From fc3d28ce5d8a129c8e1972918877c981bada12f0 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 1 May 2026 11:18:31 -0700 Subject: [PATCH 1/9] Initial v4.0.0 baseline. --- .dockerignore | 37 - .editorconfig | 13 + .filenesting.json | 14 + .github/copilot-instructions.md | 4 + .github/workflows/CI.yml | 71 - .gitignore | 441 +--- .vscode/extensions.json | 6 - .vscode/launch.json | 39 - .vscode/settings.json | 12 - .vscode/tasks.json | 117 - CHANGELOG.md | 563 +---- Common.targets | 39 - CoreEx.sln | 1015 +++++++-- Directory.Packages.props | 103 + Docker.md | 58 - docker-compose.myHr.override.yml | 50 - docker-compose.myHr.yml | 25 - docker-compose.yml | 44 + gen/CoreEx.Generator/ContractGenerator.cs | 86 + gen/CoreEx.Generator/ContractModel.cs | 657 ++++++ gen/CoreEx.Generator/CoreEx.Generator.csproj | 74 + gen/CoreEx.Generator/GenApproach.cs | 27 + .../Properties/launchSettings.json | 8 + gen/CoreEx.Generator/PropertyModel.cs | 255 +++ .../ReferenceDataGenerator.cs | 58 + gen/CoreEx.Generator/ReferenceDataModel.cs | 210 ++ .../Templates/CleanAttribute.cs.hb | 23 + gen/CoreEx.Generator/Templates/Contract.cs.hb | 103 + .../Templates/ContractAttribute.cs.hb | 18 + .../Templates/ContractIgnoreAttribute.cs.hb | 17 + .../Templates/DateTimeAttribute.cs.hb | 24 + .../Templates/ReferenceData.cs.hb | 53 + .../Templates/ReferenceDataAttribute.cs.hb | 15 + ...eferenceDataCodeCollectionTAttribute.cs.hb | 18 + .../Templates/ReferenceDataTAttribute.cs.hb | 30 + .../Templates/StringAttribute.cs.hb | 36 + .../Utility/CodeGenContext.cs | 54 + .../Utility/HandlebarsCodeGenerator.cs | 65 + .../Utility/HandlebarsHelpers.cs | 119 + gen/CoreEx.Generator/Utility/Pluralizer.cs | 12 + samples/Directory.Build.props | 8 + .../Controllers/EmployeeController.cs | 91 - .../Controllers/EmployeeResultController.cs | 90 - .../Controllers/ReferenceDataController.cs | 41 - .../Controllers/SwaggerController.cs | 19 - samples/My.Hr/My.Hr.Api/Dockerfile | 46 - samples/My.Hr/My.Hr.Api/ImplicitUsings.cs | 23 - samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj | 26 - samples/My.Hr/My.Hr.Api/Program.cs | 1 - .../My.Hr.Api/Properties/launchSettings.json | 31 - samples/My.Hr/My.Hr.Api/Startup.cs | 98 - .../My.Hr.Api/appsettings.Development.json | 14 - samples/My.Hr/My.Hr.Api/appsettings.json | 24 - .../Data/Employee2Configuration.cs | 22 - .../Data/EmployeeConfiguration.cs | 21 - samples/My.Hr/My.Hr.Business/Data/HrDb.cs | 7 - .../My.Hr/My.Hr.Business/Data/HrDbContext.cs | 26 - samples/My.Hr/My.Hr.Business/Data/HrEfDb.cs | 41 - .../Data/UsStateConfiguration.cs | 23 - .../External/AgifyServiceClient.cs | 31 - .../External/Contracts/AgifyResponse.cs | 7 - .../Contracts/EmployeeVerificationRequest.cs | 8 - .../Contracts/EmployeeVerificationResponse.cs | 18 - .../External/Contracts/GenderizeResponse.cs | 8 - .../External/Contracts/NationalizeResponse.cs | 14 - .../External/GenderizeApiClient.cs | 31 - .../External/NationalizeApiClient.cs | 31 - samples/My.Hr/My.Hr.Business/HrSettings.cs | 44 - .../My.Hr/My.Hr.Business/ImplicitUsings.cs | 21 - .../My.Hr/My.Hr.Business/Models/Employee.cs | 69 - .../My.Hr/My.Hr.Business/Models/Employee2.cs | 74 - samples/My.Hr/My.Hr.Business/Models/Gender.cs | 10 - .../My.Hr/My.Hr.Business/Models/UsState.cs | 10 - .../My.Hr.Business/My.Hr.Business.csproj | 25 - .../Services/AutoMapperProfile.cs | 11 - .../Services/EmployeeResultService.cs | 46 - .../Services/EmployeeService.cs | 92 - .../Services/EmployeeService2.cs | 46 - .../Services/IEmployeeResultService.cs | 17 - .../Services/IEmployeeService.cs | 17 - .../Services/ReferenceDataService.cs | 24 - .../Services/VerificationService.cs | 69 - .../Validators/EmployeeValidator.cs | 17 - .../EmployeeVerificationValidator.cs | 13 - .../My.Hr/My.Hr.Database/Data/RefData.yaml | 72 - samples/My.Hr/My.Hr.Database/Dockerfile | 60 - .../20190101-000001-create-Hr-schema.sql | 2 - .../20200909-162702-create-Hr-Employee.sql | 24 - ...0909-163321-create-Hr-EmergencyContact.sql | 14 - ...915-160812-create-Hr-PerformanceReview.sql | 19 - ...15-161927-create-hr-performanceoutcome.sql | 18 - ...208-001509-create-hr-eventoutbox-table.sql | 11 - ...001509-create-hr-eventoutboxdata-table.sql | 15 - .../20240930-132603-hr-spgetemployees.sql | 9 - .../20250127-175724-create-Hr-Employee2.sql | 25 - .../My.Hr.Database/My.Hr.Database.csproj | 33 - samples/My.Hr/My.Hr.Database/Program.cs | 34 - .../Properties/launchSettings.json | 8 - samples/My.Hr/My.Hr.Database/entrypoint.sh | 20 - samples/My.Hr/My.Hr.Database/wait-for-it.sh | 183 -- samples/My.Hr/My.Hr.Functions/.dockerignore | 1 - samples/My.Hr/My.Hr.Functions/.gitignore | 264 --- .../My.Hr.Functions/.vscode/extensions.json | 5 - samples/My.Hr/My.Hr.Functions/Dockerfile | 55 - .../Functions/EmployeeFunction.cs | 73 - .../HttpTriggerQueueVerificationFunction.cs | 37 - .../ServiceBusExecuteVerificationFunction.cs | 28 - .../My.Hr.Functions/My.Hr.Functions.csproj | 27 - .../MyHrApiConfigurationOptions.cs | 27 - samples/My.Hr/My.Hr.Functions/README.md | 34 - samples/My.Hr/My.Hr.Functions/Startup.cs | 81 - samples/My.Hr/My.Hr.Functions/host.json | 14 - samples/My.Hr/My.Hr.UnitTest/Data/Data.yaml | 15 - samples/My.Hr/My.Hr.UnitTest/DatabaseTest.cs | 196 -- .../My.Hr.UnitTest/EmployeeControllerTest.cs | 464 ---- .../My.Hr.UnitTest/EmployeeControllerTest2.cs | 395 ---- .../My.Hr.UnitTest/EmployeeFunctionTest.cs | 372 ---- .../EmployeeResultControllerTest.cs | 408 ---- ...ttpTriggerQueueVerificationFunctionTest.cs | 31 - .../My.Hr.UnitTest/My.Hr.UnitTest.csproj | 52 - samples/My.Hr/My.Hr.UnitTest/OneTimeSetUp.cs | 16 - .../ReferenceDataControllerTest.cs | 100 - .../Resources/VerificationResult.Unix.json | 29 - .../Resources/VerificationResult.Win32.json | 29 - ...rviceBusExecuteVerificationFunctionTest.cs | 79 - .../My.Hr.UnitTest/appsettings.unittest.json | 14 - samples/My.Hr/My.Hr.sln | 58 - samples/aspire/Contoso.Aspire/AppHost.cs | 42 + .../Contoso.Aspire/Contoso.Aspire.csproj | 22 + .../Properties/launchSettings.json | 31 + .../appsettings.Development.json | 8 + .../aspire/Contoso.Aspire/appsettings.json | 9 + .../Contoso.Products.Api.csproj | 19 + .../Contoso.Products.Api.http | 6 + .../Controllers/InventoryController.cs | 13 + .../Controllers/MovementController.cs | 32 + .../Controllers/MovementReadController.cs | 18 + .../Controllers/ProductController.cs | 38 + .../Controllers/ProductReadController.cs | 22 + .../Controllers/ReferenceDataController.cs | 52 + .../src/Contoso.Products.Api/GlobalUsing.cs | 14 + samples/src/Contoso.Products.Api/Program.cs | 94 + .../Properties/launchSettings.json | 22 + .../appsettings.Development.json | 29 + .../src/Contoso.Products.Api/appsettings.json | 20 + .../Contoso.Products.Application.csproj | 9 + .../GlobalUsing.cs | 14 + .../Interfaces/IInventoryService.cs | 6 + .../Interfaces/IMovementReadService.cs | 24 + .../Interfaces/IMovementService.cs | 24 + .../Interfaces/IProductReadService.cs | 10 + .../Interfaces/IProductService.cs | 16 + .../InventoryService.cs | 13 + .../MovementReadService.cs | 16 + .../MovementService.cs | 131 ++ .../ProductReadService.cs | 13 + .../ProductService.cs | 98 + .../ReferenceDataService.cs | 28 + .../Repositories/IInventoryRepository.cs | 6 + .../Repositories/IMovementRepository.cs | 50 + .../Repositories/IProductRepository.cs | 18 + .../Repositories/IReferenceDataRepository.cs | 16 + .../Validators/MovementRequestValidator.cs | 46 + .../Validators/ProductValidator.cs | 13 + .../src/Contoso.Products.Contracts/Brand.cs | 6 + .../Contoso.Products.Contracts/Category.cs | 6 + .../Contoso.Products.Contracts.csproj | 9 + .../Contoso.Products.Contracts/GlobalUsing.cs | 5 + .../Contoso.Products.Contracts/Movement.cs | 39 + .../MovementKind.cs | 11 + .../MovementRequest.cs | 9 + .../MovementRequestProduct.cs | 12 + .../MovementStatus.cs | 11 + .../src/Contoso.Products.Contracts/Product.cs | 11 + .../Contoso.Products.Contracts/ProductBase.cs | 34 + .../Contoso.Products.Contracts/ProductLite.cs | 6 + .../ProductReserve.cs | 12 + .../Contoso.Products.Contracts/SubCategory.cs | 10 + .../UnitOfMeasure.cs | 12 + .../Contoso.Products.Database.csproj | 21 + .../Data/ref-data.yaml | 57 + ...20260101-000001-create-products-schema.sql | 1 + ...60101-000101-create-products-category.sql} | 10 +- ...101-000102-create-products-subcategory.sql | 19 + ...1-000103-create-products-unitofmeasure.sql | 19 + ...20260101-000104-create-products-brand.sql} | 10 +- ...1-000105-create-products-movementkind.sql} | 10 +- ...000106-create-products-movementstatus.sql} | 10 +- ...0260101-000201-create-products-product.sql | 23 + ...60101-000202-create-products-inventory.sql | 11 + ...260101-000203-create-products-movement.sql | 22 + ...1-000301-create-products-outbox-tables.sql | 37 + .../src/Contoso.Products.Database/Program.cs | 40 + .../Properties/launchSettings.json | 8 + .../spOutboxBatchCancel.g.sql | 71 + .../spOutboxBatchClaim.g.sql | 116 + .../spOutboxBatchComplete.g.sql | 73 + .../Stored Procedures/spOutboxEnqueue.g.sql | 36 + .../spOutboxLeaseAcquire.g.sql | 73 + .../spOutboxLeaseRelease.g.sql | 44 + .../src/Contoso.Products.Database/dbex.yaml | 14 + .../Contoso.Products.Infrastructure.csproj | 12 + .../GlobalUsing.cs | 19 + .../Mapping/BrandMapper.cs | 16 + .../Mapping/CategoryMapper.cs | 16 + .../Mapping/MovementKindMapper.cs | 16 + .../Mapping/MovementMapper.cs | 27 + .../Mapping/MovementStatusMapper.cs | 16 + .../Mapping/ProductMapper.cs | 30 + .../Mapping/SubCategoryMapper.cs | 16 + .../Mapping/UnitOfMeasureMapper.cs | 16 + .../Persistence/Brand.g.cs | 16 + .../Persistence/Category.g.cs | 16 + .../Persistence/Inventory.g.cs | 23 + .../Persistence/Movement.g.cs | 48 + .../Persistence/MovementKind.g.cs | 16 + .../Persistence/MovementStatus.g.cs | 16 + .../Persistence/Product.g.cs | 63 + .../Persistence/SubCategory.g.cs | 22 + .../Persistence/UnitOfMeasure.g.cs | 22 + .../Repositories/DatabaseConsts.cs | 12 + .../Repositories/InventoryRepository.cs | 33 + .../Repositories/MovementRepository.cs | 175 ++ .../Repositories/ProductRepository.cs | 90 + .../Repositories/ProductsDbContext.cs | 185 ++ .../Repositories/ProductsDbContext.g.cs | 183 ++ .../Repositories/ProductsEfDb.cs | 25 + .../Repositories/ReferenceDataRepository.cs | 25 + .../Contoso.Products.Outbox.Relay.csproj | 19 + .../Contoso.Products.Outbox.Relay.http | 16 + .../Contoso.Products.Outbox.Relay/Program.cs | 64 + .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 27 + .../appsettings.json | 25 + .../Contoso.Products.Subscribe.csproj | 23 + .../Contoso.Products.Subscribe.http | 12 + .../Contoso.Products.Subscribe/GlobalUsing.cs | 8 + .../src/Contoso.Products.Subscribe/Program.cs | 117 + .../Properties/launchSettings.json | 23 + .../ReservationCancelSubscriber.cs | 20 + .../ReservationConfirmSubscriber.cs | 25 + .../appsettings.Development.json | 39 + .../appsettings.json | 21 + .../Contoso.Shopping.Api.csproj | 20 + .../Contoso.Shopping.Api.http | 6 + .../Controllers/BasketController.cs | 52 + .../Controllers/BasketReadController.cs | 13 + .../src/Contoso.Shopping.Api/GlobalUsing.cs | 13 + samples/src/Contoso.Shopping.Api/Program.cs | 108 + .../Properties/launchSettings.json | 22 + .../appsettings.Development.json | 41 + .../src/Contoso.Shopping.Api/appsettings.json | 18 + .../Adapters/Products/IProductAdapter.cs | 27 + .../Adapters/Products/IProductSyncAdapter.cs | 17 + .../Adapters/Products/Product.cs | 20 + .../BasketReadService.cs | 12 + .../BasketService.cs | 160 ++ .../Contoso.Shopping.Application.csproj | 11 + .../GlobalUsing.cs | 19 + .../Interfaces/IBasketReadService.cs | 9 + .../Interfaces/IBasketService.cs | 34 + .../Mapping/BasketMapper.cs | 35 + .../Policies/ProductPolicy.cs | 11 + .../ReferenceDataService.cs | 22 + .../Repositories/IBasketRepository.cs | 10 + .../Repositories/IReferenceDataRepository.cs | 10 + .../BasketItemAddRequestValidator.cs | 10 + .../BasketItemUpdateRequestValidator.cs | 9 + .../Validators/ProductValidator.cs | 17 + .../src/Contoso.Shopping.Contracts/Basket.cs | 24 + .../Contoso.Shopping.Contracts/BasketItem.cs | 32 + .../BasketItemAddRequest.cs | 8 + .../BasketItemUpdateRequest.cs | 8 + .../BasketPricing.cs | 23 + .../BasketStatus.cs | 14 + .../Contoso.Shopping.Contracts.csproj | 9 + .../DiscountCoupon.cs | 9 + .../Contoso.Shopping.Contracts/GlobalUsing.cs | 5 + .../UnitOfMeasure.cs | 9 + .../Contoso.Shopping.Database.csproj | 25 + .../Data/ref-data.yaml | 18 + ...20260101-000001-create-shopping-schema.sql | 1 + ...1-000101-create-shopping-unitofmeasure.sql | 19 + ...-000102-create-shopping-discountcoupon.sql | 21 + ...01-000103-create-shopping-basketstatus.sql | 18 + ...0260101-000201-create-shopping-product.sql | 15 + ...20260101-000301-create-shopping-basket.sql | 21 + ...0101-000302-create-shopping-basketitem.sql | 18 + ...1-000401-create-shopping-outbox-tables.sql | 37 + .../src/Contoso.Shopping.Database/Program.cs | 40 + .../Properties/launchSettings.json | 8 + .../spOutboxBatchCancel.g.sql | 71 + .../spOutboxBatchClaim.g.sql | 116 + .../spOutboxBatchComplete.g.sql | 73 + .../Stored Procedures/spOutboxEnqueue.g.sql | 36 + .../spOutboxLeaseAcquire.g.sql | 73 + .../spOutboxLeaseRelease.g.sql | 44 + .../src/Contoso.Shopping.Database/dbex.yaml | 11 + samples/src/Contoso.Shopping.Domain/Basket.cs | 139 ++ .../src/Contoso.Shopping.Domain/BasketItem.cs | 39 + .../Contoso.Shopping.Domain.csproj | 8 + .../Contoso.Shopping.Domain/GlobalUsing.cs | 7 + .../ValueObjects/ItemPricing.cs | 15 + .../Adapters/ProductAdapter.cs | 61 + .../Adapters/ProductSyncAdapter.cs | 25 + .../Clients/MovementRequest.cs | 8 + .../Clients/MovementRequestProduct.cs | 9 + .../Clients/ProductsHttpClient.cs | 20 + .../Contoso.Shopping.Infrastructure.csproj | 12 + .../GlobalUsing.cs | 25 + .../Mapping/BasketIntoMapper.cs | 15 + .../Mapping/BasketItemIntoMapper.cs | 15 + .../Mapping/BasketMapper.cs | 29 + .../Mapping/BasketStatusMapper.cs | 15 + .../Mapping/DiscountCouponMaper.cs | 18 + .../Mapping/ProductMapper.cs | 26 + .../Mapping/UnitOfMeasureMapper.cs | 16 + .../Persistence/Basket.cs | 6 + .../Persistence/Basket.g.cs | 48 + .../Persistence/BasketItem.g.cs | 53 + .../Persistence/BasketStatus.g.cs | 16 + .../Persistence/DiscountCoupon.g.cs | 22 + .../Persistence/Product.g.cs | 48 + .../Persistence/UnitOfMeasure.g.cs | 22 + .../Repositories/BasketRepository.cs | 71 + .../Repositories/ReferenceDataRepository.cs | 16 + .../Repositories/ShoppingDbContext.cs | 31 + .../Repositories/ShoppingDbContext.g.cs | 130 ++ .../Repositories/ShoppingEfDb.cs | 18 + .../Contoso.Shopping.Outbox.Relay.csproj | 19 + .../Contoso.Shopping.Outbox.Relay.http | 6 + .../Contoso.Shopping.Outbox.Relay/Program.cs | 64 + .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 27 + .../appsettings.json | 25 + .../Contoso.Shopping.Subscribe.csproj | 23 + .../Contoso.Shopping.Subscribe.http | 6 + .../Contoso.Shopping.Subscribe/GlobalUsing.cs | 9 + .../src/Contoso.Shopping.Subscribe/Program.cs | 127 ++ .../Properties/launchSettings.json | 23 + .../Subscribers/ProductDeleteSubscriber.cs | 11 + .../Subscribers/ProductModifySubscriber.cs | 14 + .../appsettings.Development.json | 40 + .../appsettings.json | 18 + .../Contoso.E2E.Runner.csproj | 32 + .../tests/Contoso.E2E.Runner/GlobalUsing.cs | 16 + .../Infrastructure/ChoiceManager.cs | 60 + .../Infrastructure/ChoiceResult.cs | 13 + .../Infrastructure/LoadSimulationConfig.cs | 17 + .../Infrastructure/LoadSimulationRunner.cs | 296 +++ .../LoadSimulationSimulatorConfig.cs | 22 + .../Infrastructure/RecentEventsBuffer.cs | 75 + .../Infrastructure/RunnerAttribute.cs | 38 + .../Infrastructure/ScenarioContext.cs | 66 + .../Infrastructure/ScenarioRunner.cs | 77 + .../Infrastructure/TestContext.cs | 78 + .../Infrastructure/WorkerStatistics.cs | 74 + samples/tests/Contoso.E2E.Runner/Program.cs | 129 ++ .../Scenarios/DataSeedingSetup.cs | 37 + .../Scenarios/DatabaseMigrationSetup.cs | 40 + .../Contoso.E2E.Runner/Scenarios/IScenario.cs | 12 + .../Scenarios/ProductQuantityScenario.cs | 41 + .../Scenarios/ProductQueryScenario.cs | 66 + .../Scenarios/ProductUpdateScenario.cs | 68 + .../Scenarios/ScenarioAttribute.cs | 18 + .../Scenarios/ScenarioDefinition.cs | 16 + .../Scenarios/ScenarioManager.cs | 22 + .../Scenarios/ScenarioSetUpAttribute.cs | 19 + .../Scenarios/SetUpManager.cs | 22 + .../Scenarios/ShoppingBasketScenario.cs | 93 + .../tests/Contoso.E2E.Runner/appsettings.json | 49 + .../Contoso.Products.Test.Api.csproj | 29 + .../Contoso.Products.Test.Api/GlobalUsing.cs | 11 + .../MovementMutateTests.Adjust.cs | 51 + .../MovementMutateTests.Cancel.cs | 49 + .../MovementMutateTests.Confirm.cs | 23 + .../MovementMutateTests.Reserve.cs | 91 + .../MovementMutateTests.cs | 13 + .../OtherTests.Health.cs | 39 + .../OtherTests.ReferenceData.cs | 65 + .../OtherTests.Swagger.cs | 34 + .../Contoso.Products.Test.Api/OtherTests.cs | 11 + .../ProductMutateTests.Create.cs | 118 + .../ProductMutateTests.Delete.cs | 59 + .../ProductMutateTests.Patch.cs | 107 + .../ProductMutateTests.Update.cs | 97 + .../ProductMutateTests.cs | 13 + .../ReadTests.MovementQuery.cs | 25 + .../ReadTests.ProductGet.cs | 44 + .../ReadTests.ProductQtyOnHand.cs | 30 + .../ReadTests.ProductQuery.cs | 100 + .../Contoso.Products.Test.Api/ReadTests.cs | 11 + .../Adjust_Success.event.json | 62 + .../Cancel_Success.res.json | 34 + .../Confirm_Success.res.json | 50 + .../Reserve_Success.event.json | 62 + .../Create_Success.res.json | 16 + .../Movement_Query_Filter.res.json | 30 + .../ReadTests/Product_Get_Found.res.json | 15 + ...t_Query_FilterBySku_IncludeFields.res.json | 18 + .../ReadTests/Product_Query_Schema.res.json | 70 + .../appsettings.unittest.json | 13 + .../Contoso.Products.Test.Common.csproj | 16 + .../Data/data.yaml | 45 + .../Contoso.Products.Test.Common/TestData.cs | 6 + .../Contoso.Products.Test.Outbox.Relay.csproj | 29 + .../GlobalUsing.cs | 13 + .../OtherTests.Health.cs | 41 + .../OtherTests.HostedServices.cs | 36 + .../OtherTests.cs | 10 + .../RelayTests.cs | 48 + .../Resources/ProductCreatedCloudEvent.json | 29 + .../Resources/ProductDeletedCloudEvent.json | 12 + .../appsettings.unittest.json | 36 + .../Contoso.Products.Test.Subscribe.csproj | 29 + .../GlobalUsing.cs | 14 + .../SubscriberTests.ReservationCancel.cs | 61 + .../SubscriberTests.ReservationConfirm.cs | 51 + .../SubscriberTests.cs | 36 + .../appsettings.unittest.json | 13 + .../Contoso.Products.Test.Unit.csproj | 24 + .../Contoso.Products.Test.Unit/EntryPoint.cs | 40 + .../Contoso.Products.Test.Unit/GlobalUsing.cs | 15 + ...ventoryReservationRequestValidatorTests.cs | 101 + .../Validators/ProductValidatorTests.cs | 32 + .../Contoso.Shopping.Test.Api.csproj | 30 + .../Contoso.Shopping.Test.Api/GlobalUsing.cs | 10 + .../MutateTests.Basket.cs | 179 ++ .../MutateTests.BasketItem.cs | 133 ++ .../Contoso.Shopping.Test.Api/MutateTests.cs | 25 + .../ReadTests.Basket.cs | 23 + .../Contoso.Shopping.Test.Api/ReadTests.cs | 17 + ...tream_Validation_Failure.products.res.json | 12 + ...ut_Insufficient_Quantity.products.res.json | 8 + .../Basket_Checkout_Success.products.req.json | 13 + .../Resources/Basket_Create.res.json | 15 + .../Resources/Basket_Get_Found.res.json | 41 + .../Basket_Item_Add_Existing.res.json | 29 + .../Resources/Basket_Item_Add_New.res.json | 51 + .../appsettings.unittest.json | 13 + .../Contoso.Shopping.Test.Common.csproj | 16 + .../Data/data.yaml | 56 + .../Contoso.Shopping.Test.Common/TestData.cs | 6 + servicebus/Config.json | 81 + .../CoreEx.AspNetCore.NSwag.csproj | 11 + ...reExNSwagExtensions.DependencyInjection.cs | 53 + src/CoreEx.AspNetCore.NSwag/GlobalUsing.cs | 20 + .../NSwagOpenApiOperationProcessor.cs | 213 ++ .../AspNetCoreApplicationBuilderExtensions.cs | 45 - .../AspNetCoreServiceCollectionExtensions.cs | 63 - .../Abstractions/IWebApiRequestOptions.cs | 19 + .../Abstractions/IWebApiResponseOptions.cs | 12 + .../Abstractions/IWebApiResponseOptionsT.cs | 13 + .../ReferenceDataOrchestratorExtensions.cs | 70 - .../Abstractions/WebApi.Delete.cs | 97 + .../Abstractions/WebApi.Get.cs | 45 + .../Abstractions/WebApi.MergePatch.cs | 105 + .../Abstractions/WebApi.Patch.cs | 261 +++ .../Abstractions/WebApi.Post.cs | 261 +++ .../Abstractions/WebApi.Put.cs | 223 ++ src/CoreEx.AspNetCore/Abstractions/WebApi.cs | 220 ++ .../Abstractions/WebApiBase.cs | 74 + .../Abstractions/WebApiHeader.cs | 141 ++ .../Abstractions/WebApiInvoker.cs | 7 + .../Abstractions/WebApiOptionsBase.cs | 265 +++ .../Abstractions/WebApiPagingResult.cs | 57 + .../Abstractions/WebApiResult.cs | 50 + src/CoreEx.AspNetCore/AspNetCoreExtensions.cs | 59 + .../CoreEx.AspNetCore.csproj | 18 +- ...AspNetCoreExtensions.ApplicationBuilder.cs | 148 ++ ...spNetCoreExtensions.DependencyInjection.cs | 64 + ...oreExAspNetCoreExtensions.OpenTelemetry.cs | 30 + .../ExceptionHandlingMiddleware.cs | 27 + .../ExecutionContextMiddleware.cs | 66 + src/CoreEx.AspNetCore/GlobalUsing.cs | 44 + .../HealthChecks/HealthCheckOptions.cs | 110 + .../HealthChecks/HealthReportStatusWriter.cs | 115 - .../Http/AspNetCoreHttpExtensions.cs | 66 + .../Http/HttpRequestJsonValue.cs | 15 - .../Http/HttpRequestJsonValueBase.cs | 28 - .../Http/HttpRequestJsonValueT.cs | 18 - .../Http/HttpResultExtensions.cs | 192 -- src/CoreEx.AspNetCore/Http/WebApi.cs | 89 + src/CoreEx.AspNetCore/Http/WebApiInvoker.cs | 15 + .../HybridCacheIdempotencyProvider.cs | 219 ++ .../Idempotency/IIdempotencyProvider.cs | 18 + .../Idempotency/IdempotencyKey.Static.cs | 78 + .../Idempotency/IdempotencyKey.cs | 92 + .../Idempotency/IdempotencyKeyMiddleware.cs | 23 + .../Idempotency/IdempotencyProviderInvoker.cs | 15 + .../Idempotency/IdempotencyStatus.cs | 22 + src/CoreEx.AspNetCore/Mvc/AcceptsAttribute.cs | 32 + .../Mvc/AcceptsAttributeT.cs | 11 + .../Mvc/IdempotencyKeyAttribute.cs | 14 + src/CoreEx.AspNetCore/Mvc/PagingAttribute.cs | 15 + .../Mvc/ProducesNotFoundProblemAttribute.cs | 10 + src/CoreEx.AspNetCore/Mvc/QueryAttribute.cs | 23 + src/CoreEx.AspNetCore/Mvc/WebApi.cs | 112 + src/CoreEx.AspNetCore/Mvc/WebApiInvoker.cs | 17 + src/CoreEx.AspNetCore/OpenApiOptions.cs | 111 + src/CoreEx.AspNetCore/README.md | 258 --- src/CoreEx.AspNetCore/WebApiOptions.cs | 19 + src/CoreEx.AspNetCore/WebApiRequestOptions.cs | 66 + .../WebApiRequestResponseOptions.cs | 84 + .../WebApiResponseOptions.cs | 42 + .../WebApis/AcceptsBodyAttribute.cs | 28 - .../WebApis/AcceptsBodyOperationFilter.cs | 35 - .../WebApis/ExtendedContentResult.cs | 48 - .../WebApis/ExtendedStatusCodeResult.cs | 64 - .../WebApis/IExtendedActionResult.cs | 28 - .../WebApis/IWebApiPublisherArgs.cs | 109 - .../WebApis/IWebApiPublisherCollectionArgs.cs | 95 - .../WebApis/PagingAttribute.cs | 14 - .../WebApis/PagingOperationFilter.cs | 79 - .../WebApis/PagingOperationFilterFields.cs | 80 - .../WebApis/QueryAttribute.cs | 14 - .../WebApis/QueryOperationFilter.cs | 55 - .../WebApis/QueryOperationFilterFields.cs | 30 - .../WebApis/ReferenceDataContentWebApi.cs | 24 - .../WebApis/ValueContentResult.cs | 248 --- src/CoreEx.AspNetCore/WebApis/WebApi.cs | 884 -------- src/CoreEx.AspNetCore/WebApis/WebApiBase.cs | 272 --- .../WebApiExceptionHandlerMiddleware.cs | 45 - .../WebApiExecutionContextMiddleware.cs | 59 - .../WebApis/WebApiInvoker.cs | 73 - src/CoreEx.AspNetCore/WebApis/WebApiParam.cs | 80 - src/CoreEx.AspNetCore/WebApis/WebApiParamT.cs | 23 - .../WebApis/WebApiPublisher.cs | 476 ---- .../WebApis/WebApiPublisherArgs.cs | 64 - .../WebApis/WebApiPublisherArgsT.cs | 94 - .../WebApis/WebApiPublisherArgsT2.cs | 95 - .../WebApis/WebApiPublisherCancelArgs.cs | 46 - .../WebApis/WebApiPublisherCollectionArgsT.cs | 64 - .../WebApiPublisherCollectionArgsT2.cs | 65 - .../WebApis/WebApiPublisherResultArgs.cs | 42 - .../WebApis/WebApiPublisherResultArgsT.cs | 40 - .../WebApis/WebApiPublisherStatusArgs.cs | 47 - .../WebApis/WebApiRequestOptions.cs | 173 -- .../WebApis/WebApiWithResult.cs | 784 ------- src/CoreEx.AspNetCore/strong-name-key.snk | Bin 596 -> 0 bytes .../AutoMapperConverterWrapper.cs | 62 - src/CoreEx.AutoMapper/AutoMapperExtensions.cs | 85 - src/CoreEx.AutoMapper/AutoMapperProfile.cs | 40 - .../AutoMapperServiceCollectionExtensions.cs | 21 - src/CoreEx.AutoMapper/AutoMapperWrapper.cs | 25 - .../AutoMapperObjectToJsonConverter.cs | 9 - .../AutoMapperReferenceDataCodeConverter.cs | 11 - .../AutoMapperReferenceDataIdConverter.cs | 11 - .../AutoMapperStringToBase64Converter.cs | 9 - .../AutoMapperTypeToStringConverter.cs | 9 - .../CoreEx.AutoMapper.csproj | 22 - src/CoreEx.AutoMapper/strong-name-key.snk | Bin 596 -> 0 bytes .../ProcessMessageEventArgsActions.cs | 27 + .../ProcessSessionMessageEventArgsActions.cs | 27 + .../Abstractions/ServiceBusErrorClassifier.cs | 87 + .../ServiceBusMessageActionsBase.cs | 72 + .../ServiceBusReceiverBase.Static.cs | 115 + .../Abstractions/ServiceBusReceiverBase.cs | 232 ++ .../Abstractions/ServiceBusReceiverBaseT.cs | 98 + .../ServiceBusReceiverOptionsBase.cs | 105 + .../CoreEx.Azure.Messaging.ServiceBus.csproj | 9 + ...erviceBusExtensions.DependencyInjection.cs | 425 ++++ ...oreExServiceBusExtensions.OpenTelemetry.cs | 21 + .../GlobalUsing.cs | 27 + .../IServiceBusMessageActions.cs | 37 + .../MessageProcessedEventArgs.cs | 13 + .../ServiceBusExtensions.CloudEvent.cs | 147 ++ .../ServiceBusExtensions.cs | 21 + .../ServiceBusMetrics.cs | 62 + .../ServiceBusPublisher.cs | 148 ++ .../ServiceBusReceiver.cs | 71 + .../ServiceBusReceiverHostedService.cs | 50 + .../ServiceBusReceiverInvoker.cs | 61 + .../ServiceBusReceiverOptions.cs | 54 + .../ServiceBusReceiverResiliency.cs | 127 ++ .../ServiceBusSessionReceiver.cs | 71 + .../ServiceBusSessionReceiverOptions.cs | 54 + .../ServiceBusSessionStrategy.cs | 29 + .../ServiceBusSubscribedSubscriber.cs | 47 + .../ServiceBusSubscriberBase.cs | 51 + src/CoreEx.Azure/AppConfig/Extensions.cs | 72 - src/CoreEx.Azure/CoreEx.Azure.csproj | 29 - src/CoreEx.Azure/README.md | 22 - .../Abstractions/ServiceBusMessageActions.cs | 69 - .../EventDataToServiceBusConverter.cs | 38 - .../EventSendDataToServiceBusConverter.cs | 98 - .../ServiceBusReceiverHealthCheck.cs | 37 - src/CoreEx.Azure/ServiceBus/IEventPurger.cs | 49 - .../ServiceBus/IServiceBusSender.cs | 11 - .../ServiceBus/IServiceBusSubscriber.cs | 38 - .../IServiceCollectionExtensions.cs | 100 - src/CoreEx.Azure/ServiceBus/README.md | 68 - .../ServiceBusOrchestratedSubscriber.cs | 127 -- .../ServiceBus/ServiceBusPurger.cs | 80 - ...iceBusReceivedMessageEventDataConverter.cs | 101 - .../ServiceBus/ServiceBusReceiverActions.cs | 50 - .../ServiceBus/ServiceBusSender.cs | 156 -- .../ServiceBus/ServiceBusSenderInvoker.cs | 35 - .../ServiceBus/ServiceBusSubscriber.cs | 262 --- .../ServiceBus/ServiceBusSubscriberInvoker.cs | 287 --- .../Storage/BlobAttachmentStorage.cs | 57 - .../Storage/BlobLeaseSynchronizer.cs | 161 -- .../Storage/BlobSasAttachmentStorage.cs | 84 - .../Storage/TableWorkStatePersistence.cs | 237 -- src/CoreEx.Azure/strong-name-key.snk | Bin 596 -> 0 bytes .../CoreEx.Caching.FusionCache.csproj | 11 + ...sionCacheExtensions.DependencyInjection.cs | 16 + .../FusionCacheExtensions.cs | 22 + .../FusionHybridCache.cs | 67 + .../GlobalUsings.cs | 3 + src/CoreEx.Cosmos/Batch/CosmosDbBatch.cs | 370 ---- src/CoreEx.Cosmos/CoreEx.Cosmos.csproj | 24 - src/CoreEx.Cosmos/CosmosDb.cs | 78 - src/CoreEx.Cosmos/CosmosDbArgs.cs | 184 -- src/CoreEx.Cosmos/CosmosDbContainer.cs | 965 --------- src/CoreEx.Cosmos/CosmosDbContainerT.cs | 257 --- src/CoreEx.Cosmos/CosmosDbInvoker.cs | 44 - src/CoreEx.Cosmos/CosmosDbQuery.cs | 71 - src/CoreEx.Cosmos/CosmosDbQueryBase.cs | 236 -- .../CosmosDbServiceCollectionExtensions.cs | 61 - src/CoreEx.Cosmos/CosmosDbValue.cs | 100 - src/CoreEx.Cosmos/CosmosDbValueContainerT.cs | 257 --- src/CoreEx.Cosmos/CosmosDbValueQuery.cs | 70 - .../CosmosDbValueQueryableExtensions.cs | 24 - src/CoreEx.Cosmos/CosmosExtensions.cs | 58 - .../HealthChecks/CosmosDbHealthCheck.cs | 36 - src/CoreEx.Cosmos/ICosmosDb.cs | 77 - src/CoreEx.Cosmos/ICosmosDbType.cs | 16 - src/CoreEx.Cosmos/ICosmosDbValue.cs | 31 - src/CoreEx.Cosmos/IMultiSetValueArgs.cs | 11 - src/CoreEx.Cosmos/Model/CosmosDbModelBase.cs | 42 - .../Model/CosmosDbModelContainer.cs | 1120 ---------- .../Model/CosmosDbModelContainerT.cs | 235 -- src/CoreEx.Cosmos/Model/CosmosDbModelQuery.cs | 69 - .../Model/CosmosDbModelQueryBase.cs | 235 -- .../Model/CosmosDbValueModelContainerT.cs | 394 ---- .../Model/CosmosDbValueModelQuery.cs | 69 - src/CoreEx.Cosmos/Model/IMultiSetArgs.cs | 59 - src/CoreEx.Cosmos/Model/IMultiSetModelArgs.cs | 9 - .../Model/MultiSetModelCollArgs.cs | 82 - .../Model/MultiSetModelSingleArgs.cs | 86 - src/CoreEx.Cosmos/MultiSetValueCollArgs.cs | 84 - src/CoreEx.Cosmos/MultiSetValueSingleArgs.cs | 88 - src/CoreEx.Cosmos/README.md | 218 -- src/CoreEx.Cosmos/strong-name-key.snk | Bin 596 -> 0 bytes src/CoreEx.Data/CoreEx.Data.csproj | 19 +- src/CoreEx.Data/DataResult.cs | 44 + src/CoreEx.Data/DataResultT.cs | 54 + src/CoreEx.Data/GlobalUsing.cs | 14 + src/CoreEx.Data/IDataArgs.cs | 6 + .../IUnitOfWork.WithoutCancellation.cs | 34 + src/CoreEx.Data/IUnitOfWork.cs | 56 + src/CoreEx.Data/Models/ModelBase.cs | 29 + .../Models/ReferenceDataModelBase.cs | 45 + .../IQueryFilterFieldStatementExpression.cs | 25 +- .../QueryFilterCloseParenthesisExpression.cs | 29 +- .../Expressions/QueryFilterExpressionBase.cs | 117 +- .../QueryFilterLogicalExpression.cs | 81 +- .../QueryFilterOpenParenthesisExpression.cs | 29 +- .../QueryFilterOperatorExpression.cs | 265 +-- .../QueryFilterStringFunctionExpression.cs | 188 +- .../Querying/Expressions/QueryFilterToken.cs | 207 +- .../Expressions/QueryFilterTokenKind.cs | 313 ++- .../Querying/IQueryFilterFieldConfig.cs | 219 +- src/CoreEx.Data/Querying/IQueryParseError.cs | 17 + src/CoreEx.Data/Querying/QueryArgsConfig.cs | 210 +- .../Querying/QueryArgsParseResult.cs | 72 +- src/CoreEx.Data/Querying/QueryExtensions.cs | 105 + .../Querying/QueryFilterEnumFieldConfigT.cs | 76 + .../Querying/QueryFilterExtensions.cs | 113 - .../Querying/QueryFilterFieldConfigBase.cs | 531 +++-- .../Querying/QueryFilterFieldConfigBaseT.cs | 175 +- .../Querying/QueryFilterFieldConfigT.cs | 97 - .../Querying/QueryFilterFieldResultWriter.cs | 22 +- .../Querying/QueryFilterFieldType.cs | 27 + .../Querying/QueryFilterNullFieldConfig.cs | 73 +- .../Querying/QueryFilterOperator.cs | 131 +- .../QueryFilterParseableFieldConfigT.cs | 121 ++ src/CoreEx.Data/Querying/QueryFilterParser.cs | 858 ++++---- .../Querying/QueryFilterParserException.cs | 25 +- .../Querying/QueryFilterParserResult.cs | 161 +- .../Querying/QueryFilterParserWriter.cs | 103 + .../QueryFilterReferenceDataFieldConfig.cs | 85 - .../QueryFilterReferenceDataFieldConfigT.cs | 48 + .../Querying/QueryFilterSchemaType.cs | 34 + .../Querying/QueryOrderByDirection.cs | 37 +- .../Querying/QueryOrderByFieldConfig.cs | 175 +- .../Querying/QueryOrderByParser.cs | 331 +-- .../Querying/QueryOrderByParserException.cs | 18 +- .../Querying/QueryOrderByParserResult.cs | 55 +- src/CoreEx.Data/Querying/QueryStatement.cs | 43 +- src/CoreEx.Data/README.md | 187 -- src/CoreEx.Data/strong-name-key.snk | Bin 596 -> 0 bytes .../CoreEx.Database.MySql.csproj | 34 - src/CoreEx.Database.MySql/MySqlDatabase.cs | 78 - src/CoreEx.Database.MySql/strong-name-key.snk | Bin 596 -> 0 bytes .../CoreEx.Database.Postgres.csproj | 22 - .../PostgresDatabase.cs | 134 -- .../PostgresDatabaseColumns.cs | 40 - .../strong-name-key.snk | Bin 596 -> 0 bytes .../CoreEx.Database.SqlServer.csproj | 20 +- ...xSqlServerExtensions.ApplicationBuilder.cs | 34 + ...SqlServerExtensions.DependencyInjection.cs | 124 ++ ...CoreExSqlServerExtensions.OpenTelemetry.cs | 21 + .../DatabaseServiceCollectionExtensions.cs | 58 - .../Extended/SqlServerDatabaseColumns.cs | 27 + .../Extended/SqlServerInvoker.cs | 15 + .../Extended/SqlServerUnitOfWorkInvoker.cs | 139 ++ src/CoreEx.Database.SqlServer/GlobalUsing.cs | 25 + .../Outbox/EventOutboxDequeueBase.cs | 161 -- .../Outbox/EventOutboxEnqueueBase.cs | 190 -- .../Outbox/EventOutboxHostedService.cs | 166 -- .../Outbox/EventOutboxService.cs | 87 - .../Outbox/SqlServerOutboxPublisher.cs | 30 + .../Outbox/SqlServerOutboxRelay.cs | 58 + .../SqlServerOutboxRelayHostedService.cs | 10 + .../SqlServerCommand.cs | 9 + .../SqlServerDatabase.SessionContext.cs | 47 + .../SqlServerDatabase.cs | 192 +- .../SqlServerDatabaseArgs.cs | 6 + .../SqlServerDatabaseColumns.cs | 65 - .../SqlServerExtensions.Parameters.cs | 95 + .../SqlServerExtensions.cs | 404 +--- .../SqlServerMetrics.cs | 32 + .../SqlServerUnitOfWork.cs | 48 + .../TableValuedParameter.cs | 66 - .../strong-name-key.snk | Bin 596 -> 0 bytes .../Abstractions/DatabaseArgs.cs | 16 + .../Abstractions/DatabaseArgsBase.cs | 22 + .../Abstractions/DatabaseInvoker.cs | 35 + .../Abstractions/IDatabaseParameters.cs | 18 + src/CoreEx.Database/CoreEx.Database.csproj | 20 +- src/CoreEx.Database/Database.cs | 308 ++- .../DatabaseCommand.NonQuery.cs | 24 + src/CoreEx.Database/DatabaseCommand.Scalar.cs | 27 + src/CoreEx.Database/DatabaseCommand.Select.cs | 33 + .../DatabaseCommand.SelectFirstSingle.cs | 97 + .../DatabaseCommand.SelectMultiSet.cs | 131 ++ .../DatabaseCommand.SelectQuery.cs | 50 + src/CoreEx.Database/DatabaseCommand.cs | 672 +----- src/CoreEx.Database/DatabaseCommandT.cs | 32 + .../DatabaseExtensions.Parameters.cs | 386 ++++ src/CoreEx.Database/DatabaseExtensions.cs | 6 + src/CoreEx.Database/DatabaseInvoker.cs | 44 - .../DatabaseParameterCollection.cs | 375 ++-- src/CoreEx.Database/DatabaseQueryMapper.cs | 22 - src/CoreEx.Database/DatabaseRecord.cs | 243 ++- src/CoreEx.Database/DatabaseRecordMapper.cs | 23 - .../DatabaseServiceCollectionExtensions.cs | 78 - src/CoreEx.Database/DatabaseWildcard.cs | 123 -- src/CoreEx.Database/Extended/DatabaseArgs.cs | 56 - .../Extended/DatabaseColumns.cs | 260 ++- .../Extended/DatabaseExtendedExtensions.cs | 413 ---- src/CoreEx.Database/Extended/DatabaseQuery.cs | 219 -- .../Extended/DatabaseWildcard.cs | 112 + src/CoreEx.Database/Extended/IMultiSetArgs.cs | 33 + .../Extended/IMultiSetArgsT.cs | 13 + src/CoreEx.Database/Extended/MultiSetArgs.cs | 14 + .../Extended/MultiSetCollArgs.cs | 38 + .../Extended/MultiSetCollArgsT.cs | 42 + .../Extended/MultiSetSingleArgs.cs | 29 + .../Extended/MultiSetSingleArgsT.cs | 30 + src/CoreEx.Database/Extended/RefDataLoader.cs | 96 - src/CoreEx.Database/Extended/RefDataMapper.cs | 69 - .../Extended/RefDataMultiSetCollArgs.cs | 37 - src/CoreEx.Database/GlobalUsing.cs | 30 + .../HealthChecks/DatabaseHealthCheck.cs | 28 - src/CoreEx.Database/IDatabase.cs | 238 +- src/CoreEx.Database/IDatabaseMapper.cs | 43 - src/CoreEx.Database/IDatabaseMapperT.cs | 43 - src/CoreEx.Database/IDatabaseParameters.cs | 23 - .../IDatabaseParametersExtensions.cs | 518 ----- src/CoreEx.Database/IMultiSetArgs.cs | 36 - src/CoreEx.Database/IMultiSetArgsT.cs | 18 - .../Mapping/ChangeLogDatabaseMapper.cs | 59 - .../Mapping/ChangeLogExDatabaseMapper.cs | 59 - src/CoreEx.Database/Mapping/DatabaseMapper.cs | 206 +- .../Mapping/DatabaseMapperExT.cs | 175 -- .../Mapping/DatabaseMapperExT2.cs | 21 - .../Mapping/DatabaseMapperT.cs | 302 +-- .../Mapping/DatabaseMapperT2.cs | 22 - .../Mapping/IDatabaseMapper.cs | 36 + .../Mapping/IDatabaseMapperEx.cs | 20 - .../Mapping/IDatabaseMapperExT.cs | 26 - .../Mapping/IDatabaseMapperMappings.cs | 51 - .../Mapping/IDatabaseMapperT.cs | 36 + .../Mapping/IPropertyColumnMapper.cs | 119 - .../Mapping/PropertyColumnMapper.cs | 229 -- src/CoreEx.Database/MultiSetArgs.cs | 20 - src/CoreEx.Database/MultiSetCollArgs.cs | 54 - src/CoreEx.Database/MultiSetCollArgsT.cs | 53 - src/CoreEx.Database/MultiSetSingleArgs.cs | 43 - src/CoreEx.Database/MultiSetSingleArgsT.cs | 41 - .../Outbox/DatabaseOutboxPublisherBase.cs | 112 + .../Outbox/DatabaseOutboxRelayArgs.cs | 27 + .../Outbox/DatabaseOutboxRelayBase.cs | 311 +++ .../DatabaseOutboxRelayHostedServiceBase.cs | 83 + .../DatabaseOutboxRelayHostedServiceBaseT.cs | 39 + .../Outbox/DatabaseOutboxRelayInvoker.cs | 9 + .../Outbox/IDatabaseOutboxRelay.cs | 15 + src/CoreEx.Database/README.md | 186 -- src/CoreEx.Database/SqlStatement.cs | 74 + .../Templates/EfModelBuilder_cs.hbs | 77 + src/CoreEx.Database/Templates/EfModel_cs.hbs | 82 + src/CoreEx.Database/strong-name-key.snk | Bin 596 -> 0 bytes src/CoreEx.Dataverse/CoreEx.Dataverse.csproj | 37 - src/CoreEx.Dataverse/IDataverseMapper.cs | 35 - src/CoreEx.Dataverse/IDataverseMapperT.cs | 40 - .../Mapping/DataverseMapper.cs | 23 - .../Mapping/DataverseMapperT.cs | 260 --- .../Mapping/IDataverseMapperMappings.cs | 43 - .../Mapping/IPropertyColumnMapper.cs | 102 - .../Mapping/PropertyColumnMapper.cs | 229 -- src/CoreEx.Dataverse/strong-name-key.snk | Bin 596 -> 0 bytes src/CoreEx.DomainDriven/Aggregate.cs | 43 + .../CoreEx.DomainDriven.csproj | 11 + .../DomainDrivenExtensions.cs | 40 + src/CoreEx.DomainDriven/Entity.cs | 110 + src/CoreEx.DomainDriven/EntityBase.cs | 289 +++ src/CoreEx.DomainDriven/GlobalUsing.cs | 5 + src/CoreEx.DomainDriven/IAggregateRoot.cs | 22 + src/CoreEx.DomainDriven/IEntity.cs | 19 + src/CoreEx.DomainDriven/PersistenceState.cs | 32 + .../Converters/JsonElementStringConverter.cs | 12 + .../Converters/StringBase64Converter.cs | 14 + .../Converters/ValueConverterBridge.cs | 26 + .../Converters/ValueConverterBridgeT2.cs | 11 + .../CoreEx.EntityFrameworkCore.csproj | 31 +- ...oreExEfDbExtensions.DependencyInjection.cs | 17 + src/CoreEx.EntityFrameworkCore/EfDb.cs | 294 +-- src/CoreEx.EntityFrameworkCore/EfDbArgs.cs | 148 +- src/CoreEx.EntityFrameworkCore/EfDbEntity.cs | 246 --- .../EfDbExtensions.cs | 545 ++--- src/CoreEx.EntityFrameworkCore/EfDbInvoker.cs | 97 +- .../EfDbMappedModel.Create.cs | 46 + .../EfDbMappedModel.Delete.cs | 42 + .../EfDbMappedModel.Get.cs | 46 + .../EfDbMappedModel.Update.cs | 46 + .../EfDbMappedModel.cs | 33 + .../EfDbModel.Create.cs | 77 + .../EfDbModel.Delete.cs | 94 + .../EfDbModel.Get.cs | 52 + .../EfDbModel.Query.cs | 25 + .../EfDbModel.Update.cs | 105 + src/CoreEx.EntityFrameworkCore/EfDbModel.cs | 179 +- .../EfDbModelOptions.cs | 249 +++ .../EfDbModelQuery.cs | 308 --- src/CoreEx.EntityFrameworkCore/EfDbOptions.cs | 66 + src/CoreEx.EntityFrameworkCore/EfDbQuery.cs | 250 --- .../EfDbQueryableExtensions.cs | 39 - .../EfDbServiceCollectionExtensions.cs | 29 - src/CoreEx.EntityFrameworkCore/GlobalUsing.cs | 20 + src/CoreEx.EntityFrameworkCore/IEfDb.cs | 219 +- .../IEfDbContext.cs | 19 +- src/CoreEx.EntityFrameworkCore/IEfDbEntity.cs | 15 - src/CoreEx.EntityFrameworkCore/README.md | 138 -- .../strong-name-key.snk | Bin 596 -> 0 bytes src/CoreEx.Events/CoreEx.Events.csproj | 9 + ...eExEventsExtensions.DependencyInjection.cs | 86 + .../CoreExEventsExtensions.OpenTelemetry.cs | 21 + src/CoreEx.Events/EventAction.cs | 88 + src/CoreEx.Events/EventData.Infra.cs | 176 ++ src/CoreEx.Events/EventData.With.cs | 185 ++ src/CoreEx.Events/EventData.cs | 176 ++ src/CoreEx.Events/EventFormatter.cs | 388 ++++ .../EventsExtensions.CloudEvent.cs | 175 ++ src/CoreEx.Events/EventsExtensions.cs | 8 + src/CoreEx.Events/GlobalUsing.cs | 35 + src/CoreEx.Events/IEventFormatter.cs | 45 + src/CoreEx.Events/MessageType.cs | 22 + .../Publishing/DestinationEvent.cs | 28 + .../Publishing/EventPublisherBase.cs | 198 ++ .../Publishing/EventPublisherInvoker.cs | 15 + .../Publishing/FixedDestinationProvider.cs | 33 + .../Publishing/IDestinationProvider.cs | 33 + .../Publishing/IEventPublisher.cs | 43 + src/CoreEx.Events/Publishing/IEventQueue.cs | 52 + .../Publishing/NoOpEventPublisher.cs | 13 + src/CoreEx.Events/Subscribing/ErrorHandler.cs | 181 ++ .../Subscribing/ErrorHandlerArgs.cs | 37 + .../Subscribing/ErrorHandling.cs | 52 + .../Subscribing/EventSubscriberArgs.cs | 59 + .../Subscribing/EventSubscriberBase.cs | 192 ++ .../Subscribing/EventSubscriberMetrics.cs | 70 + .../EventSubscriberCatastrophicException.cs | 14 + .../EventSubscriberDeadLetterException.cs | 14 + .../EventSubscriberHandledException.cs | 17 + .../EventSubscriberReceiveException.cs | 16 + .../EventSubscriberRetryException.cs | 13 + .../EventSubscriberUnhandledException.cs | 14 + .../Exceptions/IEventSubscriberException.cs | 12 + .../Subscribing/IEventSubscriberInbox.cs | 16 + .../Subscribing/SubscribeAttribute.cs | 65 + .../Subscribing/SubscribedBase.Static.cs | 80 + .../Subscribing/SubscribedBase.cs | 73 + .../Subscribing/SubscribedBaseT.cs | 39 + .../Subscribing/SubscribedInvoker.cs | 8 + .../Subscribing/SubscribedManager.cs | 220 ++ .../CoreEx.FluentValidation.csproj | 22 - .../FluentValidationExtensions.cs | 78 - .../FluentValidator.cs | 24 - .../IFluentServiceCollectionExtensions.cs | 81 - .../ReferenceDataCodeValidator.cs | 27 - .../ReferenceDataValidator.cs | 26 - .../ValidationExtensions.cs | 54 - .../ValidationResultWrapper.cs | 77 - .../ValidatorWrapper.cs | 27 - .../strong-name-key.snk | Bin 596 -> 0 bytes .../CoreEx.Newtonsoft.csproj | 23 - .../Json/CloudEventSerializer.cs | 70 - .../Json/CollectionResultJsonConverter.cs | 52 - .../Json/CompositeKeyJsonConverter.cs | 147 -- .../Json/ContractResolver.cs | 183 -- .../Json/EventDataSerializer.cs | 25 - src/CoreEx.Newtonsoft/Json/JsonFilterer.cs | 139 -- .../Json/JsonPreFilterInspector.cs | 26 - src/CoreEx.Newtonsoft/Json/JsonSerializer.cs | 206 -- .../NewtonsoftServiceCollectionExtensions.cs | 49 - .../ReferenceDataContentJsonSerializer.cs | 38 - .../Json/ReferenceDataJsonConverter.cs | 54 - .../Json/SubstituteNamingStrategy.cs | 34 - src/CoreEx.Newtonsoft/strong-name-key.snk | Bin 596 -> 0 bytes src/CoreEx.OData/CoreEx.OData.csproj | 22 - src/CoreEx.OData/IBoundClientExtensions.cs | 114 - src/CoreEx.OData/IOData.cs | 117 - src/CoreEx.OData/Mapping/IODataMapper.cs | 65 - src/CoreEx.OData/Mapping/IODataMapperT.cs | 47 - .../Mapping/IPropertyColumnMapper.cs | 95 - src/CoreEx.OData/Mapping/ODataMapperT.cs | 345 --- src/CoreEx.OData/Mapping/ODataMapping.cs | 23 - .../Mapping/PropertyColumnMapper.cs | 189 -- src/CoreEx.OData/ODataArgs.cs | 58 - src/CoreEx.OData/ODataClient.cs | 141 -- src/CoreEx.OData/ODataExtensions.cs | 387 ---- src/CoreEx.OData/ODataInvoker.cs | 63 - src/CoreEx.OData/ODataItem.cs | 159 -- src/CoreEx.OData/ODataItemCollection.cs | 248 --- src/CoreEx.OData/ODataQuery.cs | 277 --- src/CoreEx.OData/README.md | 324 --- src/CoreEx.OData/strong-name-key.snk | Bin 596 -> 0 bytes .../ReferenceDataCollectionCore.cs | 325 +++ .../Abstractions/ReferenceDataCore.cs | 145 ++ src/CoreEx.RefData/CoreEx.RefData.csproj | 9 + ...renceDataExtensions.DependencyInjection.cs | 51 + src/CoreEx.RefData/GlobalUsing.cs | 19 + .../ReferenceDataOrchestratorHealthCheck.cs | 22 + .../ReferenceDataCodeCollection.cs | 80 + .../ReferenceDataCollectionT.cs | 22 + .../ReferenceDataCollectionT2.cs | 23 + src/CoreEx.RefData/ReferenceDataContext.cs | 40 + .../ReferenceDataHybridCache.TypedInvoker.cs | 45 + .../ReferenceDataHybridCache.cs | 100 + src/CoreEx.RefData/ReferenceDataSortOrder.cs | 27 + src/CoreEx.RefData/ReferenceDataT.cs | 74 + src/CoreEx.RefData/ReferenceDataT2.cs | 61 + src/CoreEx.Solace/CoreEx.Solace.csproj | 22 - .../EventDataToPubSubMessageConverter.cs | 38 - .../PubSub/EventSendDataToPubSubConverter.cs | 97 - src/CoreEx.Solace/PubSub/IPubSubSender.cs | 11 - src/CoreEx.Solace/PubSub/PubSubSender.cs | 221 -- .../PubSub/PubSubSenderInvoker.cs | 47 - src/CoreEx.Solace/README.md | 11 - src/CoreEx.Solace/strong-name-key.snk | Bin 596 -> 0 bytes .../AzureFunctionsCoreExOneOffTestSetUp.cs | 16 - .../CoreEx.UnitTesting.Azure.Functions.csproj | 24 - .../UnitTestExExtensions.cs | 127 -- .../strong-name-key.snk | Bin 596 -> 0 bytes .../AzureServiceBusCoreExOneOffTestSetUp.cs | 16 - ...CoreEx.UnitTesting.Azure.ServiceBus.csproj | 23 - .../UnitTestExExtensions.cs | 49 - .../strong-name-key.snk | Bin 596 -> 0 bytes .../Abstractions/CoreExExtension.cs | 81 - .../Abstractions/CoreExOneOffTestSetUp.cs | 51 - .../AspNetCore/AgentTester.cs | 112 - .../AspNetCore/AgentTesterT.cs | 87 - .../Assertors/HttpResultAssertor.cs | 21 - .../Assertors/HttpResultAssertorT.cs | 36 - .../CoreEx.UnitTesting.csproj | 26 +- src/CoreEx.UnitTesting/Data/JsonDataReader.cs | 415 ++++ .../Data/JsonDataReaderArgs.cs | 44 + .../Data/JsonDataReaderOptions.cs | 122 ++ .../Events/EventExpecationsConfig.cs | 430 ++++ .../Events/EventExpectationAssertor.cs | 110 + .../Events/EventExpectations.cs | 88 + .../Events/EventPublisherDecorator.cs | 80 + .../Expectations/EventExpectations.cs | 244 --- .../Expectations/ExpectedEventPublisher.cs | 102 - .../Expectations/UnitTestExExtensions.cs | 721 ------- src/CoreEx.UnitTesting/GlobalUsing.cs | 37 + .../Json/ToCoreExJsonSerializerMapper.cs | 59 - .../Json/ToUnitTestExJsonSerializerMapper.cs | 68 - src/CoreEx.UnitTesting/README.md | 31 - .../UnitTestExExpectations.ChangeLog.cs | 122 ++ .../UnitTestExExpectations.ETag.cs | 44 + .../UnitTestExExpectations.Events.cs | 100 + .../UnitTestExExpectations.Identifier.cs | 49 + .../UnitTestExExpectations.ServiceBus.cs | 30 + .../UnitTestExExpectations.SqlServer.cs | 30 + .../UnitTestExExpectations.cs | 40 + .../UnitTestExExtensions.Assert.cs | 23 + .../UnitTestExExtensions.Caching.cs | 13 + .../UnitTestExExtensions.CloudEvent.cs | 52 + .../UnitTestExExtensions.DbEx.cs | 51 + .../UnitTestExExtensions.Events.cs | 28 + .../UnitTestExExtensions.ServiceBus.cs | 160 ++ .../UnitTestExExtensions.SqlServer.cs | 32 + .../UnitTestExExtensions.Validation.cs | 68 + .../UnitTestExExtensions.cs | 502 +---- .../UnitTestExOneOffTestSetUp.cs | 31 + src/CoreEx.UnitTesting/UsingApiTester.cs | 39 - src/CoreEx.UnitTesting/strong-name-key.snk | Bin 596 -> 0 bytes src/CoreEx.Validation/AbstractValidator.cs | 19 +- src/CoreEx.Validation/AbstractValidatorT2.cs | 9 + .../Abstractions/IPropertyContext.cs | 94 + .../Abstractions/IPropertyContextT.cs | 26 + .../Abstractions/IPropertyContextT2.cs | 24 + .../Abstractions/ISelfRuntimeMetadata.cs | 6 + .../Abstractions/IValidationContext.cs | 51 + .../Abstractions/IValidationContextT.cs | 7 + .../Abstractions/IValidatorEx.cs | 17 + .../Abstractions/IValidatorExT.cs | 38 + .../Abstractions/IValueValidator.cs | 23 + .../Abstractions/InlineValidator.cs | 82 + .../Abstractions/SelfRuntimeMetadata.cs | 59 + .../Abstractions/ValidationMessageItem.cs | 15 + .../Abstractions/ValidationValue.cs | 19 + .../Abstractions/ValidatorBase.Fluent.cs | 30 + .../Abstractions/ValidatorBase.cs | 177 ++ .../Abstractions/ValueFormatter.cs | 62 + .../Abstractions/ValueValidator.cs | 35 + .../Clauses/DependsOnClause.cs | 49 +- .../Clauses/IPropertyClauseT.cs | 16 + .../Clauses/IPropertyClauseT2.cs | 20 + .../Clauses/IPropertyRuleClause.cs | 25 - src/CoreEx.Validation/Clauses/WhenClause.cs | 59 +- src/CoreEx.Validation/CommonValidator.cs | 20 - src/CoreEx.Validation/CommonValidatorT.cs | 162 +- src/CoreEx.Validation/CompareOperator.cs | 44 +- .../CoreEx.Validation.csproj | 13 - src/CoreEx.Validation/GlobalUsing.cs | 23 + src/CoreEx.Validation/IEntityRule.cs | 22 - src/CoreEx.Validation/IPropertyContext.cs | 82 - src/CoreEx.Validation/IPropertyContextT.cs | 13 - src/CoreEx.Validation/IPropertyRule.cs | 32 - src/CoreEx.Validation/IPropertyRuleT2.cs | 52 - src/CoreEx.Validation/IValidationContext.cs | 47 - src/CoreEx.Validation/IValidatorEx.cs | 22 - src/CoreEx.Validation/IValidatorExT.cs | 32 - src/CoreEx.Validation/IncludeBaseRule.cs | 52 - src/CoreEx.Validation/PredicateAsync.cs | 11 + src/CoreEx.Validation/PropertyContext.cs | 474 ++-- src/CoreEx.Validation/PropertyRule.cs | 81 - src/CoreEx.Validation/PropertyRuleBase.cs | 119 - src/CoreEx.Validation/README.md | 445 ---- .../ReferenceDataValidation.cs | 32 - .../ReferenceDataValidator.cs | 49 - .../ReferenceDataValidatorBase.cs | 35 - src/CoreEx.Validation/Resources.resx | 258 --- src/CoreEx.Validation/RuleSet.cs | 40 - src/CoreEx.Validation/Rules/BetweenRule.cs | 142 +- src/CoreEx.Validation/Rules/CollectionRule.cs | 348 ++- .../Rules/CollectionRuleItem.cs | 36 - .../Rules/CollectionRuleItemT.cs | 138 -- src/CoreEx.Validation/Rules/CommonRule.cs | 33 +- .../Rules/ComparePropertyRule.cs | 89 +- .../Rules/CompareRuleBase.cs | 107 +- .../Rules/CompareValueRule.cs | 103 +- .../Rules/CompareValuesRule.cs | 87 +- src/CoreEx.Validation/Rules/CustomRule.cs | 48 - src/CoreEx.Validation/Rules/DecimalRule.cs | 113 +- .../Rules/DecimalRuleHelper.cs | 99 - src/CoreEx.Validation/Rules/DictionaryRule.cs | 264 ++- .../Rules/DictionaryRuleItem.cs | 22 - .../Rules/DictionaryRuleItemT.cs | 51 - src/CoreEx.Validation/Rules/DuplicateRule.cs | 48 - src/CoreEx.Validation/Rules/EmailRule.cs | 54 +- src/CoreEx.Validation/Rules/EntityRule.cs | 99 +- src/CoreEx.Validation/Rules/EntityRuleWith.cs | 29 - src/CoreEx.Validation/Rules/EnumRule.cs | 53 +- src/CoreEx.Validation/Rules/EnumStringRule.cs | 99 + src/CoreEx.Validation/Rules/EnumValueRule.cs | 43 - .../Rules/EnumValueRuleAs.cs | 31 - src/CoreEx.Validation/Rules/ErrorRule.cs | 19 + src/CoreEx.Validation/Rules/ExistsRule.cs | 102 - .../Rules/ICollectionRuleItem.cs | 30 - .../Rules/IDictionaryRuleItem.cs | 38 - .../Rules/IPropertyRuleEx.cs | 16 + src/CoreEx.Validation/Rules/IPropertyRuleT.cs | 34 + .../Rules/IPropertyRuleT2.cs | 16 + .../Rules/IRootPropertyRuleT.cs | 50 + .../Rules/IRootPropertyRuleT2.cs | 8 + src/CoreEx.Validation/Rules/IValueRule.cs | 42 - src/CoreEx.Validation/Rules/ImmutableRule.cs | 63 - .../Rules/IncludeBaseRule.cs | 60 + src/CoreEx.Validation/Rules/InteropRule.cs | 91 +- src/CoreEx.Validation/Rules/MandatoryRule.cs | 105 +- src/CoreEx.Validation/Rules/MustRule.cs | 63 - src/CoreEx.Validation/Rules/NoneRule.cs | 46 - src/CoreEx.Validation/Rules/NotNullRule.cs | 25 - .../Rules/NullNoneEmptyRule.cs | 70 + src/CoreEx.Validation/Rules/NullRule.cs | 25 - .../Rules/NullableEnumRule.cs | 31 - src/CoreEx.Validation/Rules/NumericRule.cs | 54 +- src/CoreEx.Validation/Rules/OverrideRule.cs | 61 - .../Rules/PropertyRuleBase.cs | 88 + .../Rules/ReferenceDataCodeCollectionRule.cs | 19 + .../Rules/ReferenceDataCodeRule.cs | 96 +- .../Rules/ReferenceDataCodeRuleAs.cs | 31 - .../Rules/ReferenceDataRule.cs | 35 +- .../Rules/ReferenceDataSidListRule.cs | 70 - .../Rules/RootPropertyRule.cs | 142 ++ src/CoreEx.Validation/Rules/RuleSet.cs | 55 + src/CoreEx.Validation/Rules/StringRule.cs | 92 +- src/CoreEx.Validation/Rules/ValueRuleBase.cs | 82 - src/CoreEx.Validation/Rules/WildcardRule.cs | 43 +- .../ValidatingInlineValidator.cs | 112 + src/CoreEx.Validation/ValidationArgs.cs | 105 +- .../ValidationContext.Utility.cs | 163 ++ src/CoreEx.Validation/ValidationContext.cs | 639 ++---- .../ValidationExtensions.BetweenRule.cs | 178 ++ .../ValidationExtensions.CollectionRule.cs | 136 ++ .../ValidationExtensions.CommonRule.cs | 24 + ...alidationExtensions.ComparePropertyRule.cs | 32 + .../ValidationExtensions.CompareValueRule.cs | 346 +++ .../ValidationExtensions.CompareValuesRule.cs | 54 + .../ValidationExtensions.DecimalRule.cs | 94 + .../ValidationExtensions.DependsOnClause.cs | 17 + .../ValidationExtensions.DictionaryRule.cs | 65 + .../ValidationExtensions.EmailRule.cs | 24 + .../ValidationExtensions.EntityRule.cs | 25 + .../ValidationExtensions.EnumRule.cs | 77 + .../ValidationExtensions.ErrorRule.cs | 51 + .../ValidationExtensions.InteropRule.cs | 26 + .../ValidationExtensions.MandatoryRule.cs | 44 + .../ValidationExtensions.NullNoneEmptyRule.cs | 52 + .../ValidationExtensions.NumericRule.cs | 64 + .../ValidationExtensions.ReferenceDataRule.cs | 57 + .../ValidationExtensions.StringRule.cs | 89 + .../ValidationExtensions.WhenClause.cs | 80 + .../ValidationExtensions.WildcardRule.cs | 25 + src/CoreEx.Validation/ValidationExtensions.cs | 1920 ++--------------- .../ValidationServiceCollectionExtensions.cs | 127 -- .../ValidationTextProvider.cs | 21 - src/CoreEx.Validation/ValidationValue.cs | 34 - src/CoreEx.Validation/Validator.cs | 144 +- src/CoreEx.Validation/ValidatorBase.cs | 52 - src/CoreEx.Validation/ValidatorStrings.cs | 433 ++-- src/CoreEx.Validation/ValidatorT.cs | 279 +-- src/CoreEx.Validation/ValidatorT2.cs | 14 + .../ValueValidationConfiguration.cs | 35 - src/CoreEx.Validation/ValueValidator.cs | 67 - src/CoreEx.Validation/ValueValidatorResult.cs | 61 - src/CoreEx.Validation/strong-name-key.snk | Bin 596 -> 0 bytes src/CoreEx/Abstractions/ETagGenerator.cs | 111 - src/CoreEx/Abstractions/ErrorType.cs | 65 - src/CoreEx/Abstractions/ExtendedException.cs | 109 + src/CoreEx/Abstractions/ExtendedExceptionT.cs | 21 + .../Abstractions/IEnumerableExtensions.cs | 169 -- src/CoreEx/Abstractions/IExtendedException.cs | 105 +- .../Abstractions/IQueryableExtensions.cs | 220 -- .../IServiceCollectionExtensions.cs | 438 ---- src/CoreEx/Abstractions/IUniqueKey.cs | 13 - src/CoreEx/Abstractions/Internal.cs | 216 +- src/CoreEx/Abstractions/ObjectExtensions.cs | 151 -- src/CoreEx/Abstractions/README.md | 29 - .../Reflection/IPropertyExpression.cs | 64 - .../Reflection/IPropertyReflector.cs | 84 - .../Reflection/IReflectionCache.cs | 11 - .../Abstractions/Reflection/ITypeReflector.cs | 93 - .../Reflection/PropertyExpression.cs | 73 - .../Reflection/PropertyExpressionT.cs | 158 -- .../Reflection/PropertyReflector.cs | 108 - .../Abstractions/Reflection/TypeReflector.cs | 199 -- .../Reflection/TypeReflectorArgs.cs | 73 - .../Abstractions/Reflection/TypeReflectorT.cs | 252 --- .../Reflection/TypeReflectorTypeCode.cs | 42 - src/CoreEx/Abstractions/Resource.cs | 92 +- src/CoreEx/AuthenticationException.cs | 89 +- src/CoreEx/AuthorizationException.cs | 89 +- src/CoreEx/BusinessException.cs | 93 +- src/CoreEx/Caching/CacheStrategy.cs | 23 + src/CoreEx/Caching/DefaultCacheKeyProvider.cs | 26 + src/CoreEx/Caching/HybridCacheEntryOptions.cs | 121 ++ src/CoreEx/Caching/ICacheKey.cs | 20 - src/CoreEx/Caching/ICacheKeyProvider.cs | 24 + src/CoreEx/Caching/IHybridCache.cs | 79 + src/CoreEx/Caching/IRequestCache.cs | 52 - src/CoreEx/Caching/MemoryOnlyHybridCache.cs | 65 + src/CoreEx/Caching/README.md | 15 - src/CoreEx/Caching/RequestCache.cs | 92 - src/CoreEx/Caching/RequestCacheExtensions.cs | 104 - src/CoreEx/Caching/ResultExtensions.cs | 231 -- src/CoreEx/ConcurrencyException.cs | 89 +- src/CoreEx/Configuration/DefaultSettings.cs | 13 - src/CoreEx/Configuration/DeploymentInfo.cs | 42 - src/CoreEx/Configuration/README.md | 41 - src/CoreEx/Configuration/SettingsBase.cs | 222 -- src/CoreEx/ConflictException.cs | 89 +- src/CoreEx/CoreEx.csproj | 78 +- .../CoreExExtensions.ApplicationBuilder.cs | 137 ++ .../CoreExExtensions.DependencyInjection.cs | 253 +++ src/CoreEx/CoreExExtensions.OpenTelemetry.cs | 38 + src/CoreEx/Data/DataExtensions.ITotalCount.cs | 90 + src/CoreEx/Data/DataExtensions.Where.cs | 93 + src/CoreEx/Data/DataExtensions.With.cs | 21 + src/CoreEx/Data/DataExtensions.cs | 6 + src/CoreEx/Data/IItemsResult.cs | 34 + src/CoreEx/Data/IItemsResultT.cs | 27 + src/CoreEx/Data/ILogicallyDeleted.cs | 15 + src/CoreEx/Data/IPartitionKey.cs | 15 + src/CoreEx/Data/IPrimaryKey.cs | 17 + src/CoreEx/Data/IReadOnlyLogicallyDeleted.cs | 12 + src/CoreEx/Data/IReadOnlyPartitionKey.cs | 12 + src/CoreEx/Data/IReadOnlyTenantId.cs | 12 + src/CoreEx/Data/IReadOnlyTypeDiscriminator.cs | 13 + src/CoreEx/Data/ITenantId.cs | 15 + src/CoreEx/Data/ITotalCount.cs | 28 + src/CoreEx/Data/ITypeDiscriminator.cs | 16 + src/CoreEx/Data/ItemsResultT.cs | 53 + src/CoreEx/Data/Model.cs | 143 ++ src/CoreEx/Data/PagingArgs.cs | 91 + src/CoreEx/Data/PagingResult.cs | 35 + src/CoreEx/Data/PartitionKey.cs | 79 + src/CoreEx/Data/PartitionPicker.cs | 191 ++ src/CoreEx/Data/QueryArgs.cs | 91 + src/CoreEx/DataConsistencyException.cs | 93 +- .../ScopedServiceAttribute.cs | 11 + .../ScopedServiceAttributeT.cs | 12 + .../ServiceLifetimeAttribute.cs | 63 + .../SingletonServiceAttribute.cs | 11 + .../SingletonServiceAttributeT.cs | 12 + .../TransientServiceAttribute.cs | 11 + .../TransientServiceAttributeT.cs | 12 + src/CoreEx/DuplicateException.cs | 89 +- .../Entities/Abstractions/IIdentifier.cs | 15 + .../Entities/Abstractions/IIdentifierCore.cs | 31 + .../Abstractions/IReadOnlyIdentifier.cs | 6 + src/CoreEx/Entities/ChangeLog.cs | 191 +- src/CoreEx/Entities/CleanOption.cs | 31 + src/CoreEx/Entities/Cleaner.cs | 458 ++-- src/CoreEx/Entities/CollectionResult.cs | 49 - .../Entities/CompositeKey.Conversion.cs | 178 ++ src/CoreEx/Entities/CompositeKey.cs | 695 ++---- src/CoreEx/Entities/CompositeKeyComparer.cs | 155 +- src/CoreEx/Entities/DataMap.cs | 49 + src/CoreEx/Entities/DateTimeTransform.cs | 62 +- src/CoreEx/Entities/ETag.cs | 198 ++ .../EntitiesExtensions.FeatureSupport.cs | 46 + .../EntitiesExtensions.IEnumerable.cs | 123 ++ src/CoreEx/Entities/EntityKeyCollection.cs | 48 - src/CoreEx/Entities/Extended/ChangeLogEx.cs | 47 - .../CollectionItemChangedEventArgs.cs | 19 - .../CollectionItemChangedEventHandler.cs | 11 - src/CoreEx/Entities/Extended/EntityBase.cs | 177 -- .../Entities/Extended/EntityBaseCollection.cs | 250 --- .../Entities/Extended/EntityBaseDictionary.cs | 259 --- .../Extended/EntityCollectionResult.cs | 63 - src/CoreEx/Entities/Extended/EntityConsts.cs | 20 - src/CoreEx/Entities/Extended/EntityCore.cs | 320 --- .../Extended/EntityKeyBaseCollection.cs | 88 - .../Entities/Extended/ExtendedExtensions.cs | 46 - src/CoreEx/Entities/Extended/IChangeLogEx.cs | 38 - src/CoreEx/Entities/Extended/ICopyFrom.cs | 14 + src/CoreEx/Entities/Extended/IDefault.cs | 14 + .../Extended/IEntityBaseCollection.cs | 11 - .../Entities/Extended/IIdentifierGenerator.cs | 36 + .../Extended/INotifyCollectionItemChanged.cs | 15 - .../Entities/Extended/IPropertyValue.cs | 58 - .../Entities/Extended/IdentifierGenerator.cs | 50 + .../Entities/Extended/ObservableDictionary.cs | 267 --- src/CoreEx/Entities/Extended/PropertyValue.cs | 111 - src/CoreEx/Entities/FeatureSupport.cs | 22 + src/CoreEx/Entities/IChangeLog.cs | 43 +- src/CoreEx/Entities/IChangeLogAudit.cs | 37 - src/CoreEx/Entities/IChangeLogAuditLog.cs | 15 - src/CoreEx/Entities/IChangeLogEx.cs | 39 + src/CoreEx/Entities/ICleanUp.cs | 16 - src/CoreEx/Entities/ICollectionResult.cs | 35 - src/CoreEx/Entities/ICollectionResultT.cs | 21 - src/CoreEx/Entities/ICollectionResultT2.cs | 39 - .../Entities/ICompositeKeyCollection.cs | 38 - .../Entities/ICompositeKeyCollectionT.cs | 23 - src/CoreEx/Entities/IContract.cs | 6 + src/CoreEx/Entities/IContractT.cs | 7 + src/CoreEx/Entities/ICopyFrom.cs | 16 - src/CoreEx/Entities/IETag.cs | 20 +- src/CoreEx/Entities/IEntityKey.cs | 26 +- src/CoreEx/Entities/IIdentifier.cs | 28 - src/CoreEx/Entities/IIdentifierGenerator.cs | 29 - src/CoreEx/Entities/IIdentifierGeneratorT.cs | 21 - src/CoreEx/Entities/IIdentifierT.cs | 59 +- src/CoreEx/Entities/IInitial.cs | 17 - src/CoreEx/Entities/ILogicallyDeleted.cs | 15 - src/CoreEx/Entities/IPagingResult.cs | 15 - src/CoreEx/Entities/IPartitionKey.cs | 15 - src/CoreEx/Entities/IPrimaryKey.cs | 22 - src/CoreEx/Entities/IReadOnly.cs | 20 - src/CoreEx/Entities/IReadOnlyChangeLog.cs | 12 + src/CoreEx/Entities/IReadOnlyChangeLogEx.cs | 27 + src/CoreEx/Entities/IReadOnlyETag.cs | 12 + src/CoreEx/Entities/IReadOnlyIdentifierT.cs | 33 + src/CoreEx/Entities/ITenantId.cs | 15 - src/CoreEx/Entities/IValueResult.cs | 19 + src/CoreEx/Entities/IValueResultT.cs | 17 + src/CoreEx/Entities/IdentifierGenerator.cs | 54 - src/CoreEx/Entities/MessageCollection.cs | 42 + src/CoreEx/Entities/MessageItem.Create.cs | 95 + src/CoreEx/Entities/MessageItem.cs | 139 +- src/CoreEx/Entities/MessageItemCollection.cs | 260 --- src/CoreEx/Entities/MessageType.cs | 33 +- src/CoreEx/Entities/PagingArgs.cs | 220 -- src/CoreEx/Entities/PagingOption.cs | 25 - src/CoreEx/Entities/PagingResult.cs | 100 - src/CoreEx/Entities/QueryArgs.cs | 89 - src/CoreEx/Entities/README.md | 132 -- src/CoreEx/Entities/StringCase.cs | 53 +- src/CoreEx/Entities/StringTransform.cs | 43 +- src/CoreEx/Entities/StringTrim.cs | 51 +- src/CoreEx/Entities/ValueResult.cs | 22 + src/CoreEx/Entities/Writable.cs | 30 + src/CoreEx/Entities/WritableAttribute.cs | 16 + .../Events/Attachments/EventAttachment.cs | 25 - .../Events/Attachments/IAttachmentStorage.cs | 41 - src/CoreEx/Events/CloudEventSerializerBase.cs | 281 --- src/CoreEx/Events/CustomEventSerializers.cs | 67 - src/CoreEx/Events/EventData.cs | 33 - src/CoreEx/Events/EventDataBase.cs | 184 -- src/CoreEx/Events/EventDataFormatter.cs | 278 --- src/CoreEx/Events/EventDataProperty.cs | 79 - src/CoreEx/Events/EventDataSerializerBase.cs | 156 -- src/CoreEx/Events/EventDataT.cs | 36 - src/CoreEx/Events/EventExtensions.cs | 587 ----- src/CoreEx/Events/EventPublisher.cs | 117 - src/CoreEx/Events/EventPublisherInvoker.cs | 33 - src/CoreEx/Events/EventSendData.cs | 34 - src/CoreEx/Events/EventSendException.cs | 33 - src/CoreEx/Events/EventSubscriberBase.cs | 273 --- src/CoreEx/Events/EventSubscriberException.cs | 69 - .../HealthChecks/EventPublisherHealthCheck.cs | 38 - .../EventPublisherHealthCheckOptions.cs | 28 - src/CoreEx/Events/IEventConverterT.cs | 72 - src/CoreEx/Events/IEventDataConverter.cs | 56 - src/CoreEx/Events/IEventDataFormatter.cs | 17 - src/CoreEx/Events/IEventPublisher.cs | 59 - src/CoreEx/Events/IEventSender.cs | 29 - src/CoreEx/Events/IEventSerializer.cs | 73 - src/CoreEx/Events/InMemoryPublisher.cs | 75 - src/CoreEx/Events/InMemorySender.cs | 40 - src/CoreEx/Events/LoggerEventPublisher.cs | 40 - src/CoreEx/Events/LoggerEventSender.cs | 50 - src/CoreEx/Events/NullEventPublisher.cs | 15 - src/CoreEx/Events/NullEventSender.cs | 25 - src/CoreEx/Events/README.md | 123 -- src/CoreEx/Events/Subscribing/ErrorHandler.cs | 132 -- .../Events/Subscribing/ErrorHandlerArgs.cs | 47 - .../Events/Subscribing/ErrorHandling.cs | 63 - .../Events/Subscribing/EventSubscriberArgs.cs | 84 - .../Subscribing/EventSubscriberAttribute.cs | 183 -- .../EventSubscriberExceptionSource.cs | 37 - .../EventSubscriberInstrumentationBase.cs | 183 -- .../Subscribing/EventSubscriberInvoker.cs | 11 - .../EventSubscriberOrchestrator.cs | 240 --- .../Events/Subscribing/IErrorHandling.cs | 55 - .../Events/Subscribing/IEventSubscriber.cs | 34 - .../IEventSubscriberInstrumentation.cs | 20 - .../Events/Subscribing/SubscriberBase.cs | 57 - .../Events/Subscribing/SubscriberBaseT.cs | 61 - src/CoreEx/ExecutionContext.Infra.cs | 220 ++ src/CoreEx/ExecutionContext.cs | 452 +--- src/CoreEx/Extensions.ExtendedException.cs | 83 + src/CoreEx/Extensions.HttpRequestMessage.cs | 159 ++ src/CoreEx/Extensions.HttpResponseMessage.cs | 161 ++ src/CoreEx/Extensions.IEnumerable.cs | 41 + src/CoreEx/Extensions.IHybridCache.cs | 117 + src/CoreEx/Extensions.OperationType.cs | 42 + src/CoreEx/Extensions.cs | 187 ++ src/CoreEx/GlobalUsing.cs | 61 + .../Globalization/GlobalizationExtensions.cs | 23 + src/CoreEx/Globalization/README.md | 17 - src/CoreEx/Globalization/TextInfoCasing.cs | 43 +- .../Globalization/TextInfoExtensions.cs | 27 - src/CoreEx/HealthChecks/Extensions.cs | 15 + src/CoreEx/HealthChecks/HealthCheckTags.cs | 22 + src/CoreEx/Hosting/ConcurrentSynchronizer.cs | 20 - .../Hosting/Extensions.ServiceStatus.cs | 58 + src/CoreEx/Hosting/FileLockSynchronizer.cs | 81 - .../TimerHostedServiceHealthCheck.cs | 33 - src/CoreEx/Hosting/HostSettings.cs | 51 + src/CoreEx/Hosting/HostStartup.cs | 23 - src/CoreEx/Hosting/HostStartupExtensions.cs | 26 - src/CoreEx/Hosting/HostedServiceBase.cs | 380 ++++ .../Hosting/HostedServiceHealthCheck.cs | 25 + src/CoreEx/Hosting/HostedServiceInvoker.cs | 12 + src/CoreEx/Hosting/HostedServiceManager.cs | 117 + src/CoreEx/Hosting/IHostSettings.cs | 29 + src/CoreEx/Hosting/IHostStartup.cs | 36 - src/CoreEx/Hosting/IServiceSynchronizer.cs | 27 - src/CoreEx/Hosting/README.md | 67 - src/CoreEx/Hosting/ServiceBase.cs | 119 - src/CoreEx/Hosting/ServiceInvoker.cs | 19 - src/CoreEx/Hosting/ServiceStatus.cs | 58 + .../HybridCacheSynchronizer.cs | 81 + .../Hosting/Synchronization/ISynchronizer.cs | 26 + .../SynchronizedTimerHostedServiceBase.cs | 98 +- src/CoreEx/Hosting/TimerHostedServiceBase.cs | 744 ++++--- .../Hosting/TimerHostedServiceStatus.cs | 42 - .../Hosting/Work/FileWorkStatePersistence.cs | 133 -- .../Hosting/Work/HybridCacheWorkProvider.cs | 117 + src/CoreEx/Hosting/Work/IWorkProvider.cs | 57 + .../Hosting/Work/IWorkStatePersistence.cs | 62 - .../Work/InMemoryWorkStatePersistence.cs | 84 - src/CoreEx/Hosting/Work/WorkArgs.cs | 64 + src/CoreEx/Hosting/Work/WorkOrchestrator.cs | 385 ++++ .../Hosting/Work/WorkOrchestratorInvoker.cs | 15 + src/CoreEx/Hosting/Work/WorkState.cs | 120 +- src/CoreEx/Hosting/Work/WorkStateArgs.cs | 61 - .../Hosting/Work/WorkStateOrchestrator.cs | 381 ---- src/CoreEx/Hosting/Work/WorkStatus.cs | 101 +- .../Http/Abstractions/ProblemDetails.cs | 74 + .../Http/Extended/ITypedHttpClientOptions.cs | 25 - .../Http/Extended/ITypedMappedHttpClient.cs | 41 - .../Http/Extended/TypedHttpClientOptions.cs | 249 --- .../Http/Extended/TypedMappedHttpClient.cs | 30 - .../Extended/TypedMappedHttpClientBase.cs | 245 --- .../Extended/TypedMappedHttpClientCore.cs | 245 --- .../TypedHttpClientCoreHealthCheck.cs | 50 - .../TypedHttpClientHealthCheck.cs | 50 - src/CoreEx/Http/HttpArg.cs | 181 -- src/CoreEx/Http/HttpArgType.cs | 30 - src/CoreEx/Http/HttpArgs.cs | 57 - src/CoreEx/Http/HttpConsts.cs | 210 -- src/CoreEx/Http/HttpExtensions.cs | 199 -- src/CoreEx/Http/HttpNames.cs | 102 + src/CoreEx/Http/HttpPatchOption.cs | 27 - src/CoreEx/Http/HttpRequestOptions.cs | 330 --- src/CoreEx/Http/HttpResult.cs | 144 -- src/CoreEx/Http/HttpResultBase.cs | 202 -- src/CoreEx/Http/HttpResultT.cs | 111 - src/CoreEx/Http/IHttpArg.cs | 31 - src/CoreEx/Http/IHttpArgTypeArg.cs | 28 - src/CoreEx/Http/IdempotencyKeyHandler.cs | 44 + src/CoreEx/Http/ProblemDetailsException.cs | 91 + src/CoreEx/Http/README.md | 95 - src/CoreEx/Http/TypedHttpClient.cs | 28 - src/CoreEx/Http/TypedHttpClientBase.cs | 299 --- src/CoreEx/Http/TypedHttpClientBaseT.cs | 667 ------ src/CoreEx/Http/TypedHttpClientCore.cs | 343 --- src/CoreEx/Http/TypedHttpClientInvoker.cs | 19 - src/CoreEx/ISystemTime.cs | 17 - src/CoreEx/Invokers/DataInvoker.cs | 18 - src/CoreEx/Invokers/DataSvcInvoker.cs | 18 - src/CoreEx/Invokers/IInvoker.cs | 91 +- src/CoreEx/Invokers/InvokeArgs.cs | 284 --- src/CoreEx/Invokers/Invoker.cs | 83 +- src/CoreEx/Invokers/InvokerArgs.cs | 88 - src/CoreEx/Invokers/InvokerBase.cs | 145 +- src/CoreEx/Invokers/InvokerBaseT.cs | 408 +--- src/CoreEx/Invokers/InvokerBaseT2.cs | 562 +---- src/CoreEx/Invokers/InvokerLogger.cs | 22 + src/CoreEx/Invokers/InvokerNameAttribute.cs | 29 + src/CoreEx/Invokers/InvokerTracer.cs | 235 ++ src/CoreEx/Invokers/ManagerInvoker.cs | 18 - src/CoreEx/Invokers/README.md | 70 - src/CoreEx/Invokers/ResultInvokerWith.cs | 86 - .../Invokers/ResultInvokerWithExtensions.cs | 43 - .../Json/Compare/JsonElementComparer.cs | 461 ---- .../Compare/JsonElementComparerOptions.cs | 87 - .../Json/Compare/JsonElementComparerResult.cs | 306 --- .../Json/Compare/JsonElementComparison.cs | 23 - .../Json/Compare/JsonElementDifference.cs | 57 - .../Json/Compare/JsonElementDifferenceType.cs | 37 - src/CoreEx/Json/Data/JsonDataReader.cs | 515 ----- src/CoreEx/Json/Data/JsonDataReaderArgs.cs | 84 - src/CoreEx/Json/IJsonPreFilterInspector.cs | 22 - src/CoreEx/Json/IJsonSerializer.cs | 158 -- .../IReferenceDataContentJsonSerializer.cs | 12 - .../Json/JsonDataMapConverterFactory.cs | 58 + src/CoreEx/Json/JsonDefaults.cs | 49 + .../Json/JsonExceptionConverterFactory.cs | 53 + src/CoreEx/Json/JsonFilter.cs | 361 ++++ src/CoreEx/Json/JsonFilterOption.cs | 17 + src/CoreEx/Json/JsonMergePatch.cs | 385 ++++ src/CoreEx/Json/JsonMergePatchOptions.cs | 18 + src/CoreEx/Json/JsonMergePatchResult.cs | 17 + src/CoreEx/Json/JsonPropertyFilter.cs | 23 - src/CoreEx/Json/JsonReferenceDataConverter.cs | 16 + src/CoreEx/Json/JsonSerializer.cs | 31 - src/CoreEx/Json/JsonSubstituteNamingPolicy.cs | 32 + src/CoreEx/Json/JsonWriteFormat.cs | 20 - src/CoreEx/Json/Mapping/IJsonObjectMapper.cs | 40 - .../Json/Mapping/IJsonObjectMapperMappings.cs | 43 - src/CoreEx/Json/Mapping/IJsonObjectMapperT.cs | 39 - .../Json/Mapping/IPropertyJsonMapper.cs | 96 - src/CoreEx/Json/Mapping/JsonObjectMapper.cs | 32 - src/CoreEx/Json/Mapping/JsonObjectMapperT.cs | 265 --- src/CoreEx/Json/Mapping/PropertyJsonMapper.cs | 211 -- .../Json/Merge/DictionaryMergeApproach.cs | 27 - .../Merge/EntityKeyCollectionMergeApproach.cs | 29 - .../Json/Merge/Extended/JsonMergePatchEx.cs | 376 ---- .../Merge/Extended/JsonMergePatchExOptions.cs | 33 - src/CoreEx/Json/Merge/IJsonMergePatch.cs | 46 - src/CoreEx/Json/Merge/JsonMergePatch.cs | 281 --- .../Json/Merge/JsonMergePatchException.cs | 31 - .../Json/Merge/JsonMergePatchOptions.cs | 23 - src/CoreEx/Json/README.md | 36 - src/CoreEx/Localization/ITextProvider.cs | 21 +- src/CoreEx/Localization/LText.cs | 283 ++- .../Localization/LocalizationAttribute.cs | 26 + src/CoreEx/Localization/NullTextProvider.cs | 21 +- src/CoreEx/Localization/README.md | 31 - src/CoreEx/Localization/TextProvider.cs | 70 +- src/CoreEx/Localization/TextProviderBase.cs | 42 +- src/CoreEx/Mapping/BiDirectionMapperT2.cs | 66 + src/CoreEx/Mapping/BiDirectionMapperT3.cs | 80 + src/CoreEx/Mapping/BidirectionalMapper.cs | 74 - src/CoreEx/Mapping/CollectionMapper.cs | 79 - .../Abstractions/IDestinationConverter.cs | 25 + .../Abstractions/ISourceConverter.cs | 25 + src/CoreEx/Mapping/Converters/Converter.cs | 22 - src/CoreEx/Mapping/Converters/ConverterT.cs | 41 - .../Converters/DateTimeToStringConverter.cs | 66 - .../EncodedStringToDateTimeConverter.cs | 47 - .../EncodedStringToUInt32Converter.cs | 47 - src/CoreEx/Mapping/Converters/IConverter.cs | 51 +- src/CoreEx/Mapping/Converters/IConverterT.cs | 79 +- .../Mapping/Converters/IValueConverter.cs | 23 +- .../Mapping/Converters/IValueConverterT.cs | 33 +- .../Converters/JsonElementStringConverter.cs | 49 + .../Converters/ObjectToJsonConverter.cs | 60 - .../Converters/ReferenceDataCodeConverter.cs | 49 - .../Converters/ReferenceDataIdConverter.cs | 51 - .../Converters/StringBase64Converter.cs | 42 + .../Converters/StringToBase64Converter.cs | 47 - .../Converters/StringToTypeConverter.cs | 47 - .../Converters/TypeToStringConverter.cs | 47 - .../Mapping/Converters/ValueConverter.cs | 27 +- src/CoreEx/Mapping/IBiDirectionMapper.cs | 20 + .../Mapping/IBidirectionalMapperBase.cs | 20 - src/CoreEx/Mapping/IBidirectionalMapperT.cs | 30 - src/CoreEx/Mapping/IIntoMapper.cs | 14 + src/CoreEx/Mapping/IIntoMapperT.cs | 29 + src/CoreEx/Mapping/IMapper.cs | 52 +- src/CoreEx/Mapping/IMapperBase.cs | 51 +- src/CoreEx/Mapping/IMapperT.cs | 79 +- src/CoreEx/Mapping/IntoMapperT2.cs | 31 + src/CoreEx/Mapping/IntoMapperT3.cs | 30 + src/CoreEx/Mapping/Mapper.cs | 500 ++--- src/CoreEx/Mapping/MapperExtensions.cs | 45 + src/CoreEx/Mapping/MapperOptions.cs | 54 - src/CoreEx/Mapping/MapperT.cs | 321 --- src/CoreEx/Mapping/MapperT2.cs | 28 + src/CoreEx/Mapping/MapperT3.cs | 25 + src/CoreEx/Mapping/OperationTypes.cs | 58 - src/CoreEx/Mapping/README.md | 73 - .../Metadata/IPropertyRuntimeMetadata.cs | 103 + src/CoreEx/Metadata/IRuntimeMetadata.cs | 13 + src/CoreEx/Metadata/IRuntimeMetadataCore.cs | 12 + .../Metadata/PropertyRuntimeMetadata.cs | 136 ++ .../PropertyRuntimeMetadataReflector.cs | 36 + .../Metadata/RuntimeMetadata.AreEqual.cs | 145 ++ src/CoreEx/Metadata/RuntimeMetadata.Clean.cs | 82 + .../Metadata/RuntimeMetadata.CopyInto.cs | 139 ++ .../Metadata/RuntimeMetadata.GetHashCode.cs | 71 + .../Metadata/RuntimeMetadata.Internal.cs | 76 + .../Metadata/RuntimeMetadata.IsDefault.cs | 42 + src/CoreEx/Metadata/RuntimeMetadata.cs | 154 ++ src/CoreEx/NotFoundException.cs | 101 +- src/CoreEx/OperationType.cs | 55 +- src/CoreEx/README.md | 65 - .../RefData/Abstractions/IReferenceData.cs | 110 + .../Abstractions/IReferenceDataCollection.cs | 111 + .../Caching/FixedExpirationCacheEntry.cs | 30 - .../RefData/Caching/ICacheEntryConfig.cs | 28 - .../Caching/SettingsBasedCacheEntry.cs | 43 - .../RefData/Extended/ReferenceDataBaseEx.cs | 309 --- .../ReferenceDataOrchestratorHealthCheck.cs | 33 - src/CoreEx/RefData/IReferenceData.cs | 105 - src/CoreEx/RefData/IReferenceDataCache.cs | 16 + .../RefData/IReferenceDataCodeCollection.cs | 36 + src/CoreEx/RefData/IReferenceDataCodeList.cs | 29 - .../RefData/IReferenceDataCollection.cs | 121 -- .../RefData/IReferenceDataCollectionT.cs | 215 +- src/CoreEx/RefData/IReferenceDataContext.cs | 47 +- src/CoreEx/RefData/IReferenceDataProvider.cs | 38 +- src/CoreEx/RefData/IReferenceDataT.cs | 16 +- src/CoreEx/RefData/README.md | 157 -- src/CoreEx/RefData/ReferenceDataBase.cs | 57 - src/CoreEx/RefData/ReferenceDataBaseT.cs | 36 - src/CoreEx/RefData/ReferenceDataCodeList.cs | 195 -- src/CoreEx/RefData/ReferenceDataCollection.cs | 297 --- .../RefData/ReferenceDataCollectionBase.cs | 72 - src/CoreEx/RefData/ReferenceDataContext.cs | 48 - src/CoreEx/RefData/ReferenceDataFilter.cs | 34 - .../RefData/ReferenceDataMultiDictionary.cs | 23 +- .../RefData/ReferenceDataOrchestrator.cs | 1104 +++++----- .../ReferenceDataOrchestratorInvoker.cs | 24 +- src/CoreEx/RefData/ReferenceDataSortOrder.cs | 32 - src/CoreEx/Results/Abstractions/IResult.cs | 42 + src/CoreEx/Results/Abstractions/IResultT.cs | 14 + src/CoreEx/Results/Abstractions/IToResult.cs | 12 + src/CoreEx/Results/Abstractions/IToResultT.cs | 15 + src/CoreEx/Results/AnyExtensions.cs | 1076 --------- src/CoreEx/Results/CoreExtensions.cs | 136 -- src/CoreEx/Results/IResult.cs | 47 - src/CoreEx/Results/IResultT.cs | 18 - src/CoreEx/Results/IToResult.cs | 16 - src/CoreEx/Results/IToResultT.cs | 17 - src/CoreEx/Results/ITypedToResult.cs | 18 - src/CoreEx/Results/MatchExtensions.cs | 438 ---- src/CoreEx/Results/OnFailureExtensions.cs | 1082 ---------- src/CoreEx/Results/README.md | 258 --- src/CoreEx/Results/Result.Error.cs | 93 + src/CoreEx/Results/Result.Go.cs | 114 + src/CoreEx/Results/Result.Static.cs | 59 + src/CoreEx/Results/Result.cs | 335 +-- src/CoreEx/Results/ResultGo.cs | 220 -- src/CoreEx/Results/ResultT.Error.cs | 93 + src/CoreEx/Results/ResultT.Static.cs | 46 + src/CoreEx/Results/ResultT.cs | 369 ++-- src/CoreEx/Results/ResultsExtensions.Any.cs | 620 ++++++ src/CoreEx/Results/ResultsExtensions.Error.cs | 67 + src/CoreEx/Results/ResultsExtensions.Match.cs | 432 ++++ .../Results/ResultsExtensions.OnFailure.cs | 632 ++++++ src/CoreEx/Results/ResultsExtensions.Then.cs | 686 ++++++ src/CoreEx/Results/ResultsExtensions.When.cs | 939 ++++++++ src/CoreEx/Results/ResultsExtensions.cs | 390 ++-- src/CoreEx/Results/ThenExtensions.cs | 1136 ---------- src/CoreEx/Results/WhenExtensions.cs | 1564 -------------- src/CoreEx/Runtime.cs | 24 + src/CoreEx/Schemas/IReadOnlySchemaVersion.cs | 12 + src/CoreEx/Schemas/ISchemaVersion.cs | 15 + src/CoreEx/Schemas/Schema.cs | 33 + src/CoreEx/Schemas/SchemaAttribute.cs | 74 + src/CoreEx/Security/AuthenticationType.cs | 33 + src/CoreEx/Security/AuthenticationUser.cs | 47 + src/CoreEx/SystemTime.cs | 43 - src/CoreEx/Text/Json/CloudEventSerializer.cs | 67 - .../Json/CollectionResultConverterFactory.cs | 59 - .../Text/Json/CompositeKeyConverterFactory.cs | 148 -- src/CoreEx/Text/Json/EventDataSerializer.cs | 25 - .../Text/Json/ExceptionConverterFactory.cs | 101 - src/CoreEx/Text/Json/JsonExtensions.cs | 57 - src/CoreEx/Text/Json/JsonFilterer.cs | 260 --- .../Text/Json/JsonPreFilterInspector.cs | 25 - src/CoreEx/Text/Json/JsonSerializer.cs | 133 -- .../Text/Json/NumberToStringConverter.cs | 27 - .../ReferenceDataContentJsonSerializer.cs | 38 - .../Json/ReferenceDataConverterFactory.cs | 62 - ...enceDataMultiDictionaryConverterFactory.cs | 45 - .../Text/Json/ResultConverterFactory.cs | 60 - .../Text/Json/SubstituteNamingPolicy.cs | 21 - src/CoreEx/Text/SentenceCase.cs | 389 +++- src/CoreEx/TransientException.cs | 100 +- src/CoreEx/UnexpectedInternalException.cs | 32 + src/CoreEx/Validation/DecimalRuleHelper.cs | 160 ++ src/CoreEx/Validation/IValidationResult.cs | 63 +- src/CoreEx/Validation/IValidationResultT.cs | 27 +- src/CoreEx/Validation/IValidator.cs | 37 +- src/CoreEx/Validation/IValidatorT.cs | 43 +- src/CoreEx/Validation/MultiValidator.cs | 161 +- src/CoreEx/Validation/MultiValidatorResult.cs | 156 +- src/CoreEx/Validation/README.md | 29 - src/CoreEx/Validation/Validation.cs | 324 +-- src/CoreEx/Validation/ValidationInvoker.cs | 40 - .../ValidatorExtensions.Requires.cs | 48 + src/CoreEx/Validation/ValidatorExtensions.cs | 167 ++ src/CoreEx/ValidationException.cs | 177 +- src/CoreEx/Wildcards/Wildcard.cs | 436 ++-- src/CoreEx/Wildcards/WildcardResult.cs | 184 +- src/CoreEx/Wildcards/WildcardSelection.cs | 133 +- .../Wildcards/WildcardSpaceTreatment.cs | 49 +- src/CoreEx/strong-name-key.snk | Bin 596 -> 0 bytes src/Directory.Build.props | 11 + src/Directory.Build.targets | 9 + .../Controllers/OtherController.cs | 33 + .../Controllers/PersonController.cs | 52 + .../Controllers/ReferenceDataController.cs | 17 + .../CoreEx.AspNetCore.Test.Api.csproj | 28 + .../CoreEx.AspNetCore.Test.Api.http | 6 + .../Entities/Gender.cs | 8 + .../Entities/Person.cs | 23 + tests/CoreEx.AspNetCore.Test.Api/Program.cs | 118 + .../Properties/launchSettings.json | 43 +- .../Services/PersonService.cs | 108 + .../Services/PersonService2.cs | 99 + .../Services/ReferenceDataService.cs | 22 + .../appsettings.Development.json | 11 + .../appsettings.json | 15 + .../CoreEx.AspNetCore.Test.Unit.csproj | 56 + .../OtherApiTests.cs | 275 +++ .../PersonApi_HttpMutateTests.cs | 7 + .../PersonApi_HttpQueryTests.cs | 7 + .../PersonApi_MutateTestsBase.cs | 216 ++ .../PersonApi_MvcMutateTests.cs | 7 + .../PersonApi_MvcQueryTests.cs | 7 + .../PersonApi_QueryTestsBase.cs | 192 ++ .../ReferenceDataApi_HttpTests.cs | 7 + .../ReferenceDataApi_MvcTests.cs | 7 + .../ReferenceDataApi_TestsBase.cs | 62 + .../Resources/Gender_Get_All.json | 12 + .../Resources/Person_GetByQuery_Default.json | 34 + .../Person_GetByQuery_FilterError.json | 86 + .../Person_GetByQuery_IncludeFields.json | 18 + .../Person_GetByQuery_OrderByError.json | 86 + .../Resources/Person_GetByQuery_Paging.json | 18 + .../WebApiTestsBase.Delete.cs | 34 + .../WebApiTestsBase.DeleteWithResult.cs | 43 + .../WebApiTestsBase.Exceptions.cs | 50 + .../WebApiTestsBase.ExceptionsWithResult.cs | 92 + .../WebApiTestsBase.Get.cs | 284 +++ .../WebApiTestsBase.GetWithResult.cs | 283 +++ .../WebApiTestsBase.MergePatch.cs | 89 + .../WebApiTestsBase.MergePatchWithResult.cs | 90 + .../WebApiTestsBase.Patch.cs | 120 ++ .../WebApiTestsBase.PatchWithResult.cs | 121 ++ .../WebApiTestsBase.Post.cs | 136 ++ .../WebApiTestsBase.PostWithResult.cs | 136 ++ .../WebApiTestsBase.Put.cs | 123 ++ .../WebApiTestsBase.PutWithResult.cs | 124 ++ .../WebApiTestsBase.cs | 19 + .../WebApi_HttpTests.cs | 6 + .../WebApi_MvcTests.cs | 6 + ...zure.Messaging.ServiceBus.Test.Unit.csproj | 38 + .../EntryPoint.cs | 33 + .../ServiceBusMessageTests.cs | 85 + .../ServiceBusPublisherTests.cs | 60 + .../ServiceBusReceiverTests.cs | 231 ++ .../ServiceBusSubscriberTests.cs | 28 + .../Subscribers/ProductSubscriber.cs | 32 + .../appsettings.unittest.json | 11 + .../CoreEx.Caching.Redis.Test.Unit.csproj | 43 + .../EntryPoint.cs | 40 + .../HybridCacheSynchronizerTests.cs | 37 + .../HybridCacheTests.cs | 257 +++ .../ReferenceDataCacheTests.cs | 138 ++ .../appsettings.unittest.json | 13 + .../CoreEx.Cosmos.Test.csproj | 46 - tests/CoreEx.Cosmos.Test/CosmosDb.cs | 26 - .../CosmosDbContainerAuthTest.cs | 195 -- .../CosmosDbContainerPartitioningTest.cs | 224 -- .../CosmosDbContainerTest.cs | 300 --- tests/CoreEx.Cosmos.Test/CosmosDbModelTest.cs | 42 - .../CosmosDbQueryPartitioningTest.cs | 48 - .../CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs | 230 -- tests/CoreEx.Cosmos.Test/Data/Data.yaml | 24 - tests/CoreEx.Cosmos.Test/Data/RefData.yaml | 4 - tests/CoreEx.Cosmos.Test/Models.cs | 68 - tests/CoreEx.Cosmos.Test/TestSetUp.cs | 102 - .../CoreEx.Cosmos.Test/TestingWithEmulator.md | 58 - tests/CoreEx.Cosmos.Test/Usings.cs | 1 - .../CoreEx.Data.Test.Unit.csproj | 44 + .../Querying/QueryFilterParserTests.cs | 243 +++ .../Querying/QueryOrderByParserTests.cs | 39 + .../Querying/TestUtility.cs | 71 + .../Resources/FilterSchema.json | 49 + .../Resources/FilterToString.txt | 12 + .../Resources/OrderBySchema.json | 9 + .../Resources/OrderByToString.txt | 8 + ...eEx.Database.SqlServer.Test.Console.csproj | 23 + .../Data/data.yaml | 12 + .../Migrations/001-create-test-schema.sql | 1 + .../Migrations/002-create-test-table.sql | 17 + .../003-create-test-table-unique-index.sql | 2 + .../Program.cs | 27 + .../Properties/launchSettings.json | 8 + .../database.beef-5.yaml | 0 .../Contracts/TestTableDto.cs | 21 + .../Contracts/TestTableDtoMapper.cs | 32 + ...CoreEx.Database.SqlServer.Test.Unit.csproj | 50 + .../DatabaseTestBase.cs | 25 + .../DatabaseTests.cs | 191 ++ .../EntityFrameworkCrudTests.Create.cs | 146 ++ .../EntityFrameworkCrudTests.Get.cs | 82 + .../EntityFrameworkCrudTests.cs | 5 + .../EntityFrameworkCrudTestsDelete.cs | 61 + .../EntityFrameworkCrudTestsUpdate.cs | 157 ++ .../EntityFrameworkQueryTests.cs | 165 ++ .../EntityFrameworkUowTests.cs | 99 + .../EntryPoint.cs | 20 + .../Models/TestTable.cs | 24 + .../Models/TestTableMapper.cs | 19 + .../Repository/TestDbContext.cs | 47 + .../Repository/TestEfDb.cs | 18 + .../appsettings.unittest.json | 24 + .../CoreEx.Database.Test.Unit.csproj | 34 + .../DatabaseParameterCollectionTests.cs | 278 +++ .../AggregateTests.cs | 247 +++ .../CoreEx.DomainDriven.Test.Unit.csproj | 34 + .../CloudEventsExtensionsTests.cs | 132 ++ .../CoreEx.Events.Test.Unit.csproj | 35 + .../CoreEx.Events.Test.Unit/EventDataTests.cs | 277 +++ .../EventFormatterTests.cs | 246 +++ .../GlobalSuppressions.cs | 9 + .../Publishing/EventPublisherBaseTests.cs | 176 ++ .../FixedDestinationProviderTests.cs | 57 + .../CoreEx.RefData.Test.Unit.csproj | 36 + .../ReferenceDataCollectionTests.cs | 206 ++ .../ReferenceDataOrchestratorTests.cs | 423 ++++ .../ReferenceDataTests.cs | 152 ++ .../ReferenceDataValidationTests.cs | 99 + .../CoreEx.Solace.Test.csproj | 40 - .../PubSubOrchestratedTest.cs | 107 - tests/CoreEx.Solace.Test/Usings.cs | 8 - .../appsettings.unittest.json | 9 - .../Abstractions/ExtensionTests.cs | 48 + .../Abstractions/InternalTests.cs | 45 + .../CoreEx.Test.Unit/CoreEx.Test.Unit.csproj | 35 + .../CoreEx.Test.Unit/Data/PagingArgsTests.cs | 102 + .../Data/PagingResultTests.cs | 66 + .../Data/PartitionKeyTests.cs | 27 + .../Data/PartitionPickerTests.cs | 99 + tests/CoreEx.Test.Unit/Data/QueryArgsTests.cs | 106 + .../Entities/ChangeLogTests.cs | 75 + .../CoreEx.Test.Unit/Entities/CleanerTests.cs | 229 ++ .../Entities/CompositeKeyTests.cs | 142 ++ .../Entities/IdentifierTests.cs | 43 + .../Entities/MessageItemCollectionTests.cs | 72 + .../Entities/MessageItemCreateTests.cs | 74 + .../Entities/MessageItemTests.cs | 66 + tests/CoreEx.Test.Unit/ExceptionTests.cs | 142 ++ .../CoreEx.Test.Unit/ExecutionContextTests.cs | 150 ++ .../Hosting/Work/WorkOrchestratorTests.cs | 181 ++ .../CoreEx.Test.Unit/Http/HttpClientTests.cs | 117 + .../CoreEx.Test.Unit/Json/JsonFilterTests.cs | 503 +++++ tests/CoreEx.Test.Unit/Json/JsonMergeTests.cs | 327 +++ .../Json/JsonSubstituteNamingPolicyTests.cs | 28 + .../Localization/LTextTests.cs | 153 ++ .../Localization/TextProviderTests.cs | 84 + .../StringToBase64ConverterTests.cs | 59 + tests/CoreEx.Test.Unit/Mapping/MapTests.cs | 124 ++ tests/CoreEx.Test.Unit/Mapping/MapperTests.cs | 41 + .../Results/ExtensionsAnyTests.cs | 416 ++++ .../Results/ExtensionsMatchTests.cs | 489 +++++ .../Results/ExtensionsOnFailureTests.cs | 419 ++++ .../Results/ExtensionsTests.cs | 359 +++ .../Results/ExtensionsThenTests.cs | 438 ++++ .../Results/ExtensionsWhenTests.cs | 392 ++++ .../Results/ResultErrorTests.cs | 181 ++ .../Results/ResultStaticTests.cs | 74 + .../Results/ResultTErrorTests.cs | 181 ++ .../CoreEx.Test.Unit/Results/ResultTTests.cs | 145 ++ tests/CoreEx.Test.Unit/Results/ResultTests.cs | 112 + .../Runtime/ReflectionMetadataTests.cs | 181 ++ .../Runtime/RuntimeMetadataTests.cs | 434 ++++ tests/CoreEx.Test.Unit/Schemas/SchemaTests.cs | 61 + .../Text/SentenceCaseTests.cs | 365 ++++ .../Validation/ValidationTests.cs | 134 ++ .../Wildcards/WildcardTests.cs | 130 ++ tests/CoreEx.Test/CoreEx.Test.csproj | 54 - .../Abstractions/ObjectExtensionsTest.cs | 59 - .../Reflection/PropertyExpressionTest.cs | 84 - .../Reflection/TypeReflectorTest.cs | 319 --- .../EventDataToServiceBusConverterTest.cs | 225 -- .../Storage/BlobAttachmentStorageTest.cs | 111 - .../Azure/Storage/BlobLockSynchronizerTest.cs | 65 - .../Framework/Caching/RequestCacheTest.cs | 31 - .../Framework/Caching/ResultExtensionsTest.cs | 51 - .../Configuration/SettingsBaseTest.cs | 226 -- .../Framework/Data/QueryArgsConfigTest.cs | 350 --- .../Database/Mapping/DatabaseMapperTest.cs | 70 - .../Dataverse/Mapping/DataverseMapperTest.cs | 92 - .../Framework/Entities/CleanerTest.cs | 144 -- .../Framework/Entities/CompositeKeyTest.cs | 222 -- .../Entities/Extended/EntityBaseTest.cs | 981 --------- .../Entities/Extended/EntityCoreTest.cs | 89 - .../Extended/ObservableDictionaryTest.cs | 230 -- .../Framework/Entities/IdentifierTest.cs | 35 - .../Framework/Entities/PagingArgsTest.cs | 49 - .../Events/CloudEventSerializerTest.cs | 177 -- .../Events/EventDataFormatterTest.cs | 222 -- .../Events/EventDataPublisherTest.cs | 220 -- .../Events/EventDataSerializerTest.cs | 318 --- .../EventSubscriberAttributeTest.cs | 155 -- .../EventSubscriberOrchestratorTest.cs | 336 --- .../FluentValidation/HttpRequestTest.cs | 66 - .../FluentValidation/MultiValidatorTest.cs | 34 - .../ServiceBusTriggerExecutorTest.cs | 31 - .../FluentValidation/ServiceCollectionTest.cs | 123 -- .../Framework/Globalization/TextInfoTest.cs | 22 - .../Hosting/Work/WorkStateOrchestratorTest.cs | 181 -- .../Framework/Http/HttpRequestOptionsTest.cs | 183 -- .../Framework/Http/HttpResultTest.cs | 48 - .../Framework/Http/TypedHttpClientBaseTest.cs | 162 -- .../Framework/Http/TypedHttpClientCoreTest.cs | 831 ------- .../Http/TypedHttpClientOptionsTest.cs | 129 -- .../Http/TypedMapperHttpClientBaseTest.cs | 135 -- .../Framework/Invokers/InvokerBaseTest.cs | 67 - .../Framework/Invokers/InvokerTest.cs | 13 - .../Json/Compare/JsonElementComparerTest.cs | 389 ---- .../Framework/Json/Data/JsonDataReaderTest.cs | 243 --- .../Framework/Json/JsonEmployeeTest.cs | 83 - .../Framework/Json/JsonSerializerTest.cs | 760 ------- .../Json/Mapping/JsonObjectMapperTest.cs | 79 - .../Merge/Extended/JsonMergePatchExTest.cs | 1370 ------------ .../Json/Merge/JsonMergePatchTest.cs | 963 --------- .../Framework/Localization/LTextTest.cs | 60 - .../Mapping/Converters/ConverterTest.cs | 19 - .../DateTimeToStringConverterTest.cs | 25 - .../EncodedStringToDateTimeConverterTest.cs | 25 - .../EncodedStringToUInt32ConverterTest.cs | 24 - .../Converters/TypeToStringConverterTest.cs | 51 - .../Framework/Mapping/MapperTest.cs | 1060 --------- .../ServiceBus/ServiceBusSubscriberTest.cs | 78 - .../CoreEx.Test/Framework/OData/ODataTest.cs | 424 ---- .../Framework/RefData/ReferenceDataTest.cs | 1298 ----------- .../Framework/Results/AnyExtensionsTest.cs | 11 - .../Framework/Results/MatchExtensionsTest.cs | 367 ---- .../Results/OnFailureExtensionsTest.cs | 616 ------ .../Framework/Results/ResultGoTest.cs | 108 - .../Results/ResultInvokerWithTest.cs | 20 - .../Framework/Results/ResultStateTest.cs | 196 -- .../Framework/Results/ResultTTest.cs | 315 --- .../Framework/Results/ResultTest.cs | 159 -- .../Framework/Results/ThenExtensionsTest.cs | 167 -- .../Results/ValidationExtensionsTest.cs | 169 -- .../Framework/Results/WhenExtensionsTest.cs | 144 -- .../PubSub/EventDataToPubSubConverterTest.cs | 88 - .../Framework/UnitTesting/AgentTest.cs | 88 - .../Framework/UnitTesting/ExpectationsTest.cs | 184 -- .../Framework/UnitTesting/ValidationTest.cs | 50 - .../Validation/AbstractValidatorTest.cs | 91 - .../Validation/Clauses/DependsOnClauseTest.cs | 43 - .../Validation/Clauses/WhenClauseTest.cs | 103 - .../Validation/CommonValidatorTest.cs | 287 --- .../Validation/MultiValidatorTest.cs | 99 - .../Validation/ReferenceDataValidatorTest.cs | 111 - .../Validation/Rules/BetweenRuleTest.cs | 193 -- .../Validation/Rules/CollectionRuleTest.cs | 323 --- .../Rules/ComparePropertyRuleTest.cs | 52 - .../Validation/Rules/CompareValueRuleTest.cs | 164 -- .../Validation/Rules/CompareValuesRuleTest.cs | 94 - .../Validation/Rules/CustomRuleTest.cs | 29 - .../Validation/Rules/DecimalRuleTest.cs | 248 --- .../Validation/Rules/DictionaryRuleTest.cs | 158 -- .../Validation/Rules/DuplicateRuleTest.cs | 44 - .../Validation/Rules/EmailRuleTest.cs | 69 - .../Validation/Rules/EntityRuleTest.cs | 34 - .../Validation/Rules/EnumRuleTest.cs | 98 - .../Validation/Rules/EnumValueRuleTest.cs | 91 - .../Validation/Rules/ExistsRuleTest.cs | 102 - .../Validation/Rules/ImmutableRuleTest.cs | 44 - .../Validation/Rules/MandatoryRuleTest.cs | 158 -- .../Validation/Rules/MustRuleTest.cs | 44 - .../Validation/Rules/NoneRuleTest.cs | 87 - .../Validation/Rules/NumericRuleTest.cs | 50 - .../Validation/Rules/OverrideRuleTest.cs | 19 - .../Validation/Rules/ReferenceDataRuleTest.cs | 182 -- .../Validation/Rules/StringRuleTest.cs | 118 - .../Validation/Rules/WildcardRuleTest.cs | 44 - .../Framework/Validation/TestData.cs | 39 - .../Framework/Validation/ValidatorTest.cs | 903 -------- .../Validation/ValueValidatorTest.cs | 52 - .../Framework/WebApis/WebApiPublisherTest.cs | 567 ----- .../WebApis/WebApiRequestOptionsTest.cs | 104 - .../Framework/WebApis/WebApiTest.cs | 733 ------- .../Framework/WebApis/WebApiWithResultTest.cs | 618 ------ .../Framework/Wildcards/WildcardTest.cs | 425 ---- .../TypedHttpClientCoreHealthCheckTests.cs | 110 - .../TypedHttpClientHealthCheckTest.cs | 127 -- .../TestFunction/HttpTriggerFunctionTest.cs | 126 -- .../HttpTriggerPublishFunctionTest.cs | 160 -- ...rviceBusOrchestratedTriggerFunctionTest.cs | 144 -- .../TestFunction/ServiceBusSubsciberTest.cs | 361 ---- .../TestFunction/ServiceBusTriggerTest.cs | 188 -- tests/CoreEx.Test/appsettings.unittest.json | 7 - tests/CoreEx.Test2/CoreEx.Test2.csproj | 37 - .../Validation/ValidationExtensionsTest.cs | 34 - tests/CoreEx.Test2/GlobalUsings.cs | 1 - .../TestFunctionIso/HttpFunctionTest.cs | 19 - .../TestFunctionIso/ServiceBusTest.cs | 78 - .../Controllers/ProductController.cs | 44 - tests/CoreEx.TestApi/CoreEx.TestApi.csproj | 17 - tests/CoreEx.TestApi/Program.cs | 26 - tests/CoreEx.TestApi/Startup.cs | 67 - .../Validators/ProductValidator.cs | 14 - .../appsettings.Development.json | 9 - tests/CoreEx.TestApi/appsettings.json | 11 - .../CoreEx.TestFunction/BackendHttpClient.cs | 8 - .../CoreEx.TestFunction.csproj | 32 - .../Functions/HttpTriggerFunction.cs | 30 - .../Functions/HttpTriggerPublishFunction.cs | 26 - .../ServiceBusOrchestratedTriggerFunction.cs | 21 - .../Functions/ServiceBusTriggerFunction.cs | 29 - .../Mappers/ProductMapperProfile.cs | 17 - tests/CoreEx.TestFunction/Models/Product.cs | 51 - .../Properties/serviceDependencies.json | 11 - .../Properties/serviceDependencies.local.json | 11 - .../Services/ProductService.cs | 65 - tests/CoreEx.TestFunction/Startup.cs | 65 - .../Subscribers/NoValueSubscriber.cs | 27 - .../Subscribers/ProductSubscriber.cs | 33 - tests/CoreEx.TestFunction/TestSettings.cs | 58 - .../Validators/ProductValidator.cs | 27 - .../appsettings.unittest.json | 3 - tests/CoreEx.TestFunction/host.json | 11 - tests/CoreEx.TestFunction/local.settings.json | 16 - tests/CoreEx.TestFunctionIso/.gitignore | 264 --- .../CoreEx.TestFunctionIso.csproj | 38 - tests/CoreEx.TestFunctionIso/HttpFunction.cs | 16 - tests/CoreEx.TestFunctionIso/Program.cs | 22 - .../Properties/launchSettings.json | 9 - .../Properties/serviceDependencies.json | 11 - .../Properties/serviceDependencies.local.json | 11 - .../ServiceBusFunction.cs | 25 - tests/CoreEx.TestFunctionIso/Startup.cs | 28 - tests/CoreEx.TestFunctionIso/host.json | 12 - .../Clauses/DependsOnClauseTests.cs | 23 + .../Clauses/WhenClauseTests.cs | 25 + .../CommonValidatorTests.cs | 12 + .../CoreEx.Validation.Test.Unit.csproj | 37 + tests/CoreEx.Validation.Test.Unit/Helper.cs | 105 + .../Rules/BetweenRuleTests.cs | 52 + .../Rules/CollectionRuleTests.cs | 268 +++ .../Rules/CommonRuleTests.cs | 65 + .../Rules/ComparePropertyRuleTests.cs | 55 + .../Rules/CompareValueRuleTests.cs | 195 ++ .../Rules/CompareValuesRuleTests.cs | 78 + .../Rules/DecimalRuleTests.cs | 256 +++ .../Rules/DictionaryRuleTests.cs | 133 ++ .../Rules/EmailRuleTests.cs | 23 + .../Rules/EnumRuleTests.cs | 59 + .../Rules/ErrorRuleTests.cs | 45 + .../Rules/MandatoryRuleTests.cs | 74 + .../Rules/NullNoneEmptyRuleTests.cs | 60 + .../Rules/NumericRuleTests.cs | 117 + .../Rules/StringRuleTests.cs | 109 + .../Rules/WildcardRuleTests.cs | 20 + .../ValidatorTests.cs | 142 ++ 2028 files changed, 78114 insertions(+), 110962 deletions(-) delete mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .filenesting.json create mode 100644 .github/copilot-instructions.md delete mode 100644 .github/workflows/CI.yml delete mode 100644 .vscode/extensions.json delete mode 100644 .vscode/launch.json delete mode 100644 .vscode/settings.json delete mode 100644 .vscode/tasks.json delete mode 100644 Common.targets create mode 100644 Directory.Packages.props delete mode 100644 Docker.md delete mode 100644 docker-compose.myHr.override.yml delete mode 100644 docker-compose.myHr.yml create mode 100644 docker-compose.yml create mode 100644 gen/CoreEx.Generator/ContractGenerator.cs create mode 100644 gen/CoreEx.Generator/ContractModel.cs create mode 100644 gen/CoreEx.Generator/CoreEx.Generator.csproj create mode 100644 gen/CoreEx.Generator/GenApproach.cs create mode 100644 gen/CoreEx.Generator/Properties/launchSettings.json create mode 100644 gen/CoreEx.Generator/PropertyModel.cs create mode 100644 gen/CoreEx.Generator/ReferenceDataGenerator.cs create mode 100644 gen/CoreEx.Generator/ReferenceDataModel.cs create mode 100644 gen/CoreEx.Generator/Templates/CleanAttribute.cs.hb create mode 100644 gen/CoreEx.Generator/Templates/Contract.cs.hb create mode 100644 gen/CoreEx.Generator/Templates/ContractAttribute.cs.hb create mode 100644 gen/CoreEx.Generator/Templates/ContractIgnoreAttribute.cs.hb create mode 100644 gen/CoreEx.Generator/Templates/DateTimeAttribute.cs.hb create mode 100644 gen/CoreEx.Generator/Templates/ReferenceData.cs.hb create mode 100644 gen/CoreEx.Generator/Templates/ReferenceDataAttribute.cs.hb create mode 100644 gen/CoreEx.Generator/Templates/ReferenceDataCodeCollectionTAttribute.cs.hb create mode 100644 gen/CoreEx.Generator/Templates/ReferenceDataTAttribute.cs.hb create mode 100644 gen/CoreEx.Generator/Templates/StringAttribute.cs.hb create mode 100644 gen/CoreEx.Generator/Utility/CodeGenContext.cs create mode 100644 gen/CoreEx.Generator/Utility/HandlebarsCodeGenerator.cs create mode 100644 gen/CoreEx.Generator/Utility/HandlebarsHelpers.cs create mode 100644 gen/CoreEx.Generator/Utility/Pluralizer.cs create mode 100644 samples/Directory.Build.props delete mode 100644 samples/My.Hr/My.Hr.Api/Controllers/EmployeeController.cs delete mode 100644 samples/My.Hr/My.Hr.Api/Controllers/EmployeeResultController.cs delete mode 100644 samples/My.Hr/My.Hr.Api/Controllers/ReferenceDataController.cs delete mode 100644 samples/My.Hr/My.Hr.Api/Controllers/SwaggerController.cs delete mode 100644 samples/My.Hr/My.Hr.Api/Dockerfile delete mode 100644 samples/My.Hr/My.Hr.Api/ImplicitUsings.cs delete mode 100644 samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj delete mode 100644 samples/My.Hr/My.Hr.Api/Program.cs delete mode 100644 samples/My.Hr/My.Hr.Api/Properties/launchSettings.json delete mode 100644 samples/My.Hr/My.Hr.Api/Startup.cs delete mode 100644 samples/My.Hr/My.Hr.Api/appsettings.Development.json delete mode 100644 samples/My.Hr/My.Hr.Api/appsettings.json delete mode 100644 samples/My.Hr/My.Hr.Business/Data/Employee2Configuration.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Data/EmployeeConfiguration.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Data/HrDb.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Data/HrDbContext.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Data/HrEfDb.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Data/UsStateConfiguration.cs delete mode 100644 samples/My.Hr/My.Hr.Business/External/AgifyServiceClient.cs delete mode 100644 samples/My.Hr/My.Hr.Business/External/Contracts/AgifyResponse.cs delete mode 100644 samples/My.Hr/My.Hr.Business/External/Contracts/EmployeeVerificationRequest.cs delete mode 100644 samples/My.Hr/My.Hr.Business/External/Contracts/EmployeeVerificationResponse.cs delete mode 100644 samples/My.Hr/My.Hr.Business/External/Contracts/GenderizeResponse.cs delete mode 100644 samples/My.Hr/My.Hr.Business/External/Contracts/NationalizeResponse.cs delete mode 100644 samples/My.Hr/My.Hr.Business/External/GenderizeApiClient.cs delete mode 100644 samples/My.Hr/My.Hr.Business/External/NationalizeApiClient.cs delete mode 100644 samples/My.Hr/My.Hr.Business/HrSettings.cs delete mode 100644 samples/My.Hr/My.Hr.Business/ImplicitUsings.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Models/Employee.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Models/Employee2.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Models/Gender.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Models/UsState.cs delete mode 100644 samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj delete mode 100644 samples/My.Hr/My.Hr.Business/Services/AutoMapperProfile.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Services/EmployeeResultService.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Services/IEmployeeResultService.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Services/IEmployeeService.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Services/ReferenceDataService.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Services/VerificationService.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Validators/EmployeeValidator.cs delete mode 100644 samples/My.Hr/My.Hr.Business/Validators/EmployeeVerificationValidator.cs delete mode 100644 samples/My.Hr/My.Hr.Database/Data/RefData.yaml delete mode 100644 samples/My.Hr/My.Hr.Database/Dockerfile delete mode 100644 samples/My.Hr/My.Hr.Database/Migrations/20190101-000001-create-Hr-schema.sql delete mode 100644 samples/My.Hr/My.Hr.Database/Migrations/20200909-162702-create-Hr-Employee.sql delete mode 100644 samples/My.Hr/My.Hr.Database/Migrations/20200909-163321-create-Hr-EmergencyContact.sql delete mode 100644 samples/My.Hr/My.Hr.Database/Migrations/20200915-160812-create-Hr-PerformanceReview.sql delete mode 100644 samples/My.Hr/My.Hr.Database/Migrations/20200915-161927-create-hr-performanceoutcome.sql delete mode 100644 samples/My.Hr/My.Hr.Database/Migrations/20211208-001509-create-hr-eventoutbox-table.sql delete mode 100644 samples/My.Hr/My.Hr.Database/Migrations/20211208-001509-create-hr-eventoutboxdata-table.sql delete mode 100644 samples/My.Hr/My.Hr.Database/Migrations/20240930-132603-hr-spgetemployees.sql delete mode 100644 samples/My.Hr/My.Hr.Database/Migrations/20250127-175724-create-Hr-Employee2.sql delete mode 100644 samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj delete mode 100644 samples/My.Hr/My.Hr.Database/Program.cs delete mode 100644 samples/My.Hr/My.Hr.Database/Properties/launchSettings.json delete mode 100644 samples/My.Hr/My.Hr.Database/entrypoint.sh delete mode 100644 samples/My.Hr/My.Hr.Database/wait-for-it.sh delete mode 100644 samples/My.Hr/My.Hr.Functions/.dockerignore delete mode 100644 samples/My.Hr/My.Hr.Functions/.gitignore delete mode 100644 samples/My.Hr/My.Hr.Functions/.vscode/extensions.json delete mode 100644 samples/My.Hr/My.Hr.Functions/Dockerfile delete mode 100644 samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs delete mode 100644 samples/My.Hr/My.Hr.Functions/Functions/HttpTriggerQueueVerificationFunction.cs delete mode 100644 samples/My.Hr/My.Hr.Functions/Functions/ServiceBusExecuteVerificationFunction.cs delete mode 100644 samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj delete mode 100644 samples/My.Hr/My.Hr.Functions/MyHrApiConfigurationOptions.cs delete mode 100644 samples/My.Hr/My.Hr.Functions/README.md delete mode 100644 samples/My.Hr/My.Hr.Functions/Startup.cs delete mode 100644 samples/My.Hr/My.Hr.Functions/host.json delete mode 100644 samples/My.Hr/My.Hr.UnitTest/Data/Data.yaml delete mode 100644 samples/My.Hr/My.Hr.UnitTest/DatabaseTest.cs delete mode 100644 samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs delete mode 100644 samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs delete mode 100644 samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs delete mode 100644 samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs delete mode 100644 samples/My.Hr/My.Hr.UnitTest/HttpTriggerQueueVerificationFunctionTest.cs delete mode 100644 samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj delete mode 100644 samples/My.Hr/My.Hr.UnitTest/OneTimeSetUp.cs delete mode 100644 samples/My.Hr/My.Hr.UnitTest/ReferenceDataControllerTest.cs delete mode 100644 samples/My.Hr/My.Hr.UnitTest/Resources/VerificationResult.Unix.json delete mode 100644 samples/My.Hr/My.Hr.UnitTest/Resources/VerificationResult.Win32.json delete mode 100644 samples/My.Hr/My.Hr.UnitTest/ServiceBusExecuteVerificationFunctionTest.cs delete mode 100644 samples/My.Hr/My.Hr.UnitTest/appsettings.unittest.json delete mode 100644 samples/My.Hr/My.Hr.sln create mode 100644 samples/aspire/Contoso.Aspire/AppHost.cs create mode 100644 samples/aspire/Contoso.Aspire/Contoso.Aspire.csproj create mode 100644 samples/aspire/Contoso.Aspire/Properties/launchSettings.json create mode 100644 samples/aspire/Contoso.Aspire/appsettings.Development.json create mode 100644 samples/aspire/Contoso.Aspire/appsettings.json create mode 100644 samples/src/Contoso.Products.Api/Contoso.Products.Api.csproj create mode 100644 samples/src/Contoso.Products.Api/Contoso.Products.Api.http create mode 100644 samples/src/Contoso.Products.Api/Controllers/InventoryController.cs create mode 100644 samples/src/Contoso.Products.Api/Controllers/MovementController.cs create mode 100644 samples/src/Contoso.Products.Api/Controllers/MovementReadController.cs create mode 100644 samples/src/Contoso.Products.Api/Controllers/ProductController.cs create mode 100644 samples/src/Contoso.Products.Api/Controllers/ProductReadController.cs create mode 100644 samples/src/Contoso.Products.Api/Controllers/ReferenceDataController.cs create mode 100644 samples/src/Contoso.Products.Api/GlobalUsing.cs create mode 100644 samples/src/Contoso.Products.Api/Program.cs create mode 100644 samples/src/Contoso.Products.Api/Properties/launchSettings.json create mode 100644 samples/src/Contoso.Products.Api/appsettings.Development.json create mode 100644 samples/src/Contoso.Products.Api/appsettings.json create mode 100644 samples/src/Contoso.Products.Application/Contoso.Products.Application.csproj create mode 100644 samples/src/Contoso.Products.Application/GlobalUsing.cs create mode 100644 samples/src/Contoso.Products.Application/Interfaces/IInventoryService.cs create mode 100644 samples/src/Contoso.Products.Application/Interfaces/IMovementReadService.cs create mode 100644 samples/src/Contoso.Products.Application/Interfaces/IMovementService.cs create mode 100644 samples/src/Contoso.Products.Application/Interfaces/IProductReadService.cs create mode 100644 samples/src/Contoso.Products.Application/Interfaces/IProductService.cs create mode 100644 samples/src/Contoso.Products.Application/InventoryService.cs create mode 100644 samples/src/Contoso.Products.Application/MovementReadService.cs create mode 100644 samples/src/Contoso.Products.Application/MovementService.cs create mode 100644 samples/src/Contoso.Products.Application/ProductReadService.cs create mode 100644 samples/src/Contoso.Products.Application/ProductService.cs create mode 100644 samples/src/Contoso.Products.Application/ReferenceDataService.cs create mode 100644 samples/src/Contoso.Products.Application/Repositories/IInventoryRepository.cs create mode 100644 samples/src/Contoso.Products.Application/Repositories/IMovementRepository.cs create mode 100644 samples/src/Contoso.Products.Application/Repositories/IProductRepository.cs create mode 100644 samples/src/Contoso.Products.Application/Repositories/IReferenceDataRepository.cs create mode 100644 samples/src/Contoso.Products.Application/Validators/MovementRequestValidator.cs create mode 100644 samples/src/Contoso.Products.Application/Validators/ProductValidator.cs create mode 100644 samples/src/Contoso.Products.Contracts/Brand.cs create mode 100644 samples/src/Contoso.Products.Contracts/Category.cs create mode 100644 samples/src/Contoso.Products.Contracts/Contoso.Products.Contracts.csproj create mode 100644 samples/src/Contoso.Products.Contracts/GlobalUsing.cs create mode 100644 samples/src/Contoso.Products.Contracts/Movement.cs create mode 100644 samples/src/Contoso.Products.Contracts/MovementKind.cs create mode 100644 samples/src/Contoso.Products.Contracts/MovementRequest.cs create mode 100644 samples/src/Contoso.Products.Contracts/MovementRequestProduct.cs create mode 100644 samples/src/Contoso.Products.Contracts/MovementStatus.cs create mode 100644 samples/src/Contoso.Products.Contracts/Product.cs create mode 100644 samples/src/Contoso.Products.Contracts/ProductBase.cs create mode 100644 samples/src/Contoso.Products.Contracts/ProductLite.cs create mode 100644 samples/src/Contoso.Products.Contracts/ProductReserve.cs create mode 100644 samples/src/Contoso.Products.Contracts/SubCategory.cs create mode 100644 samples/src/Contoso.Products.Contracts/UnitOfMeasure.cs create mode 100644 samples/src/Contoso.Products.Database/Contoso.Products.Database.csproj create mode 100644 samples/src/Contoso.Products.Database/Data/ref-data.yaml create mode 100644 samples/src/Contoso.Products.Database/Migrations/20260101-000001-create-products-schema.sql rename samples/{My.Hr/My.Hr.Database/Migrations/20200909-164735-create-hr-gender.sql => src/Contoso.Products.Database/Migrations/20260101-000101-create-products-category.sql} (61%) create mode 100644 samples/src/Contoso.Products.Database/Migrations/20260101-000102-create-products-subcategory.sql create mode 100644 samples/src/Contoso.Products.Database/Migrations/20260101-000103-create-products-unitofmeasure.sql rename samples/{My.Hr/My.Hr.Database/Migrations/20200909-165752-create-hr-usstate.sql => src/Contoso.Products.Database/Migrations/20260101-000104-create-products-brand.sql} (61%) rename samples/{My.Hr/My.Hr.Database/Migrations/20200909-165308-create-hr-relationshiptype.sql => src/Contoso.Products.Database/Migrations/20260101-000105-create-products-movementkind.sql} (58%) rename samples/{My.Hr/My.Hr.Database/Migrations/20200909-164828-create-hr-terminationreason.sql => src/Contoso.Products.Database/Migrations/20260101-000106-create-products-movementstatus.sql} (58%) create mode 100644 samples/src/Contoso.Products.Database/Migrations/20260101-000201-create-products-product.sql create mode 100644 samples/src/Contoso.Products.Database/Migrations/20260101-000202-create-products-inventory.sql create mode 100644 samples/src/Contoso.Products.Database/Migrations/20260101-000203-create-products-movement.sql create mode 100644 samples/src/Contoso.Products.Database/Migrations/20260101-000301-create-products-outbox-tables.sql create mode 100644 samples/src/Contoso.Products.Database/Program.cs create mode 100644 samples/src/Contoso.Products.Database/Properties/launchSettings.json create mode 100644 samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql create mode 100644 samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql create mode 100644 samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql create mode 100644 samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql create mode 100644 samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql create mode 100644 samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql create mode 100644 samples/src/Contoso.Products.Database/dbex.yaml create mode 100644 samples/src/Contoso.Products.Infrastructure/Contoso.Products.Infrastructure.csproj create mode 100644 samples/src/Contoso.Products.Infrastructure/GlobalUsing.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Mapping/BrandMapper.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Mapping/CategoryMapper.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Mapping/MovementKindMapper.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Mapping/MovementMapper.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Mapping/MovementStatusMapper.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Mapping/ProductMapper.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Mapping/SubCategoryMapper.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Mapping/UnitOfMeasureMapper.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Persistence/Brand.g.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Persistence/Category.g.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Persistence/Inventory.g.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Persistence/Movement.g.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Persistence/MovementKind.g.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Persistence/MovementStatus.g.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Persistence/Product.g.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Persistence/SubCategory.g.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Persistence/UnitOfMeasure.g.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Repositories/DatabaseConsts.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Repositories/InventoryRepository.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Repositories/MovementRepository.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Repositories/ProductRepository.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Repositories/ProductsEfDb.cs create mode 100644 samples/src/Contoso.Products.Infrastructure/Repositories/ReferenceDataRepository.cs create mode 100644 samples/src/Contoso.Products.Outbox.Relay/Contoso.Products.Outbox.Relay.csproj create mode 100644 samples/src/Contoso.Products.Outbox.Relay/Contoso.Products.Outbox.Relay.http create mode 100644 samples/src/Contoso.Products.Outbox.Relay/Program.cs create mode 100644 samples/src/Contoso.Products.Outbox.Relay/Properties/launchSettings.json create mode 100644 samples/src/Contoso.Products.Outbox.Relay/appsettings.Development.json create mode 100644 samples/src/Contoso.Products.Outbox.Relay/appsettings.json create mode 100644 samples/src/Contoso.Products.Subscribe/Contoso.Products.Subscribe.csproj create mode 100644 samples/src/Contoso.Products.Subscribe/Contoso.Products.Subscribe.http create mode 100644 samples/src/Contoso.Products.Subscribe/GlobalUsing.cs create mode 100644 samples/src/Contoso.Products.Subscribe/Program.cs create mode 100644 samples/src/Contoso.Products.Subscribe/Properties/launchSettings.json create mode 100644 samples/src/Contoso.Products.Subscribe/Subscribers/ReservationCancelSubscriber.cs create mode 100644 samples/src/Contoso.Products.Subscribe/Subscribers/ReservationConfirmSubscriber.cs create mode 100644 samples/src/Contoso.Products.Subscribe/appsettings.Development.json create mode 100644 samples/src/Contoso.Products.Subscribe/appsettings.json create mode 100644 samples/src/Contoso.Shopping.Api/Contoso.Shopping.Api.csproj create mode 100644 samples/src/Contoso.Shopping.Api/Contoso.Shopping.Api.http create mode 100644 samples/src/Contoso.Shopping.Api/Controllers/BasketController.cs create mode 100644 samples/src/Contoso.Shopping.Api/Controllers/BasketReadController.cs create mode 100644 samples/src/Contoso.Shopping.Api/GlobalUsing.cs create mode 100644 samples/src/Contoso.Shopping.Api/Program.cs create mode 100644 samples/src/Contoso.Shopping.Api/Properties/launchSettings.json create mode 100644 samples/src/Contoso.Shopping.Api/appsettings.Development.json create mode 100644 samples/src/Contoso.Shopping.Api/appsettings.json create mode 100644 samples/src/Contoso.Shopping.Application/Adapters/Products/IProductAdapter.cs create mode 100644 samples/src/Contoso.Shopping.Application/Adapters/Products/IProductSyncAdapter.cs create mode 100644 samples/src/Contoso.Shopping.Application/Adapters/Products/Product.cs create mode 100644 samples/src/Contoso.Shopping.Application/BasketReadService.cs create mode 100644 samples/src/Contoso.Shopping.Application/BasketService.cs create mode 100644 samples/src/Contoso.Shopping.Application/Contoso.Shopping.Application.csproj create mode 100644 samples/src/Contoso.Shopping.Application/GlobalUsing.cs create mode 100644 samples/src/Contoso.Shopping.Application/Interfaces/IBasketReadService.cs create mode 100644 samples/src/Contoso.Shopping.Application/Interfaces/IBasketService.cs create mode 100644 samples/src/Contoso.Shopping.Application/Mapping/BasketMapper.cs create mode 100644 samples/src/Contoso.Shopping.Application/Policies/ProductPolicy.cs create mode 100644 samples/src/Contoso.Shopping.Application/ReferenceDataService.cs create mode 100644 samples/src/Contoso.Shopping.Application/Repositories/IBasketRepository.cs create mode 100644 samples/src/Contoso.Shopping.Application/Repositories/IReferenceDataRepository.cs create mode 100644 samples/src/Contoso.Shopping.Application/Validators/BasketItemAddRequestValidator.cs create mode 100644 samples/src/Contoso.Shopping.Application/Validators/BasketItemUpdateRequestValidator.cs create mode 100644 samples/src/Contoso.Shopping.Application/Validators/ProductValidator.cs create mode 100644 samples/src/Contoso.Shopping.Contracts/Basket.cs create mode 100644 samples/src/Contoso.Shopping.Contracts/BasketItem.cs create mode 100644 samples/src/Contoso.Shopping.Contracts/BasketItemAddRequest.cs create mode 100644 samples/src/Contoso.Shopping.Contracts/BasketItemUpdateRequest.cs create mode 100644 samples/src/Contoso.Shopping.Contracts/BasketPricing.cs create mode 100644 samples/src/Contoso.Shopping.Contracts/BasketStatus.cs create mode 100644 samples/src/Contoso.Shopping.Contracts/Contoso.Shopping.Contracts.csproj create mode 100644 samples/src/Contoso.Shopping.Contracts/DiscountCoupon.cs create mode 100644 samples/src/Contoso.Shopping.Contracts/GlobalUsing.cs create mode 100644 samples/src/Contoso.Shopping.Contracts/UnitOfMeasure.cs create mode 100644 samples/src/Contoso.Shopping.Database/Contoso.Shopping.Database.csproj create mode 100644 samples/src/Contoso.Shopping.Database/Data/ref-data.yaml create mode 100644 samples/src/Contoso.Shopping.Database/Migrations/20260101-000001-create-shopping-schema.sql create mode 100644 samples/src/Contoso.Shopping.Database/Migrations/20260101-000101-create-shopping-unitofmeasure.sql create mode 100644 samples/src/Contoso.Shopping.Database/Migrations/20260101-000102-create-shopping-discountcoupon.sql create mode 100644 samples/src/Contoso.Shopping.Database/Migrations/20260101-000103-create-shopping-basketstatus.sql create mode 100644 samples/src/Contoso.Shopping.Database/Migrations/20260101-000201-create-shopping-product.sql create mode 100644 samples/src/Contoso.Shopping.Database/Migrations/20260101-000301-create-shopping-basket.sql create mode 100644 samples/src/Contoso.Shopping.Database/Migrations/20260101-000302-create-shopping-basketitem.sql create mode 100644 samples/src/Contoso.Shopping.Database/Migrations/20260101-000401-create-shopping-outbox-tables.sql create mode 100644 samples/src/Contoso.Shopping.Database/Program.cs create mode 100644 samples/src/Contoso.Shopping.Database/Properties/launchSettings.json create mode 100644 samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql create mode 100644 samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql create mode 100644 samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql create mode 100644 samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql create mode 100644 samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql create mode 100644 samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql create mode 100644 samples/src/Contoso.Shopping.Database/dbex.yaml create mode 100644 samples/src/Contoso.Shopping.Domain/Basket.cs create mode 100644 samples/src/Contoso.Shopping.Domain/BasketItem.cs create mode 100644 samples/src/Contoso.Shopping.Domain/Contoso.Shopping.Domain.csproj create mode 100644 samples/src/Contoso.Shopping.Domain/GlobalUsing.cs create mode 100644 samples/src/Contoso.Shopping.Domain/ValueObjects/ItemPricing.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductSyncAdapter.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Clients/MovementRequest.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Clients/MovementRequestProduct.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Clients/ProductsHttpClient.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Contoso.Shopping.Infrastructure.csproj create mode 100644 samples/src/Contoso.Shopping.Infrastructure/GlobalUsing.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketIntoMapper.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketItemIntoMapper.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketMapper.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketStatusMapper.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Mapping/DiscountCouponMaper.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Mapping/ProductMapper.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Mapping/UnitOfMeasureMapper.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Persistence/BasketItem.g.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Persistence/BasketStatus.g.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Persistence/DiscountCoupon.g.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Persistence/Product.g.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Persistence/UnitOfMeasure.g.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Repositories/BasketRepository.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Repositories/ReferenceDataRepository.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingEfDb.cs create mode 100644 samples/src/Contoso.Shopping.Outbox.Relay/Contoso.Shopping.Outbox.Relay.csproj create mode 100644 samples/src/Contoso.Shopping.Outbox.Relay/Contoso.Shopping.Outbox.Relay.http create mode 100644 samples/src/Contoso.Shopping.Outbox.Relay/Program.cs create mode 100644 samples/src/Contoso.Shopping.Outbox.Relay/Properties/launchSettings.json create mode 100644 samples/src/Contoso.Shopping.Outbox.Relay/appsettings.Development.json create mode 100644 samples/src/Contoso.Shopping.Outbox.Relay/appsettings.json create mode 100644 samples/src/Contoso.Shopping.Subscribe/Contoso.Shopping.Subscribe.csproj create mode 100644 samples/src/Contoso.Shopping.Subscribe/Contoso.Shopping.Subscribe.http create mode 100644 samples/src/Contoso.Shopping.Subscribe/GlobalUsing.cs create mode 100644 samples/src/Contoso.Shopping.Subscribe/Program.cs create mode 100644 samples/src/Contoso.Shopping.Subscribe/Properties/launchSettings.json create mode 100644 samples/src/Contoso.Shopping.Subscribe/Subscribers/ProductDeleteSubscriber.cs create mode 100644 samples/src/Contoso.Shopping.Subscribe/Subscribers/ProductModifySubscriber.cs create mode 100644 samples/src/Contoso.Shopping.Subscribe/appsettings.Development.json create mode 100644 samples/src/Contoso.Shopping.Subscribe/appsettings.json create mode 100644 samples/tests/Contoso.E2E.Runner/Contoso.E2E.Runner.csproj create mode 100644 samples/tests/Contoso.E2E.Runner/GlobalUsing.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/ChoiceManager.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/ChoiceResult.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationConfig.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationRunner.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationSimulatorConfig.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/RecentEventsBuffer.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/RunnerAttribute.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/ScenarioContext.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/ScenarioRunner.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/TestContext.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Infrastructure/WorkerStatistics.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Program.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/DataSeedingSetup.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/DatabaseMigrationSetup.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/IScenario.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/ProductQuantityScenario.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/ProductQueryScenario.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/ProductUpdateScenario.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioAttribute.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioDefinition.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioManager.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioSetUpAttribute.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/SetUpManager.cs create mode 100644 samples/tests/Contoso.E2E.Runner/Scenarios/ShoppingBasketScenario.cs create mode 100644 samples/tests/Contoso.E2E.Runner/appsettings.json create mode 100644 samples/tests/Contoso.Products.Test.Api/Contoso.Products.Test.Api.csproj create mode 100644 samples/tests/Contoso.Products.Test.Api/GlobalUsing.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Adjust.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Cancel.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Confirm.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Reserve.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/MovementMutateTests.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/OtherTests.Health.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/OtherTests.ReferenceData.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/OtherTests.Swagger.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/OtherTests.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Delete.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Patch.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Update.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/ProductMutateTests.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/ReadTests.MovementQuery.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/ReadTests.ProductGet.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQtyOnHand.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQuery.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/ReadTests.cs create mode 100644 samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Adjust_Success.event.json create mode 100644 samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Cancel_Success.res.json create mode 100644 samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Confirm_Success.res.json create mode 100644 samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Reserve_Success.event.json create mode 100644 samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Create_Success.res.json create mode 100644 samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Movement_Query_Filter.res.json create mode 100644 samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Get_Found.res.json create mode 100644 samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Query_FilterBySku_IncludeFields.res.json create mode 100644 samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Query_Schema.res.json create mode 100644 samples/tests/Contoso.Products.Test.Api/appsettings.unittest.json create mode 100644 samples/tests/Contoso.Products.Test.Common/Contoso.Products.Test.Common.csproj create mode 100644 samples/tests/Contoso.Products.Test.Common/Data/data.yaml create mode 100644 samples/tests/Contoso.Products.Test.Common/TestData.cs create mode 100644 samples/tests/Contoso.Products.Test.Outbox.Relay/Contoso.Products.Test.Outbox.Relay.csproj create mode 100644 samples/tests/Contoso.Products.Test.Outbox.Relay/GlobalUsing.cs create mode 100644 samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.Health.cs create mode 100644 samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.HostedServices.cs create mode 100644 samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.cs create mode 100644 samples/tests/Contoso.Products.Test.Outbox.Relay/RelayTests.cs create mode 100644 samples/tests/Contoso.Products.Test.Outbox.Relay/Resources/ProductCreatedCloudEvent.json create mode 100644 samples/tests/Contoso.Products.Test.Outbox.Relay/Resources/ProductDeletedCloudEvent.json create mode 100644 samples/tests/Contoso.Products.Test.Outbox.Relay/appsettings.unittest.json create mode 100644 samples/tests/Contoso.Products.Test.Subscribe/Contoso.Products.Test.Subscribe.csproj create mode 100644 samples/tests/Contoso.Products.Test.Subscribe/GlobalUsing.cs create mode 100644 samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.ReservationCancel.cs create mode 100644 samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.ReservationConfirm.cs create mode 100644 samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.cs create mode 100644 samples/tests/Contoso.Products.Test.Subscribe/appsettings.unittest.json create mode 100644 samples/tests/Contoso.Products.Test.Unit/Contoso.Products.Test.Unit.csproj create mode 100644 samples/tests/Contoso.Products.Test.Unit/EntryPoint.cs create mode 100644 samples/tests/Contoso.Products.Test.Unit/GlobalUsing.cs create mode 100644 samples/tests/Contoso.Products.Test.Unit/Validators/InventoryReservationRequestValidatorTests.cs create mode 100644 samples/tests/Contoso.Products.Test.Unit/Validators/ProductValidatorTests.cs create mode 100644 samples/tests/Contoso.Shopping.Test.Api/Contoso.Shopping.Test.Api.csproj create mode 100644 samples/tests/Contoso.Shopping.Test.Api/GlobalUsing.cs create mode 100644 samples/tests/Contoso.Shopping.Test.Api/MutateTests.Basket.cs create mode 100644 samples/tests/Contoso.Shopping.Test.Api/MutateTests.BasketItem.cs create mode 100644 samples/tests/Contoso.Shopping.Test.Api/MutateTests.cs create mode 100644 samples/tests/Contoso.Shopping.Test.Api/ReadTests.Basket.cs create mode 100644 samples/tests/Contoso.Shopping.Test.Api/ReadTests.cs create mode 100644 samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Downstream_Validation_Failure.products.res.json create mode 100644 samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Insufficient_Quantity.products.res.json create mode 100644 samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Success.products.req.json create mode 100644 samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Create.res.json create mode 100644 samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Get_Found.res.json create mode 100644 samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Item_Add_Existing.res.json create mode 100644 samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Item_Add_New.res.json create mode 100644 samples/tests/Contoso.Shopping.Test.Api/appsettings.unittest.json create mode 100644 samples/tests/Contoso.Shopping.Test.Common/Contoso.Shopping.Test.Common.csproj create mode 100644 samples/tests/Contoso.Shopping.Test.Common/Data/data.yaml create mode 100644 samples/tests/Contoso.Shopping.Test.Common/TestData.cs create mode 100644 servicebus/Config.json create mode 100644 src/CoreEx.AspNetCore.NSwag/CoreEx.AspNetCore.NSwag.csproj create mode 100644 src/CoreEx.AspNetCore.NSwag/CoreExNSwagExtensions.DependencyInjection.cs create mode 100644 src/CoreEx.AspNetCore.NSwag/GlobalUsing.cs create mode 100644 src/CoreEx.AspNetCore.NSwag/NSwagOpenApiOperationProcessor.cs delete mode 100644 src/CoreEx.AspNetCore/Abstractions/AspNetCoreApplicationBuilderExtensions.cs delete mode 100644 src/CoreEx.AspNetCore/Abstractions/AspNetCoreServiceCollectionExtensions.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/IWebApiRequestOptions.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/IWebApiResponseOptions.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/IWebApiResponseOptionsT.cs delete mode 100644 src/CoreEx.AspNetCore/Abstractions/ReferenceDataOrchestratorExtensions.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApi.Delete.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApi.Get.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApi.MergePatch.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApi.Patch.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApi.Post.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApi.Put.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApi.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApiBase.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApiHeader.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApiInvoker.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApiOptionsBase.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApiPagingResult.cs create mode 100644 src/CoreEx.AspNetCore/Abstractions/WebApiResult.cs create mode 100644 src/CoreEx.AspNetCore/AspNetCoreExtensions.cs create mode 100644 src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.ApplicationBuilder.cs create mode 100644 src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.DependencyInjection.cs create mode 100644 src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.OpenTelemetry.cs create mode 100644 src/CoreEx.AspNetCore/ExceptionHandlingMiddleware.cs create mode 100644 src/CoreEx.AspNetCore/ExecutionContextMiddleware.cs create mode 100644 src/CoreEx.AspNetCore/GlobalUsing.cs create mode 100644 src/CoreEx.AspNetCore/HealthChecks/HealthCheckOptions.cs delete mode 100644 src/CoreEx.AspNetCore/HealthChecks/HealthReportStatusWriter.cs create mode 100644 src/CoreEx.AspNetCore/Http/AspNetCoreHttpExtensions.cs delete mode 100644 src/CoreEx.AspNetCore/Http/HttpRequestJsonValue.cs delete mode 100644 src/CoreEx.AspNetCore/Http/HttpRequestJsonValueBase.cs delete mode 100644 src/CoreEx.AspNetCore/Http/HttpRequestJsonValueT.cs delete mode 100644 src/CoreEx.AspNetCore/Http/HttpResultExtensions.cs create mode 100644 src/CoreEx.AspNetCore/Http/WebApi.cs create mode 100644 src/CoreEx.AspNetCore/Http/WebApiInvoker.cs create mode 100644 src/CoreEx.AspNetCore/Idempotency/HybridCacheIdempotencyProvider.cs create mode 100644 src/CoreEx.AspNetCore/Idempotency/IIdempotencyProvider.cs create mode 100644 src/CoreEx.AspNetCore/Idempotency/IdempotencyKey.Static.cs create mode 100644 src/CoreEx.AspNetCore/Idempotency/IdempotencyKey.cs create mode 100644 src/CoreEx.AspNetCore/Idempotency/IdempotencyKeyMiddleware.cs create mode 100644 src/CoreEx.AspNetCore/Idempotency/IdempotencyProviderInvoker.cs create mode 100644 src/CoreEx.AspNetCore/Idempotency/IdempotencyStatus.cs create mode 100644 src/CoreEx.AspNetCore/Mvc/AcceptsAttribute.cs create mode 100644 src/CoreEx.AspNetCore/Mvc/AcceptsAttributeT.cs create mode 100644 src/CoreEx.AspNetCore/Mvc/IdempotencyKeyAttribute.cs create mode 100644 src/CoreEx.AspNetCore/Mvc/PagingAttribute.cs create mode 100644 src/CoreEx.AspNetCore/Mvc/ProducesNotFoundProblemAttribute.cs create mode 100644 src/CoreEx.AspNetCore/Mvc/QueryAttribute.cs create mode 100644 src/CoreEx.AspNetCore/Mvc/WebApi.cs create mode 100644 src/CoreEx.AspNetCore/Mvc/WebApiInvoker.cs create mode 100644 src/CoreEx.AspNetCore/OpenApiOptions.cs delete mode 100644 src/CoreEx.AspNetCore/README.md create mode 100644 src/CoreEx.AspNetCore/WebApiOptions.cs create mode 100644 src/CoreEx.AspNetCore/WebApiRequestOptions.cs create mode 100644 src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs create mode 100644 src/CoreEx.AspNetCore/WebApiResponseOptions.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/AcceptsBodyAttribute.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/AcceptsBodyOperationFilter.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/IExtendedActionResult.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/IWebApiPublisherArgs.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/IWebApiPublisherCollectionArgs.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/PagingAttribute.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/PagingOperationFilter.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/PagingOperationFilterFields.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/QueryAttribute.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/QueryOperationFilterFields.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/ReferenceDataContentWebApi.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApi.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiBase.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiExceptionHandlerMiddleware.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiExecutionContextMiddleware.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiInvoker.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiParam.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiParamT.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiPublisher.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgs.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgsT.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgsT2.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiPublisherCancelArgs.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiPublisherCollectionArgsT.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiPublisherCollectionArgsT2.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiPublisherResultArgs.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiPublisherResultArgsT.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiPublisherStatusArgs.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs delete mode 100644 src/CoreEx.AspNetCore/WebApis/WebApiWithResult.cs delete mode 100644 src/CoreEx.AspNetCore/strong-name-key.snk delete mode 100644 src/CoreEx.AutoMapper/AutoMapperConverterWrapper.cs delete mode 100644 src/CoreEx.AutoMapper/AutoMapperExtensions.cs delete mode 100644 src/CoreEx.AutoMapper/AutoMapperProfile.cs delete mode 100644 src/CoreEx.AutoMapper/AutoMapperServiceCollectionExtensions.cs delete mode 100644 src/CoreEx.AutoMapper/AutoMapperWrapper.cs delete mode 100644 src/CoreEx.AutoMapper/Converters/AutoMapperObjectToJsonConverter.cs delete mode 100644 src/CoreEx.AutoMapper/Converters/AutoMapperReferenceDataCodeConverter.cs delete mode 100644 src/CoreEx.AutoMapper/Converters/AutoMapperReferenceDataIdConverter.cs delete mode 100644 src/CoreEx.AutoMapper/Converters/AutoMapperStringToBase64Converter.cs delete mode 100644 src/CoreEx.AutoMapper/Converters/AutoMapperTypeToStringConverter.cs delete mode 100644 src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj delete mode 100644 src/CoreEx.AutoMapper/strong-name-key.snk create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ProcessMessageEventArgsActions.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ProcessSessionMessageEventArgsActions.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusErrorClassifier.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusMessageActionsBase.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBase.Static.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBase.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBaseT.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverOptionsBase.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/CoreEx.Azure.Messaging.ServiceBus.csproj create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.DependencyInjection.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.OpenTelemetry.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/GlobalUsing.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/IServiceBusMessageActions.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/MessageProcessedEventArgs.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusExtensions.CloudEvent.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusExtensions.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusMetrics.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisher.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiver.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverHostedService.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverInvoker.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverOptions.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverResiliency.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionReceiver.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionReceiverOptions.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionStrategy.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSubscribedSubscriber.cs create mode 100644 src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSubscriberBase.cs delete mode 100644 src/CoreEx.Azure/AppConfig/Extensions.cs delete mode 100644 src/CoreEx.Azure/CoreEx.Azure.csproj delete mode 100644 src/CoreEx.Azure/README.md delete mode 100644 src/CoreEx.Azure/ServiceBus/Abstractions/ServiceBusMessageActions.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/EventDataToServiceBusConverter.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/EventSendDataToServiceBusConverter.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/HealthChecks/ServiceBusReceiverHealthCheck.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/IEventPurger.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/IServiceBusSender.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/IServiceBusSubscriber.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/IServiceCollectionExtensions.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/README.md delete mode 100644 src/CoreEx.Azure/ServiceBus/ServiceBusOrchestratedSubscriber.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/ServiceBusPurger.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/ServiceBusReceivedMessageEventDataConverter.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/ServiceBusReceiverActions.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/ServiceBusSender.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/ServiceBusSenderInvoker.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/ServiceBusSubscriber.cs delete mode 100644 src/CoreEx.Azure/ServiceBus/ServiceBusSubscriberInvoker.cs delete mode 100644 src/CoreEx.Azure/Storage/BlobAttachmentStorage.cs delete mode 100644 src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs delete mode 100644 src/CoreEx.Azure/Storage/BlobSasAttachmentStorage.cs delete mode 100644 src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs delete mode 100644 src/CoreEx.Azure/strong-name-key.snk create mode 100644 src/CoreEx.Caching.FusionCache/CoreEx.Caching.FusionCache.csproj create mode 100644 src/CoreEx.Caching.FusionCache/CoreExFusionCacheExtensions.DependencyInjection.cs create mode 100644 src/CoreEx.Caching.FusionCache/FusionCacheExtensions.cs create mode 100644 src/CoreEx.Caching.FusionCache/FusionHybridCache.cs create mode 100644 src/CoreEx.Caching.FusionCache/GlobalUsings.cs delete mode 100644 src/CoreEx.Cosmos/Batch/CosmosDbBatch.cs delete mode 100644 src/CoreEx.Cosmos/CoreEx.Cosmos.csproj delete mode 100644 src/CoreEx.Cosmos/CosmosDb.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbArgs.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbContainer.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbContainerT.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbInvoker.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbQuery.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbQueryBase.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbServiceCollectionExtensions.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbValue.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbValueContainerT.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbValueQuery.cs delete mode 100644 src/CoreEx.Cosmos/CosmosDbValueQueryableExtensions.cs delete mode 100644 src/CoreEx.Cosmos/CosmosExtensions.cs delete mode 100644 src/CoreEx.Cosmos/HealthChecks/CosmosDbHealthCheck.cs delete mode 100644 src/CoreEx.Cosmos/ICosmosDb.cs delete mode 100644 src/CoreEx.Cosmos/ICosmosDbType.cs delete mode 100644 src/CoreEx.Cosmos/ICosmosDbValue.cs delete mode 100644 src/CoreEx.Cosmos/IMultiSetValueArgs.cs delete mode 100644 src/CoreEx.Cosmos/Model/CosmosDbModelBase.cs delete mode 100644 src/CoreEx.Cosmos/Model/CosmosDbModelContainer.cs delete mode 100644 src/CoreEx.Cosmos/Model/CosmosDbModelContainerT.cs delete mode 100644 src/CoreEx.Cosmos/Model/CosmosDbModelQuery.cs delete mode 100644 src/CoreEx.Cosmos/Model/CosmosDbModelQueryBase.cs delete mode 100644 src/CoreEx.Cosmos/Model/CosmosDbValueModelContainerT.cs delete mode 100644 src/CoreEx.Cosmos/Model/CosmosDbValueModelQuery.cs delete mode 100644 src/CoreEx.Cosmos/Model/IMultiSetArgs.cs delete mode 100644 src/CoreEx.Cosmos/Model/IMultiSetModelArgs.cs delete mode 100644 src/CoreEx.Cosmos/Model/MultiSetModelCollArgs.cs delete mode 100644 src/CoreEx.Cosmos/Model/MultiSetModelSingleArgs.cs delete mode 100644 src/CoreEx.Cosmos/MultiSetValueCollArgs.cs delete mode 100644 src/CoreEx.Cosmos/MultiSetValueSingleArgs.cs delete mode 100644 src/CoreEx.Cosmos/README.md delete mode 100644 src/CoreEx.Cosmos/strong-name-key.snk create mode 100644 src/CoreEx.Data/DataResult.cs create mode 100644 src/CoreEx.Data/DataResultT.cs create mode 100644 src/CoreEx.Data/GlobalUsing.cs create mode 100644 src/CoreEx.Data/IDataArgs.cs create mode 100644 src/CoreEx.Data/IUnitOfWork.WithoutCancellation.cs create mode 100644 src/CoreEx.Data/IUnitOfWork.cs create mode 100644 src/CoreEx.Data/Models/ModelBase.cs create mode 100644 src/CoreEx.Data/Models/ReferenceDataModelBase.cs create mode 100644 src/CoreEx.Data/Querying/IQueryParseError.cs create mode 100644 src/CoreEx.Data/Querying/QueryExtensions.cs create mode 100644 src/CoreEx.Data/Querying/QueryFilterEnumFieldConfigT.cs delete mode 100644 src/CoreEx.Data/Querying/QueryFilterExtensions.cs delete mode 100644 src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs create mode 100644 src/CoreEx.Data/Querying/QueryFilterFieldType.cs create mode 100644 src/CoreEx.Data/Querying/QueryFilterParseableFieldConfigT.cs create mode 100644 src/CoreEx.Data/Querying/QueryFilterParserWriter.cs delete mode 100644 src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs create mode 100644 src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfigT.cs create mode 100644 src/CoreEx.Data/Querying/QueryFilterSchemaType.cs delete mode 100644 src/CoreEx.Data/README.md delete mode 100644 src/CoreEx.Data/strong-name-key.snk delete mode 100644 src/CoreEx.Database.MySql/CoreEx.Database.MySql.csproj delete mode 100644 src/CoreEx.Database.MySql/MySqlDatabase.cs delete mode 100644 src/CoreEx.Database.MySql/strong-name-key.snk delete mode 100644 src/CoreEx.Database.Postgres/CoreEx.Database.Postgres.csproj delete mode 100644 src/CoreEx.Database.Postgres/PostgresDatabase.cs delete mode 100644 src/CoreEx.Database.Postgres/PostgresDatabaseColumns.cs delete mode 100644 src/CoreEx.Database.Postgres/strong-name-key.snk create mode 100644 src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.ApplicationBuilder.cs create mode 100644 src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.DependencyInjection.cs create mode 100644 src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.OpenTelemetry.cs delete mode 100644 src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs create mode 100644 src/CoreEx.Database.SqlServer/Extended/SqlServerDatabaseColumns.cs create mode 100644 src/CoreEx.Database.SqlServer/Extended/SqlServerInvoker.cs create mode 100644 src/CoreEx.Database.SqlServer/Extended/SqlServerUnitOfWorkInvoker.cs create mode 100644 src/CoreEx.Database.SqlServer/GlobalUsing.cs delete mode 100644 src/CoreEx.Database.SqlServer/Outbox/EventOutboxDequeueBase.cs delete mode 100644 src/CoreEx.Database.SqlServer/Outbox/EventOutboxEnqueueBase.cs delete mode 100644 src/CoreEx.Database.SqlServer/Outbox/EventOutboxHostedService.cs delete mode 100644 src/CoreEx.Database.SqlServer/Outbox/EventOutboxService.cs create mode 100644 src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxPublisher.cs create mode 100644 src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs create mode 100644 src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelayHostedService.cs create mode 100644 src/CoreEx.Database.SqlServer/SqlServerCommand.cs create mode 100644 src/CoreEx.Database.SqlServer/SqlServerDatabase.SessionContext.cs create mode 100644 src/CoreEx.Database.SqlServer/SqlServerDatabaseArgs.cs delete mode 100644 src/CoreEx.Database.SqlServer/SqlServerDatabaseColumns.cs create mode 100644 src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs create mode 100644 src/CoreEx.Database.SqlServer/SqlServerMetrics.cs create mode 100644 src/CoreEx.Database.SqlServer/SqlServerUnitOfWork.cs delete mode 100644 src/CoreEx.Database.SqlServer/TableValuedParameter.cs delete mode 100644 src/CoreEx.Database.SqlServer/strong-name-key.snk create mode 100644 src/CoreEx.Database/Abstractions/DatabaseArgs.cs create mode 100644 src/CoreEx.Database/Abstractions/DatabaseArgsBase.cs create mode 100644 src/CoreEx.Database/Abstractions/DatabaseInvoker.cs create mode 100644 src/CoreEx.Database/Abstractions/IDatabaseParameters.cs create mode 100644 src/CoreEx.Database/DatabaseCommand.NonQuery.cs create mode 100644 src/CoreEx.Database/DatabaseCommand.Scalar.cs create mode 100644 src/CoreEx.Database/DatabaseCommand.Select.cs create mode 100644 src/CoreEx.Database/DatabaseCommand.SelectFirstSingle.cs create mode 100644 src/CoreEx.Database/DatabaseCommand.SelectMultiSet.cs create mode 100644 src/CoreEx.Database/DatabaseCommand.SelectQuery.cs create mode 100644 src/CoreEx.Database/DatabaseCommandT.cs create mode 100644 src/CoreEx.Database/DatabaseExtensions.Parameters.cs create mode 100644 src/CoreEx.Database/DatabaseExtensions.cs delete mode 100644 src/CoreEx.Database/DatabaseInvoker.cs delete mode 100644 src/CoreEx.Database/DatabaseQueryMapper.cs delete mode 100644 src/CoreEx.Database/DatabaseRecordMapper.cs delete mode 100644 src/CoreEx.Database/DatabaseServiceCollectionExtensions.cs delete mode 100644 src/CoreEx.Database/DatabaseWildcard.cs delete mode 100644 src/CoreEx.Database/Extended/DatabaseArgs.cs delete mode 100644 src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs delete mode 100644 src/CoreEx.Database/Extended/DatabaseQuery.cs create mode 100644 src/CoreEx.Database/Extended/DatabaseWildcard.cs create mode 100644 src/CoreEx.Database/Extended/IMultiSetArgs.cs create mode 100644 src/CoreEx.Database/Extended/IMultiSetArgsT.cs create mode 100644 src/CoreEx.Database/Extended/MultiSetArgs.cs create mode 100644 src/CoreEx.Database/Extended/MultiSetCollArgs.cs create mode 100644 src/CoreEx.Database/Extended/MultiSetCollArgsT.cs create mode 100644 src/CoreEx.Database/Extended/MultiSetSingleArgs.cs create mode 100644 src/CoreEx.Database/Extended/MultiSetSingleArgsT.cs delete mode 100644 src/CoreEx.Database/Extended/RefDataLoader.cs delete mode 100644 src/CoreEx.Database/Extended/RefDataMapper.cs delete mode 100644 src/CoreEx.Database/Extended/RefDataMultiSetCollArgs.cs create mode 100644 src/CoreEx.Database/GlobalUsing.cs delete mode 100644 src/CoreEx.Database/HealthChecks/DatabaseHealthCheck.cs delete mode 100644 src/CoreEx.Database/IDatabaseMapper.cs delete mode 100644 src/CoreEx.Database/IDatabaseMapperT.cs delete mode 100644 src/CoreEx.Database/IDatabaseParameters.cs delete mode 100644 src/CoreEx.Database/IDatabaseParametersExtensions.cs delete mode 100644 src/CoreEx.Database/IMultiSetArgs.cs delete mode 100644 src/CoreEx.Database/IMultiSetArgsT.cs delete mode 100644 src/CoreEx.Database/Mapping/ChangeLogDatabaseMapper.cs delete mode 100644 src/CoreEx.Database/Mapping/ChangeLogExDatabaseMapper.cs delete mode 100644 src/CoreEx.Database/Mapping/DatabaseMapperExT.cs delete mode 100644 src/CoreEx.Database/Mapping/DatabaseMapperExT2.cs delete mode 100644 src/CoreEx.Database/Mapping/DatabaseMapperT2.cs create mode 100644 src/CoreEx.Database/Mapping/IDatabaseMapper.cs delete mode 100644 src/CoreEx.Database/Mapping/IDatabaseMapperEx.cs delete mode 100644 src/CoreEx.Database/Mapping/IDatabaseMapperExT.cs delete mode 100644 src/CoreEx.Database/Mapping/IDatabaseMapperMappings.cs create mode 100644 src/CoreEx.Database/Mapping/IDatabaseMapperT.cs delete mode 100644 src/CoreEx.Database/Mapping/IPropertyColumnMapper.cs delete mode 100644 src/CoreEx.Database/Mapping/PropertyColumnMapper.cs delete mode 100644 src/CoreEx.Database/MultiSetArgs.cs delete mode 100644 src/CoreEx.Database/MultiSetCollArgs.cs delete mode 100644 src/CoreEx.Database/MultiSetCollArgsT.cs delete mode 100644 src/CoreEx.Database/MultiSetSingleArgs.cs delete mode 100644 src/CoreEx.Database/MultiSetSingleArgsT.cs create mode 100644 src/CoreEx.Database/Outbox/DatabaseOutboxPublisherBase.cs create mode 100644 src/CoreEx.Database/Outbox/DatabaseOutboxRelayArgs.cs create mode 100644 src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs create mode 100644 src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBase.cs create mode 100644 src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBaseT.cs create mode 100644 src/CoreEx.Database/Outbox/DatabaseOutboxRelayInvoker.cs create mode 100644 src/CoreEx.Database/Outbox/IDatabaseOutboxRelay.cs delete mode 100644 src/CoreEx.Database/README.md create mode 100644 src/CoreEx.Database/SqlStatement.cs create mode 100644 src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs create mode 100644 src/CoreEx.Database/Templates/EfModel_cs.hbs delete mode 100644 src/CoreEx.Database/strong-name-key.snk delete mode 100644 src/CoreEx.Dataverse/CoreEx.Dataverse.csproj delete mode 100644 src/CoreEx.Dataverse/IDataverseMapper.cs delete mode 100644 src/CoreEx.Dataverse/IDataverseMapperT.cs delete mode 100644 src/CoreEx.Dataverse/Mapping/DataverseMapper.cs delete mode 100644 src/CoreEx.Dataverse/Mapping/DataverseMapperT.cs delete mode 100644 src/CoreEx.Dataverse/Mapping/IDataverseMapperMappings.cs delete mode 100644 src/CoreEx.Dataverse/Mapping/IPropertyColumnMapper.cs delete mode 100644 src/CoreEx.Dataverse/Mapping/PropertyColumnMapper.cs delete mode 100644 src/CoreEx.Dataverse/strong-name-key.snk create mode 100644 src/CoreEx.DomainDriven/Aggregate.cs create mode 100644 src/CoreEx.DomainDriven/CoreEx.DomainDriven.csproj create mode 100644 src/CoreEx.DomainDriven/DomainDrivenExtensions.cs create mode 100644 src/CoreEx.DomainDriven/Entity.cs create mode 100644 src/CoreEx.DomainDriven/EntityBase.cs create mode 100644 src/CoreEx.DomainDriven/GlobalUsing.cs create mode 100644 src/CoreEx.DomainDriven/IAggregateRoot.cs create mode 100644 src/CoreEx.DomainDriven/IEntity.cs create mode 100644 src/CoreEx.DomainDriven/PersistenceState.cs create mode 100644 src/CoreEx.EntityFrameworkCore/Converters/JsonElementStringConverter.cs create mode 100644 src/CoreEx.EntityFrameworkCore/Converters/StringBase64Converter.cs create mode 100644 src/CoreEx.EntityFrameworkCore/Converters/ValueConverterBridge.cs create mode 100644 src/CoreEx.EntityFrameworkCore/Converters/ValueConverterBridgeT2.cs create mode 100644 src/CoreEx.EntityFrameworkCore/CoreExEfDbExtensions.DependencyInjection.cs delete mode 100644 src/CoreEx.EntityFrameworkCore/EfDbEntity.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Create.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Delete.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Get.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Update.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbMappedModel.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbModel.Create.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbModel.Delete.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbModel.Get.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbModel.Query.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbModel.Update.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs delete mode 100644 src/CoreEx.EntityFrameworkCore/EfDbModelQuery.cs create mode 100644 src/CoreEx.EntityFrameworkCore/EfDbOptions.cs delete mode 100644 src/CoreEx.EntityFrameworkCore/EfDbQuery.cs delete mode 100644 src/CoreEx.EntityFrameworkCore/EfDbQueryableExtensions.cs delete mode 100644 src/CoreEx.EntityFrameworkCore/EfDbServiceCollectionExtensions.cs create mode 100644 src/CoreEx.EntityFrameworkCore/GlobalUsing.cs delete mode 100644 src/CoreEx.EntityFrameworkCore/IEfDbEntity.cs delete mode 100644 src/CoreEx.EntityFrameworkCore/README.md delete mode 100644 src/CoreEx.EntityFrameworkCore/strong-name-key.snk create mode 100644 src/CoreEx.Events/CoreEx.Events.csproj create mode 100644 src/CoreEx.Events/CoreExEventsExtensions.DependencyInjection.cs create mode 100644 src/CoreEx.Events/CoreExEventsExtensions.OpenTelemetry.cs create mode 100644 src/CoreEx.Events/EventAction.cs create mode 100644 src/CoreEx.Events/EventData.Infra.cs create mode 100644 src/CoreEx.Events/EventData.With.cs create mode 100644 src/CoreEx.Events/EventData.cs create mode 100644 src/CoreEx.Events/EventFormatter.cs create mode 100644 src/CoreEx.Events/EventsExtensions.CloudEvent.cs create mode 100644 src/CoreEx.Events/EventsExtensions.cs create mode 100644 src/CoreEx.Events/GlobalUsing.cs create mode 100644 src/CoreEx.Events/IEventFormatter.cs create mode 100644 src/CoreEx.Events/MessageType.cs create mode 100644 src/CoreEx.Events/Publishing/DestinationEvent.cs create mode 100644 src/CoreEx.Events/Publishing/EventPublisherBase.cs create mode 100644 src/CoreEx.Events/Publishing/EventPublisherInvoker.cs create mode 100644 src/CoreEx.Events/Publishing/FixedDestinationProvider.cs create mode 100644 src/CoreEx.Events/Publishing/IDestinationProvider.cs create mode 100644 src/CoreEx.Events/Publishing/IEventPublisher.cs create mode 100644 src/CoreEx.Events/Publishing/IEventQueue.cs create mode 100644 src/CoreEx.Events/Publishing/NoOpEventPublisher.cs create mode 100644 src/CoreEx.Events/Subscribing/ErrorHandler.cs create mode 100644 src/CoreEx.Events/Subscribing/ErrorHandlerArgs.cs create mode 100644 src/CoreEx.Events/Subscribing/ErrorHandling.cs create mode 100644 src/CoreEx.Events/Subscribing/EventSubscriberArgs.cs create mode 100644 src/CoreEx.Events/Subscribing/EventSubscriberBase.cs create mode 100644 src/CoreEx.Events/Subscribing/EventSubscriberMetrics.cs create mode 100644 src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberCatastrophicException.cs create mode 100644 src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberDeadLetterException.cs create mode 100644 src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberHandledException.cs create mode 100644 src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberReceiveException.cs create mode 100644 src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberRetryException.cs create mode 100644 src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberUnhandledException.cs create mode 100644 src/CoreEx.Events/Subscribing/Exceptions/IEventSubscriberException.cs create mode 100644 src/CoreEx.Events/Subscribing/IEventSubscriberInbox.cs create mode 100644 src/CoreEx.Events/Subscribing/SubscribeAttribute.cs create mode 100644 src/CoreEx.Events/Subscribing/SubscribedBase.Static.cs create mode 100644 src/CoreEx.Events/Subscribing/SubscribedBase.cs create mode 100644 src/CoreEx.Events/Subscribing/SubscribedBaseT.cs create mode 100644 src/CoreEx.Events/Subscribing/SubscribedInvoker.cs create mode 100644 src/CoreEx.Events/Subscribing/SubscribedManager.cs delete mode 100644 src/CoreEx.FluentValidation/CoreEx.FluentValidation.csproj delete mode 100644 src/CoreEx.FluentValidation/FluentValidationExtensions.cs delete mode 100644 src/CoreEx.FluentValidation/FluentValidator.cs delete mode 100644 src/CoreEx.FluentValidation/IFluentServiceCollectionExtensions.cs delete mode 100644 src/CoreEx.FluentValidation/ReferenceDataCodeValidator.cs delete mode 100644 src/CoreEx.FluentValidation/ReferenceDataValidator.cs delete mode 100644 src/CoreEx.FluentValidation/ValidationExtensions.cs delete mode 100644 src/CoreEx.FluentValidation/ValidationResultWrapper.cs delete mode 100644 src/CoreEx.FluentValidation/ValidatorWrapper.cs delete mode 100644 src/CoreEx.FluentValidation/strong-name-key.snk delete mode 100644 src/CoreEx.Newtonsoft/CoreEx.Newtonsoft.csproj delete mode 100644 src/CoreEx.Newtonsoft/Json/CloudEventSerializer.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/CollectionResultJsonConverter.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/CompositeKeyJsonConverter.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/ContractResolver.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/EventDataSerializer.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/JsonFilterer.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/JsonPreFilterInspector.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/JsonSerializer.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/NewtonsoftServiceCollectionExtensions.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/ReferenceDataContentJsonSerializer.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/ReferenceDataJsonConverter.cs delete mode 100644 src/CoreEx.Newtonsoft/Json/SubstituteNamingStrategy.cs delete mode 100644 src/CoreEx.Newtonsoft/strong-name-key.snk delete mode 100644 src/CoreEx.OData/CoreEx.OData.csproj delete mode 100644 src/CoreEx.OData/IBoundClientExtensions.cs delete mode 100644 src/CoreEx.OData/IOData.cs delete mode 100644 src/CoreEx.OData/Mapping/IODataMapper.cs delete mode 100644 src/CoreEx.OData/Mapping/IODataMapperT.cs delete mode 100644 src/CoreEx.OData/Mapping/IPropertyColumnMapper.cs delete mode 100644 src/CoreEx.OData/Mapping/ODataMapperT.cs delete mode 100644 src/CoreEx.OData/Mapping/ODataMapping.cs delete mode 100644 src/CoreEx.OData/Mapping/PropertyColumnMapper.cs delete mode 100644 src/CoreEx.OData/ODataArgs.cs delete mode 100644 src/CoreEx.OData/ODataClient.cs delete mode 100644 src/CoreEx.OData/ODataExtensions.cs delete mode 100644 src/CoreEx.OData/ODataInvoker.cs delete mode 100644 src/CoreEx.OData/ODataItem.cs delete mode 100644 src/CoreEx.OData/ODataItemCollection.cs delete mode 100644 src/CoreEx.OData/ODataQuery.cs delete mode 100644 src/CoreEx.OData/README.md delete mode 100644 src/CoreEx.OData/strong-name-key.snk create mode 100644 src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs create mode 100644 src/CoreEx.RefData/Abstractions/ReferenceDataCore.cs create mode 100644 src/CoreEx.RefData/CoreEx.RefData.csproj create mode 100644 src/CoreEx.RefData/CoreExReferenceDataExtensions.DependencyInjection.cs create mode 100644 src/CoreEx.RefData/GlobalUsing.cs create mode 100644 src/CoreEx.RefData/HealthChecks/ReferenceDataOrchestratorHealthCheck.cs create mode 100644 src/CoreEx.RefData/ReferenceDataCodeCollection.cs create mode 100644 src/CoreEx.RefData/ReferenceDataCollectionT.cs create mode 100644 src/CoreEx.RefData/ReferenceDataCollectionT2.cs create mode 100644 src/CoreEx.RefData/ReferenceDataContext.cs create mode 100644 src/CoreEx.RefData/ReferenceDataHybridCache.TypedInvoker.cs create mode 100644 src/CoreEx.RefData/ReferenceDataHybridCache.cs create mode 100644 src/CoreEx.RefData/ReferenceDataSortOrder.cs create mode 100644 src/CoreEx.RefData/ReferenceDataT.cs create mode 100644 src/CoreEx.RefData/ReferenceDataT2.cs delete mode 100644 src/CoreEx.Solace/CoreEx.Solace.csproj delete mode 100644 src/CoreEx.Solace/PubSub/EventDataToPubSubMessageConverter.cs delete mode 100644 src/CoreEx.Solace/PubSub/EventSendDataToPubSubConverter.cs delete mode 100644 src/CoreEx.Solace/PubSub/IPubSubSender.cs delete mode 100644 src/CoreEx.Solace/PubSub/PubSubSender.cs delete mode 100644 src/CoreEx.Solace/PubSub/PubSubSenderInvoker.cs delete mode 100644 src/CoreEx.Solace/README.md delete mode 100644 src/CoreEx.Solace/strong-name-key.snk delete mode 100644 src/CoreEx.UnitTesting.Azure.Functions/Abstractions/AzureFunctionsCoreExOneOffTestSetUp.cs delete mode 100644 src/CoreEx.UnitTesting.Azure.Functions/CoreEx.UnitTesting.Azure.Functions.csproj delete mode 100644 src/CoreEx.UnitTesting.Azure.Functions/UnitTestExExtensions.cs delete mode 100644 src/CoreEx.UnitTesting.Azure.Functions/strong-name-key.snk delete mode 100644 src/CoreEx.UnitTesting.Azure.ServiceBus/Abstractions/AzureServiceBusCoreExOneOffTestSetUp.cs delete mode 100644 src/CoreEx.UnitTesting.Azure.ServiceBus/CoreEx.UnitTesting.Azure.ServiceBus.csproj delete mode 100644 src/CoreEx.UnitTesting.Azure.ServiceBus/UnitTestExExtensions.cs delete mode 100644 src/CoreEx.UnitTesting.Azure.ServiceBus/strong-name-key.snk delete mode 100644 src/CoreEx.UnitTesting/Abstractions/CoreExExtension.cs delete mode 100644 src/CoreEx.UnitTesting/Abstractions/CoreExOneOffTestSetUp.cs delete mode 100644 src/CoreEx.UnitTesting/AspNetCore/AgentTester.cs delete mode 100644 src/CoreEx.UnitTesting/AspNetCore/AgentTesterT.cs delete mode 100644 src/CoreEx.UnitTesting/Assertors/HttpResultAssertor.cs delete mode 100644 src/CoreEx.UnitTesting/Assertors/HttpResultAssertorT.cs create mode 100644 src/CoreEx.UnitTesting/Data/JsonDataReader.cs create mode 100644 src/CoreEx.UnitTesting/Data/JsonDataReaderArgs.cs create mode 100644 src/CoreEx.UnitTesting/Data/JsonDataReaderOptions.cs create mode 100644 src/CoreEx.UnitTesting/Events/EventExpecationsConfig.cs create mode 100644 src/CoreEx.UnitTesting/Events/EventExpectationAssertor.cs create mode 100644 src/CoreEx.UnitTesting/Events/EventExpectations.cs create mode 100644 src/CoreEx.UnitTesting/Events/EventPublisherDecorator.cs delete mode 100644 src/CoreEx.UnitTesting/Expectations/EventExpectations.cs delete mode 100644 src/CoreEx.UnitTesting/Expectations/ExpectedEventPublisher.cs delete mode 100644 src/CoreEx.UnitTesting/Expectations/UnitTestExExtensions.cs create mode 100644 src/CoreEx.UnitTesting/GlobalUsing.cs delete mode 100644 src/CoreEx.UnitTesting/Json/ToCoreExJsonSerializerMapper.cs delete mode 100644 src/CoreEx.UnitTesting/Json/ToUnitTestExJsonSerializerMapper.cs delete mode 100644 src/CoreEx.UnitTesting/README.md create mode 100644 src/CoreEx.UnitTesting/UnitTestExExpectations.ChangeLog.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExpectations.ETag.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExpectations.Events.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExpectations.Identifier.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExpectations.ServiceBus.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExpectations.SqlServer.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExpectations.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExtensions.Caching.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExtensions.CloudEvent.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExtensions.DbEx.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExtensions.Events.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExtensions.ServiceBus.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExtensions.SqlServer.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExExtensions.Validation.cs create mode 100644 src/CoreEx.UnitTesting/UnitTestExOneOffTestSetUp.cs delete mode 100644 src/CoreEx.UnitTesting/UsingApiTester.cs delete mode 100644 src/CoreEx.UnitTesting/strong-name-key.snk create mode 100644 src/CoreEx.Validation/AbstractValidatorT2.cs create mode 100644 src/CoreEx.Validation/Abstractions/IPropertyContext.cs create mode 100644 src/CoreEx.Validation/Abstractions/IPropertyContextT.cs create mode 100644 src/CoreEx.Validation/Abstractions/IPropertyContextT2.cs create mode 100644 src/CoreEx.Validation/Abstractions/ISelfRuntimeMetadata.cs create mode 100644 src/CoreEx.Validation/Abstractions/IValidationContext.cs create mode 100644 src/CoreEx.Validation/Abstractions/IValidationContextT.cs create mode 100644 src/CoreEx.Validation/Abstractions/IValidatorEx.cs create mode 100644 src/CoreEx.Validation/Abstractions/IValidatorExT.cs create mode 100644 src/CoreEx.Validation/Abstractions/IValueValidator.cs create mode 100644 src/CoreEx.Validation/Abstractions/InlineValidator.cs create mode 100644 src/CoreEx.Validation/Abstractions/SelfRuntimeMetadata.cs create mode 100644 src/CoreEx.Validation/Abstractions/ValidationMessageItem.cs create mode 100644 src/CoreEx.Validation/Abstractions/ValidationValue.cs create mode 100644 src/CoreEx.Validation/Abstractions/ValidatorBase.Fluent.cs create mode 100644 src/CoreEx.Validation/Abstractions/ValidatorBase.cs create mode 100644 src/CoreEx.Validation/Abstractions/ValueFormatter.cs create mode 100644 src/CoreEx.Validation/Abstractions/ValueValidator.cs create mode 100644 src/CoreEx.Validation/Clauses/IPropertyClauseT.cs create mode 100644 src/CoreEx.Validation/Clauses/IPropertyClauseT2.cs delete mode 100644 src/CoreEx.Validation/Clauses/IPropertyRuleClause.cs delete mode 100644 src/CoreEx.Validation/CommonValidator.cs create mode 100644 src/CoreEx.Validation/GlobalUsing.cs delete mode 100644 src/CoreEx.Validation/IEntityRule.cs delete mode 100644 src/CoreEx.Validation/IPropertyContext.cs delete mode 100644 src/CoreEx.Validation/IPropertyContextT.cs delete mode 100644 src/CoreEx.Validation/IPropertyRule.cs delete mode 100644 src/CoreEx.Validation/IPropertyRuleT2.cs delete mode 100644 src/CoreEx.Validation/IValidationContext.cs delete mode 100644 src/CoreEx.Validation/IValidatorEx.cs delete mode 100644 src/CoreEx.Validation/IValidatorExT.cs delete mode 100644 src/CoreEx.Validation/IncludeBaseRule.cs create mode 100644 src/CoreEx.Validation/PredicateAsync.cs delete mode 100644 src/CoreEx.Validation/PropertyRule.cs delete mode 100644 src/CoreEx.Validation/PropertyRuleBase.cs delete mode 100644 src/CoreEx.Validation/README.md delete mode 100644 src/CoreEx.Validation/ReferenceDataValidation.cs delete mode 100644 src/CoreEx.Validation/ReferenceDataValidator.cs delete mode 100644 src/CoreEx.Validation/ReferenceDataValidatorBase.cs delete mode 100644 src/CoreEx.Validation/Resources.resx delete mode 100644 src/CoreEx.Validation/RuleSet.cs delete mode 100644 src/CoreEx.Validation/Rules/CollectionRuleItem.cs delete mode 100644 src/CoreEx.Validation/Rules/CollectionRuleItemT.cs delete mode 100644 src/CoreEx.Validation/Rules/CustomRule.cs delete mode 100644 src/CoreEx.Validation/Rules/DecimalRuleHelper.cs delete mode 100644 src/CoreEx.Validation/Rules/DictionaryRuleItem.cs delete mode 100644 src/CoreEx.Validation/Rules/DictionaryRuleItemT.cs delete mode 100644 src/CoreEx.Validation/Rules/DuplicateRule.cs delete mode 100644 src/CoreEx.Validation/Rules/EntityRuleWith.cs create mode 100644 src/CoreEx.Validation/Rules/EnumStringRule.cs delete mode 100644 src/CoreEx.Validation/Rules/EnumValueRule.cs delete mode 100644 src/CoreEx.Validation/Rules/EnumValueRuleAs.cs create mode 100644 src/CoreEx.Validation/Rules/ErrorRule.cs delete mode 100644 src/CoreEx.Validation/Rules/ExistsRule.cs delete mode 100644 src/CoreEx.Validation/Rules/ICollectionRuleItem.cs delete mode 100644 src/CoreEx.Validation/Rules/IDictionaryRuleItem.cs create mode 100644 src/CoreEx.Validation/Rules/IPropertyRuleEx.cs create mode 100644 src/CoreEx.Validation/Rules/IPropertyRuleT.cs create mode 100644 src/CoreEx.Validation/Rules/IPropertyRuleT2.cs create mode 100644 src/CoreEx.Validation/Rules/IRootPropertyRuleT.cs create mode 100644 src/CoreEx.Validation/Rules/IRootPropertyRuleT2.cs delete mode 100644 src/CoreEx.Validation/Rules/IValueRule.cs delete mode 100644 src/CoreEx.Validation/Rules/ImmutableRule.cs create mode 100644 src/CoreEx.Validation/Rules/IncludeBaseRule.cs delete mode 100644 src/CoreEx.Validation/Rules/MustRule.cs delete mode 100644 src/CoreEx.Validation/Rules/NoneRule.cs delete mode 100644 src/CoreEx.Validation/Rules/NotNullRule.cs create mode 100644 src/CoreEx.Validation/Rules/NullNoneEmptyRule.cs delete mode 100644 src/CoreEx.Validation/Rules/NullRule.cs delete mode 100644 src/CoreEx.Validation/Rules/NullableEnumRule.cs delete mode 100644 src/CoreEx.Validation/Rules/OverrideRule.cs create mode 100644 src/CoreEx.Validation/Rules/PropertyRuleBase.cs create mode 100644 src/CoreEx.Validation/Rules/ReferenceDataCodeCollectionRule.cs delete mode 100644 src/CoreEx.Validation/Rules/ReferenceDataCodeRuleAs.cs delete mode 100644 src/CoreEx.Validation/Rules/ReferenceDataSidListRule.cs create mode 100644 src/CoreEx.Validation/Rules/RootPropertyRule.cs create mode 100644 src/CoreEx.Validation/Rules/RuleSet.cs delete mode 100644 src/CoreEx.Validation/Rules/ValueRuleBase.cs create mode 100644 src/CoreEx.Validation/ValidatingInlineValidator.cs create mode 100644 src/CoreEx.Validation/ValidationContext.Utility.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.BetweenRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.CollectionRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.CommonRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.ComparePropertyRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.CompareValueRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.CompareValuesRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.DecimalRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.DependsOnClause.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.DictionaryRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.EmailRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.EntityRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.EnumRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.ErrorRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.InteropRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.MandatoryRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.NullNoneEmptyRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.NumericRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.ReferenceDataRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.StringRule.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.WhenClause.cs create mode 100644 src/CoreEx.Validation/ValidationExtensions.WildcardRule.cs delete mode 100644 src/CoreEx.Validation/ValidationServiceCollectionExtensions.cs delete mode 100644 src/CoreEx.Validation/ValidationTextProvider.cs delete mode 100644 src/CoreEx.Validation/ValidationValue.cs delete mode 100644 src/CoreEx.Validation/ValidatorBase.cs create mode 100644 src/CoreEx.Validation/ValidatorT2.cs delete mode 100644 src/CoreEx.Validation/ValueValidationConfiguration.cs delete mode 100644 src/CoreEx.Validation/ValueValidator.cs delete mode 100644 src/CoreEx.Validation/ValueValidatorResult.cs delete mode 100644 src/CoreEx.Validation/strong-name-key.snk delete mode 100644 src/CoreEx/Abstractions/ETagGenerator.cs delete mode 100644 src/CoreEx/Abstractions/ErrorType.cs create mode 100644 src/CoreEx/Abstractions/ExtendedException.cs create mode 100644 src/CoreEx/Abstractions/ExtendedExceptionT.cs delete mode 100644 src/CoreEx/Abstractions/IEnumerableExtensions.cs delete mode 100644 src/CoreEx/Abstractions/IQueryableExtensions.cs delete mode 100644 src/CoreEx/Abstractions/IServiceCollectionExtensions.cs delete mode 100644 src/CoreEx/Abstractions/IUniqueKey.cs delete mode 100644 src/CoreEx/Abstractions/ObjectExtensions.cs delete mode 100644 src/CoreEx/Abstractions/README.md delete mode 100644 src/CoreEx/Abstractions/Reflection/IPropertyExpression.cs delete mode 100644 src/CoreEx/Abstractions/Reflection/IPropertyReflector.cs delete mode 100644 src/CoreEx/Abstractions/Reflection/IReflectionCache.cs delete mode 100644 src/CoreEx/Abstractions/Reflection/ITypeReflector.cs delete mode 100644 src/CoreEx/Abstractions/Reflection/PropertyExpression.cs delete mode 100644 src/CoreEx/Abstractions/Reflection/PropertyExpressionT.cs delete mode 100644 src/CoreEx/Abstractions/Reflection/PropertyReflector.cs delete mode 100644 src/CoreEx/Abstractions/Reflection/TypeReflector.cs delete mode 100644 src/CoreEx/Abstractions/Reflection/TypeReflectorArgs.cs delete mode 100644 src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs delete mode 100644 src/CoreEx/Abstractions/Reflection/TypeReflectorTypeCode.cs create mode 100644 src/CoreEx/Caching/CacheStrategy.cs create mode 100644 src/CoreEx/Caching/DefaultCacheKeyProvider.cs create mode 100644 src/CoreEx/Caching/HybridCacheEntryOptions.cs delete mode 100644 src/CoreEx/Caching/ICacheKey.cs create mode 100644 src/CoreEx/Caching/ICacheKeyProvider.cs create mode 100644 src/CoreEx/Caching/IHybridCache.cs delete mode 100644 src/CoreEx/Caching/IRequestCache.cs create mode 100644 src/CoreEx/Caching/MemoryOnlyHybridCache.cs delete mode 100644 src/CoreEx/Caching/README.md delete mode 100644 src/CoreEx/Caching/RequestCache.cs delete mode 100644 src/CoreEx/Caching/RequestCacheExtensions.cs delete mode 100644 src/CoreEx/Caching/ResultExtensions.cs delete mode 100644 src/CoreEx/Configuration/DefaultSettings.cs delete mode 100644 src/CoreEx/Configuration/DeploymentInfo.cs delete mode 100644 src/CoreEx/Configuration/README.md delete mode 100644 src/CoreEx/Configuration/SettingsBase.cs create mode 100644 src/CoreEx/CoreExExtensions.ApplicationBuilder.cs create mode 100644 src/CoreEx/CoreExExtensions.DependencyInjection.cs create mode 100644 src/CoreEx/CoreExExtensions.OpenTelemetry.cs create mode 100644 src/CoreEx/Data/DataExtensions.ITotalCount.cs create mode 100644 src/CoreEx/Data/DataExtensions.Where.cs create mode 100644 src/CoreEx/Data/DataExtensions.With.cs create mode 100644 src/CoreEx/Data/DataExtensions.cs create mode 100644 src/CoreEx/Data/IItemsResult.cs create mode 100644 src/CoreEx/Data/IItemsResultT.cs create mode 100644 src/CoreEx/Data/ILogicallyDeleted.cs create mode 100644 src/CoreEx/Data/IPartitionKey.cs create mode 100644 src/CoreEx/Data/IPrimaryKey.cs create mode 100644 src/CoreEx/Data/IReadOnlyLogicallyDeleted.cs create mode 100644 src/CoreEx/Data/IReadOnlyPartitionKey.cs create mode 100644 src/CoreEx/Data/IReadOnlyTenantId.cs create mode 100644 src/CoreEx/Data/IReadOnlyTypeDiscriminator.cs create mode 100644 src/CoreEx/Data/ITenantId.cs create mode 100644 src/CoreEx/Data/ITotalCount.cs create mode 100644 src/CoreEx/Data/ITypeDiscriminator.cs create mode 100644 src/CoreEx/Data/ItemsResultT.cs create mode 100644 src/CoreEx/Data/Model.cs create mode 100644 src/CoreEx/Data/PagingArgs.cs create mode 100644 src/CoreEx/Data/PagingResult.cs create mode 100644 src/CoreEx/Data/PartitionKey.cs create mode 100644 src/CoreEx/Data/PartitionPicker.cs create mode 100644 src/CoreEx/Data/QueryArgs.cs create mode 100644 src/CoreEx/DependencyInjection/ScopedServiceAttribute.cs create mode 100644 src/CoreEx/DependencyInjection/ScopedServiceAttributeT.cs create mode 100644 src/CoreEx/DependencyInjection/ServiceLifetimeAttribute.cs create mode 100644 src/CoreEx/DependencyInjection/SingletonServiceAttribute.cs create mode 100644 src/CoreEx/DependencyInjection/SingletonServiceAttributeT.cs create mode 100644 src/CoreEx/DependencyInjection/TransientServiceAttribute.cs create mode 100644 src/CoreEx/DependencyInjection/TransientServiceAttributeT.cs create mode 100644 src/CoreEx/Entities/Abstractions/IIdentifier.cs create mode 100644 src/CoreEx/Entities/Abstractions/IIdentifierCore.cs create mode 100644 src/CoreEx/Entities/Abstractions/IReadOnlyIdentifier.cs create mode 100644 src/CoreEx/Entities/CleanOption.cs delete mode 100644 src/CoreEx/Entities/CollectionResult.cs create mode 100644 src/CoreEx/Entities/CompositeKey.Conversion.cs create mode 100644 src/CoreEx/Entities/DataMap.cs create mode 100644 src/CoreEx/Entities/ETag.cs create mode 100644 src/CoreEx/Entities/EntitiesExtensions.FeatureSupport.cs create mode 100644 src/CoreEx/Entities/EntitiesExtensions.IEnumerable.cs delete mode 100644 src/CoreEx/Entities/EntityKeyCollection.cs delete mode 100644 src/CoreEx/Entities/Extended/ChangeLogEx.cs delete mode 100644 src/CoreEx/Entities/Extended/CollectionItemChangedEventArgs.cs delete mode 100644 src/CoreEx/Entities/Extended/CollectionItemChangedEventHandler.cs delete mode 100644 src/CoreEx/Entities/Extended/EntityBase.cs delete mode 100644 src/CoreEx/Entities/Extended/EntityBaseCollection.cs delete mode 100644 src/CoreEx/Entities/Extended/EntityBaseDictionary.cs delete mode 100644 src/CoreEx/Entities/Extended/EntityCollectionResult.cs delete mode 100644 src/CoreEx/Entities/Extended/EntityConsts.cs delete mode 100644 src/CoreEx/Entities/Extended/EntityCore.cs delete mode 100644 src/CoreEx/Entities/Extended/EntityKeyBaseCollection.cs delete mode 100644 src/CoreEx/Entities/Extended/ExtendedExtensions.cs delete mode 100644 src/CoreEx/Entities/Extended/IChangeLogEx.cs create mode 100644 src/CoreEx/Entities/Extended/ICopyFrom.cs create mode 100644 src/CoreEx/Entities/Extended/IDefault.cs delete mode 100644 src/CoreEx/Entities/Extended/IEntityBaseCollection.cs create mode 100644 src/CoreEx/Entities/Extended/IIdentifierGenerator.cs delete mode 100644 src/CoreEx/Entities/Extended/INotifyCollectionItemChanged.cs delete mode 100644 src/CoreEx/Entities/Extended/IPropertyValue.cs create mode 100644 src/CoreEx/Entities/Extended/IdentifierGenerator.cs delete mode 100644 src/CoreEx/Entities/Extended/ObservableDictionary.cs delete mode 100644 src/CoreEx/Entities/Extended/PropertyValue.cs create mode 100644 src/CoreEx/Entities/FeatureSupport.cs delete mode 100644 src/CoreEx/Entities/IChangeLogAudit.cs delete mode 100644 src/CoreEx/Entities/IChangeLogAuditLog.cs create mode 100644 src/CoreEx/Entities/IChangeLogEx.cs delete mode 100644 src/CoreEx/Entities/ICleanUp.cs delete mode 100644 src/CoreEx/Entities/ICollectionResult.cs delete mode 100644 src/CoreEx/Entities/ICollectionResultT.cs delete mode 100644 src/CoreEx/Entities/ICollectionResultT2.cs delete mode 100644 src/CoreEx/Entities/ICompositeKeyCollection.cs delete mode 100644 src/CoreEx/Entities/ICompositeKeyCollectionT.cs create mode 100644 src/CoreEx/Entities/IContract.cs create mode 100644 src/CoreEx/Entities/IContractT.cs delete mode 100644 src/CoreEx/Entities/ICopyFrom.cs delete mode 100644 src/CoreEx/Entities/IIdentifier.cs delete mode 100644 src/CoreEx/Entities/IIdentifierGenerator.cs delete mode 100644 src/CoreEx/Entities/IIdentifierGeneratorT.cs delete mode 100644 src/CoreEx/Entities/IInitial.cs delete mode 100644 src/CoreEx/Entities/ILogicallyDeleted.cs delete mode 100644 src/CoreEx/Entities/IPagingResult.cs delete mode 100644 src/CoreEx/Entities/IPartitionKey.cs delete mode 100644 src/CoreEx/Entities/IPrimaryKey.cs delete mode 100644 src/CoreEx/Entities/IReadOnly.cs create mode 100644 src/CoreEx/Entities/IReadOnlyChangeLog.cs create mode 100644 src/CoreEx/Entities/IReadOnlyChangeLogEx.cs create mode 100644 src/CoreEx/Entities/IReadOnlyETag.cs create mode 100644 src/CoreEx/Entities/IReadOnlyIdentifierT.cs delete mode 100644 src/CoreEx/Entities/ITenantId.cs create mode 100644 src/CoreEx/Entities/IValueResult.cs create mode 100644 src/CoreEx/Entities/IValueResultT.cs delete mode 100644 src/CoreEx/Entities/IdentifierGenerator.cs create mode 100644 src/CoreEx/Entities/MessageCollection.cs create mode 100644 src/CoreEx/Entities/MessageItem.Create.cs delete mode 100644 src/CoreEx/Entities/MessageItemCollection.cs delete mode 100644 src/CoreEx/Entities/PagingArgs.cs delete mode 100644 src/CoreEx/Entities/PagingOption.cs delete mode 100644 src/CoreEx/Entities/PagingResult.cs delete mode 100644 src/CoreEx/Entities/QueryArgs.cs delete mode 100644 src/CoreEx/Entities/README.md create mode 100644 src/CoreEx/Entities/ValueResult.cs create mode 100644 src/CoreEx/Entities/Writable.cs create mode 100644 src/CoreEx/Entities/WritableAttribute.cs delete mode 100644 src/CoreEx/Events/Attachments/EventAttachment.cs delete mode 100644 src/CoreEx/Events/Attachments/IAttachmentStorage.cs delete mode 100644 src/CoreEx/Events/CloudEventSerializerBase.cs delete mode 100644 src/CoreEx/Events/CustomEventSerializers.cs delete mode 100644 src/CoreEx/Events/EventData.cs delete mode 100644 src/CoreEx/Events/EventDataBase.cs delete mode 100644 src/CoreEx/Events/EventDataFormatter.cs delete mode 100644 src/CoreEx/Events/EventDataProperty.cs delete mode 100644 src/CoreEx/Events/EventDataSerializerBase.cs delete mode 100644 src/CoreEx/Events/EventDataT.cs delete mode 100644 src/CoreEx/Events/EventExtensions.cs delete mode 100644 src/CoreEx/Events/EventPublisher.cs delete mode 100644 src/CoreEx/Events/EventPublisherInvoker.cs delete mode 100644 src/CoreEx/Events/EventSendData.cs delete mode 100644 src/CoreEx/Events/EventSendException.cs delete mode 100644 src/CoreEx/Events/EventSubscriberBase.cs delete mode 100644 src/CoreEx/Events/EventSubscriberException.cs delete mode 100644 src/CoreEx/Events/HealthChecks/EventPublisherHealthCheck.cs delete mode 100644 src/CoreEx/Events/HealthChecks/EventPublisherHealthCheckOptions.cs delete mode 100644 src/CoreEx/Events/IEventConverterT.cs delete mode 100644 src/CoreEx/Events/IEventDataConverter.cs delete mode 100644 src/CoreEx/Events/IEventDataFormatter.cs delete mode 100644 src/CoreEx/Events/IEventPublisher.cs delete mode 100644 src/CoreEx/Events/IEventSender.cs delete mode 100644 src/CoreEx/Events/IEventSerializer.cs delete mode 100644 src/CoreEx/Events/InMemoryPublisher.cs delete mode 100644 src/CoreEx/Events/InMemorySender.cs delete mode 100644 src/CoreEx/Events/LoggerEventPublisher.cs delete mode 100644 src/CoreEx/Events/LoggerEventSender.cs delete mode 100644 src/CoreEx/Events/NullEventPublisher.cs delete mode 100644 src/CoreEx/Events/NullEventSender.cs delete mode 100644 src/CoreEx/Events/README.md delete mode 100644 src/CoreEx/Events/Subscribing/ErrorHandler.cs delete mode 100644 src/CoreEx/Events/Subscribing/ErrorHandlerArgs.cs delete mode 100644 src/CoreEx/Events/Subscribing/ErrorHandling.cs delete mode 100644 src/CoreEx/Events/Subscribing/EventSubscriberArgs.cs delete mode 100644 src/CoreEx/Events/Subscribing/EventSubscriberAttribute.cs delete mode 100644 src/CoreEx/Events/Subscribing/EventSubscriberExceptionSource.cs delete mode 100644 src/CoreEx/Events/Subscribing/EventSubscriberInstrumentationBase.cs delete mode 100644 src/CoreEx/Events/Subscribing/EventSubscriberInvoker.cs delete mode 100644 src/CoreEx/Events/Subscribing/EventSubscriberOrchestrator.cs delete mode 100644 src/CoreEx/Events/Subscribing/IErrorHandling.cs delete mode 100644 src/CoreEx/Events/Subscribing/IEventSubscriber.cs delete mode 100644 src/CoreEx/Events/Subscribing/IEventSubscriberInstrumentation.cs delete mode 100644 src/CoreEx/Events/Subscribing/SubscriberBase.cs delete mode 100644 src/CoreEx/Events/Subscribing/SubscriberBaseT.cs create mode 100644 src/CoreEx/ExecutionContext.Infra.cs create mode 100644 src/CoreEx/Extensions.ExtendedException.cs create mode 100644 src/CoreEx/Extensions.HttpRequestMessage.cs create mode 100644 src/CoreEx/Extensions.HttpResponseMessage.cs create mode 100644 src/CoreEx/Extensions.IEnumerable.cs create mode 100644 src/CoreEx/Extensions.IHybridCache.cs create mode 100644 src/CoreEx/Extensions.OperationType.cs create mode 100644 src/CoreEx/Extensions.cs create mode 100644 src/CoreEx/GlobalUsing.cs create mode 100644 src/CoreEx/Globalization/GlobalizationExtensions.cs delete mode 100644 src/CoreEx/Globalization/README.md delete mode 100644 src/CoreEx/Globalization/TextInfoExtensions.cs create mode 100644 src/CoreEx/HealthChecks/Extensions.cs create mode 100644 src/CoreEx/HealthChecks/HealthCheckTags.cs delete mode 100644 src/CoreEx/Hosting/ConcurrentSynchronizer.cs create mode 100644 src/CoreEx/Hosting/Extensions.ServiceStatus.cs delete mode 100644 src/CoreEx/Hosting/FileLockSynchronizer.cs delete mode 100644 src/CoreEx/Hosting/HealthChecks/TimerHostedServiceHealthCheck.cs create mode 100644 src/CoreEx/Hosting/HostSettings.cs delete mode 100644 src/CoreEx/Hosting/HostStartup.cs delete mode 100644 src/CoreEx/Hosting/HostStartupExtensions.cs create mode 100644 src/CoreEx/Hosting/HostedServiceBase.cs create mode 100644 src/CoreEx/Hosting/HostedServiceHealthCheck.cs create mode 100644 src/CoreEx/Hosting/HostedServiceInvoker.cs create mode 100644 src/CoreEx/Hosting/HostedServiceManager.cs create mode 100644 src/CoreEx/Hosting/IHostSettings.cs delete mode 100644 src/CoreEx/Hosting/IHostStartup.cs delete mode 100644 src/CoreEx/Hosting/IServiceSynchronizer.cs delete mode 100644 src/CoreEx/Hosting/README.md delete mode 100644 src/CoreEx/Hosting/ServiceBase.cs delete mode 100644 src/CoreEx/Hosting/ServiceInvoker.cs create mode 100644 src/CoreEx/Hosting/ServiceStatus.cs create mode 100644 src/CoreEx/Hosting/Synchronization/HybridCacheSynchronizer.cs create mode 100644 src/CoreEx/Hosting/Synchronization/ISynchronizer.cs delete mode 100644 src/CoreEx/Hosting/TimerHostedServiceStatus.cs delete mode 100644 src/CoreEx/Hosting/Work/FileWorkStatePersistence.cs create mode 100644 src/CoreEx/Hosting/Work/HybridCacheWorkProvider.cs create mode 100644 src/CoreEx/Hosting/Work/IWorkProvider.cs delete mode 100644 src/CoreEx/Hosting/Work/IWorkStatePersistence.cs delete mode 100644 src/CoreEx/Hosting/Work/InMemoryWorkStatePersistence.cs create mode 100644 src/CoreEx/Hosting/Work/WorkArgs.cs create mode 100644 src/CoreEx/Hosting/Work/WorkOrchestrator.cs create mode 100644 src/CoreEx/Hosting/Work/WorkOrchestratorInvoker.cs delete mode 100644 src/CoreEx/Hosting/Work/WorkStateArgs.cs delete mode 100644 src/CoreEx/Hosting/Work/WorkStateOrchestrator.cs create mode 100644 src/CoreEx/Http/Abstractions/ProblemDetails.cs delete mode 100644 src/CoreEx/Http/Extended/ITypedHttpClientOptions.cs delete mode 100644 src/CoreEx/Http/Extended/ITypedMappedHttpClient.cs delete mode 100644 src/CoreEx/Http/Extended/TypedHttpClientOptions.cs delete mode 100644 src/CoreEx/Http/Extended/TypedMappedHttpClient.cs delete mode 100644 src/CoreEx/Http/Extended/TypedMappedHttpClientBase.cs delete mode 100644 src/CoreEx/Http/Extended/TypedMappedHttpClientCore.cs delete mode 100644 src/CoreEx/Http/HealthChecks/TypedHttpClientCoreHealthCheck.cs delete mode 100644 src/CoreEx/Http/HealthChecks/TypedHttpClientHealthCheck.cs delete mode 100644 src/CoreEx/Http/HttpArg.cs delete mode 100644 src/CoreEx/Http/HttpArgType.cs delete mode 100644 src/CoreEx/Http/HttpArgs.cs delete mode 100644 src/CoreEx/Http/HttpConsts.cs delete mode 100644 src/CoreEx/Http/HttpExtensions.cs create mode 100644 src/CoreEx/Http/HttpNames.cs delete mode 100644 src/CoreEx/Http/HttpPatchOption.cs delete mode 100644 src/CoreEx/Http/HttpRequestOptions.cs delete mode 100644 src/CoreEx/Http/HttpResult.cs delete mode 100644 src/CoreEx/Http/HttpResultBase.cs delete mode 100644 src/CoreEx/Http/HttpResultT.cs delete mode 100644 src/CoreEx/Http/IHttpArg.cs delete mode 100644 src/CoreEx/Http/IHttpArgTypeArg.cs create mode 100644 src/CoreEx/Http/IdempotencyKeyHandler.cs create mode 100644 src/CoreEx/Http/ProblemDetailsException.cs delete mode 100644 src/CoreEx/Http/README.md delete mode 100644 src/CoreEx/Http/TypedHttpClient.cs delete mode 100644 src/CoreEx/Http/TypedHttpClientBase.cs delete mode 100644 src/CoreEx/Http/TypedHttpClientBaseT.cs delete mode 100644 src/CoreEx/Http/TypedHttpClientCore.cs delete mode 100644 src/CoreEx/Http/TypedHttpClientInvoker.cs delete mode 100644 src/CoreEx/ISystemTime.cs delete mode 100644 src/CoreEx/Invokers/DataInvoker.cs delete mode 100644 src/CoreEx/Invokers/DataSvcInvoker.cs delete mode 100644 src/CoreEx/Invokers/InvokeArgs.cs delete mode 100644 src/CoreEx/Invokers/InvokerArgs.cs create mode 100644 src/CoreEx/Invokers/InvokerLogger.cs create mode 100644 src/CoreEx/Invokers/InvokerNameAttribute.cs create mode 100644 src/CoreEx/Invokers/InvokerTracer.cs delete mode 100644 src/CoreEx/Invokers/ManagerInvoker.cs delete mode 100644 src/CoreEx/Invokers/README.md delete mode 100644 src/CoreEx/Invokers/ResultInvokerWith.cs delete mode 100644 src/CoreEx/Invokers/ResultInvokerWithExtensions.cs delete mode 100644 src/CoreEx/Json/Compare/JsonElementComparer.cs delete mode 100644 src/CoreEx/Json/Compare/JsonElementComparerOptions.cs delete mode 100644 src/CoreEx/Json/Compare/JsonElementComparerResult.cs delete mode 100644 src/CoreEx/Json/Compare/JsonElementComparison.cs delete mode 100644 src/CoreEx/Json/Compare/JsonElementDifference.cs delete mode 100644 src/CoreEx/Json/Compare/JsonElementDifferenceType.cs delete mode 100644 src/CoreEx/Json/Data/JsonDataReader.cs delete mode 100644 src/CoreEx/Json/Data/JsonDataReaderArgs.cs delete mode 100644 src/CoreEx/Json/IJsonPreFilterInspector.cs delete mode 100644 src/CoreEx/Json/IJsonSerializer.cs delete mode 100644 src/CoreEx/Json/IReferenceDataContentJsonSerializer.cs create mode 100644 src/CoreEx/Json/JsonDataMapConverterFactory.cs create mode 100644 src/CoreEx/Json/JsonDefaults.cs create mode 100644 src/CoreEx/Json/JsonExceptionConverterFactory.cs create mode 100644 src/CoreEx/Json/JsonFilter.cs create mode 100644 src/CoreEx/Json/JsonFilterOption.cs create mode 100644 src/CoreEx/Json/JsonMergePatch.cs create mode 100644 src/CoreEx/Json/JsonMergePatchOptions.cs create mode 100644 src/CoreEx/Json/JsonMergePatchResult.cs delete mode 100644 src/CoreEx/Json/JsonPropertyFilter.cs create mode 100644 src/CoreEx/Json/JsonReferenceDataConverter.cs delete mode 100644 src/CoreEx/Json/JsonSerializer.cs create mode 100644 src/CoreEx/Json/JsonSubstituteNamingPolicy.cs delete mode 100644 src/CoreEx/Json/JsonWriteFormat.cs delete mode 100644 src/CoreEx/Json/Mapping/IJsonObjectMapper.cs delete mode 100644 src/CoreEx/Json/Mapping/IJsonObjectMapperMappings.cs delete mode 100644 src/CoreEx/Json/Mapping/IJsonObjectMapperT.cs delete mode 100644 src/CoreEx/Json/Mapping/IPropertyJsonMapper.cs delete mode 100644 src/CoreEx/Json/Mapping/JsonObjectMapper.cs delete mode 100644 src/CoreEx/Json/Mapping/JsonObjectMapperT.cs delete mode 100644 src/CoreEx/Json/Mapping/PropertyJsonMapper.cs delete mode 100644 src/CoreEx/Json/Merge/DictionaryMergeApproach.cs delete mode 100644 src/CoreEx/Json/Merge/EntityKeyCollectionMergeApproach.cs delete mode 100644 src/CoreEx/Json/Merge/Extended/JsonMergePatchEx.cs delete mode 100644 src/CoreEx/Json/Merge/Extended/JsonMergePatchExOptions.cs delete mode 100644 src/CoreEx/Json/Merge/IJsonMergePatch.cs delete mode 100644 src/CoreEx/Json/Merge/JsonMergePatch.cs delete mode 100644 src/CoreEx/Json/Merge/JsonMergePatchException.cs delete mode 100644 src/CoreEx/Json/Merge/JsonMergePatchOptions.cs delete mode 100644 src/CoreEx/Json/README.md create mode 100644 src/CoreEx/Localization/LocalizationAttribute.cs delete mode 100644 src/CoreEx/Localization/README.md create mode 100644 src/CoreEx/Mapping/BiDirectionMapperT2.cs create mode 100644 src/CoreEx/Mapping/BiDirectionMapperT3.cs delete mode 100644 src/CoreEx/Mapping/BidirectionalMapper.cs delete mode 100644 src/CoreEx/Mapping/CollectionMapper.cs create mode 100644 src/CoreEx/Mapping/Converters/Abstractions/IDestinationConverter.cs create mode 100644 src/CoreEx/Mapping/Converters/Abstractions/ISourceConverter.cs delete mode 100644 src/CoreEx/Mapping/Converters/Converter.cs delete mode 100644 src/CoreEx/Mapping/Converters/ConverterT.cs delete mode 100644 src/CoreEx/Mapping/Converters/DateTimeToStringConverter.cs delete mode 100644 src/CoreEx/Mapping/Converters/EncodedStringToDateTimeConverter.cs delete mode 100644 src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs create mode 100644 src/CoreEx/Mapping/Converters/JsonElementStringConverter.cs delete mode 100644 src/CoreEx/Mapping/Converters/ObjectToJsonConverter.cs delete mode 100644 src/CoreEx/Mapping/Converters/ReferenceDataCodeConverter.cs delete mode 100644 src/CoreEx/Mapping/Converters/ReferenceDataIdConverter.cs create mode 100644 src/CoreEx/Mapping/Converters/StringBase64Converter.cs delete mode 100644 src/CoreEx/Mapping/Converters/StringToBase64Converter.cs delete mode 100644 src/CoreEx/Mapping/Converters/StringToTypeConverter.cs delete mode 100644 src/CoreEx/Mapping/Converters/TypeToStringConverter.cs create mode 100644 src/CoreEx/Mapping/IBiDirectionMapper.cs delete mode 100644 src/CoreEx/Mapping/IBidirectionalMapperBase.cs delete mode 100644 src/CoreEx/Mapping/IBidirectionalMapperT.cs create mode 100644 src/CoreEx/Mapping/IIntoMapper.cs create mode 100644 src/CoreEx/Mapping/IIntoMapperT.cs create mode 100644 src/CoreEx/Mapping/IntoMapperT2.cs create mode 100644 src/CoreEx/Mapping/IntoMapperT3.cs create mode 100644 src/CoreEx/Mapping/MapperExtensions.cs delete mode 100644 src/CoreEx/Mapping/MapperOptions.cs delete mode 100644 src/CoreEx/Mapping/MapperT.cs create mode 100644 src/CoreEx/Mapping/MapperT2.cs create mode 100644 src/CoreEx/Mapping/MapperT3.cs delete mode 100644 src/CoreEx/Mapping/OperationTypes.cs delete mode 100644 src/CoreEx/Mapping/README.md create mode 100644 src/CoreEx/Metadata/IPropertyRuntimeMetadata.cs create mode 100644 src/CoreEx/Metadata/IRuntimeMetadata.cs create mode 100644 src/CoreEx/Metadata/IRuntimeMetadataCore.cs create mode 100644 src/CoreEx/Metadata/PropertyRuntimeMetadata.cs create mode 100644 src/CoreEx/Metadata/PropertyRuntimeMetadataReflector.cs create mode 100644 src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs create mode 100644 src/CoreEx/Metadata/RuntimeMetadata.Clean.cs create mode 100644 src/CoreEx/Metadata/RuntimeMetadata.CopyInto.cs create mode 100644 src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs create mode 100644 src/CoreEx/Metadata/RuntimeMetadata.Internal.cs create mode 100644 src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs create mode 100644 src/CoreEx/Metadata/RuntimeMetadata.cs delete mode 100644 src/CoreEx/README.md create mode 100644 src/CoreEx/RefData/Abstractions/IReferenceData.cs create mode 100644 src/CoreEx/RefData/Abstractions/IReferenceDataCollection.cs delete mode 100644 src/CoreEx/RefData/Caching/FixedExpirationCacheEntry.cs delete mode 100644 src/CoreEx/RefData/Caching/ICacheEntryConfig.cs delete mode 100644 src/CoreEx/RefData/Caching/SettingsBasedCacheEntry.cs delete mode 100644 src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs delete mode 100644 src/CoreEx/RefData/HealthChecks/ReferenceDataOrchestratorHealthCheck.cs delete mode 100644 src/CoreEx/RefData/IReferenceData.cs create mode 100644 src/CoreEx/RefData/IReferenceDataCache.cs create mode 100644 src/CoreEx/RefData/IReferenceDataCodeCollection.cs delete mode 100644 src/CoreEx/RefData/IReferenceDataCodeList.cs delete mode 100644 src/CoreEx/RefData/IReferenceDataCollection.cs delete mode 100644 src/CoreEx/RefData/README.md delete mode 100644 src/CoreEx/RefData/ReferenceDataBase.cs delete mode 100644 src/CoreEx/RefData/ReferenceDataBaseT.cs delete mode 100644 src/CoreEx/RefData/ReferenceDataCodeList.cs delete mode 100644 src/CoreEx/RefData/ReferenceDataCollection.cs delete mode 100644 src/CoreEx/RefData/ReferenceDataCollectionBase.cs delete mode 100644 src/CoreEx/RefData/ReferenceDataContext.cs delete mode 100644 src/CoreEx/RefData/ReferenceDataFilter.cs delete mode 100644 src/CoreEx/RefData/ReferenceDataSortOrder.cs create mode 100644 src/CoreEx/Results/Abstractions/IResult.cs create mode 100644 src/CoreEx/Results/Abstractions/IResultT.cs create mode 100644 src/CoreEx/Results/Abstractions/IToResult.cs create mode 100644 src/CoreEx/Results/Abstractions/IToResultT.cs delete mode 100644 src/CoreEx/Results/AnyExtensions.cs delete mode 100644 src/CoreEx/Results/CoreExtensions.cs delete mode 100644 src/CoreEx/Results/IResult.cs delete mode 100644 src/CoreEx/Results/IResultT.cs delete mode 100644 src/CoreEx/Results/IToResult.cs delete mode 100644 src/CoreEx/Results/IToResultT.cs delete mode 100644 src/CoreEx/Results/ITypedToResult.cs delete mode 100644 src/CoreEx/Results/MatchExtensions.cs delete mode 100644 src/CoreEx/Results/OnFailureExtensions.cs delete mode 100644 src/CoreEx/Results/README.md create mode 100644 src/CoreEx/Results/Result.Error.cs create mode 100644 src/CoreEx/Results/Result.Go.cs create mode 100644 src/CoreEx/Results/Result.Static.cs delete mode 100644 src/CoreEx/Results/ResultGo.cs create mode 100644 src/CoreEx/Results/ResultT.Error.cs create mode 100644 src/CoreEx/Results/ResultT.Static.cs create mode 100644 src/CoreEx/Results/ResultsExtensions.Any.cs create mode 100644 src/CoreEx/Results/ResultsExtensions.Error.cs create mode 100644 src/CoreEx/Results/ResultsExtensions.Match.cs create mode 100644 src/CoreEx/Results/ResultsExtensions.OnFailure.cs create mode 100644 src/CoreEx/Results/ResultsExtensions.Then.cs create mode 100644 src/CoreEx/Results/ResultsExtensions.When.cs delete mode 100644 src/CoreEx/Results/ThenExtensions.cs delete mode 100644 src/CoreEx/Results/WhenExtensions.cs create mode 100644 src/CoreEx/Runtime.cs create mode 100644 src/CoreEx/Schemas/IReadOnlySchemaVersion.cs create mode 100644 src/CoreEx/Schemas/ISchemaVersion.cs create mode 100644 src/CoreEx/Schemas/Schema.cs create mode 100644 src/CoreEx/Schemas/SchemaAttribute.cs create mode 100644 src/CoreEx/Security/AuthenticationType.cs create mode 100644 src/CoreEx/Security/AuthenticationUser.cs delete mode 100644 src/CoreEx/SystemTime.cs delete mode 100644 src/CoreEx/Text/Json/CloudEventSerializer.cs delete mode 100644 src/CoreEx/Text/Json/CollectionResultConverterFactory.cs delete mode 100644 src/CoreEx/Text/Json/CompositeKeyConverterFactory.cs delete mode 100644 src/CoreEx/Text/Json/EventDataSerializer.cs delete mode 100644 src/CoreEx/Text/Json/ExceptionConverterFactory.cs delete mode 100644 src/CoreEx/Text/Json/JsonExtensions.cs delete mode 100644 src/CoreEx/Text/Json/JsonFilterer.cs delete mode 100644 src/CoreEx/Text/Json/JsonPreFilterInspector.cs delete mode 100644 src/CoreEx/Text/Json/JsonSerializer.cs delete mode 100644 src/CoreEx/Text/Json/NumberToStringConverter.cs delete mode 100644 src/CoreEx/Text/Json/ReferenceDataContentJsonSerializer.cs delete mode 100644 src/CoreEx/Text/Json/ReferenceDataConverterFactory.cs delete mode 100644 src/CoreEx/Text/Json/ReferenceDataMultiDictionaryConverterFactory.cs delete mode 100644 src/CoreEx/Text/Json/ResultConverterFactory.cs delete mode 100644 src/CoreEx/Text/Json/SubstituteNamingPolicy.cs create mode 100644 src/CoreEx/UnexpectedInternalException.cs create mode 100644 src/CoreEx/Validation/DecimalRuleHelper.cs delete mode 100644 src/CoreEx/Validation/README.md delete mode 100644 src/CoreEx/Validation/ValidationInvoker.cs create mode 100644 src/CoreEx/Validation/ValidatorExtensions.Requires.cs create mode 100644 src/CoreEx/Validation/ValidatorExtensions.cs delete mode 100644 src/CoreEx/strong-name-key.snk create mode 100644 src/Directory.Build.props create mode 100644 src/Directory.Build.targets create mode 100644 tests/CoreEx.AspNetCore.Test.Api/Controllers/OtherController.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Api/Controllers/PersonController.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Api/Controllers/ReferenceDataController.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Api/CoreEx.AspNetCore.Test.Api.csproj create mode 100644 tests/CoreEx.AspNetCore.Test.Api/CoreEx.AspNetCore.Test.Api.http create mode 100644 tests/CoreEx.AspNetCore.Test.Api/Entities/Gender.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Api/Entities/Person.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Api/Program.cs rename tests/{CoreEx.TestApi => CoreEx.AspNetCore.Test.Api}/Properties/launchSettings.json (52%) create mode 100644 tests/CoreEx.AspNetCore.Test.Api/Services/PersonService.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Api/Services/PersonService2.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Api/Services/ReferenceDataService.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Api/appsettings.Development.json create mode 100644 tests/CoreEx.AspNetCore.Test.Api/appsettings.json create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/CoreEx.AspNetCore.Test.Unit.csproj create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/OtherApiTests.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/PersonApi_HttpMutateTests.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/PersonApi_HttpQueryTests.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MvcMutateTests.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MvcQueryTests.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/PersonApi_QueryTestsBase.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_HttpTests.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_MvcTests.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_TestsBase.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/Resources/Gender_Get_All.json create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_Default.json create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_FilterError.json create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_IncludeFields.json create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_OrderByError.json create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_Paging.json create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Delete.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.DeleteWithResult.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Exceptions.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.ExceptionsWithResult.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Get.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.GetWithResult.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.MergePatch.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.MergePatchWithResult.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Patch.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PatchWithResult.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Post.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PostWithResult.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Put.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PutWithResult.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApi_HttpTests.cs create mode 100644 tests/CoreEx.AspNetCore.Test.Unit/WebApi_MvcTests.cs create mode 100644 tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/CoreEx.Azure.Messaging.ServiceBus.Test.Unit.csproj create mode 100644 tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/EntryPoint.cs create mode 100644 tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusMessageTests.cs create mode 100644 tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusPublisherTests.cs create mode 100644 tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusReceiverTests.cs create mode 100644 tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusSubscriberTests.cs create mode 100644 tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/Subscribers/ProductSubscriber.cs create mode 100644 tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/appsettings.unittest.json create mode 100644 tests/CoreEx.Caching.Redis.Test.Unit/CoreEx.Caching.Redis.Test.Unit.csproj create mode 100644 tests/CoreEx.Caching.Redis.Test.Unit/EntryPoint.cs create mode 100644 tests/CoreEx.Caching.Redis.Test.Unit/HybridCacheSynchronizerTests.cs create mode 100644 tests/CoreEx.Caching.Redis.Test.Unit/HybridCacheTests.cs create mode 100644 tests/CoreEx.Caching.Redis.Test.Unit/ReferenceDataCacheTests.cs create mode 100644 tests/CoreEx.Caching.Redis.Test.Unit/appsettings.unittest.json delete mode 100644 tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj delete mode 100644 tests/CoreEx.Cosmos.Test/CosmosDb.cs delete mode 100644 tests/CoreEx.Cosmos.Test/CosmosDbContainerAuthTest.cs delete mode 100644 tests/CoreEx.Cosmos.Test/CosmosDbContainerPartitioningTest.cs delete mode 100644 tests/CoreEx.Cosmos.Test/CosmosDbContainerTest.cs delete mode 100644 tests/CoreEx.Cosmos.Test/CosmosDbModelTest.cs delete mode 100644 tests/CoreEx.Cosmos.Test/CosmosDbQueryPartitioningTest.cs delete mode 100644 tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs delete mode 100644 tests/CoreEx.Cosmos.Test/Data/Data.yaml delete mode 100644 tests/CoreEx.Cosmos.Test/Data/RefData.yaml delete mode 100644 tests/CoreEx.Cosmos.Test/Models.cs delete mode 100644 tests/CoreEx.Cosmos.Test/TestSetUp.cs delete mode 100644 tests/CoreEx.Cosmos.Test/TestingWithEmulator.md delete mode 100644 tests/CoreEx.Cosmos.Test/Usings.cs create mode 100644 tests/CoreEx.Data.Test.Unit/CoreEx.Data.Test.Unit.csproj create mode 100644 tests/CoreEx.Data.Test.Unit/Querying/QueryFilterParserTests.cs create mode 100644 tests/CoreEx.Data.Test.Unit/Querying/QueryOrderByParserTests.cs create mode 100644 tests/CoreEx.Data.Test.Unit/Querying/TestUtility.cs create mode 100644 tests/CoreEx.Data.Test.Unit/Resources/FilterSchema.json create mode 100644 tests/CoreEx.Data.Test.Unit/Resources/FilterToString.txt create mode 100644 tests/CoreEx.Data.Test.Unit/Resources/OrderBySchema.json create mode 100644 tests/CoreEx.Data.Test.Unit/Resources/OrderByToString.txt create mode 100644 tests/CoreEx.Database.SqlServer.Test.Console/CoreEx.Database.SqlServer.Test.Console.csproj create mode 100644 tests/CoreEx.Database.SqlServer.Test.Console/Data/data.yaml create mode 100644 tests/CoreEx.Database.SqlServer.Test.Console/Migrations/001-create-test-schema.sql create mode 100644 tests/CoreEx.Database.SqlServer.Test.Console/Migrations/002-create-test-table.sql create mode 100644 tests/CoreEx.Database.SqlServer.Test.Console/Migrations/003-create-test-table-unique-index.sql create mode 100644 tests/CoreEx.Database.SqlServer.Test.Console/Program.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Console/Properties/launchSettings.json create mode 100644 tests/CoreEx.Database.SqlServer.Test.Console/database.beef-5.yaml create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDto.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDtoMapper.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/CoreEx.Database.SqlServer.Test.Unit.csproj create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTestBase.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTests.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Create.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Get.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTestsDelete.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTestsUpdate.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkQueryTests.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkUowTests.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/EntryPoint.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTable.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTableMapper.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestDbContext.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestEfDb.cs create mode 100644 tests/CoreEx.Database.SqlServer.Test.Unit/appsettings.unittest.json create mode 100644 tests/CoreEx.Database.Test.Unit/CoreEx.Database.Test.Unit.csproj create mode 100644 tests/CoreEx.Database.Test.Unit/DatabaseParameterCollectionTests.cs create mode 100644 tests/CoreEx.DomainDriven.Test.Unit/AggregateTests.cs create mode 100644 tests/CoreEx.DomainDriven.Test.Unit/CoreEx.DomainDriven.Test.Unit.csproj create mode 100644 tests/CoreEx.Events.Test.Unit/CloudEventsExtensionsTests.cs create mode 100644 tests/CoreEx.Events.Test.Unit/CoreEx.Events.Test.Unit.csproj create mode 100644 tests/CoreEx.Events.Test.Unit/EventDataTests.cs create mode 100644 tests/CoreEx.Events.Test.Unit/EventFormatterTests.cs create mode 100644 tests/CoreEx.Events.Test.Unit/GlobalSuppressions.cs create mode 100644 tests/CoreEx.Events.Test.Unit/Publishing/EventPublisherBaseTests.cs create mode 100644 tests/CoreEx.Events.Test.Unit/Publishing/FixedDestinationProviderTests.cs create mode 100644 tests/CoreEx.RefData.Test.Unit/CoreEx.RefData.Test.Unit.csproj create mode 100644 tests/CoreEx.RefData.Test.Unit/ReferenceDataCollectionTests.cs create mode 100644 tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorTests.cs create mode 100644 tests/CoreEx.RefData.Test.Unit/ReferenceDataTests.cs create mode 100644 tests/CoreEx.RefData.Test.Unit/ReferenceDataValidationTests.cs delete mode 100644 tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj delete mode 100644 tests/CoreEx.Solace.Test/PubSubOrchestratedTest.cs delete mode 100644 tests/CoreEx.Solace.Test/Usings.cs delete mode 100644 tests/CoreEx.Solace.Test/appsettings.unittest.json create mode 100644 tests/CoreEx.Test.Unit/Abstractions/ExtensionTests.cs create mode 100644 tests/CoreEx.Test.Unit/Abstractions/InternalTests.cs create mode 100644 tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj create mode 100644 tests/CoreEx.Test.Unit/Data/PagingArgsTests.cs create mode 100644 tests/CoreEx.Test.Unit/Data/PagingResultTests.cs create mode 100644 tests/CoreEx.Test.Unit/Data/PartitionKeyTests.cs create mode 100644 tests/CoreEx.Test.Unit/Data/PartitionPickerTests.cs create mode 100644 tests/CoreEx.Test.Unit/Data/QueryArgsTests.cs create mode 100644 tests/CoreEx.Test.Unit/Entities/ChangeLogTests.cs create mode 100644 tests/CoreEx.Test.Unit/Entities/CleanerTests.cs create mode 100644 tests/CoreEx.Test.Unit/Entities/CompositeKeyTests.cs create mode 100644 tests/CoreEx.Test.Unit/Entities/IdentifierTests.cs create mode 100644 tests/CoreEx.Test.Unit/Entities/MessageItemCollectionTests.cs create mode 100644 tests/CoreEx.Test.Unit/Entities/MessageItemCreateTests.cs create mode 100644 tests/CoreEx.Test.Unit/Entities/MessageItemTests.cs create mode 100644 tests/CoreEx.Test.Unit/ExceptionTests.cs create mode 100644 tests/CoreEx.Test.Unit/ExecutionContextTests.cs create mode 100644 tests/CoreEx.Test.Unit/Hosting/Work/WorkOrchestratorTests.cs create mode 100644 tests/CoreEx.Test.Unit/Http/HttpClientTests.cs create mode 100644 tests/CoreEx.Test.Unit/Json/JsonFilterTests.cs create mode 100644 tests/CoreEx.Test.Unit/Json/JsonMergeTests.cs create mode 100644 tests/CoreEx.Test.Unit/Json/JsonSubstituteNamingPolicyTests.cs create mode 100644 tests/CoreEx.Test.Unit/Localization/LTextTests.cs create mode 100644 tests/CoreEx.Test.Unit/Localization/TextProviderTests.cs create mode 100644 tests/CoreEx.Test.Unit/Mapping/Converters/StringToBase64ConverterTests.cs create mode 100644 tests/CoreEx.Test.Unit/Mapping/MapTests.cs create mode 100644 tests/CoreEx.Test.Unit/Mapping/MapperTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ExtensionsAnyTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ExtensionsMatchTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ExtensionsOnFailureTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ExtensionsTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ExtensionsThenTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ExtensionsWhenTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ResultErrorTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ResultStaticTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ResultTErrorTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ResultTTests.cs create mode 100644 tests/CoreEx.Test.Unit/Results/ResultTests.cs create mode 100644 tests/CoreEx.Test.Unit/Runtime/ReflectionMetadataTests.cs create mode 100644 tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs create mode 100644 tests/CoreEx.Test.Unit/Schemas/SchemaTests.cs create mode 100644 tests/CoreEx.Test.Unit/Text/SentenceCaseTests.cs create mode 100644 tests/CoreEx.Test.Unit/Validation/ValidationTests.cs create mode 100644 tests/CoreEx.Test.Unit/Wildcards/WildcardTests.cs delete mode 100644 tests/CoreEx.Test/CoreEx.Test.csproj delete mode 100644 tests/CoreEx.Test/Framework/Abstractions/ObjectExtensionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Abstractions/Reflection/PropertyExpressionTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Azure/ServiceBus/EventDataToServiceBusConverterTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Azure/Storage/BlobAttachmentStorageTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Azure/Storage/BlobLockSynchronizerTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Caching/RequestCacheTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Caching/ResultExtensionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Configuration/SettingsBaseTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Database/Mapping/DatabaseMapperTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Dataverse/Mapping/DataverseMapperTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Entities/CleanerTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Entities/CompositeKeyTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Entities/Extended/EntityBaseTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Entities/Extended/EntityCoreTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Entities/Extended/ObservableDictionaryTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Entities/IdentifierTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Entities/PagingArgsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Events/EventDataFormatterTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Events/EventDataPublisherTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberAttributeTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberOrchestratorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/FluentValidation/HttpRequestTest.cs delete mode 100644 tests/CoreEx.Test/Framework/FluentValidation/MultiValidatorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/FluentValidation/ServiceBusTriggerExecutorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/FluentValidation/ServiceCollectionTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Globalization/TextInfoTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Hosting/Work/WorkStateOrchestratorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Http/HttpRequestOptionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Http/HttpResultTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Http/TypedHttpClientBaseTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Http/TypedHttpClientCoreTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Http/TypedHttpClientOptionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Http/TypedMapperHttpClientBaseTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Invokers/InvokerBaseTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Invokers/InvokerTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Json/Compare/JsonElementComparerTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Json/Data/JsonDataReaderTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Json/JsonEmployeeTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Json/JsonSerializerTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Json/Mapping/JsonObjectMapperTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Json/Merge/Extended/JsonMergePatchExTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Json/Merge/JsonMergePatchTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Localization/LTextTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Mapping/Converters/ConverterTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Mapping/Converters/DateTimeToStringConverterTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Mapping/Converters/EncodedStringToDateTimeConverterTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Mapping/Converters/EncodedStringToUInt32ConverterTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Mapping/Converters/TypeToStringConverterTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Mapping/MapperTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Messaging/Azure/ServiceBus/ServiceBusSubscriberTest.cs delete mode 100644 tests/CoreEx.Test/Framework/OData/ODataTest.cs delete mode 100644 tests/CoreEx.Test/Framework/RefData/ReferenceDataTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/AnyExtensionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/MatchExtensionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/OnFailureExtensionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/ResultGoTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/ResultInvokerWithTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/ResultStateTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/ResultTTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/ResultTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/ThenExtensionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/ValidationExtensionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Results/WhenExtensionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Solace/PubSub/EventDataToPubSubConverterTest.cs delete mode 100644 tests/CoreEx.Test/Framework/UnitTesting/AgentTest.cs delete mode 100644 tests/CoreEx.Test/Framework/UnitTesting/ExpectationsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/UnitTesting/ValidationTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/AbstractValidatorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Clauses/DependsOnClauseTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Clauses/WhenClauseTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/CommonValidatorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/MultiValidatorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/ReferenceDataValidatorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/BetweenRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/CollectionRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/ComparePropertyRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/CompareValueRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/CompareValuesRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/CustomRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/DecimalRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/DictionaryRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/DuplicateRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/EmailRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/EntityRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/EnumRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/EnumValueRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/ExistsRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/ImmutableRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/MandatoryRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/MustRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/NoneRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/NumericRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/OverrideRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/ReferenceDataRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/StringRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/Rules/WildcardRuleTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/TestData.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/ValidatorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Validation/ValueValidatorTest.cs delete mode 100644 tests/CoreEx.Test/Framework/WebApis/WebApiPublisherTest.cs delete mode 100644 tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs delete mode 100644 tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs delete mode 100644 tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs delete mode 100644 tests/CoreEx.Test/Framework/Wildcards/WildcardTest.cs delete mode 100644 tests/CoreEx.Test/HealthChecks/TypedHttpClientCoreHealthCheckTests.cs delete mode 100644 tests/CoreEx.Test/HealthChecks/TypedHttpClientHealthCheckTest.cs delete mode 100644 tests/CoreEx.Test/TestFunction/HttpTriggerFunctionTest.cs delete mode 100644 tests/CoreEx.Test/TestFunction/HttpTriggerPublishFunctionTest.cs delete mode 100644 tests/CoreEx.Test/TestFunction/ServiceBusOrchestratedTriggerFunctionTest.cs delete mode 100644 tests/CoreEx.Test/TestFunction/ServiceBusSubsciberTest.cs delete mode 100644 tests/CoreEx.Test/TestFunction/ServiceBusTriggerTest.cs delete mode 100644 tests/CoreEx.Test/appsettings.unittest.json delete mode 100644 tests/CoreEx.Test2/CoreEx.Test2.csproj delete mode 100644 tests/CoreEx.Test2/Framework/Validation/ValidationExtensionsTest.cs delete mode 100644 tests/CoreEx.Test2/GlobalUsings.cs delete mode 100644 tests/CoreEx.Test2/TestFunctionIso/HttpFunctionTest.cs delete mode 100644 tests/CoreEx.Test2/TestFunctionIso/ServiceBusTest.cs delete mode 100644 tests/CoreEx.TestApi/Controllers/ProductController.cs delete mode 100644 tests/CoreEx.TestApi/CoreEx.TestApi.csproj delete mode 100644 tests/CoreEx.TestApi/Program.cs delete mode 100644 tests/CoreEx.TestApi/Startup.cs delete mode 100644 tests/CoreEx.TestApi/Validators/ProductValidator.cs delete mode 100644 tests/CoreEx.TestApi/appsettings.Development.json delete mode 100644 tests/CoreEx.TestApi/appsettings.json delete mode 100644 tests/CoreEx.TestFunction/BackendHttpClient.cs delete mode 100644 tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj delete mode 100644 tests/CoreEx.TestFunction/Functions/HttpTriggerFunction.cs delete mode 100644 tests/CoreEx.TestFunction/Functions/HttpTriggerPublishFunction.cs delete mode 100644 tests/CoreEx.TestFunction/Functions/ServiceBusOrchestratedTriggerFunction.cs delete mode 100644 tests/CoreEx.TestFunction/Functions/ServiceBusTriggerFunction.cs delete mode 100644 tests/CoreEx.TestFunction/Mappers/ProductMapperProfile.cs delete mode 100644 tests/CoreEx.TestFunction/Models/Product.cs delete mode 100644 tests/CoreEx.TestFunction/Properties/serviceDependencies.json delete mode 100644 tests/CoreEx.TestFunction/Properties/serviceDependencies.local.json delete mode 100644 tests/CoreEx.TestFunction/Services/ProductService.cs delete mode 100644 tests/CoreEx.TestFunction/Startup.cs delete mode 100644 tests/CoreEx.TestFunction/Subscribers/NoValueSubscriber.cs delete mode 100644 tests/CoreEx.TestFunction/Subscribers/ProductSubscriber.cs delete mode 100644 tests/CoreEx.TestFunction/TestSettings.cs delete mode 100644 tests/CoreEx.TestFunction/Validators/ProductValidator.cs delete mode 100644 tests/CoreEx.TestFunction/appsettings.unittest.json delete mode 100644 tests/CoreEx.TestFunction/host.json delete mode 100644 tests/CoreEx.TestFunction/local.settings.json delete mode 100644 tests/CoreEx.TestFunctionIso/.gitignore delete mode 100644 tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj delete mode 100644 tests/CoreEx.TestFunctionIso/HttpFunction.cs delete mode 100644 tests/CoreEx.TestFunctionIso/Program.cs delete mode 100644 tests/CoreEx.TestFunctionIso/Properties/launchSettings.json delete mode 100644 tests/CoreEx.TestFunctionIso/Properties/serviceDependencies.json delete mode 100644 tests/CoreEx.TestFunctionIso/Properties/serviceDependencies.local.json delete mode 100644 tests/CoreEx.TestFunctionIso/ServiceBusFunction.cs delete mode 100644 tests/CoreEx.TestFunctionIso/Startup.cs delete mode 100644 tests/CoreEx.TestFunctionIso/host.json create mode 100644 tests/CoreEx.Validation.Test.Unit/Clauses/DependsOnClauseTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Clauses/WhenClauseTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/CommonValidatorTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/CoreEx.Validation.Test.Unit.csproj create mode 100644 tests/CoreEx.Validation.Test.Unit/Helper.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/BetweenRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/CollectionRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/CommonRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/ComparePropertyRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/CompareValueRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/CompareValuesRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/DecimalRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/DictionaryRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/EmailRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/EnumRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/ErrorRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/MandatoryRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/NullNoneEmptyRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/NumericRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/StringRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/Rules/WildcardRuleTests.cs create mode 100644 tests/CoreEx.Validation.Test.Unit/ValidatorTests.cs diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index f2dfd3bc..00000000 --- a/.dockerignore +++ /dev/null @@ -1,37 +0,0 @@ -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/azds.yaml -**/bin -**/charts -**/docker-compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..6ed50a36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = false + +[*.cs] +indent_style = space +indent_size = 4 + +[*.{json,jsn,xml,yaml,yml,props,csproj,sln,sql}] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.filenesting.json b/.filenesting.json new file mode 100644 index 00000000..e21bbefa --- /dev/null +++ b/.filenesting.json @@ -0,0 +1,14 @@ +{ + "help": "https://go.microsoft.com/fwlink/?linkid=866610", + "dependentFileProviders": { + "add": { + "pathSegment": { + "add": { + ".g.cs": [ + ".cs" + ] + } + } + } + } +} \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..5d40a046 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## Project Guidelines +- All code comments should end with a period/fullstop, as they are sentences. \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml deleted file mode 100644 index 97912c21..00000000 --- a/.github/workflows/CI.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build: - runs-on: ubuntu-latest - # ubuntu-18.04 # cosmos emulator doesn't work on ubuntu 20 and 22: https://github.com/Qayme/qayme-action-cosmosdb-emulator - - services: - # cosmos: # tests fail when cosmos has less than 4 CPUs assigned, but github free runners have only 2 - # image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator - # ports: - # - 8081:8081 - # - 10251:10251 - # - 10252:10252 - # - 10253:10253 - # - 10254:10254 - # env: - # AZURE_COSMOS_EMULATOR_PARTITION_COUNT: 20 - # AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE: false - # options: -m 4g --cpus=2.0 - - sql: - image: mcr.microsoft.com/mssql/server:2019-latest - ports: - - 1433:1433 - env: - ACCEPT_EULA: Y - SA_PASSWORD: sAPWD23.^0 - - steps: - - uses: actions/checkout@v2 - - - name: Setup .NET - uses: actions/setup-dotnet@v1 - with: - dotnet-version: | - 6.0.x - 8.0.x - 9.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore - - - name: Test - run: dotnet test --filter "(TestCategory!=WithDB)&(TestCategory!=WithCosmos)&(TestCategory!=WithSolace)" --no-build --verbosity normal /p:CollectCoverage=true /p:Exclude="[CoreEx.TestFunction]*" /p:CoverletOutputFormat=lcov /p:CoverletOutput=./coverage/lcov.info - - - name: Set EnvVar for Test - run: | - echo "ConnectionStrings__Database=Data Source=localhost,1433;Initial Catalog=My.Hr;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true" >> $GITHUB_ENV - - - name: Create/Migrate DB - run: dotnet run all --project ./samples/My.Hr/My.Hr.Database --connection-varname ConnectionStrings__Database - - - name: Test With DB - run: dotnet test --filter TestCategory=WithDB --no-build --verbosity normal /p:CollectCoverage=true /p:Exclude="[CoreEx.TestFunction]*" /p:CoverletOutputFormat=lcov /p:CoverletOutput=./coverage/lcov2.info - - - name: Test With Cosmos DB TestCategory=WithCosmos - run: dotnet test --filter Category=WithCosmos --no-build --verbosity normal /p:CollectCoverage=true /p:Exclude="[CoreEx.TestFunction]*" /p:CoverletOutputFormat=lcov /p:CoverletOutput=./coverage/lcov3.info - if: ${{ false }} - - #- name: Test Docker Build - # run: docker-compose -f docker-compose.myHr.yml -f docker-compose.myHr.override.yml build --build-arg LOCAL=true diff --git a/.gitignore b/.gitignore index 3398393a..ead2755b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,7 @@ -# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig - -# Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,macos,csharp,dotnetcore,jupyternotebooks,linux,node,python -# Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,macos,csharp,dotnetcore,jupyternotebooks,linux,node,python - -### Csharp ### ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore # User-specific files *.rsuser @@ -15,6 +9,7 @@ *.user *.userosscache *.sln.docstates +*.env # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -32,12 +27,20 @@ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ bld/ [Bb]in/ [Oo]bj/ +[Oo]ut/ [Ll]og/ [Ll]ogs/ +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot @@ -49,12 +52,16 @@ Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* +*.trx # NUnit *.VisualState.xml TestResult.xml nunit-*.xml +# Approval Tests result files +*.received.* + # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ @@ -81,6 +88,7 @@ StyleCopReport.xml *.ilk *.meta *.obj +*.idb *.iobj *.pch *.pdb @@ -88,6 +96,8 @@ StyleCopReport.xml *.pgc *.pgd *.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp *.sbr *.tlb *.tli @@ -159,6 +169,7 @@ coverage*.info # NCrunch _NCrunch_* +.NCrunch_* .*crunch*.local.xml nCrunchTemp_* @@ -212,9 +223,6 @@ PublishScripts/ *.nuget.props *.nuget.targets -# Nuget personal access tokens and Credentials -nuget.config - # Microsoft Azure Build Output csx/ *.build.csdef @@ -303,6 +311,17 @@ node_modules/ # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts @@ -312,22 +331,22 @@ node_modules/ _Pvt_Extensions # Paket dependency manager -.paket/paket.exe +**/.paket/paket.exe paket-files/ # FAKE - F# Make -.fake/ +**/.fake/ # CodeRush personal settings -.cr/personal +**/.cr/personal # Python Tools for Visual Studio (PTVS) -__pycache__/ +**/__pycache__/ *.pyc # Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config +#tools/** +#!tools/packages.config # Tabs Studio *.tss @@ -349,15 +368,22 @@ ASALocalRun/ # MSBuild Binary and Structured Log *.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder -.mfractor/ +**/.mfractor/ # Local History for Visual Studio -.localhistory/ +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ # BeatPulse healthcheck temp database healthchecksdb @@ -366,7 +392,7 @@ healthchecksdb MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder -.ionide/ +**/.ionide/ # Fody - auto-generated XML schema FodyWeavers.xsd @@ -377,384 +403,17 @@ FodyWeavers.xsd !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json -*.code-workspace +!.vscode/*.code-snippets # Local History for Visual Studio Code .history/ +# Built Visual Studio Code Extensions +*.vsix + # Windows Installer files from build outputs *.cab *.msi *.msix *.msm -*.msp - -# JetBrains Rider -.idea/ -*.sln.iml - -### DotnetCore ### -# .NET Core build folders -bin/ -obj/ - -# Common node modules locations -/node_modules -/wwwroot/node_modules - -### JupyterNotebooks ### -# gitignore template for Jupyter Notebooks -# website: http://jupyter.org/ - -.ipynb_checkpoints -*/.ipynb_checkpoints/* - -# IPython -profile_default/ -ipython_config.py - -# Remove previous ipynb_checkpoints -# git rm -r .ipynb_checkpoints/ - -### Linux ### - -# temporary files which can be created if a process still has a handle open of a deleted file -.fuse_hidden* - -# KDE directory preferences -.directory - -# Linux trash folder which might appear on any partition or disk -.Trash-* - -# .nfs files are created when an open file is removed but is still being accessed -.nfs* - -### macOS ### -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### Node ### -# Logs -logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env -.env.test -.env.production - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -### Node Patch ### -# Serverless Webpack directories -.webpack/ - -### Python ### -# Byte-compiled / optimized / DLL files -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook - -# IPython - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -### VisualStudioCode ### - -# Local History for Visual Studio Code - -### VisualStudioCode Patch ### -# Ignore all local history of files -.history -.ionide - -### Windows ### -# Windows thumbnail cache files -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files - -# Windows shortcuts -*.lnk - -# End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,macos,csharp,dotnetcore,jupyternotebooks,linux,node,python - -# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) -__azurite_db_blob__.json -__azurite_db_blob_extent__.json -__blobstorage__/ -docker-compose.local.override.yml -__azurite_db_* - -# Pulumi -Pulumi.*.yaml - -# Tests -/tests/**/*.runsettings +*.msp \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index de991f40..00000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "recommendations": [ - "ms-azuretools.vscode-azurefunctions", - "ms-dotnettools.csharp" - ] -} diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 694e09e5..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": ".NET Core Launch (web)", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - "program": "${workspaceFolder}/samples/My.Hr/My.Hr.Api/bin/Debug/net6.0/My.Hr.Api.dll", - "args": [], - "cwd": "${workspaceFolder}/samples/My.Hr/My.Hr.Api", - "stopAtEntry": false, - "serverReadyAction": { - "action": "openExternally", - "pattern": "\\bNow listening on:\\s+(https?://\\S+)" - }, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "sourceFileMap": { - "/Views": "${workspaceFolder}/Views" - } - }, - { - "name": ".NET Core Attach", - "type": "coreclr", - "request": "attach" - }, - { - "name": "Attach to .NET Functions", - "type": "coreclr", - "request": "attach", - "processId": "${command:azureFunctions.pickProcess}" - } - ] -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 4261b0ce..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "azureFunctions.projectSubpath": "samples/My.Hr/My.Hr.Functions", - "azureFunctions.deploySubpath": "samples/My.Hr/My.Hr.Functions/bin/Release/net6.0/publish", - "azureFunctions.projectLanguage": "C#", - "azureFunctions.projectRuntime": "~3", - "debug.internalConsoleOptions": "neverOpen", - "azureFunctions.preDeployTask": "publish (functions)", - "cSpell.words": [ - "Agify", - "Genderize" - ] -} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index a19b4ed3..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "publish", - "command": "dotnet", - "type": "process", - "args": [ - "publish", - "${workspaceFolder}/samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "watch", - "command": "dotnet", - "type": "process", - "args": [ - "watch", - "run", - "--project", - "${workspaceFolder}/samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "clean (functions)", - "command": "dotnet", - "args": [ - "clean", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "type": "process", - "problemMatcher": "$msCompile", - "options": { - "cwd": "${workspaceFolder}/samples/My.Hr/My.Hr.Functions" - } - }, - { - "label": "build (functions)", - "command": "dotnet", - "args": [ - "build", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "type": "process", - "dependsOn": "clean (functions)", - "group": { - "kind": "build", - "isDefault": true - }, - "problemMatcher": "$msCompile", - "options": { - "cwd": "${workspaceFolder}/samples/My.Hr/My.Hr.Functions" - } - }, - { - "label": "clean release (functions)", - "command": "dotnet", - "args": [ - "clean", - "--configuration", - "Release", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "type": "process", - "problemMatcher": "$msCompile", - "options": { - "cwd": "${workspaceFolder}/samples/My.Hr/My.Hr.Functions" - } - }, - { - "label": "publish (functions)", - "command": "dotnet", - "args": [ - "publish", - "--configuration", - "Release", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "type": "process", - "dependsOn": "clean release (functions)", - "problemMatcher": "$msCompile", - "options": { - "cwd": "${workspaceFolder}/samples/My.Hr/My.Hr.Functions" - } - }, - { - "type": "func", - "dependsOn": "build (functions)", - "options": { - "cwd": "${workspaceFolder}/samples/My.Hr/My.Hr.Functions/bin/Debug/net6.0" - }, - "command": "host start", - "isBackground": true, - "problemMatcher": "$func-dotnet-watch" - } - ] -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca6b680..da69459a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,558 +2,11 @@ Represents the **NuGet** versions. -## v3.31.0 -- *Enhancement:* Moved existing reflection-based `JsonMergePatch` to `Extended.JsonMergePatchEx`; this remains the `AddJsonMergePatch` default implementation. -- *Enhancement:* Added new `JsonMergePatch` that leverages `JsonElement` and `Utf8JsonWriter` without underlying reflection; useful in scenarios where the value type is not known. This is also not as performant as the reflection-based `JsonMergePatchEx` version and the primary reason why it is not the new default. -- *Enhancement:* Refactored the `CosmosDb` capabilities such that the `CosmosDbContainer` and `CosmosDbModelContainer` are model type independent, with underlying type support implemented at the method level for greater flexibility and control. The typed `CosmosDbContainer` etc. remain and are accessed from the type independent containers as required. - - The existing `IMultiSetArgs` operations have been moved (and renamed) from `CosmosDb` to `CosmosDbContainer` and `CosmosDbModelContainer` as these are single container-specific. - - The existing `CosmosDb.UseAuthorizeFilter` operations have been moved to `CosmosDbContainer` as these are single container-specific. -- *Enhancement:* Added `Cleaner.PrepareCreate` and `Cleaner.PrepareUpdate` to encapsulate `ChangeLog.PrepareCreated` and `ChangeLog.PrepareUpdated`, and `Cleaner.ResetTenantId` to ensure consistent handling throughout _CoreEx_. -- *Enhancement:* Added `SystemTime.Timestamp` as the standard means to access the current timestamp (uses `Cleaner.Clean`) to ensure consistency throughout _CoreEx_. Therefore, the likes of `DateTime.UtcNow` should be replaced with `SystemTime.Timestamp`. The previous `ExecutionContent.SystemTime` has been removed as it was not consistent with the `ExecutionContext` pattern. -- *Enhancement:* Updated the `IExtendedException.ShouldBeLogged` implementations to check `SettingsBase` configuration to enable/disable. -- *Enhancement:* Updated dependencies to latest; including transitive where applicable. - -## v3.30.2 -- *Fixed:* Missing `QueryArgs.IncludeText` added to set the `$text=true` equivalent. -- *Fixed:* Simplification of creating and setting the `QueryArgs.Filter` using an implict string operator. - -## v3.30.1 -- *Fixed:* Added support for `SettingsBase.DateTimeTransform`, `StringTransform`, `StringTrim` and `StringCase` to allow specification via configuration. -- *Fixed:* Added support for `CoreEx:` hierarchy (optional) for all _CoreEx_ settings to enable a more structured and explicit configuration. - -## v3.30.0 -- *Enhancement:* Integrated `UnitTestEx` version `5.0.0` to enable the latest capabilities and improvements. - - `CoreEx.UnitTesting.NUnit` given changes is no longer required and has been deprecated, the `UnitTestEx.NUnit` (or other) must be explicitly referenced as per testing framework being used. - - `CoreEx.UnitTesting` package updated to include only standard .NET core capabilities to follow new `UnitTestEx` pattern; new packages created to house specific as follows: - - `CoreEx.UnitTesting.Azure.Functions` created to house Azure Functions specific capabilities; - - `CoreEx.UnitTesting.Azure.ServiceBus` created to house Azure Service Bus specific capabilities. - - Existing usage will require references to the new packages as required. There should be limited need to update existing tests to use beyond the requirement for the root `UnitTestEx` namespace. The updated default within `UnitTestEx` is to expose the key capabilities from the root namespace. For example, `using UnitTestEx.NUnit`, should be replaced with `using UnitTestEx`. - -## v3.29.0 -- *Enhancement:* Added `net9.0` support. -- *Enhancement:* Deprecated `net7.0` support; no longer supported by [Microsoft](https://dotnet.microsoft.com/en-us/platform/support/policy). -- *Enhancement:* Updated dependencies to latest; including transitive where applicable. - -## v3.28.0 -- *Enhancement:* Added extended capabilities to the `InvokeArgs` to allow additional customization. - -## v3.27.3 -- *Fixed:* The `ExecutionContext.Messages` were not being returned as intended within the `x-messages` HTTP Response header; enabled within the `ExtendedStatusCodeResult` and `ExtendedContentResult` on success only (status code `>= 200` and `<= 299`). Note these messages are JSON serialized as the underlying `MessageItemCollection` type. -- *Fixed:* The `AgentTester` has been updated to return a `HttpResultAssertor` where the operation returns a `HttpResult` to enable further assertions to be made on the `Result` itself. - -## v3.27.2 -- *Fixed:* The `IServiceCollection.AddCosmosDb` extension method was registering as a singleton; this has been corrected to register as scoped. The dependent `CosmosClient` should remain a singleton as is [best practice](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/best-practice-dotnet). - -## v3.27.1 -- *Fixed:* Updated `Microsoft.Extensions.Caching.Memory` package depenedency to latest (including related); resolve [Microsoft Security Advisory CVE-2024-43483](https://github.com/advisories/GHSA-qj66-m88j-hmgj). -- *Fixed:* Fixed the `ExecutionContext.UserIsAuthorized` to have base implementation similar to `UserIsInRole`. -- *Fixed:* Rationalize the `UtcNow` usage to be consistent, where applicable `ExecutionContext.SystemTime.UtcNow` is leveraged. - -## v3.27.0 -- *Fixed:* The `ValueContentResult.TryCreateValueContentResult` would return `NotModified` where the request `ETag` was `null`; this has been corrected to return `OK` with the resulting `value`. -- *Fixed:* The `ValueContentResult.TryCreateValueContentResult` now returns `ExtendedStatusCodeResult` versus `StatusCodeResult` as this offers additional capabilities where required. -- *Enhancement:* The `ExtendedStatusCodeResult` and `ExtendedContentResult` now implement `IExtendedActionResult` to standardize access to the `BeforeExtension` and `AfterExtension` functions. -- *Enhancement:* Added `WebApiParam.CreateActionResult` helper methods to enable execution of the underlying `ValueContentResult.CreateValueContentResult` (which is no longer public as this was always intended as internal only). -- *Fixed:* `PostgresDatabase.OnDbException` corrected to use `PostgresException.MessageText` versus `Message` as it does not include the `SQLSTATE` code. -- *Enhancement:* Improve debugging insights by adding `ILogger.LogDebug` start/stop/elapsed for the `InvokerArgs`. -- *Fixed*: Updated `System.Text.Json` package depenedency to latest (including related); resolve [Microsoft Security Advisory CVE-2024-43485](https://github.com/advisories/GHSA-8g4q-xg66-9fp4). - -## v3.26.0 -- *Enhancement:* Enable JSON serialization of database parameter values; added `DatabaseParameterCollection.AddJsonParameter` method and associated `JsonParam`, `JsonParamWhen` and `JsonParamWith` extension methods. -- *Enhancement:* Updated (simplified) `EventOutboxEnqueueBase` to pass events to the underlying stored procedures as JSON versus existing TVP removing database dependency on a UDT (user-defined type). -- **Note:** Accidently published as `v3.25.6`, re-publishing as `v3.26.0` as intended - includes no code changes. - -## v3.25.5 -- *Fixed:* Fixed the unit testing `CreateServiceBusMessage` extension method so that it no longer invokes a `TesterBase.ResetHost` (this reset should now be invoked explicitly by the developer as required). - -## v3.25.4 -- *Fixed*: Fixed the `InvalidOperationException` with a 'Sequence contains no elements' when performing validation with the `CompareValuesRule` that has the `OverrideValue` set. -- *Fixed:* Updated all dependencies to latest versions. - -## v3.25.3 -- *Fixed:* Added function parameter support for `WithDefault()` to enable runtime execution of the default statement where required for the query filter capabilities. - -## v3.25.2 -- *Fixed:* `HttpRequestOptions.WithQuery` fixed to ensure any previously set `Include` and `Exclude` fields are not lost (results in a merge); i.e. only the `Filter` and `OrderBy` properties are explicitly overridden. - -## v3.25.1 -- *Fixed:* Extend `QueryFilterFieldConfigBase` to include `AsNullable()` to specifiy whether the field supports `null`. -- *Fixed:* Extend `QueryFilterFieldConfigBase` to include `WithResultWriter()` to specify a function to override the corresponding LINQ statement result writing. -- *Fixed:* Adjusted the fluent-style method-chaining interface to improve usability (and consistency). - -## v3.25.0 -- *Enhancement:* Added new `CoreEx.Data` project/package to encapsulate all generic data-related capabilities, specifically the new `QueryFilterParser` and `QueryOrderByParser` classes. These enable a limited, explicitly supported, dynamic capability to `$filter` and `$orderby` an underlying query _similar_ to _OData_. This is **not** intended to be a replacement for the full capabilities of OData, GraphQL, etc. but to offer basic dynamic flexibility where needed. - - Added `IQueryable.Where()` and `IQueryable.OrderBy` extension method that will use the aforementioned parsers configured within the new `QueryArgsConfig` and `QueryArgs` and apply leveraging `System.Linq.Dynamic.Core`. - - Updated `HttpRequestOptions` and `WebApiRequestOptions` to support `QueryArgs` (`$filter` and `$orderby` query string arguments) similar to the existing `PagingArgs`. - - Added `QueryAttribute` to enable _Swagger/Swashbuckle_ generated documentation. -- *Fixed:* Fixed missing `IServiceCollection.AddCosmosDb` including corresponding `CosmosDbHealthCheck`. -- *Fixed:* Added `JsonIgnore` to all interfaces that have a `CompositeKey` property as _not_ intended to be serialized by default. -- *Fixed:* Fixed `ReferenceDataCollectionBase` constructor which was hiding `sortOrder` and `codeComparer` parameters. - -## v3.24.1 -- *Fixed*: `CosmosDb.SelectMultiSetWithResultAsync` updated to skip items that are not considered valid; ensures same outcome as if using a `CosmosDbModelQueryBase` with respect to filtering. - -## v3.24.0 -- *Enhancement:* `CosmosDb.SelectMultiSetWithResultAsync` and `SelectMultiSetAsync` added to enable the selection of multiple sets of data in a single operation; see also `MultiSetSingleArgs` and `MultiSetCollArgs`. -- *Enhancement:* `CosmosDbValue.Type` is now updatable and defaults from `CosmosDbValueModelContainer.TypeName` (updateable using `UseTypeName`). - -## v3.23.5 -- *Fixed:* `CosmosDbValue.PrepareBefore` corrected to set the `PartitionKey` where the underlying `Value` implements `IPartitionKey`. -- *Fixed:* `CosmosDbBatch` corrected to default to the `CosmosDbContainerBase.DbArgs` where not specified. -- *Fixed:* `CosmosDbArgs.AutoMapETag` added, indicates whether when mapping the model to the corresponding entity that the `IETag.ETag` is to be automatically mapped (default is `true`, existing behavior). - -## v3.23.4 -- *Fixed:* Added `Result.AdjustsAsync` to support asynchronous adjustments. - -## v3.23.3 -- *Fixed:* Added `Result.Adjusts` as wrapper for `ObjectExtensions.Adjust` to simplify support and resolve issue where the compiler sees the adjustment otherwise as a implicit cast resulting in an errant outcome. - -## v3.23.2 -- *Fixed:* `DatabaseExtendedExtensions.DeleteWithResultAsync` corrected to return a `Task`.` - -## v3.23.1 -- *Fixed:* Updated all dependencies to latest versions (specifically _UnitTestEx_). - -## v3.23.0 -- *Enhancement:* Added `ICacheKey` and updated `RequestCache` accordingly to support, in addition to the existing `IEntityKey`, to enable additional caching key specification. -- *Enhancement:* Added `ItemKeySelector` to `EntityBaseDictionary` to enable automatic inference of the key from an item being added. -- *Fixed:* Updated all dependencies to latest versions. - -## v3.22.0 -- *Enhancement:* Identifier parsing and `CompositeKey` formatting moved to the `CosmosDbArgs` to enable overriding where required. -- *Enhancement:* Cosmos model constraint softened to allow for `IEntityKey` to support more flexible identifier scenarios. -- *Enhancement:* All Cosmos methods updated to support `CompositeKey` versus `object` for identifier specification for greater flexibility. -- *Enhancement:* `CosmosDbModelContainer` and `CosmosDbValueModelContainer` enable model-only access; also, all model capabilities housed under new `Model` namespace. -- *Fixed:* `PagingOperationFilter` correctly specifies a format of `int64` for the `number`-type paging parameters. -- *Fixed:* `CompositeKey` correctly supports `IReferenceData` types leveraging the underlying `IReferenceData.Code`. - -## v3.21.1 -- *Fixed:* `Mapper.MapSameTypeWithSourceValue` added (defaults to `true`) to map the source value to the destination value where the types are the same; previously this would result in an exception unless added explicitly. The `Mapper.SameTypeMapper` enables. -- *Fixed:* `ReferenceDataOrchestrator.GetAllTypesInNamespace` added to get all the `IReferenceData` types in the specified namespace. Needed for the likes of the `CosmosDbBatch.ImportValueBatchAsync` where a list of types is required. - -## v3.21.0 -- *Enhancement*: `CoreEx.Cosmos` improvements: - - Added `CosmosDbArgs` to `CosmosDbContainerBase` to allow per container configuration where required. - - Partition key specification centralized into `CosmosDbArgs`. - - `ITenantId` and `ILogicallyDeleted` support integrated into `CosmosDbContainerBase`, etc. to offer consistent behavior with `EfDb`. - -## v3.20.0 -- *Fixed*: Include all constructor parameters when using `AddReferenceDataOrchestrator`. -- *Enhancement*: Integrated dynamic `ITenantId` filtering into `EfDb` (controlled with `EfDbArgs`). - -## v3.19.0 -- *Fixed:* Updated all dependencies to latest versions. -- *Enhancement:* Added `DatabaseCommand.SelectAsync` and `SelectWithResultAsync` that has no integrated typing and mapping. - -## v3.18.1 -- *Fixed*: The `ITypedMappedHttpClient.MapResponse` was not validating the input HTTP response correctly before mapping; resulted in a `null` success value versus the originating error/exception. -- *Fixed*: The `HttpResult.ThrowOnError` was not correctly throwing the internal exception. - -## v3.18.0 -- *Fixed*: Removed `Azure.Identity` dependency as no longer required; related to `https://github.com/advisories/GHSA-wvxc-855f-jvrv`. -- *Fixed*: Removed `AspNetCore.HealthChecks.SqlServer` dependency as no longer required. -- *Fixed:* Updated all dependencies to latest versions. -- *Fixed*: `CoreEx.AutoMapper` updated to leverage latest major version (`13.0.1`); as such `netstandard` no longer supported. -- *Fixed*: The `TimerHostedServiceBase` was incorrectly resetting the `LastException` on sleep versus wake. -- *Fixed*: The `AddEventSender` dependency injection extension methods now correctly register as _Scoped_. -- *Fixed*: The `Logger.LogInformation` invocations refactored to `Logger.LogDebug` where applicable to reduce noise in the logs. -- *Fixed*: The `IPropertyRule.ValidateAsync` method removed as it was not required and could lead to incorrect usage. -- *Fixed:* The `ValueValidator` now only supports a `Configure` method to enable `IPropertyRule`-based configuration (versus directly). -- *Fixed:* The `CommonValidator.ValidateAsync` is now internal as this was not intended and could lead to incorrect usage. -- *Enhancement*: Added `AfterSend` event to `IEventSender` to enable post-send processing. -- *Enhancement*: Added `EventOutboxHostedService.OneOffTrigger` method to enable a _one-off_ trigger interval to be specified for the registered (DI) instance. - -## v3.17.0 -- *Enhancement*: Additional `CoreEx.Validation` usability improvements: - - `Validator.CreateFor` added to enable the creation of a `CommonValidator` instance for a specified type `T` (more purposeful name); synonym for existing `CommonValidator.Create` (unchanged). - - `Validator.Null` added to enable simplified specification of a `IValidatorEx` of `null` to avoid explicit `null` casting. - - `Collection` extension method has additional overload to pass in the `IValidatorEx` to use for each item in the collection; versus, having to use `CollectionRuleItem.Create`. - - `Dictionary` extension method has additional overload to pass in the `IValidatorEx` and `IValidator` to use for each entry in the dictionary; versus, having to use `DictionaryRuleItem.Create`. - - `MinimumCount` and `MaximumCount` extension methods for `ICollection` added to enable explicit specification of these two basic validations. - - `Validation.CreateCollection` renamed to `Validation.CreateForCollection` and creates a `CommonValidator`. - - Existing `CollectionValidator` deprecated as the `CommonValidator` offers same; removes duplication of capability. - - `Validation.CreateDictionary` renamed to `Validation.CreateForDictionary` and creates a `CommonValidator`. - - Existing `DictionaryValidator` deprecated as the `CommonValidator` offers same; removes duplication of capability. -- *Enhancement*: Added `ServiceBusReceiverHealthCheck` to perform a peek message on the `ServiceBusReceiver` as a means to determine health. Use `IHealthChecksBuilder.AddServiceBusReceiverHealthCheck` to configure. -- *Fixed:* The `FileLockSynchronizer`, `BlobLeaseSynchronizer` and `TableWorkStatePersistence` have had any file/blob/table validations/manipulations moved from the constructor to limit critical failures at startup from a DI perspective; now only performed where required/used. This also allows improved health check opportunities as means to verify. - -## v3.16.0 -- *Enhancement*: Added basic [FluentValidator](https://docs.fluentvalidation.net/en/latest/) compatibility to the `CoreEx.Validation` by supporting _key_ (common) named capabilities: - - `AbstractValidator` added as a wrapper for `Validator`; with both supporting `RuleFor` method (wrapper for existing `Property`). - - `NotEmpty`, `NotNull`, `Empty`, `Null`, `InclusiveBetween`, `ExclusiveBetween`, `Equal`, `NotEqual`, `LessThan`, `LessThanOrEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `Matches`, `Length`, `MinimumLength`, `MaximumLength`, `PrecisionScale`, `EmailAddress` and `IsInEnum` extension methods added (invoking existing equivalents). - - `NullRule` and `NotNullRule` added to support the `Null` and `NotNull` capabilities specifically. - - `WithMessage` added to explcitly set the error message for a preceeding `IValueRule` (equivalent to specifying when invoking extension method). - - `ValidatorStrings` have had their fallback texts added to ensure an appropriate text is output where `ITextProvider` is not available. - - _Note:_ The above changes are to achieve a basic level of compatibility, they are not intended to implement the full capabilities of _FluentValidation_; nor, will it ever. The `CoreEx.FluentValidation` enables _FluentValidation_ to be used directly where required; also, the existing `CoreEx.Validation.InteropRule` enables interoperability between the two. -- *Enhancement*: Added `StringSyntaxAttribute` support to improve intellisense for JSON and URI specification. -- *Enhancement*: Added `EventPublisherHealthCheck` that will send an `EventData` message to verify that the `IEventPublisher` is functioning correctly. - - _Note:_ only use where the corresponding subscriber(s)/consumer(s) are aware and can ignore/filter to avoid potential downstream challenges. - -## v3.15.0 -- *Enhancement*: This is a clean-up version to remove all obsolete code and dependencies. This will result in a number of minor breaking changes, but will ensure that the codebase is up-to-date and maintainable. - - As per [`v3.14.0`](#v3.14.0) the previously obsoleted `TypedHttpClientBase` methods `WithRetry`, `WithTimeout`, `WithCustomRetryPolicy` and `WithMaxRetryDelay` are now removed; including `TypedHttpClientOptions`, `HttpRequestLogger` and related `SettingsBase` capabilities. - - Health checks: - - `CoreEx.Azure.HealthChecks` namespace and classes removed. - - `SqlServerHealthCheck` replaced with simple generic `DatabaseHealthCheck`. - - `IServiceCollection.AddDatabase` automatically adds `DatabaseHealthCheck`. - - `IServiceCollection.AddSqlServerEventOutboxHostedService` automatically adds `TimerHostedServiceHealthCheck`. - - `IServiceCollection.AddReferenceDataOrchestrator` automatically adds `ReferenceDataOrchestratorHealthCheck` (reports cache statistics). - - `HealthReportStatusWriter` added to support richer JSON reporting. - - Generally recommend using 3rd-party library to enable further health checks; for example: [`https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks`](https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks). - -## v3.14.1 -- *Fixed*: The `Result.ValidatesAsync` extension method signature has had the value nullability corrected to enable fluent-style method-chaining. -- *Fixed*: The fully qualified type and property name is now correctly used as the `LText.KeyAndOrText` when creating within the `PropertyExpression` to enable a qualified _key_ that can be used by the `ITextProvider` to substitute the text at runtime; the existing text fallback behavior remains such that an appropriate text is used. The `PropertyExpression.CreatePropertyLTextKey` function can be overridden to change this behavior. - -## v3.14.0 -- *Enhancement*: Planned feature obsoletion. The `TypedHttpClientBase` methods `WithRetry`, `WithTimeout`, `WithCustomRetryPolicy` and `WithMaxRetryDelay` are now marked as obsolete and will result in a compile-time warning. Related `TypedHttpClientOptions`, `HttpRequestLogger` and `SettingsBase` capabilities have also been obsoleted. - - Why? Primarily based on Microsoft guidance around [`IHttpClientFactory`](https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory) usage. Specifically advances in native HTTP [resilency](https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience) support, and the [.NET 8 networking improvements](https://devblogs.microsoft.com/dotnet/dotnet-8-networking-improvements/). - - When? Soon, planned within the next minor release (`v3.15.0`). This will simplify the underlying `TypedHttpClientBase` logic and remove the internal dependency on an older version of the [_Polly_](https://www.nuget.org/packages/Polly/7.2.4) package. - - How? Review the compile-time warnings, and [update the codebase](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly) to use the native `IHttpClientFactory` resiliency capabilities. -- *Enhancement*: Updated `CoreEx.UnitTesting` to leverage the latest `UnitTestEx` (`v4.2.0`) which has added support for testing `HttpMessageHandler` and `HttpClient` configurations. This will enable improved mocked testing as a result of the above changes where applicable. -- *Enhancement*: Added `CustomSerializers` property to `IEventSerializer` of type `CustomEventSerializers`. This allows for the add (registration) of custom JSON serialization logic for a specified `EventData.Value` type. This is intended to allow an opportunity to serialize a specific type in a different manner to the default JSON serialization; for example, exclude certain properties, or use a different serialization format. -- *Enhancement*: Updated the unit testing `ExpectedEventPublisher` so that it now executes the configured `IEventSerializer` during publishing. A new `UnitTestBase.GetExpectedEventPublisher` extension method added to simplify access to the `ExpectedEventPublisher` instance and corresponding `GetPublishedEvents` property to enable further assert where required. - -## v3.13.0 -- *Enhancement*: Added `DatabaseMapperEx` enabling extended/explicit mapping where performance is critical versus existing that uses reflection and compiled expressions; can offer up to 40%+ improvement in some scenarios. -- *Enhancement*: The `AddMappers()` and `AddValidators()` extension methods now also support two or three assembly specification overloads. -- *Enhancement*: A `WorkState.UserName` has been added to enable the tracking of the user that initiated the work; this is then checked to ensure that only the initiating user can interact with their own work state. -- *Fixed:* The `ReferenceDataOrchestrator.GetByTypeAsync` has had the previous sync-over-async corrected to be fully async. -- *Fixed*: Validation extensions `Exists` and `ExistsAsync` which expect a non-null resultant value have been renamed to `ValueExists` and `ValueExistsAsync` to improve usability; also they are `IResult` aware and will act accordingly. -- *Fixed*: The `ETag` HTTP handling has been updated to correctly output and expect the weak `W/"xxxx"` format. -- *Fixed*: The `ETagGenerator` implementation has been further optimized to minimize unneccessary string allocations. -- *Fixed*: The `ValueContentResult` will only generate a response header ETag (`ETagGenerator`) for a `GET` or `HEAD` request. The underlying result `IETag.ETag` is used as-is where there is no query string; otherwise, generates as assumes query string will alter result (i.e. filtering, paging, sorting, etc.). The result `IETag.ETag` is unchanged so the consumer can still use as required for a further operation. -- *Fixed*: The `SettingsBase` has been optimized. The internal recursion checking has been removed and as such an endless loop (`StackOverflowException`) may occur where misconfigured; given frequency of `IConfiguration` usage the resulting performance is deemed more important. Additionally, `prefixes` are now optional. - - The existing support of referencing a settings property by name (`settings.GetValue("NamedProperty")`) and it using reflection to find before querying the `IConfiguration` has been removed. This was not a common, or intended usage, and was somewhat magical, and finally was non-performant. - -## v3.12.0 -- *Enhancement*: Added new `CoreEx.Database.Postgres` project/package to support [PostgreSQL](https://www.postgresql.org/) database capabilities. Primarily encapsulates the open-source [`Npqsql`](https://www.npgsql.org/) .NET ADO database provider for PostgreSQL. - - Added `EncodedStringToUInt32Converter` to support PostgreSQL `xmin` column encoding as the row version/etag. -- *Enhancement*: Migrated sentence case logic from inside `PropertyExpression` into `CoreEx.Text.SentenceCase` to improve discoverablity and reuse opportunities. -- *Fixed:* The `IServiceCollection.AddAzureServiceBusClient` extension method as been removed; the `ServiceBusClient` will need to be instantiated prior to usage. Standard approach is for consumers to create client instances independently. -- *Fixed*: The `WorkOrchestrator.GetAsync()` and `WorkOrchestrator.GetAsync(string type, ..)` methods were not automatically cancelling where expired. -- *Fixed*: The `InvokerArgs` activity tracing updated to correctly capture the `Exception.Message` where an `Exception` has been thrown. -- *Internal*: - - All `throw new ArgumentNullException` checking migrated to the `xxx.ThrowIfNull` extension method equivalent. - - All _Run Code Analysis_ issues resolved. - -## v3.11.0 -- *Enhancement*: The `ITypedToResult` updated to correctly implement `IToResult` as the simple `ToResult` where required. -- *Enhancement*: Added `Result.AsTask()` and `Result.AsTask` to simplify the conversion to a completed `Task` or `Task>` where applicable. -- *Enhancement*: Added `IResult.IsFailureOfType` to indicate whether the result is in a failure state and the underlying error is of the specified `TException` type. -- *Enhancement*: Added `EventTemplate` property to the `WebApiPublisherArgs` and `WebApiPublisherCollectionArgs` to define an `EventData` template. -- *Enhancement*: Added `SubscriberBase` constructor overload to enable specification of `valueValidator` and `ValueIsRequired` parameters versus setting properties directly simplifying usage. -- *Enhancement:* Enum renames to improve understanding of intent for event subscribing logic: `ErrorHandling.None` is now `ErrorHandling.HandleByHost` and `ErrorHandling.Handle` is now `ErrorHandling.HandleBySubscriber`. -- *Enhancement:* Simplified the `ServiceBusSubscriber.Receive` methods by removing the `afterReceive` parameter which served no real purpose; also, reversed the `validator` and `valueIsRequired` parameters (order as stated) as the `validator` is more likely to be specified than `valueIsRequired` which defaults to `true`. -- *Enhancement*: Added `CoreEx.Hosting.Work` namespace which includes light-weight/simple foundational capabilities to track and orchestrate work; intended for the likes of [_asynchronous request-response_](https://learn.microsoft.com/en-us/azure/architecture/patterns/async-request-reply) scenarios. - - Added `IWorkStatePersistence` to enable flexible/pluggable persistence of the `WorkState` and resulting data; includes `InMemoryWorkStatePersistence` for testing, `FileWorkStatePersistence` for file-based, and `TableWorkStatePersistence` leveraging Azure table storage. - - Added `WorkStateOrchestrator` support to `EventSubscriberBase`, including corresponding `ServiceBusSubscriber` and `ServiceBusOrchestratedSubscriber` using the `ServiceBusMessage.MessageId` as the corresponding `WorkState.Id`. - - Extended `EventSubscriberArgs` to support a new `SetWorkStateDataAsync` operation to enable the setting of the underlying `WorkState` data is a consistent manner where using the event subscriber capabilities. - -## v3.10.0 -- *Enhancement*: The `WebApiPublisher` publishing methods have been simplified (breaking change), primarily through the use of a new _argument_ that encapsulates the various related options. This will enable the addition of further options in the future without resulting in breaking changes or adding unneccessary complexities. The related [`README`](./src/CoreEx.AspNetCore/WebApis/README.md) has been updated to document. -- *Enhancement*: Added `ValidationUseJsonNames` to `SettingsBase` (defaults to `true`) to allow setting `ValidationArgs.DefaultUseJsonNames` to be configurable. - -## v3.9.0 -- *Enhancement*: A new `Abstractions.ServiceBusMessageActions` has been created to encapsulate either a `Microsoft.Azure.WebJobs.ServiceBus.ServiceBusMessageActions` (existing [_in-process_](https://learn.microsoft.com/en-us/azure/azure-functions/functions-dotnet-class-library) function support) or `Microsoft.Azure.Functions.Worker.ServiceBusMessageActions` (new [_isolated_](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide) function support) and used internally. Implicit conversion is enabled to simplify usage; existing projects will need to be recompiled. The latter capability does not support `RenewAsync` and as such this capability is no longer leveraged for consistency; review documented [`PeekLock`](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-service-bus-trigger?tabs=python-v2%2Cisolated-process%2Cnodejs-v4%2Cextensionv5&pivots=programming-language-csharp#peeklock-behavior) behavior to get desired outcome. -- *Enhancement*: The `Result`, `Result`, `PagingArgs` and `PagingResult` have had `IEquatable` added to enable equality comparisons. -- *Enhancement*: Upgraded `UnitTestEx` dependency to `4.0.2` to enable _isolated_ function testing. -- *Enhancement*: Enabled `IJsonSerializer` support for `CompositeKey` JSON serialization/deserialization. -- *Enhancement*: Added `IEventDataFormatter` which when implemented by the value set as the `EventData.Value` allows additional formatting to be applied by the `EventDataFormatter`. -- *Enhancement*: Added `IsMapNullIfNull` to `BidirectionalMapper` that indicates whether to map `null` source value to a corresponding `null` destination automatically. -- *Fixed*: Added `ReferenceDataMultiDictionaryConverterFactory` to ensure each `IReferenceDataCollection` is serialized correctly according to its underlying type. -- *Fixed*: `EventDataFormatter` and `CloudEventSerializerBase` updated to correctly set the `Key` property where applicable. -- *Internal:* Upgraded `NUnit` dependency to `4.0.1` for all `CoreEx` unit test; also, all unit tests now leverage the [_NUnit constraint model_](https://docs.nunit.org/articles/nunit/writing-tests/assertions/assertion-models/constraint.html) testing approach. - -## v3.8.1 -- *Fixed*: The `CoreEx.Text.JsonSerializer` has been updated to cache the _indented_ option correctly. -- *Fixed*: The `ReferenceDataOrchestator` updated to use the correct serializer for `ETag` generation. - -## v3.8.0 -- *Enhancement*: The `ValueContentResult.CreateResult` has been updated to return the resulting value as-is where is an instance of `IActionResult`; otherwise, converts `value` to a `ValueContentResult` (previous behavior). -- *Enhancement*: The `PagingArgs` has been extended to support `Token`; being a continuation token to enable paging to be performed where the underlying data source does not support skip/take-style paging. - -## v3.7.2 -- *Fixed*: The `ReferenceDataMultiCollection` and `ReferenceDataMultiItem` have been replaced with the `ReferenceDataMultiDictionary` as existing resulted in an unintended format with which to return the data. This fix also removed the need for the `ReferenceDataMultiCollectionConverterFactory` as custom serialization for this is no longer required. - -## v3.7.1 -- *Fixed*: The `WebApi.PutWithResultAsync` methods that support `get` function parameter have had the result nullability corrected. -- *Fixed*: The `BidirectionalMapper` has been added to further simplify the specification of a bidirectional mapping capability. - -## v3.7.0 -- *Enhancement:* The `Mapper` has a new constructor override to enable the specification of the mapping (`OnMap` equivalent) logic. -- *Enhancement:* The `Mapper` has had `When*` helper methods added to aid the specification of the mapping logic depending on the `OperationTypes` (singular) being performed. -- *Enhancement:* A new `NoneRule` validation has been added to ensure that a value is none (i.e. must be its default value). - -## v3.6.3 -- *Fixed:* All related package dependencies updated to latest. - -## v3.6.2 -- *Enhancement:* Added `Converter.Create` to enable a simple one-off `IConverter` implementation to be created. -- *Fixed:* The `IReferenceData.SetInvalid` method corrected to throw `NotImplementedException` where not explicitly implemented. -- *Fixed:* The `ReferenceDataBase` updated to handle the `IsValid` and `SetInvalid` functionality correctly. - -## v3.6.1 -- *Enhancement:* Added `IBidirectionalMapper` to enable a single mapping capability that can support mapping both ways. -- *Enhancement:* Added `IBidirectionalMapper` registration support to `Mapper.Register` and by extension `IServiceCollection.AddMappings`. -- *Enhancement:* Finalized initial capabilities for `CoreEx.OData`; package now published. - -## v3.6.0 -- *Enhancement:* `UnitTestEx` as of `v4.0.0` removed all dependencies to `CoreEx`, breaking a long-time circular reference challenge. Added extension capabilities to enable existing behaviors. These extensions have been added within `CoreEx.UnitTesting` and `CoreEx.UnitTesting.NUnit` respectively; using `UnitTestEx` namespace to minimize breaking changes and clearly separate. The following will need to be corrected where applicable: - - Add `UnitTestEx` namespace where missing to enable new extension methods. - - Replace existing `TestSetUp.Default.ExpectedEventsEnabled = true` with `TestSetUp.Default.EnableExpectedEvents()`; changed to a method as extension properties are not currently supported in C#. - - Replace existing `TestSetUp.Default.ExpectNoEvents = true` with `TestSetUp.Default.ExpectNoEvents()`; changed to a method as extension properties are not currently supported in C#. - - The existing `ApiTester.Agent` property has had to be made an extension method as follows: - - Before: `test.Agent().Expect...` - - After: `test.Agent().With().Expect...` - - The `ValidationTester` has _not_ been ported; but has been implemented using extension methods on the `GenericTester` as follows: - - Before: `ValidationTester.Create().ExpectErrors("").Run(x);` - - After: `GenericTester.Create().ExpectErrors("").Validation().With(x);` -- *Enhancement:* Added `net8.0` support. - -## v3.5.0 -- *Enhancement:* Update the `JsonFilterer` classes to support qualified (indexed) property names; all paths are standardized with the `$` prefix internally. -- *Enhancement:* Added `JsonNode` extension methods `ApplyInclude` and `ApplyExclude` to simplify corresponding `JsonFilterer` usage. -- *Enhancement:* Added `JsonElementComparer` to compare two `JsonElement` values (and typed values) and return the differences (`JsonElementComparerResult`). Additionally, the `JsonElementComparerResult.ToMergePatch` will create a corresponding `JsonNode` that represents an `application/merge-patch+json` representation of the differences. -- *Enhancement:* Added `DateTimeToStringConverter` to enable the explicit formatting of a `DateTime` to a `string` and vice-versa. -- *Enhancement:* Added `JsonObjectMapper` to enable explicit mapping of a `Type` (class) to a `JsonObject` and vice-versa (versus serialization). This enables property conversion, mapping and operation types to be specified, similar to other _CoreEx_ mapping capabilities. -- *Enhancement:* Renamed `WebApiPublisher.PublishAsync` to `WebApiPublisher.PublishCollectionAsync` to be more explicit with respect to purpose and usage. -- *Enhancement:* The `WebApiPublisher.PublishAsync` has had the `eventModifier` delegate parameter simplified to no longer include a value as this is already available via the `Value` property of the existing `EventData` parameter. -- *Enhancement:* Added additional overloads to `WebApiPublisher.PublishAsync` and `WebApiPublisher.PublishCollectionAsync` to support the event publishing of a different (mapped) type where applicable; see `eventModifier` delegate parameter. Additionally, supports `WebApiPublisher.Mapper` to convert/map by default where applicable. -- *Fixed:* The `Result.OnFailure*` methods corrected to pass in the `Error` versus the `Value` (previously throwing incorrect exception as a result). -- *Enhancement:* Added new `CoreEx.OData` project/package to support [OData](https://learn.microsoft.com/en-us/odata/overview) capabilities. Primarily encapsulates the open-source [`Simple.OData.Client`](https://github.com/simple-odata-client/Simple.OData.Client). - - _Note:_ this package has not been published as this is currently considered experimental; is subject to future change and/or removal. -- *Enhancement:* Added new `CoreEx.Dataverse` project/package to support [Microsoft Dataverse](https://docs.microsoft.com/en-us/powerapps/developer/data-platform/choose-data-platform) (formerly known as Common Data Service or CDS) capabilities. Primarily encapsulates the [`ServiceClient`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.powerplatform.dataverse.client.serviceclient) and mappings to/from the _Dataverse_ entities. - - _Note:_ this package has not been published as this is currently considered experimental; is subject to future change and/or removal. - -## v3.4.1 -- *Fixed:* The `IEfDb.With` fixed (as extension methods) to also support the `with` value being passed into the corresponding `Action` to simplify usage (only a subset of common intrinsic types supported, both nullable and non-nullable overloads). -- *Fixed:* Missing `Result.CacheSet` and `Result.CacheRemove` extension methods added to `CoreEx.Results` to fully enable `IRequestCaching` in addition to existing `Result.CacheGetOrAddAsync`. - -## v3.4.0 -- *Enhancement:* Added `IEventSubscriberInstrumentation` (and related `EventSubscriberInstrumentationBase`) to enable `EventSubscriberBase.Instrumentation` monitoring of the subscriber as applicable. -- *Enhancement:* Previous `EventSubscriberInvoker` exception/error handling moved into individual subscribers for greater control; a new `ErrorHandler` added to encapsulate the consistent handling of the underlying exceptions/errors. This was internal and should have no impact. -- *Enhancement:* `ErrorHandling.ThrowSubscriberException` renamed to `Handle` and `ErrorHandling.TransientRetry` renamed to `Retry`. Old names have been obsoleted and as such will generate a compile-time error where not corrected. -- *Enhancement:* Added `DataConsistencyException` to support the throwing of possible data consistency issues; internally integrated throughout _CoreEx_. -- *Enhancement:* Added `IDatabase.SqlFromResource` support to enable simple access to SQL statements embedded as a resource within a specified assembly. -- *Enhancement:* `Result.When*` methods updated to support _optional_ `otherwise` function to enable `if/then/else` scenarios (only invoked where `Result.IsSuccess`). - -## v3.3.1 -- *Fixed:* `ServiceBusSubscriber` was not correctly bubbling (not handling) exceptions where `UnhandledHandling` was set to `ErrorHandling.None`. Was incorrectly treating same as `ErrorHandling.ThrowSubscriberException` and automatically dead-lettering and continuing. - -## v3.3.0 -- *Enhancement:* [Distributed tracing](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/distributed-tracing-instrumentation-walkthroughs#best-practices) has been added via the `InvokerBase` set of classes throughout `CoreEx` to ensure coverage and consistency of implementation. A new `InvokeArgs` has been added to house the [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource) instance; this also provides for further extension opportunities limiting future potential breaking changes. - -## v3.2.0 -- *Enhancement:* Added `ServiceBusReceiverActions` as a means to encapsulate the `ServiceBusReceivedMessage` and `ServiceBusReceiver` as a `ServiceBusMessageActions` equivalent to enable both the `ServiceBusSubscriber` and `ServiceBusOrchestratedSubscriber` to be leveraged outside of native Azure Functions. -- *Enhancement:* Added support for [claim-check pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/claim-check) for large messages. `EventData.Value` serialization to be stored as an attachment in the likes of blob storage and then referenced (claim-check) from the actual message. A new `IAttachmentStorage` encapsulates the attachment behavior with the `IEventSerializer` implementations referencing as applicable; whereby separating this behavior from the `IEventSender` enabling greater consistency and reuse. Added `BlobAttachmentStorage` and `BlobSasAttachmentStorage` to support Azure blob storage. - -## v3.1.1 -- *Fixed:* The `DatabaseParameterCollection.AddParameter` now explicitly sets the `DbParameter.Value` to `DbNull.Value` where the value passed in is `null`. - -## v3.1.0 -- *Enhancement:* Added `Hosting.ServiceBase` class for a self-orchestrated service to execute for a specified `MaxIterations`; provides an alternative to using a `HostedService`. Useful for the likes of timer trigger Azure Functions for eample. -- *Enhancement:* Added `EventOutboxService` as an alternative to `EventOutboxHostedService`; related to (and leverages) above to achieve same outcome. -- *Fixed:* `Database.OnDbException` was incorrectly converting the unhandled exception to a `Result`; will now throw as expected. - -## v3.0.0 -- *Enhancement:* Added new `CoreEx.Results` namespace with primary `Result` and `Result` classes to enable [monadic](https://en.wikipedia.org/wiki/Monad_(functional_programming)) error-handling, often referred to [Railway-oriented programming](https://swlaschin.gitbooks.io/fsharpforfunandprofit/content/posts/recipe-part2.html); see [`CoreEx.Results`](./src/CoreEx/Results/README.md) for more implementation details. Thanks [Adi](https://github.com/AdiThakker) for inspiring and guiding on this change. Related changes as follows: - - *Enhancement:* `EventSubscriberBase`, `SubscriberBase` and `SubscriberBase` modified to include `EventSubscriberArgs` (`Dictionary`) to allow other parameters to be passed in. The `ReceiveAsync` methods now support the args as a parameter, and must return a `Result` to better support errors; breaking change. - - *Enhancement:* Where overriding `Validator.OnValidateAsync` this method must return a `Result`, as does the `CustomRule` (for consistency); breaking change. The `Result` enables other errors to be returned avoiding the need/cost to throw an exception. - - *Enhancement:* `ExecutionContext` user authorization methods have been renamed (`UserIsAuthorized` and `UserIsInRole`) and explicitly leverage `Result`; breaking change. - - *Enhancement:* `IReferenceDataProvider.GetAsync` method now supports a return type of `Result`; breaking change. -- *Enhancement:* The `WebApi` namespace has been moved to a new `CoreEx.AspNetCore` project/package to decouple these explicit [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/introduction-to-aspnet-core) capabilities from the core; breaking change. - - The `IExceptionResult` interface has been deprecated as a result; all exceptions have been updated accordingly. -- *Fixed:* Validation extension method `EnsureValue` has been renamed to `Required` to be more explicit as to purpose; breaking change. -- *Fixed:* `InvokerBase` and `InvokerBase` now split sync and async code to avoid sync over async; requires both the sync and async virtual methods to be overridden to implement correctly. -- *Enhancement:* Ad-hoc performance optimizations; some minor breaking changes primarily impacting internal usage. -- *Enhancement:* Added `net6.0` and `net7.0` support in addition to [.NET Standard](https://learn.microsoft.com/en-us/dotnet/standard/net-standard#when-to-target-net50-or-net60-vs-netstandard) to all packages. This will allow access to additional features per version where required, and overall performance improvements. -- *Enhancement:* Added `CoreEx.Solace` to enable the publishing of messages to [Solace](https://solace.com/) message broker; thanks [Israel](https://github.com/israels). -- *Enhancement:* Updated `CoreEx.Cosmos` to support direct model queries using `ModelQuery` methods where applicable. -- *Enhancement:* Added `PagingOperationFilterFields` to allow specific selection of fields for the `PagingOperationFilter`. This was influenced by pull request [67](https://github.com/Avanade/CoreEx/pull/67). - -## v2.10.1 -- *Fixed:* `EventOutboxHostedService` updated so when a new `IServiceScope` is created that `ExecutionContext.Reset` is invoked to ensure existing `ServiceProvider` is not reused. -- *Fixed:* `EventDataFormatter` defaults `PartitionKey` and `TenantId` properties, where not already set, from the value where implements `IPartitionKey` and `ITenantId` respectively. - -## v2.10.0 -- *Enhancement:* Added `IServiceBusSubscriber` with following properties: `AbandonOnTransient` (perform an Abandon versus bubbling exception), `MaxDeliveryCount` (max delivery count check within subscriber), `RetryDelay` (basic transient retry specification) and `MaxRetryDelay` (defines upper bounds of retry delay). These are defaulted from corresponding `IConfiguration` settings. Both the `ServiveBusSubscriber` and `ServiveBusOrchestratedSubscriber` implement; related logic within `ServiceBusSubscriberInvoker`. -- *Enhancement:* Added `RetryAfterSeconds` to `TransientException` to allow overriding; defaults to `120` seconds. -- *Fixed:* Log output from subscribers will no longer write exception stack trace where known `IExtendedException` (noise reduction). -- *Fixed:* `ValidationException` message reformatted such that newlines are no longer included (message simplification). - -## v2.9.1 -- *Fixed:* The dead-lettering within `ServiceBusSubscriberInvoker` will write the exception stack trace, etc. to a new message property named `SubscriberException` to ensure this content is consistently persisted, with related error description being the exception message only. - -## v2.9.0 -- *Enhancement:* Added `PagingAttribute` and `PagingOperationFilter` to enable swagger output of `PagingArgs` parameters for an operation. - -## v2.8.0 -- *Enhancement:* Added `CoreEx.EntityFrameworkCore` support for .NET framework `net7.0`. -- *Enhancement:* Updated `ServiceBusSubscriberInvoker` to improve logging, including opportunities to inherit and add further before and after processing logging and/or monitoring. -- *Enhancement:* Updated `ServiceBusOrchestratedSubscriber` to perform a `LogInformation` on success. -- *Enhancement:* The `TypedHttpClientBase` will probe settings by `GetType().Name` to enable settings per implementation type as an overridding configurable option. -- *Fixed:* `HttpResult.CreateExtendedException` passes inner `HttpRequestException` for context. -- *Fixed:* `EventSubscriberOrchestrator.AmbiquousSubscriberHandling` is correctly set to `ErrorHandling.CriticalFailFast` by default. - -## v2.7.0 -- *Enhancement:* Simplified usage for `TypedHttpClientCore` and `TypedHttpClientBase` such that all parameters with the exception of `HttpClient` default where not specified. -- *Enhancement:* `IServiceCollection` extension methods for `CoreEx.Validation` and `CoreEx.FluentValidation` support option to include/exclude underlying interfaces where performing register using `AddValidator` and `AddValidators`. -- *Enhancement:* Enable interoperability between `CoreEx.Validation` and a non-`CoreEx.Validation` mapped `IValidator`; see `Interop` validation extension method. -- *Enhancement:* `ServiceBusOrchestratedSubscriber` added to support orchestrated (`EventSubscriberOrchestrator`) event subscribers (`IEventSubscriber`, `SubscriberBase` and `SubscriberBase`) based on matching metadata (`EventSubscriberAttribute`) to determine which registered subscriber to execute for the current `ServiceBusReceivedMessage`. -- *Enhancement:* `BlobLockSynchronizer` added to perform `IServiceSynchronizer` using Azure Blob storage. -- *Fixed:* Resolved transaction already diposed exception for the `EventOutboxHostedService` by creating a new `IServiceProvider` scope and instantiating a `EventOutboxDequeueBase` per execution to ensure all dependencies are reinstantiated where applicable. -- *Enhancement:* Updated all package dependencies to latest. - -## v2.6.0 -- *Enhancement:* `ReferenceDataOrchestrator` supports `IConfigureCacheEntry` to enable flexibility of configuration; no changes to current behaviour. -- *Fixed:* `ReferenceDataBase` was not correctly managing the `Id` and `IdType` throughout the inheritence hierarchy. -- *Enhancement:* Database, Entity Framework, and Cosmos capabilities can be configured within their respective `*Args` to perform a `Cleaner.Clean` automatically on the response. Defaults to `false` to maintain current functionality. -- *Fixed:* `Mapper` was not correctly initializing nullable destination properties during a `Flatten`; for example, a destination `DateTime?` was being set with a `DateTime.MinValue` where source property was not nullable. A new `InitializeDestination` can be optionally specified (or overridden) to perform; otherwise, current initialization behavior will continue. - -## v2.5.3 -- *Fixed:* Database `RowVersion` conversion fixed to correctly enable per database provider. - -## v2.5.2 -- *Fixed:* `ReferenceDataOrchestrator` further updated to attempt to use `ExecutionContext` where possible when `Current` has not previously been set; this is similar to previous behaviour (< `2.5.1`). -- *Fixed:* `ReferenceDataOrchestrator` updated to leverage `AsyncLocal` for `Current` to remove `static` value leakage; lifetime within the context of the request. - -## v2.5.1 -- *Fixed:* `System.ObjectDisposedException: Cannot access a disposed object` for the `IServiceProvider` has been resolved where reference data loading (`ReferenceDataOrchestrator`), that in turn loaded child reference data. A new start up `UseReferenceDataOrchestrator` method simplifies set up. - -## v2.5.0 -- *Enhancement:* Added string casing support to `Cleaner` and `EntityCore` using new `StringCase`; being `None` (as-is default), `Upper`, `Lower` and `Title`. Leverages standard .NET `TextInfo` to implement underlying case conversion. -- *Fixed:* Applied all changes identified by Code Analysis. -- *Fixed:* `NullReferenceException` in `EntityBaseDictionary` where item value is `null` corrected. -- *Enhancement:* Added `KeyModifier` function to `ObservableDictionary` to allow key manipulation to ensure consistency, i.e. all uppercase. -- *Fixed:* Potential SQL Injection opportunity within `DatabaseExtendedExtensions.ReferenceData` corrected when schema and table names were being specified explicitly; now quoted using `DbCommandBuilder.QuoteIdentifier`. - -## v2.4.0 -- *Enhancement:* Added `CompareValuesRule`, `EnumRule` and `EnumValueRule` as additional validation rules. - -## v2.3.0 -- *Enhancement:* `PagingArgs.MaxTake` default set by `SettingsBase.PagingMaxTake`. -- *Enhancement:* Reference data `ICacheEntry` policy configuration can now be defined in settings. - -## v2.2.0 -- *Fixed:* Entity Framework `EfDb.UpdateAsync` resolved error where the instance of entity type cannot be tracked because another instance with the same key value is already being tracked. -- *Fixed:* The `CollectionMapper` was incorrectly appending items to an existing collection, versus performing a replacement operation. -- *Enhancement:* Improved Entity Framework support where entities contain relationships, both query and update; new `EfDbArgs.QueryNoTracking` and `EfDbArgs.ClearChangeTrackerAfterGet` added to configure/override default behaviour. -- *Enhancement:* Added `TypedHttpClientOptions OnBeforeRequest` and within `TypedHttpClientBase` to enable updating of the `HttpRequestMessage` before it is sent. - -## v2.1.0 -- *Enhancement:* Added additional `ReferenceDataBaseEx.GetRefDataText` method overload with a parameter of `id`; as an alternative to the pre-existing `code`. -- *Enhancement:* `ReferenceDataOrchestrator` caching supports virtual `OnGetCacheKey` to enable overridding classes to further define the key characteristics. - -## v2.0.0 -- *Enhancement:* Added support for [`MySQL`](https://dev.mysql.com/) through introduction of `MySqlDatabase` that supports similar pattern to `SqlServerDatabase`. -- *Enhancement:* Added new `EncodedStringToDateTimeConverter` to simulate row versioning from a `DateTime` (timestamp) as an alternative. -- *Enhancement:* **Breaking change**: `CoreEx.EntityFrameworkCore` updated to only have database provider independent reference of `Microsoft.EntityFrameworkCore`. Developer will need to add database specific within own solution to use. -- *Enhancement:* **Breaking change**: Moved classes that inherit from the likes of `EntityBase` into corresponding `Extended` namespace as secondary, and moved the corresponding `Models` implementation into root as primary and removed namespace accordingly. This is to ensure consistency, such that _extended_ usage is explicit (non-default). `MessageItem` updated to no longer inherit from `EntityBase` as the extended capabilities are not required. -- *Enhancement:* **Breaking change**: The `AddValidators` extension method has been updated to register the implementing validators directly, versus the underlying `IValidatorEx`. This enables multiple validators to be registered for an entity. Any references to the interface will need to be updated to reference the concrete to continue functioning through dependency injection. Generally, the validators are not mocked, and the concrete classes can be if need using `MOQ` where required; impact of change is considered low risk for higher reward. -- *Enhancement:* Added the security related capabilities to `ExecutionContext` as was previously available in _[Beef](https://github.com/Avanade/Beef)_. - -## v1.0.12 -- *Enhancement:* Added new `Mapping.Mapper` as a simple (explicit) `IMapper` capability as an alternative to AutoMapper. Enable the key `Map`, `Flatten` and `Expand` mapping capabilities. This is no reflection/compiling magic, just specified mapping code which executes very fast. -- *Enhancement:* **Breaking change**: Validation `Additional` method renamed to `AdditionalAsync` to be more explicit. -- *Enhancement:* **Breaking change**: The `SqlServer` specific capabilities within `CoreEx.Database` project/assembly have been moved to a new `CoreEx.Database.SqlServer` project/assembly. - -## v1.0.11 -- *Enhancement:* Updated the `EventOutboxEnqueueBase` and `EventOutboxDequeueBase` to include the `EventDataBase.Key` value/column. -- *Enhancement:* Added the `EventOutboxHostedService` (migrated from `NTangle`) to enable hosted outbox publishing service execution. -- *Enhancement:* `LoggerEventSender` updated to also log event metadata. - -## v1.0.10 -- *Enhancement:* Loosened `EntityCollectionResult` generic `TEntity` constraint to `EntityBase` to enable inherited extended entitites. -- *Enhancement:* Extended `TypedHttpClientBase` to support `DefaultOptions` and `SendOptions` to enable default configuration of new `TypedHttpClientOptions`; i.e. the likes of `WithRetry` can now default versus having to be set per invocation of `SendAsync`. -- *Enhancement:* Added `TypedMappedHttpClientBase`, `TypedMappedHttpClientCore` and `TypedMappedHttpClient` with new `IMapper` property used to add extended support for request and response type mappings as part of the request. New methods are `GetMappedAsync`, `PostMappedAsync`, `PutMappedAsync` and `PatchMappedAsync` where applicable. -- *Enhancement:* `IConverter` usability improvements; including others that leverage. -- *Enhancement:* AutoMapper converters added for common `IConverter` implementations to enable. -- *Enhancement:* `ReferenceDataOrchestrator.ConvertFromId(object? id)` overload added to enable usage when `Type` of `Id` is unknown. -- *Enhancement:* Added `RefDataLoader` overload that supports stored procedure command usage. -- *Enhancement:* Extended `TableValuedParameter` to support standard list types; including corresponding configurable `DatabaseColumn` names. -- *Enhancement:* Add `DatabaseCommand.SelectMultiSetAsync` overloads to support paging. -- *Enhancement:* Added `IEntityKey` to enable key-based support in a consistent and standardized manner; refactored `IIdentifier` and `IPrimaryKey` to leverage; existing references within updated to leverage `IEntityKey` where applicable. -- *Enhancement:* Improved validation handling of nullable vs non-nullable types when adding rules. -- *Enhancement:* `EntityBase` usage simplified especially where inheriting indirectly, i.e. from a base class that inherits `EntityBase`. As a result `EntityBase<>` will be deprecated next version. `ICloneable` support removed, now supported via `ExtendedExtensions.Clone()`. -- *Enhancement:* Improved the `HttpArg` query string output support. -- *Enhancement:* Added `Models.ChangeLog` (does not inherit from `EntityBase`) as alternative to `Entities.ChangeLog` (which does). Also, added corresponding `AutoMapper` mapping between the two. -- *Enhancement:* `CosmosDbContainerBase` updated to further centralize functionality, inheriting classes updated accordingly. -- *Enhancement:* **Breaking change**: `ICollectionResult.Collection` renamed to `ICollectionResult.Items`. -- *Enhancement:* **Breaking change**: `IReferenceDataCollection` properties `AllList` and `ActiveList` renamed to `AllItems` and `ActiveItems` respectively. -- *Enhancement:* **Breaking change**: `HttpClientEx` removed as was a duplicate of `TypedHttpClient`; the latter was/is the intended implementation. -- *Enhancement:* `JsonDataReader` when loading `IReferenceData` will attempt to read using both JSON and .NET Property names before overridding to allow additional flexibility within the specified JSON/YAML. -- *Enhancement:* `ReferenceDataOrchestrator` concurrency support improved to ensure loading of reference data items for a `Type` is managed with a `SemaphoreSlim` to ensure only a single thread loads (only once). -- *Fixed:* `SettingsBase` was not looking for keys containing `__` or `:` consistently. -- *Fixed:* `JsonFilterer` implementations now filters contents of a JSON object array correctly. - -## v1.0.9 -- *Enhancement:* Ported and refactored CosmosDb components from _Beef_ repo. -- *Enhancement:* **Breaking change**: Replaced `DatabaseArgs.Paging` with `DatabaseQuery.Paging` and `DatabaseQuery.WithPaging`. -- *Enhancement:* **Breaking change**: Replaced `EfDbArgs.Paging` with `EfDbQuery.Paging` and `EfDbQuery.WithPaging`. -- *Enhancement:* Added `JsonDataReader` to enable dynamic loading of either YAML or JSON formatted data for data migration/uploading. -- *Enhancement:* Added `WebApiExceptionHandlerMiddleware` to manage any unhandled exceptions. -- *Enhancement:* Added `TypedHttpClient` to enable basic support for instantiating a `TypedHttpClientCore` without having to explicitly inherit. -- *Enhancement:* **Breaking change**: HealthChecks project deprecated with functionality moved to individual projects where applicable. -- *Enhancement:* Added `EfDbEntity` to provide a typed entity wrapper over the `IEfDb` operations. -- *Enhancement:* `AddAzureServiceBusClient` has had support to configure `ServiceBusClientOptions` added. -- *Fixed:* The `ServiceBusMessage` cannot be sent due to local transactions not being supported with other resource managers/DTC resolved. -- *Fixed:* The `AuthenticationException` and `AuthorizationException` HTTP status codes were incorrect; updated to `401` and `403` respectively. - -## v1.0.8 -- *Enhancement:* `InvokerBase` has been updated to that the `TArgs` value is optional. -- *Enhancement:* `ReferenceDataFilter` added to simplify HTTP Agent filtering as a single encapsulated object. -- *Enhancement:* `WebApi.ConvertNotfoundToDefaultStatusCodeOnDelete` property added to convert `NotFoundException` to the default `StatusCode` (`NoContent`) as considered an idempotent operation; defaults to `true`. -- *Enhancement:* `PropertyExpression.SentenceCaseConverter` added to enable overridding of default `ToSentenceCase` logic. -- *Fixed:* `HttpArg` where `HttpArgType.FromUriUseProperties` was incorrectly formatting string values. -- *Fixed:* `WebApi.Patch` operation was not returning the updated value correctly. -- *Fixed:* `WebApiExecutionContextMiddleware` not setting `Username` and `Timestamp` correctly. -- *Fixed:* `WebApiInvoker` was not setting the `x-error-type` and `x-error-code` headers for `IExtendedException` exceptions. -- *Fixed:* `WebApiParam` updated to use `ETag` header (primary) then `IETag.ETag` property (secondary) as request `ETag` value. -- *Fixed:* `ValidationException` updated to return message as `MediaTypeNames.Text.Plain` where message only (i.e. no property errors). -- *Fixed:* `IChangeLog` values set correctly for `IDatabase` and `IEfDb` create and update. -- *Fixed:* `IDatabase` connection open override methods now called correctly. -- *Fixed:* `CollectionRuleItem` updated to support duplicate checking by `IIdentifier` as well as the existing `IPrimaryKey`. - -## v1.0.7 -- *Fixed:* Invokers updated to leverage `async/await` correctly. - -## v1.0.6 -- *Enhancement:* Added `WithTimeout(TimeSpan timeout)` support to `TypedHttpClientBase` to enable per request timeouts. -- *Enhancement:* Added `AddFluentValidators` to automatically add the requisite dependency injection (DI) configuration for all validators defined within an `Assembly`. -- *Enhancement:* **Breaking change**: Refactored the extended entities to simplify implementation and improve experience via new `EntityBase.GetPropertyValues` and corresponding `PropertyValue`. -- *Enhancement:* Ported and refactored validation framework from _Beef_ repo. -- *Enhancement:* Added support for `IReferenceData` serialization where only the `Code` is serialized/deserialized. This also required new `IReferenceDataContentJsonSerializer`, `ReferenceDataContentJsonSerializer` and `ReferenceDataContentWebApi` for when full `IReferenceData` content serialization is required. -- *Enhancement:* Serializers updated to support `ICollectionResult` which by default only (de)serializes the underlying `Collection`. The `Paging` is expected to be handled separately. -- *Enhancement:* Ported and refactored _core_ database framework components from _DbEx_ rep. -- *Enhancement:* Ported and refactored extended database and entity framework components from _Beef_ repo. -- *Enhancement:* Added implementation agnostic `IMapper` for typed value mappings. Added _AutoMapper_ implementation with wrapper to enable. - -## v1.0.5 -- *Enhancement:* Overloads added to `WebApi` and `WebApiPublisher` to allow the body value to be passed versus reading from the `HttpRequest`. This is useful where allowing the likes of the ASP.NET infrastructure to deserialize value directly. -- *Enhancement:* Automatic `ETag` generation is performed prior to field filtering as this is considered a post response action and should not affect `ETag` value. -- *Enhancement:* Added `EventSendException` to provide a standard means to capture the events not sent to enable additional processing of those where required. - -## v1.0.4 -- *Enhancement:* Status code checking added to `TypedHttpClientBase`. -- *Enhancement:* Added `IValidator` to enable any implementation (agnostic); created wrappers to enable `FluentValidation` (including dependency injection helper). -- *Enhancement:* Added `AcceptsBodyAttribute` to enable Swagger (via `AcceptsBodyOperationFilter`) to output body type characteristics where not explicitly defined. -- *Enhancement:* Added opt-in simulated concurrency (ETag) checking/generation to `WebApi.PutAsync` and `WebApi.PatchAsync` where underlying data source does not support. -- *Enhancement:* Added `CancellationToken` to all `Async` methods. - -## v1.0.3 -- *Enhancement:* `IIdentifier.GetIdentifier` method replaced with `IIdentifier.Id`. The `IIdentifier` overrides the `Id` property hiding the base `IIdentifier.Id`. -- *Enhancement:* `ValueContentResult` properties are now all get and set enabled. The `Value` property has been removed as it is JSON serialized into `Content`. -- *Fixed:* `ValueContentResult.ETag` generation enhanced to handle different query string parameters when performing an HTTP GET for `IEnumerable` (collection) types. -- *Enhancement:* Added `HttpClientEx` as a light-weight means to instantiate a one-off instance from an `HttpClient`. -- *Enhancement:* Added `JsonMergePatch` (`application/merge-patch+json`) whereby the contents of a JSON document are merged into an existing object value as per [RFC7396](https://tools.ietf.org/html/rfc7396). -- *Enhancement:* Added/updated reference data capabilities. -- Plus, many more minor fixes and enhancements. - -## v1.0.2 -- *Enhancement:* **Breaking change**: The event publishing (`IEventPublisher`) is now designed to occur in three distinct phases: 1) formatting (`EventDataFormatter.Format`), 2) serialization (`IEventSerializer.SerializeAsync`), and 3) sending (`IEventSender.SendAsync`). The `EventPublisher` has been added to orchestrate this flow. -- *Enhancement:* Updated the `IJsonSerializer` implementation defaults to align with the expected default serialization behavior. -- *Fixed:* The `TypedHttpClientBase` fixed to handle where the `requestUri` parameter is only a query string and not a path. - -## v1.0.1 -- *New:* Initial publish to GitHub/NuGet. \ No newline at end of file +## v4.0.0 +- This is a **major** version release; a re-imagine / re-invention of the existing capabilities to enable a more modern, flexible and maintainable codebase. + - This release contains **significant breaking changes** - there is **no** upgrade path from the previous `v3.x` versions; however, the core capabilities and patterns remain largely consistent. + - A number of capabilities have been removed as they were not widely used, considered legacy/obsolete, or there are better alternatives available. + - Not all existing capabilities have been re-implemented in this release; the intention is to (re-)add further capabilities in future releases as required. + +## v3.* and earlier +- These versions are now considered **legacy** and are no longer maintained; there are no further updates planned for these versions, including bug fixes or security patches. \ No newline at end of file diff --git a/Common.targets b/Common.targets deleted file mode 100644 index 2283c31e..00000000 --- a/Common.targets +++ /dev/null @@ -1,39 +0,0 @@ - - - 3.31.0 - preview - Avanade - Avanade - Avanade (c) - https://github.com/Avanade/CoreEx - https://github.com/Avanade/CoreEx - true - false - strong-name-key.snk - git - false - https://github.com/Avanade/CoreEx/raw/main/images/Logo256x256.png - Logo256x256.png - true - MIT - $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb - true - enable - true - true - snupkg - true - true - true - true - true - false - - - - - \ - true - - - \ No newline at end of file diff --git a/CoreEx.sln b/CoreEx.sln index 6be1c777..b5488a0b 100644 --- a/CoreEx.sln +++ b/CoreEx.sln @@ -1,274 +1,873 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.32112.339 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11206.111 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{D5BD5701-A950-4E69-8EDA-F40CD0B47278}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx", "src\CoreEx\CoreEx.csproj", "{AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{414D0A8D-ED8F-4373-A5C1-306A571974DF}" ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .filenesting.json = .filenesting.json + .gitignore = .gitignore CHANGELOG.md = CHANGELOG.md - .github\workflows\CI.yml = .github\workflows\CI.yml CODE_OF_CONDUCT.md = CODE_OF_CONDUCT.md - Common.targets = Common.targets CONTRIBUTING.md = CONTRIBUTING.md + Directory.Packages.props = Directory.Packages.props + docker-compose.yml = docker-compose.yml LICENCE = LICENCE nuget-publish.ps1 = nuget-publish.ps1 README.md = README.md SECURITY.md = SECURITY.md EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx", "src\CoreEx\CoreEx.csproj", "{A4B64F4F-224D-492C-8B0D-DAB27D510007}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.RefData", "src\CoreEx.RefData\CoreEx.RefData.csproj", "{F3F98268-80E9-4441-B6C9-8D384EE9C857}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Data", "src\CoreEx.Data\CoreEx.Data.csproj", "{56892C3A-9C97-40D5-80EA-2F677129B420}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D37F89FC-A03D-4501-8414-34AE0C6FC765}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Test.Unit", "tests\CoreEx.Test.Unit\CoreEx.Test.Unit.csproj", "{F8CA814A-2885-4A8E-B320-87B53C72432F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F31B53BC-66DD-4844-8349-D4B0CB04EA12}" + ProjectSection(SolutionItems) = preProject + src\Directory.Build.props = src\Directory.Build.props + src\Directory.Build.targets = src\Directory.Build.targets + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Data.Test.Unit", "tests\CoreEx.Data.Test.Unit\CoreEx.Data.Test.Unit.csproj", "{E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.RefData.Test.Unit", "tests\CoreEx.RefData.Test.Unit\CoreEx.RefData.Test.Unit.csproj", "{9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Events", "src\CoreEx.Events\CoreEx.Events.csproj", "{330BE032-03F9-4754-8675-BDE6FAEC3AD3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Events.Test.Unit", "tests\CoreEx.Events.Test.Unit\CoreEx.Events.Test.Unit.csproj", "{74D21189-79FA-4DB9-A9FA-CBF525C65636}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.AspNetCore", "src\CoreEx.AspNetCore\CoreEx.AspNetCore.csproj", "{07EF31E2-5622-4F8B-9B91-BB49F8D581DC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.AspNetCore.Test.Unit", "tests\CoreEx.AspNetCore.Test.Unit\CoreEx.AspNetCore.Test.Unit.csproj", "{5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.AspNetCore.Test.Api", "tests\CoreEx.AspNetCore.Test.Api\CoreEx.AspNetCore.Test.Api.csproj", "{21775DC9-1A2B-4F56-BE90-0747EB1C45CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Validation", "src\CoreEx.Validation\CoreEx.Validation.csproj", "{753E4168-865B-45CD-8998-D1AD78DF5A88}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Validation.Test.Unit", "tests\CoreEx.Validation.Test.Unit\CoreEx.Validation.Test.Unit.csproj", "{FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Database", "src\CoreEx.Database\CoreEx.Database.csproj", "{F796D99C-88A2-42E8-9A18-0705CA9EA28E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Database.SqlServer", "src\CoreEx.Database.SqlServer\CoreEx.Database.SqlServer.csproj", "{BEE91E61-7F8C-4C4B-B234-4AC32218E533}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Database.Test.Unit", "tests\CoreEx.Database.Test.Unit\CoreEx.Database.Test.Unit.csproj", "{BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Database.SqlServer.Test.Unit", "tests\CoreEx.Database.SqlServer.Test.Unit\CoreEx.Database.SqlServer.Test.Unit.csproj", "{2095C6E7-7404-48D3-8EEB-3DCD386F414B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.AspNetCore.NSwag", "src\CoreEx.AspNetCore.NSwag\CoreEx.AspNetCore.NSwag.csproj", "{A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Caching.FusionCache", "src\CoreEx.Caching.FusionCache\CoreEx.Caching.FusionCache.csproj", "{D922722D-0F99-4FC4-B82D-38911B290021}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Caching.Redis.Test.Unit", "tests\CoreEx.Caching.Redis.Test.Unit\CoreEx.Caching.Redis.Test.Unit.csproj", "{F105BB1C-37CC-407E-AD2A-B22494730554}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.DomainDriven", "src\CoreEx.DomainDriven\CoreEx.DomainDriven.csproj", "{A860CC32-62ED-46A5-91B6-4A3B1E358614}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.DomainDriven.Test.Unit", "tests\CoreEx.DomainDriven.Test.Unit\CoreEx.DomainDriven.Test.Unit.csproj", "{CAC90D9E-BB00-4A70-AFCC-606720C211A3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Database.SqlServer.Test.Console", "tests\CoreEx.Database.SqlServer.Test.Console\CoreEx.Database.SqlServer.Test.Console.csproj", "{84263CA4-96CA-4C43-B34A-B084E3860695}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.EntityFrameworkCore", "src\CoreEx.EntityFrameworkCore\CoreEx.EntityFrameworkCore.csproj", "{4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5B5342D2-2392-4EB8-9933-A21DB9416534}" + ProjectSection(SolutionItems) = preProject + samples\Directory.Build.props = samples\Directory.Build.props + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Products", "Products", "{3467DBAC-5C5C-4FED-8EB5-95F5D3FE12EC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Contracts", "samples\src\Contoso.Products.Contracts\Contoso.Products.Contracts.csproj", "{B4B6388D-961C-4F5B-809E-CDC4DEDD3316}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Database", "samples\src\Contoso.Products.Database\Contoso.Products.Database.csproj", "{592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{51654FA6-7BAC-4DD9-8438-99D9A5EDEC4F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{86C93AEE-6E67-44EB-BE96-168DFD26311D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Newtonsoft", "src\CoreEx.Newtonsoft\CoreEx.Newtonsoft.csproj", "{14A7663B-134D-4C5E-8C61-4A164F645528}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{3CEF11D5-9320-4BDA-A7A8-5D1FCE868FE3}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3145DCB9-98FB-4519-BCC0-75A22A252EDC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Infrastructure", "samples\src\Contoso.Products.Infrastructure\Contoso.Products.Infrastructure.csproj", "{08BCDA3B-9E4A-40D0-8D28-88256E5812CE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Test", "tests\CoreEx.Test\CoreEx.Test.csproj", "{29C58371-9B52-47DA-99A0-D3B696BA74C9}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Application", "samples\src\Contoso.Products.Application\Contoso.Products.Application.csproj", "{31D994C3-EC90-4D9E-8CD9-469DE1AF4241}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.TestFunction", "tests\CoreEx.TestFunction\CoreEx.TestFunction.csproj", "{284E43D8-26F9-4D9C-8C71-9BD4436FBD8A}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "hosts", "hosts", "{FCCFE5E0-1D23-4DA9-A4E5-A05E139A169C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.TestApi", "tests\CoreEx.TestApi\CoreEx.TestApi.csproj", "{AE9992CC-CCC1-46C2-B78C-F8F55A41D4DC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Api", "samples\src\Contoso.Products.Api\Contoso.Products.Api.csproj", "{5712A8E1-4755-40FA-8258-78682C91282C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.FluentValidation", "src\CoreEx.FluentValidation\CoreEx.FluentValidation.csproj", "{40AFC397-8B33-45D2-B16B-457ABF8C3D8D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Test.Common", "samples\tests\Contoso.Products.Test.Common\Contoso.Products.Test.Common.csproj", "{6B6900F1-6F6F-4190-98AF-DBF6108BFA83}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{4B6BC31E-93B1-42B0-AE09-AD85AC4DB657}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Test.Api", "samples\tests\Contoso.Products.Test.Api\Contoso.Products.Test.Api.csproj", "{D260C63F-8769-409B-B2E4-1CD597A5F4A4}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{1B711C83-4E75-4A94-9B89-09DF08FF6602}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.UnitTesting", "src\CoreEx.UnitTesting\CoreEx.UnitTesting.csproj", "{F527AE41-5892-4C70-9E61-235AA6CD876C}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "My.Hr", "My.Hr", "{F53B0E83-87F8-4679-94B8-268FE6A9C0AD}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "servicebus", "servicebus", "{04C37037-FC54-4036-9FBA-970200F6E118}" ProjectSection(SolutionItems) = preProject - docker-compose.myHr.override.yml = docker-compose.myHr.override.yml - docker-compose.myHr.yml = docker-compose.myHr.yml + servicebus\Config.json = servicebus\Config.json EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "My.Hr.Api", "samples\My.Hr\My.Hr.Api\My.Hr.Api.csproj", "{EC6BE86F-231A-4CA6-974E-27DDD290BCEE}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Azure.Messaging.ServiceBus", "src\CoreEx.Azure.Messaging.ServiceBus\CoreEx.Azure.Messaging.ServiceBus.csproj", "{F9541022-E9C3-45A7-82E4-A9CD79327D4C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "My.Hr.Business", "samples\My.Hr\My.Hr.Business\My.Hr.Business.csproj", "{616C2FB0-02FB-4B9A-8092-325DA728F76F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Azure.Messaging.ServiceBus.Test.Unit", "tests\CoreEx.Azure.Messaging.ServiceBus.Test.Unit\CoreEx.Azure.Messaging.ServiceBus.Test.Unit.csproj", "{FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "My.Hr.Functions", "samples\My.Hr\My.Hr.Functions\My.Hr.Functions.csproj", "{8280D72B-3FA8-43D6-95FC-4133F6B415F5}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Outbox.Relay", "samples\src\Contoso.Products.Outbox.Relay\Contoso.Products.Outbox.Relay.csproj", "{E1598991-E502-467F-8005-D8A479B14505}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "My.Hr.UnitTest", "samples\My.Hr\My.Hr.UnitTest\My.Hr.UnitTest.csproj", "{CB46A8D4-60BE-48C9-9FCC-C62017F195AB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Subscribe", "samples\src\Contoso.Products.Subscribe\Contoso.Products.Subscribe.csproj", "{A13CFF3E-780E-472E-8FC6-824E50392D81}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "My.Hr.Database", "samples\My.Hr\My.Hr.Database\My.Hr.Database.csproj", "{86CEE51A-CCAB-4E35-835B-9B67C3D637C6}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aspire", "Aspire", "{4943BF85-38ED-4E0D-9FD7-1CC3D24FE106}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Azure", "src\CoreEx.Azure\CoreEx.Azure.csproj", "{431795AC-9CDC-4562-B422-B633FE725810}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Aspire", "samples\aspire\Contoso.Aspire\Contoso.Aspire.csproj", "{61666AE0-B7C2-4C46-BFF2-85F109BA90BB}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Validation", "src\CoreEx.Validation\CoreEx.Validation.csproj", "{4AFA8838-7DF1-487E-8B72-452E4EFA3AD6}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shopping", "Shopping", "{B8E07BB8-263C-49C7-BD31-294E1CF9CA4F}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Database", "src\CoreEx.Database\CoreEx.Database.csproj", "{6F12636A-4720-4A82-A1F6-ED29E7876F90}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Database", "samples\src\Contoso.Shopping.Database\Contoso.Shopping.Database.csproj", "{FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.EntityFrameworkCore", "src\CoreEx.EntityFrameworkCore\CoreEx.EntityFrameworkCore.csproj", "{9824E0A9-B9A6-49B1-8B7C-A2E157406C66}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{83C087DA-703F-41A5-994B-C132EB812835}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.AutoMapper", "src\CoreEx.AutoMapper\CoreEx.AutoMapper.csproj", "{F3384ADC-1DA8-4538-B991-DBD2BC591AF1}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Contracts", "samples\src\Contoso.Shopping.Contracts\Contoso.Shopping.Contracts.csproj", "{B723FF69-AD3A-4CC2-8606-6E21B33BA51E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Cosmos", "src\CoreEx.Cosmos\CoreEx.Cosmos.csproj", "{8D0CC3FD-65C2-4302-99F4-A90AC7680E1B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Infrastructure", "samples\src\Contoso.Shopping.Infrastructure\Contoso.Shopping.Infrastructure.csproj", "{53B6C1F1-E94A-41A6-B091-A2F774B9BC48}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Cosmos.Test", "tests\CoreEx.Cosmos.Test\CoreEx.Cosmos.Test.csproj", "{C8021CF0-006F-427C-827F-B997F26E5FF6}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Application", "samples\src\Contoso.Shopping.Application\Contoso.Shopping.Application.csproj", "{59223667-437C-419A-8C91-C2E9040A5551}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Database.SqlServer", "src\CoreEx.Database.SqlServer\CoreEx.Database.SqlServer.csproj", "{BB2FF5B1-67BC-4EC9-BB51-5980982030C2}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A153E771-21A8-4321-A5F1-1F0A0CE17C30}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Database.MySql", "src\CoreEx.Database.MySql\CoreEx.Database.MySql.csproj", "{9A583DF9-5851-4CE1-97A1-9DC406792022}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "hosts", "hosts", "{E01B4001-A2EB-4134-8AA8-8A32F06F53FE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Solace", "src\CoreEx.Solace\CoreEx.Solace.csproj", "{FB6495D7-931C-4280-9499-69D08A613B54}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Subscribe", "samples\src\Contoso.Shopping.Subscribe\Contoso.Shopping.Subscribe.csproj", "{DA7A3683-2913-76F1-7A13-35A0765F6CA1}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Solace.Test", "tests\CoreEx.Solace.Test\CoreEx.Solace.Test.csproj", "{B2C31D4A-87EE-4568-859B-DC7E8EA1043D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Test.Outbox.Relay", "samples\tests\Contoso.Products.Test.Outbox.Relay\Contoso.Products.Test.Outbox.Relay.csproj", "{697A0BAA-F594-4367-9B64-D2B1F6A2EF27}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.AspNetCore", "src\CoreEx.AspNetCore\CoreEx.AspNetCore.csproj", "{804FFA4D-D530-46A7-9ECD-D2D42987161E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Domain", "samples\src\Contoso.Shopping.Domain\Contoso.Shopping.Domain.csproj", "{48E936FE-E657-4F6F-808B-897EE7D75A20}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Dataverse", "src\CoreEx.Dataverse\CoreEx.Dataverse.csproj", "{E7CF0E06-BDC3-49E5-AC52-D65894ED6AFF}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Api", "samples\src\Contoso.Shopping.Api\Contoso.Shopping.Api.csproj", "{8769A8B1-559C-17A5-949B-762ECC85561D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.OData", "src\CoreEx.OData\CoreEx.OData.csproj", "{2A31E887-0340-47D2-AF80-D88D2866C80E}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{40501BBE-C1F6-421F-A251-0DFCC3B69DB1}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UnitTesting", "UnitTesting", "{D2C61D4A-2A6D-4284-BF9D-09F51BA735B8}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Test.Api", "samples\tests\Contoso.Shopping.Test.Api\Contoso.Shopping.Test.Api.csproj", "{4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.UnitTesting", "src\CoreEx.UnitTesting\CoreEx.UnitTesting.csproj", "{AC247FD3-5F9F-4DD2-B0CB-1C9CD1EB98D2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Test.Common", "samples\tests\Contoso.Shopping.Test.Common\Contoso.Shopping.Test.Common.csproj", "{9909DEF7-70E0-4D1B-BB99-B77681634BCF}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.TestFunctionIso", "tests\CoreEx.TestFunctionIso\CoreEx.TestFunctionIso.csproj", "{6F7B4F1E-3C3A-4CD7-A9BF-973A5053C1C8}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Test.Unit", "samples\tests\Contoso.Products.Test.Unit\Contoso.Products.Test.Unit.csproj", "{F10366C7-4BD8-4EE5-8464-77E372F8AC09}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Test2", "tests\CoreEx.Test2\CoreEx.Test2.csproj", "{910B5894-46BC-4427-95D6-2804F06458E3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.E2E.Runner", "samples\tests\Contoso.E2E.Runner\Contoso.E2E.Runner.csproj", "{48955503-ECE7-4E3E-A1A1-8A04AA133724}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Database.Postgres", "src\CoreEx.Database.Postgres\CoreEx.Database.Postgres.csproj", "{C042AC2A-415D-432E-83FA-B911FD9ED378}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Outbox.Relay", "samples\src\Contoso.Shopping.Outbox.Relay\Contoso.Shopping.Outbox.Relay.csproj", "{E47B85E3-AB64-C985-5561-3BA06AEB256F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Data", "src\CoreEx.Data\CoreEx.Data.csproj", "{B927138A-1DCA-4BA6-A9E5-E5DA6446DABC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Test.Subscribe", "samples\tests\Contoso.Products.Test.Subscribe\Contoso.Products.Test.Subscribe.csproj", "{4B987914-01EE-48B4-B645-A6F469297853}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.UnitTesting.Azure.Functions", "src\CoreEx.UnitTesting.Azure.Functions\CoreEx.UnitTesting.Azure.Functions.csproj", "{2BBB766A-D8A2-47B4-AD42-1FD96051604C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Generator", "gen\CoreEx.Generator\CoreEx.Generator.csproj", "{54CD8587-0F45-2C2C-7AE4-BB92254202F5}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.UnitTesting.Azure.ServiceBus", "src\CoreEx.UnitTesting.Azure.ServiceBus\CoreEx.UnitTesting.Azure.ServiceBus.csproj", "{CA21C777-2592-4E1A-970B-0888F21AB0D7}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "gen", "gen", "{570F3635-BEB1-4067-B10F-33DD890BDBD4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A4B64F4F-224D-492C-8B0D-DAB27D510007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A4B64F4F-224D-492C-8B0D-DAB27D510007}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A4B64F4F-224D-492C-8B0D-DAB27D510007}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A4B64F4F-224D-492C-8B0D-DAB27D510007}.Release|Any CPU.Build.0 = Release|Any CPU - {14A7663B-134D-4C5E-8C61-4A164F645528}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {14A7663B-134D-4C5E-8C61-4A164F645528}.Debug|Any CPU.Build.0 = Debug|Any CPU - {14A7663B-134D-4C5E-8C61-4A164F645528}.Release|Any CPU.ActiveCfg = Release|Any CPU - {14A7663B-134D-4C5E-8C61-4A164F645528}.Release|Any CPU.Build.0 = Release|Any CPU - {29C58371-9B52-47DA-99A0-D3B696BA74C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {29C58371-9B52-47DA-99A0-D3B696BA74C9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {29C58371-9B52-47DA-99A0-D3B696BA74C9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {29C58371-9B52-47DA-99A0-D3B696BA74C9}.Release|Any CPU.Build.0 = Release|Any CPU - {284E43D8-26F9-4D9C-8C71-9BD4436FBD8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {284E43D8-26F9-4D9C-8C71-9BD4436FBD8A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {284E43D8-26F9-4D9C-8C71-9BD4436FBD8A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {284E43D8-26F9-4D9C-8C71-9BD4436FBD8A}.Release|Any CPU.Build.0 = Release|Any CPU - {AE9992CC-CCC1-46C2-B78C-F8F55A41D4DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AE9992CC-CCC1-46C2-B78C-F8F55A41D4DC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AE9992CC-CCC1-46C2-B78C-F8F55A41D4DC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AE9992CC-CCC1-46C2-B78C-F8F55A41D4DC}.Release|Any CPU.Build.0 = Release|Any CPU - {40AFC397-8B33-45D2-B16B-457ABF8C3D8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {40AFC397-8B33-45D2-B16B-457ABF8C3D8D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {40AFC397-8B33-45D2-B16B-457ABF8C3D8D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {40AFC397-8B33-45D2-B16B-457ABF8C3D8D}.Release|Any CPU.Build.0 = Release|Any CPU - {EC6BE86F-231A-4CA6-974E-27DDD290BCEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EC6BE86F-231A-4CA6-974E-27DDD290BCEE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EC6BE86F-231A-4CA6-974E-27DDD290BCEE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EC6BE86F-231A-4CA6-974E-27DDD290BCEE}.Release|Any CPU.Build.0 = Release|Any CPU - {616C2FB0-02FB-4B9A-8092-325DA728F76F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {616C2FB0-02FB-4B9A-8092-325DA728F76F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {616C2FB0-02FB-4B9A-8092-325DA728F76F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {616C2FB0-02FB-4B9A-8092-325DA728F76F}.Release|Any CPU.Build.0 = Release|Any CPU - {8280D72B-3FA8-43D6-95FC-4133F6B415F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8280D72B-3FA8-43D6-95FC-4133F6B415F5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8280D72B-3FA8-43D6-95FC-4133F6B415F5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8280D72B-3FA8-43D6-95FC-4133F6B415F5}.Release|Any CPU.Build.0 = Release|Any CPU - {CB46A8D4-60BE-48C9-9FCC-C62017F195AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CB46A8D4-60BE-48C9-9FCC-C62017F195AB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CB46A8D4-60BE-48C9-9FCC-C62017F195AB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CB46A8D4-60BE-48C9-9FCC-C62017F195AB}.Release|Any CPU.Build.0 = Release|Any CPU - {86CEE51A-CCAB-4E35-835B-9B67C3D637C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {86CEE51A-CCAB-4E35-835B-9B67C3D637C6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {86CEE51A-CCAB-4E35-835B-9B67C3D637C6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {86CEE51A-CCAB-4E35-835B-9B67C3D637C6}.Release|Any CPU.Build.0 = Release|Any CPU - {431795AC-9CDC-4562-B422-B633FE725810}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {431795AC-9CDC-4562-B422-B633FE725810}.Debug|Any CPU.Build.0 = Debug|Any CPU - {431795AC-9CDC-4562-B422-B633FE725810}.Release|Any CPU.ActiveCfg = Release|Any CPU - {431795AC-9CDC-4562-B422-B633FE725810}.Release|Any CPU.Build.0 = Release|Any CPU - {4AFA8838-7DF1-487E-8B72-452E4EFA3AD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4AFA8838-7DF1-487E-8B72-452E4EFA3AD6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4AFA8838-7DF1-487E-8B72-452E4EFA3AD6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4AFA8838-7DF1-487E-8B72-452E4EFA3AD6}.Release|Any CPU.Build.0 = Release|Any CPU - {6F12636A-4720-4A82-A1F6-ED29E7876F90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6F12636A-4720-4A82-A1F6-ED29E7876F90}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6F12636A-4720-4A82-A1F6-ED29E7876F90}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6F12636A-4720-4A82-A1F6-ED29E7876F90}.Release|Any CPU.Build.0 = Release|Any CPU - {9824E0A9-B9A6-49B1-8B7C-A2E157406C66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9824E0A9-B9A6-49B1-8B7C-A2E157406C66}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9824E0A9-B9A6-49B1-8B7C-A2E157406C66}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9824E0A9-B9A6-49B1-8B7C-A2E157406C66}.Release|Any CPU.Build.0 = Release|Any CPU - {F3384ADC-1DA8-4538-B991-DBD2BC591AF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F3384ADC-1DA8-4538-B991-DBD2BC591AF1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F3384ADC-1DA8-4538-B991-DBD2BC591AF1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F3384ADC-1DA8-4538-B991-DBD2BC591AF1}.Release|Any CPU.Build.0 = Release|Any CPU - {8D0CC3FD-65C2-4302-99F4-A90AC7680E1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8D0CC3FD-65C2-4302-99F4-A90AC7680E1B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8D0CC3FD-65C2-4302-99F4-A90AC7680E1B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8D0CC3FD-65C2-4302-99F4-A90AC7680E1B}.Release|Any CPU.Build.0 = Release|Any CPU - {C8021CF0-006F-427C-827F-B997F26E5FF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C8021CF0-006F-427C-827F-B997F26E5FF6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C8021CF0-006F-427C-827F-B997F26E5FF6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C8021CF0-006F-427C-827F-B997F26E5FF6}.Release|Any CPU.Build.0 = Release|Any CPU - {BB2FF5B1-67BC-4EC9-BB51-5980982030C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BB2FF5B1-67BC-4EC9-BB51-5980982030C2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BB2FF5B1-67BC-4EC9-BB51-5980982030C2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BB2FF5B1-67BC-4EC9-BB51-5980982030C2}.Release|Any CPU.Build.0 = Release|Any CPU - {9A583DF9-5851-4CE1-97A1-9DC406792022}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9A583DF9-5851-4CE1-97A1-9DC406792022}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9A583DF9-5851-4CE1-97A1-9DC406792022}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9A583DF9-5851-4CE1-97A1-9DC406792022}.Release|Any CPU.Build.0 = Release|Any CPU - {FB6495D7-931C-4280-9499-69D08A613B54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FB6495D7-931C-4280-9499-69D08A613B54}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FB6495D7-931C-4280-9499-69D08A613B54}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FB6495D7-931C-4280-9499-69D08A613B54}.Release|Any CPU.Build.0 = Release|Any CPU - {B2C31D4A-87EE-4568-859B-DC7E8EA1043D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2C31D4A-87EE-4568-859B-DC7E8EA1043D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2C31D4A-87EE-4568-859B-DC7E8EA1043D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2C31D4A-87EE-4568-859B-DC7E8EA1043D}.Release|Any CPU.Build.0 = Release|Any CPU - {804FFA4D-D530-46A7-9ECD-D2D42987161E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {804FFA4D-D530-46A7-9ECD-D2D42987161E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {804FFA4D-D530-46A7-9ECD-D2D42987161E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {804FFA4D-D530-46A7-9ECD-D2D42987161E}.Release|Any CPU.Build.0 = Release|Any CPU - {E7CF0E06-BDC3-49E5-AC52-D65894ED6AFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E7CF0E06-BDC3-49E5-AC52-D65894ED6AFF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E7CF0E06-BDC3-49E5-AC52-D65894ED6AFF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E7CF0E06-BDC3-49E5-AC52-D65894ED6AFF}.Release|Any CPU.Build.0 = Release|Any CPU - {2A31E887-0340-47D2-AF80-D88D2866C80E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2A31E887-0340-47D2-AF80-D88D2866C80E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2A31E887-0340-47D2-AF80-D88D2866C80E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2A31E887-0340-47D2-AF80-D88D2866C80E}.Release|Any CPU.Build.0 = Release|Any CPU - {AC247FD3-5F9F-4DD2-B0CB-1C9CD1EB98D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AC247FD3-5F9F-4DD2-B0CB-1C9CD1EB98D2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AC247FD3-5F9F-4DD2-B0CB-1C9CD1EB98D2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AC247FD3-5F9F-4DD2-B0CB-1C9CD1EB98D2}.Release|Any CPU.Build.0 = Release|Any CPU - {6F7B4F1E-3C3A-4CD7-A9BF-973A5053C1C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6F7B4F1E-3C3A-4CD7-A9BF-973A5053C1C8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6F7B4F1E-3C3A-4CD7-A9BF-973A5053C1C8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6F7B4F1E-3C3A-4CD7-A9BF-973A5053C1C8}.Release|Any CPU.Build.0 = Release|Any CPU - {910B5894-46BC-4427-95D6-2804F06458E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {910B5894-46BC-4427-95D6-2804F06458E3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {910B5894-46BC-4427-95D6-2804F06458E3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {910B5894-46BC-4427-95D6-2804F06458E3}.Release|Any CPU.Build.0 = Release|Any CPU - {C042AC2A-415D-432E-83FA-B911FD9ED378}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C042AC2A-415D-432E-83FA-B911FD9ED378}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C042AC2A-415D-432E-83FA-B911FD9ED378}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C042AC2A-415D-432E-83FA-B911FD9ED378}.Release|Any CPU.Build.0 = Release|Any CPU - {B927138A-1DCA-4BA6-A9E5-E5DA6446DABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B927138A-1DCA-4BA6-A9E5-E5DA6446DABC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B927138A-1DCA-4BA6-A9E5-E5DA6446DABC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B927138A-1DCA-4BA6-A9E5-E5DA6446DABC}.Release|Any CPU.Build.0 = Release|Any CPU - {2BBB766A-D8A2-47B4-AD42-1FD96051604C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2BBB766A-D8A2-47B4-AD42-1FD96051604C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2BBB766A-D8A2-47B4-AD42-1FD96051604C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2BBB766A-D8A2-47B4-AD42-1FD96051604C}.Release|Any CPU.Build.0 = Release|Any CPU - {CA21C777-2592-4E1A-970B-0888F21AB0D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CA21C777-2592-4E1A-970B-0888F21AB0D7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CA21C777-2592-4E1A-970B-0888F21AB0D7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CA21C777-2592-4E1A-970B-0888F21AB0D7}.Release|Any CPU.Build.0 = Release|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Debug|x64.Build.0 = Debug|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Debug|x86.Build.0 = Debug|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Release|Any CPU.Build.0 = Release|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Release|x64.ActiveCfg = Release|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Release|x64.Build.0 = Release|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Release|x86.ActiveCfg = Release|Any CPU + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6}.Release|x86.Build.0 = Release|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Debug|x64.ActiveCfg = Debug|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Debug|x64.Build.0 = Debug|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Debug|x86.ActiveCfg = Debug|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Debug|x86.Build.0 = Debug|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Release|Any CPU.Build.0 = Release|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Release|x64.ActiveCfg = Release|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Release|x64.Build.0 = Release|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Release|x86.ActiveCfg = Release|Any CPU + {F3F98268-80E9-4441-B6C9-8D384EE9C857}.Release|x86.Build.0 = Release|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Debug|x64.ActiveCfg = Debug|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Debug|x64.Build.0 = Debug|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Debug|x86.ActiveCfg = Debug|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Debug|x86.Build.0 = Debug|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Release|Any CPU.Build.0 = Release|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Release|x64.ActiveCfg = Release|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Release|x64.Build.0 = Release|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Release|x86.ActiveCfg = Release|Any CPU + {56892C3A-9C97-40D5-80EA-2F677129B420}.Release|x86.Build.0 = Release|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Debug|x64.ActiveCfg = Debug|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Debug|x64.Build.0 = Debug|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Debug|x86.ActiveCfg = Debug|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Debug|x86.Build.0 = Debug|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Release|Any CPU.Build.0 = Release|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Release|x64.ActiveCfg = Release|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Release|x64.Build.0 = Release|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Release|x86.ActiveCfg = Release|Any CPU + {F8CA814A-2885-4A8E-B320-87B53C72432F}.Release|x86.Build.0 = Release|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Debug|x64.ActiveCfg = Debug|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Debug|x64.Build.0 = Debug|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Debug|x86.ActiveCfg = Debug|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Debug|x86.Build.0 = Debug|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Release|Any CPU.Build.0 = Release|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Release|x64.ActiveCfg = Release|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Release|x64.Build.0 = Release|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Release|x86.ActiveCfg = Release|Any CPU + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08}.Release|x86.Build.0 = Release|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Debug|x64.ActiveCfg = Debug|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Debug|x64.Build.0 = Debug|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Debug|x86.ActiveCfg = Debug|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Debug|x86.Build.0 = Debug|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Release|Any CPU.Build.0 = Release|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Release|x64.ActiveCfg = Release|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Release|x64.Build.0 = Release|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Release|x86.ActiveCfg = Release|Any CPU + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9}.Release|x86.Build.0 = Release|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Debug|x64.ActiveCfg = Debug|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Debug|x64.Build.0 = Debug|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Debug|x86.ActiveCfg = Debug|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Debug|x86.Build.0 = Debug|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Release|Any CPU.Build.0 = Release|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Release|x64.ActiveCfg = Release|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Release|x64.Build.0 = Release|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Release|x86.ActiveCfg = Release|Any CPU + {330BE032-03F9-4754-8675-BDE6FAEC3AD3}.Release|x86.Build.0 = Release|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Debug|x64.ActiveCfg = Debug|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Debug|x64.Build.0 = Debug|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Debug|x86.ActiveCfg = Debug|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Debug|x86.Build.0 = Debug|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Release|Any CPU.Build.0 = Release|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Release|x64.ActiveCfg = Release|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Release|x64.Build.0 = Release|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Release|x86.ActiveCfg = Release|Any CPU + {74D21189-79FA-4DB9-A9FA-CBF525C65636}.Release|x86.Build.0 = Release|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Debug|x64.ActiveCfg = Debug|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Debug|x64.Build.0 = Debug|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Debug|x86.ActiveCfg = Debug|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Debug|x86.Build.0 = Debug|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Release|Any CPU.Build.0 = Release|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Release|x64.ActiveCfg = Release|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Release|x64.Build.0 = Release|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Release|x86.ActiveCfg = Release|Any CPU + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC}.Release|x86.Build.0 = Release|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Debug|x64.ActiveCfg = Debug|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Debug|x64.Build.0 = Debug|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Debug|x86.ActiveCfg = Debug|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Debug|x86.Build.0 = Debug|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Release|Any CPU.Build.0 = Release|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Release|x64.ActiveCfg = Release|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Release|x64.Build.0 = Release|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Release|x86.ActiveCfg = Release|Any CPU + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5}.Release|x86.Build.0 = Release|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Debug|x64.Build.0 = Debug|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Debug|x86.Build.0 = Debug|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Release|Any CPU.Build.0 = Release|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Release|x64.ActiveCfg = Release|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Release|x64.Build.0 = Release|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Release|x86.ActiveCfg = Release|Any CPU + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA}.Release|x86.Build.0 = Release|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Debug|Any CPU.Build.0 = Debug|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Debug|x64.ActiveCfg = Debug|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Debug|x64.Build.0 = Debug|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Debug|x86.ActiveCfg = Debug|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Debug|x86.Build.0 = Debug|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Release|Any CPU.ActiveCfg = Release|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Release|Any CPU.Build.0 = Release|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Release|x64.ActiveCfg = Release|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Release|x64.Build.0 = Release|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Release|x86.ActiveCfg = Release|Any CPU + {753E4168-865B-45CD-8998-D1AD78DF5A88}.Release|x86.Build.0 = Release|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Debug|x64.ActiveCfg = Debug|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Debug|x64.Build.0 = Debug|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Debug|x86.ActiveCfg = Debug|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Debug|x86.Build.0 = Debug|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Release|Any CPU.Build.0 = Release|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Release|x64.ActiveCfg = Release|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Release|x64.Build.0 = Release|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Release|x86.ActiveCfg = Release|Any CPU + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2}.Release|x86.Build.0 = Release|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Debug|x64.ActiveCfg = Debug|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Debug|x64.Build.0 = Debug|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Debug|x86.ActiveCfg = Debug|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Debug|x86.Build.0 = Debug|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Release|Any CPU.Build.0 = Release|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Release|x64.ActiveCfg = Release|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Release|x64.Build.0 = Release|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Release|x86.ActiveCfg = Release|Any CPU + {F796D99C-88A2-42E8-9A18-0705CA9EA28E}.Release|x86.Build.0 = Release|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Debug|x64.ActiveCfg = Debug|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Debug|x64.Build.0 = Debug|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Debug|x86.ActiveCfg = Debug|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Debug|x86.Build.0 = Debug|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Release|Any CPU.Build.0 = Release|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Release|x64.ActiveCfg = Release|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Release|x64.Build.0 = Release|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Release|x86.ActiveCfg = Release|Any CPU + {BEE91E61-7F8C-4C4B-B234-4AC32218E533}.Release|x86.Build.0 = Release|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Debug|x64.Build.0 = Debug|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Debug|x86.Build.0 = Debug|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Release|Any CPU.Build.0 = Release|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Release|x64.ActiveCfg = Release|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Release|x64.Build.0 = Release|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Release|x86.ActiveCfg = Release|Any CPU + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE}.Release|x86.Build.0 = Release|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Debug|x64.Build.0 = Debug|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Debug|x86.ActiveCfg = Debug|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Debug|x86.Build.0 = Debug|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Release|Any CPU.Build.0 = Release|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Release|x64.ActiveCfg = Release|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Release|x64.Build.0 = Release|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Release|x86.ActiveCfg = Release|Any CPU + {2095C6E7-7404-48D3-8EEB-3DCD386F414B}.Release|x86.Build.0 = Release|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Debug|x64.ActiveCfg = Debug|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Debug|x64.Build.0 = Debug|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Debug|x86.ActiveCfg = Debug|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Debug|x86.Build.0 = Debug|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Release|Any CPU.Build.0 = Release|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Release|x64.ActiveCfg = Release|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Release|x64.Build.0 = Release|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Release|x86.ActiveCfg = Release|Any CPU + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52}.Release|x86.Build.0 = Release|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Debug|x64.ActiveCfg = Debug|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Debug|x64.Build.0 = Debug|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Debug|x86.ActiveCfg = Debug|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Debug|x86.Build.0 = Debug|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Release|Any CPU.Build.0 = Release|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Release|x64.ActiveCfg = Release|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Release|x64.Build.0 = Release|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Release|x86.ActiveCfg = Release|Any CPU + {D922722D-0F99-4FC4-B82D-38911B290021}.Release|x86.Build.0 = Release|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Debug|x64.ActiveCfg = Debug|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Debug|x64.Build.0 = Debug|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Debug|x86.ActiveCfg = Debug|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Debug|x86.Build.0 = Debug|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Release|Any CPU.Build.0 = Release|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Release|x64.ActiveCfg = Release|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Release|x64.Build.0 = Release|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Release|x86.ActiveCfg = Release|Any CPU + {F105BB1C-37CC-407E-AD2A-B22494730554}.Release|x86.Build.0 = Release|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Debug|x64.ActiveCfg = Debug|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Debug|x64.Build.0 = Debug|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Debug|x86.ActiveCfg = Debug|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Debug|x86.Build.0 = Debug|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Release|Any CPU.Build.0 = Release|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Release|x64.ActiveCfg = Release|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Release|x64.Build.0 = Release|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Release|x86.ActiveCfg = Release|Any CPU + {A860CC32-62ED-46A5-91B6-4A3B1E358614}.Release|x86.Build.0 = Release|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Debug|x64.ActiveCfg = Debug|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Debug|x64.Build.0 = Debug|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Debug|x86.ActiveCfg = Debug|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Debug|x86.Build.0 = Debug|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Release|Any CPU.Build.0 = Release|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Release|x64.ActiveCfg = Release|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Release|x64.Build.0 = Release|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Release|x86.ActiveCfg = Release|Any CPU + {CAC90D9E-BB00-4A70-AFCC-606720C211A3}.Release|x86.Build.0 = Release|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Debug|x64.ActiveCfg = Debug|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Debug|x64.Build.0 = Debug|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Debug|x86.ActiveCfg = Debug|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Debug|x86.Build.0 = Debug|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Release|Any CPU.Build.0 = Release|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Release|x64.ActiveCfg = Release|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Release|x64.Build.0 = Release|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Release|x86.ActiveCfg = Release|Any CPU + {84263CA4-96CA-4C43-B34A-B084E3860695}.Release|x86.Build.0 = Release|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Debug|x64.ActiveCfg = Debug|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Debug|x64.Build.0 = Debug|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Debug|x86.Build.0 = Debug|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Release|Any CPU.Build.0 = Release|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Release|x64.ActiveCfg = Release|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Release|x64.Build.0 = Release|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Release|x86.ActiveCfg = Release|Any CPU + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF}.Release|x86.Build.0 = Release|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Debug|x64.Build.0 = Debug|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Debug|x86.Build.0 = Debug|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Release|Any CPU.Build.0 = Release|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Release|x64.ActiveCfg = Release|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Release|x64.Build.0 = Release|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Release|x86.ActiveCfg = Release|Any CPU + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316}.Release|x86.Build.0 = Release|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Debug|x64.ActiveCfg = Debug|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Debug|x64.Build.0 = Debug|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Debug|x86.ActiveCfg = Debug|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Debug|x86.Build.0 = Debug|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Release|Any CPU.Build.0 = Release|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Release|x64.ActiveCfg = Release|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Release|x64.Build.0 = Release|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Release|x86.ActiveCfg = Release|Any CPU + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50}.Release|x86.Build.0 = Release|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Debug|x64.ActiveCfg = Debug|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Debug|x64.Build.0 = Debug|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Debug|x86.ActiveCfg = Debug|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Debug|x86.Build.0 = Debug|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Release|Any CPU.Build.0 = Release|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Release|x64.ActiveCfg = Release|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Release|x64.Build.0 = Release|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Release|x86.ActiveCfg = Release|Any CPU + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE}.Release|x86.Build.0 = Release|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Debug|x64.ActiveCfg = Debug|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Debug|x64.Build.0 = Debug|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Debug|x86.ActiveCfg = Debug|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Debug|x86.Build.0 = Debug|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Release|Any CPU.Build.0 = Release|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Release|x64.ActiveCfg = Release|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Release|x64.Build.0 = Release|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Release|x86.ActiveCfg = Release|Any CPU + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241}.Release|x86.Build.0 = Release|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Debug|x64.Build.0 = Debug|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Debug|x86.Build.0 = Debug|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Release|Any CPU.Build.0 = Release|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Release|x64.ActiveCfg = Release|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Release|x64.Build.0 = Release|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Release|x86.ActiveCfg = Release|Any CPU + {5712A8E1-4755-40FA-8258-78682C91282C}.Release|x86.Build.0 = Release|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Debug|x64.ActiveCfg = Debug|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Debug|x64.Build.0 = Debug|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Debug|x86.ActiveCfg = Debug|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Debug|x86.Build.0 = Debug|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Release|Any CPU.Build.0 = Release|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Release|x64.ActiveCfg = Release|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Release|x64.Build.0 = Release|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Release|x86.ActiveCfg = Release|Any CPU + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83}.Release|x86.Build.0 = Release|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Debug|x64.ActiveCfg = Debug|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Debug|x64.Build.0 = Debug|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Debug|x86.ActiveCfg = Debug|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Debug|x86.Build.0 = Debug|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Release|Any CPU.Build.0 = Release|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Release|x64.ActiveCfg = Release|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Release|x64.Build.0 = Release|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Release|x86.ActiveCfg = Release|Any CPU + {D260C63F-8769-409B-B2E4-1CD597A5F4A4}.Release|x86.Build.0 = Release|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Debug|x64.ActiveCfg = Debug|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Debug|x64.Build.0 = Debug|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Debug|x86.ActiveCfg = Debug|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Debug|x86.Build.0 = Debug|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Release|Any CPU.Build.0 = Release|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Release|x64.ActiveCfg = Release|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Release|x64.Build.0 = Release|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Release|x86.ActiveCfg = Release|Any CPU + {F527AE41-5892-4C70-9E61-235AA6CD876C}.Release|x86.Build.0 = Release|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Debug|x64.ActiveCfg = Debug|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Debug|x64.Build.0 = Debug|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Debug|x86.ActiveCfg = Debug|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Debug|x86.Build.0 = Debug|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Release|Any CPU.Build.0 = Release|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Release|x64.ActiveCfg = Release|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Release|x64.Build.0 = Release|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Release|x86.ActiveCfg = Release|Any CPU + {F9541022-E9C3-45A7-82E4-A9CD79327D4C}.Release|x86.Build.0 = Release|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Debug|x64.ActiveCfg = Debug|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Debug|x64.Build.0 = Debug|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Debug|x86.ActiveCfg = Debug|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Debug|x86.Build.0 = Debug|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Release|Any CPU.Build.0 = Release|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Release|x64.ActiveCfg = Release|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Release|x64.Build.0 = Release|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Release|x86.ActiveCfg = Release|Any CPU + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6}.Release|x86.Build.0 = Release|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Debug|x64.ActiveCfg = Debug|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Debug|x64.Build.0 = Debug|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Debug|x86.ActiveCfg = Debug|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Debug|x86.Build.0 = Debug|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Release|Any CPU.Build.0 = Release|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Release|x64.ActiveCfg = Release|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Release|x64.Build.0 = Release|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Release|x86.ActiveCfg = Release|Any CPU + {E1598991-E502-467F-8005-D8A479B14505}.Release|x86.Build.0 = Release|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Debug|x64.ActiveCfg = Debug|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Debug|x64.Build.0 = Debug|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Debug|x86.ActiveCfg = Debug|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Debug|x86.Build.0 = Debug|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Release|Any CPU.Build.0 = Release|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Release|x64.ActiveCfg = Release|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Release|x64.Build.0 = Release|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Release|x86.ActiveCfg = Release|Any CPU + {A13CFF3E-780E-472E-8FC6-824E50392D81}.Release|x86.Build.0 = Release|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Debug|x64.ActiveCfg = Debug|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Debug|x64.Build.0 = Debug|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Debug|x86.ActiveCfg = Debug|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Debug|x86.Build.0 = Debug|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Release|Any CPU.Build.0 = Release|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Release|x64.ActiveCfg = Release|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Release|x64.Build.0 = Release|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Release|x86.ActiveCfg = Release|Any CPU + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB}.Release|x86.Build.0 = Release|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Debug|x64.Build.0 = Debug|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Debug|x86.Build.0 = Debug|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Release|Any CPU.Build.0 = Release|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Release|x64.ActiveCfg = Release|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Release|x64.Build.0 = Release|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Release|x86.ActiveCfg = Release|Any CPU + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD}.Release|x86.Build.0 = Release|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Debug|x64.ActiveCfg = Debug|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Debug|x64.Build.0 = Debug|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Debug|x86.ActiveCfg = Debug|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Debug|x86.Build.0 = Debug|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Release|Any CPU.Build.0 = Release|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Release|x64.ActiveCfg = Release|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Release|x64.Build.0 = Release|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Release|x86.ActiveCfg = Release|Any CPU + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E}.Release|x86.Build.0 = Release|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Debug|x64.ActiveCfg = Debug|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Debug|x64.Build.0 = Debug|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Debug|x86.ActiveCfg = Debug|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Debug|x86.Build.0 = Debug|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Release|Any CPU.Build.0 = Release|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Release|x64.ActiveCfg = Release|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Release|x64.Build.0 = Release|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Release|x86.ActiveCfg = Release|Any CPU + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48}.Release|x86.Build.0 = Release|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Debug|x64.ActiveCfg = Debug|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Debug|x64.Build.0 = Debug|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Debug|x86.ActiveCfg = Debug|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Debug|x86.Build.0 = Debug|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Release|Any CPU.Build.0 = Release|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Release|x64.ActiveCfg = Release|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Release|x64.Build.0 = Release|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Release|x86.ActiveCfg = Release|Any CPU + {59223667-437C-419A-8C91-C2E9040A5551}.Release|x86.Build.0 = Release|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Debug|x64.ActiveCfg = Debug|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Debug|x64.Build.0 = Debug|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Debug|x86.Build.0 = Debug|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Release|Any CPU.Build.0 = Release|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Release|x64.ActiveCfg = Release|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Release|x64.Build.0 = Release|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Release|x86.ActiveCfg = Release|Any CPU + {DA7A3683-2913-76F1-7A13-35A0765F6CA1}.Release|x86.Build.0 = Release|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Debug|Any CPU.Build.0 = Debug|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Debug|x64.ActiveCfg = Debug|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Debug|x64.Build.0 = Debug|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Debug|x86.ActiveCfg = Debug|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Debug|x86.Build.0 = Debug|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Release|Any CPU.ActiveCfg = Release|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Release|Any CPU.Build.0 = Release|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Release|x64.ActiveCfg = Release|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Release|x64.Build.0 = Release|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Release|x86.ActiveCfg = Release|Any CPU + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27}.Release|x86.Build.0 = Release|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Debug|x64.ActiveCfg = Debug|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Debug|x64.Build.0 = Debug|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Debug|x86.ActiveCfg = Debug|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Debug|x86.Build.0 = Debug|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Release|Any CPU.Build.0 = Release|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Release|x64.ActiveCfg = Release|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Release|x64.Build.0 = Release|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Release|x86.ActiveCfg = Release|Any CPU + {48E936FE-E657-4F6F-808B-897EE7D75A20}.Release|x86.Build.0 = Release|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Debug|x64.ActiveCfg = Debug|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Debug|x64.Build.0 = Debug|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Debug|x86.ActiveCfg = Debug|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Debug|x86.Build.0 = Debug|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Release|Any CPU.Build.0 = Release|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Release|x64.ActiveCfg = Release|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Release|x64.Build.0 = Release|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Release|x86.ActiveCfg = Release|Any CPU + {8769A8B1-559C-17A5-949B-762ECC85561D}.Release|x86.Build.0 = Release|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Debug|x64.ActiveCfg = Debug|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Debug|x64.Build.0 = Debug|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Debug|x86.ActiveCfg = Debug|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Debug|x86.Build.0 = Debug|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Release|Any CPU.Build.0 = Release|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Release|x64.ActiveCfg = Release|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Release|x64.Build.0 = Release|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Release|x86.ActiveCfg = Release|Any CPU + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A}.Release|x86.Build.0 = Release|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Debug|x64.ActiveCfg = Debug|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Debug|x64.Build.0 = Debug|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Debug|x86.ActiveCfg = Debug|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Debug|x86.Build.0 = Debug|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Release|Any CPU.Build.0 = Release|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Release|x64.ActiveCfg = Release|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Release|x64.Build.0 = Release|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Release|x86.ActiveCfg = Release|Any CPU + {9909DEF7-70E0-4D1B-BB99-B77681634BCF}.Release|x86.Build.0 = Release|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Debug|x64.ActiveCfg = Debug|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Debug|x64.Build.0 = Debug|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Debug|x86.ActiveCfg = Debug|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Debug|x86.Build.0 = Debug|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Release|Any CPU.Build.0 = Release|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Release|x64.ActiveCfg = Release|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Release|x64.Build.0 = Release|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Release|x86.ActiveCfg = Release|Any CPU + {F10366C7-4BD8-4EE5-8464-77E372F8AC09}.Release|x86.Build.0 = Release|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Debug|x64.ActiveCfg = Debug|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Debug|x64.Build.0 = Debug|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Debug|x86.ActiveCfg = Debug|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Debug|x86.Build.0 = Debug|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Release|Any CPU.Build.0 = Release|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Release|x64.ActiveCfg = Release|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Release|x64.Build.0 = Release|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Release|x86.ActiveCfg = Release|Any CPU + {48955503-ECE7-4E3E-A1A1-8A04AA133724}.Release|x86.Build.0 = Release|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Debug|x64.ActiveCfg = Debug|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Debug|x64.Build.0 = Debug|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Debug|x86.ActiveCfg = Debug|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Debug|x86.Build.0 = Debug|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Release|Any CPU.Build.0 = Release|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Release|x64.ActiveCfg = Release|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Release|x64.Build.0 = Release|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Release|x86.ActiveCfg = Release|Any CPU + {E47B85E3-AB64-C985-5561-3BA06AEB256F}.Release|x86.Build.0 = Release|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Debug|x64.ActiveCfg = Debug|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Debug|x64.Build.0 = Debug|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Debug|x86.ActiveCfg = Debug|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Debug|x86.Build.0 = Debug|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Release|Any CPU.Build.0 = Release|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Release|x64.ActiveCfg = Release|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Release|x64.Build.0 = Release|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Release|x86.ActiveCfg = Release|Any CPU + {4B987914-01EE-48B4-B645-A6F469297853}.Release|x86.Build.0 = Release|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Debug|x64.ActiveCfg = Debug|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Debug|x64.Build.0 = Debug|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Debug|x86.ActiveCfg = Debug|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Debug|x86.Build.0 = Debug|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Release|Any CPU.Build.0 = Release|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Release|x64.ActiveCfg = Release|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Release|x64.Build.0 = Release|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Release|x86.ActiveCfg = Release|Any CPU + {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {A4B64F4F-224D-492C-8B0D-DAB27D510007} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {14A7663B-134D-4C5E-8C61-4A164F645528} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {29C58371-9B52-47DA-99A0-D3B696BA74C9} = {3145DCB9-98FB-4519-BCC0-75A22A252EDC} - {284E43D8-26F9-4D9C-8C71-9BD4436FBD8A} = {3145DCB9-98FB-4519-BCC0-75A22A252EDC} - {AE9992CC-CCC1-46C2-B78C-F8F55A41D4DC} = {3145DCB9-98FB-4519-BCC0-75A22A252EDC} - {40AFC397-8B33-45D2-B16B-457ABF8C3D8D} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {F53B0E83-87F8-4679-94B8-268FE6A9C0AD} = {1B711C83-4E75-4A94-9B89-09DF08FF6602} - {EC6BE86F-231A-4CA6-974E-27DDD290BCEE} = {F53B0E83-87F8-4679-94B8-268FE6A9C0AD} - {616C2FB0-02FB-4B9A-8092-325DA728F76F} = {F53B0E83-87F8-4679-94B8-268FE6A9C0AD} - {8280D72B-3FA8-43D6-95FC-4133F6B415F5} = {F53B0E83-87F8-4679-94B8-268FE6A9C0AD} - {CB46A8D4-60BE-48C9-9FCC-C62017F195AB} = {F53B0E83-87F8-4679-94B8-268FE6A9C0AD} - {86CEE51A-CCAB-4E35-835B-9B67C3D637C6} = {F53B0E83-87F8-4679-94B8-268FE6A9C0AD} - {431795AC-9CDC-4562-B422-B633FE725810} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {4AFA8838-7DF1-487E-8B72-452E4EFA3AD6} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {6F12636A-4720-4A82-A1F6-ED29E7876F90} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {9824E0A9-B9A6-49B1-8B7C-A2E157406C66} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {F3384ADC-1DA8-4538-B991-DBD2BC591AF1} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {8D0CC3FD-65C2-4302-99F4-A90AC7680E1B} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {C8021CF0-006F-427C-827F-B997F26E5FF6} = {3145DCB9-98FB-4519-BCC0-75A22A252EDC} - {BB2FF5B1-67BC-4EC9-BB51-5980982030C2} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {9A583DF9-5851-4CE1-97A1-9DC406792022} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {FB6495D7-931C-4280-9499-69D08A613B54} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {B2C31D4A-87EE-4568-859B-DC7E8EA1043D} = {3145DCB9-98FB-4519-BCC0-75A22A252EDC} - {804FFA4D-D530-46A7-9ECD-D2D42987161E} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {E7CF0E06-BDC3-49E5-AC52-D65894ED6AFF} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {2A31E887-0340-47D2-AF80-D88D2866C80E} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {D2C61D4A-2A6D-4284-BF9D-09F51BA735B8} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {AC247FD3-5F9F-4DD2-B0CB-1C9CD1EB98D2} = {D2C61D4A-2A6D-4284-BF9D-09F51BA735B8} - {6F7B4F1E-3C3A-4CD7-A9BF-973A5053C1C8} = {3145DCB9-98FB-4519-BCC0-75A22A252EDC} - {910B5894-46BC-4427-95D6-2804F06458E3} = {3145DCB9-98FB-4519-BCC0-75A22A252EDC} - {C042AC2A-415D-432E-83FA-B911FD9ED378} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {B927138A-1DCA-4BA6-A9E5-E5DA6446DABC} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657} - {2BBB766A-D8A2-47B4-AD42-1FD96051604C} = {D2C61D4A-2A6D-4284-BF9D-09F51BA735B8} - {CA21C777-2592-4E1A-970B-0888F21AB0D7} = {D2C61D4A-2A6D-4284-BF9D-09F51BA735B8} + {AE410B43-C4C6-42B2-91D0-3C8DFFF435C6} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {F3F98268-80E9-4441-B6C9-8D384EE9C857} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {56892C3A-9C97-40D5-80EA-2F677129B420} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {F8CA814A-2885-4A8E-B320-87B53C72432F} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {E2348CBC-7FF4-4A35-9154-8CE4A2BD8D08} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {9A4F36C6-1D3B-45BB-B41A-D52EEF6CC7C9} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {330BE032-03F9-4754-8675-BDE6FAEC3AD3} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {74D21189-79FA-4DB9-A9FA-CBF525C65636} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {07EF31E2-5622-4F8B-9B91-BB49F8D581DC} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {5898E0ED-BD7B-4476-8A6F-C7C8AAE89DC5} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {21775DC9-1A2B-4F56-BE90-0747EB1C45CA} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {753E4168-865B-45CD-8998-D1AD78DF5A88} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {FEBF5A45-8F3E-45A5-AD54-7E3DC39C1AA2} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {F796D99C-88A2-42E8-9A18-0705CA9EA28E} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {BEE91E61-7F8C-4C4B-B234-4AC32218E533} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {BF3DB560-69BD-4A4C-A1B4-B81EC867B0FE} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {2095C6E7-7404-48D3-8EEB-3DCD386F414B} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {A4EF23FD-78FB-4E0F-9F82-B4CAB3A9DE52} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {D922722D-0F99-4FC4-B82D-38911B290021} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {F105BB1C-37CC-407E-AD2A-B22494730554} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {A860CC32-62ED-46A5-91B6-4A3B1E358614} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {CAC90D9E-BB00-4A70-AFCC-606720C211A3} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {84263CA4-96CA-4C43-B34A-B084E3860695} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {4FBC8A1A-5A03-43C9-BE9C-1AC16AD0B9FF} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {3467DBAC-5C5C-4FED-8EB5-95F5D3FE12EC} = {5B5342D2-2392-4EB8-9933-A21DB9416534} + {B4B6388D-961C-4F5B-809E-CDC4DEDD3316} = {51654FA6-7BAC-4DD9-8438-99D9A5EDEC4F} + {592364C8-D3A2-45C3-8DC9-BC4FE3C59C50} = {3CEF11D5-9320-4BDA-A7A8-5D1FCE868FE3} + {51654FA6-7BAC-4DD9-8438-99D9A5EDEC4F} = {3467DBAC-5C5C-4FED-8EB5-95F5D3FE12EC} + {86C93AEE-6E67-44EB-BE96-168DFD26311D} = {3467DBAC-5C5C-4FED-8EB5-95F5D3FE12EC} + {3CEF11D5-9320-4BDA-A7A8-5D1FCE868FE3} = {3467DBAC-5C5C-4FED-8EB5-95F5D3FE12EC} + {08BCDA3B-9E4A-40D0-8D28-88256E5812CE} = {51654FA6-7BAC-4DD9-8438-99D9A5EDEC4F} + {31D994C3-EC90-4D9E-8CD9-469DE1AF4241} = {51654FA6-7BAC-4DD9-8438-99D9A5EDEC4F} + {FCCFE5E0-1D23-4DA9-A4E5-A05E139A169C} = {51654FA6-7BAC-4DD9-8438-99D9A5EDEC4F} + {5712A8E1-4755-40FA-8258-78682C91282C} = {FCCFE5E0-1D23-4DA9-A4E5-A05E139A169C} + {6B6900F1-6F6F-4190-98AF-DBF6108BFA83} = {86C93AEE-6E67-44EB-BE96-168DFD26311D} + {D260C63F-8769-409B-B2E4-1CD597A5F4A4} = {86C93AEE-6E67-44EB-BE96-168DFD26311D} + {F527AE41-5892-4C70-9E61-235AA6CD876C} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {F9541022-E9C3-45A7-82E4-A9CD79327D4C} = {F31B53BC-66DD-4844-8349-D4B0CB04EA12} + {FBEE90E0-F47D-4EF6-B920-CCB1E8D0E3A6} = {D37F89FC-A03D-4501-8414-34AE0C6FC765} + {E1598991-E502-467F-8005-D8A479B14505} = {FCCFE5E0-1D23-4DA9-A4E5-A05E139A169C} + {A13CFF3E-780E-472E-8FC6-824E50392D81} = {FCCFE5E0-1D23-4DA9-A4E5-A05E139A169C} + {4943BF85-38ED-4E0D-9FD7-1CC3D24FE106} = {5B5342D2-2392-4EB8-9933-A21DB9416534} + {61666AE0-B7C2-4C46-BFF2-85F109BA90BB} = {4943BF85-38ED-4E0D-9FD7-1CC3D24FE106} + {B8E07BB8-263C-49C7-BD31-294E1CF9CA4F} = {5B5342D2-2392-4EB8-9933-A21DB9416534} + {FD2B5F07-639A-4F00-A14E-ABED6AFE63CD} = {83C087DA-703F-41A5-994B-C132EB812835} + {83C087DA-703F-41A5-994B-C132EB812835} = {B8E07BB8-263C-49C7-BD31-294E1CF9CA4F} + {B723FF69-AD3A-4CC2-8606-6E21B33BA51E} = {A153E771-21A8-4321-A5F1-1F0A0CE17C30} + {53B6C1F1-E94A-41A6-B091-A2F774B9BC48} = {A153E771-21A8-4321-A5F1-1F0A0CE17C30} + {59223667-437C-419A-8C91-C2E9040A5551} = {A153E771-21A8-4321-A5F1-1F0A0CE17C30} + {A153E771-21A8-4321-A5F1-1F0A0CE17C30} = {B8E07BB8-263C-49C7-BD31-294E1CF9CA4F} + {E01B4001-A2EB-4134-8AA8-8A32F06F53FE} = {A153E771-21A8-4321-A5F1-1F0A0CE17C30} + {DA7A3683-2913-76F1-7A13-35A0765F6CA1} = {E01B4001-A2EB-4134-8AA8-8A32F06F53FE} + {697A0BAA-F594-4367-9B64-D2B1F6A2EF27} = {86C93AEE-6E67-44EB-BE96-168DFD26311D} + {48E936FE-E657-4F6F-808B-897EE7D75A20} = {A153E771-21A8-4321-A5F1-1F0A0CE17C30} + {8769A8B1-559C-17A5-949B-762ECC85561D} = {E01B4001-A2EB-4134-8AA8-8A32F06F53FE} + {40501BBE-C1F6-421F-A251-0DFCC3B69DB1} = {B8E07BB8-263C-49C7-BD31-294E1CF9CA4F} + {4CB35174-4DE8-EAE1-7AD3-1AB401DF374A} = {40501BBE-C1F6-421F-A251-0DFCC3B69DB1} + {9909DEF7-70E0-4D1B-BB99-B77681634BCF} = {40501BBE-C1F6-421F-A251-0DFCC3B69DB1} + {F10366C7-4BD8-4EE5-8464-77E372F8AC09} = {86C93AEE-6E67-44EB-BE96-168DFD26311D} + {48955503-ECE7-4E3E-A1A1-8A04AA133724} = {4943BF85-38ED-4E0D-9FD7-1CC3D24FE106} + {E47B85E3-AB64-C985-5561-3BA06AEB256F} = {E01B4001-A2EB-4134-8AA8-8A32F06F53FE} + {4B987914-01EE-48B4-B645-A6F469297853} = {86C93AEE-6E67-44EB-BE96-168DFD26311D} + {54CD8587-0F45-2C2C-7AE4-BB92254202F5} = {570F3635-BEB1-4067-B10F-33DD890BDBD4} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {8B4566D2-9B22-4E27-9654-402BDBA6C744} + SolutionGuid = {99046729-5A7F-41B6-9415-F77D8E9C44F1} EndGlobalSection EndGlobal diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..81e385c5 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,103 @@ + + + true + + $(NoWarn);NU1507 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Docker.md b/Docker.md deleted file mode 100644 index 0909e1a1..00000000 --- a/Docker.md +++ /dev/null @@ -1,58 +0,0 @@ -# About - -To run with docker-compose: - -```bash -docker-compose -f docker-compose.myHr.yml -f docker-compose.myHr.override.yml -f docker-compose.local.override.yml up -``` - -where `docker-compose.local.override.yml` should include connection string to service bus: - -```yaml -version: '3.4' - -services: - myhr-functions: - environment: - - ServiceBusConnection=Endpoint=sb://coreex.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=xxxxxx - - myhr-api: - environment: - - ServiceBusConnection=Endpoint=sb://coreex.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=xxxxxx -``` - -Service Bus should have `pendingverifications` queue used by *My.Hr* sample. - -## To build - -```bash -docker-compose -f docker-compose.myHr.yml -f docker-compose.myHr.override.yml -f docker-compose.local.override.yml build --build-arg LOCAL=true -``` - -## Services - -Available services: - -* Database at port 5433 -* API at port 5103 -* Functions at 5104 - -Sample curl commands: - -### Function - -```bash -curl localhost:5104/api/health # to [get] to 'HealthInfo' -curl localhost:5104/api/employee/verify # to [post] to 'HttpTriggerQueueVerificationFunction' -curl localhost:5104/api/oauth2-redirect.html # to [GET] to 'OAuth2Redirect' -curl localhost:5104/api/openapi/{version}.{extension} # to [GET] to 'OpenApiDocument' -curl localhost:5104/api/swagger.{extension} # to [GET] to 'SwaggerDocument' -curl localhost:5104/api/swagger/ui # to [GET] to 'SwaggerUI' -``` - -### API - -```bash -curl localhost:5103/health # to [get] to 'HealthInfo' -curl localhost:5103/swagger/index.html # to [GET] to 'SwaggerUI' -``` diff --git a/docker-compose.myHr.override.yml b/docker-compose.myHr.override.yml deleted file mode 100644 index 3f0b1d66..00000000 --- a/docker-compose.myHr.override.yml +++ /dev/null @@ -1,50 +0,0 @@ -version: '3.4' - -# The default docker-compose.override file can use the "localhost" as the external name for testing web apps within the same dev machine. -# The ESHOP_EXTERNAL_DNS_NAME_OR_IP environment variable is taken, by default, from the ".env" file defined like: -# ESHOP_EXTERNAL_DNS_NAME_OR_IP=localhost -# but values present in the environment vars at runtime will always override those defined inside the .env file -# An external IP or DNS name has to be used (instead localhost and the 10.0.75.1 IP) when testing the Web apps and the Xamarin apps from remote machines/devices using the same WiFi, for instance. - -services: - - sqldata: - environment: - - ACCEPT_EULA=Y - - MSSQL_SA_PASSWORD=sAPWD23.^0 - - MSSQL_TCP_PORT=1433 - - MSSQL_AGENT_ENABLED=true - - ASPNETCORE_ENVIRONMENT=Development - - ConnectionStrings__sqlserver:MyHr=Data Source=localhost,1433;Initial Catalog=My.Hr;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true - ports: - - "5433:1433" - volumes: - - myhr-sqldata:/var/opt/mssql - - myhr-api: - environment: - - ASPNETCORE_ENVIRONMENT=Development - - ASPNETCORE_URLS=http://0.0.0.0:80 - - ConnectionStrings__Database=Data Source=sqldata,1433;Initial Catalog=My.Hr;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true - - PORT=80 - ports: - - "5103:80" - - myhr-functions: - environment: - - ASPNETCORE_ENVIRONMENT=Development - - AZURE_FUNCTIONS_ENVIRONMENT=Development - - OpenApi__HideSwaggerUI=false - - AgifyApiEndpointUri=https://api.agify.io - - NationalizeApiClientApiEndpointUri=https://api.nationalize.io - - GenderizeApiClientApiEndpointUri=https://api.genderize.io - - VerificationQueueName=pendingVerifications - - VerificationResultsQueueName=verificationResults - - ConnectionStrings__Database=Data Source=sqldata,1433;Initial Catalog=My.Hr;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true - - PORT=80 - ports: - - "5104:80" - -volumes: - myhr-sqldata: - external: false diff --git a/docker-compose.myHr.yml b/docker-compose.myHr.yml deleted file mode 100644 index 47d49ee5..00000000 --- a/docker-compose.myHr.yml +++ /dev/null @@ -1,25 +0,0 @@ -# To run: docker-compose -f docker-compose.myHr.yml -f docker-compose.myHr.override.yml up -version: '3.4' - -services: - - sqldata: - build: - context: . - dockerfile: samples/My.Hr/My.Hr.Database/Dockerfile - - myhr-api: - build: - context: . - dockerfile: samples/My.Hr/My.Hr.Api/Dockerfile - depends_on: - - sqldata - - myhr-functions: - build: - context: . - args: - LOCAL: "true" - dockerfile: samples/My.Hr/My.Hr.Functions/Dockerfile - depends_on: - - sqldata \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..a477b9a3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +name: CoreEx + +services: + db-sql-server: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + SA_PASSWORD: "yourStrong(!)Password" + ACCEPT_EULA: "Y" + ports: + - "1433:1433" + + db-postgres: + image: postgres + environment: + POSTGRES_PASSWORD: "yourStrong#!Password" + ports: + - "5432:5432" + + redis-cache: + image: docker.io/redis:latest + ports: + - "6379:6379" + + aspire-dashboard: + image: mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest + environment: + - DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true + ports: + - "18888:18888" + - "4317:18889" + + servicebus-emulator: + image: mcr.microsoft.com/azure-messaging/servicebus-emulator:latest + depends_on: + - db-sql-server + volumes: + - ./servicebus/Config.json:/ServiceBus_Emulator/ConfigFiles/Config.json + environment: + SQL_SERVER: db-sql-server + MSSQL_SA_PASSWORD: "yourStrong(!)Password" + ACCEPT_EULA: "Y" + ports: + - "5672:5672" # AMQP + - "5300:5300" # Management / HTTP \ No newline at end of file diff --git a/gen/CoreEx.Generator/ContractGenerator.cs b/gen/CoreEx.Generator/ContractGenerator.cs new file mode 100644 index 00000000..565c4788 --- /dev/null +++ b/gen/CoreEx.Generator/ContractGenerator.cs @@ -0,0 +1,86 @@ +using CoreEx.Generator.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Text; + +namespace CoreEx.Generator; + +/// +/// Provides the 'ContractAttribute' implementation. +/// +[Generator] +public class ContractGenerator : IIncrementalGenerator +{ + private const string _contractAttributeResourceName = "CoreEx.Generator.Templates.ContractAttribute.cs.hb"; + private const string _contractIgnoreAttributeResourceName = "CoreEx.Generator.Templates.ContractIgnoreAttribute.cs.hb"; + private const string _refDataAttributeResourceName = "CoreEx.Generator.Templates.ReferenceDataAttribute.cs.hb"; + private const string _refDataTAttributeResourceName = "CoreEx.Generator.Templates.ReferenceDataTAttribute.cs.hb"; + private const string _refDataCodeCollectionTAttributeResourceName = "CoreEx.Generator.Templates.ReferenceDataCodeCollectionTAttribute.cs.hb"; + private const string _stringAttributeResourceName = "CoreEx.Generator.Templates.StringAttribute.cs.hb"; + private const string _dateTimeAttributeResourceName = "CoreEx.Generator.Templates.DateTimeAttribute.cs.hb"; + private const string _cleanAttributeResourceName = "CoreEx.Generator.Templates.CleanAttribute.cs.hb"; + private const string _templateResourceName = "CoreEx.Generator.Templates.Contract.cs.hb"; + private readonly HandlebarsCodeGenerator _codeGenerator = HandlebarsCodeGenerator.Create(_templateResourceName); + + /// + /// Gets a that includes nullability. + /// + internal static SymbolDisplayFormat FullyQualifiedWithNullability = + new(globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Register the requisite '*Attribute' classes. + context.RegisterPostInitializationOutput(static ctx => + { + ctx.AddSource("contractattribute.g.cs", SourceText.From(HandlebarsCodeGenerator.Create(_contractAttributeResourceName).Generate(new CodeGenContext()), Encoding.UTF8)); + ctx.AddSource("contractignoreattribute.g.cs", SourceText.From(HandlebarsCodeGenerator.Create(_contractIgnoreAttributeResourceName).Generate(new CodeGenContext()), Encoding.UTF8)); + ctx.AddSource("referencedataattribute.g.cs", SourceText.From(HandlebarsCodeGenerator.Create(_refDataAttributeResourceName).Generate(new CodeGenContext()), Encoding.UTF8)); + ctx.AddSource("referencedatatattribute.g.cs", SourceText.From(HandlebarsCodeGenerator.Create(_refDataTAttributeResourceName).Generate(new CodeGenContext()), Encoding.UTF8)); + ctx.AddSource("referencedatacodecollectiontattribute.g.cs", SourceText.From(HandlebarsCodeGenerator.Create(_refDataCodeCollectionTAttributeResourceName).Generate(new CodeGenContext()), Encoding.UTF8)); + ctx.AddSource("stringattribute.g.cs", SourceText.From(HandlebarsCodeGenerator.Create(_stringAttributeResourceName).Generate(new CodeGenContext()), Encoding.UTF8)); + ctx.AddSource("datetimeattribute.g.cs", SourceText.From(HandlebarsCodeGenerator.Create(_dateTimeAttributeResourceName).Generate(new CodeGenContext()), Encoding.UTF8)); + ctx.AddSource("cleanattribute.g.cs", SourceText.From(HandlebarsCodeGenerator.Create(_cleanAttributeResourceName).Generate(new CodeGenContext()), Encoding.UTF8)); + }); + + // Register the source generator for the above 'ContractAttribute' class usage. + var provider = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "CoreEx.Entities.ContractAttribute", + predicate: static (syntaxNode, cancellationToken) => syntaxNode is ClassDeclarationSyntax || syntaxNode is RecordDeclarationSyntax, + transform: static (context, cancellationToken) => ContractModel.Create(context, cancellationToken) + ); + + // Register the source output to generate the resulting contract partial class contents. + context.RegisterSourceOutput(provider, (context, model) => + { + try + { + if (!model.ReportDiagnostics(context)) + return; // Do not generate as there are errors. + + if (model.IContract == GenApproach.Undetermined) + return; // No need to generate if IContract is already declared. + + var sourceText = SourceText.From(_codeGenerator.Generate(model), Encoding.UTF8); + context.AddSource($"{model.ClassName}.contract.g.cs", sourceText); + } + catch (System.Exception ex) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx000", + title: "Contract generation error.", + messageFormat: "An error occurred while generating an 'ContractAttribute': {0}", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + context.ReportDiagnostic(Diagnostic.Create(descriptor, null, ex.Message)); + } + }); + } +} \ No newline at end of file diff --git a/gen/CoreEx.Generator/ContractModel.cs b/gen/CoreEx.Generator/ContractModel.cs new file mode 100644 index 00000000..35e9e758 --- /dev/null +++ b/gen/CoreEx.Generator/ContractModel.cs @@ -0,0 +1,657 @@ +using CoreEx.Generator.Utility; +using HandlebarsDotNet; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; + +namespace CoreEx.Generator; + +/// +/// Represents the ContractAttribute class model configuration used to drive the underlying partial class source generation. +/// +internal class ContractModel : CodeGenContext +{ + /// + /// Gets the namespace of the contract. + /// + public string? Namespace { get; private set; } + + /// + /// Gets the class name of the contract. + /// + public string? ClassName { get; private set; } + + /// + /// Gets the containing type hierarchy of the contract. + /// + public List? ContainingTypeHierarchy { get; private set; } + + /// + /// Indicates whether the contract is a record; otherwise, indicates a class. + /// + public bool IsRecord { get; private set; } + + /// + /// Indicates whether the contract has a base type. + /// + public bool HasBaseType => BaseType is not null && !BaseType.Equals("object", System.StringComparison.OrdinalIgnoreCase); + + /// + /// Gets the base type of the contract. + /// + public string? BaseType { get; private set; } + + /// + /// Gets the for the contract. + /// + public GenApproach IContract { get; private set; } + + /// + /// Gets the list of properties for the contract. + /// + public List Properties { get; } = []; + + /// + /// Gets the list of properties that are to be code-generated as declared as partial. + /// + public IEnumerable PartialProperties => Properties.Where(p => p.IsPartial); + + /// + /// Create the from the . + /// + /// The . + /// The . + /// The . + public static ContractModel Create(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + try + { + return context.TargetNode is ClassDeclarationSyntax ? CreateForClass(context, cancellationToken) : CreateForRecord(context, cancellationToken); + } + catch (System.Exception ex) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx000", + title: "Contract generation error.", + messageFormat: "An error occurred while generating an 'ContractAttribute': {0}", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + return new ContractModel { IContract = GenApproach.Undetermined, Diagnostics = { Diagnostic.Create(descriptor, null, ex.Message) } }; + } + } + + /// + /// Create the from the . + /// + private static ContractModel CreateForClass(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + var syntax = (ClassDeclarationSyntax)context.TargetNode; + var symbol = (INamedTypeSymbol)context.SemanticModel.GetDeclaredSymbol(syntax)!; + + var model = new ContractModel + { + Namespace = context.TargetSymbol.ContainingType is null + ? context.TargetSymbol.ContainingNamespace?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)) + : context.TargetSymbol.ContainingType.ContainingNamespace?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)), + ContainingTypeHierarchy = context.TargetSymbol.ContainingType is null ? [] : GetContainingTypeHierarchy(context.TargetSymbol.ContainingType), + ClassName = symbol.Name + }; + + return CreateForStandard(context, symbol, model, cancellationToken); + } + + /// + /// Create the from the . + /// + private static ContractModel CreateForRecord(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + var syntax = (RecordDeclarationSyntax)context.TargetNode; + var symbol = (INamedTypeSymbol)context.SemanticModel.GetDeclaredSymbol(syntax)!; + + var model = new ContractModel + { + Namespace = context.TargetSymbol.ContainingType is null + ? context.TargetSymbol.ContainingNamespace?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)) + : context.TargetSymbol.ContainingType.ContainingNamespace?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)), + ContainingTypeHierarchy = context.TargetSymbol.ContainingType is null ? [] : GetContainingTypeHierarchy(context.TargetSymbol.ContainingType), + ClassName = symbol.Name, + IsRecord = true + }; + + return CreateForStandard(context, symbol, model, cancellationToken); + } + + /// + /// Continues the create for the standardized behaviour. + /// + private static ContractModel CreateForStandard(GeneratorAttributeSyntaxContext context, INamedTypeSymbol symbol, ContractModel model, CancellationToken cancellationToken) + { + // Determine whether already implements IContract where T is itself. + var iContractSymbol = context.SemanticModel.Compilation.GetTypeByMetadataName("CoreEx.Entities.IContract`1"); + if (AlreadyImplementsIContractGenericSelf(symbol, iContractSymbol)) + return model; + + // Determine whether IContract is the base/interface implementation. + model.IContract = IsBaseInterfaceImplementation(symbol, iContractSymbol!) ? GenApproach.Declare : GenApproach.Override; + model.BaseType = symbol.BaseType?.ToDisplayString(); + + // Check the cancellation token. + cancellationToken.ThrowIfCancellationRequested(); + + // Get the symbol for IReferenceData. + var iRefDataSymbol = context.SemanticModel.Compilation.GetTypeByMetadataName("CoreEx.RefData.Abstractions.IReferenceData"); + if (symbol.AllInterfaces.FirstOrDefault(x => SymbolEqualityComparer.Default.Equals(x.OriginalDefinition, iRefDataSymbol)) is not null) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx010", + title: "ContractAttribute is not supported.", + messageFormat: "The ContractAttribute is not supported where the class/record implements CoreEx.RefData.Abstractions.IReferenceData; alternatively, consider using the ReferenceDataAttribute.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Diagnostics.Add(Diagnostic.Create(descriptor, symbol.Locations.FirstOrDefault(), symbol.Name)); + } + + // Get the list of properties which as a minimum do a get. + foreach (var p in symbol.GetMembers().OfType().Where(p => p.GetMethod is not null)) + { + if (p.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "CoreEx.Entities.ContractIgnoreAttribute") is not null) + continue; // Ignore properties with ContractIgnoreAttribute. + + var emp = new PropertyModel + { + Context = model, + Name = p.Name, + IsReadonly = p.SetMethod is null, + IsInitOnly = p.SetMethod?.IsInitOnly ?? false, + IsRequired = p.IsRequired, + Type = FormatTypeWithNullability(p.Type.ToDisplayString(ContractGenerator.FullyQualifiedWithNullability), p.NullableAnnotation), + JsonName = p.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "System.Text.Json.Serialization.JsonPropertyNameAttribute")?.ConstructorArguments.FirstOrDefault().Value as string, + FallbackText = GetDisplayAttributeName(p), + Default = p.DeclaringSyntaxReferences.Select(ds => ds.GetSyntax()).OfType().Select(ps => GetDefaultConstant(ps, context.SemanticModel)).FirstOrDefault(), + Format = GetDisplayFormatAttributeDataFormatString(p) + }; + + if (model.IsRecord && emp.Name == "EqualityContract") + continue; + + emp.KeyAndOrText = emp.HasFallbackText ? emp.Name : null; + + ManageLocalizationAttribute(p, emp); + ManageStringAttributeProperty(p, emp); + ManageDateTimeAttributeProperty(p, emp); + ManageCleanAttributeProperty(p, emp); + ManageReferenceDataAttributeProperty(p, emp); + ManageReferenceDataCodeCollectionAttributeProperty(p, emp); + + model.Properties.Add(emp); + } + + return model; + } + + /// + /// Formats the type with nullability. + /// + /// The type name. + /// The . + /// The type name with added as required. + internal static string FormatTypeWithNullability(string type, NullableAnnotation nullableAnnotation) => nullableAnnotation == NullableAnnotation.Annotated && !type.EndsWith("?") ? type + "?" : type; + + /// + /// Gets the name from the DisplayAttribute where defined. + /// + internal static string? GetDisplayAttributeName(IPropertySymbol propertySymbol) + { + var att = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.OriginalDefinition.ToDisplayString() == "System.ComponentModel.DataAnnotations.DisplayAttribute"); + if (att is null) + return null; + + var na = att.NamedArguments.FirstOrDefault(na => na.Key == "Name"); + if (na.Key is not null && na.Value.Value is string name) + return string.IsNullOrEmpty(name) ? null : name; + + return null; + } + + /// + /// Gets the format from the DisplayFormatAttribute where defined. + /// + internal static string? GetDisplayFormatAttributeDataFormatString(IPropertySymbol propertySymbol) + { + var att = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.OriginalDefinition.ToDisplayString() == "System.ComponentModel.DataAnnotations.DisplayFormatAttribute"); + if (att is null) + return null; + + var na = att.NamedArguments.FirstOrDefault(na => na.Key == "DataFormatString"); + if (na.Key is not null && na.Value.Value is string format) + return string.IsNullOrEmpty(format) ? null : format; + + return null; + } + + /// + /// Gets the default constant value from the property syntax. + /// + internal static string? GetDefaultConstant(PropertyDeclarationSyntax propertySyntax, SemanticModel semanticModel) + { + // Check if there's an initializer + var initializer = propertySyntax.Initializer?.Value; + if (initializer is null) + return null; + + // Try to get the constant value + var constantValue = semanticModel.GetConstantValue(initializer); + if (constantValue.HasValue) + return initializer.ToString(); + + // Where not constant then we can not reliably determine the value, so default for you! + return initializer.ToString(); + } + + /// + /// Determine type declaration hierarchy. + /// + /// The . + /// The resulting hierarchy list. + internal static List GetContainingTypeHierarchy(INamedTypeSymbol? type) + { + static void AddContainingTypeHierarchy(INamedTypeSymbol? type, List list) + { + if (type is null) + return; + + list.Insert(0, type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)); + AddContainingTypeHierarchy(type.ContainingType, list); + } + + var list = new List(); + AddContainingTypeHierarchy(type, list); + return list; + } + + /// + /// Determines whether the already implements somewhere in its parent hierarchy. + /// + private static bool AlreadyImplementsIContractGenericSelf(INamedTypeSymbol symbol, INamedTypeSymbol? interfaceSymbol) + { + foreach (var iface in symbol.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, interfaceSymbol) && iface.TypeArguments.Length == 1 && SymbolEqualityComparer.Default.Equals(iface.TypeArguments[0], symbol)) + return true; + } + + return false; + } + + /// + /// Check whether this is considered the base interface implementation. + /// + private static bool IsBaseInterfaceImplementation(INamedTypeSymbol symbol, INamedTypeSymbol interfaceSymbol) + { + if (symbol.BaseType is null || symbol.SpecialType == SpecialType.System_Object) + return true; + + if (!symbol.BaseType.Locations.Any(loc => loc.IsInSource)) + return !symbol.BaseType.AllInterfaces.Any(x => SymbolEqualityComparer.Default.Equals(x.OriginalDefinition, interfaceSymbol)); + + if (symbol.BaseType.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == "CoreEx.Entities.ContractAttribute")) + return false; + + return IsBaseInterfaceImplementation(symbol.BaseType, interfaceSymbol); + } + + /// + /// Determines and manages the LocalizationAttribute property configuration. + /// + internal static void ManageLocalizationAttribute(IPropertySymbol propertySymbol, PropertyModel model) + { + var att = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.OriginalDefinition.ToDisplayString() == "CoreEx.Localization.LocalizationAttribute"); + if (att is null) + return; + + var kt = att.ConstructorArguments.Length < 1 ? null : att.ConstructorArguments[0].Value as string; + var ft = att.ConstructorArguments.Length < 2 ? null : att.ConstructorArguments[1].Value as string; + + if (!string.IsNullOrEmpty(kt)) + { + model.KeyAndOrText = kt; + model.FallbackText = ft; + } + } + + /// + /// Determines and manages the property configuration. + /// + /// The . + /// The . + internal static void ManageStringAttributeProperty(IPropertySymbol propertySymbol, PropertyModel model) + { + var att = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.OriginalDefinition.ToDisplayString() == "CoreEx.Entities.StringAttribute"); + if (att is null) + return; + + if (propertySymbol.Type.SpecialType != SpecialType.System_String) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx006", + title: "Property type invalid.", + messageFormat: "Property '{0}' must be declared with a type of 'string' to enable 'StringAttribute' capabilities.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + return; + } + + if (!propertySymbol.IsPartialDefinition) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx007", + title: "Property must be partial.", + messageFormat: "Property '{0}' must be declared as 'partial' to enable 'StringAttribute' capabilities.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + return; + } + + if (model.IsReadonly) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx008", + title: "Property must support get and set.", + messageFormat: "Property '{0}' must be declared with a 'get' and 'set' to enable 'StringAttribute' capabilities.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + } + + model.IsPartial = true; + model.IsSelfCleanedString = true; + model.StringTrim = (att.ConstructorArguments.Length < 1 ? null : GetEnumFriendlyName(att.ConstructorArguments[0])) ?? "UseDefault"; + model.StringTransform = (att.ConstructorArguments.Length < 2 ? null : GetEnumFriendlyName(att.ConstructorArguments[1])) ?? "UseDefault"; + model.StringCase = (att.ConstructorArguments.Length < 3 ? null : GetEnumFriendlyName(att.ConstructorArguments[2])) ?? "UseDefault"; + } + + /// + /// Determines and manages the property configuration. + /// + /// The . + /// The . + internal static void ManageDateTimeAttributeProperty(IPropertySymbol propertySymbol, PropertyModel model) + { + var att = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.OriginalDefinition.ToDisplayString() == "CoreEx.Entities.DateTimeAttribute"); + if (att is null) + return; + + var ds = propertySymbol.Type.ToDisplayString(); + if (ds != "System.DateTime" && ds != "System.DateTime?") + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx009", + title: "Property type invalid.", + messageFormat: "Property '{0}' must be declared with a type of 'DateTime' to enable 'DateTimeAttribute' capabilities.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + return; + } + + if (!propertySymbol.IsPartialDefinition) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx011", + title: "Property must be partial.", + messageFormat: "Property '{0}' must be declared as 'partial' to enable 'DateTimeAttribute' capabilities.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + return; + } + + if (model.IsReadonly) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx012", + title: "Property must support get and set.", + messageFormat: "Property '{0}' must be declared with a 'get' and 'set' to enable 'DateTimeAttribute' capabilities.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + } + + model.IsPartial = true; + model.IsSelfCleanedDateTime = true; + model.DateTimeTransform = (att.ConstructorArguments.Length < 1 ? null : GetEnumFriendlyName(att.ConstructorArguments[0])) ?? "UseDefault"; + } + + /// + /// Determines and manages the clean property configuration. + /// + /// The . + /// The . + internal static void ManageCleanAttributeProperty(IPropertySymbol propertySymbol, PropertyModel model) + { + var att = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.OriginalDefinition.ToDisplayString() == "CoreEx.Entities.CleanAttribute"); + if (att is null) + return; + + model.IsCleanOption = true; + model.CleanOption = GetEnumFriendlyName(att.ConstructorArguments[0]) ?? "UseDefault"; + } + + /// + /// Gets the friendly name from the . + /// + private static string? GetEnumFriendlyName(TypedConstant arg) + { + if (arg.Kind != TypedConstantKind.Enum || arg.Value is not int ev) + return null; + + var enumType = (INamedTypeSymbol)arg.Type!; + var member = enumType + .GetMembers() + .OfType() + .FirstOrDefault(f => f.HasConstantValue && (int)f.ConstantValue! == ev); + + return member?.Name; + } + + /// + /// Determines and manages the reference data property configuration. + /// + /// The . + /// The . + internal static void ManageReferenceDataAttributeProperty(IPropertySymbol propertySymbol, PropertyModel model) + { + var att = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.OriginalDefinition.ToDisplayString() == "CoreEx.RefData.ReferenceDataAttribute"); + if (att is null || att.AttributeClass is null || !att.AttributeClass!.IsGenericType) + return; + + model.IsRefData = true; + model.RefDataType = FormatTypeWithNullability(att.AttributeClass.TypeArguments.FirstOrDefault()?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)!, propertySymbol.NullableAnnotation); + + if (propertySymbol.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) != "string" && propertySymbol.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) != "string?") + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx001", + title: "Reference Data property type invalid.", + messageFormat: "Reference Data property '{0}' must be declared with a type of 'string' to enable.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + model.IsRefData = false; + } + + if (!propertySymbol.IsPartialDefinition) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx002", + title: "Reference Data property must be partial.", + messageFormat: "Reference Data property '{0}' must be declared as 'partial' to enable.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + model.IsRefData = false; + } + + if (!(propertySymbol.Name.Length >= 4 && propertySymbol.Name.EndsWith("Sid", System.StringComparison.OrdinalIgnoreCase)) + && !(propertySymbol.Name.Length >= 5 && propertySymbol.Name.EndsWith("Code", System.StringComparison.OrdinalIgnoreCase))) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx004", + title: "Reference Data property name invalid.", + messageFormat: "Reference Data property '{0}' must be declared by convention with a name that ends with 'Sid' (Serializer Identifier) or 'Code'.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + model.IsRefData = false; + } + + if (propertySymbol.DeclaredAccessibility != Accessibility.Public || (propertySymbol.GetMethod?.DeclaredAccessibility ?? Accessibility.Public) != Accessibility.Public || (propertySymbol.SetMethod?.DeclaredAccessibility ?? Accessibility.Public) != Accessibility.Public) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx005", + title: "Reference Data property accessibility invalid.", + messageFormat: "Reference data property '{0}' must be declared with 'public' accessibility only.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + model.IsRefData = false; + } + + if (!model.IsRefData) + return; + + model.IsPartial = true; + model.IsRefDataJson = !propertySymbol.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString()?.StartsWith("System.Text.Json.Serialization.Json") ?? false); + + // Camelcase the property name for JSON serialization. + model.JsonName ??= model.RefDataName!.Length == 1 ? model.RefDataName.ToLowerInvariant() : char.ToLower(model.RefDataName[0], CultureInfo.InvariantCulture) + model.RefDataName.Substring(1); + } + + /// + /// Determines and manages the reference data code collection property configuration. + /// + /// The . + /// The . + internal static void ManageReferenceDataCodeCollectionAttributeProperty(IPropertySymbol propertySymbol, PropertyModel model) + { + var att = propertySymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.OriginalDefinition.ToDisplayString() == "CoreEx.RefData.ReferenceDataCodeCollectionAttribute"); + if (att is null || att.AttributeClass is null || !att.AttributeClass!.IsGenericType) + return; + + model.IsPartial = true; + model.IsRefDataCodeCollection = true; + model.RefDataType = att.AttributeClass.TypeArguments.FirstOrDefault()?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)!; + + if (propertySymbol.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) != "global::System.Collections.Generic.List") + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx002", + title: "Reference Data property type invalid.", + messageFormat: "Reference Data code collection property '{0}' must be declared with a type of 'List'/'List' to enable.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + model.IsRefDataCodeCollection = false; + } + + if (!propertySymbol.IsPartialDefinition) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx002", + title: "Reference Data property must be partial.", + messageFormat: "Reference Data code collection property '{0}' must be declared as 'partial' to enable.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + model.IsRefDataCodeCollection = false; + } + + if (!(propertySymbol.Name.Length >= 5 && propertySymbol.Name.EndsWith("Sids", System.StringComparison.OrdinalIgnoreCase)) + && !(propertySymbol.Name.Length >= 6 && propertySymbol.Name.EndsWith("Codes", System.StringComparison.OrdinalIgnoreCase))) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx004", + title: "Reference Data property name invalid.", + messageFormat: "Reference Data code collection property '{0}' must be declared by convention with a name that ends with 'Sids' (Serializer Identifiers) or 'Codes'.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Context!.Diagnostics.Add(Diagnostic.Create(descriptor, propertySymbol.Locations.FirstOrDefault(), propertySymbol.Name)); + model.IsRefDataCodeCollection = false; + } + } + + /// + public override bool Equals(object obj) + { + if (ReferenceEquals(this, obj)) + return true; + + if (obj is not ContractModel other) + return false; + + if (Namespace != other.Namespace + || ClassName != other.ClassName + || IsRecord != other.IsRecord + || IContract != other.IContract + || BaseType != other.BaseType) + return false; + + if (Enumerable.SequenceEqual(ContainingTypeHierarchy ?? [], other.ContainingTypeHierarchy ?? []) && Enumerable.SequenceEqual(Properties, other.Properties)) + return true; + + return false; + } + + /// + public override int GetHashCode() + { + var hash = (Namespace?.GetHashCode() ?? 0) + ^ (ClassName?.GetHashCode() ?? 0) + ^ IsRecord.GetHashCode() + ^ IContract.GetHashCode() + ^ (BaseType?.GetHashCode() ?? 0); + + if (ContainingTypeHierarchy is not null) + hash ^= ContainingTypeHierarchy.Aggregate(0, (current, item) => current ^ item.GetHashCode()); + + if (Properties is not null) + hash ^= Properties.Aggregate(0, (current, item) => current ^ item.GetHashCode()); + + return hash; + } +} \ No newline at end of file diff --git a/gen/CoreEx.Generator/CoreEx.Generator.csproj b/gen/CoreEx.Generator/CoreEx.Generator.csproj new file mode 100644 index 00000000..b4cbf460 --- /dev/null +++ b/gen/CoreEx.Generator/CoreEx.Generator.csproj @@ -0,0 +1,74 @@ + + + + netstandard2.0 + enable + preview + true + true + Analyzer + + false + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + false + + + + + + + true + + + + + + <_AnalyzerDep Include="$(PkgPluralize_NET)\lib\netstandard2.0\Pluralize.NET.dll" /> + <_AnalyzerDep Include="$(PkgHandlebars_Net)\lib\netstandard2.0\Handlebars.dll" /> + + + + + + + diff --git a/gen/CoreEx.Generator/GenApproach.cs b/gen/CoreEx.Generator/GenApproach.cs new file mode 100644 index 00000000..3bbf6972 --- /dev/null +++ b/gen/CoreEx.Generator/GenApproach.cs @@ -0,0 +1,27 @@ +namespace CoreEx.Generator; + +/// +/// Defines the approach to code generation for a given scenario. +/// +internal enum GenApproach +{ + /// + /// No approach has been determined; i.e. initial state. + /// + Undetermined, + + /// + /// Declares new code (as virtual), but does not override any existing code. + /// + Declare, + + /// + /// Overrides existing code, also calling into the base implementation as appropriate. + /// + Override, + + /// + /// Bypasses code generation as determined as not required (i.e. manually implemented) or unable (i.e. sealed). + /// + Bypass +} \ No newline at end of file diff --git a/gen/CoreEx.Generator/Properties/launchSettings.json b/gen/CoreEx.Generator/Properties/launchSettings.json new file mode 100644 index 00000000..5d4005ad --- /dev/null +++ b/gen/CoreEx.Generator/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Roslyn": { + "commandName": "DebugRoslynComponent", + "targetProject": "..\\..\\samples\\src\\Contoso.Products.Contracts\\Contoso.Products.Contracts.csproj" + } + } +} \ No newline at end of file diff --git a/gen/CoreEx.Generator/PropertyModel.cs b/gen/CoreEx.Generator/PropertyModel.cs new file mode 100644 index 00000000..cf0d1a03 --- /dev/null +++ b/gen/CoreEx.Generator/PropertyModel.cs @@ -0,0 +1,255 @@ +using CoreEx.Generator.Utility; + +namespace CoreEx.Generator; + +/// +/// Represents the ContractAttribute class model's property configuration used to drive the underlying partial class source generation. +/// +internal class PropertyModel +{ + /// + /// Gets the owner of the property. + /// + public CodeGenContext? Context { get; set; } + + /// + /// Gets or sets the property name. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the property type. + /// + public string? Type { get; set; } + + /// + /// Indicates whether the property type is a nullable value type. + /// + public bool IsNullableValueType { get; set; } + + /// + /// Indicates whether the property has been declared as partial. + /// + public bool IsPartial { get; set; } + + /// + /// Indicates whether the property is read-only (i.e. does not have a setter). + /// + public bool IsReadonly { get; set; } + + /// + /// Indicates whether the property is init-only (i.e. has an init setter syntax). + /// + public bool IsInitOnly { get; set; } + + /// + /// Indicates whether the property is settable (i.e. has a setter that is not init-only). + /// + public bool IsSettable => !IsReadonly && !IsInitOnly; + + /// + /// Indicates whether the property is required (i.e. has required syntax). + /// + public bool IsRequired { get; set; } + + /// + /// Indicates whether the property has a . + /// + public bool HasJsonName => !string.IsNullOrEmpty(JsonName); + + /// + /// Gets or sets the JSON property name where different from the without the Code suffix for reference data serialization. + /// + public string? JsonName { get; set; } + + /// + /// Gets or sets the key and/or text for the property. + /// + public string? KeyAndOrText { get; set; } + + /// + /// Gets or sets the fallback text for the property. + /// + public string? FallbackText { get; set; } + + /// + /// Indicates whether the property has . + /// + public bool HasText => !string.IsNullOrEmpty(KeyAndOrText); + + /// + /// Indicates whether the property has . + /// + public bool HasFallbackText => !string.IsNullOrEmpty(FallbackText); + + /// + /// Gets or sets the default; being the corresponding c# code. + /// + public string? Default { get; set; } + + /// + /// Indicates whether the property has a . + /// + public bool HasDefault => !string.IsNullOrEmpty(Default); + + /// + /// Gets or sets the format string used when formatting the property value as a . + /// + public string? Format { get; set; } + + /// + /// Indicates whether the property has a . + /// + public bool HasFormat => Format is not null; + + /// + /// Indicates whether the property has been marked up with the ReferenceData<TRefData>. + /// + public bool IsRefData { get; set; } + + /// + /// Indicates whether the property name ends with the 'Sid' or 'Sids' suffix. + /// + public bool IsSuffixSid => IsRefDataCodeCollection + ? Name is not null && Name.EndsWith("Sids") + : Name is not null && Name.EndsWith("Sid"); + + /// + /// Gets the reference data name. + /// + public string? RefDataName => Name is not null && IsRefData + ? (IsSuffixSid ? Name.Substring(0, Name.Length - 3) : Name.Substring(0, Name.Length - 4)) + : Name is not null && IsRefDataCodeCollection + ? Pluralizer.Instance.Pluralize(IsSuffixSid ? Name.Substring(0, Name.Length - 4) : Name.Substring(0, Name.Length - 5)) : Name; + + /// + /// Gets or sets the reference data type where is . + /// + public string? RefDataType { get; set; } + + /// + /// Indicates whether the json serialization attribute is required. + /// + public bool IsRefDataJson { get; set; } + + /// + /// Indicates whether an additional Text property is required. + /// + public bool IsRefDataText { get; set; } + + /// + /// Gets or sets the JSON property name used for reference data text serialization. + /// + public string? RefDataTextJsonName { get; set; } + + /// + /// Indicates whether the property has been marked up with the ReferenceDataCodeCollection<TRefData>. + /// + public bool IsRefDataCodeCollection { get; set; } + + /// + /// Gets the corresponding backing field name for the property. + /// + public string RefDataCodeCollectionFieldName => $"_{char.ToLowerInvariant(Name![0])}{Name.Substring(1)}"; + + /// + /// Gets the JSON property name used to represent the reference data code collection. + /// + public string RefDataCodeCollectionJsonName => $"{char.ToLowerInvariant(RefDataName![0])}{RefDataName.Substring(1)}"; + + /// + /// Indicates whether the property is self-cleaned (i.e. has a StringAttribute or DataTimeAttribute declared). + /// + public bool IsSelfCleaned => IsSelfCleanedString || IsSelfCleanedDateTime || IsCleanOption; + + /// + /// Indicates whether the property is self-cleaned as a value (i.e. has a StringAttribute declared). + /// + public bool IsSelfCleanedString { get; set; } + + /// + /// Gets or sets the trim. + /// + public string? StringTrim { get; set; } + + /// + /// Gets or sets the transform. + /// + public string? StringTransform { get; set; } + + /// + /// Gets or sets the casing. + /// + public string? StringCase { get; set; } + + /// + /// Indicates whether the property is self-cleaned as a value (i.e. has a DateTimeAttribute declared). + /// + public bool IsSelfCleanedDateTime { get; set; } + + /// + /// Gets or sets the transform. + /// + public string? DateTimeTransform { get; set; } + + /// + /// Indicates whether the property has a clean option specified. + /// + public bool IsCleanOption { get; set; } + + /// + /// Gets or sets the clean option. + /// + public string? CleanOption { get; set; } = "UseDefault"; + + /// + public override bool Equals(object obj) + { + if (ReferenceEquals(this, obj)) + return true; + + if (obj is not PropertyModel other) + return false; + + if (Name != other.Name || Type != other.Type || IsNullableValueType != other.IsNullableValueType || IsPartial != other.IsPartial || IsReadonly != other.IsReadonly || IsInitOnly != other.IsInitOnly || IsRequired != other.IsRequired + || KeyAndOrText != other.KeyAndOrText || FallbackText != other.FallbackText || JsonName != other.JsonName || Default != other.Default || Format != other.Format + || IsRefData != other.IsRefData || RefDataType != other.RefDataType + || IsRefDataJson != other.IsRefDataJson || IsRefDataText != other.IsRefDataText || RefDataTextJsonName != other.RefDataTextJsonName + || IsRefDataCodeCollection != other.IsRefDataCodeCollection + || IsSelfCleanedString != other.IsSelfCleanedString || StringTrim != other.StringTrim || StringTransform != other.StringTransform || StringCase != other.StringCase + || IsSelfCleanedDateTime != other.IsSelfCleanedDateTime || DateTimeTransform != other.DateTimeTransform + || IsCleanOption != other.IsCleanOption || CleanOption != other.CleanOption) + return false; + + return true; + } + + /// + public override int GetHashCode() + => Name?.GetHashCode() ?? 0 + ^ (Type?.GetHashCode() ?? 0) + ^ IsNullableValueType.GetHashCode() + ^ IsPartial.GetHashCode() + ^ IsReadonly.GetHashCode() + ^ IsInitOnly.GetHashCode() + ^ IsRequired.GetHashCode() + ^ (KeyAndOrText?.GetHashCode() ?? 0) + ^ (FallbackText?.GetHashCode() ?? 0) + ^ (JsonName?.GetHashCode() ?? 0) + ^ (Default?.GetHashCode() ?? 0) + ^ (Format?.GetHashCode() ?? 0) + ^ IsRefData.GetHashCode() + ^ (RefDataType?.GetHashCode() ?? 0) + ^ IsRefDataJson.GetHashCode() + ^ IsRefDataText.GetHashCode() + ^ (RefDataTextJsonName?.GetHashCode() ?? 0) + ^ IsRefDataCodeCollection.GetHashCode() + ^ IsSelfCleanedString.GetHashCode() + ^ (StringTrim?.GetHashCode() ?? 0) + ^ (StringTransform?.GetHashCode() ?? 0) + ^ (StringCase?.GetHashCode() ?? 0) + ^ IsSelfCleanedDateTime.GetHashCode() + ^ (DateTimeTransform?.GetHashCode() ?? 0) + ^ IsCleanOption.GetHashCode() + ^ (CleanOption?.GetHashCode() ?? 0); +} \ No newline at end of file diff --git a/gen/CoreEx.Generator/ReferenceDataGenerator.cs b/gen/CoreEx.Generator/ReferenceDataGenerator.cs new file mode 100644 index 00000000..ec125dde --- /dev/null +++ b/gen/CoreEx.Generator/ReferenceDataGenerator.cs @@ -0,0 +1,58 @@ +using CoreEx.Generator.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Text; + +namespace CoreEx.Generator; + +/// +/// Provides the 'ReferenceDataAttribute' implementation. +/// +[Generator] +public class ReferenceDataGenerator : IIncrementalGenerator +{ + private const string _templateResourceName = "CoreEx.Generator.Templates.ReferenceData.cs.hb"; + private readonly HandlebarsCodeGenerator _codeGenerator = HandlebarsCodeGenerator.Create(_templateResourceName); + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // No RegisterPostInitializationOutput needed as handled by ContractGenerator (i.e. centralized singleton). + + // Register the source generator for the above 'ReferenceDataAttribute' class usage. + var provider = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "CoreEx.RefData.ReferenceDataAttribute", + predicate: static (syntaxNode, cancellationToken) => syntaxNode is ClassDeclarationSyntax || syntaxNode is RecordDeclarationSyntax, + transform: static (context, cancellationToken) => ReferenceDataModel.Create(context, cancellationToken) + ); + + // Register the source output to generate the resulting reference data partial class contents. + context.RegisterSourceOutput(provider, (context, model) => + { + try + { + if (!model.ReportDiagnostics(context)) + return; // Do not generate as there are errors. + + if (model.IReferenceData == GenApproach.Undetermined) + return; // No need to generate if IReferenceData is already declared. + + var sourceText = SourceText.From(_codeGenerator.Generate(model), Encoding.UTF8); + context.AddSource($"{model.ClassName}.refdata.g.cs", sourceText); + } + catch (System.Exception ex) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx000", + title: "Reference data generation error.", + messageFormat: "An error occurred while generating a 'ReferenceDataAttribute': {0}", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + context.ReportDiagnostic(Diagnostic.Create(descriptor, null, ex.Message)); + } + }); + } +} \ No newline at end of file diff --git a/gen/CoreEx.Generator/ReferenceDataModel.cs b/gen/CoreEx.Generator/ReferenceDataModel.cs new file mode 100644 index 00000000..b6ca53fd --- /dev/null +++ b/gen/CoreEx.Generator/ReferenceDataModel.cs @@ -0,0 +1,210 @@ +using CoreEx.Generator.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace CoreEx.Generator; + +/// +/// Represents the ReferenceDataAttribute class model configuration used to drive the underlying partial class source generation. +/// +internal class ReferenceDataModel : CodeGenContext +{ + /// + /// Gets the namespace of the contract. + /// + public string? Namespace { get; private set; } + + /// + /// Gets the class name of the contract. + /// + public string? ClassName { get; private set; } + + /// + /// Gets the containing type hierarchy of the contract. + /// + public List? ContainingTypeHierarchy { get; private set; } + + /// + /// Indicates whether the contract is a record; otherwise, indicates a class. + /// + public bool IsRecord { get; private set; } + + /// + /// Gets the for the reference data. + /// + public GenApproach IReferenceData { get; private set; } + + /// + /// Gets the list of properties for the contract. + /// + public List Properties { get; } = []; + + /// + /// Gets the list of properties that are to be code-generated as declared as partial. + /// + public IEnumerable PartialProperties => Properties.Where(p => p.IsRefData || p.IsSelfCleanedString || p.IsSelfCleanedDateTime); + + /// + /// Create the from the . + /// + /// The . + /// The . + /// The . + public static ReferenceDataModel Create(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + try + { + var model = context.TargetNode is ClassDeclarationSyntax ? CreateForClass(context, cancellationToken) : CreateForRecord(context, cancellationToken); + return model; + } + catch (System.Exception ex) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx100", + title: "Reference data generation error.", + messageFormat: "An error occurred while generating an 'ReferenceDataAttribute': {0}", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + return new ReferenceDataModel { IReferenceData = GenApproach.Undetermined, Diagnostics = { Diagnostic.Create(descriptor, null, ex.Message) } }; + } + } + + /// + /// Create the from the . + /// + private static ReferenceDataModel CreateForClass(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + var syntax = (ClassDeclarationSyntax)context.TargetNode; + var symbol = (INamedTypeSymbol)context.SemanticModel.GetDeclaredSymbol(syntax)!; + + var model = new ReferenceDataModel + { + Namespace = context.TargetSymbol.ContainingType is null + ? context.TargetSymbol.ContainingNamespace?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)) + : context.TargetSymbol.ContainingType.ContainingNamespace?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)), + ContainingTypeHierarchy = context.TargetSymbol.ContainingType is null ? [] : ContractModel.GetContainingTypeHierarchy(context.TargetSymbol.ContainingType), + ClassName = symbol.Name + }; + + return CreateForStandard(context, symbol, model, cancellationToken); + } + + /// + /// Create the from the . + /// + private static ReferenceDataModel CreateForRecord(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + var syntax = (RecordDeclarationSyntax)context.TargetNode; + var symbol = (INamedTypeSymbol)context.SemanticModel.GetDeclaredSymbol(syntax)!; + + var model = new ReferenceDataModel + { + Namespace = context.TargetSymbol.ContainingType is null + ? context.TargetSymbol.ContainingNamespace?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)) + : context.TargetSymbol.ContainingType.ContainingNamespace?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)), + ContainingTypeHierarchy = context.TargetSymbol.ContainingType is null ? [] : ContractModel.GetContainingTypeHierarchy(context.TargetSymbol.ContainingType), + ClassName = symbol.Name, + IsRecord = true + }; + + return CreateForStandard(context, symbol, model, cancellationToken); + } + + /// + /// Continues the create for the standardized behaviour. + /// + private static ReferenceDataModel CreateForStandard(GeneratorAttributeSyntaxContext context, INamedTypeSymbol symbol, ReferenceDataModel model, CancellationToken cancellationToken) + { + // Check the cancellation token. + cancellationToken.ThrowIfCancellationRequested(); + + // Get the symbol for IReferenceData. + var iRefDataSymbol = context.SemanticModel.Compilation.GetTypeByMetadataName("CoreEx.RefData.Abstractions.IReferenceData"); + if (symbol.AllInterfaces.FirstOrDefault(x => SymbolEqualityComparer.Default.Equals(x.OriginalDefinition, iRefDataSymbol)) is null) + { + var descriptor = new DiagnosticDescriptor( + id: "CoreEx101", + title: "ReferenceDataAttribute is not supported.", + messageFormat: "The ReferenceDataAttribute is not supported where the class/record does not implement CoreEx.RefData.Abstractions.IReferenceData; alternatively, consider using ContractAttribute.", + category: "CoreEx", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + model.Diagnostics.Add(Diagnostic.Create(descriptor, symbol.Locations.FirstOrDefault(), symbol.Name)); + } + + model.IReferenceData = GenApproach.Declare; + + // Get the list of get/set properties. + foreach (var p in symbol.GetMembers().OfType().Where(p => p.GetMethod is not null)) + { + if (p.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "CoreEx.Entities.ContractIgnoreAttribute") is not null) + continue; // Ignore properties with ContractIgnoreAttribute. + + var emp = new PropertyModel + { + Context = model, + Name = p.Name, + IsReadonly = p.SetMethod is null, + IsInitOnly = p.SetMethod?.IsInitOnly ?? false, + Type = ContractModel.FormatTypeWithNullability(p.Type.ToDisplayString(ContractGenerator.FullyQualifiedWithNullability), p.NullableAnnotation), + JsonName = p.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "System.Text.Json.Serialization.JsonPropertyNameAttribute")?.ConstructorArguments.FirstOrDefault().Value as string, + FallbackText = ContractModel.GetDisplayAttributeName(p), + Default = p.DeclaringSyntaxReferences.Select(ds => ds.GetSyntax()).OfType().Select(ps => ContractModel.GetDefaultConstant(ps, context.SemanticModel)).FirstOrDefault(), + Format = ContractModel.GetDisplayFormatAttributeDataFormatString(p) + }; + + if (model.IsRecord && emp.Name == "EqualityContract") + continue; + + emp.KeyAndOrText = emp.HasFallbackText ? emp.Name : null; + + ContractModel.ManageLocalizationAttribute(p, emp); + ContractModel.ManageStringAttributeProperty(p, emp); + ContractModel.ManageDateTimeAttributeProperty(p, emp); + ContractModel.ManageCleanAttributeProperty(p, emp); + ContractModel.ManageReferenceDataAttributeProperty(p, emp); + ContractModel.ManageReferenceDataCodeCollectionAttributeProperty(p, emp); + + model.Properties.Add(emp); + } + + return model; + } + + /// + public override bool Equals(object obj) + { + if (ReferenceEquals(this, obj)) + return true; + + if (obj is not ReferenceDataModel other) + return false; + + if (Namespace != other.Namespace || ClassName != other.ClassName || IsRecord != other.IsRecord || IReferenceData != other.IReferenceData) + return false; + + if (Enumerable.SequenceEqual(ContainingTypeHierarchy ?? [], other.ContainingTypeHierarchy ?? []) && Enumerable.SequenceEqual(Properties, other.Properties)) + return true; + + return false; + } + + /// + public override int GetHashCode() + { + var hash = (Namespace?.GetHashCode() ?? 0) ^ (ClassName?.GetHashCode() ?? 0) ^ IsRecord.GetHashCode() ^ IReferenceData.GetHashCode(); + if (ContainingTypeHierarchy is not null) + hash ^= ContainingTypeHierarchy.Aggregate(0, (current, item) => current ^ item.GetHashCode()); + + if (Properties is not null) + hash ^= Properties.Aggregate(0, (current, item) => current ^ item.GetHashCode()); + + return hash; + } +} \ No newline at end of file diff --git a/gen/CoreEx.Generator/Templates/CleanAttribute.cs.hb b/gen/CoreEx.Generator/Templates/CleanAttribute.cs.hb new file mode 100644 index 00000000..21fd2470 --- /dev/null +++ b/gen/CoreEx.Generator/Templates/CleanAttribute.cs.hb @@ -0,0 +1,23 @@ +// +#nullable enable +#pragma warning disable + +namespace CoreEx.Entities; + +/// +/// Indicates that the corresponding property should be extended (source generation) to include capabilities. +/// +/// This is dependent on either or usage. +/// This is used for the generation of functionality; this otherwise does not get used for the implementation of the property itself. +[global::System.AttributeUsage(global::System.AttributeTargets.Property, AllowMultiple = false)] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("CoreEx.Generator.ContractGenerator", "1.0.0.0")] +internal class CleanAttribute(CleanOption option = CleanOption.UseDefault) : global::System.Attribute +{ + /// + /// Gets the . + /// + public CleanOption CleanOption { get; } = option; +} + +#pragma warning restore +#nullable restore \ No newline at end of file diff --git a/gen/CoreEx.Generator/Templates/Contract.cs.hb b/gen/CoreEx.Generator/Templates/Contract.cs.hb new file mode 100644 index 00000000..1163793c --- /dev/null +++ b/gen/CoreEx.Generator/Templates/Contract.cs.hb @@ -0,0 +1,103 @@ +// + +#nullable enable +#pragma warning disable + +namespace {{Namespace}}; + +{{#each ContainingTypeHierarchy}} +{{indent}}partial class {{.}} +{{indent}}{{bo}}{{indent++}} +{{/each}} +{{indent}}partial {{#if IsRecord}}record{{else}}class{{/if}} {{ClassName}} : global::CoreEx.Entities.IContract<{{ClassName}}> +{{indent}}{{bo}} +{{indent}} /// +{{indent}} public static {{#ifne IContract 'Declare'}}new {{/ifne}}global::System.Collections.Generic.IEnumerable GetStaticPropertyRuntimeMetadata() +{{indent}} { + {{#if HasBaseType}} +{{indent}} foreach (var p in global::CoreEx.Metadata.RuntimeMetadata.GetPropertyRuntimeMetadata<{{BaseType}}>()) +{{indent}} yield return p; + + {{/if}} + {{#each Properties}} +{{indent}} yield return new global::CoreEx.Metadata.PropertyRuntimeMetadata<{{../ClassName}}, {{Type}}>(nameof({{Name}}), static e => e.{{Name}}{{#unless IsReadOnly}}{{#unless IsInitOnly}}, static (e, v) => e.{{Name}} = v{{/unless}}{{/unless}}{{#if HasDefault}}, defaultValue: {{Default}}{{/if}}{{#if HasText}}, text: static () => new global::CoreEx.Localization.LText("{{KeyAndOrText}}"{{#if HasFallbackText}}, "{{FallbackText}}"{{/if}}){{/if}}{{#ifne CleanOption 'UseDefault'}}, clean: global::CoreEx.Entities.CleanOption.{{CleanOption}}{{/ifne}}{{#if HasJsonName}}, jsonName: "{{JsonName}}"{{/if}}{{#if HasFormat}}, format: "{{Format}}"{{/if}}); + {{/each}} +{{indent}} } + +{{indent}} /// +{{indent}} public {{#ifeq IContract 'Declare'}}virtual{{else}}override{{/ifeq}} global::System.Collections.Generic.IEnumerable GetPropertyRuntimeMetadata() +{{indent}} { +{{indent}} foreach (var p in GetStaticPropertyRuntimeMetadata()) +{{indent}} yield return p; +{{indent}} } +{{#ifeq IContract 'Declare'}} + +{{indent}} /// +{{indent}} public virtual bool IsDefault() => global::CoreEx.Metadata.RuntimeMetadata.IsDefault(this); + +{{indent}} /// +{{indent}} public virtual void CopyFrom(TFrom from) where TFrom : class => global::CoreEx.Metadata.RuntimeMetadata.CopyInto(from, this); +{{/ifeq}} +{{#unless IsRecord}} + +{{indent}} /// +{{indent}} public override int GetHashCode() => global::CoreEx.Metadata.RuntimeMetadata.GetHashCode(this); + +{{indent}} /// +{{indent}} public override bool Equals(object? other) => global::CoreEx.Metadata.RuntimeMetadata.AreEqual(this, other); + +{{indent}} /// +{{indent}} public virtual bool Equals({{ClassName}}? other) => global::CoreEx.Metadata.RuntimeMetadata.AreEqual(this, other); +{{/unless}} +{{#each PartialProperties}} + + {{#if IsRefData}} + {{#if IsRefDataJson}} +{{indent}} [global::System.Text.Json.Serialization.JsonPropertyName("{{JsonName}}")] + {{/if}} +{{indent}} public partial {{Type}} {{Name}} { get => field;{{#unless IsReadOnly}} {{#if IsInitOnly}}init{{else}}set{{/if}} => field = value; {{/unless}} } + +{{indent}} /// +{{indent}} /// Gets the corresponding value as per the related . +{{indent}} /// +{{indent}} [global::System.Diagnostics.DebuggerBrowsable(global::System.Diagnostics.DebuggerBrowsableState.Never)] +{{indent}} [global::System.Text.Json.Serialization.JsonIgnore] +{{indent}} [global::System.Text.Json.Serialization.JsonPropertyName("{{JsonName}}")] + {{#if HasText}} +{{indent}} [global::CoreEx.Localization.Localization("{{KeyAndOrText}}"{{#if HasFallbackText}}, "{{FallbackText}}"{{/if}})] + {{/if}} +{{indent}} public {{RefDataType}} {{RefDataName}} { get => ({{RefDataType}}){{Name}}; {{#if IsSettable}}set => {{Name}} = value; {{/if}} } + +{{indent}} /// +{{indent}} /// Gets the related text where explicitly requested. +{{indent}} /// +{{indent}} /// Generally, the guidance (by design) is that the text should not be initialized/set directly; only offered to support advanced, serialization, and testing scenarios. +{{indent}} [global::System.Diagnostics.DebuggerBrowsable(global::System.Diagnostics.DebuggerBrowsableState.Never)] +{{indent}} [global::System.ComponentModel.ReadOnly(true)] +{{indent}} public string? {{RefDataName}}Text { get => field ?? global::CoreEx.ExecutionContext.GetRelatedText(() => {{Name}} is null ? null : {{RefDataName}}?.Text); init => field = value; } + {{else}} + {{#if IsRefDataCodeCollection}} +{{indent}} private {{Type}} {{RefDataCodeCollectionFieldName}}; + + {{#unless HasJsonName}} +{{indent}} [global::System.Text.Json.Serialization.JsonPropertyName("{{RefDataCodeCollectionJsonName}}")] + {{/unless}} +{{indent}} public partial {{Type}} {{Name}} { get => {{RefDataCodeCollectionFieldName}}; set => {{RefDataCodeCollectionFieldName}} = value; } + +{{indent}} /// +{{indent}} /// Gets or sets the () encapsulation of the underlying . +{{indent}} /// +{{indent}} [global::System.Text.Json.Serialization.JsonIgnore] +{{indent}} public global::CoreEx.RefData.ReferenceDataCodeCollection<{{RefDataType}}> {{RefDataName}} { get => new(ref {{RefDataCodeCollectionFieldName}}); set => value?.ToCodeList(); } + {{else}} +{{indent}} public {{#if IsRequired}}required {{/if}}partial {{Type}} {{Name}} { get => field;{{#unless IsReadOnly}} {{#if IsInitOnly}}init{{else}}set{{/if}} => field = global::CoreEx.Entities.Cleaner.Clean(value, {{#if IsSelfCleanedString}}global::CoreEx.Entities.StringTrim.{{StringTrim}}, global::CoreEx.Entities.StringTransform.{{StringTransform}}, global::CoreEx.Entities.StringCase.{{StringCase}}{{else}}global::CoreEx.Entities.DateTimeTransform.{{DateTimeTransform}}{{/if}});{{/unless}} } + {{/if}} + {{/if}} +{{/each}} +{{indent}}{{bc}} +{{#each ContainingTypeHierarchy}} +{{indent--}}{{Indent}}{{bc}} +{{/each}} + +#pragma warning restore +#nullable restore \ No newline at end of file diff --git a/gen/CoreEx.Generator/Templates/ContractAttribute.cs.hb b/gen/CoreEx.Generator/Templates/ContractAttribute.cs.hb new file mode 100644 index 00000000..ce663f71 --- /dev/null +++ b/gen/CoreEx.Generator/Templates/ContractAttribute.cs.hb @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable + +namespace CoreEx.Entities; + +/// +/// Enables source generation of the CoreEx extended contract functionality; specifically the implementation of . +/// +/// Supports both and types; however, the equality for a is not implemented as the native compiler generated equality must be used instead. +/// The equality is implemented using the functionality which supports deep equality (where possible). +/// Usage is all or nothing, no partial implementation of the is supported. +[global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = false)] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("CoreEx.Generator.ContractGenerator", "1.0.0.0")] +internal class ContractAttribute : global::System.Attribute { } + +#pragma warning restore +#nullable restore \ No newline at end of file diff --git a/gen/CoreEx.Generator/Templates/ContractIgnoreAttribute.cs.hb b/gen/CoreEx.Generator/Templates/ContractIgnoreAttribute.cs.hb new file mode 100644 index 00000000..0cc75c35 --- /dev/null +++ b/gen/CoreEx.Generator/Templates/ContractIgnoreAttribute.cs.hb @@ -0,0 +1,17 @@ +// +#nullable enable +#pragma warning disable + +namespace CoreEx.Entities; + +/// +/// Indicates that the corresponding property should not be included in the source generation of the CoreEx extended contract functionality. +/// +/// This is dependent on either or usage. +/// This is used for the generation of functionality; this otherwise does not get used for the implementation of the property itself. +[global::System.AttributeUsage(global::System.AttributeTargets.Property, AllowMultiple = false)] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("CoreEx.Generator.ContractGenerator", "1.0.0.0")] +internal class ContractIgnoreAttribute : global::System.Attribute { } + +#pragma warning restore +#nullable restore \ No newline at end of file diff --git a/gen/CoreEx.Generator/Templates/DateTimeAttribute.cs.hb b/gen/CoreEx.Generator/Templates/DateTimeAttribute.cs.hb new file mode 100644 index 00000000..e62858eb --- /dev/null +++ b/gen/CoreEx.Generator/Templates/DateTimeAttribute.cs.hb @@ -0,0 +1,24 @@ +// +#nullable enable +#pragma warning disable + +namespace CoreEx.Entities; + +/// +/// Indicates that the corresponding property should be extended (source generation) to include capabilities. +/// +/// The (defaults to ). +/// This is dependent on either or usage. +/// The property must be declared as for this to be generated correctly. +[global::System.AttributeUsage(global::System.AttributeTargets.Property, AllowMultiple = false)] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("CoreEx.Generator.ContractGenerator", "1.0.0.0")] +internal class DateTimeAttribute(DateTimeTransform transform = DateTimeTransform.UseDefault) : global::System.Attribute +{ + /// + /// Gets the . + /// + public DateTimeTransform Transform { get; } = transform; +} + +#pragma warning restore +#nullable restore \ No newline at end of file diff --git a/gen/CoreEx.Generator/Templates/ReferenceData.cs.hb b/gen/CoreEx.Generator/Templates/ReferenceData.cs.hb new file mode 100644 index 00000000..093ec00b --- /dev/null +++ b/gen/CoreEx.Generator/Templates/ReferenceData.cs.hb @@ -0,0 +1,53 @@ +// + +#nullable enable +#pragma warning disable + +namespace {{Namespace}}; + +{{#each ContainingTypeHierarchy}} +{{indent}}partial class {{.}} +{{indent}}{{bo}}{{indent++}} +{{/each}} +{{indent}}partial {{#if IsRecord}}record{{else}}class{{/if}} {{ClassName}} +{{indent}}{{bo}} +{{#each PartialProperties}} + {{#if IsRefData}} + {{#if IsRefDataJson}} +{{indent}} [global::System.Text.Json.Serialization.JsonPropertyName("{{JsonName}}")] + {{/if}} +{{indent}} public partial {{Type}} {{Name}} { get => field;{{#unless IsReadOnly}} {{#if IsInitOnly}}init{{else}}set{{/if}} => field = value; {{/unless}} } + +{{indent}} /// +{{indent}} /// Gets the corresponding value as per the related . +{{indent}} /// +{{indent}} [global::System.Diagnostics.DebuggerBrowsable(global::System.Diagnostics.DebuggerBrowsableState.Never)] +{{indent}} [global::System.Text.Json.Serialization.JsonIgnore] +{{indent}} public {{RefDataType}} {{RefDataName}} { get => ({{RefDataType}}){{Name}}; {{#if IsSettable}}set => {{Name}} = value; {{/if}} } + +{{indent}} /// +{{indent}} /// Gets the related text where explicitly requested. +{{indent}} /// +{{indent}} /// Generally, the guidance (by design) is that the text should not be initialized/set directly; only offered to support advanced, serialization, and testing scenarios. +{{indent}} [global::System.Diagnostics.DebuggerBrowsable(global::System.Diagnostics.DebuggerBrowsableState.Never)] +{{indent}} [global::System.ComponentModel.ReadOnly(true)] +{{indent}} public string? {{RefDataName}}Text { get => field ?? global::CoreEx.ExecutionContext.GetRelatedText(() => {{Name}} is null ? null : {{RefDataName}}?.Text); init => field = value; } + {{else}} +{{indent}} public partial {{Type}} {{Name}} { get => field;{{#unless IsReadOnly}} {{#if IsInitOnly}}init{{else}}set{{/if}} => field = global::CoreEx.Entities.Cleaner.Clean(value, {{#if IsSelfCleanedString}}global::CoreEx.Entities.StringTrim.{{StringTrim}}, global::CoreEx.Entities.StringTransform.{{StringTransform}}, global::CoreEx.Entities.StringCase.{{StringCase}}{{else}}global::CoreEx.Entities.DateTimeTransform.{{DateTimeTransform}}{{/if}});{{/unless}} } + {{/if}} + +{{/each}} +{{indent}} /// +{{indent}} /// An implicit cast operator that converts a to a instance. +{{indent}} /// +{{indent}} /// The . +{{indent}} /// The corresponding instance where is not ; otherwise, . +{{indent}} [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull(nameof(code))] +{{indent}} public static implicit operator {{ClassName}}?(string? code) => code is null ? null : global::CoreEx.RefData.ReferenceDataOrchestrator.TryGetByCode<{{ClassName}}>(code, out var item) ? item : item; +{{indent}}{{bc}} +{{#each ContainingTypeHierarchy}} +{{indent--}}{{Indent}}{{bc}} +{{/each}} + +#pragma warning restore +#nullable restore \ No newline at end of file diff --git a/gen/CoreEx.Generator/Templates/ReferenceDataAttribute.cs.hb b/gen/CoreEx.Generator/Templates/ReferenceDataAttribute.cs.hb new file mode 100644 index 00000000..9586af74 --- /dev/null +++ b/gen/CoreEx.Generator/Templates/ReferenceDataAttribute.cs.hb @@ -0,0 +1,15 @@ +// +#nullable enable +#pragma warning disable + +namespace CoreEx.RefData; + +/// +/// Enables source generation of the CoreEx reference data functionality; specifically the implementation of . +/// +[global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = false)] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("CoreEx.Generator.ContractGenerator", "1.0.0.0")] +internal class ReferenceDataAttribute : global::System.Attribute { } + +#pragma warning restore +#nullable restore \ No newline at end of file diff --git a/gen/CoreEx.Generator/Templates/ReferenceDataCodeCollectionTAttribute.cs.hb b/gen/CoreEx.Generator/Templates/ReferenceDataCodeCollectionTAttribute.cs.hb new file mode 100644 index 00000000..c3fac4bd --- /dev/null +++ b/gen/CoreEx.Generator/Templates/ReferenceDataCodeCollectionTAttribute.cs.hb @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable + +namespace CoreEx.RefData; + +/// +/// Indicates that the corresponding property should be extended (source generation) to include the full suite of reference data code collection properties/capabilities. +/// +/// This is dependent on either or usage. +/// Primarily, a corresponding property will be created (and linked) for non-serialized usage. +/// The property must be declared as for this to be generated correctly. +[global::System.AttributeUsage(global::System.AttributeTargets.Property, AllowMultiple = false)] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("CoreEx.Generator.ContractGenerator", "1.0.0.0")] +internal class ReferenceDataCodeCollectionAttribute : global::System.Attribute where TReferenceData : class, CoreEx.RefData.Abstractions.IReferenceData { } + +#pragma warning restore +#nullable restore \ No newline at end of file diff --git a/gen/CoreEx.Generator/Templates/ReferenceDataTAttribute.cs.hb b/gen/CoreEx.Generator/Templates/ReferenceDataTAttribute.cs.hb new file mode 100644 index 00000000..27864c81 --- /dev/null +++ b/gen/CoreEx.Generator/Templates/ReferenceDataTAttribute.cs.hb @@ -0,0 +1,30 @@ +// +#nullable enable +#pragma warning disable + +namespace CoreEx.RefData; + +/// +/// Indicates that the corresponding property should be extended (source generation) to include the full suite of reference data properties/capabilities. +/// +/// +/// This is dependent on either or usage. +/// Primarily, a corresponding property will be created (and linked) for non-serialized usage. +/// The property must be declared as for this to be generated correctly. +[global::System.AttributeUsage(global::System.AttributeTargets.Property, AllowMultiple = false)] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("CoreEx.Generator.ContractGenerator", "1.0.0.0")] +internal class ReferenceDataAttribute : global::System.Attribute where TReferenceData : class, CoreEx.RefData.Abstractions.IReferenceData +{ + /// + /// Indicates whether the CoreEx.Refdata.Abstractions.IReferenceData.Text read-only property should be included in the generated source. + /// + public bool Text { get; set; } = true; + + /// + /// Gets or sets the JSON name override to use for the CoreEx.Refdata.Abstractions.IReferenceData.Text property when serializing to JSON. + /// + public string? TextJsonName { get; set; } +} + +#pragma warning restore +#nullable restore \ No newline at end of file diff --git a/gen/CoreEx.Generator/Templates/StringAttribute.cs.hb b/gen/CoreEx.Generator/Templates/StringAttribute.cs.hb new file mode 100644 index 00000000..d3c9ab82 --- /dev/null +++ b/gen/CoreEx.Generator/Templates/StringAttribute.cs.hb @@ -0,0 +1,36 @@ +// +#nullable enable +#pragma warning disable + +namespace CoreEx.Entities; + +/// +/// Indicates that the corresponding property should be extended (source generation) to include capabilities. +/// +/// The (defaults to ). +/// The (defaults to ). +/// The (defaults to ). +/// This is dependent on either or usage. +/// The property must be declared as for this to be generated correctly. +[global::System.AttributeUsage(global::System.AttributeTargets.Property, AllowMultiple = false)] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("CoreEx.Generator.ContractGenerator", "1.0.0.0")] +internal class StringAttribute(StringTrim trim = StringTrim.UseDefault, StringTransform transform = StringTransform.UseDefault, StringCase casing = StringCase.UseDefault) : global::System.Attribute +{ + /// + /// Gets the . + /// + public StringTrim Trim { get; } = trim; + + /// + /// Gets the . + /// + public StringTransform Transform { get; } = transform; + + /// + /// Gets the . + /// + public StringCase Casing { get; } = casing; +} + +#pragma warning restore +#nullable restore \ No newline at end of file diff --git a/gen/CoreEx.Generator/Utility/CodeGenContext.cs b/gen/CoreEx.Generator/Utility/CodeGenContext.cs new file mode 100644 index 00000000..5919f99e --- /dev/null +++ b/gen/CoreEx.Generator/Utility/CodeGenContext.cs @@ -0,0 +1,54 @@ +using Microsoft.CodeAnalysis; +using System.Collections.Generic; +using System.Linq; + +namespace CoreEx.Generator.Utility; + +/// +/// Provides context for code generation, allowing for customization of the generated code. +/// +public class CodeGenContext +{ + /// + /// Gets or sets the indent level to be used for the generated code. + /// + public int Indent { get; set; } = 0; + + /// + /// Gets or sets the number of spaces used for each indentation level. + /// + public int IndentSize { get; set; } = 4; + + /// + /// Increments the indent level by one. + /// + public void IncrementIndent() => Indent++; + + /// + /// Decreases the current indentation level by one. + /// + public void DecrementIndent() => Indent--; + + /// + /// Gets the string used for indentation, consisting of spaces. + /// + public string GetIndentString() => new(' ', Indent * IndentSize); + + /// + /// Gets the list to be reported. + /// + public List Diagnostics { get; } = []; + + /// + /// Reports the accumulated to the provided . + /// + /// The . + /// indicates there are no in and source production should occur; otherwise, indicates that no source production should occur. + public bool ReportDiagnostics(SourceProductionContext context) + { + foreach (var d in Diagnostics) + context.ReportDiagnostic(d); + + return !Diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error); + } +} \ No newline at end of file diff --git a/gen/CoreEx.Generator/Utility/HandlebarsCodeGenerator.cs b/gen/CoreEx.Generator/Utility/HandlebarsCodeGenerator.cs new file mode 100644 index 00000000..e3a35c0b --- /dev/null +++ b/gen/CoreEx.Generator/Utility/HandlebarsCodeGenerator.cs @@ -0,0 +1,65 @@ +using HandlebarsDotNet; +using System; +using System.IO; + +namespace CoreEx.Generator.Utility; + +/// +/// The core code generator that manages the Handlebars compilation (cached for performance) and enables the corresponding (one or more invocations). +/// +public class HandlebarsCodeGenerator +{ + private readonly HandlebarsTemplate _template; + + /// + /// Static constructor. + /// + static HandlebarsCodeGenerator() + { + HandlebarsHelpers.RegisterHelpers(); + Handlebars.Configuration.TextEncoder = null; + } + + /// + /// Creates a new instance of the from the specified . + /// + /// The fully qualified embedded resource name for the code template. + /// The . + public static HandlebarsCodeGenerator Create(string resourceName) + { + using var s = typeof(HandlebarsCodeGenerator).Assembly.GetManifestResourceStream(resourceName); + using var sr = new StreamReader(s); + return new HandlebarsCodeGenerator(sr); + } + + /// + /// Creates a new instance of the from the specified . + /// + /// The template . + /// + public static HandlebarsCodeGenerator Create(Stream stream) + { + using var sr = new StreamReader(stream); + return new HandlebarsCodeGenerator(sr); + } + + /// + /// Initializes a new instance of the from the . + /// + /// The template . + public HandlebarsCodeGenerator(StreamReader sr) + { + if (sr is null) + throw new ArgumentNullException(nameof(sr)); + + _template = Handlebars.Compile(sr.ReadToEnd()); + } + + /// + /// Generate content from the template using the and optional secondary . + /// + /// The primary context value referenced within the template. + /// The optional secondary data. + /// The resulting generated output. + public string Generate(CodeGenContext context, object? data = null) => _template(context, data); +} \ No newline at end of file diff --git a/gen/CoreEx.Generator/Utility/HandlebarsHelpers.cs b/gen/CoreEx.Generator/Utility/HandlebarsHelpers.cs new file mode 100644 index 00000000..6de6d692 --- /dev/null +++ b/gen/CoreEx.Generator/Utility/HandlebarsHelpers.cs @@ -0,0 +1,119 @@ +using HandlebarsDotNet; + +namespace CoreEx.Generator.Utility; + +/// +/// Provides the Handlebars.Net capability. +/// +public static class HandlebarsHelpers +{ + private static readonly object _lock = new(); + private static bool _areRegistered = false; + + /// + /// Registers all of the required Handlebars helpers. + /// + public static void RegisterHelpers() + { + if (_areRegistered) + return; + + lock (_lock) + { + if (_areRegistered) + return; + + _areRegistered = true; + + // Increments indent only! + Handlebars.RegisterHelper("indent++", (in w, in options, in context, in args) => + { + var hc = (CodeGenContext)options.Data["Root"]; + hc.IncrementIndent(); + }); + + // Decrements indent only! + Handlebars.RegisterHelper("indent--", (in w, in options, in context, in args) => + { + var hc = (CodeGenContext)options.Data["Root"]; + hc.DecrementIndent(); + }); + + // Writes the current indent string. + Handlebars.RegisterHelper("indent", (in w, in options, in context, in args) => + { + var hc = (CodeGenContext)options.Data["Root"]; + w.WriteSafeString(hc.GetIndentString()); + }); + + Handlebars.RegisterHelper("bo", (w, _, __) => w.WriteSafeString("{")); + Handlebars.RegisterHelper("bc", (w, _, __) => w.WriteSafeString("}")); + + // Will check that the first argument equals at least one of the subsequent arguments. + Handlebars.RegisterHelper("ifeq", (writer, options, context, args) => + { + if (IfEq(args)) + options.Template(writer, context); + else + options.Inverse(writer, context); + }); + + // Will check that the first argument does not equal any of the subsequent arguments. + Handlebars.RegisterHelper("ifne", (writer, options, context, args) => + { + if (IfEq(args)) + options.Inverse(writer, context); + else + options.Template(writer, context); + }); + } + } + + /// + /// Perform the actual IfEq equality check. + /// + private static bool IfEq(Arguments args) + { + bool func() + { + for (int i = 1; i < args.Length; i++) + { + if (Compare(args[0], args[i])) + return true; + } + + return false; + } + + return args.Length switch + { + 0 => true, + 1 => args[0] is null, + 2 => Compare(args[0], args[1]), + _ => func() + }; + } + + /// + /// Compare the two values for equality. + /// + private static bool Compare(object? lval, object? rval) + { + if (lval is null && rval is null) + return true; + + if (lval is null || rval is null) + return false; + + if (lval is string ls && rval is string rs) + return ls == rs; + + if (lval is bool lb && rval is bool rb) + return lb == rb; + + if (lval is int li && rval is int ri) + return li == ri; + + return lval.ToString() == rval.ToString(); + } +} \ No newline at end of file diff --git a/gen/CoreEx.Generator/Utility/Pluralizer.cs b/gen/CoreEx.Generator/Utility/Pluralizer.cs new file mode 100644 index 00000000..d66576c9 --- /dev/null +++ b/gen/CoreEx.Generator/Utility/Pluralizer.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Generator.Utility; + +/// +/// Enables access to the pluralization services. +/// +internal static class Pluralizer +{ + /// + /// Gets the singleton instance. + /// + public static Pluralize.NET.IPluralize Instance { get; } = new Pluralize.NET.Pluralizer(); +} \ No newline at end of file diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props new file mode 100644 index 00000000..233eeabe --- /dev/null +++ b/samples/Directory.Build.props @@ -0,0 +1,8 @@ + + + net8.0;net9.0;net10.0 + enable + enable + preview + + \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Api/Controllers/EmployeeController.cs b/samples/My.Hr/My.Hr.Api/Controllers/EmployeeController.cs deleted file mode 100644 index 8f68fc0a..00000000 --- a/samples/My.Hr/My.Hr.Api/Controllers/EmployeeController.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace My.Hr.Api.Controllers; - -[Route("api/employees")] -[Produces(MediaTypeNames.Application.Json)] -public class EmployeeController : ControllerBase -{ - private readonly WebApi _webApi; - private readonly IEmployeeService _service; - - public EmployeeController(WebApi webApi, IEmployeeService service) - { - _webApi = webApi; - _service = service; - } - - /// - /// Gets the specified . - /// - /// The identifier. - /// The selected where found. - [HttpGet("{id}")] - [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.OK)] - [ProducesResponseType((int)HttpStatusCode.NotFound)] - public Task GetAsync(Guid id) - => _webApi.GetAsync(Request, _ => _service.GetEmployeeAsync(id)); - - /// - /// Gets all . - /// - /// All . - [HttpGet("")] - [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] - [Paging] - [Query] - public Task GetAllAsync() - => _webApi.GetAsync(Request, p => _service.GetAllAsync(p.RequestOptions.Query, p.RequestOptions.Paging)); - - /// - /// Creates a new . - /// - /// The validator. - /// The created . - [HttpPost("")] - [AcceptsBody(typeof(Employee))] - [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.Created)] - public Task CreateAsync([FromServices] IValidator validator) - => _webApi.PostAsync(Request, p => _service.AddEmployeeAsync(p.Value!), - statusCode: HttpStatusCode.Created, validator: validator, locationUri: e => new Uri($"api/employees/{e.Id}", UriKind.RelativeOrAbsolute)); - - /// - /// Updates an existing . - /// - /// The identifier. - /// The validator. - /// The updated . - [HttpPut("{id}")] - [AcceptsBody(typeof(Employee))] - [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.OK)] - public Task UpdateAsync(Guid id, [FromServices] IValidator validator) - => _webApi.PutAsync(Request, p => _service.UpdateEmployeeAsync(p.Value!, id), validator: validator); - - /// - /// Patches an existing . - /// - /// The identifier. - /// The validator. - /// The updated . - [HttpPatch("{id}", Name = "Patch")] - [AcceptsBody(typeof(Employee), HttpConsts.MergePatchMediaTypeName)] - [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.OK)] - public Task PatchAsync(Guid id, [FromServices] IValidator validator) - => _webApi.PatchAsync(Request, get: _ => _service.GetEmployeeAsync(id), put: p => _service.UpdateEmployeeAsync(p.Value!, id), validator: validator); - - /// - /// Deletes the specified . - /// - /// The Id. - [HttpDelete("{id}")] - [ProducesResponseType((int)HttpStatusCode.NoContent)] - public Task DeleteAsync(Guid id) - => _webApi.DeleteAsync(Request, _ => _service.DeleteEmployeeAsync(id)); - - /// - /// Performs verification in an asynchronous process. - /// - [HttpPost("{id}/verify")] - [ProducesResponseType((int)HttpStatusCode.Accepted)] - [ProducesResponseType((int)HttpStatusCode.NotFound)] - public Task VerifyAsync(Guid id) - => _webApi.PostAsync(Request, apiParam => _service.VerifyEmployeeAsync(id), HttpStatusCode.Accepted); -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Api/Controllers/EmployeeResultController.cs b/samples/My.Hr/My.Hr.Api/Controllers/EmployeeResultController.cs deleted file mode 100644 index 1eace93d..00000000 --- a/samples/My.Hr/My.Hr.Api/Controllers/EmployeeResultController.cs +++ /dev/null @@ -1,90 +0,0 @@ -namespace My.Hr.Api.Controllers; - -[Route("api/results")] -[Produces(MediaTypeNames.Application.Json)] -public class EmployeeResultController : ControllerBase -{ - private readonly WebApi _webApi; - private readonly IEmployeeResultService _service; - - public EmployeeResultController(WebApi webApi, IEmployeeResultService service) - { - _webApi = webApi; - _service = service; - } - - /// - /// Gets the specified . - /// - /// The identifier. - /// The selected where found. - [HttpGet("{id}")] - [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.OK)] - [ProducesResponseType((int)HttpStatusCode.NotFound)] - public Task GetAsync(Guid id) - => _webApi.GetWithResultAsync(Request, _ => _service.GetEmployeeAsync(id)); - - /// - /// Gets all . - /// - /// All . - [HttpGet("")] - [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] - [Paging] - public Task GetAllAsync() - => _webApi.GetWithResultAsync(Request, p => _service.GetAllAsync(p.RequestOptions.Paging)); - - /// - /// Creates a new . - /// - /// The validator. - /// The created . - [HttpPost("")] - [AcceptsBody(typeof(Employee))] - [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.Created)] - public Task CreateAsync([FromServices] IValidator validator) - => _webApi.PostWithResultAsync(Request, p => _service.AddEmployeeAsync(p.Value!), - statusCode: HttpStatusCode.Created, validator: validator, locationUri: e => new Uri($"api/employees/{e.Id}", UriKind.RelativeOrAbsolute)); - - /// - /// Updates an existing . - /// - /// The identifier. - /// The validator. - /// The updated . - [HttpPut("{id}")] - [AcceptsBody(typeof(Employee))] - [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.OK)] - public Task UpdateAsync(Guid id, [FromServices] IValidator validator) - => _webApi.PutWithResultAsync(Request, p => _service.UpdateEmployeeAsync(p.Value!, id), validator: validator); - - /// - /// Patches an existing . - /// - /// The identifier. - /// The validator. - /// The updated . - [HttpPatch("{id}")] - [AcceptsBody(typeof(Employee), HttpConsts.MergePatchMediaTypeName)] - [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.OK)] - public Task PatchAsync(Guid id, [FromServices] IValidator validator) - => _webApi.PatchWithResultAsync(Request, get: _ => _service.GetEmployeeAsync(id), put: p => _service.UpdateEmployeeAsync(p.Value!, id), validator: validator); - - /// - /// Deletes the specified . - /// - /// The Id. - [HttpDelete("{id}")] - [ProducesResponseType((int)HttpStatusCode.NoContent)] - public Task DeleteAsync(Guid id) - => _webApi.DeleteWithResultAsync(Request, _ => _service.DeleteEmployeeAsync(id)); - - /// - /// Performs verification in an asynchronous process. - /// - [HttpPost("{id}/verify")] - [ProducesResponseType((int)HttpStatusCode.Accepted)] - [ProducesResponseType((int)HttpStatusCode.NotFound)] - public Task VerifyAsync(Guid id) - => _webApi.PostWithResultAsync(Request, apiParam => _service.VerifyEmployeeAsync(id), HttpStatusCode.Accepted); -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Api/Controllers/ReferenceDataController.cs b/samples/My.Hr/My.Hr.Api/Controllers/ReferenceDataController.cs deleted file mode 100644 index 59d52396..00000000 --- a/samples/My.Hr/My.Hr.Api/Controllers/ReferenceDataController.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace My.Hr.Api.Controllers; - -[Route("api/ref")] -[Produces(MediaTypeNames.Application.Json)] -public class ReferenceDataController : ControllerBase -{ - private readonly ReferenceDataContentWebApi _webApi; - private readonly ReferenceDataOrchestrator _orchestrator; - - public ReferenceDataController(ReferenceDataContentWebApi webApi, ReferenceDataOrchestrator orchestrator) - { - _webApi = webApi; - _orchestrator = orchestrator; - } - - /// - /// Gets all of the reference data items that match the specified criteria. - /// - /// The reference data code list. - /// The reference data text (including wildcards). - /// A . - [HttpGet("usstates")] - [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] - public Task USStateGetAll([FromQuery] IEnumerable? codes = default, string? text = default) => - _webApi.GetAsync(Request, x => _orchestrator.GetWithFilterAsync(codes, text, x.RequestOptions.IncludeInactive)); - - /// - /// Gets all of the reference data items that match the specified criteria. - /// - /// The reference data code list. - /// The reference data text (including wildcards). - /// A . - [HttpGet("genders")] - [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] - public Task GenderGetAll([FromQuery] IEnumerable? codes = default, string? text = default) => - _webApi.GetAsync(Request, x => _orchestrator.GetWithFilterAsync(codes, text, x.RequestOptions.IncludeInactive)); - - [HttpGet()] - [ProducesResponseType(typeof(ReferenceDataMultiDictionary), (int)HttpStatusCode.OK)] - public Task GetNamed() => _webApi.GetAsync(Request, p => _orchestrator.GetNamedAsync(p.RequestOptions)); -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Api/Controllers/SwaggerController.cs b/samples/My.Hr/My.Hr.Api/Controllers/SwaggerController.cs deleted file mode 100644 index ed3be2d5..00000000 --- a/samples/My.Hr/My.Hr.Api/Controllers/SwaggerController.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace My.Hr.Api.Controllers; - -/// -/// Swagger/OpenAPI documentation for the API. -/// -[ApiController] -[Route("[controller]")] -public class SwaggerController : ControllerBase -{ - /// - /// Swagger/OpenAPI documentation for the API. - /// - [HttpGet()] - [Route("/")] - public IActionResult Index() - { - return new RedirectResult("~/swagger"); - } -} diff --git a/samples/My.Hr/My.Hr.Api/Dockerfile b/samples/My.Hr/My.Hr.Api/Dockerfile deleted file mode 100644 index 7964b13d..00000000 --- a/samples/My.Hr/My.Hr.Api/Dockerfile +++ /dev/null @@ -1,46 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base -WORKDIR /app -EXPOSE 80 - -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build -WORKDIR /src - -# It's important to keep lines from here down to "COPY . ." identical in all Dockerfiles -# to take advantage of Docker's build cache, to speed up local container builds -COPY "samples/My.Hr/My.Hr.sln" "samples/My.Hr/My.Hr.sln" - -COPY "samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj" "samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj" -COPY "samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj" "samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj" -COPY "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj" "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj" -COPY "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj" "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj" -COPY "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj" "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj" -COPY "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj" "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj" -COPY "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj" "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj" - -COPY "src/CoreEx/CoreEx.csproj" "src/CoreEx/CoreEx.csproj" -COPY "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj" "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj" -COPY "src/CoreEx.Azure/CoreEx.Azure.csproj" "src/CoreEx.Azure/CoreEx.Azure.csproj" -COPY "src/CoreEx.Cosmos/CoreEx.Cosmos.csproj" "src/CoreEx.Cosmos/CoreEx.Cosmos.csproj" -COPY "src/CoreEx.Database/CoreEx.Database.csproj" "src/CoreEx.Database/CoreEx.Database.csproj" -COPY "src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj" "src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj" -COPY "src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj" "src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj" -COPY "src/CoreEx.FluentValidation/CoreEx.FluentValidation.csproj" "src/CoreEx.FluentValidation/CoreEx.FluentValidation.csproj" -COPY "src/CoreEx.Newtonsoft/CoreEx.Newtonsoft.csproj" "src/CoreEx.Newtonsoft/CoreEx.Newtonsoft.csproj" -COPY "src/CoreEx.Validation/CoreEx.Validation.csproj" "src/CoreEx.Validation/CoreEx.Validation.csproj" - -RUN dotnet restore "samples/My.Hr/My.Hr.sln" - -COPY . . -WORKDIR /src/samples/My.Hr/My.Hr.Api -RUN dotnet publish --no-restore -c Release -o /app - -FROM build as unittest -WORKDIR /src/samples/My.Hr/My.Hr.Test -# can run tests here on buils - -FROM build AS publish - -FROM base AS final -WORKDIR /app -COPY --from=publish /app . -ENTRYPOINT ["dotnet", "My.Hr.Api.dll"] diff --git a/samples/My.Hr/My.Hr.Api/ImplicitUsings.cs b/samples/My.Hr/My.Hr.Api/ImplicitUsings.cs deleted file mode 100644 index 7de08169..00000000 --- a/samples/My.Hr/My.Hr.Api/ImplicitUsings.cs +++ /dev/null @@ -1,23 +0,0 @@ -global using CoreEx; -global using CoreEx.Configuration; -global using CoreEx.Entities; -global using CoreEx.Events; -global using CoreEx.Http; -global using CoreEx.Json; -global using CoreEx.RefData; -global using CoreEx.RefData.Extended; -global using CoreEx.Validation; -global using CoreEx.AspNetCore.HealthChecks; -global using CoreEx.AspNetCore.WebApis; -global using Microsoft.AspNetCore.Mvc; -global using Microsoft.Extensions.Configuration; -global using Microsoft.Extensions.Logging; -global using My.Hr.Business; -global using My.Hr.Business.Data; -global using My.Hr.Business.External; -global using My.Hr.Business.External.Contracts; -global using My.Hr.Business.Models; -global using My.Hr.Business.Services; -global using System.Net; -global using System.Net.Mime; -global using System.Reflection; \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj b/samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj deleted file mode 100644 index 01d604ac..00000000 --- a/samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net6.0 - enable - enable - preview - - - - true - $(NoWarn);1591 - - - - - - - - - - - - - - diff --git a/samples/My.Hr/My.Hr.Api/Program.cs b/samples/My.Hr/My.Hr.Api/Program.cs deleted file mode 100644 index 88a5a026..00000000 --- a/samples/My.Hr/My.Hr.Api/Program.cs +++ /dev/null @@ -1 +0,0 @@ -Host.CreateDefaultBuilder().ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()).Build().Run(); diff --git a/samples/My.Hr/My.Hr.Api/Properties/launchSettings.json b/samples/My.Hr/My.Hr.Api/Properties/launchSettings.json deleted file mode 100644 index 8f6cf5fd..00000000 --- a/samples/My.Hr/My.Hr.Api/Properties/launchSettings.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:46085", - "sslPort": 44328 - } - }, - "profiles": { - "My.Hr.Api": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7129;http://localhost:5272", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/samples/My.Hr/My.Hr.Api/Startup.cs b/samples/My.Hr/My.Hr.Api/Startup.cs deleted file mode 100644 index aab6eb7e..00000000 --- a/samples/My.Hr/My.Hr.Api/Startup.cs +++ /dev/null @@ -1,98 +0,0 @@ -using CoreEx.Database; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Azure.Monitor.OpenTelemetry.AspNetCore; -using OpenTelemetry.Trace; -using Az = Azure.Messaging.ServiceBus; -using CoreEx.Database.HealthChecks; -using CoreEx.Azure.ServiceBus.HealthChecks; -using Azure.Messaging.ServiceBus; -using Microsoft.Extensions.DependencyInjection; -using CoreEx.Json.Merge; - -namespace My.Hr.Api; - -public class Startup -{ - // todo: add azure app configuration (conditional?) - - /// - /// The configure services method called by the runtime; use this method to add services to the container. - /// - /// The . - public void ConfigureServices(IServiceCollection services) - { - // Register the core services. - services - .AddSettings() - .AddReferenceDataOrchestrator(sp => new ReferenceDataOrchestrator(sp).Register()) - .AddExecutionContext() - .AddJsonSerializer() - .AddEventDataSerializer() - .AddEventDataFormatter() - .AddEventPublisher() - .AddSingleton(sp => new Az.ServiceBusClient(sp.GetRequiredService().ServiceBusConnection__fullyQualifiedNamespace)) - .AddAzureServiceBusSender() - .AddAzureServiceBusPurger() - .AddJsonMergePatch() - .AddWebApi((_, webapi) => webapi.UnhandledExceptionAsync = (ex, logger, _) => Task.FromResult(ex is DbUpdateConcurrencyException efex ? webapi.CreateActionResultFromExtendedException(new ConcurrencyException(null, ex), logger) : null)) - .AddReferenceDataContentWebApi() - .AddRequestCache(); - - // Register the business services. - services - .AddScoped() - .AddScoped() - .AddScoped() - .AddFluentValidators(); - - // Register the database and EF services, including required AutoMapper. - services.AddDatabase() - .AddDbContext((sp, o) => o.UseSqlServer(sp.GetRequiredService().GetConnection())) - .AddEfDb() - .AddAutoMapper(typeof(HrEfDb).Assembly) - .AddAutoMapperWrapper(); - - // Register the health checks. - services - .AddHealthChecks() - .AddServiceBusReceiverHealthCheck(sp => sp.GetRequiredService().CreateReceiver(sp.GetRequiredService().VerificationQueueName), "verification-queue"); - - services.AddControllers(); - - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"))) - { - services.AddOpenTelemetry().UseAzureMonitor(); - //services.Configure(options => options.RecordException = true); - services.ConfigureOpenTelemetryTracerProvider((sp, builder) => builder.AddSource("CoreEx.*")); - } - - // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle - services.AddEndpointsApiExplorer(); - services.AddSwaggerGen(options => - { - var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; - options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename)); - options.OperationFilter(); // Needed to support AcceptsBodyAttribue where body parameter not explicitly defined. - options.OperationFilter(PagingOperationFilterFields.SkipTake); // Needed to support PagingAttribute where PagingArgs parameter not explicitly defined. - options.OperationFilter(QueryOperationFilterFields.FilterAndOrderby); // Needed to support QueryAttribute where QueryArgs parameter not explicitly defined. - }); - } - - /// - /// The configure method called by the runtime; use this method to configure the HTTP request pipeline. - /// - /// The . - public void Configure(IApplicationBuilder app) - => app - .UseWebApiExceptionHandler() - .UseSwagger() - .UseSwaggerUI() - .UseHttpsRedirection() - .UseRouting() - .UseAuthorization() - .UseExecutionContext() - .UseHealthChecks("/healthz", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { ResponseWriter = HealthReportStatusWriter.WriteJsonResults }) - .UseReferenceDataOrchestrator() - .UseEndpoints(endpoints => endpoints.MapControllers()); -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Api/appsettings.Development.json b/samples/My.Hr/My.Hr.Api/appsettings.Development.json deleted file mode 100644 index 40fcd139..00000000 --- a/samples/My.Hr/My.Hr.Api/appsettings.Development.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "VerificationQueueName": "verification-queue", - "ServiceBusConnection": "coreex.servicebus.windows.net", - "ConnectionStrings": { - "Database": "Data Source=.;Initial Catalog=My.HrDb;Integrated Security=True;TrustServerCertificate=true" - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Api/appsettings.json b/samples/My.Hr/My.Hr.Api/appsettings.json deleted file mode 100644 index db33d9fb..00000000 --- a/samples/My.Hr/My.Hr.Api/appsettings.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "CoreEx": { - "PagingDefaultTake": 25, - "PagingMaxTake": 500, - "RefDataCache": { - "AbsoluteExpirationRelativeToNow": "01:45:00", - "SlidingExpiration": "00:15:00", - "Gender": { - "AbsoluteExpirationRelativeToNow": "03:00:00", - "SlidingExpiration": "00:45:00" - } - }, - "LogConcurrencyException": true - }, - "ServiceBusConnection": { - "fullyQualifiedNamespace": "Endpoint=sb://top-secret.servicebus.windows.net/;SharedAccessKeyName=top-secret;SharedAccessKey=top-encrypted-secret" - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Data/Employee2Configuration.cs b/samples/My.Hr/My.Hr.Business/Data/Employee2Configuration.cs deleted file mode 100644 index 6d7910ca..00000000 --- a/samples/My.Hr/My.Hr.Business/Data/Employee2Configuration.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace My.Hr.Business.Data; - -public class Employee2Configuration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("Employee2", "Hr"); - builder.Property(p => p.Id).HasColumnName("EmployeeId").HasColumnType("UNIQUEIDENTIFIER"); - builder.Property(p => p.Email).HasColumnType("NVARCHAR(250)"); - builder.Property(p => p.FirstName).HasColumnType("NVARCHAR(100)"); - builder.Property(p => p.LastName).HasColumnType("NVARCHAR(100)"); - builder.Property(p => p.Gender).HasColumnName("GenderCode").HasColumnType("NVARCHAR(50)").HasConversion(v => v!.Code, v => (Gender?)v); - builder.Property(p => p.Birthday).HasColumnType("DATE"); - builder.Property(p => p.StartDate).HasColumnType("DATE"); - builder.Property(p => p.TerminationDate).HasColumnType("DATE"); - builder.Property(p => p.TerminationReasonCode).HasColumnType("NVARCHAR(50)"); - builder.Property(p => p.PhoneNo).HasColumnType("NVARCHAR(50)"); - builder.Property(p => p.ETag).HasColumnName("RowVersion").IsRowVersion().HasConversion(s => s == null ? Array.Empty() : Convert.FromBase64String(s), d => Convert.ToBase64String(d)); - builder.Property(p => p.IsDeleted).HasColumnType("BIT"); - builder.HasKey("Id"); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Data/EmployeeConfiguration.cs b/samples/My.Hr/My.Hr.Business/Data/EmployeeConfiguration.cs deleted file mode 100644 index 76bcec32..00000000 --- a/samples/My.Hr/My.Hr.Business/Data/EmployeeConfiguration.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace My.Hr.Business.Data; - -public class EmployeeConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("Employee", "Hr"); - builder.Property(p => p.Id).HasColumnName("EmployeeId").HasColumnType("UNIQUEIDENTIFIER"); - builder.Property(p => p.Email).HasColumnType("NVARCHAR(250)"); - builder.Property(p => p.FirstName).HasColumnType("NVARCHAR(100)"); - builder.Property(p => p.LastName).HasColumnType("NVARCHAR(100)"); - builder.Property(p => p.Gender).HasColumnName("GenderCode").HasColumnType("NVARCHAR(50)").HasConversion(v => v!.Code, v => (Gender?)v); - builder.Property(p => p.Birthday).HasColumnType("DATE"); - builder.Property(p => p.StartDate).HasColumnType("DATE"); - builder.Property(p => p.TerminationDate).HasColumnType("DATE"); - builder.Property(p => p.TerminationReasonCode).HasColumnType("NVARCHAR(50)"); - builder.Property(p => p.PhoneNo).HasColumnType("NVARCHAR(50)"); - builder.Property(p => p.ETag).HasColumnName("RowVersion").IsRowVersion().HasConversion(s => s == null ? Array.Empty() : Convert.FromBase64String(s), d => Convert.ToBase64String(d)); - builder.HasKey("Id"); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Data/HrDb.cs b/samples/My.Hr/My.Hr.Business/Data/HrDb.cs deleted file mode 100644 index d2b2ba6f..00000000 --- a/samples/My.Hr/My.Hr.Business/Data/HrDb.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace My.Hr.Business.Data -{ - public class HrDb : SqlServerDatabase - { - public HrDb(SettingsBase settings) : base(() => new SqlConnection(settings.GetRequiredValue("ConnectionStrings:Database"))) { } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Data/HrDbContext.cs b/samples/My.Hr/My.Hr.Business/Data/HrDbContext.cs deleted file mode 100644 index dcebbd33..00000000 --- a/samples/My.Hr/My.Hr.Business/Data/HrDbContext.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace My.Hr.Business.Data; - -public class HrDbContext : DbContext, IEfDbContext -{ - public IDatabase BaseDatabase { get; } - - public DbSet USStates { get; set; } - - public DbSet Genders { get; set; } - - public DbSet Employees { get; set; } - -#pragma warning disable CS8618 // Non-nullable property - properties set by Entity Framework Core - public HrDbContext(DbContextOptions options, IDatabase database) : base(options) => BaseDatabase = database ?? throw new ArgumentNullException(nameof(database)); -#pragma warning restore CS8618 // Non-nullable property - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder - .ApplyConfiguration(new UsStateConfiguration()) - .ApplyConfiguration(new EmployeeConfiguration()) - .ApplyConfiguration(new Employee2Configuration()); - - base.OnModelCreating(modelBuilder); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Data/HrEfDb.cs b/samples/My.Hr/My.Hr.Business/Data/HrEfDb.cs deleted file mode 100644 index ab9ae11c..00000000 --- a/samples/My.Hr/My.Hr.Business/Data/HrEfDb.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace My.Hr.Business.Data -{ - /// - /// Enables the My.Hr database using Entity Framework. - /// - public interface IHrEfDb : IEfDb - { - /// - /// Gets the entity. - /// - EfDbEntity Employees { get; } - - /// - /// Gets the entity. - /// - EfDbEntity Employees2 { get; } - } - - /// - /// Represents the My.Hr database using Entity Framework. - /// - public class HrEfDb : EfDb, IHrEfDb - { - /// - /// Initializes a new instance of the class. - /// - /// The entity framework database context. - /// The . - public HrEfDb(HrDbContext dbContext, IMapper mapper) : base(dbContext, mapper) { } - - /// - /// Gets the encapsulated entity. - /// - public EfDbEntity Employees => new(this); - - /// - /// Gets the encapsulated entity. - /// - public EfDbEntity Employees2 => new(this); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Data/UsStateConfiguration.cs b/samples/My.Hr/My.Hr.Business/Data/UsStateConfiguration.cs deleted file mode 100644 index fe5995c9..00000000 --- a/samples/My.Hr/My.Hr.Business/Data/UsStateConfiguration.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace My.Hr.Business.Data; - -public class UsStateConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder entity) - { - entity.ToTable("USState", "Hr"); - entity.HasKey("Id"); - entity.Property(p => p.Id).HasColumnName("USStateId").HasColumnType("UNIQUEIDENTIFIER"); - entity.Property(p => p.Code).HasColumnType("NVARCHAR(50)"); - entity.Property(p => p.Text).HasColumnType("NVARCHAR(250)"); - entity.Property(p => p.IsActive).HasColumnType("BIT"); - entity.Property(p => p.SortOrder).HasColumnType("INT"); - entity.Property(p => p.ETag).HasColumnName("RowVersion").IsRowVersion().HasConversion(s => s == null ? Array.Empty() : Convert.FromBase64String(s), d => Convert.ToBase64String(d)); - entity.Ignore(p => p.EndDate); - entity.Ignore(p => p.StartDate); - entity.Ignore(p => p.Description); - entity.Ignore(p => p.IsReadOnly); - entity.Ignore(p => p.IsValid); - entity.Ignore(p => p.IsChanged); - entity.Ignore(p => p.IsInitial); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/External/AgifyServiceClient.cs b/samples/My.Hr/My.Hr.Business/External/AgifyServiceClient.cs deleted file mode 100644 index d6017a52..00000000 --- a/samples/My.Hr/My.Hr.Business/External/AgifyServiceClient.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace My.Hr.Business.External; - -/// -/// Http client for https://agify.io/ -/// -public class AgifyApiClient : TypedHttpClientCore -{ - public AgifyApiClient(HttpClient client, IJsonSerializer jsonSerializer, CoreEx.ExecutionContext executionContext, HrSettings settings) - : base(client, jsonSerializer, executionContext) - { - if (!Uri.IsWellFormedUriString(settings.AgifyApiEndpointUri, UriKind.Absolute)) - throw new InvalidOperationException(@$"The Api endpoint URI is not valid: {settings.AgifyApiEndpointUri}. Provide valid Api endpoint URI in the configuration '{nameof(settings.AgifyApiEndpointUri)}'. - If Api Client is not needed - remove all references to {nameof(AgifyApiClient)}."); - - client.BaseAddress = new Uri(settings.AgifyApiEndpointUri); - } - - public override Task HealthCheckAsync(CancellationToken cancellationToken) - { - return base.HeadAsync(string.Empty, null, new HttpArg[] { new("name", "health") }, cancellationToken); - } - - public async Task GetAgeAsync(string name) - { - var response = await - ThrowTransientException() - .GetAsync(string.Empty, null, HttpArgs.Create(new HttpArg("name", name))); - - return response.Value; - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/External/Contracts/AgifyResponse.cs b/samples/My.Hr/My.Hr.Business/External/Contracts/AgifyResponse.cs deleted file mode 100644 index 1e73a96c..00000000 --- a/samples/My.Hr/My.Hr.Business/External/Contracts/AgifyResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace My.Hr.Business.External.Contracts; - -public class AgifyResponse -{ - public string? Name { get; set; } - public int Age { get; set; } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/External/Contracts/EmployeeVerificationRequest.cs b/samples/My.Hr/My.Hr.Business/External/Contracts/EmployeeVerificationRequest.cs deleted file mode 100644 index 4596417f..00000000 --- a/samples/My.Hr/My.Hr.Business/External/Contracts/EmployeeVerificationRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace My.Hr.Business.External.Contracts; - -public class EmployeeVerificationRequest -{ - public string? Name { get; set; } - public int Age { get; set; } - public string? Gender { get; set; } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/External/Contracts/EmployeeVerificationResponse.cs b/samples/My.Hr/My.Hr.Business/External/Contracts/EmployeeVerificationResponse.cs deleted file mode 100644 index 2461b3ae..00000000 --- a/samples/My.Hr/My.Hr.Business/External/Contracts/EmployeeVerificationResponse.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace My.Hr.Business.External.Contracts; - -public class EmployeeVerificationResponse -{ - public EmployeeVerificationResponse(EmployeeVerificationRequest request) => Request = request; - - public int Age { get; set; } - - public string? Gender { get; set; } - - public float GenderProbability { get; set; } - - public List Country { get; set; } = new List(); - - public List VerificationMessages { get; set; } = new List(); - - public EmployeeVerificationRequest Request { get; } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/External/Contracts/GenderizeResponse.cs b/samples/My.Hr/My.Hr.Business/External/Contracts/GenderizeResponse.cs deleted file mode 100644 index 35fd7573..00000000 --- a/samples/My.Hr/My.Hr.Business/External/Contracts/GenderizeResponse.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace My.Hr.Business.External.Contracts; - -public class GenderizeResponse -{ - public string? Name { get; set; } - public string? Gender { get; set; } - public float Probability { get; set; } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/External/Contracts/NationalizeResponse.cs b/samples/My.Hr/My.Hr.Business/External/Contracts/NationalizeResponse.cs deleted file mode 100644 index ba99815d..00000000 --- a/samples/My.Hr/My.Hr.Business/External/Contracts/NationalizeResponse.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace My.Hr.Business.External.Contracts; - -public class NationalizeResponse -{ - public string? Name { get; set; } - - public List? Country { get; set; } - - public class CountryResponse - { - public string? Country_Id { get; set; } - public float Probability { get; set; } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/External/GenderizeApiClient.cs b/samples/My.Hr/My.Hr.Business/External/GenderizeApiClient.cs deleted file mode 100644 index 45c343ce..00000000 --- a/samples/My.Hr/My.Hr.Business/External/GenderizeApiClient.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace My.Hr.Business.External; - -/// -/// Http client for https://genderize.io/ -/// -public class GenderizeApiClient : TypedHttpClientCore -{ - public GenderizeApiClient(HttpClient client, IJsonSerializer jsonSerializer, CoreEx.ExecutionContext executionContext, HrSettings settings) - : base(client, jsonSerializer, executionContext) - { - if (!Uri.IsWellFormedUriString(settings.GenderizeApiClientApiEndpointUri, UriKind.Absolute)) - throw new InvalidOperationException(@$"The Api endpoint URI is not valid: {settings.GenderizeApiClientApiEndpointUri}. Provide valid Api endpoint URI in the configuration '{nameof(settings.GenderizeApiClientApiEndpointUri)}'. - If Api Client is not needed - remove all references to {nameof(GenderizeApiClient)}."); - - client.BaseAddress = new Uri(settings.GenderizeApiClientApiEndpointUri); - } - - public override Task HealthCheckAsync(CancellationToken cancellationToken = default) - { - return base.HeadAsync(string.Empty, null, HttpArgs.Create(new HttpArg("name", "health")), cancellationToken); - } - - public async Task GetGenderAsync(string name) - { - var response = await - ThrowTransientException() - .GetAsync(string.Empty, null, HttpArgs.Create(new HttpArg("name", name))); - - return response.Value; - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/External/NationalizeApiClient.cs b/samples/My.Hr/My.Hr.Business/External/NationalizeApiClient.cs deleted file mode 100644 index 2bf45b23..00000000 --- a/samples/My.Hr/My.Hr.Business/External/NationalizeApiClient.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace My.Hr.Business.External; - -/// -/// Http client for https://nationalize.io/ -/// -public class NationalizeApiClient : TypedHttpClientCore -{ - public NationalizeApiClient(HttpClient client, IJsonSerializer jsonSerializer, CoreEx.ExecutionContext executionContext, HrSettings settings) - : base(client, jsonSerializer, executionContext) - { - if (!Uri.IsWellFormedUriString(settings.NationalizeApiClientApiEndpointUri, UriKind.Absolute)) - throw new InvalidOperationException(@$"The Api endpoint URI is not valid: {settings.NationalizeApiClientApiEndpointUri}. Provide valid Api endpoint URI in the configuration '{nameof(settings.NationalizeApiClientApiEndpointUri)}'. - If Api Client is not needed - remove all references to {nameof(NationalizeApiClient)}."); - - client.BaseAddress = new Uri(settings.NationalizeApiClientApiEndpointUri); - } - - public override Task HealthCheckAsync(CancellationToken cancellationToken = default) - { - return base.HeadAsync(string.Empty, null, HttpArgs.Create(new HttpArg("name", "health")), cancellationToken); - } - - public async Task GetNationalityAsync(string name) - { - var response = await - ThrowTransientException() - .GetAsync(string.Empty, null, HttpArgs.Create(new HttpArg("name", name))); - - return response.Value; - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/HrSettings.cs b/samples/My.Hr/My.Hr.Business/HrSettings.cs deleted file mode 100644 index 91cdc5c0..00000000 --- a/samples/My.Hr/My.Hr.Business/HrSettings.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace My.Hr.Business; - -public class HrSettings : SettingsBase -{ - /// - /// Gets the setting prefixes in order of precedence. - /// - public static string[] Prefixes { get; } = { "Hr/", "Common/" }; - - /// - /// Initializes a new instance of the class. - /// - /// The . - public HrSettings(IConfiguration configuration) : base(configuration, Prefixes) { } - - public string AgifyApiEndpointUri => GetValue(); - - public string NationalizeApiClientApiEndpointUri => GetValue(); - - public string GenderizeApiClientApiEndpointUri => GetValue(); - - public string VerificationQueueName => GetValue(); - - public string VerificationResultsQueueName => GetValue(); - - /// - /// The Azure Service Bus connection string used for Publishing in . - /// - /// It defaults to managed identity connection string used by triggers 'ServiceBusConnection__fullyQualifiedNamespace' - public string ServiceBusConnection => GetValue(defaultValue: ServiceBusConnection__fullyQualifiedNamespace); - - /// - /// The Azure Service Bus connection string used by Triggers using managed identity. - /// - /// Caution this key is used implicitly by function triggers when 'ServiceBusConnection' is not set. - /// Underscores in environment variables are replaced by semicolon ':' in configuration object, hence lookup also replaces '__' with ':' - public string ServiceBusConnection__fullyQualifiedNamespace => GetValue(); - - /// - /// SQL Server connection string used by the app (depending on the value it may use managed identity or username/password) - /// - /// Underscores in environment variables are replaced by semicolon ':' in configuration object, hence lookup also replaces '__' with ':' - public string ConnectionStrings__Database => GetValue(); -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/ImplicitUsings.cs b/samples/My.Hr/My.Hr.Business/ImplicitUsings.cs deleted file mode 100644 index 12868fc3..00000000 --- a/samples/My.Hr/My.Hr.Business/ImplicitUsings.cs +++ /dev/null @@ -1,21 +0,0 @@ -global using CoreEx; -global using CoreEx.Configuration; -global using CoreEx.Database; -global using CoreEx.Database.SqlServer; -global using CoreEx.Entities; -global using CoreEx.EntityFrameworkCore; -global using CoreEx.Events; -global using CoreEx.Http; -global using CoreEx.Json; -global using CoreEx.Mapping; -global using CoreEx.RefData; -global using CoreEx.Results; -global using Microsoft.Data.SqlClient; -global using Microsoft.EntityFrameworkCore; -global using Microsoft.EntityFrameworkCore.Metadata.Builders; -global using Microsoft.Extensions.Configuration; -global using Microsoft.Extensions.Logging; -global using My.Hr.Business.Data; -global using My.Hr.Business.External; -global using My.Hr.Business.External.Contracts; -global using My.Hr.Business.Models; \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Models/Employee.cs b/samples/My.Hr/My.Hr.Business/Models/Employee.cs deleted file mode 100644 index c3c1a10e..00000000 --- a/samples/My.Hr/My.Hr.Business/Models/Employee.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Diagnostics; - -namespace My.Hr.Business.Models; - -/// -/// Represents the Entity Framework (EF) model for database object 'Hr.Employee'. -/// -public class Employee : IIdentifier, IETag -{ - /// - /// Gets or sets the 'EmployeeId' column value. - /// - public Guid Id { get; set; } - - /// - /// Gets or sets the 'Email' column value. - /// - public string? Email { get; set; } - - /// - /// Gets or sets the 'FirstName' column value. - /// - public string? FirstName { get; set; } - - /// - /// Gets or sets the 'LastName' column value. - /// - public string? LastName { get; set; } - - /// - /// Gets or sets the 'GenderCode' column value. - /// - [DebuggerBrowsable(DebuggerBrowsableState.Never)] - public Gender? Gender { get; set; } - - /// - /// Gets or sets the 'Birthday' column value. - /// - public DateTime? Birthday { get; set; } - - /// - /// Gets or sets the 'StartDate' column value. - /// - public DateTime? StartDate { get; set; } - - /// - /// Gets or sets the 'TerminationDate' column value. - /// - public DateTime? TerminationDate { get; set; } - - /// - /// Gets or sets the 'TerminationReasonCode' column value. - /// - public string? TerminationReasonCode { get; set; } - - /// - /// Gets or sets the 'PhoneNo' column value. - /// - public string? PhoneNo { get; set; } - - /// - /// Gets or sets the 'RowVersion' column value. - /// - public string? ETag { get; set; } -} - -public class EmployeeCollection : List { } - -public class EmployeeCollectionResult : CollectionResult { } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Models/Employee2.cs b/samples/My.Hr/My.Hr.Business/Models/Employee2.cs deleted file mode 100644 index 508f395d..00000000 --- a/samples/My.Hr/My.Hr.Business/Models/Employee2.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Diagnostics; - -namespace My.Hr.Business.Models; - -/// -/// Represents the Entity Framework (EF) model for database object 'Hr.Employee2'. -/// -public class Employee2 : IIdentifier, IETag, ILogicallyDeleted -{ - /// - /// Gets or sets the 'EmployeeId' column value. - /// - public Guid Id { get; set; } - - /// - /// Gets or sets the 'Email' column value. - /// - public string? Email { get; set; } - - /// - /// Gets or sets the 'FirstName' column value. - /// - public string? FirstName { get; set; } - - /// - /// Gets or sets the 'LastName' column value. - /// - public string? LastName { get; set; } - - /// - /// Gets or sets the 'GenderCode' column value. - /// - [DebuggerBrowsable(DebuggerBrowsableState.Never)] - public Gender? Gender { get; set; } - - /// - /// Gets or sets the 'Birthday' column value. - /// - public DateTime? Birthday { get; set; } - - /// - /// Gets or sets the 'StartDate' column value. - /// - public DateTime? StartDate { get; set; } - - /// - /// Gets or sets the 'TerminationDate' column value. - /// - public DateTime? TerminationDate { get; set; } - - /// - /// Gets or sets the 'TerminationReasonCode' column value. - /// - public string? TerminationReasonCode { get; set; } - - /// - /// Gets or sets the 'PhoneNo' column value. - /// - public string? PhoneNo { get; set; } - - /// - /// Gets or sets the 'RowVersion' column value. - /// - public string? ETag { get; set; } - - /// - /// Gets or sets the 'IsDeleted' column value. - /// - public bool? IsDeleted { get; set; } -} - -public class Employee2Collection : List { } - -public class Employee2CollectionResult : CollectionResult { } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Models/Gender.cs b/samples/My.Hr/My.Hr.Business/Models/Gender.cs deleted file mode 100644 index 9a94623f..00000000 --- a/samples/My.Hr/My.Hr.Business/Models/Gender.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CoreEx.RefData.Extended; - -namespace My.Hr.Business.Models; - -public class Gender : ReferenceDataBaseEx -{ - public static implicit operator Gender?(string? code) => ConvertFromCode(code); -} - -public class GenderCollection : ReferenceDataCollectionBase { } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Models/UsState.cs b/samples/My.Hr/My.Hr.Business/Models/UsState.cs deleted file mode 100644 index 529aae27..00000000 --- a/samples/My.Hr/My.Hr.Business/Models/UsState.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CoreEx.RefData.Extended; - -namespace My.Hr.Business.Models; - -public class USState : ReferenceDataBaseEx -{ - public static implicit operator USState?(string? code) => ConvertFromCode(code); -} - -public class USStateCollection() : ReferenceDataCollectionBase(ReferenceDataSortOrder.SortOrder) { } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj b/samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj deleted file mode 100644 index 9b4d7ce3..00000000 --- a/samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net6.0 - enable - enable - preview - - - - - - - - - - - - - - - - - - diff --git a/samples/My.Hr/My.Hr.Business/Services/AutoMapperProfile.cs b/samples/My.Hr/My.Hr.Business/Services/AutoMapperProfile.cs deleted file mode 100644 index e2bc0585..00000000 --- a/samples/My.Hr/My.Hr.Business/Services/AutoMapperProfile.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace My.Hr.Business.Services -{ - public class AutoMapperProfile : AutoMapper.Profile - { - public AutoMapperProfile() - { - // Need to explicitly map type to type, otherwise Map(srce, dest) just returns srce - seems like a bug to me, but apparently by design: https://github.com/AutoMapper/AutoMapper/issues/656 - CreateMap(); - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Services/EmployeeResultService.cs b/samples/My.Hr/My.Hr.Business/Services/EmployeeResultService.cs deleted file mode 100644 index 4d7f45cf..00000000 --- a/samples/My.Hr/My.Hr.Business/Services/EmployeeResultService.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace My.Hr.Business.Services; - -/// -/// Example using that largely encapsulates/simplifies the EF access with a (achieving railway-oriented programming) -/// -public class EmployeeResultService : IEmployeeResultService -{ - private readonly IHrEfDb _efDb; - private readonly IEventPublisher _publisher; - private readonly HrSettings _settings; - - public EmployeeResultService(IHrEfDb efDb, IEventPublisher publisher, HrSettings settings) - { - _efDb = efDb; - _publisher = publisher; - _settings = settings; - } - - public Task> GetEmployeeAsync(Guid id) => _efDb.Employees.GetWithResultAsync(id); - - public Task> GetAllAsync(PagingArgs? paging) - => _efDb.Employees.Query(q => q.OrderBy(x => x.LastName).ThenBy(x => x.FirstName)).WithPaging(paging).SelectResultWithResultAsync(); - - public Task> AddEmployeeAsync(Employee employee) => _efDb.Employees.CreateWithResultAsync(employee); - - public Task> UpdateEmployeeAsync(Employee employee, Guid id) => _efDb.Employees.UpdateWithResultAsync(employee.Adjust(x => x.Id = id)); - - public Task DeleteEmployeeAsync(Guid id) => _efDb.Employees.DeleteWithResultAsync(id); - - public Task VerifyEmployeeAsync(Guid id) => Result - .GoAsync(GetEmployeeAsync(id)) - .When(employee => employee == null, _ => Result.NotFoundError()) - .ThenAsAsync(async employee => - { - // Publish message to service bus for employee verification. - var verification = new EmployeeVerificationRequest - { - Name = employee!.FirstName, - Age = SystemTime.Timestamp.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365, - Gender = employee.Gender?.Code - }; - - _publisher.PublishNamed(_settings.VerificationQueueName, new EventData { Value = verification }); - await _publisher.SendAsync(); - }); -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs b/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs deleted file mode 100644 index dfc13371..00000000 --- a/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs +++ /dev/null @@ -1,92 +0,0 @@ -using CoreEx.Data.Querying; - -namespace My.Hr.Business.Services; - -public class EmployeeService : IEmployeeService -{ - private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() - .WithFilter(filter => filter - .AddField("LastName", c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) - .AddField("FirstName", c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) - .AddField("StartDate") - .AddField("TerminationDate") - .AddField(nameof(Employee.Gender), c => c.WithValue(v => - { - var g = Gender.ConvertFromCode(v); - return g is not null && g.IsValid ? g : throw new FormatException("Gender is invalid."); - }))) - .WithOrderBy(orderBy => orderBy - .AddField("LastName") - .AddField("FirstName") - .WithDefault("LastName, FirstName")); - - private readonly HrDbContext _dbContext; - private readonly IEventPublisher _publisher; - private readonly HrSettings _settings; - - public EmployeeService(HrDbContext dbContext, IEventPublisher publisher, HrSettings settings) - { - _dbContext = dbContext; - _publisher = publisher; - _settings = settings; - } - - public async Task GetEmployeeAsync(Guid id) - { - var emp = await _dbContext.Employees.FirstOrDefaultAsync(e => e.Id == id); - if (emp is not null && emp.Birthday.HasValue && emp.Birthday.Value.Year < 2000) - CoreEx.ExecutionContext.Current.Messages.Add(MessageType.Warning, "Employee is considered old."); - - return emp; - } - - public Task GetAllAsync(QueryArgs? query, PagingArgs? paging) - => _dbContext.Employees.Where(_queryConfig, query).OrderBy(_queryConfig, query).ToCollectionResultAsync(paging); - - public async Task AddEmployeeAsync(Employee employee) - { - _dbContext.Employees.Add(employee); - await _dbContext.SaveChangesAsync(); - return employee; - } - - public async Task UpdateEmployeeAsync(Employee employee, Guid id) - { - if (!await _dbContext.Employees.AnyAsync(e => e.Id == id).ConfigureAwait(false)) - throw new NotFoundException(); - - employee.Id = id; - - _dbContext.ChangeTracker.Clear(); // Different employee instance (result of using CoreEx.Json.JsonMergePatch vs CoreEx.Json.Extended.JsonMergePatchEx); therefore, clear the change tracker prior to update attempt. - _dbContext.Employees.Update(employee); - await _dbContext.SaveChangesAsync().ConfigureAwait(false); - return employee; - } - - public async Task DeleteEmployeeAsync(Guid id) - { - var employee = await _dbContext.Employees.FirstOrDefaultAsync(e => e.Id == id); - if (employee != null) - { - _dbContext.Employees.Remove(employee); - await _dbContext.SaveChangesAsync(); - } - } - - public async Task VerifyEmployeeAsync(Guid id) - { - // Get the employee. - var employee = await GetEmployeeAsync(id) ?? throw new NotFoundException(); - - // Publish message to service bus for employee verification. - var verification = new EmployeeVerificationRequest - { - Name = employee.FirstName, - Age = SystemTime.Timestamp.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365, - Gender = employee.Gender?.Code - }; - - _publisher.PublishNamed(_settings.VerificationQueueName, new EventData { Value = verification }); - await _publisher.SendAsync(); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs b/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs deleted file mode 100644 index ed8fd752..00000000 --- a/samples/My.Hr/My.Hr.Business/Services/EmployeeService2.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace My.Hr.Business.Services; - -/// -/// Example using that largely encapsulates/simplifies the EF access. -/// -public class EmployeeService2 : IEmployeeService -{ - private readonly IHrEfDb _efDb; - private readonly IEventPublisher _publisher; - private readonly HrSettings _settings; - - public EmployeeService2(IHrEfDb efDb, IEventPublisher publisher, HrSettings settings) - { - _efDb = efDb; - _publisher = publisher; - _settings = settings; - } - - public Task GetEmployeeAsync(Guid id) => _efDb.Employees.GetAsync(id); - - public Task GetAllAsync(QueryArgs? query, PagingArgs? paging) - => _efDb.Employees.Query(q => q.OrderBy(x => x.LastName).ThenBy(x => x.FirstName)).WithPaging(paging).SelectResultAsync(); - - public Task AddEmployeeAsync(Employee employee) => _efDb.Employees.CreateAsync(employee); - - public Task UpdateEmployeeAsync(Employee employee, Guid id) => _efDb.Employees.UpdateAsync(employee.Adjust(x => x.Id = id)); - - public Task DeleteEmployeeAsync(Guid id) => _efDb.Employees.DeleteAsync(id); - - public async Task VerifyEmployeeAsync(Guid id) - { - // Get the employee. - var employee = await GetEmployeeAsync(id) ?? throw new NotFoundException(); - - // Publish message to service bus for employee verification. - var verification = new EmployeeVerificationRequest - { - Name = employee.FirstName, - Age = SystemTime.Timestamp.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365, - Gender = employee.Gender?.Code - }; - - _publisher.PublishNamed(_settings.VerificationQueueName, new EventData { Value = verification }); - await _publisher.SendAsync(); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Services/IEmployeeResultService.cs b/samples/My.Hr/My.Hr.Business/Services/IEmployeeResultService.cs deleted file mode 100644 index dbd94847..00000000 --- a/samples/My.Hr/My.Hr.Business/Services/IEmployeeResultService.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace My.Hr.Business.Services -{ - public interface IEmployeeResultService - { - Task> GetEmployeeAsync(Guid id); - - Task> GetAllAsync(PagingArgs? paging); - - Task> AddEmployeeAsync(Employee employee); - - Task> UpdateEmployeeAsync(Employee employee, Guid id); - - Task DeleteEmployeeAsync(Guid id); - - Task VerifyEmployeeAsync(Guid id); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Services/IEmployeeService.cs b/samples/My.Hr/My.Hr.Business/Services/IEmployeeService.cs deleted file mode 100644 index 7b1b07c0..00000000 --- a/samples/My.Hr/My.Hr.Business/Services/IEmployeeService.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace My.Hr.Business.Services -{ - public interface IEmployeeService - { - Task GetEmployeeAsync(Guid id); - - Task GetAllAsync(QueryArgs? query, PagingArgs? paging); - - Task AddEmployeeAsync(Employee employee); - - Task UpdateEmployeeAsync(Employee employee, Guid id); - - Task DeleteEmployeeAsync(Guid id); - - Task VerifyEmployeeAsync(Guid id); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Services/ReferenceDataService.cs b/samples/My.Hr/My.Hr.Business/Services/ReferenceDataService.cs deleted file mode 100644 index b2817e16..00000000 --- a/samples/My.Hr/My.Hr.Business/Services/ReferenceDataService.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CoreEx.Database.Extended; - -namespace My.Hr.Business.Services; - -public class ReferenceDataService : IReferenceDataProvider -{ - private readonly IDatabase _db; - private readonly HrDbContext _dbContext; - - public ReferenceDataService(IDatabase db, HrDbContext dbContext) - { - _db = db; - _dbContext = dbContext; - } - - public Type[] Types => new Type[] { typeof(USState), typeof(Gender) }; - - public async Task> GetAsync(Type type, CancellationToken cancellationToken = default) => type switch - { - Type t when t == typeof(USState) => await USStateCollection.CreateAsync(_dbContext.USStates.AsNoTracking(), cancellationToken).ConfigureAwait(false), - Type t when t == typeof(Gender) => await _db.ReferenceData("Hr", "Gender").LoadAsync("GenderId", cancellationToken: cancellationToken).ConfigureAwait(false), - _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.") - }; -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Services/VerificationService.cs b/samples/My.Hr/My.Hr.Business/Services/VerificationService.cs deleted file mode 100644 index 37e4c4a2..00000000 --- a/samples/My.Hr/My.Hr.Business/Services/VerificationService.cs +++ /dev/null @@ -1,69 +0,0 @@ -namespace My.Hr.Business.Services; - -public class VerificationService -{ - private readonly AgifyApiClient _agifyApiClient; - private readonly GenderizeApiClient _genderizeApiClient; - private readonly NationalizeApiClient _nationalizeApiClient; - private readonly HrSettings _settings; - private readonly IEventPublisher _publisher; - - public VerificationService(AgifyApiClient agifyApiClient, GenderizeApiClient genderizeApiClient, NationalizeApiClient nationalizeApiClient, HrSettings settings, IEventPublisher publisher) - { - _agifyApiClient = agifyApiClient; - _genderizeApiClient = genderizeApiClient; - _nationalizeApiClient = nationalizeApiClient; - _settings = settings; - _publisher = publisher; - } - - public async Task> VerifyAsync(string name) - { - var agifyTask = _agifyApiClient.GetAgeAsync(name); - var genderizeTask = _genderizeApiClient.GetGenderAsync(name); - var nationalizeTask = _nationalizeApiClient.GetNationalityAsync(name); - - await Task.WhenAll(agifyTask, genderizeTask, nationalizeTask); - - return new Tuple(agifyTask.Result, genderizeTask.Result, nationalizeTask.Result); - } - - public async Task VerifyAndPublish(EmployeeVerificationRequest request) - { - var result = await VerifyAsync(request.Name!); - - var response = new EmployeeVerificationResponse(request) - { - Age = result.Item1.Age, - Gender = result.Item2.Gender, - GenderProbability = result.Item2.Probability - }; - - response.Country.AddRange(result.Item3.Country!); - - var nationality = response.Country.OrderByDescending(c => c.Probability).First(); - - response.VerificationMessages.Add( - @$"Performed verification for {request.Name}, {request.Gender} age {request.Age}. - Engine predicted age was {response.Age}. - Engine predicted gender was {response.Gender} with {ToPercents(response.GenderProbability)} probability. - Most likely nationality of {request.Name} is {nationality.Country_Id} with {ToPercents(nationality.Probability)} probability" - ); - - // first check age - if (Math.Abs(request.Age - response.Age) >= 10) - { - response.VerificationMessages.Add($"Employee age ({request.Age}) is not within range of 10 years of predicted age: {response.Age}"); - } - - if (response.GenderProbability > 0.5 && !response.Gender!.Equals(request.Gender, StringComparison.InvariantCultureIgnoreCase)) - { - response.VerificationMessages.Add($"Employee gender ({request.Gender}) doesn't match predicted gender: {response.Gender}"); - } - - _publisher.PublishNamed(_settings.VerificationResultsQueueName, new EventData { Value = response }); - await _publisher.SendAsync(); - } - - private static string ToPercents(float value) => (int)(value * 100) + "%"; -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Validators/EmployeeValidator.cs b/samples/My.Hr/My.Hr.Business/Validators/EmployeeValidator.cs deleted file mode 100644 index bda9f877..00000000 --- a/samples/My.Hr/My.Hr.Business/Validators/EmployeeValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentValidation; - -namespace My.Hr.Business.Validators; - -public class EmployeeValidator : AbstractValidator -{ - public EmployeeValidator() - { - RuleFor(x => x.Email).NotNull().EmailAddress(); - RuleFor(x => x.FirstName).NotNull().MaximumLength(100); - RuleFor(x => x.LastName).NotNull().MaximumLength(100); - RuleFor(x => x.Gender).NotNull().IsValid(); - RuleFor(x => x.Birthday).NotNull().LessThanOrEqualTo(SystemTime.Timestamp.AddYears(-18)).WithMessage("Birthday is invalid as the Employee must be at least 18 years of age."); - RuleFor(x => x.StartDate).NotNull().GreaterThanOrEqualTo(new DateTime(1999, 01, 01, 0, 0, 0, DateTimeKind.Utc)).WithMessage("January 1, 1999"); - RuleFor(x => x.PhoneNo).NotNull().MaximumLength(50); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Business/Validators/EmployeeVerificationValidator.cs b/samples/My.Hr/My.Hr.Business/Validators/EmployeeVerificationValidator.cs deleted file mode 100644 index 52d45417..00000000 --- a/samples/My.Hr/My.Hr.Business/Validators/EmployeeVerificationValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FluentValidation; - -namespace My.Hr.Business.Validators; - -public class EmployeeVerificationValidator : AbstractValidator -{ - public EmployeeVerificationValidator() - { - RuleFor(x => x.Name).NotNull().MaximumLength(100); - RuleFor(x => x.Gender).NotNull().MaximumLength(50); // todo: validate if reference data exists - RuleFor(x => x.Age).NotNull().GreaterThanOrEqualTo(18).LessThanOrEqualTo(120).WithMessage("Age has to be between 18 and 120"); - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Data/RefData.yaml b/samples/My.Hr/My.Hr.Database/Data/RefData.yaml deleted file mode 100644 index 2fad84b8..00000000 --- a/samples/My.Hr/My.Hr.Database/Data/RefData.yaml +++ /dev/null @@ -1,72 +0,0 @@ -Hr: - - $Gender: - - F: Female - - M: Male - - N: Not specified - - $TerminationReason: - - RE: Resigned - - RD: Redundant - - TM: Terminated - - $RelationshipType: - - SPO: Spouse - - PTN: Partner - - PAR: Parent - - CHI: Child - - SIB: Sibling - - EXF: Extended family - - FRD: Friend - - $USState: - - AL: Alabama - - AK: Alaska - - AZ: Arizona - - AR: Arkansas - - CA: California - - CO: Colorado - - CT: Connecticut - - DE: Delaware - - FL: Florida - - GA: Georgia - - HI: Hawaii - - ID: Idaho - - IL: Illinois - - IN: Indiana - - IA: Iowa - - KS: Kansas - - KY: Kentucky - - LA: Louisiana - - ME: Maine - - MD: Maryland - - MA: Massachusetts - - MI: Michigan - - MN: Minnesota - - MS: Mississippi - - MO: Missouri - - MT: Montana - - NE: Nebraska - - NV: Nevada - - NH: New Hampshire - - NJ: New Jersey - - NM: New Mexico - - NY: New York - - NC: North Carolina - - ND: North Dakota - - OH: Ohio - - OK: Oklahoma - - OR: Oregon - - PA: Pennsylvania - - RI: Rhode Island - - SC: South Carolina - - SD: South Dakota - - TN: Tennessee - - TX: Texas - - UT: Utah - - VT: Vermont - - VA: Virginia - - WA: Washington - - WV: West Virginia - - WI: Wisconsin - - WY: Wyoming - - $PerformanceOutcome: - - DN: Does not meet expectations - - ME: Meets expectations - - EE: Exceeds expectations \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Dockerfile b/samples/My.Hr/My.Hr.Database/Dockerfile deleted file mode 100644 index 2fc30395..00000000 --- a/samples/My.Hr/My.Hr.Database/Dockerfile +++ /dev/null @@ -1,60 +0,0 @@ -FROM mcr.microsoft.com/mssql/server:2019-latest AS base -USER root -# Install dotnet sdk -RUN apt-get update; \ - apt-get install -y apt-transport-https && \ - apt-get update && \ - apt-get install -y dotnet-runtime-6.0 - -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build -WORKDIR /src - -# It's important to keep lines from here down to "COPY . ." identical in all Dockerfiles -# to take advantage of Docker's build cache, to speed up local container builds -COPY "samples/My.Hr/My.Hr.sln" "samples/My.Hr/My.Hr.sln" - -COPY "samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj" "samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj" -COPY "samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj" "samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj" -COPY "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj" "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj" -COPY "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj" "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj" -COPY "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj" "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj" -COPY "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj" "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj" -COPY "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj" "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj" - -COPY "src/CoreEx/CoreEx.csproj" "src/CoreEx/CoreEx.csproj" -COPY "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj" "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj" -COPY "src/CoreEx.Azure/CoreEx.Azure.csproj" "src/CoreEx.Azure/CoreEx.Azure.csproj" -COPY "src/CoreEx.Cosmos/CoreEx.Cosmos.csproj" "src/CoreEx.Cosmos/CoreEx.Cosmos.csproj" -COPY "src/CoreEx.Database/CoreEx.Database.csproj" "src/CoreEx.Database/CoreEx.Database.csproj" -COPY "src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj" "src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj" -COPY "src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj" "src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj" -COPY "src/CoreEx.FluentValidation/CoreEx.FluentValidation.csproj" "src/CoreEx.FluentValidation/CoreEx.FluentValidation.csproj" -COPY "src/CoreEx.Newtonsoft/CoreEx.Newtonsoft.csproj" "src/CoreEx.Newtonsoft/CoreEx.Newtonsoft.csproj" -COPY "src/CoreEx.Validation/CoreEx.Validation.csproj" "src/CoreEx.Validation/CoreEx.Validation.csproj" - - -RUN dotnet restore "samples/My.Hr/My.Hr.sln" - -COPY . . -WORKDIR /src/samples/My.Hr/My.Hr.Database -RUN dotnet build -c Release -o /dbex/build - -FROM base as final -USER root - -ENV ACCEPT_EULA Y -ENV MSSQL_SA_PASSWORD sAPWD23.^0 -ENV MSSQL_TCP_PORT 1433 -ENV MSSQL_AGENT_ENABLED true -ENV ConnectionStrings__sqlserver:MyHr Data Source=localhost, $MSSQL_TCP_PORT;Initial Catalog=My.Hr;User id=sa;Password=$MSSQL_SA_PASSWORD;TrustServerCertificate=true - - -# Copy setup scripts -WORKDIR /usr/local/ -COPY --from=build /dbex/build /dbex -COPY samples/My.Hr/My.Hr.Database/wait-for-it.sh samples/My.Hr/My.Hr.Database/entrypoint.sh ./ - -RUN chmod +x ./*.sh - -ENTRYPOINT ["/usr/local/entrypoint.sh"] -CMD ["sql"] \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20190101-000001-create-Hr-schema.sql b/samples/My.Hr/My.Hr.Database/Migrations/20190101-000001-create-Hr-schema.sql deleted file mode 100644 index 5fc766a5..00000000 --- a/samples/My.Hr/My.Hr.Database/Migrations/20190101-000001-create-Hr-schema.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE SCHEMA [Hr] - AUTHORIZATION [dbo]; \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20200909-162702-create-Hr-Employee.sql b/samples/My.Hr/My.Hr.Database/Migrations/20200909-162702-create-Hr-Employee.sql deleted file mode 100644 index e6d953e1..00000000 --- a/samples/My.Hr/My.Hr.Database/Migrations/20200909-162702-create-Hr-Employee.sql +++ /dev/null @@ -1,24 +0,0 @@ --- Migration Script - -BEGIN TRANSACTION - -CREATE TABLE [Hr].[Employee] ( - [EmployeeId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, -- This is the primary key - [Email] NVARCHAR(250) NULL UNIQUE, -- This is the employee's unique email address - [FirstName] NVARCHAR(100) NULL, - [LastName] NVARCHAR(100) NULL, - [GenderCode] NVARCHAR(50) NULL, -- This is the related Gender code; see Ref.Gender table - [Birthday] DATE NULL, - [StartDate] DATE NULL, - [TerminationDate] DATE NULL, - [TerminationReasonCode] NVARCHAR(50) NULL, -- This is the related Termination Reason code; see Ref.TerminationReason table - [PhoneNo] NVARCHAR(50) NULL, - [AddressJson] NVARCHAR(500) NULL, -- This is the full address persisted as JSON. - [RowVersion] TIMESTAMP NOT NULL, -- This is used for concurrency version checking. - [CreatedBy] NVARCHAR(250) NULL, -- The following are standard audit columns. - [CreatedDate] DATETIME2 NULL, - [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL -); - -COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20200909-163321-create-Hr-EmergencyContact.sql b/samples/My.Hr/My.Hr.Database/Migrations/20200909-163321-create-Hr-EmergencyContact.sql deleted file mode 100644 index 45a6652a..00000000 --- a/samples/My.Hr/My.Hr.Database/Migrations/20200909-163321-create-Hr-EmergencyContact.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Migration Script - -BEGIN TRANSACTION - -CREATE TABLE [Hr].[EmergencyContact] ( - [EmergencyContactId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, - [EmployeeId] UNIQUEIDENTIFIER NOT NULL, - [FirstName] NVARCHAR(100) NULL, - [LastName] NVARCHAR(100) NULL, - [PhoneNo] NVARCHAR(50) NULL, - [RelationshipTypeCode] NVARCHAR(50) NULL -); - -COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20200915-160812-create-Hr-PerformanceReview.sql b/samples/My.Hr/My.Hr.Database/Migrations/20200915-160812-create-Hr-PerformanceReview.sql deleted file mode 100644 index 5582abf7..00000000 --- a/samples/My.Hr/My.Hr.Database/Migrations/20200915-160812-create-Hr-PerformanceReview.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Migration Script - -BEGIN TRANSACTION - -CREATE TABLE [Hr].[PerformanceReview] ( - [PerformanceReviewId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, - [EmployeeId] UNIQUEIDENTIFIER NOT NULL, - [Date] DATETIME2 NULL, - [PerformanceOutcomeCode] NVARCHAR(50) NULL, - [Reviewer] NVARCHAR(100) NULL, - [Notes] NVARCHAR(4000) NULL, - [RowVersion] TIMESTAMP NOT NULL, - [CreatedBy] NVARCHAR(250) NULL, - [CreatedDate] DATETIME2 NULL, - [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL -); - -COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20200915-161927-create-hr-performanceoutcome.sql b/samples/My.Hr/My.Hr.Database/Migrations/20200915-161927-create-hr-performanceoutcome.sql deleted file mode 100644 index c61c2baf..00000000 --- a/samples/My.Hr/My.Hr.Database/Migrations/20200915-161927-create-hr-performanceoutcome.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Migration Script - -BEGIN TRANSACTION - -CREATE TABLE [Hr].[PerformanceOutcome] ( - [PerformanceOutcomeId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, - [Code] NVARCHAR(50) NOT NULL UNIQUE, - [Text] NVARCHAR(250) NULL, - [IsActive] BIT NULL, - [SortOrder] INT NULL, - [RowVersion] TIMESTAMP NOT NULL, - [CreatedBy] NVARCHAR(250) NULL, - [CreatedDate] DATETIME2 NULL, - [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL -); - -COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20211208-001509-create-hr-eventoutbox-table.sql b/samples/My.Hr/My.Hr.Database/Migrations/20211208-001509-create-hr-eventoutbox-table.sql deleted file mode 100644 index fa6059d4..00000000 --- a/samples/My.Hr/My.Hr.Database/Migrations/20211208-001509-create-hr-eventoutbox-table.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE [Hr].[EventOutbox] ( - /* - * This is automatically generated; any changes will be lost. - */ - - [EventOutboxId] BIGINT IDENTITY (1, 1) NOT NULL PRIMARY KEY NONCLUSTERED ([EventOutboxId] ASC), - [EnqueuedDate] DATETIME2 NOT NULL, - [PartitionKey] NVARCHAR(128) NULL, - [DequeuedDate] DATETIME2 NULL, - CONSTRAINT [IX_Hr_EventOutbox_DequeuedDate] UNIQUE CLUSTERED ([DequeuedDate], [EventOutboxId]) -); \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20211208-001509-create-hr-eventoutboxdata-table.sql b/samples/My.Hr/My.Hr.Database/Migrations/20211208-001509-create-hr-eventoutboxdata-table.sql deleted file mode 100644 index 971dce15..00000000 --- a/samples/My.Hr/My.Hr.Database/Migrations/20211208-001509-create-hr-eventoutboxdata-table.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE [Hr].[EventOutboxData] ( - /* - * This is automatically generated; any changes will be lost. - */ - - [EventOutboxId] BIGINT NOT NULL PRIMARY KEY CLUSTERED ([EventOutboxId] ASC), - [EventId] UNIQUEIDENTIFIER, - [Subject] NVARCHAR(1024), - [Action] NVARCHAR(128) NULL, - [CorrelationId] NVARCHAR(64) NULL, - [TenantId] UNIQUEIDENTIFIER NULL, - [PartitionKey] NVARCHAR(128) NULL, - [ValueType] NVARCHAR(1024) NULL, - [EventData] VARBINARY(MAX) NULL -); \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20240930-132603-hr-spgetemployees.sql b/samples/My.Hr/My.Hr.Database/Migrations/20240930-132603-hr-spgetemployees.sql deleted file mode 100644 index a04bf562..00000000 --- a/samples/My.Hr/My.Hr.Database/Migrations/20240930-132603-hr-spgetemployees.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE OR ALTER PROCEDURE [Hr].[spGetEmployees] - @Ids AS NVARCHAR(MAX) -AS -BEGIN - SET NOCOUNT ON; - - -- Select the requested data. - SELECT * FROM [Hr].[Employee] WHERE [EmployeeId] IN (SELECT VALUE FROM OPENJSON(@Ids)) -END \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20250127-175724-create-Hr-Employee2.sql b/samples/My.Hr/My.Hr.Database/Migrations/20250127-175724-create-Hr-Employee2.sql deleted file mode 100644 index 8eabf3f1..00000000 --- a/samples/My.Hr/My.Hr.Database/Migrations/20250127-175724-create-Hr-Employee2.sql +++ /dev/null @@ -1,25 +0,0 @@ --- Migration Script - -BEGIN TRANSACTION - -CREATE TABLE [Hr].[Employee2] ( - [EmployeeId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, -- This is the primary key - [Email] NVARCHAR(250) NULL UNIQUE, -- This is the employee's unique email address - [FirstName] NVARCHAR(100) NULL, - [LastName] NVARCHAR(100) NULL, - [GenderCode] NVARCHAR(50) NULL, -- This is the related Gender code; see Ref.Gender table - [Birthday] DATE NULL, - [StartDate] DATE NULL, - [TerminationDate] DATE NULL, - [TerminationReasonCode] NVARCHAR(50) NULL, -- This is the related Termination Reason code; see Ref.TerminationReason table - [PhoneNo] NVARCHAR(50) NULL, - [AddressJson] NVARCHAR(500) NULL, -- This is the full address persisted as JSON. - [IsDeleted] BIT NULL, -- Logical delete - [RowVersion] TIMESTAMP NOT NULL, -- This is used for concurrency version checking. - [CreatedBy] NVARCHAR(250) NULL, -- The following are standard audit columns. - [CreatedDate] DATETIME2 NULL, - [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL -); - -COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj b/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj deleted file mode 100644 index 9acbcaeb..00000000 --- a/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - Exe - net6.0 - enable - enable - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Program.cs b/samples/My.Hr/My.Hr.Database/Program.cs deleted file mode 100644 index 052137b3..00000000 --- a/samples/My.Hr/My.Hr.Database/Program.cs +++ /dev/null @@ -1,34 +0,0 @@ -using DbEx.Migration; -using DbEx.SqlServer.Console; - -namespace My.Hr.Database -{ - /// - /// Represents the database utilities program (capability). - /// - public class Program - { - /// - /// Main startup. - /// - /// The startup arguments. - /// The status code whereby zero indicates success. - internal static Task Main(string[] args) => new SqlServerMigrationConsole("Data Source=.;Initial Catalog=My.HrDb;Integrated Security=True;TrustServerCertificate=true") - .Configure(c => ConfigureMigrationArgs(c.Args)) - .RunAsync(args); - - /// - /// Configure the . - /// - /// The . - /// The . - public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) - { - args.ConnectionStringEnvironmentVariableName = "My_HrDb"; - args.DataParserArgs.RefDataColumnDefaults.TryAdd("IsActive", _ => true); - args.DataParserArgs.RefDataColumnDefaults.TryAdd("SortOrder", i => i); - args.AddAssembly(); - return args; - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Properties/launchSettings.json b/samples/My.Hr/My.Hr.Database/Properties/launchSettings.json deleted file mode 100644 index 3eace5c6..00000000 --- a/samples/My.Hr/My.Hr.Database/Properties/launchSettings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "profiles": { - "My.Hr.Database": { - "commandName": "Project", - "commandLineArgs": "all" - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/entrypoint.sh b/samples/My.Hr/My.Hr.Database/entrypoint.sh deleted file mode 100644 index 1d010da8..00000000 --- a/samples/My.Hr/My.Hr.Database/entrypoint.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -e - -if [ "$1" = 'sql' ]; then - if ! [[ -f /var/opt/mssql/.initialized ]]; - then - ./wait-for-it.sh localhost:1433 -t 30 -- sleep 10 && echo "db is up" - - echo "Creating $DB_NAME database..." - - #run the setup script to create the DB and the schema in the DB - dotnet /dbex/My.Hr.Database.dll all - - echo "Database scripts complete" - touch /var/opt/mssql/.initialized - fi & - exec /opt/mssql/bin/sqlservr -fi - -exec "$@" \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/wait-for-it.sh b/samples/My.Hr/My.Hr.Database/wait-for-it.sh deleted file mode 100644 index 127f18c4..00000000 --- a/samples/My.Hr/My.Hr.Database/wait-for-it.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bash -# Use this script to test if a given TCP host/port are available -# Source: https://github.com/vishnubob/wait-for-it - -WAITFORIT_cmdname=${0##*/} - -echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } - -usage() -{ - cat << USAGE >&2 -Usage: - $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] - -h HOST | --host=HOST Host or IP under test - -p PORT | --port=PORT TCP port under test - Alternatively, you specify the host and port as host:port - -s | --strict Only execute subcommand if the test succeeds - -q | --quiet Don't output any status messages - -t TIMEOUT | --timeout=TIMEOUT - Timeout in seconds, zero for no timeout - -- COMMAND ARGS Execute command with args after the test finishes -USAGE - exit 1 -} - -wait_for() -{ - if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then - echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" - else - echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" - fi - WAITFORIT_start_ts=$(date +%s) - while : - do - if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then - nc -z $WAITFORIT_HOST $WAITFORIT_PORT - WAITFORIT_result=$? - else - (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 - WAITFORIT_result=$? - fi - if [[ $WAITFORIT_result -eq 0 ]]; then - WAITFORIT_end_ts=$(date +%s) - echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" - break - fi - sleep 1 - done - return $WAITFORIT_result -} - -wait_for_wrapper() -{ - # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 - if [[ $WAITFORIT_QUIET -eq 1 ]]; then - timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & - else - timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & - fi - WAITFORIT_PID=$! - trap "kill -INT -$WAITFORIT_PID" INT - wait $WAITFORIT_PID - WAITFORIT_RESULT=$? - if [[ $WAITFORIT_RESULT -ne 0 ]]; then - echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" - fi - return $WAITFORIT_RESULT -} - -# process arguments -while [[ $# -gt 0 ]] -do - case "$1" in - *:* ) - WAITFORIT_hostport=(${1//:/ }) - WAITFORIT_HOST=${WAITFORIT_hostport[0]} - WAITFORIT_PORT=${WAITFORIT_hostport[1]} - shift 1 - ;; - --child) - WAITFORIT_CHILD=1 - shift 1 - ;; - -q | --quiet) - WAITFORIT_QUIET=1 - shift 1 - ;; - -s | --strict) - WAITFORIT_STRICT=1 - shift 1 - ;; - -h) - WAITFORIT_HOST="$2" - if [[ $WAITFORIT_HOST == "" ]]; then break; fi - shift 2 - ;; - --host=*) - WAITFORIT_HOST="${1#*=}" - shift 1 - ;; - -p) - WAITFORIT_PORT="$2" - if [[ $WAITFORIT_PORT == "" ]]; then break; fi - shift 2 - ;; - --port=*) - WAITFORIT_PORT="${1#*=}" - shift 1 - ;; - -t) - WAITFORIT_TIMEOUT="$2" - if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi - shift 2 - ;; - --timeout=*) - WAITFORIT_TIMEOUT="${1#*=}" - shift 1 - ;; - --) - shift - WAITFORIT_CLI=("$@") - break - ;; - --help) - usage - ;; - *) - echoerr "Unknown argument: $1" - usage - ;; - esac -done - -if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then - echoerr "Error: you need to provide a host and port to test." - usage -fi - -WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} -WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} -WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} -WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} - -# Check to see if timeout is from busybox? -WAITFORIT_TIMEOUT_PATH=$(type -p timeout) -WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) - -WAITFORIT_BUSYTIMEFLAG="" -if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then - WAITFORIT_ISBUSY=1 - # Check if busybox timeout uses -t flag - # (recent Alpine versions don't support -t anymore) - if timeout &>/dev/stdout | grep -q -e '-t '; then - WAITFORIT_BUSYTIMEFLAG="-t" - fi -else - WAITFORIT_ISBUSY=0 -fi - -if [[ $WAITFORIT_CHILD -gt 0 ]]; then - wait_for - WAITFORIT_RESULT=$? - exit $WAITFORIT_RESULT -else - if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then - wait_for_wrapper - WAITFORIT_RESULT=$? - else - wait_for - WAITFORIT_RESULT=$? - fi -fi - -if [[ $WAITFORIT_CLI != "" ]]; then - if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then - echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" - exit $WAITFORIT_RESULT - fi - exec "${WAITFORIT_CLI[@]}" -else - exit $WAITFORIT_RESULT -fi \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/.dockerignore b/samples/My.Hr/My.Hr.Functions/.dockerignore deleted file mode 100644 index 1927772b..00000000 --- a/samples/My.Hr/My.Hr.Functions/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -local.settings.json \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/.gitignore b/samples/My.Hr/My.Hr.Functions/.gitignore deleted file mode 100644 index 3c3f4e6a..00000000 --- a/samples/My.Hr/My.Hr.Functions/.gitignore +++ /dev/null @@ -1,264 +0,0 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. - -# Azure Functions localsettings file -local.settings.json - -# User-specific files -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ - -# Visual Studio 2015 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# DNX -project.lock.json -project.fragment.lock.json -artifacts/ - -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings -# but database connection strings (with potential passwords) will be unencrypted -#*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# The packages folder can be ignored because of Package Restore -**/packages/* -# except build/, which is used as an MSBuild target. -!**/packages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config -# NuGet v3's project.json files produces more ignoreable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -node_modules/ -orleans.codegen.cs - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files -*.mdf -*.ldf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml - -# CodeRush -.cr/ - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/.vscode/extensions.json b/samples/My.Hr/My.Hr.Functions/.vscode/extensions.json deleted file mode 100644 index dde673dc..00000000 --- a/samples/My.Hr/My.Hr.Functions/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "ms-azuretools.vscode-azurefunctions" - ] -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/Dockerfile b/samples/My.Hr/My.Hr.Functions/Dockerfile deleted file mode 100644 index 891e4daa..00000000 --- a/samples/My.Hr/My.Hr.Functions/Dockerfile +++ /dev/null @@ -1,55 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS installer-env -# set to true for local runs - switches functions auth to anoymous -ARG LOCAL - -# Build requires 3.1 SDK -COPY --from=mcr.microsoft.com/dotnet/core/sdk:3.1 /usr/share/dotnet /usr/share/dotnet - -WORKDIR /src -# It's important to keep lines from here down to "COPY . ." identical in all Dockerfiles -# to take advantage of Docker's build cache, to speed up local container builds -COPY "samples/My.Hr/My.Hr.sln" "samples/My.Hr/My.Hr.sln" - -COPY "samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj" "samples/My.Hr/My.Hr.Api/My.Hr.Api.csproj" -COPY "samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj" "samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj" -COPY "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj" "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj" -COPY "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj" "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj" -COPY "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj" "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj" -COPY "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj" "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj" -COPY "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj" "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj" - -COPY "src/CoreEx/CoreEx.csproj" "src/CoreEx/CoreEx.csproj" -COPY "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj" "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj" -COPY "src/CoreEx.Azure/CoreEx.Azure.csproj" "src/CoreEx.Azure/CoreEx.Azure.csproj" -COPY "src/CoreEx.Cosmos/CoreEx.Cosmos.csproj" "src/CoreEx.Cosmos/CoreEx.Cosmos.csproj" -COPY "src/CoreEx.Database/CoreEx.Database.csproj" "src/CoreEx.Database/CoreEx.Database.csproj" -COPY "src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj" "src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj" -COPY "src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj" "src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj" -COPY "src/CoreEx.FluentValidation/CoreEx.FluentValidation.csproj" "src/CoreEx.FluentValidation/CoreEx.FluentValidation.csproj" -COPY "src/CoreEx.Newtonsoft/CoreEx.Newtonsoft.csproj" "src/CoreEx.Newtonsoft/CoreEx.Newtonsoft.csproj" -COPY "src/CoreEx.Validation/CoreEx.Validation.csproj" "src/CoreEx.Validation/CoreEx.Validation.csproj" - -RUN dotnet restore "samples/My.Hr/My.Hr.sln" - -COPY . . - -WORKDIR /src/samples/My.Hr/My.Hr.Functions - -RUN mkdir -p /home/site/wwwroot && \ - dotnet publish *.csproj --no-restore -c Debug --output /home/site/wwwroot && \ - echo LOCAL is "$LOCAL" && \ - echo $(if [ "$LOCAL" = "true" ] ; then find / \( -type f -name .git -prune \) -o -type f -name "function.json" -print0 | xargs -0 sed -i 's/authLevel\": \"function/authLevel\": \"anonymous/g' ; fi) - -# To enable ssh & remote debugging on app service change the base image to the one below -# FROM mcr.microsoft.com/azure-functions/dotnet:4-appservice -# FROM mcr.microsoft.com/azure-functions/dotnet:4 -FROM mcr.microsoft.com/azure-functions/dotnet:4-appservice -ARG LOCAL - -ENV AzureWebJobsScriptRoot=/home/site/wwwroot -ENV AzureFunctionsJobHost__Logging__Console__IsEnabled=true -ENV AzureFunctionsJobHost__Logging__LogLevel__CoreEx=Debug -ENV AzureFunctionsJobHost__Logging__LogToConsole=true -ENV AzureFunctionsJobHost__Logging__LogToConsoleColor=true - -COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"] \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs b/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs deleted file mode 100644 index d1fbbbfe..00000000 --- a/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs +++ /dev/null @@ -1,73 +0,0 @@ -using CoreEx.Validation; -using CoreEx.AspNetCore.WebApis; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Extensions.Http; -using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes; -using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums; -using Microsoft.OpenApi.Models; -using My.Hr.Business.Models; -using My.Hr.Business.Services; -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Mime; -using System.Threading.Tasks; - -namespace My.Hr.Functions.Functions; - -public class EmployeeFunction -{ - private readonly WebApi _webApi; - private readonly EmployeeService _service; - private readonly IValidator _validator; - - public EmployeeFunction(WebApi webApi, EmployeeService service, IValidator validator) - { - _webApi = webApi; - _service = service; - _validator = validator; - } - - [FunctionName("Get")] - [OpenApiOperation(operationId: "Get", tags: new[] { "employee" })] - [OpenApiParameter(name: "id", Description = "The employee id", Required = true, In = ParameterLocation.Path, Type = typeof(Guid))] - [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)] - [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: MediaTypeNames.Application.Json, bodyType: typeof(Employee), Description = "Employee record")] - [OpenApiResponseWithoutBody(statusCode: HttpStatusCode.NotFound, Description = "Not found")] - public Task GetAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/employees/{id}")] HttpRequest request, Guid id) - => _webApi.GetAsync(request, _ => _service.GetEmployeeAsync(id)); - - [FunctionName("GetAll")] - [OpenApiOperation(operationId: "GetAll", tags: new[] { "employee" })] - [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)] - [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: MediaTypeNames.Application.Json, bodyType: typeof(List), Description = "Employee records")] - public Task GetAllAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/employees")] HttpRequest request) - => _webApi.GetAsync(request, p => _service.GetAllAsync(p.RequestOptions.Query, p.RequestOptions.Paging)); - - [FunctionName("Create")] - [OpenApiOperation(operationId: "Create", tags: new[] { "employee" })] - [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)] - [OpenApiResponseWithoutBody(statusCode: HttpStatusCode.Created, Description = "Created employee record")] - public Task CreateAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "api/employees")] HttpRequest request) - => _webApi.PostAsync(request, p => _service.AddEmployeeAsync(p.Value!), - statusCode: HttpStatusCode.Created, validator: _validator, locationUri: e => new Uri($"api/employees/{e.Id}", UriKind.RelativeOrAbsolute)); - - [FunctionName("Update")] - [OpenApiOperation(operationId: "Update", tags: new[] { "employee" })] - [OpenApiParameter(name: "id", Description = "The employee id", Required = true, In = ParameterLocation.Path, Type = typeof(Guid))] - [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)] - [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: MediaTypeNames.Application.Json, bodyType: typeof(Employee), Description = "Employee record")] - [OpenApiResponseWithoutBody(statusCode: HttpStatusCode.NotFound, Description = "Not found")] - public Task UpdateAsync([HttpTrigger(AuthorizationLevel.Function, "put", Route = "api/employees/{id}")] HttpRequest request, Guid id) - => _webApi.PutAsync(request, p => _service.UpdateEmployeeAsync(p.Value!, id), validator: _validator); - - [FunctionName("Patch")] - public Task PatchAsync([HttpTrigger(AuthorizationLevel.Function, "patch", Route = "api/employees/{id}")] HttpRequest request, Guid id) - => _webApi.PatchAsync(request, get: _ => _service.GetEmployeeAsync(id), put: p => _service.UpdateEmployeeAsync(p.Value!, id), validator: _validator); - - [FunctionName("Delete")] - public Task DeleteAsync([HttpTrigger(AuthorizationLevel.Function, "delete", Route = "api/employees/{id}")] HttpRequest request, Guid id) - => _webApi.DeleteAsync(request, _ => _service.DeleteEmployeeAsync(id)); -} diff --git a/samples/My.Hr/My.Hr.Functions/Functions/HttpTriggerQueueVerificationFunction.cs b/samples/My.Hr/My.Hr.Functions/Functions/HttpTriggerQueueVerificationFunction.cs deleted file mode 100644 index 56be0417..00000000 --- a/samples/My.Hr/My.Hr.Functions/Functions/HttpTriggerQueueVerificationFunction.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Net; -using System.Net.Mime; -using System.Threading.Tasks; -using CoreEx.FluentValidation; -using CoreEx.AspNetCore.WebApis; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Extensions.Http; -using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes; -using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums; -using Microsoft.OpenApi.Models; -using My.Hr.Business; -using My.Hr.Business.External.Contracts; -using My.Hr.Business.Validators; - -namespace My.Hr.Functions; - -public class HttpTriggerQueueVerificationFunction -{ - private readonly WebApiPublisher _webApiPublisher; - private readonly HrSettings _settings; - - public HttpTriggerQueueVerificationFunction(WebApiPublisher webApiPublisher, HrSettings settings) - { - _webApiPublisher = webApiPublisher; - _settings = settings; - } - - [FunctionName(nameof(HttpTriggerQueueVerificationFunction))] - [OpenApiOperation(operationId: "Run", tags: new[] { "employee" })] - [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)] - [OpenApiRequestBody(MediaTypeNames.Application.Json, typeof(EmployeeVerificationRequest), Description = "The **EmployeeVerification** payload")] - [OpenApiResponseWithBody(statusCode: HttpStatusCode.Accepted, contentType: MediaTypeNames.Text.Plain, bodyType: typeof(string), Description = "The OK response")] - public Task RunAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "employee/verify")] HttpRequest request) - => _webApiPublisher.PublishAsync(request, new WebApiPublisherArgs(_settings.VerificationQueueName) { Validator = new EmployeeVerificationValidator().Wrap() }); -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/Functions/ServiceBusExecuteVerificationFunction.cs b/samples/My.Hr/My.Hr.Functions/Functions/ServiceBusExecuteVerificationFunction.cs deleted file mode 100644 index 1421a85f..00000000 --- a/samples/My.Hr/My.Hr.Functions/Functions/ServiceBusExecuteVerificationFunction.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Threading.Tasks; -using Azure.Messaging.ServiceBus; -using CoreEx.FluentValidation; -using CoreEx.Azure.ServiceBus; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.ServiceBus; -using My.Hr.Business; -using My.Hr.Business.Services; -using My.Hr.Business.Validators; - -namespace My.Hr.Functions; - -public class ServiceBusExecuteVerificationFunction -{ - private readonly ServiceBusSubscriber _subscriber; - private readonly VerificationService _service; - - public ServiceBusExecuteVerificationFunction(ServiceBusSubscriber subscriber, VerificationService service) - { - _subscriber = subscriber; - _service = service; - } - - [FunctionName(nameof(ServiceBusExecuteVerificationFunction))] - [ExponentialBackoffRetry(3, "00:02:00", "00:30:00")] - public Task RunAsync([ServiceBusTrigger("%" + nameof(HrSettings.VerificationQueueName) + "%", Connection = nameof(HrSettings.ServiceBusConnection))] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions) - => _subscriber.ReceiveAsync(message, messageActions, (ed, _) => _service.VerifyAndPublish(ed.Value), validator: new EmployeeVerificationValidator().Wrap()); -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj b/samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj deleted file mode 100644 index 7ab8d33f..00000000 --- a/samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - net6.0 - v4 - <_FunctionsSkipCleanOutput>true - - - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/MyHrApiConfigurationOptions.cs b/samples/My.Hr/My.Hr.Functions/MyHrApiConfigurationOptions.cs deleted file mode 100644 index 18f49ac5..00000000 --- a/samples/My.Hr/My.Hr.Functions/MyHrApiConfigurationOptions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Configurations; -using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums; -using Microsoft.OpenApi.Models; - -namespace My.Hr.Functions; - -/// Configuration options for . -public class MyOpenApiConfigurationOptions : DefaultOpenApiConfigurationOptions -{ - public override OpenApiInfo Info { get; set; } = new OpenApiInfo() - { - Version = "1.0.1", - Title = "CoreEx My HR Sample", - Description = "A serverless Azure Function which demonstrates the use of CoreEx.", - TermsOfService = new Uri("https://github.com/Avanade/CoreEx"), - - License = new OpenApiLicense() - { - Name = "MIT", - Url = new Uri("http://opensource.org/licenses/MIT"), - } - }; - - public override OpenApiVersionType OpenApiVersion { get; set; } = OpenApiVersionType.V3; - -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/README.md b/samples/My.Hr/My.Hr.Functions/README.md deleted file mode 100644 index df14d20f..00000000 --- a/samples/My.Hr/My.Hr.Functions/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# About - -tbd - -## Configuration - -Sample configuration for `local.settings.json` - -```json -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "FUNCTIONS_WORKER_RUNTIME": "dotnet", - - "AgifyApiEndpointUri": "https://api.agify.io", - "NationalizeApiClientApiEndpointUri": "https://api.nationalize.io", - "GenderizeApiClientApiEndpointUri": "https://api.genderize.io", - - "VerificationQueueName": "pendingVerifications", - "VerificationResultsQueueName": "verificationResults", - - "ServiceBusConnection__fullyQualifiedNamespace": "coreex.servicebus.windows.net", - - "HttpLogContent": "true", - "AzureFunctionsJobHost__logging__logLevel__CoreEx": "Debug", - "AzureFunctionsJobHost__logging__logToConsole": "true", - "AzureFunctionsJobHost__logging__logToConsoleColor": "true", - "AzureFunctionsJobHost__logging__console__isEnabled": "true", - - "MassPublishQueueName": "mass-publish" - } -} -``` diff --git a/samples/My.Hr/My.Hr.Functions/Startup.cs b/samples/My.Hr/My.Hr.Functions/Startup.cs deleted file mode 100644 index 5704aaef..00000000 --- a/samples/My.Hr/My.Hr.Functions/Startup.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Threading.Tasks; -using CoreEx; -using CoreEx.AspNetCore.HealthChecks; -using CoreEx.AspNetCore.WebApis; -using CoreEx.Database; -using CoreEx.Database.HealthChecks; -using CoreEx.Http.HealthChecks; -using CoreEx.Json.Merge; -using Microsoft.Azure.Functions.Extensions.DependencyInjection; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using My.Hr.Business; -using My.Hr.Business.Data; -using My.Hr.Business.External; -using My.Hr.Business.Services; -using Az = Azure.Messaging.ServiceBus; - -[assembly: FunctionsStartup(typeof(My.Hr.Functions.Startup))] - -namespace My.Hr.Functions; - -public class Startup : FunctionsStartup -{ - public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder) - { - } - - public override void Configure(IFunctionsHostBuilder builder) - { - try - { - // Register the core services. - builder.Services - .AddSettings() - .AddReferenceDataOrchestrator() - .AddExecutionContext() - .AddJsonSerializer() - .AddEventDataSerializer() - .AddEventDataFormatter() - .AddEventPublisher() - .AddSingleton(sp => new Az.ServiceBusClient(sp.GetRequiredService().ServiceBusConnection__fullyQualifiedNamespace)) - .AddAzureServiceBusSender() - .AddWebApi((_, webapi) => webapi.UnhandledExceptionAsync = (ex, logger, _) => Task.FromResult(ex is DbUpdateConcurrencyException efex ? webapi.CreateActionResultFromExtendedException(new ConcurrencyException(), logger) : null)) - .AddJsonMergePatch(sp => new JsonMergePatch()) - .AddWebApiPublisher() - .AddAzureServiceBusSubscriber(); - - // Register the health checks. - builder.Services - .AddHealthChecks() - .AddTypeActivatedCheck>("Genderize API") - .AddTypeActivatedCheck>("Agify API") - .AddTypeActivatedCheck>("Nationalize API") - //.AddTypeActivatedCheck("Health check for service bus verification queue", HealthStatus.Unhealthy, nameof(HrSettings.ServiceBusConnection), nameof(HrSettings.VerificationQueueName)) - .AddTypeActivatedCheck>("SQL Server", HealthStatus.Unhealthy, tags: default, timeout: System.TimeSpan.FromSeconds(15), nameof(HrSettings.ConnectionStrings__Database)); - - // Register the business services. - builder.Services - .AddScoped() - .AddScoped() - .AddScoped() - .AddFluentValidators(); - - // Register the typed backend http clients. - builder.Services.AddTypedHttpClient("Agify"); - builder.Services.AddTypedHttpClient("Genderize"); - builder.Services.AddTypedHttpClient("Nationalize"); - - // Database - builder.Services.AddDatabase(); - builder.Services.AddDbContext((sp, o) => o.UseSqlServer(sp.GetRequiredService().GetConnection())); - } - catch (System.Exception ex) - { - // try catch block for running the function in docker container, without it, it may fail silently. - System.Console.Error.WriteLine(ex); - throw; - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Functions/host.json b/samples/My.Hr/My.Hr.Functions/host.json deleted file mode 100644 index 3a74677c..00000000 --- a/samples/My.Hr/My.Hr.Functions/host.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": "2.0", - "logging": { - "applicationInsights": { - "samplingSettings": { - "isEnabled": true, - "excludedTypes": "Request" - } - }, - "logLevel": { - "default": "Warning" - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/Data/Data.yaml b/samples/My.Hr/My.Hr.UnitTest/Data/Data.yaml deleted file mode 100644 index 1b73dbf9..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/Data/Data.yaml +++ /dev/null @@ -1,15 +0,0 @@ -Hr: - - Employee: - - { EmployeeId: 1, Email: w.jones@org.com, FirstName: Wendy, LastName: Jones, GenderCode: F, Birthday: 1985-03-18, StartDate: 2000-12-11, PhoneNo: (425) 612 8113 } - - { EmployeeId: 2, Email: b.smith@org.com, FirstName: Brian, LastName: Smith, GenderCode: M, Birthday: 1994-11-07, StartDate: 2013-08-06, TerminationDate: 2015-04-08, TerminationReasonCode: RE, PhoneNo: (429) 120 0098 } - - { EmployeeId: 3, Email: r.Browne@org.com, FirstName: Rachael, LastName: Browne, GenderCode: F, Birthday: 1972-06-28, StartDate: 2019-11-06, PhoneNo: (421) 783 2343 } - - { EmployeeId: 4, Email: w.smither@org.com, FirstName: Waylon, LastName: Smithers, GenderCode: M, Birthday: 1952-02-21, StartDate: 2001-01-22, PhoneNo: (428) 893 2793, AddressJson: '{ "street1": "8365 851 PL NE", "city": "Redmond", "state": "WA", "postCode": "98052" }' } - - EmergencyContact: - - { EmergencyContactId: 201, EmployeeId: 2, FirstName: Garth, LastName: Smith, PhoneNo: (443) 678 1827, RelationshipTypeCode: PAR } - - { EmergencyContactId: 202, EmployeeId: 2, FirstName: Sarah, LastName: Smith, PhoneNo: (443) 234 3837, RelationshipTypeCode: PAR } - - { EmergencyContactId: 401, EmployeeId: 4, FirstName: Michael, LastName: Manners, PhoneNo: (234) 297 9834, RelationshipTypeCode: FRD } - - Employee2: - - { EmployeeId: 1, Email: w.jones@org.com, FirstName: Wendy, LastName: Jones, GenderCode: F, Birthday: 1985-03-18, StartDate: 2000-12-11, PhoneNo: (425) 612 8113 } - - { EmployeeId: 2, Email: b.smith@org.com, FirstName: Brian, LastName: Smith, GenderCode: M, Birthday: 1994-11-07, StartDate: 2013-08-06, TerminationDate: 2015-04-08, TerminationReasonCode: RE, PhoneNo: (429) 120 0098, IsDeleted: false } - - { EmployeeId: 3, Email: r.Browne@org.com, FirstName: Rachael, LastName: Browne, GenderCode: F, Birthday: 1972-06-28, StartDate: 2019-11-06, PhoneNo: (421) 783 2343, IsDeleted: true } - - { EmployeeId: 4, Email: w.smither@org.com, FirstName: Waylon, LastName: Smithers, GenderCode: M, Birthday: 1952-02-21, StartDate: 2001-01-22, PhoneNo: (428) 893 2793, AddressJson: '{ "street1": "8365 851 PL NE", "city": "Redmond", "state": "WA", "postCode": "98052" }' } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/DatabaseTest.cs b/samples/My.Hr/My.Hr.UnitTest/DatabaseTest.cs deleted file mode 100644 index 77944c2e..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/DatabaseTest.cs +++ /dev/null @@ -1,196 +0,0 @@ -using CoreEx; -using CoreEx.Database; -using CoreEx.Entities; -using CoreEx.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using My.Hr.Api; -using My.Hr.Business.Data; -using My.Hr.Business.Models; -using NUnit.Framework; -using NUnit.Framework.Internal; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Dynamic.Core; -using System.Threading.Tasks; -using UnitTestEx; - -namespace My.Hr.UnitTest -{ - [TestFixture] - [Category("WithDB")] - public class DatabaseTest - { - [OneTimeSetUp] - public static Task Init() => EmployeeControllerTest.Init(); - - [Test] - public async Task DatabaseParameters_JsonParameter() - { - using var test = ApiTester.Create(); - using var scope = test.Services.CreateScope(); - - var hrdb = scope.ServiceProvider.GetRequiredService(); - - var ids = new List(); - await hrdb.SqlStatement("SELECT * FROM [Hr].[Employee]").SelectAsync(dr => - { - ids.Add(dr.GetValue("EmployeeId")); - return true; - }); - - var ids2 = new List(); - var c = hrdb.StoredProcedure("[Hr].[spGetEmployees]").JsonParamWith("", "ids", () => ids); - - await c.SelectAsync(dr => - { - ids2.Add(dr.GetValue("EmployeeId")); - return true; - }); - - Assert.That(ids, Is.EquivalentTo(ids2)); - } - - [Test] - public async Task EF_00A_Query_SelectSingle() - { - using var test = ApiTester.Create(); - using var scope = test.Services.CreateScope(); - var ef = scope.ServiceProvider.GetRequiredService(); - - var r = await ef.Employees2.Query(q => q.Where(x => x.Id == 1.ToGuid())).SelectSingleAsync(); - Assert.That(r, Is.Not.Null); - - await Assert.ThatAsync(async () => await ef.Employees2.Query(q => q.Where(x => x.Id == 404.ToGuid())).SelectSingleAsync(), Throws.Exception.TypeOf()); - - var r2 = await ef.Employees2.Query(q => q.Where(x => x.Id == 404.ToGuid())).SelectSingleOrDefaultAsync(); - Assert.That(r2, Is.Null); - } - - [Test] - public async Task EF_00B_Query_SelectFirst() - { - using var test = ApiTester.Create(); - using var scope = test.Services.CreateScope(); - var ef = scope.ServiceProvider.GetRequiredService(); - - var r = await ef.Employees2.Query().SelectFirstAsync(); - Assert.That(r, Is.Not.Null); - - await Assert.ThatAsync(async () => await ef.Employees2.Query(q => q.Where(x => x.Id == 404.ToGuid())).SelectFirstAsync(), Throws.Exception.TypeOf()); - - var r2 = await ef.Employees2.Query(q => q.Where(x => x.Id == 404.ToGuid())).SelectFirstOrDefaultAsync(); - Assert.That(r2, Is.Null); - } - - [Test] - public async Task EF_00C_Query_SelectQuery() - { - using var test = ApiTester.Create(); - using var scope = test.Services.CreateScope(); - var ef = scope.ServiceProvider.GetRequiredService(); - - var c = await ef.Employees2.Query().WithPaging(1).SelectQueryAsync>(); - - Assert.That(c, Is.Not.Null); - Assert.That(c, Has.Count.EqualTo(2)); - } - - [Test] - public async Task EF_00D_Query_SelectResult() - { - using var test = ApiTester.Create(); - using var scope = test.Services.CreateScope(); - var ef = scope.ServiceProvider.GetRequiredService(); - - var r = await ef.Employees2.Query().WithPaging(PagingArgs.CreateSkipAndTake(1, null, true)).SelectResultAsync(); - - Assert.That(r, Is.Not.Null); - Assert.That(r.Items, Has.Count.EqualTo(2)); - Assert.That(r.Paging, Is.Not.Null); - Assert.That(r.Paging!.TotalCount, Is.EqualTo(3)); - } - - [Test] - public async Task EF_00E_Query_SelectQuery_Item() - { - using var test = ApiTester.Create(); - using var scope = test.Services.CreateScope(); - var ef = scope.ServiceProvider.GetRequiredService(); - - var count = 0; - - await ef.Query().SelectQueryAsync(e => - { - count++; - Assert.That(e, Is.Not.Null); - Assert.That(e.Id, Is.Not.EqualTo(Guid.Empty)); - Assert.That(e.FirstName, Is.Not.Null); - return count <= 1; - }); - - Assert.That(count, Is.EqualTo(2)); - } - - [Test] - public async Task EF_01_Query_IsDeleted() - { - using var test = ApiTester.Create(); - using var scope = test.Services.CreateScope(); - var ef = scope.ServiceProvider.GetRequiredService(); - - var r = await ef.Employees2.Query().SelectResultAsync(); - - Assert.That(r, Is.Not.Null); - Assert.That(r.Items, Has.Count.EqualTo(3)); - } - - [Test] - public async Task EF_02_Get_IsDeleted() - { - using var test = ApiTester.Create(); - using var scope = test.Services.CreateScope(); - var ef = scope.ServiceProvider.GetRequiredService(); - - var e = await ef.Employees2.GetAsync(1.ToGuid()); - Assert.That(e, Is.Not.Null); - - e = await ef.Employees2.GetAsync(2.ToGuid()); - Assert.That(e, Is.Not.Null); - - e = await ef.Employees2.GetAsync(3.ToGuid()); - Assert.That(e, Is.Null); - } - - [Test] - public async Task EF_03_Update_IsDeleted() - { - using var test = ApiTester.Create(); - using var scope = test.Services.CreateScope(); - var ef = scope.ServiceProvider.GetRequiredService(); - - var e = await ef.Employees2.GetAsync(1.ToGuid()); - Assert.That(e, Is.Not.Null); - - ef.DbContext.ChangeTracker.Clear(); - - e!.Id = 3.ToGuid(); - await Assert.ThatAsync(async () => await ef.Employees2.UpdateAsync(e), Throws.Exception.TypeOf()); - } - - [Test] - public async Task EF_04_Delete_IsDeleted() - { - using var test = ApiTester.Create(); - using var scope = test.Services.CreateScope(); - var ef = scope.ServiceProvider.GetRequiredService(); - - await ef.Employees2.DeleteAsync(1.ToGuid()); - - var e = await ef.Employees2.GetAsync(1.ToGuid()); - Assert.That(e, Is.Null); - - await Assert.ThatAsync(async () => await ef.Employees2.DeleteAsync(1.ToGuid()), Throws.Exception.TypeOf()); - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs deleted file mode 100644 index 9c6e64c5..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs +++ /dev/null @@ -1,464 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Events; -using CoreEx.Http; -using Microsoft.Extensions.Configuration; -using My.Hr.Api; -using My.Hr.Api.Controllers; -using My.Hr.Business.Models; -using My.Hr.Business.External.Contracts; -using NUnit.Framework; -using System; -using System.Linq; -using System.Threading.Tasks; -using UnitTestEx; -using UnitTestEx.Expectations; -using DbEx; -using DbEx.Migration; -using DbEx.SqlServer.Migration; - -namespace My.Hr.UnitTest -{ - [TestFixture] - [Category("WithDB")] - public class EmployeeControllerTest - { - [OneTimeSetUp] - public static async Task Init() - { - HttpConsts.IncludeFieldsQueryStringName = "include-fields"; - - using var test = ApiTester.Create(); - var cs = test.Configuration.GetConnectionString("Database"); - var args = Database.Program.ConfigureMigrationArgs(new MigrationArgs(MigrationCommand.ResetAndDatabase, cs)).AddAssembly(); - var (Success, Output) = await new SqlServerMigration(args).MigrateAndLogAsync().ConfigureAwait(false); - if (!Success) - Assert.Fail(Output); - } - - [Test] - public void A100_Get_NotFound() - { - using var test = ApiTester.Create(); - - test.Controller() - .Run(c => c.GetAsync(404.ToGuid())) - .AssertNotFound(); - } - - [Test] - public void A110_Get_Found() - { - using var test = ApiTester.Create(); - - var resp = test.Controller() - .Run(c => c.GetAsync(1.ToGuid())) - .AssertOK() - .AssertValue(new Employee - { - Id = 1.ToGuid(), - Email = "w.jones@org.com", - FirstName = "Wendy", - LastName = "Jones", - Gender = "F", - Birthday = new DateTime(1985, 03, 18, 0, 0, 0, DateTimeKind.Unspecified), - StartDate = new DateTime(2000, 12, 11, 0, 0, 0, DateTimeKind.Unspecified), - PhoneNo = "(425) 612 8113" - }, nameof(Employee.ETag)) - .Response; - - // Also, validate the context header messages. - var result = HttpResult.CreateAsync(resp).GetAwaiter().GetResult(); - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.Messages, Is.Not.Null); - }); - Assert.That(result.Messages, Has.Count.EqualTo(1)); - Assert.Multiple(() => - { - Assert.That(result.Messages![0].Type, Is.EqualTo(MessageType.Warning)); - Assert.That(result.Messages[0].Text, Is.EqualTo("Employee is considered old.")); - }); - } - - [Test] - public void A120_Get_NotModifed() - { - using var test = ApiTester.Create(); - - var e = test.Controller() - .Run(c => c.GetAsync(1.ToGuid())) - .AssertOK() - .GetValue()!; - - test.Controller() - .Run(c => c.GetAsync(e.Id), requestOptions: new HttpRequestOptions { ETag = e.ETag }) - .AssertNotModified(); - } - - [Test] - public void A130_Get_IncludeFields() - { - using var test = ApiTester.Create(); - - test.Controller() - .Run(c => c.GetAsync(1.ToGuid()), requestOptions: new HttpRequestOptions().Include("FirstName", "LastName")) - .AssertOK() - .AssertJson("{\"firstName\":\"Wendy\",\"lastName\":\"Jones\"}"); - } - - [Test] - public void B100_GetAll_All() - { - using var test = ApiTester.Create(); - - var v = test.Controller() - .Run(c => c.GetAllAsync()) - .AssertOK() - .GetValue(); - - Assert.Multiple(() => - { - Assert.That(v?.Items, Is.Not.Null); - Assert.That(v!.Items, Has.Count.EqualTo(4)); - Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Browne", "Jones", "Smith", "Smithers" })); - }); - } - - [Test] - public void B110_GetAll_Paging() - { - using var test = ApiTester.Create(); - - var x = TestSetUp.Extensions; - - var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2, true))) - .AssertOK() - .GetValue(); - - Assert.Multiple(() => - { - Assert.That(v?.Items, Is.Not.Null); - Assert.That(v!.Items, Has.Count.EqualTo(2)); - Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Jones", "Smith" })); - Assert.That(v.Paging, Is.Not.Null); - }); - Assert.That(v!.Paging!.TotalCount, Is.EqualTo(4)); - } - - [Test] - public void B120_GetAll_PagingAndIncludeFields() - { - using var test = ApiTester.Create(); - - var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2)).Include("lastname")) - .AssertOK() - .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]") - .GetValue(); - - Assert.That(v!.Paging!.TotalCount, Is.Null); // No count requested. - } - - [Test] - public void B120_GetAll_Filter_LastName() - { - using var test = ApiTester.Create(); - - var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create().Filter("startswith(lastname, 's')")) - .AssertOK() - .GetValue(); - - Assert.Multiple(() => - { - Assert.That(v?.Items, Is.Not.Null); - Assert.That(v!.Items, Has.Count.EqualTo(2)); - Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Smith", "Smithers" })); - }); - } - - [Test] - public void B130_GetAll_Filter_StartDateAndGenders_OrderBy_FirstName() - { - using var test = ApiTester.Create(); - - var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create().Filter("startdate ge 2010-01-01 and gender in ('m','f')").OrderBy("lastname desc")) - .AssertOK() - .GetValue(); - - Assert.Multiple(() => - { - Assert.That(v?.Items, Is.Not.Null); - Assert.That(v!.Items, Has.Count.EqualTo(2)); - Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Smith", "Browne" })); - }); - } - - [Test] - public void C100_Create_Error() - { - using var test = ApiTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "Z", - PhoneNo = "555 123 4567", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.Controller() - .Run(c => c.CreateAsync(null!), e) - .AssertErrors( - new ApiError("Email", "'Email' must not be empty."), - new ApiError("Gender", "'Gender' is invalid.")); - } - - [Test] - public void C110_Create_Success() - { - using var test = ApiTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - Email = "rs@email.com", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - var v = test.Controller() - .Run(c => c.CreateAsync(null!), e) - .AssertCreated() - .AssertValue(e, "Id", "ETag") - .AssertLocationHeader(v => new Uri($"api/employees/{v!.Id}", UriKind.Relative)) - .AssertLocationHeaderContains("api/employees") // Just for kicks testing both types work - .GetValue(); - - // Do a GET to make sure it is in the database and all fields equal. - test.Controller() - .Run(c => c.GetAsync(v!.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void D100_Update_Error() - { - using var test = ApiTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.Controller() - .Run(c => c.UpdateAsync(404.ToGuid(), null!), e) - .AssertErrors( - new ApiError("Email", "'Email' must not be empty.")); - } - - [Test] - public void D110_Update_NotFound() - { - using var test = ApiTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - Email = "rs@email.com", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.Controller() - .Run(c => c.UpdateAsync(404.ToGuid(), null!), e) - .AssertNotFound(); - } - - [Test] - public void D120_Update_Success() - { - using var test = ApiTester.Create(); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertOK() - .GetValue()!; - - // Update it. - v.FirstName += "X"; - - v = test.Controller() - .Run(c => c.UpdateAsync(v.Id, null!), v) - .AssertOK() - .AssertValue(v, "ETag") - .GetValue()!; - - // Get again and check all. - test.Controller() - .Run(c => c.GetAsync(v.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void D130_Update_ConcurrencyError() - { - using var test = ApiTester.Create(); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertOK() - .GetValue()!; - - // Update it with errant etag. - v.FirstName += "X"; - v.ETag = "ZZZZZZZZZZZZ"; - - test.Controller() - .ExpectLogContains("fail: A concurrency error occurred; please refresh the data and try again.") // Verifies the ConcurrencyException.ShouldBeLogged was logged as expected. - .Run(c => c.UpdateAsync(v.Id, null!), v) - .AssertPreconditionFailed(); - } - - [Test] - public void E100_Delete() - { - using var test = ApiTester.Create(); - - // Get current. - test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertOK(); - - // Delete it. - test.Controller() - .Run(c => c.DeleteAsync(2.ToGuid())) - .AssertNoContent(); - - // Must not exist. - test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertNotFound(); - - // Delete it again; should appear as if deleted as operation is considered idempotent. - test.Controller() - .Run(c => c.DeleteAsync(2.ToGuid())) - .AssertNoContent(); - } - - [Test] - public void F100_Patch_NotFound() - { - using var test = ApiTester.Create(); - - test.Controller() - .RunContent(c => c.PatchAsync(404.ToGuid(), null!), "{}", HttpConsts.MergePatchMediaTypeName) - .AssertNotFound(); - } - - [Test] - public void F110_Patch_Concurrency() - { - using var test = ApiTester.Create(); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(4.ToGuid())) - .AssertOK() - .GetValue()!; - - // Patch it with errant etag. - v.FirstName += "X"; - - test.Controller() - .ExpectLogContains("fail: A concurrency error occurred; please refresh the data and try again.") // Verifies the ConcurrencyException.ShouldBeLogged was logged as expected. - .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", HttpConsts.MergePatchMediaTypeName, new HttpRequestOptions { ETag = "ZZZZZZZZZZZZ" }) - .AssertPreconditionFailed(); - } - - [Test] - public void F120_Patch() - { - using var test = ApiTester.Create(); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(4.ToGuid())) - .AssertOK() - .GetValue()!; - - // Patch it with errant etag. - v.FirstName += "X"; - - v = test.Controller() - .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", HttpConsts.MergePatchMediaTypeName, new HttpRequestOptions { ETag = v.ETag }) - .AssertOK() - .AssertValue(v, "ETag") - .GetValue()!; - - // Get again and check all. - test.Controller() - .Run(c => c.GetAsync(v.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void G100_Verify_NotFound() - { - using var test = ApiTester.Create(); - - test.Controller() - .Run(c => c.VerifyAsync(404.ToGuid())) - .AssertNotFound(); - } - - [Test] - public void G100_Verify_Publish() - { - using var test = ApiTester.Create(); - var imp = new InMemoryPublisher(test.Logger); - - test.ReplaceScoped(_ => imp) - .Controller() - .Run(c => c.VerifyAsync(1.ToGuid())) - .AssertAccepted(); - - Assert.That(imp.GetNames(), Has.Length.EqualTo(1)); - var e = imp.GetEvents("pendingVerifications"); - Assert.That(e, Has.Length.EqualTo(1)); - ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" }, e[0].Value); - } - - [Test] - public void G100_Verify_Publish_WithExpectations() - { - using var test = ApiTester.Create(); - test.UseExpectedEvents() - .Controller() - .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" } }) - .ExpectStatusCode(System.Net.HttpStatusCode.Accepted) - .Run(c => c.VerifyAsync(1.ToGuid())); - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs deleted file mode 100644 index f8741295..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs +++ /dev/null @@ -1,395 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Events; -using CoreEx.Http; -using My.Hr.Api; -using My.Hr.Api.Controllers; -using My.Hr.Business.Models; -using My.Hr.Business.External.Contracts; -using NUnit.Framework; -using System; -using System.Linq; -using System.Threading.Tasks; -using UnitTestEx; -using UnitTestEx.Expectations; -using My.Hr.Business.Services; - -namespace My.Hr.UnitTest -{ - [TestFixture] - [Category("WithDB")] - public class EmployeeControllerTest2 - { - [OneTimeSetUp] - public static Task Init() => EmployeeControllerTest.Init(); - - [Test] - public void A100_Get_NotFound() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - test.Controller() - .Run(c => c.GetAsync(404.ToGuid())) - .AssertNotFound(); - } - - [Test] - public void A110_Get_Found() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - test.Controller() - .Run(c => c.GetAsync(1.ToGuid())) - .AssertOK() - .AssertValue(new Employee - { - Id = 1.ToGuid(), - Email = "w.jones@org.com", - FirstName = "Wendy", - LastName = "Jones", - Gender = "F", - Birthday = new DateTime(1985, 03, 18, 0, 0, 0, DateTimeKind.Unspecified), - StartDate = new DateTime(2000, 12, 11, 0, 0, 0, DateTimeKind.Unspecified), - PhoneNo = "(425) 612 8113" - }, nameof(Employee.ETag)); - } - - [Test] - public void A120_Get_NotModifed() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - var e = test.Controller() - .Run(c => c.GetAsync(1.ToGuid())) - .AssertOK() - .GetValue()!; - - test.Controller() - .Run(c => c.GetAsync(e.Id), requestOptions: new HttpRequestOptions { ETag = e.ETag }) - .AssertNotModified(); - } - - [Test] - public void A130_Get_IncludeFields() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - test.Controller() - .Run(c => c.GetAsync(1.ToGuid()), requestOptions: new HttpRequestOptions().Include("FirstName", "LastName")) - .AssertOK() - .AssertJson("{\"firstName\":\"Wendy\",\"lastName\":\"Jones\"}"); - } - - [Test] - public void B100_GetAll_All() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - var v = test.Controller() - .Run(c => c.GetAllAsync()) - .AssertOK() - .GetValue(); - - Assert.Multiple(() => - { - Assert.That(v?.Items, Is.Not.Null); - Assert.That(v!.Items, Has.Count.EqualTo(4)); - Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Browne", "Jones", "Smith", "Smithers" })); - }); - } - - [Test] - public void B110_GetAll_Paging() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2, true))) - .AssertOK() - .GetValue(); - - Assert.Multiple(() => - { - Assert.That(v?.Items, Is.Not.Null); - Assert.That(v!.Items, Has.Count.EqualTo(2)); - Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Jones", "Smith" })); - Assert.That(v.Paging, Is.Not.Null); - }); - Assert.That(v!.Paging!.TotalCount, Is.EqualTo(4)); - } - - [Test] - public void B120_GetAll_PagingAndIncludeFields() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2)).Include("lastname")) - .AssertOK() - .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]") - .GetValue(); - - Assert.That(v!.Paging!.TotalCount, Is.Null); // No count requested. - } - - [Test] - public void C100_Create_Error() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "Z", - PhoneNo = "555 123 4567", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.Controller() - .Run(c => c.CreateAsync(null!), e) - .AssertErrors( - new ApiError("Email", "'Email' must not be empty."), - new ApiError("Gender", "'Gender' is invalid.")); - } - - [Test] - public void C110_Create_Success() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - Email = "rs@email.com", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - var v = test.Controller() - .Run(c => c.CreateAsync(null!), e) - .AssertCreated() - .AssertValue(e, "Id", "ETag") - .AssertLocationHeader(v => new Uri($"api/employees/{v!.Id}", UriKind.Relative)) - .GetValue(); - - // Do a GET to make sure it is in the database and all fields equal. - test.Controller() - .Run(c => c.GetAsync(v!.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void D100_Update_Error() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.Controller() - .Run(c => c.UpdateAsync(404.ToGuid(), null!), e) - .AssertErrors( - new ApiError("Email", "'Email' must not be empty.")); - } - - [Test] - public void D110_Update_NotFound() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - Email = "rs@email.com", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.Controller() - .Run(c => c.UpdateAsync(404.ToGuid(), null!), e) - .AssertNotFound(); - } - - [Test] - public void D120_Update_Success() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertOK() - .GetValue()!; - - // Update it. - v.FirstName += "X"; - - v = test.Controller() - .Run(c => c.UpdateAsync(v.Id, null!), v) - .AssertOK() - .AssertValue(v, "ETag") - .GetValue()!; - - // Get again and check all. - test.Controller() - .Run(c => c.GetAsync(v.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void D130_Update_ConcurrencyError() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertOK() - .GetValue()!; - - // Update it with errant etag. - v.FirstName += "X"; - v.ETag = "ZZZZZZZZZZZZ"; - - test.Controller() - .Run(c => c.UpdateAsync(v.Id, null!), v) - .AssertPreconditionFailed(); - } - - [Test] - public void E100_Delete() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - // Get current. - test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertOK(); - - // Delete it. - test.Controller() - .Run(c => c.DeleteAsync(2.ToGuid())) - .AssertNoContent(); - - // Must not exist. - test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertNotFound(); - - // Delete it again; should appear as if deleted as operation is considered idempotent. - test.Controller() - .Run(c => c.DeleteAsync(2.ToGuid())) - .AssertNoContent(); - } - - [Test] - public void F100_Patch_NotFound() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - test.Controller() - .RunContent(c => c.PatchAsync(404.ToGuid(), null!), "{}", HttpConsts.MergePatchMediaTypeName) - .AssertNotFound(); - } - - [Test] - public void F110_Patch_Concurrency() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(4.ToGuid())) - .AssertOK() - .GetValue()!; - - // Patch it with errant etag. - v.FirstName += "X"; - - test.Controller() - .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", HttpConsts.MergePatchMediaTypeName, new HttpRequestOptions { ETag = "ZZZZZZZZZZZZ" }) - .AssertPreconditionFailed(); - } - - [Test] - public void F120_Patch() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(4.ToGuid())) - .AssertOK() - .GetValue()!; - - // Patch it with errant etag. - v.FirstName += "X"; - - v = test.Controller() - .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", HttpConsts.MergePatchMediaTypeName, new HttpRequestOptions { ETag = v.ETag }) - .AssertOK() - .AssertValue(v, "ETag") - .GetValue()!; - - // Get again and check all. - test.Controller() - .Run(c => c.GetAsync(v.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void G100_Verify_NotFound() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - - test.Controller() - .Run(c => c.VerifyAsync(404.ToGuid())) - .AssertNotFound(); - } - - [Test] - public void G100_Verify_Publish() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - var imp = new InMemoryPublisher(test.Logger); - - test.ReplaceScoped(_ => imp) - .Controller() - .Run(c => c.VerifyAsync(1.ToGuid())) - .AssertAccepted(); - - Assert.That(imp.GetNames(), Has.Length.EqualTo(1)); - var e = imp.GetEvents("pendingVerifications"); - Assert.That(e, Has.Length.EqualTo(1)); - ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" }, e[0].Value); - } - - [Test] - public void G100_Verify_Publish_WithExpectations() - { - using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); - test.UseExpectedEvents() - .Controller() - .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" } }) - .ExpectStatusCode(System.Net.HttpStatusCode.Accepted) - .Run(c => c.VerifyAsync(1.ToGuid())); - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs deleted file mode 100644 index e06b5d65..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs +++ /dev/null @@ -1,372 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Http; -using DbEx; -using DbEx.Migration; -using DbEx.SqlServer.Migration; -using Microsoft.Extensions.DependencyInjection; -using My.Hr.Business; -using My.Hr.Business.Models; -using My.Hr.Functions; -using My.Hr.Functions.Functions; -using NUnit.Framework; -using System; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using UnitTestEx; - -namespace My.Hr.UnitTest -{ - [TestFixture] - [Category("WithDB")] - public class EmployeeFunctionTest - { - [OneTimeSetUp] - public async Task Init() - { - HttpConsts.IncludeFieldsQueryStringName = "include-fields"; - - using var test = FunctionTester.Create(); - var settings = test.Services.GetRequiredService(); - var args = Database.Program.ConfigureMigrationArgs(new MigrationArgs(MigrationCommand.ResetAndDatabase, settings.ConnectionStrings__Database)).AddAssembly(); - var (Success, Output) = await new SqlServerMigration(args).MigrateAndLogAsync().ConfigureAwait(false); - if (!Success) - Assert.Fail(Output); - } - - [Test] - public void A100_Get_NotFound() - { - using var test = FunctionTester.Create(); - - test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{404.ToGuid()}"), 404.ToGuid())) - .AssertNotFound(); - } - - [Test] - public void A110_Get_Found() - { - using var test = FunctionTester.Create(); - - test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{1.ToGuid()}"), 1.ToGuid())) - .AssertOK() - .AssertValue(new Employee - { - Id = 1.ToGuid(), - Email = "w.jones@org.com", - FirstName = "Wendy", - LastName = "Jones", - Gender = "F", - Birthday = new DateTime(1985, 03, 18, 0, 0, 0, DateTimeKind.Unspecified), - StartDate = new DateTime(2000, 12, 11, 0, 0, 0, DateTimeKind.Unspecified), - PhoneNo = "(425) 612 8113" - }, nameof(Employee.ETag)); - } - - [Test] - public void A120_Get_NotModified() - { - using var test = FunctionTester.Create(); - - var e = test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{1.ToGuid()}"), 1.ToGuid())) - .AssertOK() - .GetValue()!; - - test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{1.ToGuid()}", new CoreEx.Http.HttpRequestOptions { ETag = e.ETag }), 1.ToGuid())) - .AssertNotModified(); - } - - [Test] - public void A130_Get_IncludeFields() - { - using var test = FunctionTester.Create(); - - var e = test.HttpTrigger() - .WithRouteCheck(UnitTestEx.Azure.Functions.RouteCheckOption.PathAndQueryStartsWith) - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{1.ToGuid()}", new CoreEx.Http.HttpRequestOptions().Include("FirstName", "LastName")), 1.ToGuid())) - .AssertOK() - .AssertJson("{\"firstName\":\"Wendy\",\"lastName\":\"Jones\"}"); - } - - [Test] - public void B100_GetAll_All() - { - using var test = FunctionTester.Create(); - - var v = test.HttpTrigger() - .Run(f => f.GetAllAsync(test.CreateHttpRequest(HttpMethod.Get, "api/employees"))) - .GetValue(); - - Assert.Multiple(() => - { - Assert.That(v?.Items, Is.Not.Null); - Assert.That(v!.Items, Has.Count.EqualTo(4)); - Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Browne", "Jones", "Smith", "Smithers" })); - }); - } - - [Test] - public void B110_GetAll_Paging() - { - using var test = FunctionTester.Create(); - - var v = test.HttpTrigger() - .WithRouteCheck(UnitTestEx.Azure.Functions.RouteCheckOption.PathAndQueryStartsWith) - .Run(f => f.GetAllAsync(test.CreateHttpRequest(HttpMethod.Get, "api/employees", CoreEx.Http.HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2, true))))) - .AssertOK() - .GetValue(); - - Assert.Multiple(() => - { - Assert.That(v?.Items, Is.Not.Null); - Assert.That(v!.Items, Has.Count.EqualTo(2)); - Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Jones", "Smith" })); - Assert.That(v.Paging, Is.Not.Null); - }); - Assert.That(v!.Paging!.TotalCount, Is.EqualTo(4)); - } - - [Test] - public void B120_GetAll_PagingAndIncludeFields() - { - using var test = FunctionTester.Create(); - - var v = test.HttpTrigger() - .WithRouteCheck(UnitTestEx.Azure.Functions.RouteCheckOption.PathAndQueryStartsWith) - .Run(f => f.GetAllAsync(test.CreateHttpRequest(HttpMethod.Get, "api/employees", CoreEx.Http.HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2, false)).Include("lastname")))) - .AssertOK() - .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]") - .GetValue(); - - Assert.That(v!.Paging!.TotalCount, Is.Null); // No count requested. - } - - [Test] - public void C100_Create_Error() - { - using var test = FunctionTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.HttpTrigger() - .Run(c => c.CreateAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "api/employees", e))) - .AssertErrors( - new ApiError("Email", "'Email' must not be empty.")); - } - - [Test] - public void C110_Create_Success() - { - using var test = FunctionTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - Email = "rs@email.com", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - var v = test.HttpTrigger() - .Run(c => c.CreateAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "api/employees", e))) - .AssertCreated() - .AssertValue(e, "Id", "ETag") - .AssertLocationHeader(v => new Uri($"api/employees/{v!.Id}", UriKind.Relative)) - .GetValue(); - - // Do a GET to make sure it is in the database and all fields equal. - test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{v!.Id}"), v.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void D100_Update_Error() - { - using var test = FunctionTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.HttpTrigger() - .Run(f => f.UpdateAsync(test.CreateJsonHttpRequest(HttpMethod.Put, $"api/employees/{404.ToGuid()}", e), 404.ToGuid())) - .AssertErrors( - new ApiError("Email", "'Email' must not be empty.")); - } - - [Test] - public void D110_Update_NotFound() - { - using var test = FunctionTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - Email = "rs@email.com", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.HttpTrigger() - .Run(f => f.UpdateAsync(test.CreateJsonHttpRequest(HttpMethod.Put, $"api/employees/{404.ToGuid()}", e), 404.ToGuid())) - .AssertNotFound(); - } - - [Test] - public void D120_Update_Success() - { - using var test = FunctionTester.Create(); - - // Get current. - var v = test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{2.ToGuid()}"), 2.ToGuid())) - .AssertOK() - .GetValue()!; - - // Update it. - v.FirstName += "X"; - - v = test.HttpTrigger() - .Run(f => f.UpdateAsync(test.CreateJsonHttpRequest(HttpMethod.Put, $"api/employees/{v.Id}", v), v.Id)) - .AssertOK() - .AssertValue(v, "ETag") - .GetValue()!; - - // Get again and check all. - test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{v.Id}"), v.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void D130_Update_ConcurrencyError() - { - using var test = FunctionTester.Create(); - - // Get current. - var v = test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{2.ToGuid()}"), 2.ToGuid())) - .AssertOK() - .GetValue()!; - - // Update it with errant etag. - v.FirstName += "X"; - v.ETag = "ZZZZZZZZZZZZ"; - - test.HttpTrigger() - .Run(f => f.UpdateAsync(test.CreateJsonHttpRequest(HttpMethod.Put, $"api/employees/{v.Id}", v), v.Id)) - .AssertPreconditionFailed(); - } - - [Test] - public void E100_Delete() - { - using var test = FunctionTester.Create(); - - // Get current. - test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{2.ToGuid()}"), 2.ToGuid())) - .AssertOK(); - - // Delete it. - test.HttpTrigger() - .Run(f => f.DeleteAsync(test.CreateHttpRequest(HttpMethod.Delete, $"api/employees/{2.ToGuid()}"), 2.ToGuid())) - .AssertNoContent(); - - // Must not exist. - test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{2.ToGuid()}"), 2.ToGuid())) - .AssertNotFound(); - - // Delete it again; should appear as if deleted as operation is considered idempotent. - test.HttpTrigger() - .Run(f => f.DeleteAsync(test.CreateHttpRequest(HttpMethod.Delete, $"api/employees/{2.ToGuid()}"), 2.ToGuid())) - .AssertNoContent(); - } - - [Test] - public void F100_Patch_NotFound() - { - using var test = FunctionTester.Create(); - - test.HttpTrigger() - .Run(f => f.PatchAsync(test.CreateHttpRequest(HttpMethod.Patch, $"api/employees/{404.ToGuid()}", "{}", HttpConsts.MergePatchMediaTypeName), 404.ToGuid())) - .AssertNotFound(); - } - - [Test] - public void F110_Patch_Concurrency() - { - using var test = FunctionTester.Create(); - - // Get current. - var v = test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{4.ToGuid()}"), 4.ToGuid())) - .AssertOK() - .GetValue()!; - - // Patch it with errant etag. - v.FirstName += "X"; - - var req = test.CreateHttpRequest(HttpMethod.Patch, $"api/employees/{v.Id}", $"{{ \"firstName\": \"{v.FirstName}\" }}", HttpConsts.MergePatchMediaTypeName, new CoreEx.Http.HttpRequestOptions { ETag = "ZZZZZZZZZZZZ" }); - test.HttpTrigger() - .Run(f => f.PatchAsync(req, v.Id)) - .AssertPreconditionFailed(); - } - - [Test] - public void F120_Patch() - { - using var test = FunctionTester.Create(); - - // Get current. - var v = test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{4.ToGuid()}"), 4.ToGuid())) - .AssertOK() - .GetValue()!; - - // Patch it with errant etag. - v.FirstName += "X"; - - var req = test.CreateHttpRequest(HttpMethod.Patch, $"api/employees/{v.Id}", $"{{ \"firstName\": \"{v.FirstName}\" }}", HttpConsts.MergePatchMediaTypeName, new CoreEx.Http.HttpRequestOptions { ETag = v.ETag }); - v = test.HttpTrigger() - .Run(f => f.PatchAsync(req, v.Id)) - .AssertOK() - .AssertValue(v, "ETag") - .GetValue()!; - - // Get again and check all. - test.HttpTrigger() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{v.Id}"), v.Id)) - .AssertOK() - .AssertValue(v); - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs deleted file mode 100644 index 8a74c8f7..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs +++ /dev/null @@ -1,408 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Events; -using CoreEx.Http; -using Microsoft.Extensions.Configuration; -using My.Hr.Api; -using My.Hr.Api.Controllers; -using My.Hr.Business.Models; -using My.Hr.Business.External.Contracts; -using NUnit.Framework; -using System; -using System.Linq; -using System.Threading.Tasks; -using UnitTestEx; -using UnitTestEx.Expectations; -using DbEx; -using DbEx.Migration; -using DbEx.SqlServer.Migration; - -namespace My.Hr.UnitTest -{ - [TestFixture] - [Category("WithDB")] - public class EmployeeResultControllerTest - { - [OneTimeSetUp] - public static async Task Init() - { - HttpConsts.IncludeFieldsQueryStringName = "include-fields"; - - using var test = ApiTester.Create(); - var cs = test.Configuration.GetConnectionString("Database"); - var args = Database.Program.ConfigureMigrationArgs(new MigrationArgs(MigrationCommand.ResetAndDatabase, cs)).AddAssembly(); - var (Success, Output) = await new SqlServerMigration(args).MigrateAndLogAsync().ConfigureAwait(false); - if (!Success) - Assert.Fail(Output); - } - - [Test] - public void A100_Get_NotFound() - { - using var test = ApiTester.Create(); - - test.Controller() - .Run(c => c.GetAsync(404.ToGuid())) - .AssertNotFound(); - } - - [Test] - public void A110_Get_Found() - { - using var test = ApiTester.Create(); - - test.Controller() - .Run(c => c.GetAsync(1.ToGuid())) - .AssertOK() - .AssertValue(new Employee - { - Id = 1.ToGuid(), - Email = "w.jones@org.com", - FirstName = "Wendy", - LastName = "Jones", - Gender = "F", - Birthday = new DateTime(1985, 03, 18, 0, 0, 0, DateTimeKind.Unspecified), - StartDate = new DateTime(2000, 12, 11, 0, 0, 0, DateTimeKind.Unspecified), - PhoneNo = "(425) 612 8113" - }, nameof(Employee.ETag)); - } - - [Test] - public void A120_Get_NotModifed() - { - using var test = ApiTester.Create(); - - var e = test.Controller() - .Run(c => c.GetAsync(1.ToGuid())) - .AssertOK() - .GetValue()!; - - test.Controller() - .Run(c => c.GetAsync(e.Id), requestOptions: new HttpRequestOptions { ETag = e.ETag }) - .AssertNotModified(); - } - - [Test] - public void A130_Get_IncludeFields() - { - using var test = ApiTester.Create(); - - test.Controller() - .Run(c => c.GetAsync(1.ToGuid()), requestOptions: new HttpRequestOptions().Include("FirstName", "LastName")) - .AssertOK() - .AssertJson("{\"firstName\":\"Wendy\",\"lastName\":\"Jones\"}"); - } - - [Test] - public void B100_GetAll_All() - { - using var test = ApiTester.Create(); - - var v = test.Controller() - .Run(c => c.GetAllAsync()) - .AssertOK() - .GetValue(); - - Assert.Multiple(() => - { - Assert.That(v?.Items, Is.Not.Null); - Assert.That(v!.Items, Has.Count.EqualTo(4)); - Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Browne", "Jones", "Smith", "Smithers" })); - }); - } - - [Test] - public void B110_GetAll_Paging() - { - using var test = ApiTester.Create(); - - var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2, true))) - .AssertOK() - .GetValue(); - - Assert.Multiple(() => - { - Assert.That(v?.Items, Is.Not.Null); - Assert.That(v!.Items, Has.Count.EqualTo(2)); - Assert.That(v.Items.Select(x => x.LastName).ToArray(), Is.EqualTo(new string[] { "Jones", "Smith" })); - Assert.That(v.Paging, Is.Not.Null); - }); - Assert.That(v!.Paging!.TotalCount, Is.EqualTo(4)); - } - - [Test] - public void B120_GetAll_PagingAndIncludeFields() - { - using var test = ApiTester.Create(); - - var v = test.Controller() - .Run(c => c.GetAllAsync(), requestOptions: HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(1, 2)).Include("lastname")) - .AssertOK() - .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]") - .GetValue(); - - Assert.That(v!.Paging!.TotalCount, Is.Null); // No count requested. - } - - [Test] - public void C100_Create_Error() - { - using var test = ApiTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "Z", - PhoneNo = "555 123 4567", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.Controller() - .Run(c => c.CreateAsync(null!), e) - .AssertErrors( - new ApiError("Email", "'Email' must not be empty."), - new ApiError("Gender", "'Gender' is invalid.")); - } - - [Test] - public void C110_Create_Success() - { - using var test = ApiTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - Email = "rs@email.com", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - var v = test.Controller() - .Run(c => c.CreateAsync(null!), e) - .AssertCreated() - .AssertValue(e, "Id", "ETag") - .AssertLocationHeader(v => new Uri($"api/employees/{v!.Id}", UriKind.Relative)) - .GetValue(); - - // Do a GET to make sure it is in the database and all fields equal. - test.Controller() - .Run(c => c.GetAsync(v!.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void D100_Update_Error() - { - using var test = ApiTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.Controller() - .Run(c => c.UpdateAsync(404.ToGuid(), null!), e) - .AssertErrors( - new ApiError("Email", "'Email' must not be empty.")); - } - - [Test] - public void D110_Update_NotFound() - { - using var test = ApiTester.Create(); - - var e = new Employee - { - FirstName = "Rebecca", - LastName = "Smythe", - Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified), - Gender = "M", - PhoneNo = "555 123 4567", - Email = "rs@email.com", - StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified) - }; - - test.Controller() - .Run(c => c.UpdateAsync(404.ToGuid(), null!), e) - .AssertNotFound(); - } - - [Test] - public void D120_Update_Success() - { - using var test = ApiTester.Create(); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertOK() - .GetValue()!; - - // Update it. - v.FirstName += "X"; - - v = test.Controller() - .Run(c => c.UpdateAsync(v.Id, null!), v) - .AssertOK() - .AssertValue(v, "ETag") - .GetValue()!; - - // Get again and check all. - test.Controller() - .Run(c => c.GetAsync(v.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void D130_Update_ConcurrencyError() - { - using var test = ApiTester.Create(); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertOK() - .GetValue()!; - - // Update it with errant etag. - v.FirstName += "X"; - v.ETag = "ZZZZZZZZZZZZ"; - - test.Controller() - .Run(c => c.UpdateAsync(v.Id, null!), v) - .AssertPreconditionFailed(); - } - - [Test] - public void E100_Delete() - { - using var test = ApiTester.Create(); - - // Get current. - test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertOK(); - - // Delete it. - test.Controller() - .Run(c => c.DeleteAsync(2.ToGuid())) - .AssertNoContent(); - - // Must not exist. - test.Controller() - .Run(c => c.GetAsync(2.ToGuid())) - .AssertNotFound(); - - // Delete it again; should appear as if deleted as operation is considered idempotent. - test.Controller() - .Run(c => c.DeleteAsync(2.ToGuid())) - .AssertNoContent(); - } - - [Test] - public void F100_Patch_NotFound() - { - using var test = ApiTester.Create(); - - test.Controller() - .RunContent(c => c.PatchAsync(404.ToGuid(), null!), "{}", HttpConsts.MergePatchMediaTypeName) - .AssertNotFound(); - } - - [Test] - public void F110_Patch_Concurrency() - { - using var test = ApiTester.Create(); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(4.ToGuid())) - .AssertOK() - .GetValue()!; - - // Patch it with errant etag. - v.FirstName += "X"; - - test.Controller() - .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", HttpConsts.MergePatchMediaTypeName, new HttpRequestOptions { ETag = "ZZZZZZZZZZZZ" }) - .AssertPreconditionFailed(); - } - - [Test] - public void F120_Patch() - { - using var test = ApiTester.Create(); - - // Get current. - var v = test.Controller() - .Run(c => c.GetAsync(4.ToGuid())) - .AssertOK() - .GetValue()!; - - // Patch it with errant etag. - v.FirstName += "X"; - - v = test.Controller() - .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", HttpConsts.MergePatchMediaTypeName, new HttpRequestOptions { ETag = v.ETag }) - .AssertOK() - .AssertValue(v, "ETag") - .GetValue()!; - - // Get again and check all. - test.Controller() - .Run(c => c.GetAsync(v.Id)) - .AssertOK() - .AssertValue(v); - } - - [Test] - public void G100_Verify_NotFound() - { - using var test = ApiTester.Create(); - - test.Controller() - .Run(c => c.VerifyAsync(404.ToGuid())) - .AssertNotFound(); - } - - [Test] - public void G100_Verify_Publish() - { - using var test = ApiTester.Create(); - var imp = new InMemoryPublisher(test.Logger); - - test.ReplaceScoped(_ => imp) - .Controller() - .Run(c => c.VerifyAsync(1.ToGuid())) - .AssertAccepted(); - - Assert.That(imp.GetNames(), Has.Length.EqualTo(1)); - var e = imp.GetEvents("pendingVerifications"); - Assert.That(e, Has.Length.EqualTo(1)); - ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" }, e[0].Value); - } - - [Test] - public void G100_Verify_Publish_WithExpectations() - { - using var test = ApiTester.Create(); - test.UseExpectedEvents() - .Controller() - .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" } }) - .ExpectStatusCode(System.Net.HttpStatusCode.Accepted) - .Run(c => c.VerifyAsync(1.ToGuid())); - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/HttpTriggerQueueVerificationFunctionTest.cs b/samples/My.Hr/My.Hr.UnitTest/HttpTriggerQueueVerificationFunctionTest.cs deleted file mode 100644 index 9d9afc21..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/HttpTriggerQueueVerificationFunctionTest.cs +++ /dev/null @@ -1,31 +0,0 @@ -using CoreEx.Events; -using My.Hr.Business.External.Contracts; -using My.Hr.Functions; -using NUnit.Framework; -using System.Net.Http; -using UnitTestEx; - -namespace My.Hr.UnitTest -{ - [TestFixture] - public class HttpTriggerQueueVerificationFunctionTest - { - [Test] - public void A110_Verify_Success() - { - var test = FunctionTester.Create(); - var imp = new InMemoryPublisher(test.Logger); - - test.ReplaceScoped(_ => imp) - .HttpTrigger() - .Run(f => f.RunAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "employee/verify", new EmployeeVerificationRequest { Name = "Wendy", Age = 37, Gender = "F" }))) - .AssertAccepted(); - - Assert.That(imp.GetNames(), Has.Length.EqualTo(1)); - - var e = imp.GetEvents("pendingVerifications"); - Assert.That(e, Has.Length.EqualTo(1)); - ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 37, Gender = "F" }, e[0].Value); - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj b/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj deleted file mode 100644 index 8e4ca919..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj +++ /dev/null @@ -1,52 +0,0 @@ - - - - net6.0 - enable - - false - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - PreserveNewest - - - - diff --git a/samples/My.Hr/My.Hr.UnitTest/OneTimeSetUp.cs b/samples/My.Hr/My.Hr.UnitTest/OneTimeSetUp.cs deleted file mode 100644 index e55a1eb6..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/OneTimeSetUp.cs +++ /dev/null @@ -1,16 +0,0 @@ -using NUnit.Framework; -using UnitTestEx; - -namespace My.Hr.UnitTest -{ - [SetUpFixture] - public class OneTimeSetUp - { - [OneTimeSetUp] - public void OneTime() - { - // Configure the TestSetUp to use the CoreEx-based Newtonsoft.Json.JsonSerialize; needed to enable the deserialization of RefData. - TestSetUp.Default.JsonSerializer = new CoreEx.Newtonsoft.Json.JsonSerializer().ToUnitTestEx(); - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/ReferenceDataControllerTest.cs b/samples/My.Hr/My.Hr.UnitTest/ReferenceDataControllerTest.cs deleted file mode 100644 index 92a76751..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/ReferenceDataControllerTest.cs +++ /dev/null @@ -1,100 +0,0 @@ -using CoreEx.Http; -using CoreEx.Text.Json; -using My.Hr.Api; -using My.Hr.Api.Controllers; -using My.Hr.Business.Models; -using NUnit.Framework; -using System.Linq; -using System.Threading.Tasks; -using UnitTestEx; - -namespace My.Hr.UnitTest -{ - [TestFixture] - [Category("WithDB")] - public class ReferenceDataControllerTest - { - [OneTimeSetUp] - public Task Init() => EmployeeControllerTest.Init(); - - [Test] - public void A100_USState_All() - { - using var test = ApiTester.Create().UseJsonSerializer(new ReferenceDataContentJsonSerializer().ToUnitTestEx()); - - var v = test.Controller() - .Run(c => c.USStateGetAll(null, null)) - .AssertOK() - .GetValue()!; - - Assert.That(v, Has.Length.EqualTo(50)); - } - - [Test] - public void A110_USState_Codes() - { - using var test = ApiTester.Create().UseJsonSerializer(new ReferenceDataContentJsonSerializer().ToUnitTestEx()); - - var v = test.Controller() - .Run(c => c.USStateGetAll(new string[] { "WA", "CO" }, null)) - .AssertOK() - .GetValue()!; - - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v.Select(x => x.Code), Is.EqualTo(new string[] { "CO", "WA" })); - } - - [Test] - public void A120_USState_Text() - { - using var test = ApiTester.Create().UseJsonSerializer(new ReferenceDataContentJsonSerializer().ToUnitTestEx()); - - var v = test.Controller() - .Run(c => c.USStateGetAll(null, "*or*")) - .AssertOK() - .GetValue()!; - - Assert.That(v, Has.Length.EqualTo(8)); - var x = v.Select(x => x.Code); - Assert.That(v.Select(x => x.Code), Is.EqualTo(new string[] { "CA", "CO", "FL", "GA", "NY", "NC", "ND", "OR" })); - } - - [Test] - public void A130_USState_FieldsAndNotModified() - { - using var test = ApiTester.Create().UseJsonSerializer(new ReferenceDataContentJsonSerializer().ToUnitTestEx()); - - var r = test.Controller() - .Run(c => c.USStateGetAll(new string[] { "WA", "CO" }, null), requestOptions: new HttpRequestOptions().Include("code", "text")) - .AssertOK() - .AssertJson("[{\"code\":\"CO\",\"text\":\"Colorado\"},{\"code\":\"WA\",\"text\":\"Washington\"}]"); - - test.Controller() - .Run(c => c.USStateGetAll(new string[] { "WA", "CO" }, null), requestOptions: new HttpRequestOptions { ETag = r.Response?.Headers?.ETag?.Tag }.Include("code", "text")) - .AssertNotModified(); - } - - [Test] - public void B100_Gender_All() - { - using var test = ApiTester.Create().UseJsonSerializer(new ReferenceDataContentJsonSerializer().ToUnitTestEx()); - - var v = test.Controller() - .Run(c => c.GenderGetAll(null, null)) - .AssertOK() - .GetValue()!; - - Assert.That(v, Has.Length.EqualTo(3)); - } - - [Test] - public void C100_Named() - { - using var test = ApiTester.Create().UseJsonSerializer(new ReferenceDataContentJsonSerializer().ToUnitTestEx()); - - var r = test.Controller() - .Run(c => c.GetNamed(), requestOptions: new HttpRequestOptions { UrlQueryString = "gender&usstate" }) - .AssertOK(); - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/Resources/VerificationResult.Unix.json b/samples/My.Hr/My.Hr.UnitTest/Resources/VerificationResult.Unix.json deleted file mode 100644 index 1aab9ecb..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/Resources/VerificationResult.Unix.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "age": 64, - "gender": "female", - "genderProbability": 0.97, - "country": [ - { - "country_Id": "SV", - "probability": 0.07477553 - }, - { - "country_Id": "GT", - "probability": 0.07223318 - }, - { - "country_Id": "NL", - "probability": 0.067494206 - } - ], - "verificationMessages": [ - "Performed verification for Wendy, F age 37. \n Engine predicted age was 64. \n Engine predicted gender was female with 97% probability.\n Most likely nationality of Wendy is SV with 7% probability", - "Employee age (37) is not within range of 10 years of predicted age: 64", - "Employee gender (F) doesn\u0027t match predicted gender: female" - ], - "request": { - "name": "Wendy", - "age": 37, - "gender": "F" - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/Resources/VerificationResult.Win32.json b/samples/My.Hr/My.Hr.UnitTest/Resources/VerificationResult.Win32.json deleted file mode 100644 index 651f0321..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/Resources/VerificationResult.Win32.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "age": 64, - "gender": "female", - "genderProbability": 0.97, - "country": [ - { - "country_Id": "SV", - "probability": 0.07477553 - }, - { - "country_Id": "GT", - "probability": 0.07223318 - }, - { - "country_Id": "NL", - "probability": 0.067494206 - } - ], - "verificationMessages": [ - "Performed verification for Wendy, F age 37. \r\n Engine predicted age was 64. \r\n Engine predicted gender was female with 97% probability.\r\n Most likely nationality of Wendy is SV with 7% probability", - "Employee age (37) is not within range of 10 years of predicted age: 64", - "Employee gender (F) doesn\u0027t match predicted gender: female" - ], - "request": { - "name": "Wendy", - "age": 37, - "gender": "F" - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/ServiceBusExecuteVerificationFunctionTest.cs b/samples/My.Hr/My.Hr.UnitTest/ServiceBusExecuteVerificationFunctionTest.cs deleted file mode 100644 index 86e01af3..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/ServiceBusExecuteVerificationFunctionTest.cs +++ /dev/null @@ -1,79 +0,0 @@ -using CoreEx.Events; -using System; -using System.Net.Http; -using Microsoft.Azure.WebJobs.ServiceBus; -using Moq; -using My.Hr.Business.External.Contracts; -using My.Hr.Functions; -using NUnit.Framework; -using UnitTestEx; - -namespace My.Hr.UnitTest -{ - [TestFixture] - public class ServiceBusExecuteVerificationFunctionTest - { - [Test] - public void A110_Verify_Success() - { - var test = FunctionTester.Create(); - var imp = new InMemoryPublisher(test.Logger); - var evr = new EmployeeVerificationRequest { Name = "Wendy", Age = 37, Gender = "F" }; - var sbm = test.CreateServiceBusMessageFromValue(evr); - var sba = new Mock(); - - var mcf = MockHttpClientFactory.Create(); - var agify = mcf.CreateClient("Agify"); - var nationalize = mcf.CreateClient("Nationalize"); - var genderize = mcf.CreateClient("Genderize"); - - agify.Request(HttpMethod.Get, $"https://api.agify.mock.io/?name={evr.Name}") - .Respond.WithJson(new - { - age = 64, - count = 82293, - name = evr.Name - }); - nationalize.Request(HttpMethod.Get, $"https://api.nationalize.mock.io/?name={evr.Name}") - .Respond.WithJson(new - { - country = new[]{ - new { - country_Id= "SV", - probability= 0.07477553 - }, - new { - country_Id= "GT", - probability= 0.07223318 - }, - new { - country_Id= "NL", - probability= 0.067494206 - }}, - name = evr.Name - }); - genderize.Request(HttpMethod.Get, $"https://api.genderize.mock.io/?name={evr.Name}") - .Respond.WithJson(new - { - count = 176697, - gender = "female", - name = evr.Name, - probability = 0.97 - }); - - test.ReplaceScoped(_ => imp) - .ReplaceHttpClientFactory(mcf) - .ServiceBusTrigger() - .Run(f => f.RunAsync(sbm, sba.Object)) - .AssertSuccess(); - - Assert.That(imp.GetNames(), Has.Length.EqualTo(1)); - var e = imp.GetEvents("verificationResults"); - Assert.That(e, Has.Length.EqualTo(1)); - if (Environment.OSVersion.Platform == PlatformID.Unix) - ObjectComparer.Assert(UnitTestEx.Resource.GetJsonValue("VerificationResult.Unix.json"), e[0].Value); - else - ObjectComparer.Assert(UnitTestEx.Resource.GetJsonValue("VerificationResult.Win32.json"), e[0].Value); - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/appsettings.unittest.json b/samples/My.Hr/My.Hr.UnitTest/appsettings.unittest.json deleted file mode 100644 index 79ed8e38..00000000 --- a/samples/My.Hr/My.Hr.UnitTest/appsettings.unittest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "DOTNET_ENVIRONMENT": "Development", - "VerificationResultsQueueName": "verificationResults", - "VerificationQueueName": "pendingVerifications", - "ServiceBusConnection__fullyQualifiedNamespace": "Endpoint=sb://top-secret.servicebus.windows.net/;SharedAccessKeyName=top-secret;SharedAccessKey=top-encrypted-secret;", - "AgifyApiEndpointUri": "https://api.agify.mock.io", - "NationalizeApiClientApiEndpointUri": "https://api.nationalize.mock.io", - "GenderizeApiClientApiEndpointUri": "https://api.genderize.mock.io", - "logging": { - "logLevel": { - "default": "debug" - } - } -} \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.sln b/samples/My.Hr/My.Hr.sln deleted file mode 100644 index e7600107..00000000 --- a/samples/My.Hr/My.Hr.sln +++ /dev/null @@ -1,58 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30114.105 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.Api", "My.Hr.Api\My.Hr.Api.csproj", "{F69909C8-9E50-4A26-8609-5B25D4F8C315}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.Database", "My.Hr.Database\My.Hr.Database.csproj", "{092B22E9-F40D-4155-B174-EED58F0F3207}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.Business", "My.Hr.Business\My.Hr.Business.csproj", "{BA634B6B-E0CF-4066-A225-E6D9C43D7F0B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.Functions", "My.Hr.Functions\My.Hr.Functions.csproj", "{A62BAA55-0737-4671-BF31-89D4BE7C4097}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.Infra", "My.Hr.Infra\My.Hr.Infra.csproj", "{E448EFD6-5CA6-4C71-B575-1149DD7181C6}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.Infra.Tests", "My.Hr.Infra.Tests\My.Hr.Infra.Tests.csproj", "{01B0FC8E-738D-47BB-AA57-F880532A501D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.UnitTest", "My.Hr.UnitTest\My.Hr.UnitTest.csproj", "{EE307518-D5FD-45B3-9A61-4451DFC44835}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {F69909C8-9E50-4A26-8609-5B25D4F8C315}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F69909C8-9E50-4A26-8609-5B25D4F8C315}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F69909C8-9E50-4A26-8609-5B25D4F8C315}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F69909C8-9E50-4A26-8609-5B25D4F8C315}.Release|Any CPU.Build.0 = Release|Any CPU - {092B22E9-F40D-4155-B174-EED58F0F3207}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {092B22E9-F40D-4155-B174-EED58F0F3207}.Debug|Any CPU.Build.0 = Debug|Any CPU - {092B22E9-F40D-4155-B174-EED58F0F3207}.Release|Any CPU.ActiveCfg = Release|Any CPU - {092B22E9-F40D-4155-B174-EED58F0F3207}.Release|Any CPU.Build.0 = Release|Any CPU - {BA634B6B-E0CF-4066-A225-E6D9C43D7F0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BA634B6B-E0CF-4066-A225-E6D9C43D7F0B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BA634B6B-E0CF-4066-A225-E6D9C43D7F0B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BA634B6B-E0CF-4066-A225-E6D9C43D7F0B}.Release|Any CPU.Build.0 = Release|Any CPU - {A62BAA55-0737-4671-BF31-89D4BE7C4097}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A62BAA55-0737-4671-BF31-89D4BE7C4097}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A62BAA55-0737-4671-BF31-89D4BE7C4097}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A62BAA55-0737-4671-BF31-89D4BE7C4097}.Release|Any CPU.Build.0 = Release|Any CPU - {E448EFD6-5CA6-4C71-B575-1149DD7181C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E448EFD6-5CA6-4C71-B575-1149DD7181C6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E448EFD6-5CA6-4C71-B575-1149DD7181C6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E448EFD6-5CA6-4C71-B575-1149DD7181C6}.Release|Any CPU.Build.0 = Release|Any CPU - {01B0FC8E-738D-47BB-AA57-F880532A501D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {01B0FC8E-738D-47BB-AA57-F880532A501D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {01B0FC8E-738D-47BB-AA57-F880532A501D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {01B0FC8E-738D-47BB-AA57-F880532A501D}.Release|Any CPU.Build.0 = Release|Any CPU - {EE307518-D5FD-45B3-9A61-4451DFC44835}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EE307518-D5FD-45B3-9A61-4451DFC44835}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EE307518-D5FD-45B3-9A61-4451DFC44835}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EE307518-D5FD-45B3-9A61-4451DFC44835}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/samples/aspire/Contoso.Aspire/AppHost.cs b/samples/aspire/Contoso.Aspire/AppHost.cs new file mode 100644 index 00000000..e00ea1b3 --- /dev/null +++ b/samples/aspire/Contoso.Aspire/AppHost.cs @@ -0,0 +1,42 @@ +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddProject("products-api").AddEndpoints("/health/ready/detailed"); +builder.AddProject("products-outbox-relay").AddEndpoints("/health/ready/detailed").AddHostedServiceSupport(); +builder.AddProject("products-subscribe").AddEndpoints("/health/ready/detailed").AddHostedServiceSupport(); + +builder.AddProject("shopping-api").AddEndpoints("/health/ready/detailed"); +builder.AddProject("shopping-outbox-relay").AddEndpoints("/health/ready/detailed").AddHostedServiceSupport(); +builder.AddProject("shopping-subscribe").AddEndpoints("/health/ready/detailed").AddHostedServiceSupport(); + +builder.Build().Run(); + + +internal static class Extensions +{ + public static IResourceBuilder AddEndpoints(this IResourceBuilder builder, params string[] urls) + { + var httpEndpoint = builder.GetEndpoint("http"); + foreach (var url in urls) + { + builder.WithAnnotation(new ResourceUrlAnnotation { Endpoint = httpEndpoint, Url = url }); + } + + return builder; + } + + // Icons: https://storybooks.fluentui.dev/react/?path=/docs/icons-catalog--docs + public static IResourceBuilder AddCommand(this IResourceBuilder builder, HttpMethod method, string path, string displayName, string? iconName) + => builder.WithHttpCommand( + path: path, + displayName: displayName, + commandOptions: new HttpCommandOptions() + { + Method = method, + IconName = iconName + }); + + public static IResourceBuilder AddHostedServiceSupport(this IResourceBuilder builder) + => builder.AddEndpoints("/hosted-services/all/status") + .AddCommand(HttpMethod.Post, "/hosted-services/all/pause", "Pause all services", "Pause") + .AddCommand(HttpMethod.Post, "/hosted-services/all/resume", "Resume all services", "PauseOff"); +} \ No newline at end of file diff --git a/samples/aspire/Contoso.Aspire/Contoso.Aspire.csproj b/samples/aspire/Contoso.Aspire/Contoso.Aspire.csproj new file mode 100644 index 00000000..9313825f --- /dev/null +++ b/samples/aspire/Contoso.Aspire/Contoso.Aspire.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0;net9.0;net10.0 + enable + enable + false + preview + 6a58b94b-d2ff-413a-9e03-e9995a3de4dd + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/aspire/Contoso.Aspire/Properties/launchSettings.json b/samples/aspire/Contoso.Aspire/Properties/launchSettings.json new file mode 100644 index 00000000..9bc93679 --- /dev/null +++ b/samples/aspire/Contoso.Aspire/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17226;http://localhost:15174", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21214", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23264", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22026" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15174", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19016", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18153", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20286" + } + } + } +} diff --git a/samples/aspire/Contoso.Aspire/appsettings.Development.json b/samples/aspire/Contoso.Aspire/appsettings.Development.json new file mode 100644 index 00000000..1b2d3baf --- /dev/null +++ b/samples/aspire/Contoso.Aspire/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/samples/aspire/Contoso.Aspire/appsettings.json b/samples/aspire/Contoso.Aspire/appsettings.json new file mode 100644 index 00000000..888f884e --- /dev/null +++ b/samples/aspire/Contoso.Aspire/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/Contoso.Products.Api.csproj b/samples/src/Contoso.Products.Api/Contoso.Products.Api.csproj new file mode 100644 index 00000000..eada2158 --- /dev/null +++ b/samples/src/Contoso.Products.Api/Contoso.Products.Api.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/samples/src/Contoso.Products.Api/Contoso.Products.Api.http b/samples/src/Contoso.Products.Api/Contoso.Products.Api.http new file mode 100644 index 00000000..1200a333 --- /dev/null +++ b/samples/src/Contoso.Products.Api/Contoso.Products.Api.http @@ -0,0 +1,6 @@ +@Contoso.Products.Api_HostAddress = http://localhost:5207 + +GET {{Contoso.Products.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/samples/src/Contoso.Products.Api/Controllers/InventoryController.cs b/samples/src/Contoso.Products.Api/Controllers/InventoryController.cs new file mode 100644 index 00000000..07ef8b99 --- /dev/null +++ b/samples/src/Contoso.Products.Api/Controllers/InventoryController.cs @@ -0,0 +1,13 @@ +namespace Contoso.Products.Api.Controllers; + +[ApiController, Route("/api/products"), OpenApiTag("Products")] +public class InventoryController(WebApi webApi, IInventoryService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly IInventoryService _service = service.ThrowIfNull(); + + [HttpGet("{id}/on-hand")] + [ProducesResponseType(typeof(Product), 200)] + [ProducesNotFoundProblem()] + public Task GetOnHandAsync(string id) => _webApi.GetAsync(Request, (_, _) => _service.GetOnHandAsync(id.Required())); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/Controllers/MovementController.cs b/samples/src/Contoso.Products.Api/Controllers/MovementController.cs new file mode 100644 index 00000000..013c9d0d --- /dev/null +++ b/samples/src/Contoso.Products.Api/Controllers/MovementController.cs @@ -0,0 +1,32 @@ +namespace Contoso.Products.Api.Controllers; + +[ApiController, Route("/api/inventory"), OpenApiTag("Inventory")] +public class MovementController(WebApi webApi, IMovementService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly IMovementService _service = service.ThrowIfNull(); + + [HttpPost("reserve")] + [Accepts] + [ProducesResponseType(200)] + [IdempotencyKey] + public Task ReserveAsync() => _webApi.PostAsync>(Request, (ro, _) + => _service.CreateReservationAsync(ro.Value), HttpStatusCode.OK); + + [HttpPost("reservation/{referenceId}/confirm")] + [ProducesResponseType(200)] + public Task ConfirmReservationAsync(string referenceId) => _webApi.PostAsync>(Request, (ro, _) + => _service.ConfirmReservationAsync(referenceId.Required()), HttpStatusCode.OK); + + [HttpPost("reservation/{referenceId}/cancel")] + [ProducesResponseType(200)] + public Task CancelReservationAsync(string referenceId) => _webApi.PostAsync>(Request, (ro, _) + => _service.CancelReservationAsync(referenceId.Required()), HttpStatusCode.OK); + + [HttpPost("adjust")] + [Accepts] + [ProducesResponseType(200)] + [IdempotencyKey] + public Task AdjustAsync() => _webApi.PostAsync>(Request, (ro, _) + => _service.AdjustAsync(ro.Value), HttpStatusCode.OK); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/Controllers/MovementReadController.cs b/samples/src/Contoso.Products.Api/Controllers/MovementReadController.cs new file mode 100644 index 00000000..7a595e9f --- /dev/null +++ b/samples/src/Contoso.Products.Api/Controllers/MovementReadController.cs @@ -0,0 +1,18 @@ +namespace Contoso.Products.Api.Controllers; + +[ApiController, Route("/api/inventory/movements"), OpenApiTag("Inventory")] +public class MovementReadController(WebApi webApi, IMovementReadService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly IMovementReadService _service = service.ThrowIfNull(); + + [HttpGet()] + [ProducesResponseType(200)] + [Query, Paging(supportsCount: true)] + public Task QueryAsync() => _webApi.GetAsync(Request, (ro, _) + => _service.QueryAsync(ro.QueryArgs, ro.PagingArgs), HttpStatusCode.OK); + + [HttpGet("$query")] + [ProducesResponseType(typeof(JsonElement), 200)] + public Task QuerySchemaAsync() => _webApi.GetAsync(Request, (ro, _) => _service.QuerySchemaAsync()); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/Controllers/ProductController.cs b/samples/src/Contoso.Products.Api/Controllers/ProductController.cs new file mode 100644 index 00000000..dec89c43 --- /dev/null +++ b/samples/src/Contoso.Products.Api/Controllers/ProductController.cs @@ -0,0 +1,38 @@ +namespace Contoso.Products.Api.Controllers; + +[ApiController, Route("/api/products"), OpenApiTag("Products")] +public class ProductController(WebApi webApi, IProductService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly IProductService _service = service.ThrowIfNull(); + + [HttpPost] + [Accepts] + [ProducesResponseType(201)] + [IdempotencyKey] + public Task PostAsync() => _webApi.PostAsync(Request, (ro, _) => + { + ro.WithLocationUri(p => new Uri($"/api/products/{p.Id}", UriKind.Relative)); + return _service.CreateAsync(ro.Value); + }); + + [HttpPut("{id}")] + [Accepts] + [ProducesResponseType(typeof(Product), 200)] + [ProducesNotFoundProblem()] + public Task PutAsync(string id) => _webApi.PutAsync(Request, (ro, _) + => _service.UpdateAsync(ro.Value.Adjust(p => p.Id = id.Required()))); + + [HttpPatch("{id}")] + [Accepts(HttpNames.MergePatchJsonMediaTypeName)] + [ProducesResponseType(typeof(Product), 200)] + [ProducesNotFoundProblem()] + public Task PatchAsync(string id) => _webApi.PatchAsync(Request, + get: (ro, _) => _service.GetAsync(id.Required()), + put: (ro, _) => _service.UpdateAsync(ro.Value.Adjust(p => p.Id = id))); + + [HttpDelete("{id}")] + [ProducesResponseType(204)] + public Task DeleteAsync(string id) => _webApi.DeleteAsync(Request, (_, _) + => _service.DeleteAsync(id.Required())); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/Controllers/ProductReadController.cs b/samples/src/Contoso.Products.Api/Controllers/ProductReadController.cs new file mode 100644 index 00000000..3ed38eb2 --- /dev/null +++ b/samples/src/Contoso.Products.Api/Controllers/ProductReadController.cs @@ -0,0 +1,22 @@ +namespace Contoso.Products.Api.Controllers; + +[ApiController, Route("/api/products"), OpenApiTag("Products")] +public class ProductReadController(WebApi webApi, IProductReadService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly IProductReadService _service = service.ThrowIfNull(); + + [HttpGet("{id}"), HttpHead("{id}")] + [ProducesResponseType(typeof(Product), 200)] + [ProducesNotFoundProblem()] + public Task GetAsync(string id) => _webApi.GetAsync(Request, (_, _) => _service.GetAsync(id.Required())); + + [HttpGet] + [ProducesResponseType(typeof(ProductLite[]), 200)] + [Query(supportsOrderBy: true), Paging(supportsCount: true)] + public Task QueryAsync() => _webApi.GetAsync(Request, (ro, _) => _service.QueryAsync(ro.QueryArgs, ro.PagingArgs)); + + [HttpGet("$query")] + [ProducesResponseType(typeof(JsonElement), 200)] + public Task QuerySchemaAsync() => _webApi.GetAsync(Request, (ro, _) => _service.QuerySchemaAsync()); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/Controllers/ReferenceDataController.cs b/samples/src/Contoso.Products.Api/Controllers/ReferenceDataController.cs new file mode 100644 index 00000000..be80bada --- /dev/null +++ b/samples/src/Contoso.Products.Api/Controllers/ReferenceDataController.cs @@ -0,0 +1,52 @@ +namespace Contoso.Products.Api.Controllers; + +[ApiController, Route("/api/refdata")] +public class ReferenceDataController(WebApi webApi) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + + private static readonly DataMap _mapper = new(StringComparer.OrdinalIgnoreCase) + { + { "categories", nameof(Category) }, + { "sub-categories", nameof(SubCategory) }, + { "units-of-measure", nameof(UnitOfMeasure) }, + { "brands", nameof(Brand) }, + { "movement-kinds", nameof(MovementKind) }, + { "movement-statuses", nameof(MovementStatus) } + }; + + [HttpGet("categories"), HttpHead("categories")] + [ProducesResponseType(typeof(Category[]), 200)] + public Task GetCategoriesAsync([FromQuery] IEnumerable? codes = default, string? text = default) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct)); + + [HttpGet("sub-categories"), HttpHead("sub-categories")] + [ProducesResponseType(typeof(SubCategory[]), 200)] + public Task GetSubCategoriesAsync([FromQuery] IEnumerable? codes = default, string? text = default) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct)); + + [HttpGet("units-of-measure"), HttpHead("units-of-measure")] + [ProducesResponseType(typeof(UnitOfMeasure[]), 200)] + public Task GetUnitsOfMeasureAsync([FromQuery] IEnumerable? codes = default, string? text = default) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct)); + + [HttpGet("brands"), HttpHead("brands")] + [ProducesResponseType(typeof(Brand[]), 200)] + public Task GetBrandsAsync([FromQuery] IEnumerable? codes = default, string? text = default) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct)); + + [HttpGet("movement-kinds"), HttpHead("movement-kinds")] + [ProducesResponseType(typeof(MovementKind[]), 200)] + public Task GetMovementKindsAsync([FromQuery] IEnumerable? codes = default, string? text = default) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct)); + + [HttpGet("movement-statuses"), HttpHead("movement-statuses")] + [ProducesResponseType(typeof(MovementKind[]), 200)] + public Task GetMovementStatusesAsync([FromQuery] IEnumerable? codes = default, string? text = default) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct)); + + [HttpGet] + [ProducesResponseType(typeof(ReferenceDataMultiDictionary), 200)] + public Task GetNamedAsync([FromQuery] string[] name) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetNamedAsync(name, ro.IsIncludeInactive, _mapper, ct)); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/GlobalUsing.cs b/samples/src/Contoso.Products.Api/GlobalUsing.cs new file mode 100644 index 00000000..f8f4a069 --- /dev/null +++ b/samples/src/Contoso.Products.Api/GlobalUsing.cs @@ -0,0 +1,14 @@ +global using Contoso.Products.Application; +global using Contoso.Products.Application.Interfaces; +global using Contoso.Products.Contracts; +global using CoreEx; +global using CoreEx.AspNetCore.Mvc; +global using CoreEx.Entities; +global using CoreEx.Http; +global using CoreEx.Json; +global using CoreEx.RefData; +global using CoreEx.Validation; +global using Microsoft.AspNetCore.Mvc; +global using NSwag.Annotations; +global using System.Net; +global using System.Text.Json; \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/Program.cs b/samples/src/Contoso.Products.Api/Program.cs new file mode 100644 index 00000000..6f16b341 --- /dev/null +++ b/samples/src/Contoso.Products.Api/Program.cs @@ -0,0 +1,94 @@ +using Contoso.Products.Infrastructure.Repositories; +using Microsoft.Extensions.Options; +using OpenTelemetry; +using OpenTelemetry.Trace; +using StackExchange.Redis; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; + +namespace Contoso.Products.Api; + +public class Program +{ + private static void Main(string[] args) + { + // Create the web builder. + var builder = WebApplication.CreateBuilder(args); + + // Add CoreEx host settings. + builder.AddHostSettings(); + + // Add CoreEx services. + builder.Services + .AddExecutionContext() + .AddReferenceDataOrchestrator() + .AddMvcWebApi() + .AddHttpWebApi(); + + // Add all the dynamically registered services. + builder.Services.AddDynamicServicesUsing(); + + // Add L1/L2 caching services. + builder.Services.AddMemoryCache(); // Adds the in-memory cache - L1. + builder.AddRedisDistributedCache("redis"); // Adds Redis as the distributed cache (using Aspire library) - L2. + + // Add and wire-up FusionCache including backplane. + builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = sp.GetRequiredService>().Value.ToString() })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); + + // Add CoreEx caching services. + builder.Services + .AddFusionHybridCache() // Adds the CoreEx.Caching.IHybridCache for FusionCache. + .AddDefaultCacheKeyProvider() // Adds the default CoreEx.Caching.ICacheKeyProvider. + .AddHybridCacheIdempotencyProvider(); // Adds the CoreEx.Caching.Idempotency.IIdempotencyProvider. + + // Add the repository and related outbox services. + builder.AddSqlServerClient("SqlServer"); // Adds the SqlServerClient (using Aspire library). + builder.Services + .AddSqlServerDatabase() // Adds the SqlServerDatabase. + .AddSqlServerUnitOfWork() // Adds the SqlServerUnitOfWork for the SqlServerDatabase. + .AddEventFormatter() // Adds the EventFormatter to enable message formatting for publishing. + .AddSqlServerOutboxPublisher() // Adds the SqlServerOutboxPublisher/IEventPublisher. + .AddDbContext() // Adds the standard EF DbContext. + .AddEfDb(); // Adds the CoreEx extended EF service. + + // Post-configure all health-checks; adds the standard tags. + builder.Services.PostConfigureAllHealthChecks(); + + // Add the ASP.NET Core services. + builder.Services.AddControllers(); + + // Add the OpenAPI services. + builder.Services.AddOpenApiDocument(s => + { + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); + }); + + // Add OpenTelemetry tracing. + builder.WithCoreExTelemetry() + .WithCoreExSqlServerTelemetry() + .UseOtlpExporter(); + + // Build the application. + var app = builder.Build(); + + // Configure the pipeline/middleware (order is important). + app.UseCoreExExceptionHandler(); + app.UseHttpsRedirection(); + app.UseAuthorization(); + app.UseExecutionContext(); + app.UseIdempotencyKey(); + app.MapControllers(); + + app.UseOpenApi(); + app.UseSwaggerUi(); + app.MapHealthChecks(); + + // Run the application. + app.Run(); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/Properties/launchSettings.json b/samples/src/Contoso.Products.Api/Properties/launchSettings.json new file mode 100644 index 00000000..adeb2b03 --- /dev/null +++ b/samples/src/Contoso.Products.Api/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5207" + }, + "https": { + "commandName": "Project", + "launchUrl": "https://localhost:7200/swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7200;http://localhost:5207" + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/appsettings.Development.json b/samples/src/Contoso.Products.Api/appsettings.Development.json new file mode 100644 index 00000000..2f9ad68a --- /dev/null +++ b/samples/src/Contoso.Products.Api/appsettings.Development.json @@ -0,0 +1,29 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Azure": "Warning", + "Microsoft": "Warning", + "ZiggyCreature": "Warning", + "StackExchange": "Warning" + } + }, + "Aspire": { + "Microsoft": { + "Data": { + "SqlClient": { + "ConnectionString": "Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" + } + } + }, + "StackExchange": { + "Redis": { + "ConnectionString": "localhost:6379", + "ConfigurationOptions": { + "ConnectTimeout": 3000, + "ConnectRetry": 2 + } + } + } + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Api/appsettings.json b/samples/src/Contoso.Products.Api/appsettings.json new file mode 100644 index 00000000..7052c07a --- /dev/null +++ b/samples/src/Contoso.Products.Api/appsettings.json @@ -0,0 +1,20 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "Contoso", + "DomainName": "Products" + }, + "Events": { + "Destination": "contoso" // Topic/queue name. + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Update": "None" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Contoso.Products.Application.csproj b/samples/src/Contoso.Products.Application/Contoso.Products.Application.csproj new file mode 100644 index 00000000..10379156 --- /dev/null +++ b/samples/src/Contoso.Products.Application/Contoso.Products.Application.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/samples/src/Contoso.Products.Application/GlobalUsing.cs b/samples/src/Contoso.Products.Application/GlobalUsing.cs new file mode 100644 index 00000000..80247c9f --- /dev/null +++ b/samples/src/Contoso.Products.Application/GlobalUsing.cs @@ -0,0 +1,14 @@ +global using Contoso.Products.Application.Interfaces; +global using Contoso.Products.Application.Repositories; +global using Contoso.Products.Application.Validators; +global using Contoso.Products.Contracts; +global using CoreEx; +global using CoreEx.Data; +global using CoreEx.DependencyInjection; +global using CoreEx.Events; +global using CoreEx.Localization; +global using CoreEx.RefData; +global using CoreEx.RefData.Abstractions; +global using CoreEx.Results; +global using CoreEx.Validation; +global using System.Text.Json; \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Interfaces/IInventoryService.cs b/samples/src/Contoso.Products.Application/Interfaces/IInventoryService.cs new file mode 100644 index 00000000..c5b67758 --- /dev/null +++ b/samples/src/Contoso.Products.Application/Interfaces/IInventoryService.cs @@ -0,0 +1,6 @@ +namespace Contoso.Products.Application.Interfaces; + +public interface IInventoryService +{ + Task GetOnHandAsync(string productId); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Interfaces/IMovementReadService.cs b/samples/src/Contoso.Products.Application/Interfaces/IMovementReadService.cs new file mode 100644 index 00000000..79ed0fbb --- /dev/null +++ b/samples/src/Contoso.Products.Application/Interfaces/IMovementReadService.cs @@ -0,0 +1,24 @@ +namespace Contoso.Products.Application.Interfaces; + +public interface IMovementReadService +{ + /// + /// Gets the movements with the specified reference identifier (see ). + /// + /// The reference identifier. + /// The movements. + Task GetAsync(string referenceId); + + /// + /// Gets the schema. + /// + Task QuerySchemaAsync(); + + /// + /// Queries the movements. + /// + /// The . + /// The. + /// The resulting movements. + Task> QueryAsync(QueryArgs? query, PagingArgs? paging); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Interfaces/IMovementService.cs b/samples/src/Contoso.Products.Application/Interfaces/IMovementService.cs new file mode 100644 index 00000000..aa6463a3 --- /dev/null +++ b/samples/src/Contoso.Products.Application/Interfaces/IMovementService.cs @@ -0,0 +1,24 @@ +namespace Contoso.Products.Application.Interfaces; + +public interface IMovementService +{ + /// + /// Creates a reservation for inventory movement(s); inventory is adjusted, bit still requires confirmation to be finalized. + /// + Task> CreateReservationAsync(MovementRequest request); + + /// + /// Confirms a reservation for inventory movement(s); inventory is finalized and cannot be canceled after this step. + /// + Task> ConfirmReservationAsync(string referenceId); + + /// + /// Cancels a reservation and reverses any inventory adjustments made during the reservation. + /// + Task> CancelReservationAsync(string referenceId); + + /// + /// Adjusts inventory movement(s) directly without creating a reservation; inventory is adjusted immediately and cannot be canceled. + /// + Task> AdjustAsync(MovementRequest request); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Interfaces/IProductReadService.cs b/samples/src/Contoso.Products.Application/Interfaces/IProductReadService.cs new file mode 100644 index 00000000..896efb4a --- /dev/null +++ b/samples/src/Contoso.Products.Application/Interfaces/IProductReadService.cs @@ -0,0 +1,10 @@ +namespace Contoso.Products.Application.Interfaces; + +public interface IProductReadService +{ + Task GetAsync(string id); + + Task> QueryAsync(QueryArgs? query, PagingArgs? paging); + + Task QuerySchemaAsync(); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Interfaces/IProductService.cs b/samples/src/Contoso.Products.Application/Interfaces/IProductService.cs new file mode 100644 index 00000000..ae72e92a --- /dev/null +++ b/samples/src/Contoso.Products.Application/Interfaces/IProductService.cs @@ -0,0 +1,16 @@ +namespace Contoso.Products.Application.Interfaces; + +public interface IProductService +{ + Task GetAsync(string id); + + Task CreateAsync(Contracts.Product product); + + Task UpdateAsync(Contracts.Product product); + + Task ActivateAsync(string id); + + Task DeactivateAsync(string id); + + Task DeleteAsync(string id); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/InventoryService.cs b/samples/src/Contoso.Products.Application/InventoryService.cs new file mode 100644 index 00000000..cf4b2c9f --- /dev/null +++ b/samples/src/Contoso.Products.Application/InventoryService.cs @@ -0,0 +1,13 @@ +namespace Contoso.Products.Application; + +[ScopedService] +public class InventoryService(IInventoryRepository repository) : IInventoryService +{ + private readonly IInventoryRepository _repository = repository.ThrowIfNull(); + + public async Task GetOnHandAsync(string productId) + { + var qtyOnHand = await _repository.GetOnHandAsync(productId, true).ConfigureAwait(false); + return qtyOnHand ?? 0m; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/MovementReadService.cs b/samples/src/Contoso.Products.Application/MovementReadService.cs new file mode 100644 index 00000000..ebcdbf5e --- /dev/null +++ b/samples/src/Contoso.Products.Application/MovementReadService.cs @@ -0,0 +1,16 @@ +namespace Contoso.Products.Application; + +[ScopedService] +public class MovementReadService(IMovementRepository movementRepository) : IMovementReadService +{ + private readonly IMovementRepository _movementRepository = movementRepository.ThrowIfNull(); + + /// + public Task GetAsync(string referenceId) => _movementRepository.GetAsync(referenceId); + + /// + public Task QuerySchemaAsync() => _movementRepository.QuerySchemaAsync(); + + /// + public Task> QueryAsync(QueryArgs? query, PagingArgs? paging) => _movementRepository.QueryAsync(query, paging); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/MovementService.cs b/samples/src/Contoso.Products.Application/MovementService.cs new file mode 100644 index 00000000..e663ee85 --- /dev/null +++ b/samples/src/Contoso.Products.Application/MovementService.cs @@ -0,0 +1,131 @@ +namespace Contoso.Products.Application; + +[ScopedService] +public class MovementService(IUnitOfWork unitOfWork, IProductRepository productRepository, IMovementRepository movementRepository) : IMovementService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork.ThrowIfNull(); + private readonly IProductRepository _productRepository = productRepository.ThrowIfNull(); + private readonly IMovementRepository _movementRepository = movementRepository.ThrowIfNull(); + + /// + public async Task> CreateReservationAsync(MovementRequest request) + { + // Validate the request + var vr = await new MovementRequestValidator(productRepository).ValidateAsync(request); + vr.ThrowOnError(); + + // Build up the list of movements to create for the reservation. + var movements = new List(); + foreach (var product in request.Products!.Where(kv => kv.Value.Quantity > 0)) + { + movements.Add(new Movement + { + ReferenceId = request.Id, + Kind = MovementKind.Issue, + Status = MovementStatus.Pending, + ProductId = product.Key, + Quantity = product.Value.Quantity * -1, + UnitOfMeasure = product.Value.UnitOfMeasure + }); + } + + // Exit early if there are no movements to create (e.g. all products had a quantity of 0). + if (movements.Count == 0) + return movements; + + // Adjust the inventory levels, create the reservations, and emit the events in a unit-of-work transaction. + await _unitOfWork.ExecuteAsync(async () => + { + // Adjust inventory levels and persist movements. + movements = await _movementRepository.CreateAsync(movements).ConfigureAwait(false); + + // Emit events for the movements that were created. + _unitOfWork.Events.Add(EventData.CreateEventsWith(movements, nameof(MovementStatus.Pending), ConfigureEvent)); + }).ConfigureAwait(false); + + return movements; + } + + /// + public Task> ConfirmReservationAsync(string referenceId) => _unitOfWork.ExecuteAsync(async () => + { + // Confirm all movements for the specified reservation. + var movements = await _movementRepository.ConfirmAsync(referenceId).ConfigureAwait(false); + if (movements.Count == 0) + throw new NotFoundException().WithKey(referenceId).WithErrorCode("pending-reservation-not-found"); + + // Emit events for the movements that were confirmed. + _unitOfWork.Events.Add(EventData.CreateEventsWith(movements, nameof(MovementStatus.Confirmed), ConfigureEvent)); + + return movements; + }); + + /// + public Task> CancelReservationAsync(string referenceId) => _unitOfWork.ExecuteAsync(async () => + { + // Cancel all movements for the specified reservation. + var movements = await _movementRepository.CancelAsync(referenceId).ConfigureAwait(false); + if (movements.Count == 0) + throw new NotFoundException().WithKey(referenceId).WithErrorCode("pending-reservation-not-found"); + + // Emit events for the movements that were canceled. + _unitOfWork.Events.Add(EventData.CreateEventsWith(movements, nameof(MovementStatus.Canceled), ConfigureEvent)); + + return movements; + }); + + /// + public async Task> AdjustAsync(MovementRequest request) + { + // Validate the request + var vr = await new MovementRequestValidator(productRepository).ValidateAsync(request); + vr.ThrowOnError(); + + // Build up the list of movements to create for the reservation. + var movements = new List(); + foreach (var product in request.Products!.Where(kv => kv.Value.Quantity > 0)) + { + movements.Add(new Movement + { + ReferenceId = request.Id, + Kind = MovementKind.Adjust, + Status = MovementStatus.Confirmed, + ProductId = product.Key, + Quantity = product.Value.Quantity, + UnitOfMeasure = product.Value.UnitOfMeasure + }); + } + + // Exit early if there are no movements to create (e.g. all products had a quantity of 0). + if (movements.Count == 0) + return movements; + + // Adjust the inventory levels, and emit the events in a unit-of-work transaction. + await _unitOfWork.ExecuteAsync(async () => + { + // Adjust inventory levels and persist movements. + movements = await _movementRepository.CreateAsync(movements).ConfigureAwait(false); + + // Emit events for the movements that were created. + _unitOfWork.Events.Add(EventData.CreateEventsWith(movements, nameof(MovementStatus.Confirmed), ConfigureEvent)); + }).ConfigureAwait(false); + + return movements; + } + + /// + /// Configure the event name for the specified movement and event data. This is used to emit different events for issue, receive, and adjust movements, which can be useful for event subscribers that want to handle them differently. + /// + private static void ConfigureEvent(Movement m, EventData ed) + { + var extra = m.Kind!.Code switch + { + MovementKind.Issue => nameof(MovementKind.Issue), + MovementKind.Receive => nameof(MovementKind.Receive), + _ => nameof(MovementKind.Adjust) + }; + + ed.Entity += $".{extra}"; + ed.WithPartitionKey(m.ProductId); // Partition events by product identifier to ensure ordering of events for the same product. + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/ProductReadService.cs b/samples/src/Contoso.Products.Application/ProductReadService.cs new file mode 100644 index 00000000..680b3bc6 --- /dev/null +++ b/samples/src/Contoso.Products.Application/ProductReadService.cs @@ -0,0 +1,13 @@ +namespace Contoso.Products.Application; + +[ScopedService] +public class ProductReadService(IProductRepository repository) : IProductReadService +{ + private readonly IProductRepository _repository = repository.ThrowIfNull(); + + public Task GetAsync(string id) => _repository.GetAsync(id); + + public Task> QueryAsync(QueryArgs? query, PagingArgs? paging) => _repository.QueryAsync(query, paging); + + public Task QuerySchemaAsync() => _repository.QuerySchemaAsync(); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/ProductService.cs b/samples/src/Contoso.Products.Application/ProductService.cs new file mode 100644 index 00000000..d6b628e2 --- /dev/null +++ b/samples/src/Contoso.Products.Application/ProductService.cs @@ -0,0 +1,98 @@ +namespace Contoso.Products.Application; + +[ScopedService] +public class ProductService(IUnitOfWork unitOfWork, IProductRepository repository) : IProductService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork.ThrowIfNull(); + private readonly IProductRepository _repository = repository.ThrowIfNull(); + + public Task GetAsync(string id) => _repository.GetAsync(id); + + public async Task CreateAsync(Product product) + { + product.ThrowIfNull(); + + await ProductValidator.Default.ValidateAndThrowAsync(product); + + product.Id = Runtime.NewId(); + product.CategoryCode = product.SubCategory!.CategoryCode; + product.IsInactive = true; + + return await _unitOfWork.ExecuteAsync(async () => + { + var dr = await _repository.CreateAsync(product).ConfigureAwait(false); + return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created))); + }).ConfigureAwait(false); + } + + public async Task UpdateAsync(Product product) + { + product.ThrowIfNull(); + product.Id.ThrowIfNullOrEmpty(); + + await ProductValidator.Default.ValidateAndThrowAsync(product); + + var current = await _repository.GetAsync(product.Id).ConfigureAwait(false); + NotFoundException.ThrowIfDefault(current); + + product.CategoryCode = product.SubCategory!.CategoryCode; + product.IsNonStocked = current.IsNonStocked; + product.IsInactive = current.IsInactive; + + return await _unitOfWork.ExecuteAsync(async () => + { + var dr = await _repository.UpdateAsync(product).ConfigureAwait(false); + return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Updated))); + }).ConfigureAwait(false); + } + + public async Task ActivateAsync(string id) + { + var product = await _repository.GetAsync(id).ConfigureAwait(false); + NotFoundException.ThrowIfDefault(product); + + if (product.IsInactive) + return product; + + return await _unitOfWork.ExecuteAsync(async () => + { + product.IsInactive = false; + + var dr = await _repository.UpdateAsync(product).ConfigureAwait(false); + return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Activated))); + }).ConfigureAwait(false); + } + + public async Task DeactivateAsync(string id) + { + var product = await _repository.GetAsync(id).ConfigureAwait(false); + NotFoundException.ThrowIfDefault(product); + + if (!product.IsInactive) + return product; + + return await _unitOfWork.ExecuteAsync(async () => + { + product.IsInactive = true; + + var dr = await _repository.UpdateAsync(product).ConfigureAwait(false); + return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Deactivated))); + }).ConfigureAwait(false); + } + + public async Task DeleteAsync(string id) + { + var product = await _repository.GetAsync(id).ConfigureAwait(false); + if (product is null) + return; + + if (!product.IsInactive) + throw new BusinessException("A product must first be deactivated before it can be deleted."); + + await _unitOfWork.ExecuteAsync(async () => + { + var dr = await _repository.DeleteAsync(id).ConfigureAwait(false); + dr.WhereMutated(() => _unitOfWork.Events.Add(EventData.CreateEventWith(default, EventAction.Deleted).WithKey(id))); + }).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/ReferenceDataService.cs b/samples/src/Contoso.Products.Application/ReferenceDataService.cs new file mode 100644 index 00000000..3fb9e366 --- /dev/null +++ b/samples/src/Contoso.Products.Application/ReferenceDataService.cs @@ -0,0 +1,28 @@ +namespace Contoso.Products.Application; + +[ScopedService] +public class ReferenceDataService(IReferenceDataRepository repository) : IReferenceDataProvider +{ + private readonly IReferenceDataRepository _repository = repository.ThrowIfNull(); + + public IEnumerable<(Type, Type)> Types => + [ + (typeof(Category), typeof(CategoryCollection)), + (typeof(SubCategory), typeof(SubCategoryCollection)), + (typeof(UnitOfMeasure), typeof(UnitOfMeasureCollection)), + (typeof(Brand), typeof(BrandCollection)), + (typeof(MovementKind), typeof(MovementKindCollection)), + (typeof(MovementStatus), typeof(MovementStatusCollection)), + ]; + + public async Task GetAsync(Type type, CancellationToken cancellationToken = default) => type switch + { + _ when type == typeof(Category) => await _repository.GetAllCategoriesAsync().ConfigureAwait(false), + _ when type == typeof(SubCategory) => await _repository.GetAllSubCategoriesAsync().ConfigureAwait(false), + _ when type == typeof(UnitOfMeasure) => await _repository.GetAllUnitsOfMeasureAsync().ConfigureAwait(false), + _ when type == typeof(Brand) => await _repository.GetAllBrandsAsync().ConfigureAwait(false), + _ when type == typeof(MovementKind) => await _repository.GetAllMovementKindsAsync().ConfigureAwait(false), + _ when type == typeof(MovementStatus) => await _repository.GetAllMovementStatusesAsync().ConfigureAwait(false), + _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.") + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Repositories/IInventoryRepository.cs b/samples/src/Contoso.Products.Application/Repositories/IInventoryRepository.cs new file mode 100644 index 00000000..9b3ea5d4 --- /dev/null +++ b/samples/src/Contoso.Products.Application/Repositories/IInventoryRepository.cs @@ -0,0 +1,6 @@ +namespace Contoso.Products.Application.Repositories; + +public interface IInventoryRepository +{ + Task GetOnHandAsync(string productId, bool throwNotFoundException); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Repositories/IMovementRepository.cs b/samples/src/Contoso.Products.Application/Repositories/IMovementRepository.cs new file mode 100644 index 00000000..0b555234 --- /dev/null +++ b/samples/src/Contoso.Products.Application/Repositories/IMovementRepository.cs @@ -0,0 +1,50 @@ +namespace Contoso.Products.Application.Repositories; + +public interface IMovementRepository +{ + /// + /// Creates new movement records (as specified) and applies the necessary adjustments to the underlying inventory based on the underlying . + /// + /// The list of movements to be created and adjusted. + /// The mutated movements. + /// All movements must be of the same . + Task> CreateAsync(List movements); + + /// + /// Confirms the movements with the specified reference identifier (see ). + /// + /// The reference identifier. + /// The mutated movements. + /// The movements must be in a pending state to be confirmed. + /// No underlying inventory adjustment(s) are required as the inventory was already adjusted during creation. + Task> ConfirmAsync(string referenceId); + + /// + /// Cancels the movements with the specified reference identifier (see ) and applies the necessary adjustments to the underlying inventory based on the underlying . + /// + /// The reference identifier. + /// The mutated movements. + /// The movements must be in a pending state to be cancelled. + Task> CancelAsync(string referenceId); + + /// + /// Gets the movements with the specified reference identifier (see ). + /// + /// The reference identifier. + /// The movements. + Task GetAsync(string referenceId); + + /// + /// Gets the schema. + /// + /// + Task QuerySchemaAsync(); + + /// + /// Queries the movements. + /// + /// The . + /// The. + /// The resulting movements. + Task> QueryAsync(QueryArgs? query, PagingArgs? paging); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Repositories/IProductRepository.cs b/samples/src/Contoso.Products.Application/Repositories/IProductRepository.cs new file mode 100644 index 00000000..2727caa2 --- /dev/null +++ b/samples/src/Contoso.Products.Application/Repositories/IProductRepository.cs @@ -0,0 +1,18 @@ +namespace Contoso.Products.Application.Repositories; + +public interface IProductRepository +{ + Task GetAsync(string id); + + Task> CreateAsync(Contracts.Product product); + + Task> UpdateAsync(Contracts.Product product); + + Task DeleteAsync(string id); + + Task QuerySchemaAsync(); + + Task> QueryAsync(QueryArgs? query, PagingArgs? paging); + + Task> GetForReservationAsync(string[] ids); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Repositories/IReferenceDataRepository.cs b/samples/src/Contoso.Products.Application/Repositories/IReferenceDataRepository.cs new file mode 100644 index 00000000..75a3cbd5 --- /dev/null +++ b/samples/src/Contoso.Products.Application/Repositories/IReferenceDataRepository.cs @@ -0,0 +1,16 @@ +namespace Contoso.Products.Application.Repositories; + +public interface IReferenceDataRepository +{ + public Task GetAllCategoriesAsync(); + + public Task GetAllSubCategoriesAsync(); + + public Task GetAllUnitsOfMeasureAsync(); + + public Task GetAllBrandsAsync(); + + public Task GetAllMovementKindsAsync(); + + public Task GetAllMovementStatusesAsync(); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Validators/MovementRequestValidator.cs b/samples/src/Contoso.Products.Application/Validators/MovementRequestValidator.cs new file mode 100644 index 00000000..3e67bcae --- /dev/null +++ b/samples/src/Contoso.Products.Application/Validators/MovementRequestValidator.cs @@ -0,0 +1,46 @@ +namespace Contoso.Products.Application.Validators; + +public class MovementRequestValidator : Validator +{ + private static readonly Validator _productValidator = Validator.Create() + .HasProperty(x => x.UnitOfMeasure, c => c.Mandatory().IsValid()) + .HasProperty(x => x.Quantity, c => c.GreaterThanOrEqualTo(0).PrecisionScale(ctx => ctx.Entity.UnitOfMeasure!.Precision, ctx => ctx.Entity.UnitOfMeasure!.Scale).DependsOn(x => x.UnitOfMeasure)); + + private readonly IProductRepository _repository; + private readonly LText _productText = "Product"; + + public MovementRequestValidator(IProductRepository repository) + { + _repository = repository.ThrowIfNull(); + + Property(x => x.Id).Mandatory().MaximumLength(50); + Property(x => x.Products).Mandatory().Dictionary(c => c + .WithKeyValidator(_productText, k => k.Mandatory().MaximumLength(50)) + .WithValueValidator(v => v.Mandatory().Entity(_productValidator))); + } + + protected async override Task OnValidateAsync(ValidationContext context, CancellationToken cancellationToken) + { + // Fail-fast where any of the cheap validation errors have occurred. + if (context.HasErrors) + return; + + // Get the product(s) info to determine whether request is valid. + var ids = context.Value.Products!.Select(kvp => kvp.Key).ToArray() ?? []; + var products = await _repository.GetForReservationAsync(ids).ConfigureAwait(false); + + // Create extended product validator (dictionary value) that can access the product information for validating the unit of measure. + var dv = Validator.Create() + .HasProperty(x => x.UnitOfMeasure, c => c.Equal(ctx => products[ctx.GetDictionaryKey()].UnitOfMeasureCode)); + + // Create new validator for the request that ensures validity of the product (dictionary key) and value (dictionary value). + await context.ValidateFurtherAsync(c => c + .HasProperty(x => x.Products, c => c.Dictionary(c => c + .WithKeyValidator(_productText, k => k + .NotFound().WhenValue(v => !products.ContainsKey(v)) + .Error("{0} is non-stocked and therefore cannot be transacted.").WhenValue(v => products[v].IsNonStocked) + .Error("{0} is not active and therefore cannot be transacted.").WhenValue(v => products[v].IsInactive)) + .WithValueValidator(dv)) + ), cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Application/Validators/ProductValidator.cs b/samples/src/Contoso.Products.Application/Validators/ProductValidator.cs new file mode 100644 index 00000000..bee54e6c --- /dev/null +++ b/samples/src/Contoso.Products.Application/Validators/ProductValidator.cs @@ -0,0 +1,13 @@ +namespace Contoso.Products.Application.Validators; + +public class ProductValidator : Validator +{ + public ProductValidator() + { + Property(p => p.Sku).Mandatory().MaximumLength(50); + Property(p => p.Text).Mandatory().MaximumLength(250); + Property(p => p.SubCategory).Mandatory().IsValid(); + Property(p => p.UnitOfMeasure).Mandatory().IsValid(); + Property(p => p.Price).PrecisionScale(null, 2).GreaterThanOrEqualTo(0, _ => "zero"); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/Brand.cs b/samples/src/Contoso.Products.Contracts/Brand.cs new file mode 100644 index 00000000..cdba71c9 --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/Brand.cs @@ -0,0 +1,6 @@ +namespace Contoso.Products.Contracts; + +[ReferenceData] +public partial class Brand : ReferenceData { } + +public class BrandCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/Category.cs b/samples/src/Contoso.Products.Contracts/Category.cs new file mode 100644 index 00000000..6bb119c9 --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/Category.cs @@ -0,0 +1,6 @@ +namespace Contoso.Products.Contracts; + +[ReferenceData] +public partial class Category : ReferenceData { } + +public class CategoryCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/Contoso.Products.Contracts.csproj b/samples/src/Contoso.Products.Contracts/Contoso.Products.Contracts.csproj new file mode 100644 index 00000000..a73b2f2b --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/Contoso.Products.Contracts.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/samples/src/Contoso.Products.Contracts/GlobalUsing.cs b/samples/src/Contoso.Products.Contracts/GlobalUsing.cs new file mode 100644 index 00000000..8b9eae91 --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/GlobalUsing.cs @@ -0,0 +1,5 @@ +global using CoreEx.Entities; +global using CoreEx.Localization; +global using CoreEx.RefData; +global using System.ComponentModel; +global using System.Text.Json.Serialization; \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/Movement.cs b/samples/src/Contoso.Products.Contracts/Movement.cs new file mode 100644 index 00000000..bee35bd2 --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/Movement.cs @@ -0,0 +1,39 @@ +namespace Contoso.Products.Contracts; + +[Contract] +public partial class Movement : IIdentifier, IETag, IChangeLog +{ + [ReadOnly(true)] + public string? Id { get; set; } + + public string? ReferenceId { get; set; } + + [ReferenceData] + public partial string? KindCode { get; set; } + + [ReferenceData] + public partial string? StatusCode { get; set; } + + public string? ProductId { get; set; } + + public decimal Quantity { get; set; } + + [ReferenceData] + public partial string? UnitOfMeasureCode { get; set; } + + [ReadOnly(true)] + public ChangeLog? ChangeLog { get; set; } + + [ReadOnly(true)] + public string? ETag { get; set; } + + [JsonIgnore] + public bool IsQuantityValidForKind => KindCode switch + { + MovementKind.Issue => Quantity < 0, + MovementKind.Receive or MovementKind.Adjust => Quantity > 0, + _ => false + }; +} + +public partial class MovementCollection : List { } \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/MovementKind.cs b/samples/src/Contoso.Products.Contracts/MovementKind.cs new file mode 100644 index 00000000..3b211d1c --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/MovementKind.cs @@ -0,0 +1,11 @@ +namespace Contoso.Products.Contracts; + +[ReferenceData] +public partial class MovementKind : ReferenceData +{ + public const string Adjust = "A"; + public const string Issue = "I"; + public const string Receive = "R"; +} + +public class MovementKindCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/MovementRequest.cs b/samples/src/Contoso.Products.Contracts/MovementRequest.cs new file mode 100644 index 00000000..ea2ee464 --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/MovementRequest.cs @@ -0,0 +1,9 @@ +namespace Contoso.Products.Contracts; + +[Contract] +public partial class MovementRequest : IIdentifier +{ + public string? Id { get; set; } + + public DataMap? Products { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/MovementRequestProduct.cs b/samples/src/Contoso.Products.Contracts/MovementRequestProduct.cs new file mode 100644 index 00000000..76e95216 --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/MovementRequestProduct.cs @@ -0,0 +1,12 @@ +namespace Contoso.Products.Contracts; + +[Contract] +public partial class MovementRequestProduct +{ + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public decimal Quantity { get; set; } + + [ReferenceData] + [Localization("Unit-of-measure")] + public partial string? UnitOfMeasureCode { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/MovementStatus.cs b/samples/src/Contoso.Products.Contracts/MovementStatus.cs new file mode 100644 index 00000000..3b6e01cd --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/MovementStatus.cs @@ -0,0 +1,11 @@ +namespace Contoso.Products.Contracts; + +[ReferenceData] +public partial class MovementStatus : ReferenceData +{ + public const string Pending = "P"; + public const string Confirmed = "C"; + public const string Canceled = "X"; +} + +public class MovementStatusCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/Product.cs b/samples/src/Contoso.Products.Contracts/Product.cs new file mode 100644 index 00000000..1187084b --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/Product.cs @@ -0,0 +1,11 @@ +namespace Contoso.Products.Contracts; + +[Contract] +public partial class Product : ProductBase, IETag, IChangeLog +{ + [ReadOnly(true)] + public ChangeLog? ChangeLog { get; set; } + + [ReadOnly(true)] + public string? ETag { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/ProductBase.cs b/samples/src/Contoso.Products.Contracts/ProductBase.cs new file mode 100644 index 00000000..2189119b --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/ProductBase.cs @@ -0,0 +1,34 @@ +namespace Contoso.Products.Contracts; + +[Contract] +public abstract partial class ProductBase : IIdentifier +{ + [ReadOnly(true)] + public string? Id { get; set; } + + public string? Sku { get; set => field = value?.ToUpper(); } + + public string? Text { get; set; } + + [ReadOnly(true)] + [ReferenceData] + public partial string? CategoryCode { get; set; } + + [ReferenceData] + [Localization("Sub-category")] + public partial string? SubCategoryCode { get; set; } + + [ReferenceData()] + [Localization("Unit-of-measure")] + public partial string? UnitOfMeasureCode { get; set; } + + [ReferenceData()] + public partial string? BrandCode { get; set; } + + public decimal Price { get; set; } + + public bool IsNonStocked { get; set; } + + [ReadOnly(true)] + public bool IsInactive { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/ProductLite.cs b/samples/src/Contoso.Products.Contracts/ProductLite.cs new file mode 100644 index 00000000..08232f36 --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/ProductLite.cs @@ -0,0 +1,6 @@ +namespace Contoso.Products.Contracts; + +public class ProductLite : ProductBase +{ + public decimal QtyOnHand { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/ProductReserve.cs b/samples/src/Contoso.Products.Contracts/ProductReserve.cs new file mode 100644 index 00000000..5d297bee --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/ProductReserve.cs @@ -0,0 +1,12 @@ +namespace Contoso.Products.Contracts; + +public class ProductReserve : IIdentifier +{ + public string Id { get; set; } = default!; + + public string UnitOfMeasureCode { get; set; } = default!; + + public bool IsNonStocked { get; set; } + + public bool IsInactive { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/SubCategory.cs b/samples/src/Contoso.Products.Contracts/SubCategory.cs new file mode 100644 index 00000000..d934deec --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/SubCategory.cs @@ -0,0 +1,10 @@ +namespace Contoso.Products.Contracts; + +[ReferenceData] +public partial class SubCategory : ReferenceData +{ + [ReferenceData] + public partial string? CategoryCode { get; init; } +} + +public class SubCategoryCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } \ No newline at end of file diff --git a/samples/src/Contoso.Products.Contracts/UnitOfMeasure.cs b/samples/src/Contoso.Products.Contracts/UnitOfMeasure.cs new file mode 100644 index 00000000..1ed5cac4 --- /dev/null +++ b/samples/src/Contoso.Products.Contracts/UnitOfMeasure.cs @@ -0,0 +1,12 @@ +namespace Contoso.Products.Contracts; + +[ReferenceData] +public partial class UnitOfMeasure : ReferenceData +{ + [JsonIgnore] + public int Precision => 16 - Scale; + + public int Scale { get; init; } +} + +public class UnitOfMeasureCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Contoso.Products.Database.csproj b/samples/src/Contoso.Products.Database/Contoso.Products.Database.csproj new file mode 100644 index 00000000..31c0694a --- /dev/null +++ b/samples/src/Contoso.Products.Database/Contoso.Products.Database.csproj @@ -0,0 +1,21 @@ + + + + Exe + + + + + + + + + + + + + + + + + diff --git a/samples/src/Contoso.Products.Database/Data/ref-data.yaml b/samples/src/Contoso.Products.Database/Data/ref-data.yaml new file mode 100644 index 00000000..d6c77401 --- /dev/null +++ b/samples/src/Contoso.Products.Database/Data/ref-data.yaml @@ -0,0 +1,57 @@ +Products: + - $^Category: + - A: Accessories + - B: Bikes + - G: Gear + - P: Parts + - M: Maintenance + - $^SubCategory: + - { Code: XC, Text: Cross country, CategoryCode: B } + - { Code: TR, Text: Trail, CategoryCode: B } + - { Code: DH, Text: Downhill, CategoryCode: B } + - { Code: EN, Text: Enduro, CategoryCode: B } + - { Code: EM, Text: E-MTB, CategoryCode: B } + - { Code: FR, Text: Freeride, CategoryCode: B } + - { Code: HT, Text: Hardtail, CategoryCode: B } + - { Code: DR, Text: Derailleur, CategoryCode: P } + - { Code: CA, Text: Cassette, CategoryCode: P } + - { Code: CR, Text: Crankset, CategoryCode: P } + - { Code: SP, Text: Seatpost, CategoryCode: P } + - { Code: HB, Text: Handlebar, CategoryCode: P } + - { Code: CH, Text: Chain, CategoryCode: P } + - { Code: TY, Text: Tires, CategoryCode: A } + - { Code: TO, Text: Tools, CategoryCode: A } + - { Code: CL, Text: Cleaner, CategoryCode: A } + - { Code: SE, Text: Sealant, CategoryCode: A } + - { Code: HE, Text: Helmets, CategoryCode: G } + - { Code: GL, Text: Gloves, CategoryCode: G } + - { Code: LU, Text: Lubricant, CategoryCode: A } + - { Code: LA, Text: Labor, CategoryCode: M } + - { Code: MS, Text: Miscellaneous, CategoryCode: M } + - $^UnitOfMeasure: + - EA: Each + - SET: Set + - PR: Pair + - PK: Pack + - M: Meter + - CM: Centimeter + - L: Litre + - { Code: HR, Text: Hour, Scale: 2 } + - $^Brand: + - YETI: Yeti Cycles + - CANYON: Canyon Bicycles + - SPEC: Specialized + - GIANT: Giant Bicycles + - SHIMANO: Shimano + - SRAM: SRAM Corporation + - ONEUP: OneUp Components + - MUCOFF: Muc-Off + - NONE: None/Not Applicable + - $^MovementKind: + - A: Adjust + - I: Issue + - R: Receive + - $^MovementStatus: + - C: Confirmed + - P: Pending + - X: Cancelled \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Migrations/20260101-000001-create-products-schema.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000001-create-products-schema.sql new file mode 100644 index 00000000..dec1525e --- /dev/null +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000001-create-products-schema.sql @@ -0,0 +1 @@ +CREATE SCHEMA [Products] \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20200909-164735-create-hr-gender.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000101-create-products-category.sql similarity index 61% rename from samples/My.Hr/My.Hr.Database/Migrations/20200909-164735-create-hr-gender.sql rename to samples/src/Contoso.Products.Database/Migrations/20260101-000101-create-products-category.sql index 08c2c383..048b724b 100644 --- a/samples/My.Hr/My.Hr.Database/Migrations/20200909-164735-create-hr-gender.sql +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000101-create-products-category.sql @@ -2,17 +2,17 @@ BEGIN TRANSACTION -CREATE TABLE [Hr].[Gender] ( - [GenderId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, +CREATE TABLE [Products].[Category] ( + [CategoryId] NVARCHAR(50) NOT NULL PRIMARY KEY, [Code] NVARCHAR(50) NOT NULL UNIQUE, [Text] NVARCHAR(250) NULL, [IsActive] BIT NULL, [SortOrder] INT NULL, [RowVersion] TIMESTAMP NOT NULL, [CreatedBy] NVARCHAR(250) NULL, - [CreatedDate] DATETIME2 NULL, + [CreatedOn] DATETIMEOFFSET NULL, [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL + [UpdatedOn] DATETIMEOFFSET NULL ); - + COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Migrations/20260101-000102-create-products-subcategory.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000102-create-products-subcategory.sql new file mode 100644 index 00000000..6dc63417 --- /dev/null +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000102-create-products-subcategory.sql @@ -0,0 +1,19 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Products].[SubCategory] ( + [SubCategoryId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [Code] NVARCHAR(50) NOT NULL UNIQUE, + [Text] NVARCHAR(250) NULL, + [IsActive] BIT NULL, + [SortOrder] INT NULL, + [CategoryCode] NVARCHAR(50) NOT NULL, + [RowVersion] TIMESTAMP NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Migrations/20260101-000103-create-products-unitofmeasure.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000103-create-products-unitofmeasure.sql new file mode 100644 index 00000000..9dfa322e --- /dev/null +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000103-create-products-unitofmeasure.sql @@ -0,0 +1,19 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Products].[UnitOfMeasure] ( + [UnitOfMeasureId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [Code] NVARCHAR(50) NOT NULL UNIQUE, + [Text] NVARCHAR(250) NULL, + [IsActive] BIT NULL, + [SortOrder] INT NULL, + [Scale] INT NOT NULL DEFAULT 0, + [RowVersion] TIMESTAMP NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20200909-165752-create-hr-usstate.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000104-create-products-brand.sql similarity index 61% rename from samples/My.Hr/My.Hr.Database/Migrations/20200909-165752-create-hr-usstate.sql rename to samples/src/Contoso.Products.Database/Migrations/20260101-000104-create-products-brand.sql index 12f0be9d..e5a3ce38 100644 --- a/samples/My.Hr/My.Hr.Database/Migrations/20200909-165752-create-hr-usstate.sql +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000104-create-products-brand.sql @@ -2,17 +2,17 @@ BEGIN TRANSACTION -CREATE TABLE [Hr].[USState] ( - [USStateId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, +CREATE TABLE [Products].[Brand] ( + [BrandId] NVARCHAR(50) NOT NULL PRIMARY KEY, [Code] NVARCHAR(50) NOT NULL UNIQUE, [Text] NVARCHAR(250) NULL, [IsActive] BIT NULL, [SortOrder] INT NULL, [RowVersion] TIMESTAMP NOT NULL, [CreatedBy] NVARCHAR(250) NULL, - [CreatedDate] DATETIME2 NULL, + [CreatedOn] DATETIMEOFFSET NULL, [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL + [UpdatedOn] DATETIMEOFFSET NULL ); - + COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20200909-165308-create-hr-relationshiptype.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000105-create-products-movementkind.sql similarity index 58% rename from samples/My.Hr/My.Hr.Database/Migrations/20200909-165308-create-hr-relationshiptype.sql rename to samples/src/Contoso.Products.Database/Migrations/20260101-000105-create-products-movementkind.sql index 692fdc36..71a4b293 100644 --- a/samples/My.Hr/My.Hr.Database/Migrations/20200909-165308-create-hr-relationshiptype.sql +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000105-create-products-movementkind.sql @@ -2,17 +2,17 @@ BEGIN TRANSACTION -CREATE TABLE [Hr].[RelationshipType] ( - [RelationshipTypeId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, +CREATE TABLE [Products].[MovementKind] ( + [MovementKindId] NVARCHAR(50) NOT NULL PRIMARY KEY, [Code] NVARCHAR(50) NOT NULL UNIQUE, [Text] NVARCHAR(250) NULL, [IsActive] BIT NULL, [SortOrder] INT NULL, [RowVersion] TIMESTAMP NOT NULL, [CreatedBy] NVARCHAR(250) NULL, - [CreatedDate] DATETIME2 NULL, + [CreatedOn] DATETIMEOFFSET NULL, [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL + [UpdatedOn] DATETIMEOFFSET NULL ); - + COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Database/Migrations/20200909-164828-create-hr-terminationreason.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000106-create-products-movementstatus.sql similarity index 58% rename from samples/My.Hr/My.Hr.Database/Migrations/20200909-164828-create-hr-terminationreason.sql rename to samples/src/Contoso.Products.Database/Migrations/20260101-000106-create-products-movementstatus.sql index cd4534e5..86ff5fdb 100644 --- a/samples/My.Hr/My.Hr.Database/Migrations/20200909-164828-create-hr-terminationreason.sql +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000106-create-products-movementstatus.sql @@ -2,17 +2,17 @@ BEGIN TRANSACTION -CREATE TABLE [Hr].[TerminationReason] ( - [TerminationReasonId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, +CREATE TABLE [Products].[MovementStatus] ( + [MovementStatusId] NVARCHAR(50) NOT NULL PRIMARY KEY, [Code] NVARCHAR(50) NOT NULL UNIQUE, [Text] NVARCHAR(250) NULL, [IsActive] BIT NULL, [SortOrder] INT NULL, [RowVersion] TIMESTAMP NOT NULL, [CreatedBy] NVARCHAR(250) NULL, - [CreatedDate] DATETIME2 NULL, + [CreatedOn] DATETIMEOFFSET NULL, [UpdatedBy] NVARCHAR(250) NULL, - [UpdatedDate] DATETIME2 NULL + [UpdatedOn] DATETIMEOFFSET NULL ); - + COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Migrations/20260101-000201-create-products-product.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000201-create-products-product.sql new file mode 100644 index 00000000..1ddc6d6f --- /dev/null +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000201-create-products-product.sql @@ -0,0 +1,23 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Products].[Product] ( + [ProductId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [Sku] NVARCHAR(50) NOT NULL UNIQUE, + [Text] NVARCHAR(250) NOT NULL, + [SubCategoryCode] NVARCHAR(50) NOT NULL, + [UnitOfMeasureCode] NVARCHAR(50) NOT NULL, + [BrandCode] NVARCHAR(50) NULL, + [Price] DECIMAL(18, 2) DEFAULT 0 NOT NULL, + [IsInactive] BIT DEFAULT 0 NOT NULL, + [IsNonStocked] BIT DEFAULT 0 NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL, + [RowVersion] TIMESTAMP NOT NULL, + [IsDeleted] BIT DEFAULT 0 +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Migrations/20260101-000202-create-products-inventory.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000202-create-products-inventory.sql new file mode 100644 index 00000000..e25002fd --- /dev/null +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000202-create-products-inventory.sql @@ -0,0 +1,11 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Products].[Inventory] ( + [InventoryId] NVARCHAR(50) NOT NULL PRIMARY KEY, -- ProductId + [QtyOnHand] DECIMAL(18, 2) DEFAULT 0 NOT NULL, + [RowVersion] TIMESTAMP NOT NULL +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Migrations/20260101-000203-create-products-movement.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000203-create-products-movement.sql new file mode 100644 index 00000000..cbd68f94 --- /dev/null +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000203-create-products-movement.sql @@ -0,0 +1,22 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Products].[Movement] ( + [MovementId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [ReferenceId] NVARCHAR(50) NOT NULL, + [MovementKindCode] NVARCHAR(50) NOT NULL, + [MovementStatusCode] NVARCHAR(50) NOT NULL, + [ProductId] NVARCHAR(50) NOT NULL, + [Quantity] DECIMAL(18, 2) DEFAULT 0 NOT NULL, + [UnitOfMeasureCode] NVARCHAR(50) NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL, + [RowVersion] TIMESTAMP NOT NULL, + + CONSTRAINT [UQ_Products_Movement_Reference_Product] UNIQUE ([ReferenceId], [ProductId]) +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Migrations/20260101-000301-create-products-outbox-tables.sql b/samples/src/Contoso.Products.Database/Migrations/20260101-000301-create-products-outbox-tables.sql new file mode 100644 index 00000000..65e2a10d --- /dev/null +++ b/samples/src/Contoso.Products.Database/Migrations/20260101-000301-create-products-outbox-tables.sql @@ -0,0 +1,37 @@ +-- Create table: [Products].[Outbox] and [Products].[OutboxLease] + +BEGIN TRANSACTION + +CREATE TABLE [Products].[Outbox] ( + [OutboxId] BIGINT IDENTITY (1, 1) NOT NULL PRIMARY KEY, + [TenantId] NVARCHAR(255) NOT NULL, -- Optional, null indicates no tenancy. + [PartitionId] INT NOT NULL, -- Partition number; computed in application from partition-key. + [Status] TINYINT NOT NULL DEFAULT 0, -- 0=Pending, 1=Processing, 2=Done. + [EnqueuedUtc] DATETIME2 NOT NULL, -- When the event was enqueued within application. + [AvailableUtc] DATETIME2 NOT NULL, -- When the event is eligible for processing (retry delay). + [DequeuedUtc] DATETIME2 NULL, -- When the event was successfully dequeued/relayed. + [Attempts] INT NOT NULL DEFAULT 0, -- Retry attempt count. + + -- Message: + [Destination] NVARCHAR(255) NULL, -- Message destination; i.e. queue/topic/etc. + [Event] NVARCHAR(MAX) NOT NULL, -- CloudEvent as JSON. + + -- Claim/leasing: + [LeaseId] UNIQUEIDENTIFIER NULL, -- Unique identifier of the lease. + [LeaseUntilUtc] DATETIME2 NULL, -- Leased until UTC; after which assume released due to possible application crash. + + INDEX [IX_Products_Outbox_PartitionOrder] ([TenantId], [PartitionId], [OutboxId]) INCLUDE ([Status], [AvailableUtc], [LeaseUntilUtc], [Destination], [Event], [Attempts]), + INDEX [IX_Products_Outbox_WorkerPull] ([TenantId], [PartitionId], [Status]) INCLUDE ([OutboxId], [AvailableUtc]), + INDEX [IX_Products_Outbox_CleanUp] ([OutboxId]) INCLUDE ([DequeuedUtc]) WHERE [Status] = 2 +); + +CREATE TABLE [Products].[OutboxLease] ( + [TenantId] NVARCHAR(255) NOT NULL, -- Optional, null indicates no tenancy. + [PartitionId] INT NOT NULL, -- Partition number; computed in application from partition-key. + [LeaseId] UNIQUEIDENTIFIER NULL, -- Unique identifier of the leasee. + [LeaseUntilUtc] DATETIME2 NULL -- Leased until UTC; after which assume released due to possible application crash. + + CONSTRAINT PK_Products_OutboxLease PRIMARY KEY (TenantId, PartitionId) +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Program.cs b/samples/src/Contoso.Products.Database/Program.cs new file mode 100644 index 00000000..b9ebfc7f --- /dev/null +++ b/samples/src/Contoso.Products.Database/Program.cs @@ -0,0 +1,40 @@ +using CoreEx.Database; +using DbEx.Migration; +using DbEx.SqlServer.Console; + +namespace Contoso.Products.Database; + +/// +/// Represents the database utilities program (capability). +/// +public class Program +{ + /// + /// Main startup. + /// + /// The startup arguments. + /// The status code whereby zero indicates success. + public static Task Main(string[] args) => SqlServerMigrationConsole + .Create("Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true") + .Configure(c => ConfigureMigrationArgs(c.Args)) + .RunAsync(args); + + /// + /// Configure the . + /// + /// The . + /// The . + public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) + { + args.AddAssembly().AddAssembly() + .IncludeExtendedSchemaScripts() + .DataParserArgs + .RefDataColumnDefault("SortOrder", _ => 0) + .RefDataColumnDefault("Scale", _ => 0); + + // Only reset data for the Products schema. + args.DataResetFilterPredicate = ts => ts.Schema == "Products"; + + return args; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Properties/launchSettings.json b/samples/src/Contoso.Products.Database/Properties/launchSettings.json new file mode 100644 index 00000000..4de54f7e --- /dev/null +++ b/samples/src/Contoso.Products.Database/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Contoso.Products.Database": { + "commandName": "Project", + "commandLineArgs": "dropandall" + } + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql new file mode 100644 index 00000000..99025006 --- /dev/null +++ b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql @@ -0,0 +1,71 @@ +CREATE OR ALTER PROCEDURE [Products].[spOutboxBatchCancel] + @LeaseId UNIQUEIDENTIFIER, + @BackoffSeconds INT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Cancels a batch by LeaseId, marking messages as pending with backoff and releasing the lease. + * > Returns: + * 0 = Success. + * -1 = No rows updated (e.g. already completed or invalid LeaseId). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @TenantId NVARCHAR(255); + DECLARE @PartitionId INT; + DECLARE @Cancelled TABLE (TenantId NVARCHAR(255), PartitionId INT); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Cancel the batch and capture tenant/partition atomically. + UPDATE o + SET o.[Status] = 0, + o.[Attempts] = o.[Attempts] + 1, + o.[AvailableUtc] = DATEADD(SECOND, @BackoffSeconds, @Now), + o.[LeaseId] = NULL, + o.[LeaseUntilUtc] = NULL + OUTPUT + deleted.[TenantId], + deleted.[PartitionId] + INTO @Cancelled + FROM [Products].[Outbox] AS o WITH (UPDLOCK, ROWLOCK) + WHERE o.[LeaseId] = @LeaseId + AND o.[Status] = 1; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + RETURN -1; -- No rows updated. + END + + -- 2) Capture tenant/partition from first cancelled row. + SELECT TOP 1 + @TenantId = TenantId, + @PartitionId = PartitionId + FROM @Cancelled; + + COMMIT; + + -- 3) Release the partition lease. + BEGIN TRY + EXEC [Products].[spOutboxLeaseRelease] @LeaseId; + END TRY + BEGIN CATCH + -- Ignore: lease will expire. Don't fail cancel. + END CATCH + + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END diff --git a/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql new file mode 100644 index 00000000..d1b7b569 --- /dev/null +++ b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql @@ -0,0 +1,116 @@ +CREATE OR ALTER PROCEDURE [Products].[spOutboxBatchClaim] + @TenantId NVARCHAR(255) = NULL, + @PartitionId INT, + @BatchSize INT, + @LeaseId UNIQUEIDENTIFIER, + @LeaseSeconds INT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Claims the next batch of pending/processing messages for a tenant/partition, marking them as processing with a lease. + * > Returns: + * 0 = Success; batch returned in result set. + * -1 = No rows updated (e.g. already claimed by another or transient error). + * -2 = No batch to claim (e.g. all completed). + * -3 = Unable to acquire lease (e.g. another active batch or transient error). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @LeaseUntilUtc DATETIME2; + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + SET LOCK_TIMEOUT 5000; -- Milliseconds. + + -- 1) Acquire a partition lease; exit where unsuccessful. + DECLARE @RC INT; + EXEC @RC = [Products].[spOutboxLeaseAcquire] @EffectiveTenantId, @PartitionId, @LeaseId, @LeaseSeconds, @LeaseUntilUtc OUTPUT; + IF (@RC < 0) RETURN -3; + + -- 2) Claim the next batch (contiguous by OutboxId) for the tenant/partition. + BEGIN TRY + BEGIN TRAN; + + DECLARE @HeadId BIGINT; + DECLARE @BlockerId BIGINT; + + -- Determine head (first pending/processing) for strict contiguity. + SELECT @HeadId = MIN(o.OutboxId) + FROM [Products].[Outbox] o WITH (UPDLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[Status] IN (0, 1) + OPTION (RECOMPILE); + + IF @HeadId IS NULL + BEGIN + COMMIT; + + -- Release the lease outside transaction. + EXEC [Products].[spOutboxLeaseRelease] @LeaseId; + RETURN -2; -- Nothing available. + END + + -- Find first blocker at/after head: actively leased or not yet available. + SELECT @BlockerId = MIN(o.OutboxId) + FROM [Products].[Outbox] o WITH (READPAST, UPDLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[OutboxId] >= @HeadId + AND ((o.Status = 1 AND o.[LeaseUntilUtc] IS NOT NULL AND o.[LeaseUntilUtc] > @Now) + OR (o.Status = 0 AND o.[AvailableUtc] > @Now)) + OPTION (RECOMPILE); + + -- Claim contiguous run from head to before blocker. + ;WITH claim AS + ( + SELECT TOP (@BatchSize) + o.[OutboxId], o.[TenantId], o.[Status], o.[PartitionId], o.[Destination], o.[Event], + o.[Attempts], o.[EnqueuedUtc], o.[AvailableUtc], o.[LeaseId], o.[LeaseUntilUtc] + FROM [Products].[Outbox] o WITH (READPAST, UPDLOCK, ROWLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[OutboxId] >= @HeadId + AND (@BlockerId IS NULL OR o.[OutboxId] < @BlockerId) + AND ((o.[Status] = 0 AND o.[AvailableUtc] <= @Now) + OR (o.[Status] = 1 AND (o.[LeaseUntilUtc] IS NULL OR o.[LeaseUntilUtc] <= @Now))) + ORDER BY o.OutboxId + ) + UPDATE claim + SET [Status] = 1, + [LeaseId] = @LeaseId, + [LeaseUntilUtc] = @LeaseUntilUtc + OUTPUT + inserted.[OutboxId], + inserted.[TenantId], + inserted.[Status], + inserted.[PartitionId], + inserted.[Destination], + inserted.[Event], + inserted.[Attempts], + inserted.[EnqueuedUtc], + inserted.[AvailableUtc], + inserted.[LeaseUntilUtc]; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + + -- Release the lease outside transaction. + EXEC [Products].[spOutboxLeaseRelease] @LeaseId; + RETURN -1; -- No rows updated. + END + + COMMIT; + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql new file mode 100644 index 00000000..1d519ea4 --- /dev/null +++ b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql @@ -0,0 +1,73 @@ +CREATE OR ALTER PROCEDURE [Products].[spOutboxBatchComplete] + @LeaseId UNIQUEIDENTIFIER, + @DequeuedUtc DATETIME2 NULL +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Marks a batch as completed by LeaseId, releasing the lease and making way for the next batch. + * > Returns: + * 0 = Success. + * -1 = No rows updated (e.g. already completed or invalid LeaseId). + * -2 = No batch to claim (e.g. all completed since claim). + * -3 = Unable to acquire lease (e.g. another active batch or transient error). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @TenantId NVARCHAR(255); + DECLARE @PartitionId INT; + DECLARE @Completed TABLE (TenantId NVARCHAR(255), PartitionId INT); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Complete the batch and capture tenant/partition atomically. + UPDATE o + SET o.[Status] = 2, + o.[LeaseId] = NULL, + o.[LeaseUntilUtc] = NULL, + o.[DequeuedUtc] = COALESCE(@DequeuedUtc, @Now) + OUTPUT + deleted.[TenantId], + deleted.[PartitionId] + INTO @Completed + FROM [Products].[Outbox] AS o WITH (UPDLOCK, ROWLOCK) + WHERE o.[LeaseId] = @LeaseId + AND o.[Status] = 1; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + RETURN -1; -- No rows updated. + END + + -- 2) Capture tenant/partition from first completed row. + SELECT TOP 1 + @TenantId = TenantId, + @PartitionId = PartitionId + FROM @Completed; + + COMMIT; + + -- 3) Release the partition lease where identified. + BEGIN TRY + EXEC [Products].[spOutboxLeaseRelease] @LeaseId; + END TRY + BEGIN CATCH + -- Ignore: lease will expire. Don't fail completion. + END CATCH + + RETURN 0 + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH + +END \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql new file mode 100644 index 00000000..00624282 --- /dev/null +++ b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql @@ -0,0 +1,36 @@ +CREATE OR ALTER PROCEDURE [Products].[spOutboxEnqueue] + @TenantId AS NVARCHAR(255) = NULL, + @PartitionId AS INT, + @Destination AS NVARCHAR(255), + @Event AS NVARCHAR(MAX), + @EnqueuedUtc AS DATETIME2 = NULL, + @AvailableUtc AS DATETIME2 = NULL +AS +BEGIN + /* + * This file is automatically generated; any changes will be lost. + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + INSERT INTO [Products].[Outbox] ( + [TenantId], + [PartitionId], + [Destination], + [Event], + [EnqueuedUtc], + [AvailableUtc] + ) + VALUES ( + @EffectiveTenantId, + @PartitionId, + @Destination, + @Event, + COALESCE(@EnqueuedUtc, @Now), + COALESCE(@AvailableUtc, COALESCE(@EnqueuedUtc, @Now)) + ) +END \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql new file mode 100644 index 00000000..8c9e22e2 --- /dev/null +++ b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql @@ -0,0 +1,73 @@ +CREATE OR ALTER PROCEDURE [Products].[spOutboxLeaseAcquire] + @TenantId NVARCHAR(255) = NULL, + @PartitionId INT, + @LeaseId UNIQUEIDENTIFIER, + @LeaseSeconds INT, + @LeaseUntilUtc DATETIME2 OUTPUT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Attempts to acquire a lease for a tenant/partition, returning success status and lease until timestamp. + * > Returns: + * 0 = Lease acquired; caller may proceed with batch claim. + * -1 = Lease not acquired; caller should backoff and retry. + * + * Notes: + * - The procedure is resilient to transient errors and will return -1 where lease acquisition is unsuccessful, including where another active lease exists or where a transient error occurs (e.g. lock timeout). + * - The caller should implement an appropriate retry/backoff strategy where -1 is returned, including randomization to avoid thundering herd issues. + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @Until DATETIME2 = DATEADD(SECOND, @LeaseSeconds, @Now) + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Ensure the row exists (self-seeding); lock the key-range for this PartitionId to avoid insert races. + IF NOT EXISTS ( + SELECT 1 + FROM [Products].[OutboxLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [TenantId] = @EffectiveTenantId AND [PartitionId] = @PartitionId + ) + BEGIN + INSERT INTO [Products].[OutboxLease] ([TenantId], [PartitionId]) + VALUES (@EffectiveTenantId, @PartitionId); + END + + -- 2) Attempt to acquire lease where expired/empty. + UPDATE ol + SET ol.[LeaseId] = @LeaseId, + ol.[LeaseUntilUtc] = @Until + FROM [Products].[OutboxLease] AS ol WITH (UPDLOCK, ROWLOCK) + WHERE ol.[PartitionId] = @PartitionId + AND ol.[TenantId] = @EffectiveTenantId + AND (ol.[LeaseUntilUtc] IS NULL OR ol.[LeaseUntilUtc] <= @Now) + OPTION (RECOMPILE); + + -- 3) Commit and return lease success status. + DECLARE @Rows INT = @@ROWCOUNT; + + COMMIT; + + IF @Rows = 1 + BEGIN + SET @LeaseUntilUtc = @Until; + RETURN 0; -- Lease successful. + END + + SET @LeaseUntilUtc = NULL; + RETURN -1; -- Lease unsuccessful. + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql new file mode 100644 index 00000000..f556c917 --- /dev/null +++ b/samples/src/Contoso.Products.Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql @@ -0,0 +1,44 @@ +CREATE OR ALTER PROCEDURE [Products].[spOutboxLeaseRelease] + @LeaseId UNIQUEIDENTIFIER +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Releases a lease by LeaseId, making way for the next batch. + * > Returns: + * 0 = Success; lease released and available for next claim. + * -1 = No rows updated (e.g. already released or invalid LeaseId). + * + * Notes: + * - The procedure is resilient to transient errors and will return -1 where release is unsuccessful, including where the lease is already released or where a transient error occurs (e.g. lock timeout). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + BEGIN TRY + BEGIN TRAN; + + -- 1) Release lease where leasee. + UPDATE ol + SET ol.[LeaseId] = NULL, + ol.[LeaseUntilUtc] = NULL + FROM [Products].[OutboxLease] AS ol WITH (UPDLOCK, ROWLOCK) + WHERE ol.[LeaseId] = @LeaseId; + + -- 2) Commit and return release success status. + DECLARE @Rows INT = @@ROWCOUNT; + + COMMIT; + + IF @Rows = 1 RETURN 0; -- Release successful. + RETURN -1; -- Release unsuccessful. + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/dbex.yaml b/samples/src/Contoso.Products.Database/dbex.yaml new file mode 100644 index 00000000..dde073be --- /dev/null +++ b/samples/src/Contoso.Products.Database/dbex.yaml @@ -0,0 +1,14 @@ +outbox: true +tables: +# Reference-data +- name: Brand +- name: Category +- name: MovementKind +- name: MovementStatus +- name: SubCategory +- name: UnitOfMeasure + +# Transactional-data +- name: Inventory +- name: Movement +- name: Product \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Contoso.Products.Infrastructure.csproj b/samples/src/Contoso.Products.Infrastructure/Contoso.Products.Infrastructure.csproj new file mode 100644 index 00000000..42ba6850 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Contoso.Products.Infrastructure.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/GlobalUsing.cs b/samples/src/Contoso.Products.Infrastructure/GlobalUsing.cs new file mode 100644 index 00000000..5f5292d9 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/GlobalUsing.cs @@ -0,0 +1,19 @@ +global using Contoso.Products.Application.Repositories; +global using Contoso.Products.Infrastructure.Mapping; +global using CoreEx; +global using CoreEx.Data; +global using CoreEx.Data.Models; +global using CoreEx.Data.Querying; +global using CoreEx.Database; +global using CoreEx.Database.SqlServer; +global using CoreEx.Database.SqlServer.Outbox; +global using CoreEx.DependencyInjection; +global using CoreEx.Entities; +global using CoreEx.EntityFrameworkCore; +global using CoreEx.EntityFrameworkCore.Converters; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Mapping; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Logging; +global using System.Text.Json; \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Mapping/BrandMapper.cs b/samples/src/Contoso.Products.Infrastructure/Mapping/BrandMapper.cs new file mode 100644 index 00000000..028b72a5 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Mapping/BrandMapper.cs @@ -0,0 +1,16 @@ +namespace Contoso.Products.Infrastructure.Mapping; + +internal class BrandMapper : BiDirectionMapper +{ + protected override Persistence.Brand OnMap(Contracts.Brand source) => throw new NotImplementedException(); + + protected override Contracts.Brand OnMap(Persistence.Brand source) => new() + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + SortOrder = source.SortOrder, + IsInactive = !source.IsActive, + ETag = source.ETag + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Mapping/CategoryMapper.cs b/samples/src/Contoso.Products.Infrastructure/Mapping/CategoryMapper.cs new file mode 100644 index 00000000..32d57913 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Mapping/CategoryMapper.cs @@ -0,0 +1,16 @@ +namespace Contoso.Products.Infrastructure.Mapping; + +internal class CategoryMapper : BiDirectionMapper +{ + protected override Persistence.Category OnMap(Contracts.Category source) => throw new NotImplementedException(); + + protected override Contracts.Category OnMap(Persistence.Category source) => new() + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + SortOrder = source.SortOrder, + IsInactive = !source.IsActive, + ETag = source.ETag + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Mapping/MovementKindMapper.cs b/samples/src/Contoso.Products.Infrastructure/Mapping/MovementKindMapper.cs new file mode 100644 index 00000000..0b967237 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Mapping/MovementKindMapper.cs @@ -0,0 +1,16 @@ +namespace Contoso.Products.Infrastructure.Mapping; + +internal class MovementKindMapper : BiDirectionMapper +{ + protected override Persistence.MovementKind OnMap(Contracts.MovementKind source) => throw new NotImplementedException(); + + protected override Contracts.MovementKind OnMap(Persistence.MovementKind source) => new() + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + SortOrder = source.SortOrder, + IsInactive = !source.IsActive, + ETag = source.ETag + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Mapping/MovementMapper.cs b/samples/src/Contoso.Products.Infrastructure/Mapping/MovementMapper.cs new file mode 100644 index 00000000..e6a0ba59 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Mapping/MovementMapper.cs @@ -0,0 +1,27 @@ + +namespace Contoso.Products.Infrastructure.Mapping; + +public class MovementMapper : BiDirectionMapper +{ + protected override Persistence.Movement OnMap(Contracts.Movement source) => new() + { + Id = source.Id!, + ReferenceId = source.ReferenceId!, + MovementKindCode = source.KindCode!, + MovementStatusCode = source.StatusCode!, + ProductId = source.ProductId!, + Quantity = source.Quantity, + UnitOfMeasureCode = source.UnitOfMeasureCode! + }; + + protected override Contracts.Movement OnMap(Persistence.Movement source) => new() + { + Id = source.Id, + ReferenceId = source.ReferenceId, + KindCode = source.MovementKindCode, + StatusCode = source.MovementStatusCode, + ProductId = source.ProductId, + Quantity = source.Quantity, + UnitOfMeasureCode = source.UnitOfMeasureCode + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Mapping/MovementStatusMapper.cs b/samples/src/Contoso.Products.Infrastructure/Mapping/MovementStatusMapper.cs new file mode 100644 index 00000000..6fc4a9d7 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Mapping/MovementStatusMapper.cs @@ -0,0 +1,16 @@ +namespace Contoso.Products.Infrastructure.Mapping; + +internal class MovementStatusMapper : BiDirectionMapper +{ + protected override Persistence.MovementStatus OnMap(Contracts.MovementStatus source) => throw new NotImplementedException(); + + protected override Contracts.MovementStatus OnMap(Persistence.MovementStatus source) => new() + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + SortOrder = source.SortOrder, + IsInactive = !source.IsActive, + ETag = source.ETag + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Mapping/ProductMapper.cs b/samples/src/Contoso.Products.Infrastructure/Mapping/ProductMapper.cs new file mode 100644 index 00000000..3ccf9d65 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Mapping/ProductMapper.cs @@ -0,0 +1,30 @@ +namespace Contoso.Products.Infrastructure.Mapping; + +public class ProductMapper : BiDirectionMapper +{ + protected override Persistence.Product OnMap(Contracts.Product source) => new() + { + Id = source.Id!, + Sku = source.Sku!, + Text = source.Text!, + SubCategoryCode = source.SubCategory?.Code!, + UnitOfMeasureCode = source.UnitOfMeasure?.Code!, + BrandCode = source.Brand?.Code!, + Price = source.Price, + IsInactive = source.IsInactive, + IsNonStocked = source.IsNonStocked + }; + + protected override Contracts.Product OnMap(Persistence.Product source) => new Contracts.Product() + { + Id = source.Id, + Sku = source.Sku, + Text = source.Text, + SubCategoryCode = source.SubCategoryCode, + UnitOfMeasureCode = source.UnitOfMeasureCode, + BrandCode = source.BrandCode, + Price = source.Price, + IsInactive = source.IsInactive, + IsNonStocked = source.IsNonStocked + }.Adjust(p => p.CategoryCode = p.SubCategory?.CategoryCode); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Mapping/SubCategoryMapper.cs b/samples/src/Contoso.Products.Infrastructure/Mapping/SubCategoryMapper.cs new file mode 100644 index 00000000..151b37b7 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Mapping/SubCategoryMapper.cs @@ -0,0 +1,16 @@ +namespace Contoso.Products.Infrastructure.Mapping; + +internal class SubCategoryMapper : BiDirectionMapper +{ + protected override Persistence.SubCategory OnMap(Contracts.SubCategory source) => throw new NotImplementedException(); + + protected override Contracts.SubCategory OnMap(Persistence.SubCategory source) => new() + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + SortOrder = source.SortOrder, + IsInactive = !source.IsActive, + CategoryCode = source.CategoryCode + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Mapping/UnitOfMeasureMapper.cs b/samples/src/Contoso.Products.Infrastructure/Mapping/UnitOfMeasureMapper.cs new file mode 100644 index 00000000..7e3d7ae3 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Mapping/UnitOfMeasureMapper.cs @@ -0,0 +1,16 @@ +namespace Contoso.Products.Infrastructure.Mapping; + +internal class UnitOfMeasureMapper : BiDirectionMapper +{ + protected override Persistence.UnitOfMeasure OnMap(Contracts.UnitOfMeasure source) => throw new NotImplementedException(); + + protected override Contracts.UnitOfMeasure OnMap(Persistence.UnitOfMeasure source) => new() + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + SortOrder = source.SortOrder, + Scale = source.Scale, + IsInactive = !source.IsActive + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Persistence/Brand.g.cs b/samples/src/Contoso.Products.Infrastructure/Persistence/Brand.g.cs new file mode 100644 index 00000000..2c4ffe7e --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Persistence/Brand.g.cs @@ -0,0 +1,16 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Products.Infrastructure.Persistence; + +/// +/// Persistence reference-data model representing the '[Products].[Brand]' database table. +/// +public partial class Brand : ReferenceDataModelBase { } + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Persistence/Category.g.cs b/samples/src/Contoso.Products.Infrastructure/Persistence/Category.g.cs new file mode 100644 index 00000000..bda49441 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Persistence/Category.g.cs @@ -0,0 +1,16 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Products.Infrastructure.Persistence; + +/// +/// Persistence reference-data model representing the '[Products].[Category]' database table. +/// +public partial class Category : ReferenceDataModelBase { } + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Persistence/Inventory.g.cs b/samples/src/Contoso.Products.Infrastructure/Persistence/Inventory.g.cs new file mode 100644 index 00000000..1475699e --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Persistence/Inventory.g.cs @@ -0,0 +1,23 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Products.Infrastructure.Persistence; + +/// +/// Persistence model representing the '[Products].[Inventory]' database table. +/// +/// The primary key column is 'InventoryId' (type 'NVARCHAR(50)'). +public partial class Inventory : ModelBase +{ + /// + /// Gets or sets the value of the 'QtyOnHand' column (type 'DECIMAL(18, 2)'). + /// + public decimal QtyOnHand { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Persistence/Movement.g.cs b/samples/src/Contoso.Products.Infrastructure/Persistence/Movement.g.cs new file mode 100644 index 00000000..3ca9a486 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Persistence/Movement.g.cs @@ -0,0 +1,48 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Products.Infrastructure.Persistence; + +/// +/// Persistence model representing the '[Products].[Movement]' database table. +/// +/// The primary key column is 'MovementId' (type 'NVARCHAR(50)'). +public partial class Movement : ModelBase +{ + /// + /// Gets or sets the value of the 'ReferenceId' column (type 'NVARCHAR(50)'). + /// + public string ReferenceId { get; set; } = default!; + + /// + /// Gets or sets the value of the 'MovementKindCode' column (type 'NVARCHAR(50)'). + /// + public string MovementKindCode { get; set; } = default!; + + /// + /// Gets or sets the value of the 'MovementStatusCode' column (type 'NVARCHAR(50)'). + /// + public string MovementStatusCode { get; set; } = default!; + + /// + /// Gets or sets the value of the 'ProductId' column (type 'NVARCHAR(50)'). + /// + public string ProductId { get; set; } = default!; + + /// + /// Gets or sets the value of the 'Quantity' column (type 'DECIMAL(18, 2)'). + /// + public decimal Quantity { get; set; } + + /// + /// Gets or sets the value of the 'UnitOfMeasureCode' column (type 'NVARCHAR(50)'). + /// + public string UnitOfMeasureCode { get; set; } = default!; +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Persistence/MovementKind.g.cs b/samples/src/Contoso.Products.Infrastructure/Persistence/MovementKind.g.cs new file mode 100644 index 00000000..b66531d0 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Persistence/MovementKind.g.cs @@ -0,0 +1,16 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Products.Infrastructure.Persistence; + +/// +/// Persistence reference-data model representing the '[Products].[MovementKind]' database table. +/// +public partial class MovementKind : ReferenceDataModelBase { } + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Persistence/MovementStatus.g.cs b/samples/src/Contoso.Products.Infrastructure/Persistence/MovementStatus.g.cs new file mode 100644 index 00000000..df5aff89 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Persistence/MovementStatus.g.cs @@ -0,0 +1,16 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Products.Infrastructure.Persistence; + +/// +/// Persistence reference-data model representing the '[Products].[MovementStatus]' database table. +/// +public partial class MovementStatus : ReferenceDataModelBase { } + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Persistence/Product.g.cs b/samples/src/Contoso.Products.Infrastructure/Persistence/Product.g.cs new file mode 100644 index 00000000..0df5fff6 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Persistence/Product.g.cs @@ -0,0 +1,63 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Products.Infrastructure.Persistence; + +/// +/// Persistence model representing the '[Products].[Product]' database table. +/// +/// The primary key column is 'ProductId' (type 'NVARCHAR(50)'). +public partial class Product : ModelBase, ILogicallyDeleted +{ + /// + /// Gets or sets the value of the 'Sku' column (type 'NVARCHAR(50)'). + /// + public string Sku { get; set; } = default!; + + /// + /// Gets or sets the value of the 'Text' column (type 'NVARCHAR(250)'). + /// + public string Text { get; set; } = default!; + + /// + /// Gets or sets the value of the 'SubCategoryCode' column (type 'NVARCHAR(50)'). + /// + public string SubCategoryCode { get; set; } = default!; + + /// + /// Gets or sets the value of the 'UnitOfMeasureCode' column (type 'NVARCHAR(50)'). + /// + public string UnitOfMeasureCode { get; set; } = default!; + + /// + /// Gets or sets the value of the 'BrandCode' column (type 'NVARCHAR(50) NULL'). + /// + public string? BrandCode { get; set; } + + /// + /// Gets or sets the value of the 'Price' column (type 'DECIMAL(18, 2)'). + /// + public decimal Price { get; set; } + + /// + /// Gets or sets the value of the 'IsInactive' column (type 'BIT'). + /// + public bool IsInactive { get; set; } + + /// + /// Gets or sets the value of the 'IsNonStocked' column (type 'BIT'). + /// + public bool IsNonStocked { get; set; } + + /// + /// Gets or sets the value of the 'IsDeleted' column (type 'BIT NULL'); see . + /// + public bool IsDeleted { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Persistence/SubCategory.g.cs b/samples/src/Contoso.Products.Infrastructure/Persistence/SubCategory.g.cs new file mode 100644 index 00000000..ee6f3c6d --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Persistence/SubCategory.g.cs @@ -0,0 +1,22 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Products.Infrastructure.Persistence; + +/// +/// Persistence reference-data model representing the '[Products].[SubCategory]' database table. +/// +public partial class SubCategory : ReferenceDataModelBase +{ + /// + /// Gets or sets the value of the 'CategoryCode' column (type 'NVARCHAR(50)'). + /// + public string CategoryCode { get; set; } = default!; +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Persistence/UnitOfMeasure.g.cs b/samples/src/Contoso.Products.Infrastructure/Persistence/UnitOfMeasure.g.cs new file mode 100644 index 00000000..d4cf8905 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Persistence/UnitOfMeasure.g.cs @@ -0,0 +1,22 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Products.Infrastructure.Persistence; + +/// +/// Persistence reference-data model representing the '[Products].[UnitOfMeasure]' database table. +/// +public partial class UnitOfMeasure : ReferenceDataModelBase +{ + /// + /// Gets or sets the value of the 'Scale' column (type 'INT'). + /// + public int Scale { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/DatabaseConsts.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/DatabaseConsts.cs new file mode 100644 index 00000000..b94fefaa --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/DatabaseConsts.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Contoso.Products.Infrastructure.Repositories +{ + internal class DatabaseConsts + { + } +} diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/InventoryRepository.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/InventoryRepository.cs new file mode 100644 index 00000000..cf9fee03 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/InventoryRepository.cs @@ -0,0 +1,33 @@ +namespace Contoso.Products.Infrastructure.Repositories; + +[ScopedService] +public class InventoryRepository(ProductsEfDb ef) : IInventoryRepository +{ + private readonly ProductsEfDb _ef = ef.ThrowIfNull(); + + public async Task GetOnHandAsync(string productId, bool throwNotFoundException) + { + var products = _ef.Products.Model.Query(); + var inventory = _ef.Inventory.Query(); + + var q = + from p in products + where p.Id == productId + join i in inventory on p.Id equals i.Id into ig + from i in ig.DefaultIfEmpty() + select new + { + HasInventory = i != null, + QtyOnHand = i == null ? 0m : i.QtyOnHand + }; + + var res = await q.SingleOrDefaultAsync().ConfigureAwait(false); + + // Where existence is not considered important, return zero when not found. + if (res is null) + return throwNotFoundException ? throw new NotFoundException() : null; + + // Return the current quantity-on-hand where applicable. + return res.HasInventory ? res.QtyOnHand : null; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/MovementRepository.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/MovementRepository.cs new file mode 100644 index 00000000..81eb1289 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/MovementRepository.cs @@ -0,0 +1,175 @@ +namespace Contoso.Products.Infrastructure.Repositories; + +[ScopedService] +public class MovementRepository(ProductsEfDb ef) : IMovementRepository +{ + private readonly ProductsEfDb _ef = ef.ThrowIfNull(); + + private readonly static QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField(nameof(Contracts.Movement.ReferenceId), c => c.WithOperators(QueryFilterOperator.EqualityOperators)) + .AddField(nameof(Contracts.Movement.ProductId), c => c.WithOperators(QueryFilterOperator.EqualityOperators)) + .AddReferenceDataField(nameof(Contracts.Movement.Kind), nameof(Persistence.Movement.MovementKindCode)) + .AddReferenceDataField(nameof(Contracts.Movement.Status), nameof(Persistence.Movement.MovementStatusCode))) + .WithOrderBy(orderby => orderby + .AddField(nameof(Contracts.Movement.ReferenceId), c => c.WithDefault().WithAlwaysInclude()) + .AddField(nameof(Contracts.Movement.ProductId), c => c.WithDefault().WithAlwaysInclude())); + + /// + public async Task> CreateAsync(List movements) + { + movements.ThrowIfNull().ThrowWhen(movements => movements.Select(x => x.Kind).Distinct().Count() > 1, "All movements must be of the same kind.") + .ThrowWhen(movements => movements.Any(m => !m.IsQuantityValidForKind), "One or more movements have invalid quantities for their kind."); + + // Process the movements and related inventory items. + var ids = movements.Select(m => m.ProductId).Distinct(); + var inventoryItems = await _ef.Inventory.QueryTracked().Where(i => ids.Contains(i.Id)).ToDictionaryAsync(i => i.Id!, i => i).ConfigureAwait(false); + var args = new EfDbArgs { SaveChanges = false }; + + foreach (var movement in movements) + { + movement.Id = Runtime.NewId(); + var movementModel = MovementMapper.To.Map(movement); + + // Adjust the inventory item for the movement. + await InventoryAdjustAsync(args, inventoryItems, movementModel).ConfigureAwait(false); + + // Create the movement. + movement.Id = Runtime.NewId(); + await _ef.Movements.CreateAsync(args, MovementMapper.To.Map(movement)).ConfigureAwait(false); + } + + // Save changes and return the mutated movements. + return await SaveAndGetAllMutatedMovements().ConfigureAwait(false); + } + + /// + /// Adjust the inventory item for the movement. + /// + private async Task InventoryAdjustAsync(EfDbArgs args, Dictionary inventoryItems, Persistence.Movement movement) + { + // Create/adjust the inventory item for the movement. + if (inventoryItems.TryGetValue(movement.ProductId!, out var inventoryItem)) + { + if (movement.MovementKindCode == Contracts.MovementKind.Adjust) + inventoryItem.QtyOnHand = movement.Quantity; + else + inventoryItem.QtyOnHand += movement.Quantity; + + if (inventoryItem.QtyOnHand < 0) + throw new BusinessException($"Product '{movement.ProductId}' does not have sufficient quantity on hand.").WithErrorCode("insufficient-quantity").WithKey(movement.ProductId); + + await _ef.Inventory.UpdateAsync(args, inventoryItem).ConfigureAwait(false); + } + else + { + if (movement.MovementKindCode == Contracts.MovementKind.Issue) + throw new BusinessException($"Product '{movement.ProductId}' does not have sufficient quantity on hand.").WithErrorCode("insufficient-quantity").WithKey(movement.ProductId); + + var newInventoryItem = new Persistence.Inventory + { + Id = movement.ProductId, + QtyOnHand = movement.Quantity + }; + + await _ef.Inventory.CreateAsync(args, newInventoryItem).ConfigureAwait(false); + } + } + + /// + public async Task> ConfirmAsync(string referenceId) + { + // Get all pending movements for the reference identifier. + var movements = await _ef.Movements.QueryTracked().Where(m => m.ReferenceId == referenceId && m.MovementStatusCode == Contracts.MovementStatus.Pending).ToListAsync().ConfigureAwait(false); + if (movements.Count == 0) + return []; + + // Update the movement status to confirmed for all movements; no inventory adjustment is needed as the inventory was already adjusted during creation. + var args = new EfDbArgs { SaveChanges = false }; + foreach (var movement in movements) + { + movement.MovementStatusCode = Contracts.MovementStatus.Confirmed; + await _ef.Movements.UpdateAsync(args, movement).ConfigureAwait(false); + } + + // Save changes and return the mutated movements. + return await SaveAndGetAllMutatedMovements().ConfigureAwait(false); + } + + /// + public async Task> CancelAsync(string referenceId) + { + // Get all pending movements for the reference identifier. + var movements = await _ef.Movements.QueryTracked().Where(m => m.ReferenceId == referenceId && m.MovementStatusCode == Contracts.MovementStatus.Pending).ToListAsync().ConfigureAwait(false); + if (movements.Count == 0) + return []; + + // Get the related inventory ready to adjust. + var ids = movements.Select(m => m.ProductId).Distinct(); + var inventoryItems = await _ef.Inventory.QueryTracked().Where(i => ids.Contains(i.Id)).ToDictionaryAsync(i => i.Id!, i => i).ConfigureAwait(false); + + // Update the movement status to cancelled and adjust inventory back for all movements. + var args = new EfDbArgs { SaveChanges = false }; + foreach (var movement in movements) + { + // Create a fake movement with opposite quantity to adjust the inventory back. + var fakeMovement = new Persistence.Movement + { + ProductId = movement.ProductId, + Quantity = -movement.Quantity, + MovementKindCode = CreateReversalMovementKind(movement.MovementKindCode) + }; + + // Reverse (adjust back) the inventory for the movement. + await InventoryAdjustAsync(args, inventoryItems, fakeMovement).ConfigureAwait(false); + + // Update movement status to cancelled. + movement.MovementStatusCode = Contracts.MovementStatus.Canceled; + await _ef.Movements.UpdateAsync(args, movement).ConfigureAwait(false); + } + + // Save changes and return the mutated movements. + return await SaveAndGetAllMutatedMovements().ConfigureAwait(false); + } + + /// + /// Create a reversal movement kind for the given movement kind. + /// + private static string CreateReversalMovementKind(string? kind) => kind switch + { + Contracts.MovementKind.Issue => Contracts.MovementKind.Receive, + Contracts.MovementKind.Receive => Contracts.MovementKind.Issue, + _ => throw new InvalidOperationException($"Unsupported movement kind: {kind}") + }; + + /// + /// Saves all the changes and returns the mutated movements mapping to the contract version. + /// + private async Task> SaveAndGetAllMutatedMovements() + { + // Save all changes. + await _ef.DbContext.SaveChangesAsync().ConfigureAwait(false); + + // Return all mutated movements from the change tracker. + return [.. _ef.DbContext.ChangeTracker.Entries().Select(m => MovementMapper.From.Map(m.Entity))]; + } + + /// + public async Task GetAsync(string referenceId) + { + var movements = await _ef.Movements.Query().Where(m => m.ReferenceId == referenceId).OrderBy(m => m.Id).ToArrayAsync().ConfigureAwait(false); + return movements is not null ? [.. movements.Select(m => MovementMapper.From.Map(m))] : []; + } + + /// + public Task QuerySchemaAsync() => Task.FromResult(_queryConfig.ToJsonSchema()); + + /// + public async Task> QueryAsync(QueryArgs? query, PagingArgs? paging) + { + var parsed = _queryConfig.Parse(query).ThrowOnError(); + var movements = _ef.Movements.Query(); + var q = from m in movements select m; + return await q.Where(parsed).OrderBy(parsed).ToMappedItemsResultAsync(m => MovementMapper.From.Map(m), paging).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/ProductRepository.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductRepository.cs new file mode 100644 index 00000000..e84dedac --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductRepository.cs @@ -0,0 +1,90 @@ +namespace Contoso.Products.Infrastructure.Repositories; + +[ScopedService] +public class ProductRepository(ProductsEfDb ef) : IProductRepository +{ + private readonly ProductsEfDb _ef = ef.ThrowIfNull(); + + private readonly static QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(filter => filter + .WithDefaultModelPrefix("Product") + .AddField(nameof(Contracts.ProductBase.Sku), c => c.WithOperators(QueryFilterOperator.EqualityOperators | QueryFilterOperator.StartsWith).AsUpperCase()) + .AddField(nameof(Contracts.ProductBase.Text), c => c.WithOperators(QueryFilterOperator.StringFunctions).AsUpperCase()) + .AddReferenceDataField(nameof(Contracts.ProductBase.Category), "CategoryCode", c => c.WithModelPrefix(null)) + .AddReferenceDataField(nameof(Contracts.ProductBase.SubCategory), "SubCategoryCode") + .AddReferenceDataField(nameof(Contracts.ProductBase.Brand), "BrandCode")) + .WithOrderBy(orderby => orderby + .WithDefaultModelPrefix("Product") + .AddField(nameof(Contracts.ProductBase.Sku), c => c.WithDefault().WithAlwaysInclude()) + .AddField(nameof(Contracts.ProductBase.Text)) + .AddField(nameof(Contracts.ProductBase.Brand))); + + public Task GetAsync(string id) => _ef.Products.GetAsync(id); + + public Task> CreateAsync(Contracts.Product product) => _ef.Products.CreateAsync(product); + + public Task> UpdateAsync(Contracts.Product product) => _ef.Products.UpdateAsync(product); + + public Task DeleteAsync(string id) => _ef.Products.DeleteAsync(id); + + public Task QuerySchemaAsync() => Task.FromResult(_queryConfig.ToJsonSchema()); + + public async Task> QueryAsync(QueryArgs? query, PagingArgs? paging) + { + var parsed = _queryConfig.Parse(query).ThrowOnError(); + + var products = _ef.Products.Model.Query(); + if (query?.IsIncludeInactive is false) + products = products.Where(p => !p.IsInactive); + + var subcats = _ef.SubCategories.Query(); + var inventory = _ef.Inventory.Query(); + + var q = + from p in products + join sc in subcats on p.SubCategoryCode equals sc.Code into scg + from sc in scg.DefaultIfEmpty() + join i in inventory on p.Id equals i.Id into ig + from i in ig.DefaultIfEmpty() + select new + { + Product = p, + SubCategoryCode = sc.Code, + sc.CategoryCode, + QtyOnHand = i == null ? 0 : i.QtyOnHand + }; + + return await q.Where(parsed).OrderBy(parsed).ToMappedItemsResultAsync(x => new Contracts.ProductLite + { + Id = x.Product.Id, + Sku = x.Product.Sku, + Text = x.Product.Text, + CategoryCode = x.CategoryCode, + SubCategoryCode = x.SubCategoryCode, + BrandCode = x.Product.BrandCode, + UnitOfMeasureCode = x.Product.UnitOfMeasureCode, + Price = x.Product.Price, + IsInactive = x.Product.IsInactive, + IsNonStocked = x.Product.IsNonStocked, + QtyOnHand = x.QtyOnHand + }, paging); + } + + public Task> GetForReservationAsync(string[] ids) + { + var products = _ef.Products.Model.Query(); + + var q = + from p in products + where ids.Contains(p.Id) + select new Contracts.ProductReserve + { + Id = p.Id!, + UnitOfMeasureCode = p.UnitOfMeasureCode!, + IsInactive = p.IsInactive, + IsNonStocked = p.IsNonStocked + }; + + return q.ToDictionaryAsync(x => x.Id, x => x); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.cs new file mode 100644 index 00000000..036d6b27 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.cs @@ -0,0 +1,185 @@ +namespace Contoso.Products.Infrastructure.Repositories; + +public partial class ProductsDbContext(DbContextOptions options, SqlServerDatabase database) : DbContext(options), IEfDbContext +{ + /// + public IDatabase BaseDatabase { get; } = database.ThrowIfNull(); + + /// + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + // Uses IDatabase.Connection to ensure the same database/connection is used. + if (!optionsBuilder.IsConfigured) + optionsBuilder.UseSqlServer(BaseDatabase.Connection); + } + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Add the generated models to the model builder. + AddGeneratedModels(modelBuilder); + + //// Add the 'Products.Category' model configuration. + //modelBuilder.ThrowIfNull().Entity(e => + //{ + // e.ToTable("Category", "Products"); + // e.HasKey(nameof(Persistence.Category.Id)); + // e.Property(p => p.Id).HasColumnName("CategoryId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + // e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + // e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + // 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.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); + // e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + //}); + + //// Add the 'Products.SubCategory' model configuration. + //modelBuilder.ThrowIfNull().Entity(e => + //{ + // e.ToTable("SubCategory", "Products"); + // e.HasKey(nameof(Persistence.SubCategory.Id)); + // e.Property(p => p.Id).HasColumnName("SubCategoryId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + // e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + // e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + // e.Property(p => p.CategoryCode).HasColumnName("CategoryCode").HasColumnType("NVARCHAR(50)"); + // 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.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); + // e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + //}); + + //// Add the 'Products.UnitOfMeasure' model configuration. + //modelBuilder.ThrowIfNull().Entity(e => + //{ + // e.ToTable("UnitOfMeasure", "Products"); + // e.HasKey(nameof(Persistence.UnitOfMeasure.Id)); + // e.Property(p => p.Id).HasColumnName("UnitOfMeasureId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + // e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + // e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + // e.Property(p => p.Scale).HasColumnName("Scale").HasColumnType("INT"); + // 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.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); + // e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + //}); + + //// Add the 'Products.Brand' model configuration. + //modelBuilder.ThrowIfNull().Entity(e => + //{ + // e.ToTable("Brand", "Products"); + // e.HasKey(nameof(Persistence.Brand.Id)); + // e.Property(p => p.Id).HasColumnName("BrandId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + // e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + // e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + // 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.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); + // e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + //}); + + //// Add the 'Products.MovementKind' model configuration. + //modelBuilder.ThrowIfNull().Entity(e => + //{ + // e.ToTable("MovementKind", "Products"); + // e.HasKey(nameof(Persistence.MovementKind.Id)); + // e.Property(p => p.Id).HasColumnName("MovementKindId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + // e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + // e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + // 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.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); + // e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + //}); + + //// Add the 'Products.MovementStatus' model configuration. + //modelBuilder.ThrowIfNull().Entity(e => + //{ + // e.ToTable("MovementStatus", "Products"); + // e.HasKey(nameof(Persistence.MovementStatus.Id)); + // e.Property(p => p.Id).HasColumnName("MovementStatusId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + // e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + // e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + // 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.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); + // e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + //}); + + //// Add the 'Products.Product' model configuration. + //modelBuilder.ThrowIfNull().Entity(e => + //{ + // e.ToTable("Product", "Products"); + // e.HasKey(nameof(Persistence.Product.Id)); + // e.Property(p => p.Id).HasColumnName("ProductId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Sku).HasColumnName("Sku").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + // e.Property(p => p.SubCategoryCode).HasColumnName("SubCategoryCode").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.BrandCode).HasColumnName("BrandCode").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.UnitOfMeasureCode).HasColumnName("UnitOfMeasureCode").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Price).HasColumnName("Price").HasColumnType("DECIMAL(18, 2)"); + // e.Property(p => p.IsInactive).HasColumnName("IsInactive").HasColumnType("BIT"); + // e.Property(p => p.IsNonStocked).HasColumnName("IsNonStocked").HasColumnType("BIT"); + // 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"); + // e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); + //}); + + //// Add the 'Products.Inventory' model configuration. + //modelBuilder.ThrowIfNull().Entity(e => + //{ + // e.ToTable("Inventory", "Products"); + // e.HasKey(nameof(Persistence.Inventory.Id)); + // e.Property(p => p.Id).HasColumnName("InventoryId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.QtyOnHand).HasColumnName("QtyOnHand").HasColumnType("DECIMAL(18, 2)"); + // e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + //}); + + //// Add the 'Products.Movement' model configuration. + //modelBuilder.ThrowIfNull().Entity(e => + //{ + // e.ToTable("Movement", "Products"); + // e.HasKey(nameof(Persistence.Movement.Id)); + // e.Property(p => p.Id).HasColumnName("MovementId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.ReferenceId).HasColumnName("ReferenceId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.MovementKindCode).HasColumnName("MovementKindCode").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.MovementStatusCode).HasColumnName("MovementStatusCode").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.ProductId).HasColumnName("ProductId").HasColumnType("NVARCHAR(50)"); + // e.Property(p => p.Quantity).HasColumnName("Quantity").HasColumnType("DECIMAL(18, 2)"); + // e.Property(p => p.UnitOfMeasureCode).HasColumnName("UnitOfMeasureCode").HasColumnType("NVARCHAR(50)"); + // 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.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + //}); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs new file mode 100644 index 00000000..c24d3e15 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs @@ -0,0 +1,183 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Products.Infrastructure.Repositories; + +public partial class ProductsDbContext +{ + /// + /// Adds the generated models to the . + /// + /// The . + public void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder) + { + // Add the entity/model configuration for the [Products].[Brand] database table. + modelBuilder.Entity(e => + { + e.ToTable("Brand", "Products"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("BrandId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + }); + + // Add the entity/model configuration for the [Products].[Category] database table. + modelBuilder.Entity(e => + { + e.ToTable("Category", "Products"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("CategoryId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + }); + + // Add the entity/model configuration for the [Products].[MovementKind] database table. + modelBuilder.Entity(e => + { + e.ToTable("MovementKind", "Products"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("MovementKindId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + }); + + // Add the entity/model configuration for the [Products].[MovementStatus] database table. + modelBuilder.Entity(e => + { + e.ToTable("MovementStatus", "Products"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("MovementStatusId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + }); + + // Add the entity/model configuration for the [Products].[SubCategory] database table. + modelBuilder.Entity(e => + { + e.ToTable("SubCategory", "Products"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("SubCategoryId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + e.Property(p => p.CategoryCode).HasColumnName("CategoryCode").HasColumnType("NVARCHAR(50)"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + }); + + // Add the entity/model configuration for the [Products].[UnitOfMeasure] database table. + modelBuilder.Entity(e => + { + e.ToTable("UnitOfMeasure", "Products"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("UnitOfMeasureId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + e.Property(p => p.Scale).HasColumnName("Scale").HasColumnType("INT"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + }); + + // Add the entity/model configuration for the [Products].[Inventory] database table. + modelBuilder.Entity(e => + { + e.ToTable("Inventory", "Products"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("InventoryId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.QtyOnHand).HasColumnName("QtyOnHand").HasColumnType("DECIMAL(18, 2)"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.CreatedBy).Ignore(p => p.CreatedOn).Ignore(p => p.UpdatedBy).Ignore(p => p.UpdatedOn); + }); + + // Add the entity/model configuration for the [Products].[Movement] database table. + modelBuilder.Entity(e => + { + e.ToTable("Movement", "Products"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("MovementId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.ReferenceId).HasColumnName("ReferenceId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.MovementKindCode).HasColumnName("MovementKindCode").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.MovementStatusCode).HasColumnName("MovementStatusCode").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.ProductId).HasColumnName("ProductId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Quantity).HasColumnName("Quantity").HasColumnType("DECIMAL(18, 2)"); + e.Property(p => p.UnitOfMeasureCode).HasColumnName("UnitOfMeasureCode").HasColumnType("NVARCHAR(50)"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + }); + + // Add the entity/model configuration for the [Products].[Product] database table. + modelBuilder.Entity(e => + { + e.ToTable("Product", "Products"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("ProductId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Sku).HasColumnName("Sku").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.SubCategoryCode).HasColumnName("SubCategoryCode").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.UnitOfMeasureCode).HasColumnName("UnitOfMeasureCode").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.BrandCode).HasColumnName("BrandCode").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Price).HasColumnName("Price").HasColumnType("DECIMAL(18, 2)"); + e.Property(p => p.IsInactive).HasColumnName("IsInactive").HasColumnType("BIT"); + e.Property(p => p.IsNonStocked).HasColumnName("IsNonStocked").HasColumnType("BIT"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Property(p => p.IsDeleted).HasColumnName("IsDeleted").HasColumnType("BIT"); + }); + } +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsEfDb.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsEfDb.cs new file mode 100644 index 00000000..57f15b09 --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsEfDb.cs @@ -0,0 +1,25 @@ +namespace Contoso.Products.Infrastructure.Repositories; + +public sealed class ProductsEfDb(ProductsDbContext dbContext) : EfDb(dbContext, _options) +{ + private static readonly EfDbOptions _options = new EfDbOptions() + .WithModel(m => m.WithLogicalDeleteFilter()); + + public EfDbModel Categories => Model(); + + public EfDbModel SubCategories => Model(); + + public EfDbModel UnitsOfMeasure => Model(); + + public EfDbModel Brands => Model(); + + public EfDbModel MovementKinds => Model(); + + public EfDbModel MovementStatuses => Model(); + + public EfDbMappedModel Products => Model().ToMappedModel(ProductMapper.Default); + + public EfDbModel Inventory => Model(); + + public EfDbModel Movements => Model(); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/ReferenceDataRepository.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/ReferenceDataRepository.cs new file mode 100644 index 00000000..610588ca --- /dev/null +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/ReferenceDataRepository.cs @@ -0,0 +1,25 @@ +namespace Contoso.Products.Infrastructure.Repositories; + +[ScopedService] +public class ReferenceDataRepository(ProductsEfDb ef) : IReferenceDataRepository +{ + private readonly ProductsEfDb _ef = ef.ThrowIfNull(); + + public Task GetAllCategoriesAsync() + => _ef.Categories.Query().ToMappedItemsAsync(CategoryMapper.From); + + public Task GetAllSubCategoriesAsync() + => _ef.SubCategories.Query().ToMappedItemsAsync(SubCategoryMapper.From); + + public Task GetAllUnitsOfMeasureAsync() + => _ef.UnitsOfMeasure.Query().ToMappedItemsAsync(UnitOfMeasureMapper.From); + + public Task GetAllBrandsAsync() + => _ef.Brands.Query().ToMappedItemsAsync(BrandMapper.From); + + public Task GetAllMovementKindsAsync() + => _ef.MovementKinds.Query().ToMappedItemsAsync(MovementKindMapper.From); + + public Task GetAllMovementStatusesAsync() + => _ef.MovementStatuses.Query().ToMappedItemsAsync(MovementStatusMapper.From); +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Outbox.Relay/Contoso.Products.Outbox.Relay.csproj b/samples/src/Contoso.Products.Outbox.Relay/Contoso.Products.Outbox.Relay.csproj new file mode 100644 index 00000000..3a855c5e --- /dev/null +++ b/samples/src/Contoso.Products.Outbox.Relay/Contoso.Products.Outbox.Relay.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/src/Contoso.Products.Outbox.Relay/Contoso.Products.Outbox.Relay.http b/samples/src/Contoso.Products.Outbox.Relay/Contoso.Products.Outbox.Relay.http new file mode 100644 index 00000000..5ce10ff5 --- /dev/null +++ b/samples/src/Contoso.Products.Outbox.Relay/Contoso.Products.Outbox.Relay.http @@ -0,0 +1,16 @@ +@Contoso.Products.Outbox.Relay_HostAddress = https://localhost:7242 + +GET {{Contoso.Products.Outbox.Relay_HostAddress}}/hosted-services/sqlserver-outbox-relay-00/status +Accept: application/json + +### + +POST {{Contoso.Products.Outbox.Relay_HostAddress}}/hosted-services/sqlserver-outbox-relay-00/pause + +### + +POST {{Contoso.Products.Outbox.Relay_HostAddress}}/hosted-services/sqlserver-outbox-relay-00/resume + +### + +GET {{Contoso.Products.Outbox.Relay_HostAddress}}/health/ready/detailed \ No newline at end of file diff --git a/samples/src/Contoso.Products.Outbox.Relay/Program.cs b/samples/src/Contoso.Products.Outbox.Relay/Program.cs new file mode 100644 index 00000000..97c7c846 --- /dev/null +++ b/samples/src/Contoso.Products.Outbox.Relay/Program.cs @@ -0,0 +1,64 @@ +using CoreEx.Azure.Messaging.ServiceBus; +using OpenTelemetry; +using OpenTelemetry.Trace; + +namespace Contoso.Products.Outbox.Relay; + +public class Program +{ + private static void Main(string[] args) + { + // Create the web builder. + var builder = WebApplication.CreateBuilder(args); + + // Add CoreEx host settings. + builder.AddHostSettings(); + + // Add CoreEx services. + builder.Services + .AddExecutionContext() + .AddMvcWebApi() + .AddHttpWebApi() + .AddHostedServiceManager(); + + // Add the repository and related outbox services. + builder.AddSqlServerClient("SqlServer"); // Adds the SqlServerClient (using Aspire library). + builder.Services + .AddSqlServerDatabase() // Adds the SqlServerDatabase. + .AddSqlServerUnitOfWork() // Adds the SqlServerUnitOfWork for the SqlServerDatabase. + .AddSqlServerOutboxRelay(); // Adds the SqlServerOutboxRelay. + + // Adds the SqlServerOutboxRelayHostedService. + builder.AddSqlServerOutboxRelayHostedService(); + + // Add the Azure Service Bus services. + builder.AddAzureServiceBusClient("ServiceBus"); // Adds azure service bus client using aspire. + builder.Services.AddAzureServiceBusPublisher((_, c) => // Adds the service bus as the IEventPublisher. + { + c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; // Use a partition-id as the session-id. + }); + + // Post-configure all health-checks; adds the standard tags. + builder.Services.PostConfigureAllHealthChecks(); + + // Add OpenTelemetry tracing. + builder.WithCoreExTelemetry() + .WithCoreExSqlServerTelemetry() + .WithCoreExServiceBusTelemetry() + .UseOtlpExporter(); + + // Build the application. + var app = builder.Build(); + + // Configure the pipeline/middleware (order is important). + app.UseCoreExExceptionHandler(); + app.UseHttpsRedirection(); + app.UseExecutionContext(); + + app.MapHealthChecks(); + app.MapHostedServices(); + + // Run the application. + app.Run(); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Outbox.Relay/Properties/launchSettings.json b/samples/src/Contoso.Products.Outbox.Relay/Properties/launchSettings.json new file mode 100644 index 00000000..eb47a95e --- /dev/null +++ b/samples/src/Contoso.Products.Outbox.Relay/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5117", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7242;http://localhost:5117", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/src/Contoso.Products.Outbox.Relay/appsettings.Development.json b/samples/src/Contoso.Products.Outbox.Relay/appsettings.Development.json new file mode 100644 index 00000000..b2a2cccf --- /dev/null +++ b/samples/src/Contoso.Products.Outbox.Relay/appsettings.Development.json @@ -0,0 +1,27 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Azure": "Warning", + "Microsoft": "Warning", + "ZiggyCreature": "Warning", + "StackExchange": "Warning" + } + }, + "Aspire": { + "Microsoft": { + "Data": { + "SqlClient": { + "ConnectionString": "Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" + } + } + }, + "Azure": { + "Messaging": { + "ServiceBus": { + "ConnectionString": "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;" + } + } + } + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Outbox.Relay/appsettings.json b/samples/src/Contoso.Products.Outbox.Relay/appsettings.json new file mode 100644 index 00000000..9cda3b36 --- /dev/null +++ b/samples/src/Contoso.Products.Outbox.Relay/appsettings.json @@ -0,0 +1,25 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "Contoso", + "DomainName": "Products", + "Services": { + "Interval": "00:00:00.500", + "OutboxRelay": { + "BatchSize": 10, + "PerWorkerPartitionCount": 2, + "LeaseDuration": "00:00:05", + "BackoffDuration": "00:00:05", + "ServicesCount": 4 + } + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Subscribe/Contoso.Products.Subscribe.csproj b/samples/src/Contoso.Products.Subscribe/Contoso.Products.Subscribe.csproj new file mode 100644 index 00000000..38937afc --- /dev/null +++ b/samples/src/Contoso.Products.Subscribe/Contoso.Products.Subscribe.csproj @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/src/Contoso.Products.Subscribe/Contoso.Products.Subscribe.http b/samples/src/Contoso.Products.Subscribe/Contoso.Products.Subscribe.http new file mode 100644 index 00000000..e539ade1 --- /dev/null +++ b/samples/src/Contoso.Products.Subscribe/Contoso.Products.Subscribe.http @@ -0,0 +1,12 @@ +@Contoso.Products.Subscribe_HostAddress = http://localhost:5201 + +GET {{Contoso.Products.Subscribe_HostAddress}}/hosted-services/azure-service-bus-receiver/status +Accept: application/json + +### + +POST {{Contoso.Products.Subscribe_HostAddress}}/hosted-services/azure-service-bus-receiver/pause + +### + +POST {{Contoso.Products.Subscribe_HostAddress}}/hosted-services/azure-service-bus-receiver/resume \ No newline at end of file diff --git a/samples/src/Contoso.Products.Subscribe/GlobalUsing.cs b/samples/src/Contoso.Products.Subscribe/GlobalUsing.cs new file mode 100644 index 00000000..b9a20590 --- /dev/null +++ b/samples/src/Contoso.Products.Subscribe/GlobalUsing.cs @@ -0,0 +1,8 @@ +global using Contoso.Products.Application.Interfaces; +global using CoreEx; +global using CoreEx.DependencyInjection; +global using CoreEx.Events; +global using CoreEx.Events.Subscribing; +global using CoreEx.Json; +global using CoreEx.Results; +global using CoreEx.Validation; \ No newline at end of file diff --git a/samples/src/Contoso.Products.Subscribe/Program.cs b/samples/src/Contoso.Products.Subscribe/Program.cs new file mode 100644 index 00000000..99224e7e --- /dev/null +++ b/samples/src/Contoso.Products.Subscribe/Program.cs @@ -0,0 +1,117 @@ +using Contoso.Products.Application; +using Contoso.Products.Infrastructure.Repositories; +using Contoso.Products.Subscribe.Subscribers; +using CoreEx.Azure.Messaging.ServiceBus; +using Microsoft.Extensions.Options; +using OpenTelemetry; +using OpenTelemetry.Trace; +using StackExchange.Redis; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; + +namespace Contoso.Products.Subscribe; + +public class Program +{ + private static void Main(string[] args) + { + // Create the web builder. + var builder = WebApplication.CreateBuilder(args); + + // Add CoreEx host settings. + builder.AddHostSettings(); + + // Add CoreEx services. + builder.Services + .AddExecutionContext() + .AddReferenceDataOrchestrator() + .AddMvcWebApi() + .AddHttpWebApi() + .AddHostedServiceManager(); + + // Add all the dynamically registered services. + builder.Services.AddDynamicServicesUsing(); + + // Add L1/L2 caching services. + builder.Services.AddMemoryCache(); // Adds the in-memory cache - L1. + builder.AddRedisDistributedCache("redis"); // Adds Redis as the distributed cache (using Aspire library) - L2. + + // Add and wire-up FusionCache including backplane. + builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = sp.GetRequiredService>().Value.ToString() })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); + + // Add CoreEx caching services. + builder.Services + .AddFusionHybridCache() // Adds the CoreEx.Caching.IHybridCache for FusionCache. + .AddDefaultCacheKeyProvider() // Adds the default CoreEx.Caching.ICacheKeyProvider. + .AddHybridCacheIdempotencyProvider(); // Adds the CoreEx.Caching.Idempotency.IIdempotencyProvider. + + // Add the repository and related outbox services. + builder.AddSqlServerClient("SqlServer"); // Adds the SqlServerClient (using Aspire library). + builder.Services + .AddSqlServerDatabase() // Adds the SqlServerDatabase. + .AddSqlServerUnitOfWork() // Adds the SqlServerUnitOfWork for the SqlServerDatabase. + .AddEventFormatter() // Adds the EventFormatter to enable message formatting for publishing. + .AddSqlServerOutboxPublisher() // Adds the ProductsOutboxPublisher as the SqlServerOutboxPublisher/IEventPublisher. + .AddDbContext() // Adds the standard EF DbContext. + .AddEfDb(); // Adds the CoreEx extended EF service. + + // Add Azure Service Bus client using Aspire. + builder.AddAzureServiceBusClient("ServiceBus"); + + // Add event formatter and subscribed-manager. + builder.Services.AddSubscribedManager((_, c) => c.AddSubscribersUsing()); // Adds the SubscribedManager and dynamically links to the individual Subscribers. + + // Builds and creates the Azure Service Bus receiving services. + builder.Services.AzureServiceBusReceiving() + .WithSessionReceiver(_ => + { // Adds the service bus receiver to pump messages to the subscriber. + var o = ServiceBusSessionReceiverOptions.CreateForTopicSubscription(); // Set the topic and subscription from configuration. + o.SessionProcessorOptions.MaxConcurrentSessions = 4; // Set the maximum number of concurrent sessions to process. + return o; + }) + .WithSubscribedSubscriber() // Adds the service bus subscriber using the ^ SubscribedManager. + .WithHostedService() // Adds the ^ service bus receiver as a hosted service. + .Build(); // Builds all the ^ services and adds to the service collection. + + // Post-configure all health-checks; adds the standard tags. + builder.Services.PostConfigureAllHealthChecks(); + + // Add the ASP.NET Core services. + builder.Services.AddControllers(); + + // Add the OpenAPI services. + builder.Services.AddOpenApiDocument(s => + { + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); + }); + + // Add OpenTelemetry tracing. + builder.WithCoreExTelemetry() + .WithCoreExServiceBusTelemetry() + .WithCoreExSqlServerTelemetry() + .UseOtlpExporter(); + + // Build the application. + var app = builder.Build(); + + // Configure the pipeline/middleware (order is important). + app.UseCoreExExceptionHandler(); + app.UseHttpsRedirection(); + app.UseAuthorization(); + app.UseExecutionContext(); + app.MapControllers(); + + app.UseOpenApi(); + app.UseSwaggerUi(); + app.MapHealthChecks(); + app.MapHostedServices(); + + // Run the application. + app.Run(); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Subscribe/Properties/launchSettings.json b/samples/src/Contoso.Products.Subscribe/Properties/launchSettings.json new file mode 100644 index 00000000..e0e0e30f --- /dev/null +++ b/samples/src/Contoso.Products.Subscribe/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5201", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7110;http://localhost:5201", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/src/Contoso.Products.Subscribe/Subscribers/ReservationCancelSubscriber.cs b/samples/src/Contoso.Products.Subscribe/Subscribers/ReservationCancelSubscriber.cs new file mode 100644 index 00000000..1f231c9f --- /dev/null +++ b/samples/src/Contoso.Products.Subscribe/Subscribers/ReservationCancelSubscriber.cs @@ -0,0 +1,20 @@ +namespace Contoso.Products.Subscribe.Subscribers; + +[ScopedService, Subscribe("contoso.products.reservation.cancel")] +public class ReservationCancelSubscriber : SubscribedBase +{ + private readonly IMovementService _service; + + public ReservationCancelSubscriber(IMovementService service) + { + _service = service.ThrowIfNull(); + ErrorHandler = ReservationConfirmSubscriber.DefaultErrorHandler; + } + + protected async override Task OnReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) + { + var referenceId = @event.Key.Required(); + await _service.CancelReservationAsync(referenceId).ConfigureAwait(false); + return Result.Success; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Subscribe/Subscribers/ReservationConfirmSubscriber.cs b/samples/src/Contoso.Products.Subscribe/Subscribers/ReservationConfirmSubscriber.cs new file mode 100644 index 00000000..d6eeae2e --- /dev/null +++ b/samples/src/Contoso.Products.Subscribe/Subscribers/ReservationConfirmSubscriber.cs @@ -0,0 +1,25 @@ +namespace Contoso.Products.Subscribe.Subscribers; + +[ScopedService, Subscribe("contoso.products.reservation.confirm")] +public class ReservationConfirmSubscriber : SubscribedBase +{ + /// + /// Handle the scenario where a pending reservation is not found; possible where the reservation has expired and been removed, or was reserved with non-stocked products and thus not persisted. + /// + internal static readonly ErrorHandler DefaultErrorHandler = new ErrorHandler().Add(ex => ex.ErrorCode == "pending-reservation-not-found" ? ErrorHandling.CompleteAsInformation : null); + + private readonly IMovementService _service; + + public ReservationConfirmSubscriber(IMovementService service) + { + _service = service.ThrowIfNull(); + ErrorHandler = DefaultErrorHandler; + } + + protected async override Task OnReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) + { + var referenceId = @event.Key.Required(); + await _service.ConfirmReservationAsync(referenceId).ConfigureAwait(false); + return Result.Success; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Subscribe/appsettings.Development.json b/samples/src/Contoso.Products.Subscribe/appsettings.Development.json new file mode 100644 index 00000000..22297ca6 --- /dev/null +++ b/samples/src/Contoso.Products.Subscribe/appsettings.Development.json @@ -0,0 +1,39 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Azure": "Warning", + "Microsoft": "Warning", + "ZiggyCreature": "Warning", + "StackExchange": "Warning", + "Azure.Messaging.ServiceBus": "Critical" + } + }, + "Aspire": { + "Microsoft": { + "Data": { + "SqlClient": { + "ConnectionString": "Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" + } + } + }, + "StackExchange": { + "Redis": { + "ConnectionString": "localhost:6379", + "ConfigurationOptions": { + "ConnectTimeout": 3000, + "ConnectRetry": 2 + } + } + }, + "Azure": { + "Messaging": { + "ServiceBus": { + "ConnectionString": "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;", + "QueueOrTopicName": "contoso", + "SubscriptionName": "products" + } + } + } + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Products.Subscribe/appsettings.json b/samples/src/Contoso.Products.Subscribe/appsettings.json new file mode 100644 index 00000000..66eb72d3 --- /dev/null +++ b/samples/src/Contoso.Products.Subscribe/appsettings.json @@ -0,0 +1,21 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "Contoso", + "DomainName": "Products", + "Services": { + "Interval": "00:00:00.500" + } + }, + "Events": { + "Destination": "contoso" // Topic/queue name. + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Api/Contoso.Shopping.Api.csproj b/samples/src/Contoso.Shopping.Api/Contoso.Shopping.Api.csproj new file mode 100644 index 00000000..3ff1e23e --- /dev/null +++ b/samples/src/Contoso.Shopping.Api/Contoso.Shopping.Api.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/samples/src/Contoso.Shopping.Api/Contoso.Shopping.Api.http b/samples/src/Contoso.Shopping.Api/Contoso.Shopping.Api.http new file mode 100644 index 00000000..6bf6855e --- /dev/null +++ b/samples/src/Contoso.Shopping.Api/Contoso.Shopping.Api.http @@ -0,0 +1,6 @@ +@Contoso.Shopping.Api_HostAddress = http://localhost:5275 + +GET {{Contoso.Shopping.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/samples/src/Contoso.Shopping.Api/Controllers/BasketController.cs b/samples/src/Contoso.Shopping.Api/Controllers/BasketController.cs new file mode 100644 index 00000000..d1cab8d9 --- /dev/null +++ b/samples/src/Contoso.Shopping.Api/Controllers/BasketController.cs @@ -0,0 +1,52 @@ +namespace Contoso.Shopping.Api.Controllers; + +[ApiController, Route("/api/baskets"), OpenApiTag("Baskets")] +public class BasketController(WebApi webApi, IBasketService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly IBasketService _service = service.ThrowIfNull(); + + [OpenApiTag("Customers")] + [HttpPost("/api/customers/{customerId}/baskets")] + [IdempotencyKey] + [ProducesResponseType(typeof(Basket), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public Task CreateAsync(string customerId) => _webApi.PostWithResultAsync(Request, async (ro, _) => + { + ro.WithLocationUri(p => new Uri($"/api/baskets/{p.Id}", UriKind.Relative)); + return await _service.CreateAsync(customerId.Required()).ConfigureAwait(false); + }); + + [HttpPut("{basketId}/apply-discount/{coupon}")] + [ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public Task ApplyDiscountAsync(string basketId, string coupon) => _webApi.PutWithResultAsync(Request, (_, _) + => _service.ApplyDiscountAsync(basketId.Required(), coupon.Required())); + + [HttpPost("{basketId}/checkout")] + [ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public Task CheckoutAsync(string basketId) => _webApi.PostWithResultAsync(Request, (_, _) + => _service.CheckoutAsync(basketId.Required()), HttpStatusCode.OK); + + [HttpPost("{basketId}/items")] + [IdempotencyKey] + [Accepts] + [ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public Task ItemAddAsync(string basketId) => _webApi.PostWithResultAsync(Request, (ro, _) + => _service.ItemAddAsync(basketId.Required(), ro.Value), HttpStatusCode.OK); + + [HttpPut("{basketId}/items/{basketItemId}")] + [Accepts] + [ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public Task ItemUpdateAsync(string basketId, string basketItemId) => _webApi.PutWithResultAsync(Request, (ro, _) + => _service.ItemUpdateAsync(basketId.Required(), basketItemId.Required(), ro.Value), HttpStatusCode.OK); + + [HttpDelete("{basketId}/items/{basketItemId}")] + [ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public Task ItemDeleteAsync(string basketId, string basketItemId) => _webApi.DeleteWithResultAsync(Request, (_, _) + => _service.ItemDeleteAsync(basketId.Required(), basketItemId.Required())); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Api/Controllers/BasketReadController.cs b/samples/src/Contoso.Shopping.Api/Controllers/BasketReadController.cs new file mode 100644 index 00000000..d3ded2d6 --- /dev/null +++ b/samples/src/Contoso.Shopping.Api/Controllers/BasketReadController.cs @@ -0,0 +1,13 @@ +namespace Contoso.Shopping.Api.Controllers; + +[ApiController, Route("/api/baskets"), OpenApiTag("Baskets")] +public class BasketReadController(WebApi webApi, IBasketReadService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly IBasketReadService _service = service.ThrowIfNull(); + + [HttpGet("{basketId}"), HttpHead("{basketId}")] + [ProducesResponseType(typeof(Basket), 200)] + [ProducesNotFoundProblem()] + public Task GetAsync(string basketId) => _webApi.GetAsync(Request, (_, _) => _service.GetAsync(basketId.Required())); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Api/GlobalUsing.cs b/samples/src/Contoso.Shopping.Api/GlobalUsing.cs new file mode 100644 index 00000000..f107053f --- /dev/null +++ b/samples/src/Contoso.Shopping.Api/GlobalUsing.cs @@ -0,0 +1,13 @@ +global using Contoso.Shopping.Application; +global using Contoso.Shopping.Application.Interfaces; +global using Contoso.Shopping.Contracts; +global using CoreEx; +global using CoreEx.AspNetCore.Mvc; +global using CoreEx.Http; +global using CoreEx.Json; +global using CoreEx.RefData; +global using CoreEx.Validation; +global using Microsoft.AspNetCore.Mvc; +global using NSwag.Annotations; +global using System.Net; +global using System.Text.Json; \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Api/Program.cs b/samples/src/Contoso.Shopping.Api/Program.cs new file mode 100644 index 00000000..f178da4f --- /dev/null +++ b/samples/src/Contoso.Shopping.Api/Program.cs @@ -0,0 +1,108 @@ +using Contoso.Shopping.Infrastructure.Clients; +using Contoso.Shopping.Infrastructure.Repositories; +using CoreEx.Azure.Messaging.ServiceBus; +using CoreEx.Database.SqlServer.Outbox; +using CoreEx.Events.Publishing; +using Microsoft.Extensions.Options; +using OpenTelemetry; +using OpenTelemetry.Trace; +using StackExchange.Redis; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; + +namespace Contoso.Shopping.Api; + +public class Program +{ + private static void Main(string[] args) + { + // Create the web builder. + var builder = WebApplication.CreateBuilder(args); + + // Add CoreEx host settings. + builder.AddHostSettings(); + + // Add CoreEx services. + builder.Services + .AddExecutionContext() + .AddReferenceDataOrchestrator() + .AddMvcWebApi() + .AddHttpWebApi(); + + // Add all the dynamically registered services. + builder.Services.AddDynamicServicesUsing(); + + // Add L1/L2 caching services. + builder.Services.AddMemoryCache(); // Adds the in-memory cache - L1. + builder.AddRedisDistributedCache("redis"); // Adds Redis as the distributed cache (using Aspire library) - L2. + + // Add and wire-up FusionCache including backplane. + builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = sp.GetRequiredService>().Value.ToString() })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); + + // Add CoreEx caching services. + builder.Services + .AddFusionHybridCache() // Adds the CoreEx.Caching.IHybridCache for FusionCache. + .AddDefaultCacheKeyProvider() // Adds the default CoreEx.Caching.ICacheKeyProvider. + .AddHybridCacheIdempotencyProvider(); // Adds the CoreEx.Caching.Idempotency.IIdempotencyProvider. + + // Add the repository and related outbox services. + builder.AddSqlServerClient("SqlServer"); // Adds the SqlServerClient (using Aspire library). + builder.Services + .AddSqlServerDatabase() // Adds the SqlServerDatabase. + .AddSqlServerUnitOfWork() // Adds the SqlServerUnitOfWork for the SqlServerDatabase. + .AddEventFormatter() // Adds the EventFormatter to enable message formatting for publishing. + .AddSqlServerOutboxPublisher() // Adds the SqlServerOutboxPublisher/IEventPublisher. + .AddDbContext() // Adds the standard EF DbContext. + .AddEfDb(); // Adds the CoreEx extended EF service. + + // Add the Azure Service Bus services. + builder.AddAzureServiceBusClient("ServiceBus"); // Adds azure service bus client using aspire. + builder.Services.AddAzureServiceBusPublisher((_, c) => // Adds the service bus as the IEventPublisher. + { + c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; // Use a partition-id as the session-id. + }, addAsDefaultIEventPublisher: false); + + // Add external API services. + builder.AddTypedHttpClient("ProductsApi"); + + // Post-configure all health-checks; adds the standard tags. + builder.Services.PostConfigureAllHealthChecks(); + + // Add the ASP.NET Core services. + builder.Services.AddControllers(); + + // Add the OpenAPI services. + builder.Services.AddOpenApiDocument(s => + { + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); + }); + + // Add OpenTelemetry tracing. + builder.WithCoreExTelemetry() + .WithCoreExSqlServerTelemetry() + .UseOtlpExporter(); + + // Build the application. + var app = builder.Build(); + + // Configure the pipeline/middleware (order is important). + app.UseCoreExExceptionHandler(); + app.UseHttpsRedirection(); + app.UseAuthorization(); + app.UseExecutionContext(); + app.UseIdempotencyKey(); + app.MapControllers(); + + app.UseOpenApi(); + app.UseSwaggerUi(); + app.MapHealthChecks(); + + // Run the application. + app.Run(); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Api/Properties/launchSettings.json b/samples/src/Contoso.Shopping.Api/Properties/launchSettings.json new file mode 100644 index 00000000..946ed798 --- /dev/null +++ b/samples/src/Contoso.Shopping.Api/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5275" + }, + "https": { + "commandName": "Project", + "launchUrl": "https://localhost:7219/swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7219;http://localhost:5275" + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Api/appsettings.Development.json b/samples/src/Contoso.Shopping.Api/appsettings.Development.json new file mode 100644 index 00000000..72654558 --- /dev/null +++ b/samples/src/Contoso.Shopping.Api/appsettings.Development.json @@ -0,0 +1,41 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Azure": "Warning", + "Microsoft": "Warning", + "ZiggyCreature": "Warning", + "StackExchange": "Warning", + "System.Net.Http.HttpClient": "Warning", + "Polly": "Warning" + } + }, + "Aspire": { + "Microsoft": { + "Data": { + "SqlClient": { + "ConnectionString": "Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" + } + } + }, + "StackExchange": { + "Redis": { + "ConnectionString": "localhost:6379", + "ConfigurationOptions": { + "ConnectTimeout": 3000, + "ConnectRetry": 2 + } + } + }, + "Azure": { + "Messaging": { + "ServiceBus": { + "ConnectionString": "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;" + } + } + } + }, + "ProductsApi": { + "BaseAddress": "https://localhost:7200" + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Api/appsettings.json b/samples/src/Contoso.Shopping.Api/appsettings.json new file mode 100644 index 00000000..6c943a3c --- /dev/null +++ b/samples/src/Contoso.Shopping.Api/appsettings.json @@ -0,0 +1,18 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "Contoso", + "DomainName": "Shopping" + }, + "Events": { + "Destination": "contoso" // Topic/queue name. + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Adapters/Products/IProductAdapter.cs b/samples/src/Contoso.Shopping.Application/Adapters/Products/IProductAdapter.cs new file mode 100644 index 00000000..19139073 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Adapters/Products/IProductAdapter.cs @@ -0,0 +1,27 @@ +namespace Contoso.Shopping.Application.Adapters.Products; + +/// +/// Enables the Products domain integration, serving as the external dependency boundary (anti-corruption layer) for product-related operations. +/// +public interface IProductAdapter +{ + /// + /// Gets the product. + /// + Task> GetAsync(string id); + + /// + /// Reserves the inventory (product * quantity) per basket item. + /// + Task ReserveInventoryAsync(Domain.Basket basket); + + /// + /// Confirms the reservation(s) for the basket. + /// + Task CreateConfirmReservationCommand(Domain.Basket basket); + + /// + /// Cancels the reservation(s) for the basket. + /// + Task CancelReservationAsync(Domain.Basket basket); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Adapters/Products/IProductSyncAdapter.cs b/samples/src/Contoso.Shopping.Application/Adapters/Products/IProductSyncAdapter.cs new file mode 100644 index 00000000..1eef38e0 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Adapters/Products/IProductSyncAdapter.cs @@ -0,0 +1,17 @@ +namespace Contoso.Shopping.Application.Adapters.Products; + +/// +/// Enables the Products-domain data synchronization operations via evet-based subscriptions. +/// +public interface IProductSyncAdapter +{ + /// + /// Modifies (creates/updates) the product. + /// + Task ModifyAsync(Product product); + + /// + /// Deletes the replicated product. + /// + Task DeleteAsync(string id); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Adapters/Products/Product.cs b/samples/src/Contoso.Shopping.Application/Adapters/Products/Product.cs new file mode 100644 index 00000000..774d9f38 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Adapters/Products/Product.cs @@ -0,0 +1,20 @@ +namespace Contoso.Shopping.Application.Adapters.Products; + +[Contract] +public partial class Product : IIdentifier +{ + public string? Id { get; set; } + + public string? Sku { get; set => field = value?.ToUpper(); } + + public string? Text { get; set; } + + [ReferenceData()] + public partial string? UnitOfMeasureCode { get; set; } + + public decimal Price { get; set; } + + public bool IsInactive { get; set; } + + public bool IsNonStocked { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/BasketReadService.cs b/samples/src/Contoso.Shopping.Application/BasketReadService.cs new file mode 100644 index 00000000..da95a770 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/BasketReadService.cs @@ -0,0 +1,12 @@ +namespace Contoso.Shopping.Application; + +[ScopedService] +public class BasketReadService(IBasketRepository repository) : IBasketReadService +{ + private readonly IBasketRepository _repository = repository.ThrowIfNull(); + + /// + public Task> GetAsync(string basketId) + => Result.GoAsync(() => _repository.GetAsync(basketId)) + .ThenAs(b => BasketMapper.Map(b)); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/BasketService.cs b/samples/src/Contoso.Shopping.Application/BasketService.cs new file mode 100644 index 00000000..9f4c459a --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/BasketService.cs @@ -0,0 +1,160 @@ +namespace Contoso.Shopping.Application; + +[ScopedService] +public class BasketService(IUnitOfWork unitOfWork, IBasketRepository repository, IProductAdapter productAdapter, ILogger logger) : IBasketService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork.ThrowIfNull(); + private readonly IBasketRepository _repository = repository.ThrowIfNull(); + private readonly IProductAdapter _productAdapter = productAdapter.ThrowIfNull(); + private readonly ILogger _logger = logger.ThrowIfNull(); + + /// + public Task> CreateAsync(string customerId) + { + // No validation for customerId is performed here, but could be added as required (e.g. check for valid format, or existence of the customer in the system). + + // Create the aggregate representation. + var aggregate = Domain.Basket.CreateNew(customerId.ThrowIfNullOrEmpty()); + + // Orchestrate the creation of the basket within a transaction, ensuring that any events are only published if the transaction is successful. + return _unitOfWork.ExecuteAsync(async () => + { + // Create the aggregate in the repository, which will return the created aggregate with any updates (e.g. Id). + var br = await _repository.CreateAsync(aggregate).ConfigureAwait(false); + return br.ThenAs(b => + { + // Map the result to the contract representation. + var contract = BasketMapper.Map(b); + + // Add an event for the creation of the basket. + _unitOfWork.Events.Add(EventData.CreateEventWith(contract, EventAction.Created)); + + return contract; + }); + }); + } + + /// + public async Task> ApplyDiscountAsync(string basketId, DiscountCoupon discountCoupon) + { + if (discountCoupon.IsInactive) + return Result.BusinessError("Discount coupon either does not exist or is no longer active.", e => e.WithErrorCode("discount-inactive")); + + return await OrchestrateUpdateAsync(basketId, basket => basket.ApplyDiscount(discountCoupon), EventAction.Updated); + } + + /// + public async Task> ItemAddAsync(string basketId, BasketItemAddRequest item) + { + // Validate the request, and ensure the product exists (and retrieve the product details for use in the basket item). + var pr = await Result.GoAsync(() => BasketItemAddRequestValidator.Default.ValidateWithResultAsync(item)) + .ThenAsAsync(item => new ProductPolicy(_productAdapter).EnsureExistsAsync(item.ProductId!)); + + if (pr.IsFailure) + return pr.AsResult(); + + // Add the item to the basket. + return await OrchestrateUpdateAsync(basketId, basket => + { + var product = pr.Value; + + return basket.ItemAdd(Domain.BasketItem.CreateNew( + product.Id!, + product.Sku!, + product.Text!, + new Domain.ValueObjects.ItemPricing { UnitOfMeasure = product.UnitOfMeasure!, Quantity = item.Quantity, UnitPrice = product.Price })); + }); + } + + /// + public async Task> ItemUpdateAsync(string basketId, string basketItemId, BasketItemUpdateRequest item) + { + var vr = await BasketItemUpdateRequestValidator.Default.ValidateWithResultAsync(item); + if (vr.IsFailure) + return vr.AsResult(); + + return await OrchestrateUpdateAsync(basketId, basket => basket.ItemUpdate(basketItemId, item.Quantity, item.ETag)); + } + + /// + public Task> ItemDeleteAsync(string basketId, string basketItemId) + => OrchestrateUpdateAsync(basketId, basket => basket.ItemDelete(basketItemId), EventAction.Updated); + + /// + /// Orchestrate the update of the basket by first retrieving the aggregate, performing the mutation, and then updating within a transaction, ensuring that any events are only published if the transaction is successful. + /// + private async Task> OrchestrateUpdateAsync(string basketId, Func mutate, EventAction action = EventAction.Updated) + { + // Retrieve the aggregate from the repository for modification, and handle any failure (e.g. not found). + var br = await _repository.GetAsync(basketId).ConfigureAwait(false); + if (br.IsFailure) + return br.AsResult(); + + // Perform the mutation on the aggregate, then proceed to update. + return await mutate.ThrowIfNull().Invoke(br.Value) + .ThenAsAsync(() => UpdateAsync(br.Value, action)) + .ConfigureAwait(false); + } + + /// + /// Performs the "actual" update of the basket within a transaction, ensuring that any events are only published if the transaction is successful. + /// + private Task> UpdateAsync(Domain.Basket basket, EventAction action) => _unitOfWork.ExecuteAsync(async () => + { + if (!basket.HasChanges) + return Result.Ok(BasketMapper.Map(basket)); + + // Update the aggregate in the repository, which will return the updated aggregate with any updates (e.g. Id). + var ur = await _repository.UpdateAsync(basket).ConfigureAwait(false); + + return ur.ThenAs(basket => + { + // Map the result to the contract representation. + var contract = BasketMapper.Map(basket); + + // Add an event for the update of the basket. + _unitOfWork.Events.Add(EventData.CreateEventWith(contract, action)); + + return contract; + }); + }); + + /// + public Task> CheckoutAsync(string basketId) => Result + .GoAsync(() => _repository.GetAsync(basketId)) // Retrieve the basket aggregate from the repository. + .Then(basket => basket.Checkout().ThenAs(() => basket)) // Checkout the basket. + .ThenAsync(basket => _productAdapter.ReserveInventoryAsync(basket)) // Reserve the inventory; external service call, not transactional, fail-fast. + .ThenAsAsync(async basket => + { + try + { + return await _unitOfWork.ExecuteAsync(() => Result + .GoAsync(() => _repository.UpdateAsync(basket)) // Update the basket aggregate to reflect the checkout (e.g. status change). + .ThenAsAsync(async basket => + { + // Map the result to the contract representation. + var contract = BasketMapper.Map(basket); + + // Outbox the "checked-out" event for the basket aggregate. + _unitOfWork.Events.Add(EventData.CreateEventWith(contract, EventAction.CheckedOut)); + + // Note: the reservation confirmation is outboxed as a command to the products domain, as it's an action that we want to occur asynchronously after the transaction has completed, but it doesn't + // necessarily need to be performed immediately after the checkout event (e.g. it could be performed after some delay, or after some other events have been processed). By using a command, we + // also have more flexibility in how we handle failures and retries for the reservation confirmation, without impacting the checkout event processing. + return (await _productAdapter.CreateConfirmReservationCommand(basket).ConfigureAwait(false)) + .ThenAs(() => contract); + })).ConfigureAwait(false); + } + catch (Exception ex) + { + if (_logger.IsEnabled(LogLevel.Error)) + _logger.LogError(ex, "An error occurred during the checkout process; the reserved inventory will be cancelled asynchronously directly bypassing the Outbox."); + + // Cancel the inventory reservation directly, bypassing the Outbox, as the transaction has failed at this point and we don't want to leave the inventory in a reserved state. + await _productAdapter.CancelReservationAsync(basket).ConfigureAwait(false); + + // It's bad, keep throwing. + throw; + } + }); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Contoso.Shopping.Application.csproj b/samples/src/Contoso.Shopping.Application/Contoso.Shopping.Application.csproj new file mode 100644 index 00000000..3ef7e316 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Contoso.Shopping.Application.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/samples/src/Contoso.Shopping.Application/GlobalUsing.cs b/samples/src/Contoso.Shopping.Application/GlobalUsing.cs new file mode 100644 index 00000000..e49df329 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/GlobalUsing.cs @@ -0,0 +1,19 @@ +global using Contoso.Shopping.Application.Adapters.Products; +global using Contoso.Shopping.Application.Interfaces; +global using Contoso.Shopping.Application.Mapping; +global using Contoso.Shopping.Application.Policies; +global using Contoso.Shopping.Application.Repositories; +global using Contoso.Shopping.Application.Validators; +global using Contoso.Shopping.Contracts; +global using CoreEx; +global using CoreEx.Data; +global using CoreEx.DependencyInjection; +global using CoreEx.Entities; +global using CoreEx.Events; +global using CoreEx.Localization; +global using CoreEx.Mapping; +global using CoreEx.RefData; +global using CoreEx.RefData.Abstractions; +global using CoreEx.Results; +global using CoreEx.Validation; +global using Microsoft.Extensions.Logging; \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Interfaces/IBasketReadService.cs b/samples/src/Contoso.Shopping.Application/Interfaces/IBasketReadService.cs new file mode 100644 index 00000000..2e8241fd --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Interfaces/IBasketReadService.cs @@ -0,0 +1,9 @@ +namespace Contoso.Shopping.Application.Interfaces; + +public interface IBasketReadService +{ + /// + /// Get the for the specified + /// + Task> GetAsync(string basketId); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Interfaces/IBasketService.cs b/samples/src/Contoso.Shopping.Application/Interfaces/IBasketService.cs new file mode 100644 index 00000000..ca9334d1 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Interfaces/IBasketService.cs @@ -0,0 +1,34 @@ +namespace Contoso.Shopping.Application.Interfaces; + +public interface IBasketService +{ + /// + /// Create a new for the specified . + /// + Task> CreateAsync(string customerId); + + /// + /// Applies the to the specified . + /// + Task> ApplyDiscountAsync(string basketId, DiscountCoupon discountCoupon); + + /// + /// Checkout the specified . + /// + Task> CheckoutAsync(string basketId); + + /// + /// Add (or merge) the specified into the specified . + /// + Task> ItemAddAsync(string basketId, Contracts.BasketItemAddRequest item); + + /// + /// Updates an existing in the specified . + /// + Task> ItemUpdateAsync(string basketId, string basketItemId, Contracts.BasketItemUpdateRequest item); + + /// + /// Deletes an existing from the specified . + /// + Task> ItemDeleteAsync(string basketId, string basketItemId); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Mapping/BasketMapper.cs b/samples/src/Contoso.Shopping.Application/Mapping/BasketMapper.cs new file mode 100644 index 00000000..c1bdd632 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Mapping/BasketMapper.cs @@ -0,0 +1,35 @@ +namespace Contoso.Shopping.Application.Mapping; + +public class BasketMapper : Mapper +{ + protected override Contracts.Basket OnMap(Domain.Basket source) => new() + { + Id = source.Id, + CustomerId = source.CustomerId, + StatusCode = source.Status, + Pricing = new BasketPricing + { + SubTotal = source.SubTotal, + DiscountCouponCode = source.DiscountCoupon, + DiscountPercentage = source.DiscountPercentage, + DiscountAmount = source.DiscountAmount, + Total = source.Total + }, + Items = [.. source.Items.Select(i => BasketItemMapper.Map(i))] + }; + + private class BasketItemMapper : Mapper + { + protected override BasketItem OnMap(Domain.BasketItem i) => new() + { + Id = i.Id, + ProductId = i.ProductId, + Sku = i.Sku, + Text = i.Text, + Quantity = i.Pricing.Quantity, + UnitOfMeasureCode = i.Pricing.UnitOfMeasure, + UnitPrice = i.Pricing.UnitPrice, + Total = i.Pricing.Total + }; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Policies/ProductPolicy.cs b/samples/src/Contoso.Shopping.Application/Policies/ProductPolicy.cs new file mode 100644 index 00000000..1cce001b --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Policies/ProductPolicy.cs @@ -0,0 +1,11 @@ +namespace Contoso.Shopping.Application.Policies; + +public class ProductPolicy(IProductAdapter productAdapter) +{ + private static readonly LText _quantityText = new("Quantity"); + private readonly IProductAdapter _productAdapter = productAdapter.ThrowIfNull(); + + public Task> EnsureExistsAsync(string productId) => Result + .GoAsync(() => _productAdapter.GetAsync(productId)) + .OnFailure(r => r.IsNotFoundError ? Result.ValidationError(MessageItem.CreateErrorMessage(nameof(productId), "Product was not found.")) : r); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/ReferenceDataService.cs b/samples/src/Contoso.Shopping.Application/ReferenceDataService.cs new file mode 100644 index 00000000..db9508b4 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/ReferenceDataService.cs @@ -0,0 +1,22 @@ +namespace Contoso.Shopping.Application; + +[ScopedService] +public class ReferenceDataService(IReferenceDataRepository repository) : IReferenceDataProvider +{ + private readonly IReferenceDataRepository _repository = repository.ThrowIfNull(); + + public IEnumerable<(Type, Type)> Types => + [ + (typeof(BasketStatus), typeof(BasketStatusCollection)), + (typeof(DiscountCoupon), typeof(DiscountCouponCollection)), + (typeof(UnitOfMeasure), typeof(UnitOfMeasureCollection)), + ]; + + public async Task GetAsync(Type type, CancellationToken cancellationToken = default) => type switch + { + _ when type == typeof(BasketStatus) => await _repository.GetAllBasketStatusesAsync().ConfigureAwait(false), + _ when type == typeof(DiscountCoupon) => await _repository.GetAllDiscountCouponsAsync().ConfigureAwait(false), + _ when type == typeof(UnitOfMeasure) => await _repository.GetAllUnitsOfMeasureAsync().ConfigureAwait(false), + _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.") + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Repositories/IBasketRepository.cs b/samples/src/Contoso.Shopping.Application/Repositories/IBasketRepository.cs new file mode 100644 index 00000000..281cd2ad --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Repositories/IBasketRepository.cs @@ -0,0 +1,10 @@ +namespace Contoso.Shopping.Application.Repositories; + +public interface IBasketRepository +{ + Task> GetAsync(string id); + + Task> CreateAsync(Domain.Basket basket); + + Task> UpdateAsync(Domain.Basket basket); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Repositories/IReferenceDataRepository.cs b/samples/src/Contoso.Shopping.Application/Repositories/IReferenceDataRepository.cs new file mode 100644 index 00000000..915a3e7a --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Repositories/IReferenceDataRepository.cs @@ -0,0 +1,10 @@ +namespace Contoso.Shopping.Application.Repositories; + +public interface IReferenceDataRepository +{ + public Task GetAllBasketStatusesAsync(); + + public Task GetAllDiscountCouponsAsync(); + + public Task GetAllUnitsOfMeasureAsync(); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Validators/BasketItemAddRequestValidator.cs b/samples/src/Contoso.Shopping.Application/Validators/BasketItemAddRequestValidator.cs new file mode 100644 index 00000000..d1ed2505 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Validators/BasketItemAddRequestValidator.cs @@ -0,0 +1,10 @@ +namespace Contoso.Shopping.Application.Validators; + +public class BasketItemAddRequestValidator : Validator +{ + public BasketItemAddRequestValidator() + { + Property(x => x.ProductId).Mandatory(); + Property(x => x.Quantity).GreaterThanOrEqualTo(0); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Validators/BasketItemUpdateRequestValidator.cs b/samples/src/Contoso.Shopping.Application/Validators/BasketItemUpdateRequestValidator.cs new file mode 100644 index 00000000..a50bb42b --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Validators/BasketItemUpdateRequestValidator.cs @@ -0,0 +1,9 @@ +namespace Contoso.Shopping.Application.Validators; + +public class BasketItemUpdateRequestValidator : Validator +{ + public BasketItemUpdateRequestValidator() + { + Property(x => x.Quantity).GreaterThanOrEqualTo(0); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Application/Validators/ProductValidator.cs b/samples/src/Contoso.Shopping.Application/Validators/ProductValidator.cs new file mode 100644 index 00000000..685c5594 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Validators/ProductValidator.cs @@ -0,0 +1,17 @@ +namespace Contoso.Shopping.Application.Validators; + +/// +/// Product validator leveraging the FluentValidation API compatibility; still leverages CoreEx.Validation to perform the actual validation and error handling, but provides the FluentValidation API for +/// ease of use and familiarity. +/// +public class ProductValidator : AbstractValidator +{ + public ProductValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Sku).NotEmpty(); + RuleFor(x => x.Text).NotEmpty(); + RuleFor(x => x.UnitOfMeasure).NotEmpty().IsValid(); + RuleFor(x => x.Price).GreaterThanOrEqualTo(0); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Contracts/Basket.cs b/samples/src/Contoso.Shopping.Contracts/Basket.cs new file mode 100644 index 00000000..3cdeb0ad --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/Basket.cs @@ -0,0 +1,24 @@ +namespace Contoso.Shopping.Contracts; + +[Contract] +public partial class Basket : IIdentifier, IChangeLog, IETag +{ + [ReadOnly(true)] + public string? Id { get; set; } + + public string? CustomerId { get; set; } + + [ReferenceData] + public partial string? StatusCode { get; set; } + + [ReadOnly(true)] + public List? Items { get; set; } + + public BasketPricing? Pricing { get; set; } + + [ReadOnly(true)] + public ChangeLog? ChangeLog { get; set; } + + [ReadOnly(true)] + public string? ETag { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Contracts/BasketItem.cs b/samples/src/Contoso.Shopping.Contracts/BasketItem.cs new file mode 100644 index 00000000..f27e597c --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/BasketItem.cs @@ -0,0 +1,32 @@ +namespace Contoso.Shopping.Contracts; + +[Contract] +public partial class BasketItem : IIdentifier, IETag +{ + [ReadOnly(true)] + public string? Id { get; set; } + + [ReadOnly(true)] + public string? ProductId { get; set; } + + [ReadOnly(true)] + public string? Sku { get; set; } + + [ReadOnly(true)] + public string? Text { get; set; } + + public decimal? Quantity { get; set; } + + [ReadOnly(true)] + [ReferenceData] + public partial string? UnitOfMeasureCode { get; set; } + + [ReadOnly(true)] + public decimal? UnitPrice { get; set; } + + [ReadOnly(true)] + public decimal? Total { get; set; } + + [ReadOnly(true)] + public string? ETag { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Contracts/BasketItemAddRequest.cs b/samples/src/Contoso.Shopping.Contracts/BasketItemAddRequest.cs new file mode 100644 index 00000000..a7543c53 --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/BasketItemAddRequest.cs @@ -0,0 +1,8 @@ +namespace Contoso.Shopping.Contracts; + +public class BasketItemAddRequest +{ + public string? ProductId { get; set; } + + public decimal Quantity { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Contracts/BasketItemUpdateRequest.cs b/samples/src/Contoso.Shopping.Contracts/BasketItemUpdateRequest.cs new file mode 100644 index 00000000..5ed4bfdf --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/BasketItemUpdateRequest.cs @@ -0,0 +1,8 @@ +namespace Contoso.Shopping.Contracts; + +public class BasketItemUpdateRequest : IETag +{ + public decimal Quantity { get; set; } + + public string? ETag { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Contracts/BasketPricing.cs b/samples/src/Contoso.Shopping.Contracts/BasketPricing.cs new file mode 100644 index 00000000..32526ce8 --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/BasketPricing.cs @@ -0,0 +1,23 @@ +namespace Contoso.Shopping.Contracts; + +[Contract] +public partial class BasketPricing +{ + [ReadOnly(true)] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public decimal SubTotal { get; set; } + + [ReadOnly(true)] + [ReferenceData] + public partial string? DiscountCouponCode { get; set; } + + [ReadOnly(true)] + public decimal DiscountPercentage { get; set; } + + [ReadOnly(true)] + public decimal DiscountAmount { get; set; } + + [ReadOnly(true)] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public decimal Total { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Contracts/BasketStatus.cs b/samples/src/Contoso.Shopping.Contracts/BasketStatus.cs new file mode 100644 index 00000000..e2cec447 --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/BasketStatus.cs @@ -0,0 +1,14 @@ +namespace Contoso.Shopping.Contracts; + +[ReferenceData] +public partial class BasketStatus : ReferenceData +{ + public const string Empty = "E"; + public const string Active = "A"; + public const string CheckedOut = "C"; + public const string Abandoned = "B"; + + public bool CanBeMutated => Code is Empty or Active; +} + +public class BasketStatusCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Contracts/Contoso.Shopping.Contracts.csproj b/samples/src/Contoso.Shopping.Contracts/Contoso.Shopping.Contracts.csproj new file mode 100644 index 00000000..a73b2f2b --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/Contoso.Shopping.Contracts.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/samples/src/Contoso.Shopping.Contracts/DiscountCoupon.cs b/samples/src/Contoso.Shopping.Contracts/DiscountCoupon.cs new file mode 100644 index 00000000..bb053cec --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/DiscountCoupon.cs @@ -0,0 +1,9 @@ +namespace Contoso.Shopping.Contracts; + +[ReferenceData] +public partial class DiscountCoupon : ReferenceData +{ + public decimal DiscountPercentage { get; init => field = value.ThrowIfLessThanOrEqualToZero("Discount percentage cannot be less than or equal to zero.").ThrowWhen(value => value > 100, "Discount percentage cannot be greater than 100."); } +} + +public class DiscountCouponCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Contracts/GlobalUsing.cs b/samples/src/Contoso.Shopping.Contracts/GlobalUsing.cs new file mode 100644 index 00000000..f1da3a37 --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/GlobalUsing.cs @@ -0,0 +1,5 @@ +global using CoreEx; +global using CoreEx.Entities; +global using CoreEx.RefData; +global using System.ComponentModel; +global using System.Text.Json.Serialization; \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Contracts/UnitOfMeasure.cs b/samples/src/Contoso.Shopping.Contracts/UnitOfMeasure.cs new file mode 100644 index 00000000..d9a7e8a5 --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/UnitOfMeasure.cs @@ -0,0 +1,9 @@ +namespace Contoso.Shopping.Contracts; + +[ReferenceData] +public partial class UnitOfMeasure : ReferenceData +{ + public int Scale { get; init; } +} + +public class UnitOfMeasureCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Contoso.Shopping.Database.csproj b/samples/src/Contoso.Shopping.Database/Contoso.Shopping.Database.csproj new file mode 100644 index 00000000..bab0d400 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Contoso.Shopping.Database.csproj @@ -0,0 +1,25 @@ + + + + Exe + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Data/ref-data.yaml b/samples/src/Contoso.Shopping.Database/Data/ref-data.yaml new file mode 100644 index 00000000..14805e5d --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Data/ref-data.yaml @@ -0,0 +1,18 @@ +Shopping: + - $^UnitOfMeasure: + - EA: Each + - SET: Set + - PR: Pair + - PK: Pack + - M: Meter + - CM: Centimeter + - L: Litre + - { Code: HR, Text: Hour, Scale: 2 } + - $^DiscountCoupon: + - { Code: SAVE10, Text: Save 10% on your order, DiscountPercentage: 10 } + - { Code: XMAS2025, Text: Save 15% for Christmas 2025, DiscountPercentage: 15, StartsOn: 2025-12-01, EndsOn: 2025-12-31 } + - $^BasketStatus: + - E: Empty + - A: Active + - C: Checked-out + - B: Abandoned \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Migrations/20260101-000001-create-shopping-schema.sql b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000001-create-shopping-schema.sql new file mode 100644 index 00000000..0d6c8642 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000001-create-shopping-schema.sql @@ -0,0 +1 @@ +CREATE SCHEMA [Shopping] \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Migrations/20260101-000101-create-shopping-unitofmeasure.sql b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000101-create-shopping-unitofmeasure.sql new file mode 100644 index 00000000..6d78aaa5 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000101-create-shopping-unitofmeasure.sql @@ -0,0 +1,19 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Shopping].[UnitOfMeasure] ( + [UnitOfMeasureId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [Code] NVARCHAR(50) NOT NULL UNIQUE, + [Text] NVARCHAR(250) NULL, + [IsActive] BIT NULL, + [SortOrder] INT NULL, + [Scale] INT NOT NULL DEFAULT 0, + [RowVersion] TIMESTAMP NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Migrations/20260101-000102-create-shopping-discountcoupon.sql b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000102-create-shopping-discountcoupon.sql new file mode 100644 index 00000000..1eb82bb6 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000102-create-shopping-discountcoupon.sql @@ -0,0 +1,21 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Shopping].[DiscountCoupon] ( + [DiscountCouponId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [Code] NVARCHAR(50) NOT NULL UNIQUE, + [Text] NVARCHAR(250) NULL, + [IsActive] BIT NULL, + [SortOrder] INT NULL, + [DiscountPercentage] DECIMAL(18,2) DEFAULT 0 NOT NULL, + [StartsOn] DATETIMEOFFSET NULL, + [EndsOn] DATETIMEOFFSET NULL, + [RowVersion] TIMESTAMP NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Migrations/20260101-000103-create-shopping-basketstatus.sql b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000103-create-shopping-basketstatus.sql new file mode 100644 index 00000000..8b290c66 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000103-create-shopping-basketstatus.sql @@ -0,0 +1,18 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Shopping].[BasketStatus] ( + [BasketStatusId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [Code] NVARCHAR(50) NOT NULL UNIQUE, + [Text] NVARCHAR(250) NULL, + [IsActive] BIT NULL, + [SortOrder] INT NULL, + [RowVersion] TIMESTAMP NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Migrations/20260101-000201-create-shopping-product.sql b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000201-create-shopping-product.sql new file mode 100644 index 00000000..2ee4f4ac --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000201-create-shopping-product.sql @@ -0,0 +1,15 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Shopping].[Product] ( + [ProductId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [Sku] NVARCHAR(50) NOT NULL UNIQUE, + [Text] NVARCHAR(250) NOT NULL, + [UnitOfMeasureCode] NVARCHAR(50) NOT NULL, + [Price] DECIMAL(18, 2) DEFAULT 0 NOT NULL, + [IsInactive] BIT DEFAULT 0 NOT NULL, + [IsNonStocked] BIT DEFAULT 0 NOT NULL +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Migrations/20260101-000301-create-shopping-basket.sql b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000301-create-shopping-basket.sql new file mode 100644 index 00000000..36ad787c --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000301-create-shopping-basket.sql @@ -0,0 +1,21 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [Shopping].[Basket] +( + [BasketId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [CustomerId] NVARCHAR(50) NOT NULL, + [BasketStatusCode] NVARCHAR(50) NOT NULL, + [SubTotal] DECIMAL(18, 2) NOT NULL DEFAULT 0, + [DiscountCouponCode] NVARCHAR(50) NULL, + [DiscountAmount] DECIMAL(18, 2) NOT NULL DEFAULT 0, + [Total] DECIMAL(18, 2) NOT NULL DEFAULT 0, + [RowVersion] ROWVERSION NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Migrations/20260101-000302-create-shopping-basketitem.sql b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000302-create-shopping-basketitem.sql new file mode 100644 index 00000000..a9010fe2 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000302-create-shopping-basketitem.sql @@ -0,0 +1,18 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE Shopping.BasketItem +( + [BasketItemId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [BasketId] NVARCHAR(50) NOT NULL FOREIGN KEY REFERENCES Shopping.Basket([BasketId]), + [ProductId] NVARCHAR(50) NOT NULL, + [Sku] NVARCHAR(50) NOT NULL, + [Text] NVARCHAR(250) NOT NULL, + [UnitOfMeasureCode] NVARCHAR(50) NOT NULL, + [UnitPrice] DECIMAL(18, 2) NOT NULL DEFAULT 0, + [Quantity] DECIMAL(18, 2) NOT NULL DEFAULT 0, + [RowVersion] TIMESTAMP NOT NULL +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Migrations/20260101-000401-create-shopping-outbox-tables.sql b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000401-create-shopping-outbox-tables.sql new file mode 100644 index 00000000..73797fe7 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Migrations/20260101-000401-create-shopping-outbox-tables.sql @@ -0,0 +1,37 @@ +-- Create table: [Shopping].[Outbox] and [Shopping].[OutboxLease] + +BEGIN TRANSACTION + +CREATE TABLE [Shopping].[Outbox] ( + [OutboxId] BIGINT IDENTITY (1, 1) NOT NULL PRIMARY KEY, + [TenantId] NVARCHAR(255) NOT NULL, -- Optional, null indicates no tenancy. + [PartitionId] INT NOT NULL, -- Partition number; computed in application from partition-key. + [Status] TINYINT NOT NULL DEFAULT 0, -- 0=Pending, 1=Processing, 2=Done. + [EnqueuedUtc] DATETIME2 NOT NULL, -- When the event was enqueued within application. + [AvailableUtc] DATETIME2 NOT NULL, -- When the event is eligible for processing (retry delay). + [DequeuedUtc] DATETIME2 NULL, -- When the event was successfully dequeued/relayed. + [Attempts] INT NOT NULL DEFAULT 0, -- Retry attempt count. + + -- Message: + [Destination] NVARCHAR(255) NULL, -- Message destination; i.e. queue/topic/etc. + [Event] NVARCHAR(MAX) NOT NULL, -- CloudEvent as JSON. + + -- Claim/leasing: + [LeaseId] UNIQUEIDENTIFIER NULL, -- Unique identifier of the lease. + [LeaseUntilUtc] DATETIME2 NULL, -- Leased until UTC; after which assume released due to possible application crash. + + INDEX [IX_Shopping_Outbox_PartitionOrder] ([TenantId], [PartitionId], [OutboxId]) INCLUDE ([Status], [AvailableUtc], [LeaseUntilUtc], [Destination], [Event], [Attempts]), + INDEX [IX_Shopping_Outbox_WorkerPull] ([TenantId], [PartitionId], [Status]) INCLUDE ([OutboxId], [AvailableUtc]), + INDEX [IX_Shopping_Outbox_CleanUp] ([OutboxId]) INCLUDE ([DequeuedUtc]) WHERE [Status] = 2 +); + +CREATE TABLE [Shopping].[OutboxLease] ( + [TenantId] NVARCHAR(255) NOT NULL, -- Optional, null indicates no tenancy. + [PartitionId] INT NOT NULL, -- Partition number; computed in application from partition-key. + [LeaseId] UNIQUEIDENTIFIER NULL, -- Unique identifier of the leasee. + [LeaseUntilUtc] DATETIME2 NULL -- Leased until UTC; after which assume released due to possible application crash. + + CONSTRAINT PK_Shopping_OutboxLease PRIMARY KEY (TenantId, PartitionId) +); + +COMMIT TRANSACTION \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Program.cs b/samples/src/Contoso.Shopping.Database/Program.cs new file mode 100644 index 00000000..6e99dbd3 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Program.cs @@ -0,0 +1,40 @@ +using CoreEx.Database; +using DbEx.Migration; +using DbEx.SqlServer.Console; + +namespace Contoso.Shopping.Database; + +/// +/// Represents the database utilities program (capability). +/// +public class Program +{ + /// + /// Main startup. + /// + /// The startup arguments. + /// The status code whereby zero indicates success. + public static Task Main(string[] args) => SqlServerMigrationConsole + .Create("Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true") + .Configure(c => ConfigureMigrationArgs(c.Args)) + .RunAsync(args); + + /// + /// Configure the . + /// + /// The . + /// The . + public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) + { + args.AddAssembly().AddAssembly() + .IncludeExtendedSchemaScripts() + .DataParserArgs + .RefDataColumnDefault("SortOrder", _ => 0) + .RefDataColumnDefault("Scale", _ => 0); + + // Only reset data for the Shopping schema. + args.DataResetFilterPredicate = ts => ts.Schema == "Shopping"; + + return args; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Properties/launchSettings.json b/samples/src/Contoso.Shopping.Database/Properties/launchSettings.json new file mode 100644 index 00000000..f807a1c1 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Contoso.Shopping.Database": { + "commandName": "Project", + "commandLineArgs": "dropandall" + } + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql new file mode 100644 index 00000000..6046060f --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql @@ -0,0 +1,71 @@ +CREATE OR ALTER PROCEDURE [Shopping].[spOutboxBatchCancel] + @LeaseId UNIQUEIDENTIFIER, + @BackoffSeconds INT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Cancels a batch by LeaseId, marking messages as pending with backoff and releasing the lease. + * > Returns: + * 0 = Success. + * -1 = No rows updated (e.g. already completed or invalid LeaseId). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @TenantId NVARCHAR(255); + DECLARE @PartitionId INT; + DECLARE @Cancelled TABLE (TenantId NVARCHAR(255), PartitionId INT); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Cancel the batch and capture tenant/partition atomically. + UPDATE o + SET o.[Status] = 0, + o.[Attempts] = o.[Attempts] + 1, + o.[AvailableUtc] = DATEADD(SECOND, @BackoffSeconds, @Now), + o.[LeaseId] = NULL, + o.[LeaseUntilUtc] = NULL + OUTPUT + deleted.[TenantId], + deleted.[PartitionId] + INTO @Cancelled + FROM [Shopping].[Outbox] AS o WITH (UPDLOCK, ROWLOCK) + WHERE o.[LeaseId] = @LeaseId + AND o.[Status] = 1; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + RETURN -1; -- No rows updated. + END + + -- 2) Capture tenant/partition from first cancelled row. + SELECT TOP 1 + @TenantId = TenantId, + @PartitionId = PartitionId + FROM @Cancelled; + + COMMIT; + + -- 3) Release the partition lease. + BEGIN TRY + EXEC [Shopping].[spOutboxLeaseRelease] @LeaseId; + END TRY + BEGIN CATCH + -- Ignore: lease will expire. Don't fail cancel. + END CATCH + + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END diff --git a/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql new file mode 100644 index 00000000..fb901823 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql @@ -0,0 +1,116 @@ +CREATE OR ALTER PROCEDURE [Shopping].[spOutboxBatchClaim] + @TenantId NVARCHAR(255) = NULL, + @PartitionId INT, + @BatchSize INT, + @LeaseId UNIQUEIDENTIFIER, + @LeaseSeconds INT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Claims the next batch of pending/processing messages for a tenant/partition, marking them as processing with a lease. + * > Returns: + * 0 = Success; batch returned in result set. + * -1 = No rows updated (e.g. already claimed by another or transient error). + * -2 = No batch to claim (e.g. all completed). + * -3 = Unable to acquire lease (e.g. another active batch or transient error). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @LeaseUntilUtc DATETIME2; + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + SET LOCK_TIMEOUT 5000; -- Milliseconds. + + -- 1) Acquire a partition lease; exit where unsuccessful. + DECLARE @RC INT; + EXEC @RC = [Shopping].[spOutboxLeaseAcquire] @EffectiveTenantId, @PartitionId, @LeaseId, @LeaseSeconds, @LeaseUntilUtc OUTPUT; + IF (@RC < 0) RETURN -3; + + -- 2) Claim the next batch (contiguous by OutboxId) for the tenant/partition. + BEGIN TRY + BEGIN TRAN; + + DECLARE @HeadId BIGINT; + DECLARE @BlockerId BIGINT; + + -- Determine head (first pending/processing) for strict contiguity. + SELECT @HeadId = MIN(o.OutboxId) + FROM [Shopping].[Outbox] o WITH (UPDLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[Status] IN (0, 1) + OPTION (RECOMPILE); + + IF @HeadId IS NULL + BEGIN + COMMIT; + + -- Release the lease outside transaction. + EXEC [Shopping].[spOutboxLeaseRelease] @LeaseId; + RETURN -2; -- Nothing available. + END + + -- Find first blocker at/after head: actively leased or not yet available. + SELECT @BlockerId = MIN(o.OutboxId) + FROM [Shopping].[Outbox] o WITH (READPAST, UPDLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[OutboxId] >= @HeadId + AND ((o.Status = 1 AND o.[LeaseUntilUtc] IS NOT NULL AND o.[LeaseUntilUtc] > @Now) + OR (o.Status = 0 AND o.[AvailableUtc] > @Now)) + OPTION (RECOMPILE); + + -- Claim contiguous run from head to before blocker. + ;WITH claim AS + ( + SELECT TOP (@BatchSize) + o.[OutboxId], o.[TenantId], o.[Status], o.[PartitionId], o.[Destination], o.[Event], + o.[Attempts], o.[EnqueuedUtc], o.[AvailableUtc], o.[LeaseId], o.[LeaseUntilUtc] + FROM [Shopping].[Outbox] o WITH (READPAST, UPDLOCK, ROWLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[OutboxId] >= @HeadId + AND (@BlockerId IS NULL OR o.[OutboxId] < @BlockerId) + AND ((o.[Status] = 0 AND o.[AvailableUtc] <= @Now) + OR (o.[Status] = 1 AND (o.[LeaseUntilUtc] IS NULL OR o.[LeaseUntilUtc] <= @Now))) + ORDER BY o.OutboxId + ) + UPDATE claim + SET [Status] = 1, + [LeaseId] = @LeaseId, + [LeaseUntilUtc] = @LeaseUntilUtc + OUTPUT + inserted.[OutboxId], + inserted.[TenantId], + inserted.[Status], + inserted.[PartitionId], + inserted.[Destination], + inserted.[Event], + inserted.[Attempts], + inserted.[EnqueuedUtc], + inserted.[AvailableUtc], + inserted.[LeaseUntilUtc]; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + + -- Release the lease outside transaction. + EXEC [Shopping].[spOutboxLeaseRelease] @LeaseId; + RETURN -1; -- No rows updated. + END + + COMMIT; + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql new file mode 100644 index 00000000..23e828d7 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql @@ -0,0 +1,73 @@ +CREATE OR ALTER PROCEDURE [Shopping].[spOutboxBatchComplete] + @LeaseId UNIQUEIDENTIFIER, + @DequeuedUtc DATETIME2 NULL +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Marks a batch as completed by LeaseId, releasing the lease and making way for the next batch. + * > Returns: + * 0 = Success. + * -1 = No rows updated (e.g. already completed or invalid LeaseId). + * -2 = No batch to claim (e.g. all completed since claim). + * -3 = Unable to acquire lease (e.g. another active batch or transient error). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @TenantId NVARCHAR(255); + DECLARE @PartitionId INT; + DECLARE @Completed TABLE (TenantId NVARCHAR(255), PartitionId INT); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Complete the batch and capture tenant/partition atomically. + UPDATE o + SET o.[Status] = 2, + o.[LeaseId] = NULL, + o.[LeaseUntilUtc] = NULL, + o.[DequeuedUtc] = COALESCE(@DequeuedUtc, @Now) + OUTPUT + deleted.[TenantId], + deleted.[PartitionId] + INTO @Completed + FROM [Shopping].[Outbox] AS o WITH (UPDLOCK, ROWLOCK) + WHERE o.[LeaseId] = @LeaseId + AND o.[Status] = 1; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + RETURN -1; -- No rows updated. + END + + -- 2) Capture tenant/partition from first completed row. + SELECT TOP 1 + @TenantId = TenantId, + @PartitionId = PartitionId + FROM @Completed; + + COMMIT; + + -- 3) Release the partition lease where identified. + BEGIN TRY + EXEC [Shopping].[spOutboxLeaseRelease] @LeaseId; + END TRY + BEGIN CATCH + -- Ignore: lease will expire. Don't fail completion. + END CATCH + + RETURN 0 + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH + +END \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql new file mode 100644 index 00000000..d18a9d72 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql @@ -0,0 +1,36 @@ +CREATE OR ALTER PROCEDURE [Shopping].[spOutboxEnqueue] + @TenantId AS NVARCHAR(255) = NULL, + @PartitionId AS INT, + @Destination AS NVARCHAR(255), + @Event AS NVARCHAR(MAX), + @EnqueuedUtc AS DATETIME2 = NULL, + @AvailableUtc AS DATETIME2 = NULL +AS +BEGIN + /* + * This file is automatically generated; any changes will be lost. + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + INSERT INTO [Shopping].[Outbox] ( + [TenantId], + [PartitionId], + [Destination], + [Event], + [EnqueuedUtc], + [AvailableUtc] + ) + VALUES ( + @EffectiveTenantId, + @PartitionId, + @Destination, + @Event, + COALESCE(@EnqueuedUtc, @Now), + COALESCE(@AvailableUtc, COALESCE(@EnqueuedUtc, @Now)) + ) +END \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql new file mode 100644 index 00000000..98361f65 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql @@ -0,0 +1,73 @@ +CREATE OR ALTER PROCEDURE [Shopping].[spOutboxLeaseAcquire] + @TenantId NVARCHAR(255) = NULL, + @PartitionId INT, + @LeaseId UNIQUEIDENTIFIER, + @LeaseSeconds INT, + @LeaseUntilUtc DATETIME2 OUTPUT +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Attempts to acquire a lease for a tenant/partition, returning success status and lease until timestamp. + * > Returns: + * 0 = Lease acquired; caller may proceed with batch claim. + * -1 = Lease not acquired; caller should backoff and retry. + * + * Notes: + * - The procedure is resilient to transient errors and will return -1 where lease acquisition is unsuccessful, including where another active lease exists or where a transient error occurs (e.g. lock timeout). + * - The caller should implement an appropriate retry/backoff strategy where -1 is returned, including randomization to avoid thundering herd issues. + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @Until DATETIME2 = DATEADD(SECOND, @LeaseSeconds, @Now) + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + BEGIN TRY + BEGIN TRAN; + + -- 1) Ensure the row exists (self-seeding); lock the key-range for this PartitionId to avoid insert races. + IF NOT EXISTS ( + SELECT 1 + FROM [Shopping].[OutboxLease] WITH (UPDLOCK, HOLDLOCK) + WHERE [TenantId] = @EffectiveTenantId AND [PartitionId] = @PartitionId + ) + BEGIN + INSERT INTO [Shopping].[OutboxLease] ([TenantId], [PartitionId]) + VALUES (@EffectiveTenantId, @PartitionId); + END + + -- 2) Attempt to acquire lease where expired/empty. + UPDATE ol + SET ol.[LeaseId] = @LeaseId, + ol.[LeaseUntilUtc] = @Until + FROM [Shopping].[OutboxLease] AS ol WITH (UPDLOCK, ROWLOCK) + WHERE ol.[PartitionId] = @PartitionId + AND ol.[TenantId] = @EffectiveTenantId + AND (ol.[LeaseUntilUtc] IS NULL OR ol.[LeaseUntilUtc] <= @Now) + OPTION (RECOMPILE); + + -- 3) Commit and return lease success status. + DECLARE @Rows INT = @@ROWCOUNT; + + COMMIT; + + IF @Rows = 1 + BEGIN + SET @LeaseUntilUtc = @Until; + RETURN 0; -- Lease successful. + END + + SET @LeaseUntilUtc = NULL; + RETURN -1; -- Lease unsuccessful. + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql new file mode 100644 index 00000000..cb6556d4 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql @@ -0,0 +1,44 @@ +CREATE OR ALTER PROCEDURE [Shopping].[spOutboxLeaseRelease] + @LeaseId UNIQUEIDENTIFIER +AS +BEGIN + /* + * This is automatically generated; any changes will be lost. + * + * Releases a lease by LeaseId, making way for the next batch. + * > Returns: + * 0 = Success; lease released and available for next claim. + * -1 = No rows updated (e.g. already released or invalid LeaseId). + * + * Notes: + * - The procedure is resilient to transient errors and will return -1 where release is unsuccessful, including where the lease is already released or where a transient error occurs (e.g. lock timeout). + */ + + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; -- Milliseconds. + SET TRANSACTION ISOLATION LEVEL READ COMMITTED + + BEGIN TRY + BEGIN TRAN; + + -- 1) Release lease where leasee. + UPDATE ol + SET ol.[LeaseId] = NULL, + ol.[LeaseUntilUtc] = NULL + FROM [Shopping].[OutboxLease] AS ol WITH (UPDLOCK, ROWLOCK) + WHERE ol.[LeaseId] = @LeaseId; + + -- 2) Commit and return release success status. + DECLARE @Rows INT = @@ROWCOUNT; + + COMMIT; + + IF @Rows = 1 RETURN 0; -- Release successful. + RETURN -1; -- Release unsuccessful. + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; -- Re-throw preserves error details to caller. + END CATCH +END \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/dbex.yaml b/samples/src/Contoso.Shopping.Database/dbex.yaml new file mode 100644 index 00000000..8677cc56 --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/dbex.yaml @@ -0,0 +1,11 @@ +outbox: true +tables: +# Reference-data +- name: BasketStatus +- name: DiscountCoupon +- name: UnitOfMeasure + +# Transactional-data +- name: Basket +- name: BasketItem +- name: Product \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Domain/Basket.cs b/samples/src/Contoso.Shopping.Domain/Basket.cs new file mode 100644 index 00000000..3097866d --- /dev/null +++ b/samples/src/Contoso.Shopping.Domain/Basket.cs @@ -0,0 +1,139 @@ +namespace Contoso.Shopping.Domain; + +public sealed class Basket : Aggregate +{ + private List _items = []; + + public static Basket CreateNew(string customerId) => new Basket(Runtime.NewId()) + { + CustomerId = customerId, + Status = BasketStatus.Empty + }.AsNew(); + + public static Basket CreateFrom(string id, string customerId, BasketStatus status, DiscountCoupon? discountCoupon, IEnumerable? items, ChangeLog? changeLog, string? etag) => new Basket(id) + { + CustomerId = customerId, + Status = status, + DiscountCoupon = discountCoupon, + _items = items is null ? [] : [.. items.Select(i => i.Clone(PersistenceState.NotModified))], + ChangeLog = changeLog, + ETag = etag + }.AsNotModified(); + + private Basket(string id) : base(id) { } + + public string CustomerId { get; private set => field = value.ThrowIfNullOrEmpty(); } = null!; + + public BasketStatus Status { get; private set => field = value.ThrowIfNull().ThrowIfInactive(); } = null!; + + public DiscountCoupon? DiscountCoupon { get; private set => field = value?.ThrowIfInvalid(); } + + public IReadOnlyList Items => _items; + + public decimal SubTotal => _items.Where(i => i.PersistenceState != PersistenceState.Removed).Sum(i => i.Pricing.Total); + + public decimal DiscountPercentage => DiscountCoupon?.DiscountPercentage ?? 0; + + public decimal DiscountAmount => DiscountCoupon is null ? 0 : Math.Round(SubTotal * (DiscountCoupon.DiscountPercentage / 100), 2); + + public decimal Total => SubTotal - DiscountAmount; + + public bool HasChanges => PersistenceState.IsNewOrModified || _items.Any(i => i.PersistenceState.IsNewOrModified); + + /// + /// Check that it can be mutated; otherwise, error/fail. + protected override Result OnCheckCanMutate() => Status.CanBeMutated + ? Result.Success + : Result.BusinessError($"Basket has a status of '{Status}' and as such cannot be modified.", c => c.WithKey(Id).WithErrorCode("invalid-status")); + + /// + /// On mutation then re-determine status. + protected override void OnMutate() + { + // Automatically update the status based on the items in the basket (where it can be mutated). + if (Status.CanBeMutated) + Status = _items.Any(i => i.PersistenceState.IsNotRemoved) ? BasketStatus.Active : BasketStatus.Empty; + } + + /// + /// Applies the discount coupon to the basket (where not already applied). + /// + public Result ApplyDiscount(DiscountCoupon discountCoupon) + { + discountCoupon.ThrowIfNull().ThrowIfInactive(); + if (discountCoupon != DiscountCoupon) + Modify(() => DiscountCoupon = discountCoupon); + + return Result.Success; + } + + /// + /// Adds new or merges into an existing item in the basket. + /// + public Result ItemAdd(BasketItem item) => Modify(() => + { + item.ThrowIfNull(); + if (_items.FirstOrDefault(i => i.ProductId == item.ProductId && i.PersistenceState.IsNotRemoved) is BasketItem existing) + existing.IncreaseQuantity(item.Pricing.Quantity); + else + _items.Add(item.Clone(PersistenceState.New)); + + return Result.Success; + }); + + /// + /// Updates the quantity of an existing item in the basket. + /// + public Result ItemUpdate(string basketItemId, decimal quantity, string? etag) + { + var item = _items.FirstOrDefault(i => i.Id == basketItemId.ThrowIfNullOrEmpty() && i.PersistenceState.IsNotRemoved); + if (item is null) + return Result.NotFoundError(); + + if (quantity != item.Pricing.Quantity) + Modify(() => + { + item.OverrideQuantity(quantity); + item.SetETag(etag); + }); + + return Result.Success; + } + + /// + /// Deletes the item from the basket (marking as removed). + /// + public Result ItemDelete(string basketItemId) + { + var item = _items.FirstOrDefault(i => i.Id == basketItemId.ThrowIfNullOrEmpty() && i.PersistenceState.IsNotRemoved); + if (item is not null) + Modify(() => item.Delete()); + + return Result.Success; + } + + /// + /// Performs a basket checkout. + /// + public Result Checkout() + { + if (Status == BasketStatus.Empty) + return Result.BusinessError("An empty basket can not be checked out.", c => c.WithKey(Id).WithErrorCode("empty-basket")); + + if (_items.Sum(i => i.Pricing.Quantity) == 0) + return Result.BusinessError("A basket must have at least one item with a quantity greater than zero to be checked out.", c => c.WithKey(Id).WithErrorCode("zero-quantity-basket")); + + if (HasChanges) + throw new InvalidOperationException("A basket can not be checked out where changes have not been committed."); + + Modify(() => + { + foreach (var item in _items.Where(i => i.Pricing.Quantity == 0)) + item.Delete(); + + Status = BasketStatus.CheckedOut; + }); + + return Result.Success; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Domain/BasketItem.cs b/samples/src/Contoso.Shopping.Domain/BasketItem.cs new file mode 100644 index 00000000..8eb5e1ed --- /dev/null +++ b/samples/src/Contoso.Shopping.Domain/BasketItem.cs @@ -0,0 +1,39 @@ +namespace Contoso.Shopping.Domain; + +public sealed class BasketItem : Entity +{ + public static BasketItem CreateNew(string productId, string sku, string text, ItemPricing pricing) => new BasketItem(Runtime.NewId()) + { + ProductId = productId, + Sku = sku, + Text = text, + Pricing = pricing + }.AsNew(); + + public static BasketItem CreateFrom(string id, string productId, string sku, string text, ItemPricing pricing, string? etag) => new BasketItem(id) + { + ProductId = productId, + Sku = sku, + Text = text, + Pricing = pricing, + ETag = etag + }.AsNotModified(); + + private BasketItem(string id) : base(id) { } + + public string ProductId { get; private set => field = value.ThrowIfNullOrEmpty(); } = null!; + + public string Sku { get; private set => field = value.ThrowIfNullOrEmpty(); } = null!; + + public string Text { get; private set => field = value.ThrowIfNullOrEmpty(); } = null!; + + public ItemPricing Pricing { get; private set => field = value.ThrowIfNull().EnsureIsValid(); } = null!; + + internal void OverrideQuantity(decimal quantity) => Modify(() => Pricing = Pricing with { Quantity = quantity }); + + internal void IncreaseQuantity(decimal quantity) => OverrideQuantity(Pricing.Quantity + quantity); + + internal void Delete() => Remove(); + + internal BasketItem Clone(PersistenceState state) => CreateFrom(Id, ProductId, Sku, Text, Pricing, ETag).SetPersistenceState(state); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Domain/Contoso.Shopping.Domain.csproj b/samples/src/Contoso.Shopping.Domain/Contoso.Shopping.Domain.csproj new file mode 100644 index 00000000..a489b0e3 --- /dev/null +++ b/samples/src/Contoso.Shopping.Domain/Contoso.Shopping.Domain.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/samples/src/Contoso.Shopping.Domain/GlobalUsing.cs b/samples/src/Contoso.Shopping.Domain/GlobalUsing.cs new file mode 100644 index 00000000..623ff8ec --- /dev/null +++ b/samples/src/Contoso.Shopping.Domain/GlobalUsing.cs @@ -0,0 +1,7 @@ +global using Contoso.Shopping.Contracts; +global using Contoso.Shopping.Domain.ValueObjects; +global using CoreEx; +global using CoreEx.DomainDriven; +global using CoreEx.Entities; +global using CoreEx.Results; +global using CoreEx.Validation; \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Domain/ValueObjects/ItemPricing.cs b/samples/src/Contoso.Shopping.Domain/ValueObjects/ItemPricing.cs new file mode 100644 index 00000000..2e602b64 --- /dev/null +++ b/samples/src/Contoso.Shopping.Domain/ValueObjects/ItemPricing.cs @@ -0,0 +1,15 @@ +namespace Contoso.Shopping.Domain.ValueObjects; + +public sealed record class ItemPricing +{ + public required Contracts.UnitOfMeasure UnitOfMeasure { get; init => field = value.ThrowIfInactive(); } + + public decimal UnitPrice { get; init => field = value.ThrowIfLessThanZero(); } + + public decimal Quantity { get; init => field = value.ThrowIfLessThanZero(); } + + public decimal Total => UnitPrice * Quantity; + + public ItemPricing EnsureIsValid() => DecimalRuleHelper.CheckScale(Quantity, UnitOfMeasure.Scale) ? this + : throw new ValidationException($"Quantity decimal places exceed the specified unit-of-measure ({UnitOfMeasure.Text}) configuration of {UnitOfMeasure.Scale}."); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs b/samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs new file mode 100644 index 00000000..d898ef28 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs @@ -0,0 +1,61 @@ +namespace Contoso.Shopping.Infrastructure.Adapters; + +[ScopedService] +public class ProductAdapter(ShoppingEfDb ef, IEventPublisher eventPublisher, ProductsHttpClient client, [FromKeyedServices("AzureServiceBus")] IEventPublisher serviceBusPublisher) : IProductAdapter +{ + private readonly ShoppingEfDb _ef = ef.ThrowIfNull(); + private readonly IEventPublisher _eventPublisher = eventPublisher.ThrowIfNull(); + private readonly ProductsHttpClient _client = client.ThrowIfNull(); + private readonly IEventPublisher _serviceBusPublisher = serviceBusPublisher.ThrowIfNull(); + + /// + /// Leverages the internal event-based replication store. + public Task> GetAsync(string id) + => Result.GoAsync(() => _ef.Products.GetWithResultAsync(id)) + .ThenAs(p => ProductMapper.From.Map(p)); + + /// + /// Invokes the Products API directly (real-time) to perform reservation; resulting BusinessException will bubble out. + public async Task ReserveInventoryAsync(Domain.Basket basket) + { + // Get the list of non-stocked products in the basket; we don't need to reserve inventory for those, and we want to avoid sending them in the reservation request to the Products API. + var products = basket.Items.Select(i => i.ProductId).ToArray(); + products = await _ef.Products.Query().Where(p => products.Contains(p.Id!) && !p.IsNonStocked).Select(p => p.Id!).ToArrayAsync(); + + // Check where no inventory reservation needed, so return success immediately; i.e. all products in the basket are non-stocked. + if (products.Length == 0) + return Result.Success; + + // Create the reservation request for the basket. + var req = new Clients.MovementRequest + { + Id = basket.Id, + Products = basket.Items.Where(i => products.Contains(i.ProductId)).ToDataMap( + x => x.ProductId, + x => new Clients.MovementRequestProduct + { + Quantity = x.Pricing.Quantity, + UnitOfMeasure = x.Pricing.UnitOfMeasure + }) + }; + + // Reserve the inventory for the basket using the typed http client; if successful, return the basket (unchanged). + return await _client.CreateReservationAsync(req).ConfigureAwait(false); + } + + /// + /// Confirms the inventory reservation for the basket by creating the necessary confirmation command-style message to be sent/published as part of the consuming check-out unit-of-work. + public Task CreateConfirmReservationCommand(Domain.Basket basket) + { + _eventPublisher.Add(EventData.CreateCommand("products", "reservation", "confirm").WithKey(basket.Id)); + return Result.SuccessTask; + } + + /// + /// This is invoked when the check-out unit-of-work fails; therefore, we need to bypass the Outbox (it may have been the failure point) and send via the message broker directly. + public Task CancelReservationAsync(Domain.Basket basket) + { + _serviceBusPublisher.Add(EventData.CreateCommand("products", "reservation", "cancel").WithKey(basket.Id)); + return Result.GoAsync(() => _serviceBusPublisher.PublishAsync()); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductSyncAdapter.cs b/samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductSyncAdapter.cs new file mode 100644 index 00000000..422d84d7 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductSyncAdapter.cs @@ -0,0 +1,25 @@ +namespace Contoso.Shopping.Infrastructure.Adapters; + +[ScopedService] +public class ProductSyncAdapter(ShoppingEfDb ef) : IProductSyncAdapter +{ + private readonly ShoppingEfDb _ef = ef.ThrowIfNull(); + + /// + /// Persists to the internal event-based replication store. + public async Task ModifyAsync(Product product) + { + var model = ProductMapper.To.Map(product); + var exists = await ExistsAsync(model.Id!).ConfigureAwait(false); + if (!exists) + return (await _ef.Products.CreateWithResultAsync(model).ConfigureAwait(false)).AsResult(); + else + return (await _ef.Products.UpdateWithResultAsync(model).ConfigureAwait(false)).AsResult(); + } + + private Task ExistsAsync(string id) => _ef.Products.Query().AnyAsync(p => p.Id == id); + + /// + /// Removes the product from the internal event-based replication store. + public async Task DeleteAsync(string id) => (await _ef.Products.DeleteWithResultAsync(id).ConfigureAwait(false)).AsResult(); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Clients/MovementRequest.cs b/samples/src/Contoso.Shopping.Infrastructure/Clients/MovementRequest.cs new file mode 100644 index 00000000..e7a7b44d --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Clients/MovementRequest.cs @@ -0,0 +1,8 @@ +namespace Contoso.Shopping.Infrastructure.Clients; + +public class MovementRequest : IIdentifier +{ + public string? Id { get; set; } + + public DataMap? Products { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Clients/MovementRequestProduct.cs b/samples/src/Contoso.Shopping.Infrastructure/Clients/MovementRequestProduct.cs new file mode 100644 index 00000000..a404fd0c --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Clients/MovementRequestProduct.cs @@ -0,0 +1,9 @@ +namespace Contoso.Shopping.Infrastructure.Clients; + +public class MovementRequestProduct +{ + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public decimal Quantity { get; set; } + + public string? UnitOfMeasure { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Clients/ProductsHttpClient.cs b/samples/src/Contoso.Shopping.Infrastructure/Clients/ProductsHttpClient.cs new file mode 100644 index 00000000..732b6f1a --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Clients/ProductsHttpClient.cs @@ -0,0 +1,20 @@ +namespace Contoso.Shopping.Infrastructure.Clients; + +/// +/// Provides the HTTP facade for interacting with the external Products API. +/// +/// The . +public class ProductsHttpClient(HttpClient httpClient) +{ + private readonly HttpClient _httpClient = httpClient.ThrowIfNull(); + + /// + /// Creates a new inventory reservation. + /// + /// The . + public async Task CreateReservationAsync(MovementRequest request) + { + var response = await _httpClient.PostAsJsonAsync("api/inventory/reserve", request, JsonDefaults.SerializerOptions); + return await response.ToResultAsync(); // Handles the response and returns errors/exceptions as expected. + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Contoso.Shopping.Infrastructure.csproj b/samples/src/Contoso.Shopping.Infrastructure/Contoso.Shopping.Infrastructure.csproj new file mode 100644 index 00000000..aafe3597 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Contoso.Shopping.Infrastructure.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/samples/src/Contoso.Shopping.Infrastructure/GlobalUsing.cs b/samples/src/Contoso.Shopping.Infrastructure/GlobalUsing.cs new file mode 100644 index 00000000..8b57e200 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/GlobalUsing.cs @@ -0,0 +1,25 @@ +global using Contoso.Shopping.Application.Adapters.Products; +global using Contoso.Shopping.Application.Repositories; +global using Contoso.Shopping.Infrastructure.Clients; +global using Contoso.Shopping.Infrastructure.Mapping; +global using Contoso.Shopping.Infrastructure.Repositories; +global using CoreEx; +global using CoreEx.Data.Models; +global using CoreEx.Database; +global using CoreEx.Database.SqlServer; +global using CoreEx.Database.SqlServer.Outbox; +global using CoreEx.DependencyInjection; +global using CoreEx.DomainDriven; +global using CoreEx.Entities; +global using CoreEx.EntityFrameworkCore; +global using CoreEx.EntityFrameworkCore.Converters; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Json; +global using CoreEx.Mapping; +global using CoreEx.Results; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using System.Net.Http.Json; +global using System.Text.Json.Serialization; diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketIntoMapper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketIntoMapper.cs new file mode 100644 index 00000000..3c8f92af --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketIntoMapper.cs @@ -0,0 +1,15 @@ +namespace Contoso.Shopping.Infrastructure.Mapping; + +internal sealed class BasketIntoMapper : IntoMapper +{ + protected override void OnMapInto(Domain.Basket source, Persistence.Basket destination) + { + destination.Id = source.Id; + destination.CustomerId = source.CustomerId; + destination.BasketStatusCode = source.Status; + destination.SubTotal = source.SubTotal; + destination.DiscountCouponCode = source.DiscountCoupon?.Code; + destination.DiscountAmount = source.DiscountAmount; + destination.Total = source.Total; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketItemIntoMapper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketItemIntoMapper.cs new file mode 100644 index 00000000..1671c79d --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketItemIntoMapper.cs @@ -0,0 +1,15 @@ +namespace Contoso.Shopping.Infrastructure.Mapping; + +internal sealed class BasketItemIntoMapper : IntoMapper +{ + protected override void OnMapInto(Domain.BasketItem source, Persistence.BasketItem destination) + { + destination.Id = source.Id; + destination.ProductId = source.ProductId; + destination.Sku = source.Sku; + destination.Text = source.Text; + destination.UnitOfMeasureCode = source.Pricing.UnitOfMeasure; + destination.Quantity = source.Pricing.Quantity; + destination.UnitPrice = source.Pricing.UnitPrice; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketMapper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketMapper.cs new file mode 100644 index 00000000..bd048c4e --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketMapper.cs @@ -0,0 +1,29 @@ +namespace Contoso.Shopping.Infrastructure.Mapping; + +public class BasketMapper : Mapper +{ + protected override Domain.Basket OnMap(Persistence.Basket source) + { + var items = source.Items?.Select(i => Domain.BasketItem.CreateFrom( + i.Id, + i.ProductId, + i.Sku, + i.Text, + new Domain.ValueObjects.ItemPricing + { + UnitOfMeasure = i.UnitOfMeasureCode, + Quantity = i.Quantity, + UnitPrice = i.UnitPrice + }, + i.ETag)); + + return Domain.Basket.CreateFrom( + source.Id, + source.CustomerId, + source.BasketStatusCode, + source.DiscountCouponCode, + items, + ChangeLog.CreateFrom(source), + source.ETag); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketStatusMapper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketStatusMapper.cs new file mode 100644 index 00000000..1046d8e1 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketStatusMapper.cs @@ -0,0 +1,15 @@ +namespace Contoso.Shopping.Infrastructure.Mapping; + +internal class BasketStatusMapper : BiDirectionMapper +{ + protected override Persistence.BasketStatus OnMap(Contracts.BasketStatus source) => throw new NotImplementedException(); + + protected override Contracts.BasketStatus OnMap(Persistence.BasketStatus source) => new() + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + SortOrder = source.SortOrder, + IsInactive = !source.IsActive + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/DiscountCouponMaper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/DiscountCouponMaper.cs new file mode 100644 index 00000000..b73db8f7 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/DiscountCouponMaper.cs @@ -0,0 +1,18 @@ +namespace Contoso.Shopping.Infrastructure.Mapping; + +internal class DiscountCouponMapper : BiDirectionMapper +{ + protected override Persistence.DiscountCoupon OnMap(Contracts.DiscountCoupon source) => throw new NotImplementedException(); + + protected override Contracts.DiscountCoupon OnMap(Persistence.DiscountCoupon source) => new() + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + SortOrder = source.SortOrder, + DiscountPercentage = source.DiscountPercentage, + IsInactive = !source.IsActive, + StartsOn = source.StartsOn, + EndsOn = source.EndsOn + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/ProductMapper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/ProductMapper.cs new file mode 100644 index 00000000..2bdbf165 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/ProductMapper.cs @@ -0,0 +1,26 @@ +namespace Contoso.Shopping.Infrastructure.Mapping; + +public class ProductMapper : BiDirectionMapper +{ + protected override Persistence.Product OnMap(Product source) => new() + { + Id = source.Id!, + Sku = source.Sku!, + Text = source.Text!, + UnitOfMeasureCode = source.UnitOfMeasureCode!, + Price = source.Price, + IsInactive = source.IsInactive, + IsNonStocked = source.IsNonStocked + }; + + protected override Product OnMap(Persistence.Product source) => new() + { + Id = source.Id, + Sku = source.Sku, + Text = source.Text, + UnitOfMeasureCode = source.UnitOfMeasureCode, + Price = source.Price, + IsInactive = source.IsInactive, + IsNonStocked = source.IsNonStocked + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/UnitOfMeasureMapper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/UnitOfMeasureMapper.cs new file mode 100644 index 00000000..418c5f8c --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/UnitOfMeasureMapper.cs @@ -0,0 +1,16 @@ +namespace Contoso.Shopping.Infrastructure.Mapping; + +internal class UnitOfMeasureMapper : BiDirectionMapper +{ + protected override Persistence.UnitOfMeasure OnMap(Contracts.UnitOfMeasure source) => throw new NotImplementedException(); + + protected override Contracts.UnitOfMeasure OnMap(Persistence.UnitOfMeasure source) => new() + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + SortOrder = source.SortOrder, + Scale = source.Scale, + IsInactive = !source.IsActive + }; +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.cs new file mode 100644 index 00000000..70c8ad32 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.cs @@ -0,0 +1,6 @@ +namespace Contoso.Shopping.Infrastructure.Persistence; + +public partial class Basket +{ + public List? Items { get; set; } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs new file mode 100644 index 00000000..c9dcc033 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs @@ -0,0 +1,48 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Shopping.Infrastructure.Persistence; + +/// +/// Persistence model representing the '[Shopping].[Basket]' database table. +/// +/// The primary key column is 'BasketId' (type 'NVARCHAR(50)'). +public partial class Basket : ModelBase +{ + /// + /// Gets or sets the value of the 'CustomerId' column (type 'NVARCHAR(50)'). + /// + public string CustomerId { get; set; } = default!; + + /// + /// Gets or sets the value of the 'BasketStatusCode' column (type 'NVARCHAR(50)'). + /// + public string BasketStatusCode { get; set; } = default!; + + /// + /// Gets or sets the value of the 'SubTotal' column (type 'DECIMAL(18, 2)'). + /// + public decimal SubTotal { get; set; } + + /// + /// Gets or sets the value of the 'DiscountCouponCode' column (type 'NVARCHAR(50) NULL'). + /// + public string? DiscountCouponCode { get; set; } + + /// + /// Gets or sets the value of the 'DiscountAmount' column (type 'DECIMAL(18, 2)'). + /// + public decimal DiscountAmount { get; set; } + + /// + /// Gets or sets the value of the 'Total' column (type 'DECIMAL(18, 2)'). + /// + public decimal Total { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/BasketItem.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/BasketItem.g.cs new file mode 100644 index 00000000..ad7494ea --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/BasketItem.g.cs @@ -0,0 +1,53 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Shopping.Infrastructure.Persistence; + +/// +/// Persistence model representing the '[Shopping].[BasketItem]' database table. +/// +/// The primary key column is 'BasketItemId' (type 'NVARCHAR(50)'). +public partial class BasketItem : ModelBase +{ + /// + /// Gets or sets the value of the 'BasketId' column (type 'NVARCHAR(50)'). + /// + public string BasketId { get; set; } = default!; + + /// + /// Gets or sets the value of the 'ProductId' column (type 'NVARCHAR(50)'). + /// + public string ProductId { get; set; } = default!; + + /// + /// Gets or sets the value of the 'Sku' column (type 'NVARCHAR(50)'). + /// + public string Sku { get; set; } = default!; + + /// + /// Gets or sets the value of the 'Text' column (type 'NVARCHAR(250)'). + /// + public string Text { get; set; } = default!; + + /// + /// Gets or sets the value of the 'UnitOfMeasureCode' column (type 'NVARCHAR(50)'). + /// + public string UnitOfMeasureCode { get; set; } = default!; + + /// + /// Gets or sets the value of the 'UnitPrice' column (type 'DECIMAL(18, 2)'). + /// + public decimal UnitPrice { get; set; } + + /// + /// Gets or sets the value of the 'Quantity' column (type 'DECIMAL(18, 2)'). + /// + public decimal Quantity { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/BasketStatus.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/BasketStatus.g.cs new file mode 100644 index 00000000..86111770 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/BasketStatus.g.cs @@ -0,0 +1,16 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Shopping.Infrastructure.Persistence; + +/// +/// Persistence reference-data model representing the '[Shopping].[BasketStatus]' database table. +/// +public partial class BasketStatus : ReferenceDataModelBase { } + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/DiscountCoupon.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/DiscountCoupon.g.cs new file mode 100644 index 00000000..0faae699 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/DiscountCoupon.g.cs @@ -0,0 +1,22 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Shopping.Infrastructure.Persistence; + +/// +/// Persistence reference-data model representing the '[Shopping].[DiscountCoupon]' database table. +/// +public partial class DiscountCoupon : ReferenceDataModelBase +{ + /// + /// Gets or sets the value of the 'DiscountPercentage' column (type 'DECIMAL(18, 2)'). + /// + public decimal DiscountPercentage { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Product.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Product.g.cs new file mode 100644 index 00000000..542bf7e4 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Product.g.cs @@ -0,0 +1,48 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Shopping.Infrastructure.Persistence; + +/// +/// Persistence model representing the '[Shopping].[Product]' database table. +/// +/// The primary key column is 'ProductId' (type 'NVARCHAR(50)'). +public partial class Product : ModelBase +{ + /// + /// Gets or sets the value of the 'Sku' column (type 'NVARCHAR(50)'). + /// + public string Sku { get; set; } = default!; + + /// + /// Gets or sets the value of the 'Text' column (type 'NVARCHAR(250)'). + /// + public string Text { get; set; } = default!; + + /// + /// Gets or sets the value of the 'UnitOfMeasureCode' column (type 'NVARCHAR(50)'). + /// + public string UnitOfMeasureCode { get; set; } = default!; + + /// + /// Gets or sets the value of the 'Price' column (type 'DECIMAL(18, 2)'). + /// + public decimal Price { get; set; } + + /// + /// Gets or sets the value of the 'IsInactive' column (type 'BIT'). + /// + public bool IsInactive { get; set; } + + /// + /// Gets or sets the value of the 'IsNonStocked' column (type 'BIT'). + /// + public bool IsNonStocked { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/UnitOfMeasure.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/UnitOfMeasure.g.cs new file mode 100644 index 00000000..d3575dbf --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/UnitOfMeasure.g.cs @@ -0,0 +1,22 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Shopping.Infrastructure.Persistence; + +/// +/// Persistence reference-data model representing the '[Shopping].[UnitOfMeasure]' database table. +/// +public partial class UnitOfMeasure : ReferenceDataModelBase +{ + /// + /// Gets or sets the value of the 'Scale' column (type 'INT'). + /// + public int Scale { get; set; } +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Repositories/BasketRepository.cs b/samples/src/Contoso.Shopping.Infrastructure/Repositories/BasketRepository.cs new file mode 100644 index 00000000..4d4652f0 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Repositories/BasketRepository.cs @@ -0,0 +1,71 @@ +namespace Contoso.Shopping.Infrastructure.Repositories; + +[ScopedService] +public class BasketRepository(ShoppingEfDb ef) : IBasketRepository +{ + private readonly ShoppingEfDb _ef = ef.ThrowIfNull(); + + public Task> GetAsync(string id) => Result + .GoAsync(() => _ef.Baskets.GetWithResultAsync(id)) + .ThenAs(model => BasketMapper.Map(model)); + + public Task> CreateAsync(Domain.Basket basket) => Result + .Go(() => + { + var model = new Persistence.Basket(); + BasketIntoMapper.MapInto(basket, model); + return SynchronizeItems(basket, model); + }) + .ThenAsAsync(model => _ef.Baskets.CreateWithResultAsync(model)) + .ThenAs(b => BasketMapper.Map(b)); + + public Task> UpdateAsync(Domain.Basket basket) => Result + .GoAsync(() => _ef.Baskets.GetWithResultAsync(basket.Id)) + .Then(model => + { + BasketIntoMapper.MapInto(basket, model); + return SynchronizeItems(basket, model); + }) + .ThenAsAsync(model => _ef.Baskets.UpdateWithResultAsync(model)) + .ThenAs(basket => BasketMapper.Map(basket)); + + /// + /// Synchronize the items between the domain and model, ensuring the appropriate EntityState is set for each item based on its PersistenceState. + /// + private Result SynchronizeItems(Domain.Basket basket, Persistence.Basket model) + { + _ef.DbContext.Entry(model).State = EntityState.Modified; // Ensure parent is marked as modified. + + for (var i = 0; i < basket.Items.Count; i++) + { + switch (basket.Items[i].PersistenceState) + { + case PersistenceState.New: + var itemModel = new Persistence.BasketItem { BasketId = model.Id }; + BasketItemIntoMapper.MapInto(basket.Items[i], itemModel); + model.Items ??= []; + model.Items.Add(itemModel); + _ef.DbContext.Entry(itemModel).State = EntityState.Added; + break; + + case PersistenceState.Modified: + itemModel = model.Items!.SingleOrDefault(x => x.Id == basket.Items[i].Id) ?? throw new ConcurrencyException(/* Edge-case: item no longer found */); + if (!ETag.TryCompare(basket.Items[i], itemModel)) // Ensure item etag matches as EF-caches/uses its own read value. + return Result.ConcurrencyError(); + + BasketItemIntoMapper.MapInto(basket.Items[i], itemModel); + _ef.DbContext.Entry(itemModel).State = EntityState.Modified; + break; + + case PersistenceState.Removed: + itemModel = model.Items!.SingleOrDefault(x => x.Id == basket.Items[i].Id); + if (itemModel is not null) + _ef.DbContext.Entry(itemModel).State = EntityState.Deleted; + + break; + } + } + + return model; + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ReferenceDataRepository.cs b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ReferenceDataRepository.cs new file mode 100644 index 00000000..ce025dcf --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ReferenceDataRepository.cs @@ -0,0 +1,16 @@ +namespace Contoso.Shopping.Infrastructure.Repositories; + +[ScopedService] +public class ReferenceDataRepository(ShoppingEfDb ef) : IReferenceDataRepository +{ + private readonly ShoppingEfDb _ef = ef.ThrowIfNull(); + + public Task GetAllBasketStatusesAsync() + => _ef.BasketStatuses.Query().ToMappedItemsAsync(BasketStatusMapper.From); + + public Task GetAllDiscountCouponsAsync() + => _ef.DiscountCoupons.Query().ToMappedItemsAsync(DiscountCouponMapper.From); + + public Task GetAllUnitsOfMeasureAsync() + => _ef.UnitsOfMeasure.Query().ToMappedItemsAsync(UnitOfMeasureMapper.From); +} diff --git a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.cs b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.cs new file mode 100644 index 00000000..834c614b --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.cs @@ -0,0 +1,31 @@ +namespace Contoso.Shopping.Infrastructure.Repositories; + +public partial class ShoppingDbContext(DbContextOptions options, SqlServerDatabase database) : DbContext(options), IEfDbContext +{ + /// + public IDatabase BaseDatabase { get; } = database.ThrowIfNull(); + + /// + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + // Uses IDatabase.Connection to ensure the same database/connection is used. + if (!optionsBuilder.IsConfigured) + optionsBuilder.UseSqlServer(BaseDatabase.Connection).EnableDetailedErrors(true); + } + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Add the generated models to the model builder. + AddGeneratedModels(modelBuilder); + + // Extend the 'Shopping.Basket' model configuration. + modelBuilder.ThrowIfNull().Entity(e => + { + e.HasMany(r => r.Items).WithOne().HasPrincipalKey(p => p.Id).HasForeignKey(p => p.BasketId).OnDelete(DeleteBehavior.ClientCascade); + e.Navigation(r => r.Items).AutoInclude(true); + }); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs new file mode 100644 index 00000000..365dbb10 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs @@ -0,0 +1,130 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Shopping.Infrastructure.Repositories; + +public partial class ShoppingDbContext +{ + /// + /// Adds the generated models to the . + /// + /// The . + public void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder) + { + // Add the entity/model configuration for the [Shopping].[BasketStatus] database table. + modelBuilder.Entity(e => + { + e.ToTable("BasketStatus", "Shopping"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("BasketStatusId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + }); + + // Add the entity/model configuration for the [Shopping].[DiscountCoupon] database table. + modelBuilder.Entity(e => + { + e.ToTable("DiscountCoupon", "Shopping"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("DiscountCouponId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + e.Property(p => p.DiscountPercentage).HasColumnName("DiscountPercentage").HasColumnType("DECIMAL(18, 2)"); + e.Property(p => p.StartsOn).HasColumnName("StartsOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.EndsOn).HasColumnName("EndsOn").HasColumnType("DATETIMEOFFSET"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.Description); + }); + + // Add the entity/model configuration for the [Shopping].[UnitOfMeasure] database table. + modelBuilder.Entity(e => + { + e.ToTable("UnitOfMeasure", "Shopping"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("UnitOfMeasureId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + e.Property(p => p.Scale).HasColumnName("Scale").HasColumnType("INT"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + }); + + // Add the entity/model configuration for the [Shopping].[Basket] database table. + modelBuilder.Entity(e => + { + e.ToTable("Basket", "Shopping"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("BasketId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.CustomerId).HasColumnName("CustomerId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.BasketStatusCode).HasColumnName("BasketStatusCode").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.SubTotal).HasColumnName("SubTotal").HasColumnType("DECIMAL(18, 2)"); + 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.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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + }); + + // Add the entity/model configuration for the [Shopping].[BasketItem] database table. + modelBuilder.Entity(e => + { + e.ToTable("BasketItem", "Shopping"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("BasketItemId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.BasketId).HasColumnName("BasketId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.ProductId).HasColumnName("ProductId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Sku).HasColumnName("Sku").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.UnitOfMeasureCode).HasColumnName("UnitOfMeasureCode").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.UnitPrice).HasColumnName("UnitPrice").HasColumnType("DECIMAL(18, 2)"); + e.Property(p => p.Quantity).HasColumnName("Quantity").HasColumnType("DECIMAL(18, 2)"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Ignore(p => p.CreatedBy).Ignore(p => p.CreatedOn).Ignore(p => p.UpdatedBy).Ignore(p => p.UpdatedOn); + }); + + // Add the entity/model configuration for the [Shopping].[Product] database table. + modelBuilder.Entity(e => + { + e.ToTable("Product", "Shopping"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("ProductId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Sku).HasColumnName("Sku").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.UnitOfMeasureCode).HasColumnName("UnitOfMeasureCode").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Price).HasColumnName("Price").HasColumnType("DECIMAL(18, 2)"); + e.Property(p => p.IsInactive).HasColumnName("IsInactive").HasColumnType("BIT"); + e.Property(p => p.IsNonStocked).HasColumnName("IsNonStocked").HasColumnType("BIT"); + e.Ignore(p => p.CreatedBy).Ignore(p => p.CreatedOn).Ignore(p => p.UpdatedBy).Ignore(p => p.UpdatedOn).Ignore(p => p.ETag); + }); + } +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingEfDb.cs b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingEfDb.cs new file mode 100644 index 00000000..424cd8e8 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingEfDb.cs @@ -0,0 +1,18 @@ +namespace Contoso.Shopping.Infrastructure.Repositories; + +public sealed class ShoppingEfDb(ShoppingDbContext dbContext) : EfDb(dbContext, _options) +{ + private static readonly EfDbOptions _options = new(); + + public EfDbModel BasketStatuses => Model(); + + public EfDbModel DiscountCoupons => Model(); + + public EfDbModel UnitsOfMeasure => Model(); + + public EfDbModel Baskets => Model(); + + public EfDbModel BasketItems => Model(); + + public EfDbModel Products => Model(); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Outbox.Relay/Contoso.Shopping.Outbox.Relay.csproj b/samples/src/Contoso.Shopping.Outbox.Relay/Contoso.Shopping.Outbox.Relay.csproj new file mode 100644 index 00000000..ec39498f --- /dev/null +++ b/samples/src/Contoso.Shopping.Outbox.Relay/Contoso.Shopping.Outbox.Relay.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Outbox.Relay/Contoso.Shopping.Outbox.Relay.http b/samples/src/Contoso.Shopping.Outbox.Relay/Contoso.Shopping.Outbox.Relay.http new file mode 100644 index 00000000..53a27572 --- /dev/null +++ b/samples/src/Contoso.Shopping.Outbox.Relay/Contoso.Shopping.Outbox.Relay.http @@ -0,0 +1,6 @@ +@Contoso.Shopping.Outbox.Relay_HostAddress = http://localhost:5090 + +GET {{Contoso.Shopping.Outbox.Relay_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/samples/src/Contoso.Shopping.Outbox.Relay/Program.cs b/samples/src/Contoso.Shopping.Outbox.Relay/Program.cs new file mode 100644 index 00000000..89b119f1 --- /dev/null +++ b/samples/src/Contoso.Shopping.Outbox.Relay/Program.cs @@ -0,0 +1,64 @@ +using CoreEx.Azure.Messaging.ServiceBus; +using OpenTelemetry; +using OpenTelemetry.Trace; + +namespace Contoso.Shopping.Outbox.Relay; + +public class Program +{ + private static void Main(string[] args) + { + // Create the web builder. + var builder = WebApplication.CreateBuilder(args); + + // Add CoreEx host settings. + builder.AddHostSettings(); + + // Add CoreEx services. + builder.Services + .AddExecutionContext() + .AddMvcWebApi() + .AddHttpWebApi() + .AddHostedServiceManager(); + + // Add the repository and related outbox services. + builder.AddSqlServerClient("SqlServer"); // Adds the SqlServerClient (using Aspire library). + builder.Services + .AddSqlServerDatabase() // Adds the SqlServerDatabase. + .AddSqlServerUnitOfWork() // Adds the SqlServerUnitOfWork for the SqlServerDatabase. + .AddSqlServerOutboxRelay(); // Adds the SqlServerOutboxRelay. + + // Adds the SqlServerOutboxRelayHostedService. + builder.AddSqlServerOutboxRelayHostedService(); + + // Add the Azure Service Bus services. + builder.AddAzureServiceBusClient("ServiceBus"); // Adds azure service bus client using aspire. + builder.Services.AddAzureServiceBusPublisher((_, c) => // Adds the service bus as the IEventPublisher. + { + c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; // Use a partition-id as the session-id. + }); + + // Post-configure all health-checks; adds the standard tags. + builder.Services.PostConfigureAllHealthChecks(); + + // Add OpenTelemetry tracing. + builder.WithCoreExTelemetry() + .WithCoreExSqlServerTelemetry() + .WithCoreExServiceBusTelemetry() + .UseOtlpExporter(); + + // Build the application. + var app = builder.Build(); + + // Configure the pipeline/middleware (order is important). + app.UseCoreExExceptionHandler(); + app.UseHttpsRedirection(); + app.UseExecutionContext(); + + app.MapHealthChecks(); + app.MapHostedServices(); + + // Run the application. + app.Run(); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Outbox.Relay/Properties/launchSettings.json b/samples/src/Contoso.Shopping.Outbox.Relay/Properties/launchSettings.json new file mode 100644 index 00000000..4e6ac635 --- /dev/null +++ b/samples/src/Contoso.Shopping.Outbox.Relay/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5090", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7000;http://localhost:5090", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/src/Contoso.Shopping.Outbox.Relay/appsettings.Development.json b/samples/src/Contoso.Shopping.Outbox.Relay/appsettings.Development.json new file mode 100644 index 00000000..b2a2cccf --- /dev/null +++ b/samples/src/Contoso.Shopping.Outbox.Relay/appsettings.Development.json @@ -0,0 +1,27 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Azure": "Warning", + "Microsoft": "Warning", + "ZiggyCreature": "Warning", + "StackExchange": "Warning" + } + }, + "Aspire": { + "Microsoft": { + "Data": { + "SqlClient": { + "ConnectionString": "Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" + } + } + }, + "Azure": { + "Messaging": { + "ServiceBus": { + "ConnectionString": "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;" + } + } + } + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Outbox.Relay/appsettings.json b/samples/src/Contoso.Shopping.Outbox.Relay/appsettings.json new file mode 100644 index 00000000..d73f602d --- /dev/null +++ b/samples/src/Contoso.Shopping.Outbox.Relay/appsettings.json @@ -0,0 +1,25 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "Contoso", + "DomainName": "Shopping", + "Services": { + "Interval": "00:00:00.500", + "OutboxRelay": { + "BatchSize": 10, + "PerWorkerPartitionCount": 2, + "LeaseDuration": "00:00:05", + "BackoffDuration": "00:00:05", + "ServicesCount": 4 + } + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Subscribe/Contoso.Shopping.Subscribe.csproj b/samples/src/Contoso.Shopping.Subscribe/Contoso.Shopping.Subscribe.csproj new file mode 100644 index 00000000..f3c81a83 --- /dev/null +++ b/samples/src/Contoso.Shopping.Subscribe/Contoso.Shopping.Subscribe.csproj @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/src/Contoso.Shopping.Subscribe/Contoso.Shopping.Subscribe.http b/samples/src/Contoso.Shopping.Subscribe/Contoso.Shopping.Subscribe.http new file mode 100644 index 00000000..e156ce6c --- /dev/null +++ b/samples/src/Contoso.Shopping.Subscribe/Contoso.Shopping.Subscribe.http @@ -0,0 +1,6 @@ +@Contoso.Shopping.Subscribe_HostAddress = http://localhost:5057 + +GET {{Contoso.Shopping.Subscribe_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/samples/src/Contoso.Shopping.Subscribe/GlobalUsing.cs b/samples/src/Contoso.Shopping.Subscribe/GlobalUsing.cs new file mode 100644 index 00000000..89019830 --- /dev/null +++ b/samples/src/Contoso.Shopping.Subscribe/GlobalUsing.cs @@ -0,0 +1,9 @@ +global using Contoso.Shopping.Application.Adapters.Products; +global using Contoso.Shopping.Application.Validators; +global using CoreEx; +global using CoreEx.DependencyInjection; +global using CoreEx.Events; +global using CoreEx.Events.Subscribing; +global using CoreEx.Json; +global using CoreEx.Results; +global using CoreEx.Validation; \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Subscribe/Program.cs b/samples/src/Contoso.Shopping.Subscribe/Program.cs new file mode 100644 index 00000000..2796c815 --- /dev/null +++ b/samples/src/Contoso.Shopping.Subscribe/Program.cs @@ -0,0 +1,127 @@ +using Contoso.Shopping.Application; +using Contoso.Shopping.Infrastructure.Clients; +using Contoso.Shopping.Infrastructure.Repositories; +using Contoso.Shopping.Subscribe.Subscribers; +using CoreEx.Azure.Messaging.ServiceBus; +using CoreEx.Database; +using Microsoft.Extensions.Options; +using OpenTelemetry; +using OpenTelemetry.Trace; +using StackExchange.Redis; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; + +namespace Contoso.Shopping.Subscribe; + +public class Program +{ + private static void Main(string[] args) + { + // Create the web builder. + var builder = WebApplication.CreateBuilder(args); + + // Add CoreEx host settings. + builder.AddHostSettings(); + + // Add CoreEx services. + builder.Services + .AddExecutionContext() + .AddReferenceDataOrchestrator() + .AddMvcWebApi() + .AddHttpWebApi() + .AddHostedServiceManager(); + + // Add all the dynamically registered services. + builder.Services.AddDynamicServicesUsing(); + + // Add L1/L2 caching services. + builder.Services.AddMemoryCache(); // Adds the in-memory cache - L1. + builder.AddRedisDistributedCache("redis"); // Adds Redis as the distributed cache (using Aspire library) - L2. + + // Add and wire-up FusionCache including backplane. + builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = sp.GetRequiredService>().Value.ToString() })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); + + // Add CoreEx caching services. + builder.Services + .AddFusionHybridCache() // Adds the CoreEx.Caching.IHybridCache for FusionCache. + .AddDefaultCacheKeyProvider() // Adds the default CoreEx.Caching.ICacheKeyProvider. + .AddHybridCacheIdempotencyProvider(); // Adds the CoreEx.Caching.Idempotency.IIdempotencyProvider. + + // Add the repository and related outbox services. + builder.AddSqlServerClient("SqlServer"); // Adds the SqlServerClient (using Aspire library). + builder.Services + .AddSqlServerDatabase() // Adds the SqlServerDatabase. + .AddSqlServerUnitOfWork() // Adds the SqlServerUnitOfWork for the SqlServerDatabase. + .AddSqlServerOutboxPublisher() // Adds the SqlServerOutboxPublisher for the SqlServerUnitOfWork. + .AddDbContext() // Adds the standard EF DbContext. + .AddEfDb(); // Adds the CoreEx extended EF service. + + // Add Azure Service Bus client using Aspire. + builder.AddAzureServiceBusClient("ServiceBus"); + builder.Services.AddAzureServiceBusPublisher((_, c) => // Adds the service bus as the IEventPublisher. + { + c.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId; // Use a partition-id as the session-id. + }, addAsDefaultIEventPublisher: false); + + // Add event formatter and subscribed-manager. + builder.Services + .AddEventFormatter() // Adds the EventFormatter to enable message parsing. + .AddSubscribedManager((_, c) => c.AddSubscribersUsing()); // Adds the SubscribedManager and dynamically links to the individual Subscribers. + + // Creates the Azure Service Bus receiving services builder. + builder.Services.AzureServiceBusReceiving() + .WithSessionReceiver(_ => + { // Adds the service bus receiver to pump messages to the subscriber. + var o = ServiceBusSessionReceiverOptions.CreateForTopicSubscription(); // Set the topic and subscription from configuration. + o.SessionProcessorOptions.MaxConcurrentSessions = 4; // Set the maximum number of concurrent sessions to process. + return o; + }) + .WithSubscribedSubscriber() // Adds the service bus subscriber using the ^ SubscribedManager. + .WithHostedService() // Adds the ^ service bus receiver as a hosted service. + .Build(); // Builds all the ^ services and adds to the service collection. + + // Add external API services. + builder.AddTypedHttpClient("ProductsApi"); + + // Post-configure all health-checks; adds the standard tags. + builder.Services.PostConfigureAllHealthChecks(); + + // Add the ASP.NET Core services. + builder.Services.AddControllers(); + + // Add the OpenAPI services. + builder.Services.AddOpenApiDocument(s => + { + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); + }); + + // Add OpenTelemetry tracing. + builder.WithCoreExTelemetry() + .WithCoreExServiceBusTelemetry() + .WithCoreExSqlServerTelemetry() + .UseOtlpExporter(); + + // Build the application. + var app = builder.Build(); + + // Configure the pipeline/middleware (order is important). + app.UseCoreExExceptionHandler(); + app.UseHttpsRedirection(); + app.UseAuthorization(); + app.UseExecutionContext(); + app.MapControllers(); + + app.UseOpenApi(); + app.UseSwaggerUi(); + app.MapHealthChecks(); + app.MapHostedServices(); + + // Run the application. + app.Run(); + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Subscribe/Properties/launchSettings.json b/samples/src/Contoso.Shopping.Subscribe/Properties/launchSettings.json new file mode 100644 index 00000000..5e25153f --- /dev/null +++ b/samples/src/Contoso.Shopping.Subscribe/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5057", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7088;http://localhost:5057", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/src/Contoso.Shopping.Subscribe/Subscribers/ProductDeleteSubscriber.cs b/samples/src/Contoso.Shopping.Subscribe/Subscribers/ProductDeleteSubscriber.cs new file mode 100644 index 00000000..c0c86691 --- /dev/null +++ b/samples/src/Contoso.Shopping.Subscribe/Subscribers/ProductDeleteSubscriber.cs @@ -0,0 +1,11 @@ +namespace Contoso.Shopping.Subscribe.Subscribers; + +[ScopedService] +[Subscribe("contoso.products.product.deleted.v1")] +public class ProductDeleteSubscriber(IProductSyncAdapter adapter) : SubscribedBase +{ + private readonly IProductSyncAdapter _adapter = adapter.ThrowIfNull(); + + protected override Task OnReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) + => _adapter.DeleteAsync(@event.Key.Required()); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Subscribe/Subscribers/ProductModifySubscriber.cs b/samples/src/Contoso.Shopping.Subscribe/Subscribers/ProductModifySubscriber.cs new file mode 100644 index 00000000..0a6a9b88 --- /dev/null +++ b/samples/src/Contoso.Shopping.Subscribe/Subscribers/ProductModifySubscriber.cs @@ -0,0 +1,14 @@ +namespace Contoso.Shopping.Subscribe.Subscribers; + +[ScopedService] +[Subscribe("contoso.products.product.created.v1")] +[Subscribe("contoso.products.product.updated.v1")] +public class ProductModifySubscriber(IProductSyncAdapter adapter) : SubscribedBase +{ + private readonly IProductSyncAdapter _adapter = adapter.ThrowIfNull(); + + public override IValidator? ValueValidator => ProductValidator.Default; + + protected override Task OnReceiveAsync(Product value, EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) + => _adapter.ModifyAsync(value); +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Subscribe/appsettings.Development.json b/samples/src/Contoso.Shopping.Subscribe/appsettings.Development.json new file mode 100644 index 00000000..5ef20a69 --- /dev/null +++ b/samples/src/Contoso.Shopping.Subscribe/appsettings.Development.json @@ -0,0 +1,40 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Azure": "Warning", + "Microsoft": "Warning", + "ZiggyCreature": "Warning", + "StackExchange": "Warning", + "Azure.Messaging.ServiceBus": "Critical", + "CoreEx.Azure.Messaging.ServiceBus": "Warning" + } + }, + "Aspire": { + "Microsoft": { + "Data": { + "SqlClient": { + "ConnectionString": "Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" + } + } + }, + "StackExchange": { + "Redis": { + "ConnectionString": "localhost:6379", + "ConfigurationOptions": { + "ConnectTimeout": 3000, + "ConnectRetry": 2 + } + } + }, + "Azure": { + "Messaging": { + "ServiceBus": { + "ConnectionString": "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;", + "QueueOrTopicName": "contoso", + "SubscriptionName": "shopping" + } + } + } + } +} \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Subscribe/appsettings.json b/samples/src/Contoso.Shopping.Subscribe/appsettings.json new file mode 100644 index 00000000..6c943a3c --- /dev/null +++ b/samples/src/Contoso.Shopping.Subscribe/appsettings.json @@ -0,0 +1,18 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "Contoso", + "DomainName": "Shopping" + }, + "Events": { + "Destination": "contoso" // Topic/queue name. + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Contoso.E2E.Runner.csproj b/samples/tests/Contoso.E2E.Runner/Contoso.E2E.Runner.csproj new file mode 100644 index 00000000..104efa57 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Contoso.E2E.Runner.csproj @@ -0,0 +1,32 @@ + + + + Exe + net8.0;net9.0;net10.0 + enable + enable + preview + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/samples/tests/Contoso.E2E.Runner/GlobalUsing.cs b/samples/tests/Contoso.E2E.Runner/GlobalUsing.cs new file mode 100644 index 00000000..6dbbfb8b --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/GlobalUsing.cs @@ -0,0 +1,16 @@ +global using Contoso.E2E.Runner.Infrastructure; +global using Contoso.E2E.Runner.Scenarios; +global using Contoso.Products.Contracts; +global using Contoso.Shopping.Contracts; +global using CoreEx; +global using CoreEx.Json; +global using DbEx; +global using DbEx.Migration; +global using DbEx.SqlServer.Migration; +global using Microsoft.Extensions.Configuration; +global using Spectre.Console; +global using System.Collections.Concurrent; +global using System.Diagnostics; +global using System.Net.Http.Json; +global using System.Reflection; +global using System.Text; \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/ChoiceManager.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/ChoiceManager.cs new file mode 100644 index 00000000..175666a8 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/ChoiceManager.cs @@ -0,0 +1,60 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Provides a menu of choices for the user to select which scenario or set-up to run, and manages the execution of those choices. +/// +/// The . +public class ChoiceManager(TestContext context) +{ + private const string _requiresApi = " [grey](requires APIs)[/]"; + private readonly TestContext _context = context; + private readonly Dictionary RunAsync, ChoiceResult Result)> _choicesCache = []; + + /// + /// Caches choices so they can be displayed and executed efficiently. + /// + public void CacheChoices() + { + Task> RunScenarioAsync(ScenarioDefinition scenarioDefinition) => new ScenarioRunner(_context).RunScenarioAsync(scenarioDefinition); + + int i = 1; + foreach (var setUp in _context.SetUps) + _choicesCache.Add($"{i++}. {setUp.Value.Text}{(setUp.Value.Attribute.RequiresApi ? _requiresApi : string.Empty)}", (setUp.Value, 0, () => RunScenarioAsync(setUp.Value), ChoiceResult.ContinueWithPrompt)); + + foreach (var scenario in _context.Scenarios) + _choicesCache.Add($"{i++}. {scenario.Value.Text}{(scenario.Value.Attribute.RequiresApi ? _requiresApi : string.Empty)}", (scenario.Value, 1, () => RunScenarioAsync(scenario.Value), ChoiceResult.ContinueWithPrompt)); + + _choicesCache.Add($"{i++}. Run all scenarios as simulation{_requiresApi}", (null, 1, () => new LoadSimulationRunner(_context).RunAsync(), ChoiceResult.Continue)); + _choicesCache.Add($"{i++}. Retry APIs", (null, -2, () => Task.CompletedTask, ChoiceResult.RetryApi)); + _choicesCache.Add($"{i++}. Exit", (null, -1, () => Task.CompletedTask, ChoiceResult.Stop)); + } + + /// + /// Adds the cached choices to the provided , grouped by set-ups, scenarios, and other options. + /// + public SelectionPrompt AddRunnerChoices(SelectionPrompt prompt) + { + if (_choicesCache.Count == 0) + CacheChoices(); + + prompt.AddChoiceGroup("Set-up:", _choicesCache.Where(c => c.Value.Group == 0).Select(c => c.Key)); + prompt.AddChoiceGroup("Scenarios:", _choicesCache.Where(c => c.Value.Group == 1).Select(c => c.Key)); + prompt.AddChoiceGroup("Other:", _choicesCache.Where(c => c.Value.Group < 0).Select(c => c.Key)); + return prompt; + } + + /// + /// Executes the specified choice asynchronously and returns the result of the operation. + /// + public async Task RunChoiceAsync(string choice, bool apisHealthy) + { + if (!_choicesCache.TryGetValue(choice, out var item)) + throw new InvalidOperationException($"Invalid choice: {choice}"); + + if (item.Definition?.Attribute.RequiresApi == true && !apisHealthy) + return ChoiceResult.RequiresApi; + + await item.RunAsync(); + return item.Result; + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/ChoiceResult.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/ChoiceResult.cs new file mode 100644 index 00000000..c8266e29 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/ChoiceResult.cs @@ -0,0 +1,13 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Provides the possible results of executing a choice in the . +/// +public enum ChoiceResult +{ + Continue, + ContinueWithPrompt, + Stop, + RequiresApi, + RetryApi +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationConfig.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationConfig.cs new file mode 100644 index 00000000..904c6947 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationConfig.cs @@ -0,0 +1,17 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Configuration for load simulation settings. +/// +public class LoadSimulationConfig +{ + /// + /// Gets or sets the number of recent events to display. + /// + public int RecentEventsDisplayCount { get; set; } = 5; + + /// + /// Gets or sets the collection of simulator configuration settings, indexed by simulator name. + /// + public Dictionary Simulations { get; set; } = []; +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationRunner.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationRunner.cs new file mode 100644 index 00000000..49db237f --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationRunner.cs @@ -0,0 +1,296 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Manages the continuous load simulation with multiple parallel workers. +/// +public class LoadSimulationRunner +{ + private readonly TestContext _context; + private readonly LoadSimulationConfig _config; + private readonly RecentEventsBuffer _recentEvents; + private readonly WorkerStatistics _statistics = new(); + private readonly ConcurrentDictionary _scenarioStatistics = []; + private readonly string _errorLogPath; + private readonly object _errorLogLock = new(); + private readonly DateTimeOffset _startTimeUtc = DateTimeOffset.UtcNow; + + /// + /// Initializes a new instance of the class. + /// + public LoadSimulationRunner(TestContext context) + { + _context = context; + _config = new LoadSimulationConfig(); + _context.Config.GetSection("E2E").Bind(_config); + _recentEvents = new RecentEventsBuffer(_config.RecentEventsDisplayCount); + + foreach (var scenario in _context.Scenarios) + _scenarioStatistics[scenario.Key] = new WorkerStatistics(); + + // Set up error log path next to the exe + var logsDir = Path.Combine(AppContext.BaseDirectory, "logs"); + Directory.CreateDirectory(logsDir); + _errorLogPath = Path.Combine(logsDir, "load-simulation-errors.log"); + } + + /// + /// Runs the load simulation until ESC is pressed. + /// + public async Task RunAsync() + { + // Clear screen and start fresh. + AnsiConsole.Clear(); + + // Initialize error log (overwrite from previous run) + File.WriteAllText(_errorLogPath, $"Load Simulation Error Log - {DateTime.Now:yyyy-MM-dd HH:mm:ss}\n"); + File.AppendAllText(_errorLogPath, new string('=', 80) + "\n\n"); + + // Enable cancellation infrastructure. + using var cts = new CancellationTokenSource(); + var cancellationToken = cts.Token; + + // Start workers + var workers = new List(); + foreach (var scenario in _context.Scenarios) + workers.AddRange(RunWorkersAsync(scenario.Key, scenario.Value.Factory, cancellationToken)); + + // Use Spectre's built-in spinner. + var spinner = Spinner.Known.Dots; + var spinnerFrames = spinner.Frames.ToArray(); + var spinnerIndex = 0; + + // Start display and ESC listener. + await AnsiConsole.Live(CreateDisplay(spinnerFrames[0])) + .StartAsync(async ctx => + { + while (!cancellationToken.IsCancellationRequested) + { + // Check for ESC key + if (Console.KeyAvailable && Console.ReadKey(true).Key == ConsoleKey.Escape) + { + cts.Cancel(); + break; + } + + // Update spinner animation. + spinnerIndex = (spinnerIndex + 1) % spinnerFrames.Length; + + // Update display. + ctx.UpdateTarget(CreateDisplay(spinnerFrames[spinnerIndex])); + await Task.Delay(250); + } + + // Wait for all workers to complete their current iteration. + await Task.WhenAll(workers); + + // Final display update with actual final stats - checkmark instead of spinner. + ctx.UpdateTarget(CreateDisplay("✓")); + }); + + // Let user see final results + AnsiConsole.WriteLine(); + AnsiConsole.Markup("[grey]Press any key to continue...[/]"); + Console.ReadKey(true); + } + + /// + /// Spins up the configured number or workers and runs them in asynchronously parallel. + /// + private List RunWorkersAsync(string name, Func factory, CancellationToken cancellationToken) + { + var simulatorConfig = _config.Simulations.GetValueOrDefault(name, new LoadSimulationSimulatorConfig()); + var workers = new List(); + + for (int i = 0; i < simulatorConfig.Parallelism; i++) + { + var workerId = i + 1; + workers.Add(Task.Run(() => RunWorkerAsync($"{name}-{workerId}", simulatorConfig, factory, cancellationToken), cancellationToken)); + } + + return workers; + } + + /// + /// Create the scenario and run. + /// + private Task RunWorkerAsync(string workerName, LoadSimulationSimulatorConfig config, Func factory, CancellationToken cancellationToken) + { + var scenario = factory(); + return RunWorkerLoopAsync(config, workerName, scenario, cancellationToken); + } + + /// + /// Run the scenario in a loop until canceled. + /// + private async Task RunWorkerLoopAsync(LoadSimulationSimulatorConfig config, string workerName, IScenario scenario, CancellationToken cancellationToken) + { + var results = new List(); + var context = new ScenarioContext(_context, results, silentMode: true); + + // Extract scenario name from worker name (e.g., "Products-Update-1" -> "Products-Update") + var scenarioName = workerName[..workerName.LastIndexOf('-')]; + + while (!cancellationToken.IsCancellationRequested) + { + // Random delay between iterations. + if (!cancellationToken.IsCancellationRequested) + { + var delay = Random.Shared.Next(config.MinDelayMilliseconds, config.MaxDelayMilliseconds); + try + { + await Task.Delay(delay, cancellationToken); + } + catch (OperationCanceledException) + { + // Expected on cancellation + } + } + + var sw = Stopwatch.StartNew(); + results.Clear(); + + try + { + await scenario.RunAsync(context); + sw.Stop(); + + // Update both global and scenario-specific statistics + _statistics.RecordSuccess(); + _scenarioStatistics[scenarioName].RecordSuccess(); + _recentEvents.Add(workerName, true, "Completed", sw.Elapsed); + } + catch (Exception ex) + { + sw.Stop(); + + // Update both global and scenario-specific statistics + _statistics.RecordFailure(); + _scenarioStatistics[scenarioName].RecordFailure(); + + // Log full error details to file + LogError(workerName, ex); + + var errorMessage = ex.Message.Length > 100 ? ex.Message[..97] + "..." : ex.Message; + _recentEvents.Add(workerName, false, $"Failed: {errorMessage}", sw.Elapsed); + } + } + } + + /// + /// Logs full error details to the error log file. + /// + private void LogError(string workerName, Exception ex) + { + lock (_errorLogLock) + { + var errorEntry = new StringBuilder(); + errorEntry.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Worker: {workerName}"); + errorEntry.AppendLine($"Exception Type: {ex.GetType().FullName}"); + errorEntry.AppendLine($"Message: {ex.Message}"); + if (ex.InnerException != null) + { + errorEntry.AppendLine($"Inner Exception: {ex.InnerException.GetType().FullName}"); + errorEntry.AppendLine($"Inner Message: {ex.InnerException.Message}"); + } + errorEntry.AppendLine($"Stack Trace:"); + errorEntry.AppendLine(ex.StackTrace); + errorEntry.AppendLine(new string('-', 80)); + errorEntry.AppendLine(); + + File.AppendAllText(_errorLogPath, errorEntry.ToString()); + } + } + + /// + /// Creates the live display panel. + /// + private Panel CreateDisplay(string spinnerFrame) + { + // Create the stats table + var totalWorkers = 0; + var table = new Table().Border(TableBorder.Rounded).BorderColor(Color.Blue); + + // Make "Failed" column header a clickable link to error log + var failedHeader = _statistics.FailureCount > 0 + ? $"[red][link=file:///{_errorLogPath.Replace('\\', '/')}]Failed[/][/]" + : "Failed"; + + table.AddColumns( + new TableColumn("Scenario").LeftAligned(), + new TableColumn("Workers").Centered(), + new TableColumn("Iterations").RightAligned(), + new TableColumn("Success").RightAligned(), + new TableColumn(failedHeader).RightAligned(), + new TableColumn("Rate").RightAligned(), + new TableColumn("Throughput").RightAligned()); + + foreach (var scenario in _context.Scenarios.Keys) + { + var scenarioStatistics = _scenarioStatistics[scenario]; + var workers = _config.Simulations[scenario].Parallelism; + totalWorkers += workers; + + table.AddRow( + scenario.EscapeMarkup(), + workers.ToString(), + scenarioStatistics.TotalIterations.ToString(), + $"[green]{scenarioStatistics.SuccessCount}[/]", + $"[red]{scenarioStatistics.FailureCount}[/]", + $"{scenarioStatistics.SuccessRate:F1}%", + $"{scenarioStatistics.IterationsPerMinute:F0}/min"); + } + + // Add total row + table.AddRow( + "[bold]TOTAL[/]", + $"[bold]{totalWorkers}[/]", + $"[bold]{_statistics.TotalIterations}[/]", + $"[green bold]{_statistics.SuccessCount}[/]", + $"[red bold]{_statistics.FailureCount}[/]", + $"[bold]{_statistics.SuccessRate:F1}%[/]", + $"[bold]{_statistics.IterationsPerMinute:F0}/min[/]"); + + // Build recent events markup + var eventsContent = new StringBuilder(); + if (_config.RecentEventsDisplayCount > 0) + { + var recentEvents = _recentEvents.GetRecent(); + + if (recentEvents.Count > 0) + { + eventsContent.AppendLine(); + eventsContent.AppendLine($"[bold]Recent Events (last {Math.Min(recentEvents.Count, _config.RecentEventsDisplayCount)}):[/]"); + foreach (var evt in recentEvents.Take(_config.RecentEventsDisplayCount)) + { + var icon = evt.Success ? "[green]✓[/]" : "[red]✗[/]"; + var time = evt.Timestamp.ToString("HH:mm:ss"); + var workerName = evt.WorkerName.EscapeMarkup(); + var message = evt.Message.EscapeMarkup(); + eventsContent.AppendLine($" {time} [grey]{workerName}[/] {icon} {message} [grey]({evt.Duration.TotalMilliseconds:F0}ms)[/]"); + } + } + else + { + eventsContent.AppendLine(); + eventsContent.AppendLine("[grey]Waiting for events...[/]"); + } + } + + // Combine table and events using Rows + var combinedContent = new Rows( + table, + new Markup(eventsContent.ToString()) + ); + + var duration = DateTimeOffset.UtcNow - _startTimeUtc; + + // Create single panel with everything + return new Panel(combinedContent) + { + Header = new PanelHeader($"[bold blue] {spinnerFrame}[/] [bold]Load Simulation Running[/] {duration:c} [grey](Press ESC to stop)[/]"), + Border = BoxBorder.Double, + BorderStyle = new Style(Color.Blue), + Expand = false + }; + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationSimulatorConfig.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationSimulatorConfig.cs new file mode 100644 index 00000000..baf1e56d --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/LoadSimulationSimulatorConfig.cs @@ -0,0 +1,22 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Provides configuration options for the load simulation runner, including parallelism and delay settings between scenario iterations. +/// +public class LoadSimulationSimulatorConfig +{ + /// + /// Gets or sets the number of parallel workers for the simulation. + /// + public int Parallelism { get; set; } = 1; + + /// + /// Gets or sets the minimum delay in milliseconds between scenario iterations. + /// + public int MinDelayMilliseconds { get; set; } = 500; + + /// + /// Gets or sets the maximum delay in milliseconds between scenario iterations. + /// + public int MaxDelayMilliseconds { get; set; } = 3000; +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/RecentEventsBuffer.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/RecentEventsBuffer.cs new file mode 100644 index 00000000..6701bf95 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/RecentEventsBuffer.cs @@ -0,0 +1,75 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Represents a single event entry. +/// +public record EventEntry(DateTime Timestamp, string WorkerName, bool Success, string Message, TimeSpan Duration); + +/// +/// Thread-safe circular buffer for recent events. +/// +public class RecentEventsBuffer +{ + private readonly EventEntry?[] _buffer; + private readonly int _capacity; + private int _index; +#if NET8_0 + private readonly object _lock = new(); +#else + private readonly Lock _lock = new(); +#endif + + /// + /// Initializes a new instance of the class. + /// + /// The maximum number of events to store. + public RecentEventsBuffer(int capacity) + { + _capacity = capacity; + _buffer = new EventEntry?[capacity]; + _index = 0; + } + + /// + /// Adds an event to the buffer. + /// + public void Add(string workerName, bool success, string message, TimeSpan duration) + { + if (_capacity == 0) + return; + + var entry = new EventEntry(DateTime.Now, workerName, success, message, duration); + + lock (_lock) + { + _buffer[_index] = entry; + _index = (_index + 1) % _capacity; + } + } + + /// + /// Gets the most recent events in chronological order (oldest to newest). + /// + public List GetRecent() + { + if (_capacity == 0) + return []; + + lock (_lock) + { + var result = new List(_capacity); + + // Start from the oldest entry and go to the newest + for (int i = 0; i < _capacity; i++) + { + var entry = _buffer[(_index + i) % _capacity]; + if (entry != null) + result.Add(entry); + } + + // Reverse to show newest first + result.Reverse(); + return result; + } + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/RunnerAttribute.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/RunnerAttribute.cs new file mode 100644 index 00000000..70317945 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/RunnerAttribute.cs @@ -0,0 +1,38 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Provides the base attribute for defining test scenarios. +/// +public abstract class RunnerAttribute : Attribute +{ + /// + /// Gets the name. + /// + public string Name { get; protected init; } = string.Empty; + + /// + /// Gets the friendly text. + /// + public string Text { get; protected init; } = string.Empty; + + /// + /// Gets the preferred order to display. + /// + public int Order { get; protected init; } = 0; + + /// + /// Indicates whether the scenario requires API access. + /// + public bool RequiresApi { get; protected init; } = true; + + /// + /// Dynamically discovers and retrieves scenario definitions from the specified assemblies based on the presence of a specific -derived attribute. + /// + public static Dictionary GetScenarios(params IEnumerable[] assemblies) where TAttribute : RunnerAttribute + => (from assembly in assemblies.Distinct().SelectMany(a => a) + from type in assembly.GetTypes() + where !type.IsAbstract && !type.IsGenericTypeDefinition && typeof(IScenario).IsAssignableFrom(type) && type.GetConstructor(Type.EmptyTypes) != null + let attr = type.GetCustomAttributes(typeof(TAttribute), true).SingleOrDefault() as TAttribute + where attr is not null + select new ScenarioDefinition { Attribute = attr, Factory = () => (IScenario)Activator.CreateInstance(type)! }).ToDictionary(sd => sd.Attribute.Name); +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/ScenarioContext.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/ScenarioContext.cs new file mode 100644 index 00000000..9fe832ef --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/ScenarioContext.cs @@ -0,0 +1,66 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Provides the context passed to scenario execution for step tracking. +/// +public class ScenarioContext(TestContext context, List results, bool silentMode = false) +{ + private readonly TestContext _context = context; + private readonly List _results = results; + private readonly bool _silentMode = silentMode; + + public TestContext TestContext => _context; + + /// + /// Executes a step with timing, error handling, and reporting with a result. + /// + public async Task StepAsync(string stepName, Func> action, Func? detailsFormatter = null) + { + if (!_silentMode) + AnsiConsole.MarkupLine($"[grey]▶[/] {stepName.EscapeMarkup()}..."); + + var sw = Stopwatch.StartNew(); + + try + { + var result = await action(); + sw.Stop(); + + var details = detailsFormatter?.Invoke(result) ?? result?.ToString() ?? "Success"; + _results.Add(new StepResult(stepName, true, sw.Elapsed, details)); + + if (!_silentMode) + AnsiConsole.MarkupLine($"[green] ✓[/] {stepName.EscapeMarkup()} [grey]({sw.ElapsedMilliseconds}ms)[/]"); + + return result; + } + catch (Exception ex) + { + sw.Stop(); + _results.Add(new StepResult(stepName, false, sw.Elapsed, ex.Message, ex)); + + if (!_silentMode) + AnsiConsole.MarkupLine($"[red] ✗[/] {stepName.EscapeMarkup()} [grey]({sw.ElapsedMilliseconds}ms)[/]"); + + throw; + } + } + + /// + /// Executes a step with timing, error handling, and reporting with no result. + /// + public async Task StepAsync(string stepName, Func action, string? successDetails = null) + { + await StepAsync(stepName, async () => + { + await action(); + return successDetails ?? "Completed."; + }, details => details); + } + + /// + /// Adds a randomized delay to simulate a pseudo user (who is pretty bloody speedy) pause;versus, overwhelming bang-bang. + /// + public static Task RandomizedDelayAsync(ScenarioContext ctx, CancellationToken cancellationToken = default) + => Task.Delay(Random.Shared.Next(ctx.TestContext.PerStepMinDelayMilliseconds, ctx.TestContext.PerStepMaxDelayMilliseconds), cancellationToken); +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/ScenarioRunner.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/ScenarioRunner.cs new file mode 100644 index 00000000..697097ec --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/ScenarioRunner.cs @@ -0,0 +1,77 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Represents the result of a scenario step. +/// +public record StepResult(string Name, bool Success, TimeSpan Duration, string Details, Exception? Exception = null); + +/// +/// Provides scenario execution with progress tracking and visual reporting. +/// +public class ScenarioRunner(TestContext context) +{ + private readonly TestContext _context = context; + + /// + /// Runs a scenario with progress tracking. + /// + public async Task> RunScenarioAsync(ScenarioDefinition scenarioDefinition) + { + var results = new List(); + var context = new ScenarioContext(_context, results, silentMode: false); + + var scenario = scenarioDefinition.Factory(); + + AnsiConsole.Write(new Rule($"[bold blue]{scenarioDefinition.Text}[/]").RuleStyle("blue").LeftJustified()); + AnsiConsole.WriteLine(); + + try + { + await scenario.RunAsync(context); + } + catch (Exception) { } + + AnsiConsole.WriteLine(); + DisplayResults(scenarioDefinition.Text, results); + + return results; + } + + private static void DisplayResults(string title, List results) + { + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Grey) + .AddColumn(new TableColumn("[bold]Step[/]").LeftAligned()) + .AddColumn(new TableColumn("[bold]Status[/]").Centered()) + .AddColumn(new TableColumn("[bold]Duration[/]").RightAligned()) + .AddColumn(new TableColumn("[bold]Details[/]").LeftAligned()); + + foreach (var result in results) + { + var status = result.Success ? "[green]✓ PASS[/]" : "[red]✗ FAIL[/]"; + var duration = $"{result.Duration.TotalMilliseconds:F0}ms"; + var details = result.Details.Length > 200 ? result.Details[..197] + "..." : result.Details; + + table.AddRow( + result.Name.EscapeMarkup(), + status, + duration, + details.EscapeMarkup() + ); + } + + AnsiConsole.Write(table); + + var successCount = results.Count(r => r.Success); + var totalCount = results.Count; + var successRate = totalCount > 0 ? (double)successCount / totalCount * 100 : 0; + + var summaryColor = successCount == totalCount ? "green" : (successCount > 0 ? "yellow" : "red"); + var rule = new Rule($"[bold {summaryColor}]{title}: {successCount}/{totalCount} steps passed ({successRate:F0}%)[/]") + .RuleStyle(summaryColor); + + AnsiConsole.Write(rule); + AnsiConsole.WriteLine(); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/TestContext.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/TestContext.cs new file mode 100644 index 00000000..c5add121 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/TestContext.cs @@ -0,0 +1,78 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Provides the context for end-to-end testing, including configuration, HTTP clients, and scenario management. +/// +public sealed class TestContext +{ + private readonly ScenarioManager _scenarioManager; + private readonly SetUpManager _setUpManager; + + /// + /// Initializes a new instance of the class. + /// + public TestContext(IConfiguration config) + { + Config = config; + + ProductsHttpClient = new HttpClient { BaseAddress = new Uri(config["E2E:Products:BaseAddress"] ?? throw new InvalidOperationException("E2E:Products:BaseAddress configuration value is missing.")), Timeout = TimeSpan.FromSeconds(30) }; + ShoppingHttpClient = new HttpClient { BaseAddress = new Uri(config["E2E:Shopping:BaseAddress"] ?? throw new InvalidOperationException("E2E:Shopping:BaseAddress configuration value is missing.")), Timeout = TimeSpan.FromSeconds(30) }; + + PerStepMinDelayMilliseconds = config.GetValue("E2E:PerStepMinDelayMilliseconds"); + PerStepMaxDelayMilliseconds = config.GetValue("E2E:PerStepMaxDelayMilliseconds"); + + _scenarioManager = ScenarioManager.Create(); + _setUpManager = SetUpManager.Create(); + } + + /// + /// Gets the application's configuration settings. + /// + public IConfiguration Config { get; } + + /// + /// Gets the "Products" domain HTTP client configured with the base address and timeout specified in the configuration. + /// + public HttpClient ProductsHttpClient { get; } + + /// + /// Gets the "Shopping" domain HTTP client configured with the base address and timeout specified in the configuration. + /// + public HttpClient ShoppingHttpClient { get; } + + /// + /// Gets the collection of set-up scenario definitions, keyed by scenario name. + /// + public IReadOnlyDictionary SetUps => _setUpManager.SetUps; + + /// + /// Gets the collection of available scenario definitions, keyed by scenario name. + /// + public IReadOnlyDictionary Scenarios => _scenarioManager.Scenarios; + + /// + /// Gets the per step minimum delay in milliseconds. + /// + public int PerStepMinDelayMilliseconds { get; set; } + + /// + /// Gets the per step maximum delay in milliseonds. + /// + public int PerStepMaxDelayMilliseconds { get; set; } + + /// + /// Performs a health check against the "/health/ready" endpoint of the provided HTTP client to determine if the associated API is responsive and healthy. + /// + public static async Task HealthCheckAsync(HttpClient client) + { + try + { + var response = await client.GetAsync("/health/ready"); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Infrastructure/WorkerStatistics.cs b/samples/tests/Contoso.E2E.Runner/Infrastructure/WorkerStatistics.cs new file mode 100644 index 00000000..14e314b1 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Infrastructure/WorkerStatistics.cs @@ -0,0 +1,74 @@ +namespace Contoso.E2E.Runner.Infrastructure; + +/// +/// Thread-safe aggregator for worker statistics. +/// +public class WorkerStatistics +{ + private int _totalIterations; + private int _successCount; + private int _failureCount; + private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + + /// + /// Gets the total number of iterations. + /// + public int TotalIterations => Volatile.Read(ref _totalIterations); + + /// + /// Gets the number of successful iterations. + /// + public int SuccessCount => Volatile.Read(ref _successCount); + + /// + /// Gets the number of failed iterations. + /// + public int FailureCount => Volatile.Read(ref _failureCount); + + /// + /// Gets the elapsed time since the start of the simulation. + /// + public TimeSpan Elapsed => _stopwatch.Elapsed; + + /// + /// Gets the success rate as a percentage. + /// + public double SuccessRate + { + get + { + var total = TotalIterations; + return total > 0 ? (double)SuccessCount / total * 100 : 0; + } + } + + /// + /// Gets the throughput in iterations per minute. + /// + public double IterationsPerMinute + { + get + { + var elapsed = Elapsed.TotalMinutes; + return elapsed > 0 ? TotalIterations / elapsed : 0; + } + } + + /// + /// Records a successful iteration. + /// + public void RecordSuccess() + { + Interlocked.Increment(ref _totalIterations); + Interlocked.Increment(ref _successCount); + } + + /// + /// Records a failed iteration. + /// + public void RecordFailure() + { + Interlocked.Increment(ref _totalIterations); + Interlocked.Increment(ref _failureCount); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Program.cs b/samples/tests/Contoso.E2E.Runner/Program.cs new file mode 100644 index 00000000..de1ab622 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Program.cs @@ -0,0 +1,129 @@ +// Enable UTF-8 encoding for emoji and special character support. +Console.OutputEncoding = System.Text.Encoding.UTF8; + +// Build configuration +var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .AddEnvironmentVariables() + .Build(); + +var context = new TestContext(config); +(bool ProductApiOk, bool ShoppingApiOk) status = (false, false); + +/***** Main program and choice pump. *****/ + +// Check API health on startup. +await CheckApiStatusAsync(); + +// Present main menu and handle choices until user exits. +var choiceManager = new ChoiceManager(context); + +while (true) +{ + var choice = AnsiConsole.Prompt(choiceManager.AddRunnerChoices(new SelectionPrompt().Title($"Select [green]option[/]:"))); + AnsiConsole.WriteLine(); + + // Execute the selection choice. + var result = await choiceManager.RunChoiceAsync(choice, status.ProductApiOk && status.ShoppingApiOk); + + // Handle the result of the choice execution. + switch (result) + { + case ChoiceResult.Stop: + AnsiConsole.MarkupLine("[blue]Thanks for using Contoso E2E Runner :-)[/]"); + return; + + case ChoiceResult.RequiresApi: + AnsiConsole.MarkupLine("API(s) are not available :face_screaming_in_fear: - please start Contoso.Aspire [yellow]:oncoming_fist:[/]"); + ContinuePrompt(); + break; + + case ChoiceResult.RetryApi: + await CheckApiStatusAsync(); + break; + + case ChoiceResult.ContinueWithPrompt: + ContinuePrompt(); + break; + } + + // Refresh banner and config. + DisplayBannerAndConfig(); +} + +/***** Utility *****/ + +// Display main banner and configuration. +void DisplayBannerAndConfig() +{ + AnsiConsole.Clear(); + AnsiConsole.Write( + new FigletText("Contoso E2E") + .Centered() + .Color(Color.Orange3)); + + AnsiConsole.Write( + new Panel( + new Markup($"{(status.ProductApiOk ? "[green]:check_mark:[/] " : "[red]:cross_mark:[/]")} [grey]Products API:[/] {context.ProductsHttpClient.BaseAddress?.ToString().EscapeMarkup()}\n{(status.ShoppingApiOk ? "[green]:check_mark:[/] " : "[red]:cross_mark:[/]")} [grey]Shopping API:[/] {context.ShoppingHttpClient.BaseAddress?.ToString().EscapeMarkup()}")) + .Header("[bold]API status:[/]") + .BorderColor(Color.Grey) + .Padding(1, 0)); + + AnsiConsole.WriteLine(); +} + +// Present a prompt to continue, used after scenarios or when APIs are not healthy. +void ContinuePrompt() +{ + AnsiConsole.WriteLine(); + AnsiConsole.Markup("[grey]Press any key to continue...[/]"); + Console.ReadKey(true); +} + +// Check API health with option to cancel by pressing ESC. Returns true if APIs are healthy, false if not or if check was cancelled. +async Task CheckApiStatusAsync() +{ + DisplayBannerAndConfig(); + + status = await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync<(bool ProductApiOk, bool ShoppingApiOk)>("[grey]Checking API status... (press [yellow]ESC[/] to cancel)...[/]", async _ => + { + var healthCheckTask = Task.Run(async () => + { + var productApi = TestContext.HealthCheckAsync(context.ProductsHttpClient); + var shoppingApi = TestContext.HealthCheckAsync(context.ShoppingHttpClient); + + await Task.WhenAll(productApi, shoppingApi); + return (productApi.Result, shoppingApi.Result); + }); + + // Wait for health check or ESC key + bool wasCancelled = false; + while (!healthCheckTask.IsCompleted) + { + if (Console.KeyAvailable && Console.ReadKey(true).Key == ConsoleKey.Escape) + { + wasCancelled = true; + break; + } + + await Task.Delay(100); + } + + if (wasCancelled) + return (false, false); + else + return await healthCheckTask; + }); + + if (!status.ProductApiOk || !status.ShoppingApiOk) + { + DisplayBannerAndConfig(); + AnsiConsole.MarkupLine("API(s) are not available :no_bicycles: - please start the Contoso.Aspire application [yellow]:oncoming_fist:[/]"); + ContinuePrompt(); + } + + DisplayBannerAndConfig(); +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/DataSeedingSetup.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/DataSeedingSetup.cs new file mode 100644 index 00000000..5bd81db7 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/DataSeedingSetup.cs @@ -0,0 +1,37 @@ +namespace Contoso.E2E.Runner.Scenarios; + +/// +/// Provides data seeding for the E2E testing. +/// +[ScenarioSetUp("Data-Seeding", "Data Seeding for E2E Testing", 2)] +public class DataSeedingSetup : IScenario +{ + /// + public async Task RunAsync(ScenarioContext context) + { + // Step 1: Find all the products. + var products = await context.StepAsync("Find all products.", async () => + { + return await ProductUpdateScenario.GetAllProductsAsync(context); + }, result => $"{result!.Length} product(s) found"); + + // Step 2: Adjust inventory for each product to ensure they have plenty in stock. + await context.StepAsync("Adjust inventory for all active and stocked products.", async () => + { + var req = new MovementRequest + { + Id = Runtime.NewId(), + Products = products!.Where(x => !(x.IsInactive || x.IsNonStocked)).ToDataMap( + p => p.Id!, + p => new MovementRequestProduct + { + Quantity = 1000, + UnitOfMeasureCode = p.UnitOfMeasureCode + }) + }; + + var response = await context.TestContext.ProductsHttpClient.PostAsJsonAsync("/api/inventory/adjust", req, JsonDefaults.SerializerOptions); + return await response.GetValueAsync(); + }, result => $"{result!.Length} inventory adjustment(s)."); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/DatabaseMigrationSetup.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/DatabaseMigrationSetup.cs new file mode 100644 index 00000000..88571324 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/DatabaseMigrationSetup.cs @@ -0,0 +1,40 @@ +namespace Contoso.E2E.Runner.Scenarios; + +/// +/// Provides scenario setup for performing database migrations and refreshing base data for the Products and Shopping databases. +/// +[ScenarioSetUp("Database-Migration", "Database Migration and Base Data Refresh", 1, false)] +public sealed class DatabaseMigrationSetup : IScenario +{ + /// + public async Task RunAsync(ScenarioContext context) + { + // Step 1: Products database migration. + await context.StepAsync("Products database migration.", async () => + { + var cs = context.TestContext.Config.GetValue("E2E:Products:ConnectionString") ?? throw new InvalidOperationException("E2E:Products:ConnectionString configuration value is missing."); + var ma = new MigrationArgs(MigrationCommand.All | MigrationCommand.ResetAndData, cs); + Contoso.Products.Database.Program.ConfigureMigrationArgs(ma); + ma.AddAssembly(); + + using var m = new SqlServerMigration(ma); + var (Success, Output) = await m.MigrateAndLogAsync().ConfigureAwait(false); + if (!Success) + throw new Exception("Database migration failed:" + Environment.NewLine + Output); + }, "Successfully migrated; base data refreshed.").ConfigureAwait(false); + + // Step 2: Shopping database migration. + await context.StepAsync("Shopping database migration.", async () => + { + var cs = context.TestContext.Config.GetValue("E2E:Shopping:ConnectionString") ?? throw new InvalidOperationException("E2E:Shopping:ConnectionString configuration value is missing."); + var ma = new MigrationArgs(MigrationCommand.All | MigrationCommand.ResetAndData, cs); + Contoso.Shopping.Database.Program.ConfigureMigrationArgs(ma); + ma.AddAssembly(); + + using var m = new SqlServerMigration(ma); + var (Success, Output) = await m.MigrateAndLogAsync().ConfigureAwait(false); + if (!Success) + throw new Exception("Database migration failed:" + Environment.NewLine + Output); + }, "Successfully migrated; base data refreshed.").ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/IScenario.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/IScenario.cs new file mode 100644 index 00000000..ea8055ed --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/IScenario.cs @@ -0,0 +1,12 @@ +namespace Contoso.E2E.Runner.Scenarios; + +/// +/// Enables the execution. +/// +public interface IScenario +{ + /// + /// Executes the scenario asynchronously using the specified context. + /// + public Task RunAsync(ScenarioContext context); +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/ProductQuantityScenario.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/ProductQuantityScenario.cs new file mode 100644 index 00000000..04a76c74 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/ProductQuantityScenario.cs @@ -0,0 +1,41 @@ +using Azure; + +namespace Contoso.E2E.Runner.Scenarios; + +[Scenario("Products-Quantity", "Product Quantity Lifecycle", 2)] +public class ProductQuantityScenario : IScenario +{ + private static readonly SemaphoreSlim _semaphore = new(1); + private ProductLite[]? _products; + + /// + public async Task RunAsync(ScenarioContext context) + { + // Step 1: Find all the products (first time only). + _semaphore.Wait(); + try + { + if (_products is null) + { + _products = await context.StepAsync("Find all products.", async () => + { + return await ProductUpdateScenario.GetAllProductsAsync(context); + }, result => $"{result!.Length} product(s) found."); + + await ScenarioContext.RandomizedDelayAsync(context); + } + } + finally + { + _semaphore.Release(); + } + + // Step 2: Select a random product and get the quantity. + var product = _products![Random.Shared.Next(0, _products.Length - 1)]; + await context.StepAsync("Get product quantity.", async () => + { + var response = await context.TestContext.ProductsHttpClient.GetAsync($"/api/products/{product.Id}/on-hand"); + return await response.GetValueAsync(); + }, q => $"Product '{product.Sku}' has quantity: {q}."); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/ProductQueryScenario.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/ProductQueryScenario.cs new file mode 100644 index 00000000..7f330215 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/ProductQueryScenario.cs @@ -0,0 +1,66 @@ +namespace Contoso.E2E.Runner.Scenarios; + +/// +/// Scenario: Product query lifecycle. +/// +[Scenario("Products-Query", "Product Query Lifecycle", 1)] +public class ProductQueryScenario : IScenario +{ + /// + public async Task RunAsync(ScenarioContext context) + { + var sb = new StringBuilder(); + + var val = Random.Shared.Next(0, 3); + if (val == 0) + { + var categories = await context.StepAsync($"Get all categories.", async () => + { + var response = await context.TestContext.ProductsHttpClient.GetAsync($"/api/refdata/categories"); + return await response.GetValueAsync(); + }, result => "Categories retrieved successfully."); + + var category = categories![Random.Shared.Next(0, categories.Length)]; + sb.Append($"category eq '{category.Code}'"); + + await ScenarioContext.RandomizedDelayAsync(context); + } + + val = Random.Shared.Next(0, 3); + if (val == 0) + { + var brands = await context.StepAsync($"Get all brands.", async () => + { + var response = await context.TestContext.ProductsHttpClient.GetAsync($"/api/refdata/brands"); + return await response.GetValueAsync(); + }, result => "Brands retrieved successfully."); + + var brand = brands![Random.Shared.Next(0, brands.Length)]; + if (sb.Length > 0) + sb.Append(" and "); + + sb.Append($"brand eq '{brand.Code}'"); + + await ScenarioContext.RandomizedDelayAsync(context); + } + + val = Random.Shared.Next(0, 3); + if (val == 1 || val == 2) + { + if (sb.Length > 0) + sb.Append(" and "); + + if (val == 1) + sb.Append($"startswith(sku, 'Y')"); + else + sb.Append($"endswith(text, 's')"); + } + + await context.StepAsync($"Query {sb}", async () => + { + var response = await context.TestContext.ProductsHttpClient.GetAsync($"/api/products?$filter={sb}"); + var products = await response.GetValueAsync(); + return products; + }, result => $"Query returned {result!.Length} products."); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/ProductUpdateScenario.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/ProductUpdateScenario.cs new file mode 100644 index 00000000..32c0e7c5 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/ProductUpdateScenario.cs @@ -0,0 +1,68 @@ +namespace Contoso.E2E.Runner.Scenarios; + +/// +/// Scenario: Product update lifecycle. +/// +[Scenario("Products-Update", "Product Update Lifecycle", 1)] +public class ProductUpdateScenario : IScenario +{ + private static readonly SemaphoreSlim _semaphore = new(1); + private ProductLite[]? _products; + + /// + /// Gets all the products (limited to 100) for use in the scenario. + /// + public static async Task GetAllProductsAsync(ScenarioContext context) + { + var response = await context.TestContext.ProductsHttpClient.GetAsync("/api/products?$take=100"); + var products = await response.GetValueAsync(); + return products ?? []; + } + + /// + public async Task RunAsync(ScenarioContext context) + { + // Step 1: Find all the products (first time only). + _semaphore.Wait(); + try + { + if (_products is null) + { + _products = await context.StepAsync("Find all products.", async () => + { + return await GetAllProductsAsync(context); + }, result => $"{result!.Length} product(s) found."); + + await ScenarioContext.RandomizedDelayAsync(context); + } + } + finally + { + _semaphore.Release(); + } + + // Step 2: Select a random product and full get. + var index = Random.Shared.Next(0, _products!.Length - 1); + var p = _products[index]; + + var product = await context.StepAsync($"Get: '{p.Id}'.", async () => + { + var response = await context.TestContext.ProductsHttpClient.GetAsync($"/api/products/{p.Id}"); + return await response.GetValueAsync(); + }, result => "Product retrieved successfully."); + + await ScenarioContext.RandomizedDelayAsync(context); + + // Step 3: Update product description. + if (product!.Text!.EndsWith(" (updated)")) + product.Text = product.Text[..^10]; + else + product.Text += " (updated)"; + + await context.StepAsync($"Update: '{product.Id}'.", async () => + { + var response = await context.TestContext.ProductsHttpClient.PutAsJsonAsync($"/api/products/{product.Id}", product, JsonDefaults.SerializerOptions); + return await response.GetValueAsync(); + }, p => $"Product {p!.Sku} updated."); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioAttribute.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioAttribute.cs new file mode 100644 index 00000000..4ca9bfcc --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioAttribute.cs @@ -0,0 +1,18 @@ +namespace Contoso.E2E.Runner.Scenarios; + +/// +/// Provides the attribute to define an executable test scenario. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class ScenarioAttribute : RunnerAttribute +{ + /// + /// Initializes a new instance of the class. + /// + public ScenarioAttribute(string name, string text, int order = 0) + { + Name = name; + Text = text; + Order = order; + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioDefinition.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioDefinition.cs new file mode 100644 index 00000000..b099fbe6 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioDefinition.cs @@ -0,0 +1,16 @@ +using Contoso.E2E.Runner.Infrastructure; + +namespace Contoso.E2E.Runner.Scenarios; + +public sealed class ScenarioDefinition +{ + public required RunnerAttribute Attribute { get; init; } + + public required Func Factory { get; init; } + + public string Name => Attribute.Name; + + public string Text => Attribute.Text; + + public int Order => Attribute.Order; +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioManager.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioManager.cs new file mode 100644 index 00000000..3cf0f8de --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioManager.cs @@ -0,0 +1,22 @@ +namespace Contoso.E2E.Runner.Scenarios; + +/// +/// Provides functionality to discover and manage scenario definitions from assemblies using scenario attributes. +/// +public sealed class ScenarioManager +{ + /// + /// Creates a new instance of the class for the calling assembly. + /// + public static ScenarioManager Create() => Create(Assembly.GetCallingAssembly()); + + /// + /// Creates a new instance of the class for the specified assemblies. + /// + public static ScenarioManager Create(params IEnumerable assemblies) => new() { Scenarios = RunnerAttribute.GetScenarios(assemblies) }; + + /// + /// Gets the collection of scenario definitions, keyed by scenario name. + /// + public required Dictionary Scenarios { get; init; } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioSetUpAttribute.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioSetUpAttribute.cs new file mode 100644 index 00000000..3673dac8 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/ScenarioSetUpAttribute.cs @@ -0,0 +1,19 @@ +namespace Contoso.E2E.Runner.Scenarios; + +/// +/// Provides the attribute to define an executable off-off set-up scenario. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ScenarioSetUpAttribute : RunnerAttribute +{ + /// + /// Initializes a new instance of the class. + /// + public ScenarioSetUpAttribute(string name, string text, int order = 0, bool requiresApi = true) + { + Name = name; + Text = text; + Order = order; + RequiresApi = requiresApi; + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/SetUpManager.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/SetUpManager.cs new file mode 100644 index 00000000..40206cb5 --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/SetUpManager.cs @@ -0,0 +1,22 @@ +namespace Contoso.E2E.Runner.Scenarios; + +/// +/// Provides functionality to discover and manage one-off set-up scenario definitions from assemblies using scenario attributes. +/// +public sealed class SetUpManager +{ + /// + /// Creates a new instance of the class for the calling assembly. + /// + public static SetUpManager Create() => Create(Assembly.GetCallingAssembly()); + + /// + /// Creates a new instance of the class for the specified assemblies. + /// + public static SetUpManager Create(params IEnumerable assemblies) => new() { SetUps = RunnerAttribute.GetScenarios(assemblies) }; + + /// + /// Gets the collection of set-up scenario definitions, keyed by scenario name. + /// + public required Dictionary SetUps { get; init; } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/ShoppingBasketScenario.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/ShoppingBasketScenario.cs new file mode 100644 index 00000000..ca85d07d --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/ShoppingBasketScenario.cs @@ -0,0 +1,93 @@ +namespace Contoso.E2E.Runner.Scenarios; + +/// +/// Scenario: Shopping basket lifecycle. +/// +[Scenario("Shopping-Basket", "Shopping Basket Lifecycle", 2)] +public class ShoppingBasketScenario : IScenario +{ + private static readonly SemaphoreSlim _semaphore = new(1); + private ProductLite[]? _products; + + /// + public async Task RunAsync(ScenarioContext context) + { + // Step 1: Find all the products (first time only). + _semaphore.Wait(); + try + { + if (_products is null) + { + _products = await context.StepAsync("Find all products.", async () => + { + return await ProductUpdateScenario.GetAllProductsAsync(context); + }, result => $"{result!.Length} product(s) found."); + + await ScenarioContext.RandomizedDelayAsync(context); + } + } + finally + { + _semaphore.Release(); + } + + // Step 2: Create a new basket + var basket = await context.StepAsync("Create new basket.", async () => + { + var response = await context.TestContext.ShoppingHttpClient.PostAsync($"/api/customers/test/baskets", null); + return await response.GetValueAsync(); + }, b => $"Basket '{b!.Id}' created."); + + await ScenarioContext.RandomizedDelayAsync(context); + + // Step 3: Add items to the basket + for (int i = 0; i < Random.Shared.Next(1, 5); i++) + { + var product = _products![Random.Shared.Next(0, _products.Length - 1)]; + var quantity = Random.Shared.Next(1, 3); + + basket = await context.StepAsync($"Add {quantity} x {product.Sku}.", async () => + { + var item = new BasketItemAddRequest + { + ProductId = product.Id, + Quantity = quantity + }; + + var response = await context.TestContext.ShoppingHttpClient.PostAsJsonAsync($"/api/baskets/{basket!.Id}/items", item, JsonDefaults.SerializerOptions); + return await response.GetValueAsync(); + }, _ => "Item added."); + + await ScenarioContext.RandomizedDelayAsync(context); + } + + // Step 4: Apply discount coupon (10% off) + if (Random.Shared.Next(1, 3) == 2) + { + basket = await context.StepAsync("Apply discount coupon 'SAVE10'.", async () => + { + var response = await context.TestContext.ShoppingHttpClient.PutAsync($"/api/baskets/{basket!.Id}/apply-discount/SAVE10", null); + return await response.GetValueAsync(); + }, b => $"Discount applied."); + await ScenarioContext.RandomizedDelayAsync(context); + } + + await ScenarioContext.RandomizedDelayAsync(context); + + // Step 5: Checkout the basket + basket = await context.StepAsync("Checkout basket.", async () => + { + var response = await context.TestContext.ShoppingHttpClient.PostAsync($"/api/baskets/{basket!.Id}/checkout", null); + return await response.GetValueAsync(); + }, b => $"Basket checked-out."); + + await ScenarioContext.RandomizedDelayAsync(context); + + // Step 6: Get the basket. + basket = await context.StepAsync("Get checked-out basket.", async () => + { + var response = await context.TestContext.ShoppingHttpClient.GetAsync($"/api/baskets/{basket!.Id}"); + return await response.GetValueAsync() ?? throw new NotFoundException(); + }, b => $"Basket retrieved."); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.E2E.Runner/appsettings.json b/samples/tests/Contoso.E2E.Runner/appsettings.json new file mode 100644 index 00000000..df3ef63a --- /dev/null +++ b/samples/tests/Contoso.E2E.Runner/appsettings.json @@ -0,0 +1,49 @@ +{ + "E2E": { + "Products": { + "BaseAddress": "https://localhost:7200", + "ConnectionString": "Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true", + "ServiceBus": "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;", + "TopicName": "contoso", + "SubscriptionName": "products" + }, + "Shopping": { + "BaseAddress": "https://localhost:7219", + "ConnectionString": "Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true", + "ServiceBus": "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;", + "TopicName": "contoso", + "SubscriptionName": "shopping" + }, + "RecentEventsDisplayCount": 10, + "PerStepMinDelayMilliseconds": 100, + "PerStepMaxDelayMilliseconds": 500, + "Simulations": { + "Products-Query": { + "Parallelism": 2, + "MinDelayMilliseconds": 200, + "MaxDelayMilliseconds": 500 + }, + "Products-Update": { + "Parallelism": 2, + "MinDelayMilliseconds": 1000, + "MaxDelayMilliseconds": 5000 + }, + "Products-Quantity": { + "Parallelism": 2, + "MinDelayMilliseconds": 200, + "MaxDelayMilliseconds": 300 + }, + "Shopping-Basket": { + "Parallelism": 3, + "MinDelayMilliseconds": 250, + "MaxDelayMilliseconds": 750 + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "System.Net.Http": "Warning" + } + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/Contoso.Products.Test.Api.csproj b/samples/tests/Contoso.Products.Test.Api/Contoso.Products.Test.Api.csproj new file mode 100644 index 00000000..ea6ac750 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Contoso.Products.Test.Api.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + diff --git a/samples/tests/Contoso.Products.Test.Api/GlobalUsing.cs b/samples/tests/Contoso.Products.Test.Api/GlobalUsing.cs new file mode 100644 index 00000000..8b5d68f1 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/GlobalUsing.cs @@ -0,0 +1,11 @@ +global using Contoso.Products.Contracts; +global using CoreEx; +global using CoreEx.Http.Abstractions; +global using AwesomeAssertions; +global using NUnit.Framework; +global using System.Net; +global using System.Text.Json; +global using UnitTestEx; +global using UnitTestEx.Expectations; +global using DbMigration = Contoso.Products.Database.Program; +global using TestData = Contoso.Products.Test.Common.TestData; \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Adjust.cs b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Adjust.cs new file mode 100644 index 00000000..5e3c1d19 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Adjust.cs @@ -0,0 +1,51 @@ +namespace Contoso.Products.Test.Api; + +public partial class MovementMutateTests : WithApiTester +{ + [Test] + public void Adjust_Success() + { + // Arrange - get current on-hand quantities for the products to assert against after reservation + var p1 = 28.ToGuid().ToString(); + var p2 = 16.ToGuid().ToString(); + + var q1 = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p1}/on-hand") + .AssertOK() + .Value; + + var q2 = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p2}/on-hand") + .AssertOK() + .Value; + + // Act - reserve inventory for the products. + var req = new MovementRequest + { + Id = 791632.ToGuid().ToString(), + Products = new() + { + { p1, new MovementRequestProduct { Quantity = 33, UnitOfMeasureCode = "EA" } }, + { 14.ToGuid().ToString(), new MovementRequestProduct { Quantity = 0, UnitOfMeasureCode = "EA" } }, + { p2, new MovementRequestProduct { Quantity = 11, UnitOfMeasureCode = "EA" } } + } + }; + + Test.Http() + .ExpectSqlServerOutboxEvents(e => e.AssertAllFromJsonResource("MovementMutateTests.Adjust_Success.event.json")) + .Run(HttpMethod.Post, "/api/inventory/adjust", req) + .AssertOK() + .Value.Should().HaveCount(2); + + // Assert - on-hand quantities should be reduced by reserved amounts. + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p1}/on-hand") + .AssertOK() + .Value.Should().Be(33); + + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p2}/on-hand") + .AssertOK() + .Value.Should().Be(11); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Cancel.cs b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Cancel.cs new file mode 100644 index 00000000..e65e4081 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Cancel.cs @@ -0,0 +1,49 @@ +namespace Contoso.Products.Test.Api; + +public partial class MovementMutateTests +{ + [Test] + public void Cancel_Success() + { + // Arrange - get current on-hand quantities for the products to assert against after cancellation. + var p1 = 24.ToGuid().ToString(); + var p2 = 28.ToGuid().ToString(); + + var q1 = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p1}/on-hand") + .AssertOK() + .Value; + + var q2 = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p2}/on-hand") + .AssertOK() + .Value; + + // Act - cancel the reservation. + var referenceId = 1001.ToGuid().ToString(); + + Test.Http() + .ExpectSqlServerOutboxEvents(e => e.AssertCount(2)) + .Run(HttpMethod.Post, $"/api/inventory/reservation/{referenceId}/cancel") + .AssertOK() + .AssertJsonFromResource("MovementMutateTests.Cancel_Success.res.json", pathsToIgnore: ["etag", "changelog"]); + + // Assert - re-cancel, should result in not found as the reservation has already been confirmed and is no longer pending. + Test.Http() + .ExpectNoSqlServerOutboxEvents() + .Run(HttpMethod.Post, $"/api/inventory/reservation/{referenceId}/cancel") + .AssertNotFound() + .Value!.ErrorCode.Should().Be("pending-reservation-not-found"); + + // Assert - on-hand quantities should have been reversed by reserved amounts. + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p1}/on-hand") + .AssertOK() + .Value.Should().Be(q1 + 2); + + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p2}/on-hand") + .AssertOK() + .Value.Should().Be(q2 + 1); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Confirm.cs b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Confirm.cs new file mode 100644 index 00000000..4372376f --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Confirm.cs @@ -0,0 +1,23 @@ +namespace Contoso.Products.Test.Api; + +public partial class MovementMutateTests +{ + [Test] + public void Confirm_Success() + { + var referenceId = 1000.ToGuid().ToString(); + + Test.Http() + .ExpectSqlServerOutboxEvents(e => e.AssertCount(3)) + .Run(HttpMethod.Post, $"/api/inventory/reservation/{referenceId}/confirm") + .AssertOK() + .AssertJsonFromResource("MovementMutateTests.Confirm_Success.res.json", pathsToIgnore: ["etag", "changelog"]); + + // Re-confirm, should result in not found as the reservation has already been confirmed and is no longer pending. + Test.Http() + .ExpectNoSqlServerOutboxEvents() + .Run(HttpMethod.Post, $"/api/inventory/reservation/{referenceId}/confirm") + .AssertNotFound() + .Value!.ErrorCode.Should().Be("pending-reservation-not-found"); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Reserve.cs b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Reserve.cs new file mode 100644 index 00000000..75ede703 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Reserve.cs @@ -0,0 +1,91 @@ +namespace Contoso.Products.Test.Api; + +public partial class MovementMutateTests +{ + [Test] + public void Reserve_ProductNotFound() + { + var req = new MovementRequest + { + Id = 671953.ToGuid().ToString(), + Products = new() + { + { 9999.ToGuid().ToString(), new MovementRequestProduct { Quantity = 3, UnitOfMeasureCode = "EA" } } + } + }; + + Test.Http() + .Run(HttpMethod.Post, "/api/inventory/reserve", req) + .AssertBadRequest() + .AssertErrors(new ApiError("products.0000270f-0000-0000-0000-000000000000", "Product was not found.")); + } + + [Test] + public void Reserve_InsufficientStock() + { + var p1 = 28.ToGuid().ToString(); + var p2 = 16.ToGuid().ToString(); + + var req = new MovementRequest + { + Id = 671953.ToGuid().ToString(), + Products = new() + { + { p1, new MovementRequestProduct { Quantity = 3, UnitOfMeasureCode = "EA" } }, + { p2, new MovementRequestProduct { Quantity = 100, UnitOfMeasureCode = "EA" } } + } + }; + + Test.Http() + .Run(HttpMethod.Post, "/api/inventory/reserve", req) + .AssertBadRequest() + .AssertProblemDetailsTitle($"Product '{p2}' does not have sufficient quantity on hand."); + } + + [Test] + public void Reserve_Success() + { + // Arrange - get current on-hand quantities for the products to assert against after reservation + var p1 = 28.ToGuid().ToString(); + var p2 = 16.ToGuid().ToString(); + + var q1 = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p1}/on-hand") + .AssertOK() + .Value; + + var q2 = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p2}/on-hand") + .AssertOK() + .Value; + + // Act - reserve inventory for the products. + var req = new MovementRequest + { + Id = 671953.ToGuid().ToString(), + Products = new() + { + { p1, new MovementRequestProduct { Quantity = 3, UnitOfMeasureCode = "EA" } }, + { 14.ToGuid().ToString(), new MovementRequestProduct { Quantity = 0, UnitOfMeasureCode = "EA" } }, + { p2, new MovementRequestProduct { Quantity = 1, UnitOfMeasureCode = "EA" } } + } + }; + + Test.Http() + .ExpectSqlServerOutboxEvents(e => e.AssertAllFromJsonResource("MovementMutateTests.Reserve_Success.event.json")) + .Run(HttpMethod.Post, "/api/inventory/reserve", req) + .AssertOK() + .Value.Should().HaveCount(2); + + // Assert - on-hand quantities should be reduced by reserved amounts. + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p1}/on-hand") + .AssertOK() + .Value.Should().Be(q1 - 3); + + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p2}/on-hand") + .AssertOK() + .Value.Should().Be(q2 - 1); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.cs b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.cs new file mode 100644 index 00000000..bbe0d68f --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.cs @@ -0,0 +1,13 @@ +namespace Contoso.Products.Test.Api; + +public partial class MovementMutateTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + Test.UseExpectedSqlServerOutboxPublisher(); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/OtherTests.Health.cs b/samples/tests/Contoso.Products.Test.Api/OtherTests.Health.cs new file mode 100644 index 00000000..d1586fd0 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/OtherTests.Health.cs @@ -0,0 +1,39 @@ +namespace Contoso.Products.Test.Api; + +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 = + [ + "reference-data-orchestrator", + "stackExchange.Redis", + "sqlServer" + ]; + + 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); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/OtherTests.ReferenceData.cs b/samples/tests/Contoso.Products.Test.Api/OtherTests.ReferenceData.cs new file mode 100644 index 00000000..bc6aa0a7 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/OtherTests.ReferenceData.cs @@ -0,0 +1,65 @@ +namespace Contoso.Products.Test.Api; + +public partial class OtherTests +{ + [Test] + public void RefData_Categories() + { + Test.Http() + .Run(HttpMethod.Get, "/api/refdata/categories") + .AssertOK() + .Value.Should().HaveCountGreaterThan(0); + } + + [Test] + public void RefData_SubCategories() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/refdata/sub-categories") + .AssertOK() + .Value; + + r.Should().HaveCountGreaterThan(0); + r.Should().Contain(sc => sc.Code == "CA" && sc.Text == "Cassette" && sc.CategoryCode == "P"); + } + + [Test] + public void RefData_UnitsOfMeasure() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/refdata/units-of-measure") + .AssertOK() + .Value; + + r.Should().HaveCountGreaterThan(0); + r.Should().Contain(uom => uom.Code == "EA" && uom.Text == "Each" && uom.Scale == 0); + r.Should().Contain(uom => uom.Code == "HR" && uom.Text == "Hour" && uom.Scale == 2); + } + + [Test] + public void RefData_Brands() + { + Test.Http() + .Run(HttpMethod.Get, "/api/refdata/brands") + .AssertOK() + .Value.Should().HaveCountGreaterThan(0); + } + + [Test] + public void RefData_Named() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/refdata?name=sub-cateGORies&name=brands&name=subcategory&name=other") + .AssertOK() + .Value; + + r.ValueKind.Should().Be(JsonValueKind.Object); + r.EnumerateObject().Count().Should().Be(2); + + r.TryGetProperty("sub-categories", out var subCategory).Should().BeTrue(); + subCategory.GetArrayLength().Should().BeGreaterThan(0); + + r.TryGetProperty("brands", out var brands).Should().BeTrue(); + brands.GetArrayLength().Should().BeGreaterThan(0); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/OtherTests.Swagger.cs b/samples/tests/Contoso.Products.Test.Api/OtherTests.Swagger.cs new file mode 100644 index 00000000..77edfff9 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/OtherTests.Swagger.cs @@ -0,0 +1,34 @@ +namespace Contoso.Products.Test.Api; + +public partial class OtherTests +{ + [Test] + public void Swagger_UI() + { + // Hit swagger and assert redirect. + Test.Http() + .Run(HttpMethod.Get, "/swagger") + .Assert(HttpStatusCode.Found) + .AssertLocationHeader(new Uri("/swagger/index.html", UriKind.Relative)); + + // Go to redirected URL and assert basic content. + var html = Test.Http() + .Run(HttpMethod.Get, "/swagger/index.html") + .Assert(HttpStatusCode.OK) + .GetContent(); + + html.Should().Contain("Swagger UI"); + } + + [Test] + public void Swagger_Json() + { + var json = Test.Http() + .Run(HttpMethod.Get, "/swagger/v1/swagger.json") + .Assert(HttpStatusCode.OK) + .AssertContentTypeJson() + .GetContent(); + + json.Should().Contain("Contoso.Products.Api"); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/OtherTests.cs b/samples/tests/Contoso.Products.Test.Api/OtherTests.cs new file mode 100644 index 00000000..5834c260 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/OtherTests.cs @@ -0,0 +1,11 @@ +namespace Contoso.Products.Test.Api; + +public partial class OtherTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs new file mode 100644 index 00000000..d212ac98 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs @@ -0,0 +1,118 @@ +namespace Contoso.Products.Test.Api; + +public partial class ProductMutateTests : WithApiTester +{ + [Test] + public void Create_Bad_Data() + { + // Arrange. + var p = new Product + { + Sku = "abc", + Text = null, + Price = -1.99M, + SubCategoryCode = "XX", + BrandCode = "yeti", + }; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Post, "/api/products", p) + .AssertBadRequest() + .AssertErrors( + "Text is required.", + "Unit-of-measure is required.", + "Price must be greater than or equal to zero.", + "Sub-category is invalid." + ); + } + + [Test] + public void Create_Duplicate() + { + // Arrange. + var p = new Product + { + Sku = "Yeti-ASR-c2-2025", + Text = "Yeti ASR C2", + Price = 5800M, + SubCategoryCode = "XC", + UnitOfMeasureCode = "ea", + BrandCode = "yeti" + }; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Post, "/api/products", p) + .AssertConflict(); + } + + [Test] + public void Create_Success() + { + // Arrange. + var p = new Product + { + Sku = "New-SKU-123", + Text = "New Product", + Price = 1000M, + SubCategoryCode = "XC", + UnitOfMeasureCode = "ea", + BrandCode = "yeti" + }; + + // Act/Assert. + var r = Test.Http() + .ExpectIdentifier() + .ExpectETag() + .ExpectChangeLogCreated() + .ExpectJsonFromResource("ProductMutateTests.Create_Success.res.json") + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.products.product.created.v1")) + .Run(HttpMethod.Post, "/api/products", p) + .AssertCreated() + .AssertLocationHeader(r => new Uri($"/api/products/{r!.Id}", UriKind.Relative)) + .Value!; + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{r.Id}") + .AssertOK() + .AssertValue(r); + } + + [Test] + public void Create_IdempotencyKey() + { + // Arrange. + var p = new Product + { + Sku = "New-SKU-456", + Text = "Another New Product", + Price = 1200M, + SubCategoryCode = "XC", + UnitOfMeasureCode = "ea", + BrandCode = "yeti" + }; + + var ik = Guid.NewGuid().ToString(); + + // Act/Assert. + var v1 = Test.Http() + .ExpectSqlServerOutboxEvents() + .Run(HttpMethod.Post, "/api/products", p, requestModifier: r => r.WithIdempotencyKey(ik)) + .AssertCreated() + .AssertLocationHeader(r => new Uri($"/api/products/{r!.Id}", UriKind.Relative)) + .Value!; + + // Assert: repeat with same idempotency key; should get back same result & no extra event emitted. + var v2 = Test.Http() + .ExpectNoSqlServerOutboxEvents() + .Run(HttpMethod.Post, "/api/products", p, requestModifier: r => r.WithIdempotencyKey(ik)) + .AssertCreated() + .AssertLocationHeader(r => new Uri($"/api/products/{r!.Id}", UriKind.Relative)) + .Value!; + + // Assert: both results are the same. + ObjectComparer.Assert(v1, v2); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Delete.cs b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Delete.cs new file mode 100644 index 00000000..8956bfc4 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Delete.cs @@ -0,0 +1,59 @@ +namespace Contoso.Products.Test.Api; + +public partial class ProductMutateTests : WithApiTester +{ + [Test] + public void Delete_NotFound() + { + // Arrange/Act/Assert. + Test.Http() + .Run(HttpMethod.Delete, "/api/products/404") + .AssertNoContent(); + } + + [Test] + public void Delete_IsDeleted() + { + // Arrange/Act/Assert. + Test.Http() + .Run(HttpMethod.Delete, $"/api/products/{18.ToGuid()}") + .AssertNoContent(); + } + + [Test] + public void Delete_IsActive() + { + // Arrange/Act/Assert. + Test.Http() + .Run(HttpMethod.Delete, $"/api/products/{12.ToGuid()}") + .AssertBadRequest() + .AssertProblemDetailsTitle("A product must first be deactivated before it can be deleted."); + } + + [Test] + public void Delete_Success() + { + var id = 13.ToGuid().ToString(); + + // Arrange. + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{id}") + .AssertOK(); + + // Act. + Test.Http() + .ExpectSqlServerOutboxEvents(c => c.AssertMetadata("contoso", "contoso.products.product.deleted.v1", id)) + .Run(HttpMethod.Delete, $"/api/products/{id}") + .AssertNoContent(); + + // Assert idempotent. + Test.Http() + .Run(HttpMethod.Delete, $"/api/products/{id}") + .AssertNoContent(); + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{id}") + .AssertNotFound(); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Patch.cs b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Patch.cs new file mode 100644 index 00000000..26dedd58 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Patch.cs @@ -0,0 +1,107 @@ +namespace Contoso.Products.Test.Api; + +public partial class ProductMutateTests : WithApiTester +{ + [Test] + public void Patch_NotFound() + { + // Arrange/Act/Assert. + Test.Http() + .Run(HttpMethod.Patch, "/api/products/404", new { text = "abc" }, requestModifier: r => r.WithMergePatchJsonContentType()) + .AssertNotFound(); + } + + [Test] + public void Patch_Concurrency() + { + // Arrange. + var p = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{7.ToGuid()}") + .AssertOK() + .Value!; + + p.Text += " Patched"; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Patch, $"/api/products/{p.Id}", new { text = p.Text }, requestModifier: r => r.WithIfMatch("AAAAAAAA").WithMergePatchJsonContentType()) + .AssertPreconditionFailed(); + } + + [Test] + public void Patch_Validation() + { + // Arrange. + var p = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{7.ToGuid()}") + .AssertOK() + .Value!; + + p.Text += " Patched"; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Patch, $"/api/products/{p.Id}", new { text = p.Text, UnitOfMeasure = "XX" }, requestModifier: r => r.WithIfMatch(p.ETag).WithMergePatchJsonContentType()) + .AssertBadRequest() + .AssertErrors("Unit-of-measure is invalid."); + } + + [Test] + public void Patch_Success() + { + // Arrange. + var p = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{7.ToGuid()}") + .AssertOK() + .Value!; + + p.Text += " Patched"; + + // Act/Assert. + var u = Test.Http() + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.products.product.updated.v1")) + .Run(HttpMethod.Patch, $"/api/products/{p.Id}", new { text = p.Text }, requestModifier: r => r.WithIfMatch(p.ETag).WithMergePatchJsonContentType()) + .AssertOK() + .AssertValue(p, "etag", "changelog") + .Value!; + + u.Text.Should().Be(p.Text); + u.ETag.Should().NotBe(p.ETag); + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p.Id}") + .AssertOK() + .AssertValue(u, "etag", "changelog"); + } + + [Test] + public void Patch_NoChanges() + { + // Arrange. + var p = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{8.ToGuid()}") + .AssertOK() + .Value!; + + // Act/Assert. + var u = Test.Http() + .ExpectNoSqlServerOutboxEvents() + .Run(HttpMethod.Patch, $"/api/products/{p.Id}", new { }, requestModifier: r => r.WithIfMatch(p.ETag).WithMergePatchJsonContentType()) + .AssertOK() + .AssertValue(p, "etag", "changelog") + .Value!; + + u.Text.Should().Be(p.Text); + u.ETag.Should().Be(p.ETag); + u.ChangeLog.Should().NotBeNull(); + u.ChangeLog.UpdatedBy.Should().BeNull(); + u.ChangeLog.UpdatedOn.Should().BeNull(); + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p.Id}") + .AssertOK() + .AssertValue(u, "etag", "changelog"); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Update.cs b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Update.cs new file mode 100644 index 00000000..bf403cf4 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Update.cs @@ -0,0 +1,97 @@ +namespace Contoso.Products.Test.Api; + +public partial class ProductMutateTests : WithApiTester +{ + [Test] + public void Update_NotFound() + { + // Arrange. + var p = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{6.ToGuid()}") + .AssertOK() + .Value; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Put, "/api/products/404", p) + .AssertNotFound(); + } + + [Test] + public void Update_Concurrency() + { + // Arrange. + var p = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{6.ToGuid()}") + .AssertOK() + .Value!; + + p.Text += " Updated"; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Put, $"/api/products/{p.Id}", p, requestModifier: r => r.WithIfMatch("AAAAAAAA")) + .AssertPreconditionFailed(); + } + + [Test] + public void Update_Success() + { + // Arrange. + var p = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{6.ToGuid()}") + .AssertOK() + .Value!; + + p.Text += " Updated"; + + // Act/Assert. + var u = Test.Http() + .ExpectIdentifier() + .ExpectETag() + .ExpectChangeLogUpdated() + .ExpectValue(p) + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.products.product.updated.v1")) + .Run(HttpMethod.Put, $"/api/products/{p.Id}", p) + .AssertOK() + .Value!; + + u.Text.Should().Be(p.Text); + u.ETag.Should().NotBe(p.ETag); + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p.Id}") + .AssertOK() + .AssertValue(u); + } + + [Test] + public void Update_NoChanges() + { + // Arrange. + var p = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{6.ToGuid()}") + .AssertOK() + .Value!; + + // Act/Assert. + var u = Test.Http() + .Run(HttpMethod.Put, $"/api/products/{p.Id}", p) + .AssertOK() + .AssertValue(p, "etag", "changelog") + .Value!; + + u.Text.Should().Be(p.Text); + u.ETag.Should().Be(p.ETag); + u.ChangeLog.Should().NotBeNull(); + u.ChangeLog.UpdatedBy.Should().BeNull(); + u.ChangeLog.UpdatedOn.Should().BeNull(); + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{p.Id}") + .AssertOK() + .AssertValue(u); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.cs b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.cs new file mode 100644 index 00000000..59ef4c82 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.cs @@ -0,0 +1,13 @@ +namespace Contoso.Products.Test.Api; + +public partial class ProductMutateTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + Test.UseExpectedSqlServerOutboxPublisher(); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/ReadTests.MovementQuery.cs b/samples/tests/Contoso.Products.Test.Api/ReadTests.MovementQuery.cs new file mode 100644 index 00000000..876fec4c --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/ReadTests.MovementQuery.cs @@ -0,0 +1,25 @@ +using System; +namespace Contoso.Products.Test.Api; + +public partial class ReadTests +{ + [Test] + public void Movement_Query_All() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/inventory/movements") + .AssertOK() + .Value; + + r.Should().NotBeNull().And.HaveCount(5); + } + + [Test] + public void Movement_Query_Filter() + { + var r = Test.Http() + .Run(HttpMethod.Get, $"/api/inventory/movements?$filter=referenceid eq '{1000.ToGuid()}' and productid ne '{6.ToGuid()}' and kind eq 'I' and status eq 'p'") + .AssertOK() + .AssertJsonFromResource("Movement_Query_Filter.res.json", ["changelog", "etag"]); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductGet.cs b/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductGet.cs new file mode 100644 index 00000000..85020d2c --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductGet.cs @@ -0,0 +1,44 @@ +namespace Contoso.Products.Test.Api; + +public partial class ReadTests : WithApiTester +{ + [Test] + public void Product_Get_NotFound() + { + Test.Http() + .Run(HttpMethod.Get, "/api/products/404") + .AssertNotFound(); + } + + [Test] + public void Product_Get_IsDeleted() + { + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{18.ToGuid()}") + .AssertNotFound(); + } + + [Test] + public void Product_Get_Found() + { + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}") + .AssertOK() + .AssertJsonFromResource("ReadTests.Product_Get_Found.res.json", "etag", "changelog"); + } + + [Test] + public void Product_Get_Not_Modified() + { + var r = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}") + .AssertOK() + .Response; + + r.Headers.ETag.Should().NotBeNull(); + + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}", requestModifier: rm => rm.WithIfNoneMatch(r.Headers.ETag.Tag)) + .AssertNotModified(); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQtyOnHand.cs b/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQtyOnHand.cs new file mode 100644 index 00000000..57c16587 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQtyOnHand.cs @@ -0,0 +1,30 @@ +namespace Contoso.Products.Test.Api; + +public partial class ReadTests +{ + [Test] + public void Product_QtyOnHand_NotFound() + { + Test.Http() + .Run(HttpMethod.Get, "/api/products/404/on-hand") + .AssertNotFound(); + } + + [Test] + public void Product_QtyOnHand_None() + { + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{32.ToGuid()}/on-hand") + .AssertOK() + .Value.Should().Be(0); + } + + [Test] + public void Product_QtyOnHand_Value() + { + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}/on-hand") + .AssertOK() + .Value.Should().Be(3); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQuery.cs b/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQuery.cs new file mode 100644 index 00000000..992bd46b --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQuery.cs @@ -0,0 +1,100 @@ +namespace Contoso.Products.Test.Api; + +public partial class ReadTests : WithApiTester +{ + [Test] + public void Product_Query_Schema() + { + Test.Http() + .Run(HttpMethod.Get, "/api/products/$query") + .AssertOK() + .AssertJsonFromResource("ReadTests.Product_Query_Schema.res.json"); + } + + [Test] + public void Product_Query_All() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/products") + .AssertOK() + .Value; + + r.Should().NotBeNull().And.HaveCount(25); + r.Single(x => x.Id == 1.ToGuid().ToString()).QtyOnHand.Should().Be(3); + } + + [Test] + public void Product_Query_Paging() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/products?$skip=5&$take=10&$count=true") + .AssertOK(); + + r.Value.Should().NotBeNull().And.HaveCount(10); + r.Value.Should().HaveCount(10); + + r.Response.Headers.Should().ContainKey("X-Paging-Skip").WhoseValue.Should().ContainSingle().Which.Should().Be("5"); + r.Response.Headers.Should().ContainKey("X-Paging-Take").WhoseValue.Should().ContainSingle().Which.Should().Be("10"); + r.Response.Headers.Should().ContainKey("X-Paging-Total-Count").WhoseValue.Should().ContainSingle().Which.Should().Be("29"); + } + + [Test] + public void Product_Query_FilterBySku() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/products?$filter=startswith(Sku, 'spec')") + .AssertOK() + .Value; + + r.Should().NotBeNull() + .And.HaveCount(4) + .And.OnlyContain(p => p.Sku!.StartsWith("SPEC")).And.BeInAscendingOrder(x => x.Sku).And.NotContain(x => x.Sku == "SPEC-EPIC-8-PRO"); + } + + [Test] + public void Product_Query_FilterBySku_IncludeFields() + { + Test.Http() + .ExpectLogContains("ORDER BY [p].[Text] DESC, [p].[Sku]") + .Run(HttpMethod.Get, "/api/products?$filter=startswith(Sku, 'spec')&$fields=sku,text&$orderby=text desc") + .AssertOK() + .AssertJsonFromResource("ReadTests.Product_Query_FilterBySku_IncludeFields.res.json"); + } + + [Test] + public void Product_Query_FilterBySku_IncludeInactive() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/products?$filter=startswith(Sku, 'spec')&$inactive") + .AssertOK() + .Value; + + r.Should().NotBeNull() + .And.HaveCount(5) + .And.OnlyContain(p => p.Sku!.StartsWith("SPEC")).And.BeInAscendingOrder(x => x.Sku).And.Contain(x => x.Sku == "SPEC-EPIC-8-PRO"); + } + + [Test] + public void Product_Query_FilterByCategory() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/products?$filter=category eq 'm'") + .AssertOK() + .Value; + + r.Should().NotBeNull() + .And.HaveCount(1) + .And.OnlyContain(p => p.Sku == "LABOR"); + } + + [Test] + public void Product_Query_FilterByBrandAndSubCategory() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/products?$filter=subcategory eq 'xc' and brand in ('yeti', 'canyon')") + .AssertOK() + .Value; + + r.Should().NotBeNull().And.HaveCount(2); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/ReadTests.cs b/samples/tests/Contoso.Products.Test.Api/ReadTests.cs new file mode 100644 index 00000000..e71fa3c7 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/ReadTests.cs @@ -0,0 +1,11 @@ +namespace Contoso.Products.Test.Api; + +public partial class ReadTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Adjust_Success.event.json b/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Adjust_Success.event.json new file mode 100644 index 00000000..1241a837 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Adjust_Success.event.json @@ -0,0 +1,62 @@ +[ + { + "destination": "contoso", + "event": { + "specversion": "1.0", + "id": "019cced1-7956-7064-8369-e1f8512dda30", + "type": "contoso.products.movement.adjust.confirmed.v1", + "source": "urn:contoso:products", + "subject": "019cced1-78cb-7b7a-a5e3-e61958705cd4", + "time": "2026-03-08T18:59:20.399008Z", + "partitionkey": "019cced1-78cb-7b7a-a5e3-e61958705cd4", + "authtype": "user", + "authid": "METACORTEX\\thomas.anderson", + "traceparent": "00-e48c8aa42627e1cb8530989fc4b6beef-e8a010df8f010f8a-01", + "dataschemaversion": "1.0", + "datacontenttype": "application/json", + "data": { + "id": "019cced1-78cb-7b7a-a5e3-e61958705cd4", + "referenceId": "000c1450-0000-0000-0000-000000000000", + "kind": "A", + "status": "C", + "productId": "0000001c-0000-0000-0000-000000000000", + "quantity": 33, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-03-08T18:59:20.399008\u002B00:00" + } + } + } + }, + { + "destination": "contoso", + "event": { + "specversion": "1.0", + "id": "019cced1-7962-7395-8a72-0764d3cafa89", + "type": "contoso.products.movement.adjust.confirmed.v1", + "source": "urn:contoso:products", + "subject": "019cced1-78f8-7026-b422-4a56e4df5360", + "time": "2026-03-08T18:59:20.399008Z", + "partitionkey": "019cced1-78f8-7026-b422-4a56e4df5360", + "authtype": "user", + "authid": "METACORTEX\\thomas.anderson", + "traceparent": "00-e48c8aa42627e1cb8530989fc4b6beef-e8a010df8f010f8a-01", + "dataschemaversion": "1.0", + "datacontenttype": "application/json", + "data": { + "id": "019cced1-78f8-7026-b422-4a56e4df5360", + "referenceId": "000c1450-0000-0000-0000-000000000000", + "kind": "A", + "status": "C", + "productId": "00000010-0000-0000-0000-000000000000", + "quantity": 11, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-03-08T18:59:20.399008\u002B00:00" + } + } + } + } +] \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Cancel_Success.res.json b/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Cancel_Success.res.json new file mode 100644 index 00000000..991173f5 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Cancel_Success.res.json @@ -0,0 +1,34 @@ +[ + { + "id": "00000005-0000-0000-0000-000000000000", + "referenceId": "000003e9-0000-0000-0000-000000000000", + "kind": "I", + "status": "X", + "productId": "0000001c-0000-0000-0000-000000000000", + "quantity": -1.00, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-03-11T14:40:31.7590102\u002B00:00", + "updatedBy": "METACORTEX\\thomas.anderson", + "updatedOn": "2026-03-11T14:40:38.5301225\u002B00:00" + }, + "etag": "AAAAAAAAKAQ=" + }, + { + "id": "00000004-0000-0000-0000-000000000000", + "referenceId": "000003e9-0000-0000-0000-000000000000", + "kind": "I", + "status": "X", + "productId": "00000018-0000-0000-0000-000000000000", + "quantity": -2.00, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-03-11T14:40:31.7590102\u002B00:00", + "updatedBy": "METACORTEX\\thomas.anderson", + "updatedOn": "2026-03-11T14:40:38.5301225\u002B00:00" + }, + "etag": "AAAAAAAAKAM=" + } +] \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Confirm_Success.res.json b/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Confirm_Success.res.json new file mode 100644 index 00000000..9f9999eb --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Confirm_Success.res.json @@ -0,0 +1,50 @@ +[ + { + "id": "00000003-0000-0000-0000-000000000000", + "referenceId": "000003e8-0000-0000-0000-000000000000", + "kind": "I", + "status": "C", + "productId": "0000001f-0000-0000-0000-000000000000", + "quantity": -3.00, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-03-11T15:04:49.3962121\u002B00:00", + "updatedBy": "METACORTEX\\thomas.anderson", + "updatedOn": "2026-03-11T15:04:58.6529721\u002B00:00" + }, + "etag": "AAAAAAAAKNQ=" + }, + { + "id": "00000002-0000-0000-0000-000000000000", + "referenceId": "000003e8-0000-0000-0000-000000000000", + "kind": "I", + "status": "C", + "productId": "00000006-0000-0000-0000-000000000000", + "quantity": -1.00, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-03-11T15:04:49.3962121\u002B00:00", + "updatedBy": "METACORTEX\\thomas.anderson", + "updatedOn": "2026-03-11T15:04:58.6529721\u002B00:00" + }, + "etag": "AAAAAAAAKNM=" + }, + { + "id": "00000001-0000-0000-0000-000000000000", + "referenceId": "000003e8-0000-0000-0000-000000000000", + "kind": "I", + "status": "C", + "productId": "00000001-0000-0000-0000-000000000000", + "quantity": -2.00, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-03-11T15:04:49.3962121\u002B00:00", + "updatedBy": "METACORTEX\\thomas.anderson", + "updatedOn": "2026-03-11T15:04:58.6529721\u002B00:00" + }, + "etag": "AAAAAAAAKNI=" + } +] \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Reserve_Success.event.json b/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Reserve_Success.event.json new file mode 100644 index 00000000..c64cb48c --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Resources/MovementMutateTests/Reserve_Success.event.json @@ -0,0 +1,62 @@ +[ + { + "destination": "contoso", + "event": { + "specversion": "1.0", + "id": "019cced1-7956-7064-8369-e1f8512dda30", + "type": "contoso.products.movement.issue.pending.v1", + "source": "urn:contoso:products", + "subject": "019cced1-78cb-7b7a-a5e3-e61958705cd4", + "time": "2026-03-08T18:59:20.399008Z", + "partitionkey": "019cced1-78cb-7b7a-a5e3-e61958705cd4", + "authtype": "user", + "authid": "METACORTEX\\thomas.anderson", + "traceparent": "00-e48c8aa42627e1cb8530989fc4b6beef-e8a010df8f010f8a-01", + "dataschemaversion": "1.0", + "datacontenttype": "application/json", + "data": { + "id": "019cced1-78cb-7b7a-a5e3-e61958705cd4", + "referenceId": "000a40d1-0000-0000-0000-000000000000", + "kind": "I", + "status": "P", + "productId": "0000001c-0000-0000-0000-000000000000", + "quantity": -3, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-03-08T18:59:20.399008\u002B00:00" + } + } + } + }, + { + "destination": "contoso", + "event": { + "specversion": "1.0", + "id": "019cced1-7962-7395-8a72-0764d3cafa89", + "type": "contoso.products.movement.issue.pending.v1", + "source": "urn:contoso:products", + "subject": "019cced1-78f8-7026-b422-4a56e4df5360", + "time": "2026-03-08T18:59:20.399008Z", + "partitionkey": "019cced1-78f8-7026-b422-4a56e4df5360", + "authtype": "user", + "authid": "METACORTEX\\thomas.anderson", + "traceparent": "00-e48c8aa42627e1cb8530989fc4b6beef-e8a010df8f010f8a-01", + "dataschemaversion": "1.0", + "datacontenttype": "application/json", + "data": { + "id": "019cced1-78f8-7026-b422-4a56e4df5360", + "referenceId": "000a40d1-0000-0000-0000-000000000000", + "kind": "I", + "status": "P", + "productId": "00000010-0000-0000-0000-000000000000", + "quantity": -1, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-03-08T18:59:20.399008\u002B00:00" + } + } + } + } +] \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Create_Success.res.json b/samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Create_Success.res.json new file mode 100644 index 00000000..3c638de0 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Create_Success.res.json @@ -0,0 +1,16 @@ +{ + "id": "140d1f58-7927-47e4-a775-618b6516e2ab", + "sku": "NEW-SKU-123", + "text": "New Product", + "category": "B", + "subCategory": "XC", + "unitOfMeasure": "EA", + "brand": "YETI", + "price": 1000, + "isInactive": true, + "etag": "AAAAAAAACBU=", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-01-18T14:10:09.3076098\u002B00:00" + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Movement_Query_Filter.res.json b/samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Movement_Query_Filter.res.json new file mode 100644 index 00000000..264477f0 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Movement_Query_Filter.res.json @@ -0,0 +1,30 @@ +[ + { + "id": "00000001-0000-0000-0000-000000000000", + "referenceId": "000003e8-0000-0000-0000-000000000000", + "kind": "I", + "status": "P", + "productId": "00000001-0000-0000-0000-000000000000", + "quantity": -2.00, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-01-18T00:40:12.697994+00:00" + }, + "etag": "AAAAAAAEUUc=" + }, + { + "id": "00000003-0000-0000-0000-000000000000", + "referenceId": "000003e8-0000-0000-0000-000000000000", + "kind": "I", + "status": "P", + "productId": "0000001f-0000-0000-0000-000000000000", + "quantity": -3.00, + "unitOfMeasure": "EA", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-01-18T00:40:12.697994+00:00" + }, + "etag": "AAAAAAAEUUk=" + } +] \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Get_Found.res.json b/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Get_Found.res.json new file mode 100644 index 00000000..cc6f4f24 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Get_Found.res.json @@ -0,0 +1,15 @@ +{ + "id": "00000001-0000-0000-0000-000000000000", + "sku": "YETI-ASR-C2-2025", + "text": "Yeti ASR C2", + "category": "B", + "subCategory": "XC", + "unitOfMeasure": "EA", + "brand": "YETI", + "price": 5800.00, + "etag": "AAAAAAAAB9E=", + "changeLog": { + "createdBy": "METACORTEX\\thomas.anderson", + "createdOn": "2026-01-18T00:40:12.697994+00:00" + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Query_FilterBySku_IncludeFields.res.json b/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Query_FilterBySku_IncludeFields.res.json new file mode 100644 index 00000000..f1bbb090 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Query_FilterBySku_IncludeFields.res.json @@ -0,0 +1,18 @@ +[ + { + "sku": "SPEC-STUMPJUMPER-15-COMP", + "text": "Specialized Stumpjumper 15 Comp" + }, + { + "sku": "SPEC-STATUS-140", + "text": "Specialized Status 140" + }, + { + "sku": "SPEC-ENDURO-PRO", + "text": "Specialized Enduro Pro" + }, + { + "sku": "SPEC-CHISEL-COMP", + "text": "Specialized Chisel Comp" + } +] \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Query_Schema.res.json b/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Query_Schema.res.json new file mode 100644 index 00000000..f5d28773 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Query_Schema.res.json @@ -0,0 +1,70 @@ +{ + "filter": { + "fields": { + "sku": { + "type": "string", + "operators": [ + "eq", + "ne", + "in", + "startswith" + ] + }, + "text": { + "type": "string", + "operators": [ + "startswith", + "contains", + "endswith" + ] + }, + "category": { + "type": "string", + "operators": [ + "eq", + "ne", + "in" + ] + }, + "subcategory": { + "type": "string", + "operators": [ + "eq", + "ne", + "in" + ] + }, + "brand": { + "type": "string", + "operators": [ + "eq", + "ne", + "in" + ] + } + } + }, + "orderby": { + "fields": { + "sku": { + "direction": [ + "asc", + "desc" + ] + }, + "text": { + "direction": [ + "asc", + "desc" + ] + }, + "brand": { + "direction": [ + "asc", + "desc" + ] + } + }, + "default": "sku asc" + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Api/appsettings.unittest.json b/samples/tests/Contoso.Products.Test.Api/appsettings.unittest.json new file mode 100644 index 00000000..05e93644 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/appsettings.unittest.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore": "Information", + "Microsoft.EntityFrameworkCore.Update": "Information", + "ZiggyCreature": "Warning", + "StackExchange": "Warning" + } + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Common/Contoso.Products.Test.Common.csproj b/samples/tests/Contoso.Products.Test.Common/Contoso.Products.Test.Common.csproj new file mode 100644 index 00000000..be4ab109 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Common/Contoso.Products.Test.Common.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/samples/tests/Contoso.Products.Test.Common/Data/data.yaml b/samples/tests/Contoso.Products.Test.Common/Data/data.yaml new file mode 100644 index 00000000..403f84b6 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Common/Data/data.yaml @@ -0,0 +1,45 @@ +Products: + - Product: + - { ProductId: ^1, Sku: YETI-ASR-C2-2025, Text: Yeti ASR C2, SubCategoryCode: XC, UnitOfMeasureCode: EA, Price: 5800, BrandCode: YETI } + - { ProductId: ^2, Sku: YETI-SB120-C2, Text: Yeti SB120 C2, SubCategoryCode: TR, UnitOfMeasureCode: EA, Price: 6200, BrandCode: YETI } + - { ProductId: ^3, Sku: YETI-SB140-LR-C2, Text: Yeti SB140 LR C2, SubCategoryCode: TR, UnitOfMeasureCode: EA, Price: 6600, BrandCode: YETI } + - { ProductId: ^4, Sku: YETI-SB165-C3, Text: Yeti SB165 C3, SubCategoryCode: EN, UnitOfMeasureCode: EA, Price: 7200, BrandCode: YETI } + - { ProductId: ^5, Sku: YETI-MTE-C2, Text: Yeti MTe C2 eMTB, SubCategoryCode: EM, UnitOfMeasureCode: EA, Price: 9850, BrandCode: YETI } + - { ProductId: ^6, Sku: CANYON-LUXTRAIL-CF6, Text: Canyon Lux Trail CF 6, SubCategoryCode: XC, UnitOfMeasureCode: EA, Price: 3899, BrandCode: CANYON } + - { ProductId: ^7, Sku: CANYON-SPECTRAL-CF8, Text: Canyon Spectral CF 8, SubCategoryCode: TR, UnitOfMeasureCode: EA, Price: 4999, BrandCode: CANYON } + - { ProductId: ^8, Sku: CANYON-NEURON-CF7, Text: Canyon Neuron CF 7, SubCategoryCode: TR, UnitOfMeasureCode: EA, Price: 3499, BrandCode: CANYON } + - { ProductId: ^9, Sku: CANYON-TORQUE-MULLET-CF8, Text: Canyon Torque Mullet CF 8, SubCategoryCode: EN, UnitOfMeasureCode: EA, Price: 3599, BrandCode: CANYON } + - { ProductId: ^10, Sku: CANYON-SENDER-CFR, Text: Canyon Sender CFR, SubCategoryCode: DH, UnitOfMeasureCode: EA, Price: 7799, BrandCode: CANYON } + - { ProductId: ^11, Sku: SPEC-STUMPJUMPER-15-COMP, Text: Specialized Stumpjumper 15 Comp, SubCategoryCode: TR, UnitOfMeasureCode: EA, Price: 5500, BrandCode: SPEC } + - { ProductId: ^12, Sku: SPEC-ENDURO-PRO, Text: Specialized Enduro Pro, SubCategoryCode: EN, UnitOfMeasureCode: EA, Price: 9500, BrandCode: SPEC } + - { ProductId: ^13, Sku: SPEC-EPIC-8-PRO, Text: Specialized Epic 8 Pro, SubCategoryCode: XC, UnitOfMeasureCode: EA, Price: 9000, IsInactive: true, BrandCode: SPEC } + - { ProductId: ^14, Sku: SPEC-CHISEL-COMP, Text: Specialized Chisel Comp, SubCategoryCode: HT, UnitOfMeasureCode: EA, Price: 3400, BrandCode: SPEC } + - { ProductId: ^15, Sku: SPEC-STATUS-140, Text: Specialized Status 140, SubCategoryCode: FR, UnitOfMeasureCode: EA, Price: 2499, BrandCode: SPEC } + - { ProductId: ^16, Sku: GIANT-TRANCE-X-ADVPRO29, Text: Giant Trance X Advanced Pro 29, SubCategoryCode: TR, UnitOfMeasureCode: EA, Price: 6200, BrandCode: GIANT } + - { ProductId: ^17, Sku: GIANT-ANTHEM-ADV29-1, Text: Giant Anthem Advanced 29 1, SubCategoryCode: XC, UnitOfMeasureCode: EA, Price: 7800, BrandCode: GIANT } + - { ProductId: ^18, Sku: GIANT-TALON-2, Text: Giant Talon 2, SubCategoryCode: HT, UnitOfMeasureCode: EA, Price: 800, IsDeleted: true, BrandCode: GIANT } + - { ProductId: ^19, Sku: SHIMANO-XT-M8100-RD, Text: Shimano Deore XT M8100 Rear Derailleur, SubCategoryCode: DR, UnitOfMeasureCode: EA, Price: 129, BrandCode: SHIMANO } + - { ProductId: ^20, Sku: SHIMANO-SLX-M7100-CASSETTE, Text: Shimano SLX M7100 12-speed Cassette, SubCategoryCode: CA, UnitOfMeasureCode: EA, Price: 99, BrandCode: SHIMANO } + - { ProductId: ^21, Sku: SHIMANO-XTR-M9100-CRANK, Text: Shimano XTR M9100 Crankset, SubCategoryCode: CR, UnitOfMeasureCode: EA, Price: 499, BrandCode: SHIMANO } + - { ProductId: ^22, Sku: SRAM-GX-EAGLE-RD, Text: SRAM GX Eagle Rear Derailleur, SubCategoryCode: DR, UnitOfMeasureCode: EA, Price: 135, BrandCode: SRAM } + - { ProductId: ^23, Sku: SRAM-X0-TRANSMISSION-RD, Text: SRAM X0 Transmission Rear Derailleur, SubCategoryCode: DR, UnitOfMeasureCode: EA, Price: 400, BrandCode: SRAM } + - { ProductId: ^24, Sku: SRAM-XX1-EAGLE-CASSETTE, Text: SRAM XX1 Eagle Cassette, SubCategoryCode: CA, UnitOfMeasureCode: EA, Price: 449, BrandCode: SRAM } + - { ProductId: ^25, Sku: ONEUP-V3-DROPPER-180, Text: OneUp Dropper Post V3 180mm, SubCategoryCode: SP, UnitOfMeasureCode: EA, Price: 269, BrandCode: ONEUP } + - { ProductId: ^26, Sku: ONEUP-CARBON-HANDLEBAR, Text: OneUp Carbon Handlebar, SubCategoryCode: HB, UnitOfMeasureCode: EA, Price: 159, BrandCode: ONEUP } + - { ProductId: ^27, Sku: ONEUP-COMPOSITE-PEDALS, Text: OneUp Composite Pedals, SubCategoryCode: PE, UnitOfMeasureCode: PR, Price: 49, IsInactive: true, BrandCode: ONEUP } + - { ProductId: ^28, Sku: ONEUP-EDC-TOOL-V2, Text: OneUp EDC Tool V2, SubCategoryCode: TO, UnitOfMeasureCode: EA, Price: 59, BrandCode: ONEUP } + - { ProductId: ^29, Sku: MUCOFF-NANO-CLEANER, Text: Muc-Off Nano Tech Bike Cleaner, SubCategoryCode: CL, UnitOfMeasureCode: EA, Price: 16.99, BrandCode: MUCOFF } + - { ProductId: ^30, Sku: MUCOFF-TUBELESS-SEALANT, Text: Muc-Off MTB Tubeless Sealant, SubCategoryCode: SE, UnitOfMeasureCode: EA, Price: 8.00, BrandCode: MUCOFF } + - { ProductId: ^31, Sku: MUCOFF-DRIVETRAIN-CLEANER, Text: Muc-Off Drivetrain Cleaner, SubCategoryCode: CL, UnitOfMeasureCode: EA, Price: 25.00, BrandCode: MUCOFF } + - { ProductId: ^32, Sku: LABOR, Text: Labor, SubCategoryCode: LA, UnitOfMeasureCode: HR, Price: 80.00, BrandCode: NONE, IsNonStocked: true } + - Inventory: + - { InventoryId: ^1, QtyOnHand: 3 } + - { InventoryId: ^16, QtyOnHand: 1 } + - { InventoryId: ^24, QtyOnHand: 3 } + - { InventoryId: ^28, QtyOnHand: 5 } + - Movement: + - { MovementId: ^1, ProductId: ^1, Quantity: -2, ReferenceId: ^1000, MovementKindCode: I, MovementStatusCode: P, UnitOfMeasureCode: EA } + - { MovementId: ^2, ProductId: ^6, Quantity: -1, ReferenceId: ^1000, MovementKindCode: I, MovementStatusCode: P, UnitOfMeasureCode: EA } + - { MovementId: ^3, ProductId: ^31, Quantity: -3, ReferenceId: ^1000, MovementKindCode: I, MovementStatusCode: P, UnitOfMeasureCode: EA } + - { MovementId: ^4, ProductId: ^24, Quantity: -2, ReferenceId: ^1001, MovementKindCode: I, MovementStatusCode: P, UnitOfMeasureCode: EA } + - { MovementId: ^5, ProductId: ^28, Quantity: -1, ReferenceId: ^1001, MovementKindCode: I, MovementStatusCode: P, UnitOfMeasureCode: EA } diff --git a/samples/tests/Contoso.Products.Test.Common/TestData.cs b/samples/tests/Contoso.Products.Test.Common/TestData.cs new file mode 100644 index 00000000..68f3a0d4 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Common/TestData.cs @@ -0,0 +1,6 @@ +namespace Contoso.Products.Test.Common; + +/// +/// Marker class for test data used across multiple test projects in the 'Contoso.Shopping' sample. +/// +public sealed class TestData { } \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Outbox.Relay/Contoso.Products.Test.Outbox.Relay.csproj b/samples/tests/Contoso.Products.Test.Outbox.Relay/Contoso.Products.Test.Outbox.Relay.csproj new file mode 100644 index 00000000..ac525680 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Outbox.Relay/Contoso.Products.Test.Outbox.Relay.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Outbox.Relay/GlobalUsing.cs b/samples/tests/Contoso.Products.Test.Outbox.Relay/GlobalUsing.cs new file mode 100644 index 00000000..8c935e68 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Outbox.Relay/GlobalUsing.cs @@ -0,0 +1,13 @@ +global using Contoso.Products.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.Products.Database.Program; +global using ExecutionContext = CoreEx.ExecutionContext; +global using TestData = Contoso.Products.Test.Common.TestData; \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.Health.cs b/samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.Health.cs new file mode 100644 index 00000000..34d962ce --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.Health.cs @@ -0,0 +1,41 @@ +namespace Contoso.Products.Test.Outbox.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); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.HostedServices.cs b/samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.HostedServices.cs new file mode 100644 index 00000000..dd7f168c --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.HostedServices.cs @@ -0,0 +1,36 @@ +namespace Contoso.Products.Test.Outbox.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"); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.cs b/samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.cs new file mode 100644 index 00000000..5cae4848 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Outbox.Relay/OtherTests.cs @@ -0,0 +1,10 @@ +namespace Contoso.Products.Test.Outbox.Relay; + +public partial class OtherTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Outbox.Relay/RelayTests.cs b/samples/tests/Contoso.Products.Test.Outbox.Relay/RelayTests.cs new file mode 100644 index 00000000..993f33d8 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Outbox.Relay/RelayTests.cs @@ -0,0 +1,48 @@ +using CoreEx.Database.SqlServer.Outbox; + +namespace Contoso.Products.Test.Outbox.Relay; + +public class RelayTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.GetAndClearAzureServiceBusAsync(ServiceBusSessionReceiverOptions.CreateForTopicSubscription("contoso", "products")); + } + + [Test] + public async Task Outbox_Relay() + { + // Arrange the two events to publish and relay. + var ce1 = Test.CreateCloudEventFromJsonResource("ProductCreatedCloudEvent.json"); + var ce2 = Test.CreateCloudEventFromJsonResource("ProductDeletedCloudEvent.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. + await Task.Delay(TimeSpan.FromSeconds(1)); + await Task.Delay(TimeSpan.FromSeconds(1)); + await Task.Delay(TimeSpan.FromSeconds(1)); + await Task.Delay(TimeSpan.FromSeconds(1)); + await Task.Delay(TimeSpan.FromSeconds(1)); + + // Receive the events from Azure Service Bus and assert. + var list = await Test.GetAndClearAzureServiceBusAsync(ServiceBusSessionReceiverOptions.CreateForTopicSubscription("contoso", "products")); + + list.Should().NotBeNull().And.HaveCount(2); + list.Should().ContainSingle(x => x.MessageId == ce1.Id); + ObjectComparer.AssertJson(ce1.EncodeToJsonElement().ToString(), list.Single(x => x.MessageId == ce1.Id).Body.ToString()); + ObjectComparer.AssertJson(ce2.EncodeToJsonElement().ToString(), list.Single(x => x.MessageId == ce2.Id).Body.ToString()); + }).AssertSuccess(); + }); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Outbox.Relay/Resources/ProductCreatedCloudEvent.json b/samples/tests/Contoso.Products.Test.Outbox.Relay/Resources/ProductCreatedCloudEvent.json new file mode 100644 index 00000000..6e2bc299 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Outbox.Relay/Resources/ProductCreatedCloudEvent.json @@ -0,0 +1,29 @@ +{ + "specversion": "1.0", + "id": "019c72b9-e8e4-73aa-83fc-c5847cd4c217", + "type": "contoso.products.product.created.v1", + "source": "urn:contoso:products", + "subject": "019c72b9-e8d8-7d33-821b-3d18af870884", + "time": "2026-02-18T21:48:32.3532188Z", + "partitionkey": "019c72b9-e8d8-7d33-821b-3d18af870884", + "authtype": "user", + "authid": "DOMAIN-CORP\\eric.sibly", + "dataschemaversion": "1.0", + "datacontenttype": "application/json", + "traceparent": "00-7de3f6abfabf606dd289d93e6d118456-cc3d1971a8b15a04-01", + "data": { + "changeLog": { + "createdBy": "DOMAIN-CORP\\eric.sibly", + "createdOn": "2026-02-18T21:48:32.3532188\u002B00:00" + }, + "id": "019c72b9-e8d8-7d33-821b-3d18af870884", + "sku": "NEW-SKU-123", + "text": "New Product", + "category": "B", + "subCategory": "XC", + "uom": "EA", + "brand": "YETI", + "price": 1000, + "isInactive": true + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Outbox.Relay/Resources/ProductDeletedCloudEvent.json b/samples/tests/Contoso.Products.Test.Outbox.Relay/Resources/ProductDeletedCloudEvent.json new file mode 100644 index 00000000..44398821 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Outbox.Relay/Resources/ProductDeletedCloudEvent.json @@ -0,0 +1,12 @@ +{ + "specversion": "1.0", + "id": "019c72b9-e9ab-7a00-97e9-cdf97f2f6024", + "type": "contoso.products.product.deleted.v1", + "source": "urn:contoso:products", + "subject": "0000000d-0000-0000-0000-000000000000", + "time": "2026-02-18T21:48:32.5446661Z", + "partitionkey": "0000000d-0000-0000-0000-000000000000", + "authtype": "user", + "authid": "DOMAIN-CORP\\eric.sibly", + "traceparent": "00-1bd9e9901c03640e349802f6ce9df85f-e49a3299dd102aaa-01" +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Outbox.Relay/appsettings.unittest.json b/samples/tests/Contoso.Products.Test.Outbox.Relay/appsettings.unittest.json new file mode 100644 index 00000000..ea1fa4e5 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Outbox.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" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Subscribe/Contoso.Products.Test.Subscribe.csproj b/samples/tests/Contoso.Products.Test.Subscribe/Contoso.Products.Test.Subscribe.csproj new file mode 100644 index 00000000..d88fe5a9 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Subscribe/Contoso.Products.Test.Subscribe.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + diff --git a/samples/tests/Contoso.Products.Test.Subscribe/GlobalUsing.cs b/samples/tests/Contoso.Products.Test.Subscribe/GlobalUsing.cs new file mode 100644 index 00000000..39f791aa --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Subscribe/GlobalUsing.cs @@ -0,0 +1,14 @@ +global using Contoso.Products.Application.Interfaces; +global using Contoso.Products.Contracts; +global using CoreEx; +global using CoreEx.Azure.Messaging.ServiceBus; +global using CoreEx.Events; +global using CoreEx.Events.Subscribing; +global using CoreEx.Events.Subscribing.Exceptions; +global using AwesomeAssertions; +global using Microsoft.Extensions.DependencyInjection; +global using NUnit.Framework; +global using UnitTestEx; +global using UnitTestEx.Expectations; +global using DbMigration = Contoso.Products.Database.Program; +global using TestData = Contoso.Products.Test.Common.TestData; \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.ReservationCancel.cs b/samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.ReservationCancel.cs new file mode 100644 index 00000000..e39c4840 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.ReservationCancel.cs @@ -0,0 +1,61 @@ +namespace Contoso.Products.Test.Subscribe; + +public partial class SubscriberTests +{ + [Test] + public void ReservationCancel_NotFound() => Test.Scoped(test => + { + var ed = EventData.CreateCommand("products", "reservation", "cancel").WithKey("abc"); + var ce = Test.CreateCloudEventFrom(ed); + var sbm = ce.ToServiceBusReceivedMessage(); + + test.Run(async _ => + { + var sbs = test.Services.GetRequiredService(); + var r = await sbs.ReceiveAsync(sbm); + + r.IsFailure.Should().BeTrue(); + var e = r.Error.Should().BeOfType().Subject; + e.ErrorHandling.Should().Be(ErrorHandling.CompleteAsInformation); + e.InnerException.Should().BeOfType().Which.ErrorCode.Should().Be("pending-reservation-not-found"); + }).AssertSuccess(); + }); + + [Test] + public void ReservationCancel_Success() => Test.Scoped(async test => + { + // Arrange - ensure there are reservations to cancel. + var referenceId = 1001.ToGuid().ToString(); + + var ms = test.Services.GetRequiredService(); + var mr = await ms.GetAsync(referenceId); + mr.Should().NotBeNull().And.HaveCount(2).And.AllSatisfy(m => m.StatusCode.Should().Be(MovementStatus.Pending)); + + var qs = test.Services.GetRequiredService(); + var q1 = await qs.GetOnHandAsync(mr[0].ProductId!); + var q2 = await qs.GetOnHandAsync(mr[1].ProductId!); + + // Act - cancel the reservation using a simulated command-based message. + test.ExpectSqlServerOutboxEvents(e => e.AssertCount(2)) + .Run(async _ => + { + var ed = EventData.CreateCommand("products", "reservation", "cancel").WithKey(referenceId); + var ce = Test.CreateCloudEventFrom(ed); + var sbm = ce.ToServiceBusReceivedMessage(); + + var sbs = test.Services.GetRequiredService(); + var r = await sbs.ReceiveAsync(sbm); + r.IsSuccess.Should().BeTrue(); + }).AssertSuccess(); + + // Assert - the reservation should now be cancelled. + mr = await ms.GetAsync(referenceId); + mr.Should().NotBeNull().And.HaveCount(2).And.AllSatisfy(m => m.StatusCode.Should().Be(MovementStatus.Canceled)); + + // Assert - the on-hand quantity should be increased by the cancelled reservation quantity. + var q1After = await qs.GetOnHandAsync(mr[0].ProductId!); + var q2After = await qs.GetOnHandAsync(mr[1].ProductId!); + q1After.Should().Be(q1 + mr[0].Quantity * -1); + q2After.Should().Be(q2 + mr[1].Quantity * -1); + }); +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.ReservationConfirm.cs b/samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.ReservationConfirm.cs new file mode 100644 index 00000000..3c1aa4fd --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.ReservationConfirm.cs @@ -0,0 +1,51 @@ +namespace Contoso.Products.Test.Subscribe; + +public partial class SubscriberTests +{ + [Test] + public void ReservationConfirm_NotFound() => Test.Scoped(test => + { + var ed = EventData.CreateCommand("products", "reservation", "confirm").WithKey("abc"); + var ce = Test.CreateCloudEventFrom(ed); + var sbm = ce.ToServiceBusReceivedMessage(); + + test.Run(async _ => + { + var sbs = test.Services.GetRequiredService(); + var r = await sbs.ReceiveAsync(sbm); + + r.IsFailure.Should().BeTrue(); + var e = r.Error.Should().BeOfType().Subject; + e.ErrorHandling.Should().Be(ErrorHandling.CompleteAsInformation); + e.InnerException.Should().BeOfType().Which.ErrorCode.Should().Be("pending-reservation-not-found"); + }).AssertSuccess(); + }); + + [Test] + public void ReservationConfirm_Success() => Test.Scoped(async test => + { + // Arrange - ensure there are reservations to confirm. + var referenceId = 1000.ToGuid().ToString(); + + var ms = test.Services.GetRequiredService(); + var mr = await ms.GetAsync(referenceId); + mr.Should().NotBeNull().And.HaveCount(3).And.AllSatisfy(m => m.StatusCode.Should().Be(MovementStatus.Pending)); + + // Act - confirm the reservation using a simulated command-based message. + test.ExpectSqlServerOutboxEvents(e => e.AssertCount(3)) + .Run(async _ => + { + var ed = EventData.CreateCommand("products", "reservation", "confirm").WithKey(referenceId); + var ce = Test.CreateCloudEventFrom(ed); + var sbm = ce.ToServiceBusReceivedMessage(); + + var sbs = test.Services.GetRequiredService(); + var r = await sbs.ReceiveAsync(sbm); + r.IsSuccess.Should().BeTrue(); + }).AssertSuccess(); + + // Assert - the reservation should now be confirmed. + mr = await ms.GetAsync(referenceId); + mr.Should().NotBeNull().And.HaveCount(3).And.AllSatisfy(m => m.StatusCode.Should().Be(MovementStatus.Confirmed)); + }); +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.cs b/samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.cs new file mode 100644 index 00000000..817a5248 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.cs @@ -0,0 +1,36 @@ +namespace Contoso.Products.Test.Subscribe; + +/// +/// NOTE: Using the ServiceBusSubscribedSubscriber bypasses the need to actually send a message to the Service Bus, instead it simulates the receive of a message. +/// +public partial class SubscriberTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + Test.UseExpectedSqlServerOutboxPublisher(); + } + + [Test] + public void Unsubscribed_Error() => Test.Scoped(test => + { + var ed = EventData.CreateEvent("product", "deleted").WithKey("abc"); + var ce = Test.CreateCloudEventFrom(ed); + var sbm = ce.ToServiceBusReceivedMessage(); + + test.Run(async _ => + { + var sbs = test.Services.GetRequiredService(); + var r = await sbs.ReceiveAsync(sbm); + + r.IsFailure.Should().BeTrue(); + var e = r.Error.Should().BeOfType().Subject; + e.ErrorHandling.Should().Be(ErrorHandling.CompleteAsSilent); + e.InnerException.Should().NotBeNull(); + e.InnerException.Message.Should().Be("No subscriber matched the event."); + }).AssertSuccess(); + }); +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Subscribe/appsettings.unittest.json b/samples/tests/Contoso.Products.Test.Subscribe/appsettings.unittest.json new file mode 100644 index 00000000..05e93644 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Subscribe/appsettings.unittest.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore": "Information", + "Microsoft.EntityFrameworkCore.Update": "Information", + "ZiggyCreature": "Warning", + "StackExchange": "Warning" + } + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Unit/Contoso.Products.Test.Unit.csproj b/samples/tests/Contoso.Products.Test.Unit/Contoso.Products.Test.Unit.csproj new file mode 100644 index 00000000..1a2392e7 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Unit/Contoso.Products.Test.Unit.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/tests/Contoso.Products.Test.Unit/EntryPoint.cs b/samples/tests/Contoso.Products.Test.Unit/EntryPoint.cs new file mode 100644 index 00000000..a2af311b --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Unit/EntryPoint.cs @@ -0,0 +1,40 @@ +namespace Contoso.Products.Test.Unit; + +public class EntryPoint +{ + public static void ConfigureApplication(IHostApplicationBuilder builder) + { + // Configure the minimum services required for the execution context and reference data orchestrator; caching will be in-memory for the unit tests. + builder.Services.AddExecutionContext(); + builder.Services.AddMemoryCache(); + builder.Services.AddReferenceDataOrchestrator(); + + // Reuse the "real" database configured reference data. + var jdr = JsonDataReader.ParseYaml("ref-data.yaml", JsonDataReaderOptions.CreateForReferenceData()); + builder.Services.AddSingleton(new ReferenceDataProvider(jdr)); + } + + public class ReferenceDataProvider(JsonDataReader jdr) : IReferenceDataProvider + { + public IEnumerable<(Type, Type)> Types => + [ + (typeof(Category), typeof(CategoryCollection)), + (typeof(SubCategory), typeof(SubCategoryCollection)), + (typeof(UnitOfMeasure), typeof(UnitOfMeasureCollection)), + (typeof(Brand), typeof(BrandCollection)), + (typeof(MovementKind), typeof(MovementKindCollection)), + (typeof(MovementStatus), typeof(MovementStatusCollection)), + ]; + + public Task GetAsync(Type type, CancellationToken cancellationToken = default) => type switch + { + _ when type == typeof(Category) => Task.FromResult((IReferenceDataCollection)jdr.Deserialize("Products.$^Category")!), + _ when type == typeof(SubCategory) => Task.FromResult((IReferenceDataCollection)jdr.Deserialize("Products.$^SubCategory")!), + _ when type == typeof(UnitOfMeasure) => Task.FromResult((IReferenceDataCollection)jdr.Deserialize("Products.$^UnitOfMeasure")!), + _ when type == typeof(Brand) => Task.FromResult((IReferenceDataCollection)jdr.Deserialize("Products.$^Brand")!), + _ when type == typeof(MovementKind) => Task.FromResult((IReferenceDataCollection)jdr.Deserialize("Products.$^MovementKind")!), + _ when type == typeof(MovementStatus) => Task.FromResult((IReferenceDataCollection)jdr.Deserialize("Products.$^MovementStatus")!), + _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.") + }; + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Unit/GlobalUsing.cs b/samples/tests/Contoso.Products.Test.Unit/GlobalUsing.cs new file mode 100644 index 00000000..a5f438ec --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Unit/GlobalUsing.cs @@ -0,0 +1,15 @@ +global using Contoso.Products.Contracts; +global using Contoso.Products.Application.Validators; +global using Contoso.Products.Application.Repositories; +global using CoreEx; +global using CoreEx.RefData; +global using CoreEx.RefData.Abstractions; +global using CoreEx.Results; +global using CoreEx.UnitTesting; +global using CoreEx.UnitTesting.Data; +global using CoreEx.Validation; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Moq; +global using UnitTestEx; +global using ExecutionContext = CoreEx.ExecutionContext; \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Unit/Validators/InventoryReservationRequestValidatorTests.cs b/samples/tests/Contoso.Products.Test.Unit/Validators/InventoryReservationRequestValidatorTests.cs new file mode 100644 index 00000000..c7a8fcfa --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Unit/Validators/InventoryReservationRequestValidatorTests.cs @@ -0,0 +1,101 @@ +namespace Contoso.Products.Test.Unit.Validators; + +public class InventoryReservationRequestValidatorTests : WithGenericTester +{ + private readonly Mock _mock = new(); + + [OneTimeSetUp] + public void OneTimeSetUp() + { + _mock.Setup(x => x.GetForReservationAsync(It.IsAny())) + .ReturnsAsync(new Dictionary + { + ["P1"] = new ProductReserve { UnitOfMeasureCode = "EA", IsNonStocked = false, IsInactive = false }, + ["P2"] = new ProductReserve { UnitOfMeasureCode = "EA", IsNonStocked = true, IsInactive = false }, + ["P3"] = new ProductReserve { UnitOfMeasureCode = "EA", IsNonStocked = false, IsInactive = true }, + ["P4"] = new ProductReserve { UnitOfMeasureCode = "EA", IsNonStocked = false, IsInactive = false } + }); + } + + [Test] + public void Empty_Required() => Test.Scoped(test => + { + var req = new MovementRequest(); + + new MovementRequestValidator(_mock.Object).AssertErrors(req, + ("id", "Identifier is required."), + ("products", "Products is required.")); + }); + + [Test] + public void Invalid_No_Products() => Test.Scoped(test => + { + var req = new MovementRequest + { + Id = "100", + Products = [] + }; + + new MovementRequestValidator(_mock.Object).AssertErrors(req, + ("products", "Products is required.")); + }); + + [Test] + public void Invalid_Products() => Test.Scoped((Action>)(test => + { + var req = new MovementRequest + { + Id = "100", + Products = new() + { + ["P1"] = new MovementRequestProduct { UnitOfMeasure = "XX", Quantity = -1 }, + [""] = null!, + ["P2"] = new MovementRequestProduct { UnitOfMeasure = "EA", Quantity = 1.33m } + } + }; + + new MovementRequestValidator(_mock.Object).AssertErrors(req, + ("products.P1.value.unitOfMeasure", "Unit-of-measure is invalid."), + ("products.P1.value.quantity", "Quantity must be greater than or equal to '0'."), + ("products.key", "Product is required."), + ("products.P2.value.quantity", "Quantity exceeds the maximum decimal places (0).")); + })); + + [Test] + public void Invalid_Products_Extended() => Test.Scoped((Action>)(test => + { + var req = new MovementRequest + { + Id = "100", + Products = new() + { + ["P0"] = new MovementRequestProduct { UnitOfMeasure = "L", Quantity = 1 }, + ["P1"] = new MovementRequestProduct { UnitOfMeasure = "L", Quantity = 1 }, + ["P2"] = new MovementRequestProduct { UnitOfMeasure = "EA", Quantity = 1 }, + ["P3"] = new MovementRequestProduct { UnitOfMeasure = "EA", Quantity = 1 } + } + }; + + new MovementRequestValidator(_mock.Object).AssertErrors(req, + ("products.P0", "Product was not found."), + ("products.P1.unitOfMeasure", "Unit-of-measure must be equal to 'Each'."), + ("products.P2", "Product is non-stocked and therefore cannot be transacted."), + ("products.P3", "Product is not active and therefore cannot be transacted.")); + })); + + [Test] + public void Success() => Test.Scoped((Action>)(test => + { + var req = new MovementRequest + { + Id = "100", + Products = new() + { + ["P1"] = new MovementRequestProduct { UnitOfMeasure = "EA", Quantity = 1 }, + ["P4"] = new MovementRequestProduct { UnitOfMeasure = "EA", Quantity = 1 } + } + }; + + new MovementRequestValidator(_mock.Object).AssertSuccess(req); + })); +} \ No newline at end of file diff --git a/samples/tests/Contoso.Products.Test.Unit/Validators/ProductValidatorTests.cs b/samples/tests/Contoso.Products.Test.Unit/Validators/ProductValidatorTests.cs new file mode 100644 index 00000000..28422678 --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Unit/Validators/ProductValidatorTests.cs @@ -0,0 +1,32 @@ +namespace Contoso.Products.Test.Unit.Validators; + +public class ProductValidatorTests : WithGenericTester +{ + [Test] + public void Empty_Required() => Test.Scoped(test => + { + var p = new Product(); + new ProductValidator().AssertErrors(p, + ("sku", "Sku is required."), + ("text", "Text is required."), + ("subCategory", "Sub-category is required."), + ("unitOfMeasure", "Unit-of-measure is required.")); + }); + + [Test] + public void Invalid_ReferenceData() => Test.Scoped(test => + { + var p = new Product { Sku = "X", Text = "Test", SubCategoryCode = "XX", UnitOfMeasureCode = "XX", Price = -9.99m }; + new ProductValidator().AssertErrors(p, + ("subCategory", "Sub-category is invalid."), + ("unitOfMeasure", "Unit-of-measure is invalid."), + ("price", "Price must be greater than or equal to zero.")); + }); + + [Test] + public void Success() => Test.Scoped(test => + { + var p = new Product { Sku = "X", Text = "Test", SubCategoryCode = "XC", UnitOfMeasureCode = "EA", Price = 9.99m }; + new ProductValidator().AssertSuccess(p); + }); +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/Contoso.Shopping.Test.Api.csproj b/samples/tests/Contoso.Shopping.Test.Api/Contoso.Shopping.Test.Api.csproj new file mode 100644 index 00000000..5e38d6f6 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/Contoso.Shopping.Test.Api.csproj @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + diff --git a/samples/tests/Contoso.Shopping.Test.Api/GlobalUsing.cs b/samples/tests/Contoso.Shopping.Test.Api/GlobalUsing.cs new file mode 100644 index 00000000..fc1cda63 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/GlobalUsing.cs @@ -0,0 +1,10 @@ +global using Contoso.Shopping.Contracts; +global using CoreEx; +global using CoreEx.Database.SqlServer.Outbox; +global using AwesomeAssertions; +global using NUnit.Framework; +global using System.Net; +global using UnitTestEx; +global using UnitTestEx.Expectations; +global using DbMigration = Contoso.Shopping.Database.Program; +global using TestData = Contoso.Shopping.Test.Common.TestData; \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/MutateTests.Basket.cs b/samples/tests/Contoso.Shopping.Test.Api/MutateTests.Basket.cs new file mode 100644 index 00000000..f5079728 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/MutateTests.Basket.cs @@ -0,0 +1,179 @@ +namespace Contoso.Shopping.Test.Api; + +public partial class MutateTests +{ + [Test] + public void Basket_Create() + { + var basket = Test.Http() + .ExpectIdentifier() + .ExpectChangeLogCreated() + .ExpectETag() + .ExpectJsonFromResource("Basket_Create.res.json", _pathsToIgnore) + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.shopping.basket.created.v1")) + .Run(HttpMethod.Post, $"/api/customers/{1004.ToGuid()}/baskets") + .AssertCreated() + .AssertLocationHeader(b => new Uri($"/api/baskets/{b!.Id}", UriKind.Relative)) + .Value!; + + Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{basket.Id}") + .AssertOK() + .AssertValue(basket); + } + + [Test] + public void Basket_ApplyDiscount_NotFound() + { + Test.Http() + .Run(HttpMethod.Put, $"/api/baskets/{404.ToGuid()}/apply-discount/save10") + .AssertNotFound(); + } + + [Test] + public void Basket_ApplyDiscount_Invalid() + { + Test.Http() + .Run(HttpMethod.Put, $"/api/baskets/{404.ToGuid()}/apply-discount/save100") + .AssertBadRequest() + .AssertProblemDetailsTitle("Discount coupon either does not exist or is no longer active."); + } + + + [Test] + public void Basket_ApplyDiscount_Inactive() + { + Test.Http() + .Run(HttpMethod.Put, $"/api/baskets/{404.ToGuid()}/apply-discount/XMAS2025") + .AssertBadRequest() + .AssertProblemDetailsTitle("Discount coupon either does not exist or is no longer active."); + } + + [Test] + public void Basket_ApplyDiscount_Success() + { + var v = Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{3006.ToGuid()}") + .AssertOK() + .Value!; + + v.Pricing.Should().NotBeNull(); + v.Pricing.DiscountCouponCode.Should().BeNull(); + v.Pricing.DiscountPercentage.Should().Be(0m); + v.Pricing.DiscountAmount.Should().Be(0m); + + v = Test.Http() + .ExpectChangeLogUpdated() + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.shopping.basket.updated.v1")) + .Run(HttpMethod.Put, $"/api/baskets/{v.Id}/apply-discount/save10") + .AssertOK() + .Value!; + + v.Pricing.Should().NotBeNull(); + v.Pricing.DiscountCouponCode.Should().Be("SAVE10"); + v.Pricing.DiscountPercentage.Should().Be(10m); + v.Pricing.DiscountAmount.Should().NotBe(0); + + Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{v.Id}") + .AssertOK() + .AssertValue(v); + } + + [Test] + public void Basket_Checkout_Success() + { + // Arrange - ensure the basket is in the correct state for checkout. + var v = Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{3007.ToGuid()}") + .AssertOK() + .Value!; + + v.StatusCode.Should().Be(BasketStatus.Active); + + // Arrange - mock the inventory reservation to succeed. + _mockHttpReserveRequest.WithJsonResourceBody("Basket_Checkout_Success.products.req.json").Respond.With(HttpStatusCode.OK); + + // Act - checkout the basket + v = Test.Http() + .ExpectChangeLogUpdated() + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.shopping.basket.checkedout.v1") + .AssertMetadata("contoso", "contoso.products.reservation.confirm", v.Id)) + .Run(HttpMethod.Post, $"/api/baskets/{v.Id}/checkout") + .AssertOK() + .Value!; + + // Assert - the inventory reservation endpoint should have been called with the expected request body. + _mockHttpReserveRequest.Verify(); + + // Assert - the basket should be in the "CheckedOut" state. + v.StatusCode.Should().Be(BasketStatus.CheckedOut); + + Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{v.Id}") + .AssertOK() + .AssertValue(v); + } + + [Test] + public void Basket_Checkout_Insufficient_Quantity() + { + // Arrange - mock the inventory reservation to fail due to insufficient inventory. + _mockHttpReserveRequest.WithAnyBody() + .Respond.WithJsonResource("Basket_Checkout_Insufficient_Quantity.products.res.json", HttpStatusCode.BadRequest, System.Net.Mime.MediaTypeNames.Application.ProblemJson); + + // Act / Assert - checkout the basket and assert ProblemDetails. + Test.Http() + .ExpectNoSqlServerOutboxEvents() + .Run(HttpMethod.Post, $"/api/baskets/{3008.ToGuid()}/checkout") + .AssertBadRequest() + .AssertContentTypeProblemJson() + .AssertJsonFromResource("Basket_Checkout_Insufficient_Quantity.products.res.json", "traceid"); + + // Assert - the inventory reservation endpoint should have been called with the expected request body. + _mockHttpReserveRequest.Verify(); + } + + [Test] + public void Basket_Checkout_Downstream_Validation_Failure() + { + // Arrange - mock the inventory reservation to fail due to insufficient inventory. + _mockHttpReserveRequest.WithAnyBody() + .Respond.WithJsonResource("Basket_Checkout_Downstream_Validation_Failure.products.res.json", HttpStatusCode.BadRequest, System.Net.Mime.MediaTypeNames.Application.ProblemJson); + + // Act / Assert - checkout the basket and assert ProblemDetails for validation failure. + Test.Http() + .ExpectNoSqlServerOutboxEvents() + .Run(HttpMethod.Post, $"/api/baskets/{3008.ToGuid()}/checkout") + .AssertInternalServerError() + .AssertContentTypeProblemJson(); + } + + [Test] + public void Basket_Checkout_Save_Failure() + { + var id = 3008.ToGuid().ToString(); + + // Arrange - mock the inventory reservation to always succeed. + _mockHttpReserveRequest.WithAnyBody().Respond.With(HttpStatusCode.OK); + + // Act / Assert - checkout the basket and assert ProblemDetails. + Test.Http() + .OnEventPublish(SqlServerOutboxPublisher.DefaultServiceKey, () => throw new InvalidOperationException("Simulated failure during save; oh no, we all ready reserved inventory!")) + .ExpectNoSqlServerOutboxEvents() + .ExpectAzureServiceBusEvents(e => e.AssertMetadata("contoso", "contoso.products.reservation.cancel", id)) + .Run(HttpMethod.Post, $"/api/baskets/{id}/checkout") + .AssertInternalServerError(); + + // Assert - the inventory reservation endpoint should have been called with the expected request body. + _mockHttpReserveRequest.Verify(); + + // Assert - the basket should still be active as the checkout process should have been rolled back. + var v = Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{id}") + .AssertOK() + .Value!; + + v.StatusCode.Should().Be(BasketStatus.Active); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/MutateTests.BasketItem.cs b/samples/tests/Contoso.Shopping.Test.Api/MutateTests.BasketItem.cs new file mode 100644 index 00000000..8c905304 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/MutateTests.BasketItem.cs @@ -0,0 +1,133 @@ +namespace Contoso.Shopping.Test.Api; + +public partial class MutateTests +{ + [Test] + public void Basket_Item_Add_New() + { + var item = new BasketItemAddRequest { ProductId = 32.ToGuid().ToString(), Quantity = 1.5m }; + + var v = Test.Http() + .ExpectChangeLogUpdated() + .ExpectETag() + .ExpectJsonFromResource("Basket_Item_Add_New.res.json", [.. _pathsToIgnore, "items[2].id"]) + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.shopping.basket.updated.v1")) + .Run(HttpMethod.Post, $"/api/baskets/{3003.ToGuid()}/items", item, r => r.WithIdempotencyKey()) + .AssertOK() + .Value!; + + Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{3003.ToGuid()}") + .AssertOK() + .AssertValue(v); + } + + [Test] + public void Basket_Item_Add_Existing() + { + var item = new BasketItemAddRequest { ProductId = 27.ToGuid().ToString(), Quantity = 1m }; + + var v = Test.Http() + .ExpectChangeLogUpdated() + .ExpectETag() + .ExpectJsonFromResource("Basket_Item_Add_Existing.res.json", _pathsToIgnore) + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.shopping.basket.updated.v1")) + .Run(HttpMethod.Post, $"/api/baskets/{3005.ToGuid()}/items", item, r => r.WithIdempotencyKey()) + .AssertOK() + .Value!; + + Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{3005.ToGuid()}") + .AssertOK() + .AssertValue(v); + } + + [Test] + public void Basket_Item_Update_NotFound() + { + var item = new BasketItemUpdateRequest { Quantity = 2m }; + + Test.Http() + .Run(HttpMethod.Put, $"/api/baskets/{3005.ToGuid()}/items/{404.ToGuid()}", item, r => r.WithIfMatch("xxx")) + .AssertNotFound(); + } + + [Test] + public void Basket_Item_Update_Scale_Error() + { + var item = new BasketItemUpdateRequest { Quantity = 2.3m, ETag = "xxx" }; + + Test.Http() + .Run(HttpMethod.Put, $"/api/baskets/{3005.ToGuid()}/items/{4008.ToGuid()}", item) + .AssertBadRequest() + .AssertContentTypeProblemJson() + .AssertProblemDetailsTitle("Quantity decimal places exceed the specified unit-of-measure (Pair) configuration of 0."); + } + + [Test] + public void Basket_Item_Update_Success() + { + // Arrange - ensure the item to be updated exists; plus we need the ETag for concurrency control. + var v = Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{3005.ToGuid()}") + .AssertOK() + .Value!; + + var item = new BasketItemUpdateRequest { Quantity = 6m, ETag = v.Items![0].ETag }; + + // Act - update the item quantity. + v = Test.Http() + .ExpectChangeLogUpdated() + .ExpectETag() + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.shopping.basket.updated.v1")) + .Run(HttpMethod.Put, $"/api/baskets/{3005.ToGuid()}/items/{4008.ToGuid()}", item, r => r.WithIdempotencyKey()) + .AssertOK() + .Value!; + + // Assert - the item quantity is updated. + v.Items![0].Quantity.Should().Be(item.Quantity); + + // Assert - the basket get also reflects the item quantity update. + Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{3005.ToGuid()}") + .AssertOK() + .AssertValue(v); + } + + [Test] + public void Basket_Item_Delete() + { + // Arrange - ensure the item to be deleted exists before attempting to delete it, otherwise the test will not be valid + var v = Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{3006.ToGuid()}") + .AssertOK() + .Value!; + + v.Items.Should().NotBeNull().And.HaveCount(1); + v.StatusCode.Should().Be(BasketStatus.Active); + + // Act - delete the item. + var v2 = Test.Http() + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.shopping.basket.updated.v1")) + .Run(HttpMethod.Delete, $"/api/baskets/{3006.ToGuid()}/items/{v.Items![0].Id}") + .AssertOK() + .Value!; + + // Assert - the item is deleted and the basket is now empty. + v2.Items.Should().NotBeNull().And.HaveCount(0); + v2.StatusCode.Should().Be(BasketStatus.Empty); + + // Assert - deleting the item again shows the same result. + Test.Http() + .ExpectNoSqlServerOutboxEvents() + .Run(HttpMethod.Delete, $"/api/baskets/{3006.ToGuid()}/items/{v.Items![0].Id}") + .AssertOK() + .AssertValue(v2); + + // Assert - the basket get also reflects the item deletion. + Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{3006.ToGuid()}") + .AssertOK() + .AssertValue(v2); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/MutateTests.cs b/samples/tests/Contoso.Shopping.Test.Api/MutateTests.cs new file mode 100644 index 00000000..20c7950d --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/MutateTests.cs @@ -0,0 +1,25 @@ +namespace Contoso.Shopping.Test.Api; + +public partial class MutateTests : WithApiTester +{ + private static readonly string[] _pathsToIgnore = ["items.etag"]; + + private UnitTestEx.Mocking.MockHttpClientRequest _mockHttpReserveRequest = null!; + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + // Migrate the database and seed it with test data before starting the test server. + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + // Use the expected SQL Server Outbox & Azure Service Bus publishers for the tests. + Test.UseExpectedSqlServerOutboxPublisher(); + Test.UseExpectedAzureServiceBusPublisher(); + + // Mock the HTTP client. + var mcf = MockHttpClientFactory.Create(); + _mockHttpReserveRequest = mcf.CreateClient("ProductsApi").Request(HttpMethod.Post, "api/inventory/reserve"); + Test.ReplaceHttpClientFactory(mcf); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/ReadTests.Basket.cs b/samples/tests/Contoso.Shopping.Test.Api/ReadTests.Basket.cs new file mode 100644 index 00000000..0439cd59 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/ReadTests.Basket.cs @@ -0,0 +1,23 @@ +namespace Contoso.Shopping.Test.Api; + +public partial class ReadTests +{ + [Test] + public void Get_NotFound() + { + Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{404.ToGuid()}") + .AssertNotFound(); + } + + [Test] + public void Get_Found() + { + Test.Http() + .IgnoreChangeLog() + .IgnoreETag() + .ExpectJsonFromResource("Basket_Get_Found.res.json", _pathsToIgnore) + .Run(HttpMethod.Get, $"/api/baskets/{3002.ToGuid()}") + .AssertOK(); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/ReadTests.cs b/samples/tests/Contoso.Shopping.Test.Api/ReadTests.cs new file mode 100644 index 00000000..cd0565c0 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/ReadTests.cs @@ -0,0 +1,17 @@ +namespace Contoso.Shopping.Test.Api; + +public partial class ReadTests : WithApiTester +{ + private static readonly string[] _pathsToIgnore = ["items.etag"]; + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + // Use the expected SQL Server Outbox & Azure Service Bus publishers for the tests. + Test.UseExpectedSqlServerOutboxPublisher(); + Test.UseExpectedAzureServiceBusPublisher(); + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Downstream_Validation_Failure.products.res.json b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Downstream_Validation_Failure.products.res.json new file mode 100644 index 00000000..5c461245 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Downstream_Validation_Failure.products.res.json @@ -0,0 +1,12 @@ +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "A data validation error occurred.", + "status": 400, + "errors": { + "products.0000270f-0000-0000-0000-000000000000": [ + "Product was not found." + ] + }, + "traceId": "00-827ab1dc5c4ab17dda2d137d43db16d4-a9e13d892f16779b-01", + "errorType": "validation" +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Insufficient_Quantity.products.res.json b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Insufficient_Quantity.products.res.json new file mode 100644 index 00000000..7edae0d9 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Insufficient_Quantity.products.res.json @@ -0,0 +1,8 @@ +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "Product \u002700000001-0000-0000-0000-000000000000\u0027 does not have sufficient quantity on hand.", + "status": 400, + "errorType": "business", + "errorCode": "insufficient-quantity", + "Key": "00000001-0000-0000-0000-000000000000" +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Success.products.req.json b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Success.products.req.json new file mode 100644 index 00000000..85696f3a --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Checkout_Success.products.req.json @@ -0,0 +1,13 @@ +{ + "id": "00000bbf-0000-0000-0000-000000000000", + "products": { + "00000001-0000-0000-0000-000000000000": { + "quantity": 2.00, + "unitOfMeasure": "EA" + }, + "00000014-0000-0000-0000-000000000000": { + "quantity": 1.00, + "unitOfMeasure": "EA" + } + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Create.res.json b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Create.res.json new file mode 100644 index 00000000..d3febb87 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Create.res.json @@ -0,0 +1,15 @@ +{ + "id": "eda1e75a-199a-44f1-b927-93844608d4e2", + "customerId": "000003ec-0000-0000-0000-000000000000", + "status": "E", + "items": [], + "pricing": { + "subTotal": 0, + "total": 0 + }, + "changeLog": { + "createdBy": "DOMAIN-CORP\\eric.sibly", + "createdOn": "2026-02-21T19:13:24.7027106\u002B00:00" + }, + "etag": "AAAAAAAACTg=" +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Get_Found.res.json b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Get_Found.res.json new file mode 100644 index 00000000..531be9c3 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Get_Found.res.json @@ -0,0 +1,41 @@ +{ + "id": "00000bba-0000-0000-0000-000000000000", + "customerId": "000003ea-0000-0000-0000-000000000000", + "status": "C", + "items": [ + { + "id": "00000fa3-0000-0000-0000-000000000000", + "productId": "00000001-0000-0000-0000-000000000000", + "sku": "YETI-ASR-C2-2025", + "text": "Yeti ASR C2", + "quantity": 1.00, + "unitOfMeasure": "EA", + "unitPrice": 9850.00, + "total": 9850.0000, + "etag": "AAAAAAAACDQ=" + }, + { + "id": "00000fa4-0000-0000-0000-000000000000", + "productId": "00000014-0000-0000-0000-000000000000", + "sku": "SHIMANO-SLX-M7100-CASSETTE", + "text": "Shimano SLX M7100 12-speed Cassette", + "quantity": 1.00, + "unitOfMeasure": "EA", + "unitPrice": 99.00, + "total": 99.0000, + "etag": "AAAAAAAACDU=" + } + ], + "pricing": { + "subTotal": 9949.0000, + "discountCoupon": "XMAS2025", + "discountPercentage": 15.00, + "discountAmount": 1492.35, + "total": 8456.6500 + }, + "changeLog": { + "createdBy": "DOMAIN-CORP\\eric.sibly", + "createdOn": "2026-02-21T17:53:32.8347127\u002B00:00" + }, + "etag": "AAAAAAAACDA=" +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Item_Add_Existing.res.json b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Item_Add_Existing.res.json new file mode 100644 index 00000000..66e36236 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Item_Add_Existing.res.json @@ -0,0 +1,29 @@ +{ + "id": "00000bbd-0000-0000-0000-000000000000", + "customerId": "000003eb-0000-0000-0000-000000000000", + "status": "A", + "items": [ + { + "id": "00000fa8-0000-0000-0000-000000000000", + "productId": "0000001b-0000-0000-0000-000000000000", + "sku": "ONEUP-COMPOSITE-PEDALS", + "text": "OneUp Composite Pedals", + "quantity": 3.00, + "unitOfMeasure": "PR", + "unitPrice": 49.00, + "total": 147.0000, + "etag": "AAAAAAAADCg=" + } + ], + "pricing": { + "subTotal": 147.0000, + "total": 147.0000 + }, + "changeLog": { + "createdBy": "AVANADE-CORP\\eric.sibly", + "createdOn": "2026-02-22T22:49:39.6554207\u002B00:00", + "updatedBy": "AVANADE-CORP\\eric.sibly", + "updatedOn": "2026-02-22T22:49:45.001148\u002B00:00" + }, + "etag": "AAAAAAAADCc=" +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Item_Add_New.res.json b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Item_Add_New.res.json new file mode 100644 index 00000000..5b931295 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Item_Add_New.res.json @@ -0,0 +1,51 @@ +{ + "id": "00000bbb-0000-0000-0000-000000000000", + "customerId": "000003e9-0000-0000-0000-000000000000", + "status": "A", + "items": [ + { + "id": "00000fa5-0000-0000-0000-000000000000", + "productId": "0000000c-0000-0000-0000-000000000000", + "sku": "SPEC-ENDURO-PRO", + "text": "Specialized Enduro Pro", + "quantity": 1.00, + "unitOfMeasure": "EA", + "unitPrice": 9500.00, + "total": 9500.0000, + "etag": "AAAAAAAADEM=" + }, + { + "id": "00000fa6-0000-0000-0000-000000000000", + "productId": "00000015-0000-0000-0000-000000000000", + "sku": "SHIMANO-XTR-M9100-CRANK", + "text": "Shimano XTR M9100 Crankset", + "quantity": 1.00, + "unitOfMeasure": "EA", + "unitPrice": 499.00, + "total": 499.0000, + "etag": "AAAAAAAADEQ=" + }, + { + "id": "0d03b2d4-c1af-4b52-9773-666c96c409ac", + "productId": "00000020-0000-0000-0000-000000000000", + "sku": "LABOR", + "text": "Labor", + "quantity": 1.5, + "unitOfMeasure": "HR", + "unitPrice": 80.00, + "total": 120.000, + "etag": "AAAAAAAADEs=" + } + ], + "pricing": { + "subTotal": 10119.0000, + "total": 10119.0000 + }, + "changeLog": { + "createdBy": "DOMAIN-CORP\\eric.sibly", + "createdOn": "2026-02-22T22:58:37.3812872\u002B00:00", + "updatedBy": "DOMAIN-CORP\\eric.sibly", + "updatedOn": "2026-02-22T22:58:43.1942797\u002B00:00" + }, + "etag": "AAAAAAAADEo=" +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Api/appsettings.unittest.json b/samples/tests/Contoso.Shopping.Test.Api/appsettings.unittest.json new file mode 100644 index 00000000..9a9e0740 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Api/appsettings.unittest.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information", + "ZiggyCreature": "Warning", + "StackExchange": "Warning", + "System.Net.Http.HttpClient": "Information", + "Polly": "Information" + } + } +} \ No newline at end of file diff --git a/samples/tests/Contoso.Shopping.Test.Common/Contoso.Shopping.Test.Common.csproj b/samples/tests/Contoso.Shopping.Test.Common/Contoso.Shopping.Test.Common.csproj new file mode 100644 index 00000000..c164f61e --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Common/Contoso.Shopping.Test.Common.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/samples/tests/Contoso.Shopping.Test.Common/Data/data.yaml b/samples/tests/Contoso.Shopping.Test.Common/Data/data.yaml new file mode 100644 index 00000000..fbaf1e5b --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Common/Data/data.yaml @@ -0,0 +1,56 @@ +Shopping: + - Product: + - { ProductId: ^1, Sku: YETI-ASR-C2-2025, Text: Yeti ASR C2, UnitOfMeasureCode: EA, Price: 5800 } + - { ProductId: ^2, Sku: YETI-SB120-C2, Text: Yeti SB120 C2, UnitOfMeasureCode: EA, Price: 6200 } + - { ProductId: ^3, Sku: YETI-SB140-LR-C2, Text: Yeti SB140 LR C2, UnitOfMeasureCode: EA, Price: 6600 } + - { ProductId: ^4, Sku: YETI-SB165-C3, Text: Yeti SB165 C3, UnitOfMeasureCode: EA, Price: 7200 } + - { ProductId: ^5, Sku: YETI-MTE-C2, Text: Yeti MTe C2 eMTB, UnitOfMeasureCode: EA, Price: 9850 } + - { ProductId: ^6, Sku: CANYON-LUXTRAIL-CF6, Text: Canyon Lux Trail CF 6, UnitOfMeasureCode: EA, Price: 3899 } + - { ProductId: ^7, Sku: CANYON-SPECTRAL-CF8, Text: Canyon Spectral CF 8, UnitOfMeasureCode: EA, Price: 4999 } + - { ProductId: ^8, Sku: CANYON-NEURON-CF7, Text: Canyon Neuron CF 7, UnitOfMeasureCode: EA, Price: 3499 } + - { ProductId: ^9, Sku: CANYON-TORQUE-MULLET-CF8, Text: Canyon Torque Mullet CF 8, UnitOfMeasureCode: EA, Price: 3599 } + - { ProductId: ^10, Sku: CANYON-SENDER-CFR, Text: Canyon Sender CFR, UnitOfMeasureCode: EA, Price: 7799 } + - { ProductId: ^11, Sku: SPEC-STUMPJUMPER-15-COMP, Text: Specialized Stumpjumper 15 Comp, UnitOfMeasureCode: EA, Price: 5500 } + - { ProductId: ^12, Sku: SPEC-ENDURO-PRO, Text: Specialized Enduro Pro, UnitOfMeasureCode: EA, Price: 9500 } + - { ProductId: ^13, Sku: SPEC-EPIC-8-PRO, Text: Specialized Epic 8 Pro, UnitOfMeasureCode: EA, Price: 9000, IsInactive: true } + - { ProductId: ^14, Sku: SPEC-CHISEL-COMP, Text: Specialized Chisel Comp, UnitOfMeasureCode: EA, Price: 3400 } + - { ProductId: ^15, Sku: SPEC-STATUS-140, Text: Specialized Status 140, UnitOfMeasureCode: EA, Price: 2499 } + - { ProductId: ^16, Sku: GIANT-TRANCE-X-ADVPRO29, Text: Giant Trance X Advanced Pro 29, UnitOfMeasureCode: EA, Price: 6200 } + - { ProductId: ^17, Sku: GIANT-ANTHEM-ADV29-1, Text: Giant Anthem Advanced 29 1, UnitOfMeasureCode: EA, Price: 7800 } + - { ProductId: ^19, Sku: SHIMANO-XT-M8100-RD, Text: Shimano Deore XT M8100 Rear Derailleur, UnitOfMeasureCode: EA, Price: 129 } + - { ProductId: ^20, Sku: SHIMANO-SLX-M7100-CASSETTE, Text: Shimano SLX M7100 12-speed Cassette, UnitOfMeasureCode: EA, Price: 99 } + - { ProductId: ^21, Sku: SHIMANO-XTR-M9100-CRANK, Text: Shimano XTR M9100 Crankset, UnitOfMeasureCode: EA, Price: 499 } + - { ProductId: ^22, Sku: SRAM-GX-EAGLE-RD, Text: SRAM GX Eagle Rear Derailleur, UnitOfMeasureCode: EA, Price: 135 } + - { ProductId: ^23, Sku: SRAM-X0-TRANSMISSION-RD, Text: SRAM X0 Transmission Rear Derailleur, UnitOfMeasureCode: EA, Price: 400 } + - { ProductId: ^24, Sku: SRAM-XX1-EAGLE-CASSETTE, Text: SRAM XX1 Eagle Cassette, UnitOfMeasureCode: EA, Price: 449 } + - { ProductId: ^25, Sku: ONEUP-V3-DROPPER-180, Text: OneUp Dropper Post V3 180mm, UnitOfMeasureCode: EA, Price: 269 } + - { ProductId: ^26, Sku: ONEUP-CARBON-HANDLEBAR, Text: OneUp Carbon Handlebar, UnitOfMeasureCode: EA, Price: 159 } + - { ProductId: ^27, Sku: ONEUP-COMPOSITE-PEDALS, Text: OneUp Composite Pedals, UnitOfMeasureCode: PR, Price: 49, IsInactive: true } + - { ProductId: ^28, Sku: ONEUP-EDC-TOOL-V2, Text: OneUp EDC Tool V2, UnitOfMeasureCode: EA, Price: 59 } + - { ProductId: ^29, Sku: MUCOFF-NANO-CLEANER, Text: Muc-Off Nano Tech Bike Cleaner, UnitOfMeasureCode: EA, Price: 16.99 } + - { ProductId: ^30, Sku: MUCOFF-TUBELESS-SEALANT, Text: Muc-Off MTB Tubeless Sealant, UnitOfMeasureCode: EA, Price: 8.00 } + - { ProductId: ^31, Sku: MUCOFF-DRIVETRAIN-CLEANER, Text: Muc-Off Drivetrain Cleaner, UnitOfMeasureCode: EA, Price: 25.00 } + - { ProductId: ^32, Sku: LABOR, Text: Labor, UnitOfMeasureCode: HR, Price: 80.00, IsNonStocked: true } + - Basket: + - { BasketId: ^3001, CustomerId: ^1001, BasketStatusCode: E, SubTotal: 0, Total: 0 } + - { BasketId: ^3002, CustomerId: ^1002, BasketStatusCode: C, SubTotal: 9679, DiscountCouponCode: XMAS2025, DiscountAmount: 145.19, Total: 9533.81 } + - { BasketId: ^3003, CustomerId: ^1001, BasketStatusCode: A, SubTotal: 9999, Total: 9999 } + - { BasketId: ^3004, CustomerId: ^1003, BasketStatusCode: B, SubTotal: 98, Total: 98 } + - { BasketId: ^3005, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98 } + - { BasketId: ^3006, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98 } + - { BasketId: ^3007, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98 } + - { BasketId: ^3008, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98 } + - BasketItem: + - { BasketItemId: ^4001, BasketId: ^3006, ProductId: ^27, Sku: ONEUP-COMPOSITE-PEDALS, Text: OneUp Composite Pedals, UnitOfMeasureCode: PR, Quantity: 2, UnitPrice: 49 } + - { BasketItemId: ^4002, BasketId: ^3001, ProductId: ^1, Sku: YETI-ASR-C2-2025, Text: Yeti ASR C2, UnitOfMeasureCode: EA, Quantity: 2, UnitPrice: 129 } + - { BasketItemId: ^4003, BasketId: ^3002, ProductId: ^1, Sku: YETI-ASR-C2-2025, Text: Yeti ASR C2, UnitOfMeasureCode: EA, Quantity: 1, UnitPrice: 9850 } + - { BasketItemId: ^4004, BasketId: ^3002, ProductId: ^20, Sku: SHIMANO-SLX-M7100-CASSETTE, Text: Shimano SLX M7100 12-speed Cassette, UnitOfMeasureCode: EA, Quantity: 1, UnitPrice: 99 } + - { BasketItemId: ^4005, BasketId: ^3003, ProductId: ^12, Sku: SPEC-ENDURO-PRO, Text: Specialized Enduro Pro, UnitOfMeasureCode: EA, Quantity: 1, UnitPrice: 9500 } + - { BasketItemId: ^4006, BasketId: ^3003, ProductId: ^21, Sku: SHIMANO-XTR-M9100-CRANK, Text: Shimano XTR M9100 Crankset, UnitOfMeasureCode: EA, Quantity: 1, UnitPrice: 499 } + - { BasketItemId: ^4007, BasketId: ^3004, ProductId: ^27, Sku: ONEUP-COMPOSITE-PEDALS, Text: OneUp Composite Pedals, UnitOfMeasureCode: PR, Quantity: 2, UnitPrice: 49 } + - { BasketItemId: ^4008, BasketId: ^3005, ProductId: ^27, Sku: ONEUP-COMPOSITE-PEDALS, Text: OneUp Composite Pedals, UnitOfMeasureCode: PR, Quantity: 2, UnitPrice: 49 } + - { BasketItemId: ^4009, BasketId: ^3007, ProductId: ^1, Sku: YETI-ASR-C2-2025, Text: Yeti ASR C2, UnitOfMeasureCode: EA, Quantity: 2, UnitPrice: 9850 } + - { BasketItemId: ^4010, BasketId: ^3007, ProductId: ^20, Sku: SHIMANO-SLX-M7100-CASSETTE, Text: Shimano SLX M7100 12-speed Cassette, UnitOfMeasureCode: EA, Quantity: 1, UnitPrice: 99 } + - { BasketItemId: ^4011, BasketId: ^3008, ProductId: ^1, Sku: YETI-ASR-C2-2025, Text: Yeti ASR C2, UnitOfMeasureCode: EA, Quantity: 2, UnitPrice: 9850 } + - { BasketItemId: ^4012, BasketId: ^3008, ProductId: ^20, Sku: SHIMANO-SLX-M7100-CASSETTE, Text: Shimano SLX M7100 12-speed Cassette, UnitOfMeasureCode: EA, Quantity: 1, UnitPrice: 99 } + - { BasketItemId: ^4013, BasketId: ^3007, ProductId: ^32, Sku: LABOR, Text: Labor, UnitOfMeasureCode: HR, Quantity: 1.5, UnitPrice: 80 } diff --git a/samples/tests/Contoso.Shopping.Test.Common/TestData.cs b/samples/tests/Contoso.Shopping.Test.Common/TestData.cs new file mode 100644 index 00000000..662091f2 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Common/TestData.cs @@ -0,0 +1,6 @@ +namespace Contoso.Shopping.Test.Common; + +/// +/// Marker class for test data used across multiple test projects in the 'Contoso.Shopping' sample. +/// +public sealed class TestData { } \ No newline at end of file diff --git a/servicebus/Config.json b/servicebus/Config.json new file mode 100644 index 00000000..47a1a331 --- /dev/null +++ b/servicebus/Config.json @@ -0,0 +1,81 @@ +{ + "UserConfig": { + "Namespaces": [ + { + "Name": "sbemulatorns", + "Topics": [ + { + "Name": "unit-test", + "Properties": { + "DefaultMessageTimeToLive": "PT1H", + "RequiresDuplicateDetection": false + }, + "Subscriptions": [ + { + "Name": "default", + "Properties": { + "DefaultMessageTimeToLive": "PT1H", + "LockDuration": "PT1M", + "MaxDeliveryCount": 10, + "DeadLetteringOnMessageExpiration": false, + "RequiresSession": false + } + } + ] + }, + { + "Name": "unit-test-2", + "Properties": { + "DefaultMessageTimeToLive": "PT1H", + "RequiresDuplicateDetection": false + }, + "Subscriptions": [ + { + "Name": "default", + "Properties": { + "DefaultMessageTimeToLive": "PT1H", + "LockDuration": "PT1M", + "MaxDeliveryCount": 10, + "DeadLetteringOnMessageExpiration": false, + "RequiresSession": false + } + } + ] + }, + { + "Name": "contoso", + "Properties": { + "DefaultMessageTimeToLive": "PT1H", + "RequiresDuplicateDetection": false + }, + "Subscriptions": [ + { + "Name": "products", + "Properties": { + "DefaultMessageTimeToLive": "PT1H", + "LockDuration": "PT1M", + "MaxDeliveryCount": 10, + "DeadLetteringOnMessageExpiration": false, + "RequiresSession": true + } + }, + { + "Name": "shopping", + "Properties": { + "DefaultMessageTimeToLive": "PT1H", + "LockDuration": "PT1M", + "MaxDeliveryCount": 10, + "DeadLetteringOnMessageExpiration": false, + "RequiresSession": true + } + } + ] + } + ] + } + ], + "Logging": { + "Type": "Console" + } + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore.NSwag/CoreEx.AspNetCore.NSwag.csproj b/src/CoreEx.AspNetCore.NSwag/CoreEx.AspNetCore.NSwag.csproj new file mode 100644 index 00000000..44a2c3af --- /dev/null +++ b/src/CoreEx.AspNetCore.NSwag/CoreEx.AspNetCore.NSwag.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/CoreEx.AspNetCore.NSwag/CoreExNSwagExtensions.DependencyInjection.cs b/src/CoreEx.AspNetCore.NSwag/CoreExNSwagExtensions.DependencyInjection.cs new file mode 100644 index 00000000..827cecac --- /dev/null +++ b/src/CoreEx.AspNetCore.NSwag/CoreExNSwagExtensions.DependencyInjection.cs @@ -0,0 +1,53 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides the extensions. +/// +public static class CoreExNSwagExtensions +{ + /// + /// Adds the CoreEx-specific OpenAPI configuration. + /// + /// The . + /// The for fluent-style method-chaining. + /// This is a shortcut for calling both and . + public static OpenApiDocumentGeneratorSettings AddCoreExConfiguration(this OpenApiDocumentGeneratorSettings settings) => settings.AddOpenApiDocumentExtensions().ConfigureSchemaSettings(); + + /// + /// Adds the CoreEx-specific OpenAPI generated specification configuration extensions. + /// + /// The . + /// An optional action to configure the . + /// The for fluent-style method-chaining. + public static OpenApiDocumentGeneratorSettings AddOpenApiDocumentExtensions(this OpenApiDocumentGeneratorSettings settings, Action? configure = null) + { + settings.ThrowIfNull(); + + var options = new OpenApiOptions(); + configure?.Invoke(options); + + settings.OperationProcessors.Add(new NSwagOpenApiOperationProcessor(options)); + return settings; + } + + /// + /// Configures the to use the (unless specifically overridden). + /// + /// The . + /// The optional override. + /// The for fluent-style method-chaining. + public static OpenApiDocumentGeneratorSettings ConfigureSchemaSettings(this OpenApiDocumentGeneratorSettings settings, JsonSerializerOptions? jsonSerializerOptions = null) + { + settings.ThrowIfNull(); + + settings.SchemaSettings = new SystemTextJsonSchemaGeneratorSettings() + { + SchemaType = NJsonSchema.SchemaType.OpenApi3, + SerializerOptions = jsonSerializerOptions ?? JsonDefaults.SerializerOptions + }; + + return settings; + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore.NSwag/GlobalUsing.cs b/src/CoreEx.AspNetCore.NSwag/GlobalUsing.cs new file mode 100644 index 00000000..b2b8617a --- /dev/null +++ b/src/CoreEx.AspNetCore.NSwag/GlobalUsing.cs @@ -0,0 +1,20 @@ +global using CoreEx; +global using CoreEx.AspNetCore; +global using CoreEx.AspNetCore.Idempotency; +global using CoreEx.AspNetCore.Mvc; +global using CoreEx.AspNetCore.NSwag; +global using CoreEx.Data; +global using CoreEx.Http; +global using CoreEx.Json; +global using CoreEx.Results; +global using Microsoft.AspNetCore.Http; +global using NJsonSchema; +global using NJsonSchema.Generation; +global using NSwag; +global using NSwag.Generation; +global using NSwag.Generation.AspNetCore; +global using NSwag.Generation.Processors; +global using NSwag.Generation.Processors.Contexts; +global using System.Net; +global using System.Net.Mime; +global using System.Text.Json; \ No newline at end of file diff --git a/src/CoreEx.AspNetCore.NSwag/NSwagOpenApiOperationProcessor.cs b/src/CoreEx.AspNetCore.NSwag/NSwagOpenApiOperationProcessor.cs new file mode 100644 index 00000000..434c2228 --- /dev/null +++ b/src/CoreEx.AspNetCore.NSwag/NSwagOpenApiOperationProcessor.cs @@ -0,0 +1,213 @@ +namespace CoreEx.AspNetCore.NSwag; + +/// +/// Provides the NSwag implementation that leverages the and applies the relevant attributes to the generated output. +/// +/// The . +internal sealed class NSwagOpenApiOperationProcessor(OpenApiOptions options) : IOperationProcessor +{ + /// + /// Gets the . + /// + public OpenApiOptions Options { get; } = options.ThrowIfNull(); + + /// + public bool Process(OperationProcessorContext context) + { + if (context is AspNetCoreOperationProcessorContext ctx) + { + HandleAcceptsAttribute(ctx); + HandleQueryAttribute(ctx); + HandlePagingAttribute(ctx); + HandleIdempotencyKeyAttribute(ctx); + HandleProducesNotFoundProblemAttribute(ctx); + HandleResponseProblemDetails(ctx); + + if (Options.IncludeFieldsRequestHeaders) + HandleRequestFieldQueryString(ctx); + + if (Options.IncludeMessagesResponseHeaders) + HandleMessagesResponseHeaders(ctx); + } + + return true; + } + + /// + /// Handles the . + /// + private static void HandleAcceptsAttribute(AspNetCoreOperationProcessorContext context) + { + var accepts = (AcceptsAttribute?)context.ApiDescription.ActionDescriptor.EndpointMetadata.FirstOrDefault(x => x is AcceptsAttribute); + if (accepts is null) + return; + + var body = new OpenApiRequestBody(); + context.OperationDescription.Operation.RequestBody = body; + + var schema = GetOrGenerateSchemaForType(context, accepts.BodyType); + foreach (var contentType in new[] { accepts.ContentType }.Concat(accepts.AdditionalContentTypes ?? [])) + { + body.Content.Add(contentType, new OpenApiMediaType { Schema = schema }); + } + } + + /// + /// Handles the . + /// + private void HandleQueryAttribute(AspNetCoreOperationProcessorContext context) + { + var query = (QueryAttribute?)context.ApiDescription.ActionDescriptor.EndpointMetadata.FirstOrDefault(x => x is QueryAttribute); + if (query is null) + return; + + if (query.SupportsFilter) + context.OperationDescription.Operation.Parameters.Add(CreateParameter(HttpNames.QueryFilterQueryStringName, Options.QueryFilterText, JsonObjectType.String, $"{nameof(QueryArgs)}{nameof(QueryArgs.Filter)}")); + + if (query.SupportsOrderBy) + context.OperationDescription.Operation.Parameters.Add(CreateParameter(HttpNames.QueryOrderByQueryStringName, Options.QueryOrderByText, JsonObjectType.String, $"{nameof(QueryArgs)}{nameof(QueryArgs.OrderBy)}")); + } + + /// + /// Handles the . + /// + private void HandlePagingAttribute(AspNetCoreOperationProcessorContext context) + { + var paging = (PagingAttribute?)context.ApiDescription.ActionDescriptor.EndpointMetadata.FirstOrDefault(x => x is PagingAttribute); + if (paging is null) + return; + + context.OperationDescription.Operation.Parameters.Add(CreateParameter(HttpNames.PagingSkipQueryStringName, Options.PagingSkipText, JsonObjectType.Integer, $"{nameof(PagingArgs)}{nameof(PagingArgs.Skip)}")); + context.OperationDescription.Operation.Parameters.Add(CreateParameter(HttpNames.PagingTakeQueryStringName, Options.PagingTakeText, JsonObjectType.Integer, $"{nameof(PagingArgs)}{nameof(PagingArgs.Take)}")); + if (paging.SupportsCount) + context.OperationDescription.Operation.Parameters.Add(CreateParameter(HttpNames.PagingCountQueryStringName, Options.PagingCountText, JsonObjectType.Boolean, $"{nameof(PagingArgs)}{nameof(ITotalCount.IsCountRequested)}")); + + if (Options.IncludePagingResponseHeaders) + { + foreach (var r in context.OperationDescription.Operation.Responses.Where(r => int.TryParse(r.Key, out var code) && code >= 200 && code < 300)) + { + r.Value.Headers.Add(HttpNames.PagingSkipHeaderName, new OpenApiHeader { Schema = new JsonSchema { Type = JsonObjectType.Integer }, OriginalName = $"{nameof(PagingResult)}{nameof(PagingResult.Skip)}", Description = Options.PagingSkipText }); + r.Value.Headers.Add(HttpNames.PagingTakeHeaderName, new OpenApiHeader { Schema = new JsonSchema { Type = JsonObjectType.Integer }, OriginalName = $"{nameof(PagingResult)}{nameof(PagingResult.Take)}", Description = Options.PagingTakeText }); + if (paging.SupportsCount) + r.Value.Headers.Add(HttpNames.PagingTotalCountHeaderName, new OpenApiHeader { Schema = new JsonSchema { Type = JsonObjectType.Integer, IsNullableRaw = true }, OriginalName = $"{nameof(PagingResult)}{nameof(PagingResult.TotalCount)}", Description = Options.PagingTotalCountText }); + } + } + } + + /// + /// Handles the . + /// + /// + private void HandleIdempotencyKeyAttribute(AspNetCoreOperationProcessorContext context) + { + var idempotencyKey = (IdempotencyKeyAttribute?)context.ApiDescription.ActionDescriptor.EndpointMetadata.FirstOrDefault(x => x is IdempotencyKeyAttribute); + if (idempotencyKey is null) + return; + + context.OperationDescription.Operation.Parameters.Add(new OpenApiParameter + { + Name = HttpNames.IdempotencyKeyHeaderName, + OriginalName = nameof(IdempotencyKey), + Description = Options.IdempotencyKeyText, + Kind = OpenApiParameterKind.Header, + Schema = new JsonSchema { Type = JsonObjectType.String, MinLength = 8, MaxLength = 128, IsNullableRaw = !idempotencyKey.IsRequired } + }); + } + + /// + /// Handles the . + /// + private static void HandleProducesNotFoundProblemAttribute(AspNetCoreOperationProcessorContext context) + { + var notFoundProblem = (ProducesNotFoundProblemAttribute?)context.ApiDescription.ActionDescriptor.EndpointMetadata.FirstOrDefault(x => x is ProducesNotFoundProblemAttribute); + if (notFoundProblem is null) + return; + + // Add the NotFound ProblemDetails response. + var schema = GetOrGenerateSchemaForType(context, typeof(Microsoft.AspNetCore.Mvc.ProblemDetails)); + var key = ((int)HttpStatusCode.NotFound).ToString(); + if (!context.OperationDescription.Operation.Responses.ContainsKey(key)) + context.OperationDescription.Operation.Responses[key] = new OpenApiResponse { Content = { [MediaTypeNames.Application.ProblemJson] = new OpenApiMediaType { Schema = schema } } }; + } + + /// + /// Handles the response problems. + /// + private void HandleResponseProblemDetails(AspNetCoreOperationProcessorContext context) + { + if (options.IncludeValidationProblemDetailsHttpStatusCodes) + { + var schema = GetOrGenerateSchemaForType(context, typeof(Microsoft.AspNetCore.Mvc.ValidationProblemDetails)); + + foreach (var sc in options.ValidationProblemDetailsHttpStatusCodes) + { + var key = ((int)sc).ToString(); + if (!context.OperationDescription.Operation.Responses.ContainsKey(key)) + context.OperationDescription.Operation.Responses[key] = new OpenApiResponse { Content = { [MediaTypeNames.Application.ProblemJson] = new OpenApiMediaType { Schema = schema } } }; + } + } + + if (options.IncludeProblemDetailsHttpStatusCodes) + { + var schema = GetOrGenerateSchemaForType(context, typeof(Microsoft.AspNetCore.Mvc.ProblemDetails)); + + foreach (var sc in options.ProblemDetailsHttpStatusCodes) + { + var key = ((int)sc).ToString(); + if (!context.OperationDescription.Operation.Responses.ContainsKey(key)) + context.OperationDescription.Operation.Responses[key] = new OpenApiResponse { Content = { [MediaTypeNames.Application.ProblemJson] = new OpenApiMediaType { Schema = schema } } }; + } + } + } + + /// + /// Gets or generates the schema for the specified . + /// + private static JsonSchema GetOrGenerateSchemaForType(AspNetCoreOperationProcessorContext context, Type type) + { + var schema = context.SchemaGenerator.Generate(type, context.SchemaResolver); + + if (schema.Reference is not null) + return new JsonSchema { Reference = schema.Reference }; + + var name = schema.Title ?? type.Name; + context.Document.Components.Schemas[name] = schema.ActualSchema; + return new JsonSchema { Reference = schema.ActualSchema }; + } + + /// + /// Handles the and response headers. + /// + private void HandleMessagesResponseHeaders(AspNetCoreOperationProcessorContext context) + { + foreach (var r in context.OperationDescription.Operation.Responses.Where(x => int.TryParse(x.Key, out var sc) && sc < 400)) + { + r.Value.Headers.Add(HttpNames.WarningMessagesHeaderName, new OpenApiHeader { Schema = new JsonSchema { Type = JsonObjectType.Array, Item = new JsonSchema { Type = JsonObjectType.String } }, OriginalName = "WarningMessages", Description = Options.WarningMessagesText }); + r.Value.Headers.Add(HttpNames.InfoMessagesHeaderName, new OpenApiHeader { Schema = new JsonSchema { Type = JsonObjectType.Array, Item = new JsonSchema { Type = JsonObjectType.String } }, OriginalName = "InfoMessages", Description = Options.InfoMessagesText }); + } + } + + /// + /// Handles the and . + /// + private void HandleRequestFieldQueryString(AspNetCoreOperationProcessorContext context) + { + if (context.OperationDescription.Method.Equals(HttpMethods.Get, StringComparison.OrdinalIgnoreCase) && context.OperationDescription.Operation.Responses.Any(x => int.TryParse(x.Key, out var sc) && sc < 400 && x.Value.Content.ContainsKey(MediaTypeNames.Application.Json))) + { + context.OperationDescription.Operation.Parameters.Add(CreateParameter(HttpNames.IncludeFieldsQueryStringName, Options.IncludeFieldsText, JsonObjectType.String, $"{nameof(PagingArgs)}{nameof(QueryArgs.IncludeFields)}")); + context.OperationDescription.Operation.Parameters.Add(CreateParameter(HttpNames.ExcludeFieldsQueryStringName, Options.ExcludeFieldsText, JsonObjectType.String, $"{nameof(PagingArgs)}{nameof(QueryArgs.ExcludeFields)}")); + } + } + + /// + /// Create the parameter definition. + /// + private static OpenApiParameter CreateParameter(string name, string description, JsonObjectType type, string? original = null) => new() + { + Name = name, + OriginalName = original ?? name, + Description = description, + Kind = OpenApiParameterKind.Query, + Schema = new JsonSchema { Type = type } + }; +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/AspNetCoreApplicationBuilderExtensions.cs b/src/CoreEx.AspNetCore/Abstractions/AspNetCoreApplicationBuilderExtensions.cs deleted file mode 100644 index 35ce35f9..00000000 --- a/src/CoreEx.AspNetCore/Abstractions/AspNetCoreApplicationBuilderExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.RefData; -using CoreEx.AspNetCore.WebApis; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Threading.Tasks; - -namespace Microsoft.AspNetCore.Builder -{ - /// - /// Provides extensions. - /// - public static class AspNetCoreApplicationBuilderExtensions - { - /// - /// Registers the action to the for a request. - /// - /// The . - /// An optional function to update the . - /// The . - public static IApplicationBuilder UseExecutionContext(this IApplicationBuilder builder, Func? executionContextUpdate = null) - => builder.UseMiddleware(executionContextUpdate ?? WebApiExecutionContextMiddleware.DefaultExecutionContextUpdate); - - /// - /// Adds the to the request execution pipeline. - /// - /// The . - /// The . - public static IApplicationBuilder UseWebApiExceptionHandler(this IApplicationBuilder builder) => builder.UseMiddleware(); - - /// - /// Registers the instance (using ) so that it can be universally referenced. - /// - /// The . - /// The . - public static IApplicationBuilder UseReferenceDataOrchestrator(this IApplicationBuilder builder) - { - ReferenceDataOrchestrator.SetCurrent(builder.ApplicationServices.GetRequiredService()); - return builder; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/AspNetCoreServiceCollectionExtensions.cs b/src/CoreEx.AspNetCore/Abstractions/AspNetCoreServiceCollectionExtensions.cs deleted file mode 100644 index 1063bf42..00000000 --- a/src/CoreEx.AspNetCore/Abstractions/AspNetCoreServiceCollectionExtensions.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.AspNetCore.WebApis; -using CoreEx.Configuration; -using CoreEx.Events; -using CoreEx.Json; -using CoreEx.Json.Merge; -using Microsoft.Extensions.Logging; -using System; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extensions. - /// - public static class AspNetCoreServiceCollectionExtensions - { - /// - /// Checks that the is not null. - /// - private static IServiceCollection CheckServices(IServiceCollection services) => services.ThrowIfNull(nameof(services)); - - /// - /// Adds the as a scoped service. - /// - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddWebApi(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddScoped(sp => - { - var wa = new WebApi(sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetService(), sp.GetService()); - configure?.Invoke(sp, wa); - return wa; - }); - - /// - /// Adds the as a scoped service. - /// - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddReferenceDataContentWebApi(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddScoped(sp => - { - var wa = new ReferenceDataContentWebApi(sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetService(), sp.GetService()); - configure?.Invoke(sp, wa); - return wa; - }); - - /// - /// Adds the as a scoped service. - /// - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddWebApiPublisher(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddScoped(sp => - { - var wap = new WebApiPublisher(sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetService()); - configure?.Invoke(sp,wap); - return wap; - }); - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/IWebApiRequestOptions.cs b/src/CoreEx.AspNetCore/Abstractions/IWebApiRequestOptions.cs new file mode 100644 index 00000000..62e9dcaf --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/IWebApiRequestOptions.cs @@ -0,0 +1,19 @@ +namespace CoreEx.AspNetCore.Abstractions; + +/// +/// Enables the request options. +/// +/// The request type. +public interface IWebApiRequestOptions +{ + /// + /// Gets the request value or . + /// + TRequest? ValueOrDefault { get; } + + /// + /// Gets the request value where not ; otherwise, results in a corresponding (see ). + /// + [NotNull] + TRequest Value { get; } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/IWebApiResponseOptions.cs b/src/CoreEx.AspNetCore/Abstractions/IWebApiResponseOptions.cs new file mode 100644 index 00000000..5e6d99a0 --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/IWebApiResponseOptions.cs @@ -0,0 +1,12 @@ +namespace CoreEx.AspNetCore.Abstractions; + +/// +/// Enables the response options. +/// +public interface IWebApiResponseOptions +{ + /// + /// Creates the representing the location of the resource. + /// + Uri? CreateLocationUri(object? value); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/IWebApiResponseOptionsT.cs b/src/CoreEx.AspNetCore/Abstractions/IWebApiResponseOptionsT.cs new file mode 100644 index 00000000..f9f5059f --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/IWebApiResponseOptionsT.cs @@ -0,0 +1,13 @@ +namespace CoreEx.AspNetCore.Abstractions; + +/// +/// Enables the response options. +/// +/// The response type. +public interface IWebApiResponseOptions : IWebApiResponseOptions +{ + /// + /// Gets the function that will return the representing the location of the resource. + /// + Func? LocationUri { get; } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/ReferenceDataOrchestratorExtensions.cs b/src/CoreEx.AspNetCore/Abstractions/ReferenceDataOrchestratorExtensions.cs deleted file mode 100644 index c7177b9e..00000000 --- a/src/CoreEx.AspNetCore/Abstractions/ReferenceDataOrchestratorExtensions.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.AspNetCore.WebApis; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.RefData -{ - /// - /// Provides extensions. - /// - public static class ReferenceDataOrchestratorExtensions - { - /// - /// Gets the reference data items for the specified names and related codes (see ) from the . - /// - /// The . - /// The . - /// The . - /// The . - /// The reference data names and codes are specified as part of the query string. Either '?names=RefA,RefB,RefX' or ?RefA,RefB=CodeA,CodeB,RefX=CodeX or any combination thereof. - public static Task GetNamedAsync(this ReferenceDataOrchestrator orchestrator, WebApiRequestOptions requestOptions, CancellationToken cancellationToken = default) - { - var dict = new Dictionary>(); - - foreach (var q in requestOptions.Request.Query.Where(x => !string.IsNullOrEmpty(x.Key))) - { - if (string.Compare(q.Key, "names", StringComparison.OrdinalIgnoreCase) == 0) - { - foreach (var v in SplitStringValues(q.Value.Where(x => !string.IsNullOrEmpty(x)).Distinct()!)) - { - dict.TryAdd(v, []); - } - } - else - { - if (dict.TryGetValue(q.Key, out var codes)) - { - foreach (var code in SplitStringValues(q.Value.Distinct()!)) - { - if (!codes.Contains(code)) - codes.Add(code); - } - } - else - dict.Add(q.Key, new List(SplitStringValues(q.Value.Distinct()!))); - } - } - - return orchestrator.GetNamedAsync(dict.ToList(), requestOptions.IncludeInactive, cancellationToken); - } - - /// - /// Perform a further split of the string values. - /// - private static List SplitStringValues(IEnumerable values) - { - var list = new List(); - foreach (var value in values) - { - list.AddRange(value.Split(',', StringSplitOptions.RemoveEmptyEntries)); - } - - return list; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.Delete.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.Delete.cs new file mode 100644 index 00000000..09974389 --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.Delete.cs @@ -0,0 +1,97 @@ +namespace CoreEx.AspNetCore.Abstractions; + +public abstract partial class WebApi +{ + /// + /// Performs a operation. + /// + /// The . + /// The function to execute. + /// The resulting . + /// The . + /// The resulting . + public Task DeleteAsync(HttpRequest request, Func function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => DeleteWithResultAsync(request, async (ro, ct) => { await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Success; }, statusCode, cancellationToken); + + /// + /// Performs a operation. + /// + /// The . + /// The function to execute. + /// The resulting . + /// The . + /// The resulting . + public async Task DeleteWithResultAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Delete]); + function.ThrowIfNull(); + + TResult CreateStatusResult(WebApiOptions ro) => CreateResult(new WebApiResult(request.HttpContext.Response) { StatusCode = ro.StatusCode }); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiOptions(request).WithOperationType(OperationType.Delete).WithStatusCode(statusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + try + { + var result = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(DeleteAsync)}::{nameof(function)}").ConfigureAwait(false); + + if (result.IsFailure) + { + if (ConvertNotfoundToDefaultStatusCodeOnDelete && result.IsNotFoundError) + return CreateStatusResult(ro); + else + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = result.Error }); + } + else + return CreateStatusResult(ro); + } + catch (NotFoundException) when (ConvertNotfoundToDefaultStatusCodeOnDelete) + { + return CreateStatusResult(ro); + } + }, cancellationToken, nameof(DeleteAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with a response of . + /// + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + /// The is not applicable for this method. + public Task DeleteAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + => DeleteWithResultAsync(request, async (ro, ct) => { var r = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(r); }, statusCode, cancellationToken); + + /// + /// Performs a operation with a response of . + /// + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + /// The is not applicable for this method. + public async Task DeleteWithResultAsync(HttpRequest request, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Delete]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiResponseOptions(request).WithOperationType(OperationType.Delete).WithStatusCode(statusCode); + + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(DeleteAsync)}::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, fr)); + }, cancellationToken, nameof(DeleteAsync)); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.Get.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.Get.cs new file mode 100644 index 00000000..28bc60f4 --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.Get.cs @@ -0,0 +1,45 @@ +namespace CoreEx.AspNetCore.Abstractions; + +public abstract partial class WebApi +{ + /// + /// Performs a (and ) operation returning a response of . + /// + /// The result . + /// The . + /// The function to execute. + /// The where result is not . + /// The alternate where result is . + /// The . + /// The resulting . + /// This also handles a and responds accordingly. + public Task GetAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NotFound, CancellationToken cancellationToken = default) + => GetWithResultAsync(request, async (ro, ct) => { var rv = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(rv); }, statusCode, alternateStatusCode, cancellationToken); + + /// + /// Performs a (and ) operation returning a response of . + /// + /// The result . + /// The . + /// The function to execute. + /// The where result is not . + /// The alternate where result is . + /// The . + /// The resulting . + /// This also handles a and responds accordingly. + public async Task GetWithResultAsync(HttpRequest request, Func>> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NotFound, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Get, HttpMethods.Head]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiResponseOptions(request).WithStatusCode(statusCode).WithAlternateStatusCode(alternateStatusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var result = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(GetAsync)}::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, result)); + }, cancellationToken, nameof(GetAsync)).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.MergePatch.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.MergePatch.cs new file mode 100644 index 00000000..7c215676 --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.MergePatch.cs @@ -0,0 +1,105 @@ +namespace CoreEx.AspNetCore.Abstractions; + +public abstract partial class WebApi +{ + /// + /// Performs a operation with a request JSON content value of returning a corresponding response . + /// + /// The request and response value . + /// The . + /// The function to execute the get to retrieve the value to patch into. Where this returns a then this will result in a of . + /// The function to execute the put to replace (update) the patched value. + /// The where result is not . + /// The . + /// The resulting . + public Task PatchAsync(HttpRequest request, Func, CancellationToken, Task> get, Func, CancellationToken, Task> put, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + => PatchWithResultAsync(request, + async (ro, ct) => Result.Ok(await get(ro, ct).ConfigureAwait(false)), + async (ro, ct) => Result.Ok(await put(ro, ct).ConfigureAwait(false)), + statusCode, cancellationToken); + + /// + /// Performs a operation with a request JSON content value of returning a corresponding response . + /// + /// The request and response value . + /// The . + /// The function to execute the get to retrieve the value to patch into. Where this returns a then this will result in a of . + /// The function to execute the put to replace (update) the patched value. + /// The where result is not . + /// The . + /// The resulting . + public async Task PatchWithResultAsync(HttpRequest request, Func, CancellationToken, Task>> get, Func, CancellationToken, Task>> put, HttpStatusCode statusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Patch]); + get.ThrowIfNull(); + put.ThrowIfNull(); + + TResult RequiredErrorResult() => CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = Validation.Validation.CreateRequiredValueResult().Error }); + + return await InvokeAsync(request, async cancellationToken => + { + // Make sure that only the supported content types are used. + var hct = request.GetTypedHeaders()?.ContentType?.MediaType.Value; + if (StringComparer.OrdinalIgnoreCase.Compare(hct, HttpNames.MergePatchJsonMediaTypeName) != 0) + return CreateResult(new WebApiResult(request.HttpContext.Response) + { + StatusCode = HttpStatusCode.UnsupportedMediaType, + ContentType = MediaTypeNames.Text.Plain, + Content = $"Unsupported '{HeaderNames.ContentType}' for an HTTP {HttpMethods.Patch}; only JSON Merge Patch is supported using either: '{HttpNames.MergePatchJsonMediaTypeName}' or '{MediaTypeNames.Application.Json}'." + }); + + // Get the JSON merge content. + if (request.ContentLength is null || request.ContentLength == 0) + return RequiredErrorResult(); + + var content = await BinaryData.FromStreamAsync(request.Body, cancellationToken).ConfigureAwait(false); + if (content.IsEmpty) + return RequiredErrorResult(); + + // Invokes the Merge which also includes the get function execution. + var gro = new WebApiResponseOptions(request).WithOperationType(OperationType.Get).WithStatusCode(statusCode); + if (gro.IsInError(out var gror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = gror.Error }); + + var mpr = await JsonMergePatch.MergeWithResultAsync(content, async ct => + { + // Perform the get operation to retrieve the current value. + return Result.Go(await _invoker.InvokeAsync(this, async (_, cancellationToken) => await get(gro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(PatchAsync)}::{nameof(get)}").ConfigureAwait(false)) + .Then(gv => + { + if (gv is null || gv is not IReadOnlyETag etag) + return gv; + + // Where there is etag support and it is null (assumes auto-generation) then generate; and finally compare etag for a match. + //ETag.Compare(gro.ETag, etag.ETag ?? ETag.Generate(gv, JsonSerializerOptions)); + if (gro.ETag != (etag.ETag ?? ETag.Generate(gv, JsonSerializerOptions))) + return Result.ConcurrencyError(); + + return gv; + }); + }, cancellationToken).ConfigureAwait(false); + + // Value is not good so error out. + if (mpr.IsFailure) + { + if (mpr.Error is IExtendedException) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = mpr.Error }); + else + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = Validation.Validation.CreateInvalidValueResult(mpr.Error.Message).Error }); + } + + // Value is not found so error out. + if (mpr.Value.Merged is null) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = new NotFoundException() }); + + // No change so return the get value as-is. + if (!mpr.Value.HasChanges) + return CreateResult(CreateContentForValue(gro, mpr.Value.Merged)); + + // Finish by performing the put operation with the changed value. + var pro = new WebApiRequestResponseOptions(gro, mpr.Value.Merged).WithOperationType(OperationType.Update); + var value = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await put(pro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(PatchAsync)}::{nameof(put)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(pro, value)); + }, cancellationToken, nameof(PatchAsync)).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.Patch.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.Patch.cs new file mode 100644 index 00000000..6a05f78f --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.Patch.cs @@ -0,0 +1,261 @@ +namespace CoreEx.AspNetCore.Abstractions; + +public abstract partial class WebApi +{ + /// + /// Performs a operation with no request content or response value. + /// + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public Task PatchAsync(HttpRequest request, Func function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PatchWithResultAsync(request, async (ro, ct) => { await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Success; }, statusCode, cancellationToken); + + /// + /// Performs a operation with no request content or response value. + /// + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public async Task PatchWithResultAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Patch]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiOptions(request).WithOperationType(OperationType.Create).WithStatusCode(statusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Patch)}Async::{nameof(function)}").ConfigureAwait(false); + if (fr.IsSuccess) + return CreateResult(new WebApiResult(request.HttpContext.Response) { StatusCode = ro.StatusCode }); + else + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = fr.Error }); + }, cancellationToken, nameof(PatchAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with a request JSON content value of and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public Task PatchAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PatchWithResultAsync(request, async (ro, ct) => { await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Success; }, statusCode, cancellationToken); + + /// + /// Performs a operation with a request JSON content value of and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public async Task PatchWithResultAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Patch]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var vr = await GetRequestValueAsync(request, cancellationToken).ConfigureAwait(false); + if (vr.IsFailure) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = vr.Error }); + + var ro = new WebApiRequestOptions(request, vr.Value).WithOperationType(OperationType.Create).WithStatusCode(statusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Patch)}Async::{nameof(function)}").ConfigureAwait(false); + if (fr.IsSuccess) + return CreateResult(new WebApiResult(request.HttpContext.Response) { StatusCode = ro.StatusCode }); + else + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = fr.Error }); + }, cancellationToken, nameof(PatchAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with the specified request and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public Task PatchAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PatchWithResultAsync(request, value, async (ro, ct) => { await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Success; }, statusCode, cancellationToken); + + /// + /// Performs a operation with the specified request and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public async Task PatchWithResultAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Patch]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiRequestOptions(request, value).WithOperationType(OperationType.Create).WithStatusCode(statusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Patch)}Async::{nameof(function)}").ConfigureAwait(false); + if (fr.IsSuccess) + return CreateResult(new WebApiResult(request.HttpContext.Response) { StatusCode = ro.StatusCode }); + else + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = fr.Error }); + }, cancellationToken, nameof(PatchAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with no request body and a response of . + /// + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public Task PatchAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PatchWithResultAsync(request, async (ro, ct) => { var r = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(r); }, statusCode, alternateStatusCode, cancellationToken); + + /// + /// Performs a operation with no request body and a response of . + /// + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public async Task PatchWithResultAsync(HttpRequest request, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Patch]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiResponseOptions(request).WithOperationType(OperationType.Create).WithStatusCode(statusCode).WithAlternateStatusCode(alternateStatusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Patch)}Async::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, fr)); + }, cancellationToken, nameof(PatchAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with a request JSON content value of and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public Task PatchAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.Created, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PatchWithResultAsync(request, async (ro, ct) => { var r = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(r); }, statusCode, alternateStatusCode, cancellationToken); + + /// + /// Performs a operation with a request JSON content value of and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public async Task PatchWithResultAsync(HttpRequest request, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.Created, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Patch]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var vr = await GetRequestValueAsync(request, cancellationToken).ConfigureAwait(false); + if (vr.IsFailure) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = vr.Error }); + + var ro = new WebApiRequestResponseOptions(request, vr.Value).WithOperationType(OperationType.Create).WithStatusCode(statusCode).WithAlternateStatusCode(alternateStatusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Patch)}Async::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, fr)); + }, cancellationToken, nameof(PatchAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with the specified request and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public Task PatchAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.Created, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PatchWithResultAsync(request, value, async (ro, ct) => { var r = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(r); }, statusCode, alternateStatusCode, cancellationToken); + + /// + /// Performs a operation with the specified request and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public async Task PatchWithResultAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.Created, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Patch]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiRequestResponseOptions(request, value).WithOperationType(OperationType.Create).WithStatusCode(statusCode).WithAlternateStatusCode(alternateStatusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Patch)}Async::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, fr)); + }, cancellationToken, nameof(PatchAsync)).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.Post.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.Post.cs new file mode 100644 index 00000000..d783abde --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.Post.cs @@ -0,0 +1,261 @@ +namespace CoreEx.AspNetCore.Abstractions; + +public abstract partial class WebApi +{ + /// + /// Performs a operation with no request content or response value. + /// + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public Task PostAsync(HttpRequest request, Func function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PostWithResultAsync(request, async (ro, ct) => { await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Success; }, statusCode, cancellationToken); + + /// + /// Performs a operation with no request content or response value. + /// + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public async Task PostWithResultAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Post]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiOptions(request).WithOperationType(OperationType.Create).WithStatusCode(statusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Post)}Async::{nameof(function)}").ConfigureAwait(false); + if (fr.IsSuccess) + return CreateResult(new WebApiResult(request.HttpContext.Response) { StatusCode = ro.StatusCode, Headers = new WebApiHeader { Location = ro.LocationUri?.Invoke() } }); + else + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = fr.Error }); + }, cancellationToken, nameof(PostAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with a request JSON content value of and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public Task PostAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PostWithResultAsync(request, async (ro, ct) => { await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Success; }, statusCode, cancellationToken); + + /// + /// Performs a operation with a request JSON content value of and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public async Task PostWithResultAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Post]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var vr = await GetRequestValueAsync(request, cancellationToken).ConfigureAwait(false); + if (vr.IsFailure) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = vr.Error }); + + var ro = new WebApiRequestOptions(request, vr.Value).WithOperationType(OperationType.Create).WithStatusCode(statusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Post)}Async::{nameof(function)}").ConfigureAwait(false); + if (fr.IsSuccess) + return CreateResult(new WebApiResult(request.HttpContext.Response) { StatusCode = ro.StatusCode, Headers = new WebApiHeader { Location = ro.LocationUri?.Invoke() } }); + else + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = fr.Error }); + }, cancellationToken, nameof(PostAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with the specified request and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public Task PostAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PostWithResultAsync(request, value, async (ro, ct) => { await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Success; }, statusCode, cancellationToken); + + /// + /// Performs a operation with the specified request and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public async Task PostWithResultAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Post]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiRequestOptions(request, value).WithOperationType(OperationType.Create).WithStatusCode(statusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Post)}Async::{nameof(function)}").ConfigureAwait(false); + if (fr.IsSuccess) + return CreateResult(new WebApiResult(request.HttpContext.Response) { StatusCode = ro.StatusCode, Headers = new WebApiHeader { Location = ro.LocationUri?.Invoke() } }); + else + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = fr.Error }); + }, cancellationToken, nameof(PostAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with no request body and a response of . + /// + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public Task PostAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.Created, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PostWithResultAsync(request, async (ro, ct) => { var r = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(r); }, statusCode, alternateStatusCode, cancellationToken); + + /// + /// Performs a operation with no request body and a response of . + /// + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public async Task PostWithResultAsync(HttpRequest request, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.Created, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Post]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiResponseOptions(request).WithOperationType(OperationType.Create).WithStatusCode(statusCode).WithAlternateStatusCode(alternateStatusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Post)}Async::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, fr)); + }, cancellationToken, nameof(PostAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with a request JSON content value of and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public Task PostAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.Created, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PostWithResultAsync(request, async (ro, ct) => { var r = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(r); }, statusCode, alternateStatusCode, cancellationToken); + + /// + /// Performs a operation with a request JSON content value of and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public async Task PostWithResultAsync(HttpRequest request, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.Created, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Post]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var vr = await GetRequestValueAsync(request, cancellationToken).ConfigureAwait(false); + if (vr.IsFailure) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = vr.Error }); + + var ro = new WebApiRequestResponseOptions(request, vr.Value).WithOperationType(OperationType.Create).WithStatusCode(statusCode).WithAlternateStatusCode(alternateStatusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Post)}Async::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, fr)); + }, cancellationToken, nameof(PostAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with the specified request and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public Task PostAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.Created, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PostWithResultAsync(request, value, async (ro, ct) => { var r = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(r); }, statusCode, alternateStatusCode, cancellationToken); + + /// + /// Performs a operation with the specified request and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public async Task PostWithResultAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.Created, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Post]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiRequestResponseOptions(request, value).WithOperationType(OperationType.Create).WithStatusCode(statusCode).WithAlternateStatusCode(alternateStatusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Post)}Async::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, fr)); + }, cancellationToken, nameof(PostAsync)).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.Put.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.Put.cs new file mode 100644 index 00000000..bed8bc62 --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.Put.cs @@ -0,0 +1,223 @@ +namespace CoreEx.AspNetCore.Abstractions; + +public abstract partial class WebApi +{ + /// + /// Performs a operation with a request JSON content value of and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public Task PutAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PutWithResultAsync(request, async (ro, ct) => { await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Success; }, statusCode, cancellationToken); + + /// + /// Performs a operation with a request JSON content value of and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public async Task PutWithResultAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Put]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var vr = await GetRequestValueAsync(request, cancellationToken).ConfigureAwait(false); + if (vr.IsFailure) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = vr.Error }); + + var ro = new WebApiRequestOptions(request, vr.Value).WithOperationType(OperationType.Update).WithStatusCode(statusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(PutAsync)}::{nameof(function)}").ConfigureAwait(false); + if (fr.IsSuccess) + return CreateResult(new WebApiResult(request.HttpContext.Response) { StatusCode = ro.StatusCode }); + else + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = fr.Error }); + }, cancellationToken, nameof(PutAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with the specified request and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public Task PutAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PutWithResultAsync(request, value, async (ro, ct) => { await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Success; }, statusCode, cancellationToken); + + /// + /// Performs a operation with the specified request and no corresponding response value. + /// + /// The request JSON content value . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The . + /// The resulting . + public async Task PutWithResultAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Put]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiRequestOptions(request, value).WithOperationType(OperationType.Update).WithStatusCode(statusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(PutAsync)}::{nameof(function)}").ConfigureAwait(false); + if (fr.IsSuccess) + return CreateResult(new WebApiResult(request.HttpContext.Response) { StatusCode = ro.StatusCode }); + else + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = fr.Error }); + }, cancellationToken, nameof(PutAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with no request body and a response of . + /// + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public Task PutAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PutWithResultAsync(request, async (ro, ct) => { var r = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(r); }, statusCode, alternateStatusCode, cancellationToken); + + /// + /// Performs a operation with no request body and a response of . + /// + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public async Task PutWithResultAsync(HttpRequest request, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Put]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiResponseOptions(request).WithOperationType(OperationType.Create).WithStatusCode(statusCode).WithAlternateStatusCode(alternateStatusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var fr = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(HttpMethods.Put)}Async::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, fr)); + }, cancellationToken, nameof(PutAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with a request JSON content value of and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public Task PutAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PutWithResultAsync(request, async (ro, ct) => { var r = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(r); }, statusCode, alternateStatusCode, cancellationToken); + + /// + /// Performs a operation with a request JSON content value of and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public async Task PutWithResultAsync(HttpRequest request, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Put]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var vr = await GetRequestValueAsync(request, cancellationToken).ConfigureAwait(false); + if (vr.IsFailure) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = vr.Error }); + + var ro = new WebApiRequestResponseOptions(request, vr.Value).WithOperationType(OperationType.Update).WithStatusCode(statusCode).WithAlternateStatusCode(alternateStatusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var result = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(PutAsync)}::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, result)); + }, cancellationToken, nameof(PutAsync)).ConfigureAwait(false); + } + + /// + /// Performs a operation with the specified request and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public Task PutAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + => PutWithResultAsync(request, value, async (ro, ct) => { var r = await function.ThrowIfNull()(ro, ct).ConfigureAwait(false); return Result.Ok(r); }, statusCode, alternateStatusCode, cancellationToken); + + /// + /// Performs a operation with the specified request and a response of . + /// + /// The request JSON content value . + /// The response result . + /// The . + /// The value (already deserialized). + /// The function to execute. + /// The where successful. + /// The alternate where result is . + /// The . + /// The resulting . + public async Task PutWithResultAsync(HttpRequest request, TRequest value, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, + HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, CancellationToken cancellationToken = default) + { + CheckRequest(request, [HttpMethods.Put]); + function.ThrowIfNull(); + + return await InvokeAsync(request, async cancellationToken => + { + var ro = new WebApiRequestResponseOptions(request, value).WithOperationType(OperationType.Update).WithStatusCode(statusCode).WithAlternateStatusCode(alternateStatusCode); + if (ro.IsInError(out var ror)) + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ror.Error }); + + var result = await _invoker.InvokeAsync(this, async (_, cancellationToken) => await function(ro, cancellationToken).ConfigureAwait(false), cancellationToken, $"{nameof(PutAsync)}::{nameof(function)}").ConfigureAwait(false); + return CreateResult(CreateContentForValue(ro, result)); + }, cancellationToken, nameof(PutAsync)).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.cs new file mode 100644 index 00000000..e83657af --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.cs @@ -0,0 +1,220 @@ +namespace CoreEx.AspNetCore.Abstractions; + +/// +/// Provides the base ASP.NET Core Web API capabilities to enable both MVC and HTTP support in a consistent manner. +/// +/// The ASP.NET Core result . +/// The . +/// The optional . +/// The optional for the . +/// The optional . +/// The methods within can also be used for a as it is essentially the same operation without a corresponding response; this distinction is handled internally. +public abstract partial class WebApi(WebApiInvoker invoker, JsonSerializerOptions? jsonSerializerOptions = null, ILogger>? logger = null, ExecutionContext? executionContext = null) + : WebApiBase(jsonSerializerOptions, logger, executionContext) +{ + private const string _requestBodyErrorType = "request-body"; + private static readonly LText _requestBodyRequiredText = new("CoreEx.AspNetCore.WebApi.RequestBodyRequired", "Request body is required."); + private static readonly LText _requestBodyInvalidText = new("CoreEx.AspNetCore.WebApi.RequestBodyInvalid", "Request body is invalid: {0}"); + + private readonly WebApiInvoker _invoker = invoker.ThrowIfNull(); + + /// + /// Invokes the asynchronously performing standardized execution. + /// + /// The . + /// The function to execute. + /// The . + /// The calling member name (uses to default). + protected async Task InvokeAsync(HttpRequest request, Func> function, CancellationToken cancellationToken, [CallerMemberName] string? memberName = null) => await _invoker.InvokeAsync(this, async (_, cancellationToken) => + { + try + { + var result = await function(cancellationToken).ConfigureAwait(false); + if (ExecutionContext.TryGetCurrent(out var ec)) + ExecutionContextMiddleware.AddMessagesHeader(request.HttpContext, ec); + + return result; + } + catch (Exception ex) when (ex is IExtendedException eex && eex.IsError) + { + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ex }); + } + catch (Exception ex) when (ConvertUnhandledExceptionsToProblemDetails) + { + return CreateResult(new WebApiResult(request.HttpContext.Response) { Exception = ex }); + } + }, cancellationToken, memberName).ConfigureAwait(false); + + /// + /// Creates the corresponding for the , etc. + /// + private WebApiResult CreateContentForValue(WebApiOptionsBase options, T value, WebApiPagingResult? paging = null, MessageItemCollection? messages = null) + { + options.ThrowIfNull(); + + // Where null then use the alternate status code. + if (value is null) + { + return options.AlternateStatusCode.HasValue + ? new WebApiResult(options.Request.HttpContext.Response) { StatusCode = options.AlternateStatusCode.Value, Exception = OnConvertAlternateStatusCodeToException(options.AlternateStatusCode.Value) } + : throw new InvalidOperationException("Function has not returned a result; no AlternateStatusCode has been configured to return."); + } + + // Where already a TResult then return as-is. + if (value is TResult tr) + return new WebApiResult(options.Request.HttpContext.Response) { Result = tr }; + + // Special case when is railway-oriented IResult, as it is the underlying Value or Exception that is to be processed. + if (value is Results.Abstractions.IResult r) + { + if (r.IsFailure) + return new WebApiResult(options.Request.HttpContext.Response) { Exception = r.Error! }; + else + return CreateContentForValue(options, r.Value, paging, messages); + } + + // Special case when IItemsResult, as it is the Items only that is to be serialized and returned. + if (value is IItemsResult itemsResult) + { + var wpr = itemsResult.Paging is null ? null : new WebApiPagingResult(itemsResult.Paging, itemsResult.Items is null ? 0 : itemsResult.GetItemsCount()); + return CreateContentForValue(options, itemsResult.Items ?? Array.Empty(), wpr, messages); + } + + // Special case when IValueResult, as it is the underlying Value and StatusCode that is to be serialized and returned. + if (value is IValueResult valueResult) + { + if (valueResult.StatusCode.HasValue) + options.StatusCode = valueResult.StatusCode.Value; + + return CreateContentForValue(options, valueResult.Value, paging, messages); + } + + // Where there is mutable etag support and it is null (assumes auto-generation) then generate from the full value JSON contents as the baseline value. + if (value is IETag etag && etag.ETag is null) + etag.ETag = ETag.Generate(value, JsonSerializerOptions); + + // Serialize the value and perform any field selection/filtering as per the request options where specified. + string json; + if (options.IncludeFields is not null && options.IncludeFields.Count > 0) + JsonFilter.TryFilter(value, options.IncludeFields, out json, JsonFilterOption.Include, JsonSerializerOptions); + else if (options.ExcludeFields is not null && options.ExcludeFields.Count > 0) + JsonFilter.TryFilter(value, options.ExcludeFields, out json, JsonFilterOption.Exclude, JsonSerializerOptions); + else + json = JsonSerializer.Serialize(value, JsonSerializerOptions); + + // Determine final etag - where the query string is provided this may affect the ETag and should be included in the hash as such; i.e. paging, filtering, fields, etc. + var getag = options.HasQueryString + ? Entities.ETag.Generate(json, options.Request.QueryString.ToString()) + : value is IReadOnlyETag vetag && vetag.ETag is not null ? vetag.ETag : ETag.Generate(json); + + // Where the request is a GET or HEAD and the ETag matches then return a 304 Not Modified. + if (options.ETag is not null && (HttpMethods.IsGet(options.Request.Method) || HttpMethods.IsHead(options.Request.Method)) && options.ETag == getag) + return new WebApiResult(options.Request.HttpContext.Response) + { + StatusCode = HttpStatusCode.NotModified, + Headers = new WebApiHeader { ETag = getag } + }; + + // Create the location value where applicable. + var location = options is IWebApiResponseOptions lro ? lro.CreateLocationUri(value) : null; + + // Handle the HEAD request where no content is returned. + if (HttpMethods.IsHead(options.Request.Method)) + return new WebApiResult(options.Request.HttpContext.Response) + { + StatusCode = options.StatusCode, + Headers = new WebApiHeader { ETag = getag, Location = location } + }; + + // All others should return content. + return new WebApiResult(options.Request.HttpContext.Response) + { + Content = json, + ContentType = MediaTypeNames.Application.Json, + StatusCode = options.StatusCode, + Headers = new WebApiHeader + { + ETag = getag, + Location = location, + PagingResult = paging + } + }; + } + + /// + /// Creates a for the specified . + /// + /// The . + /// The content . + internal abstract TResult CreateResult(WebApiResult result); + + /// + /// Gets the value from the . + /// + /// The request JSON content value . + /// The . + /// The . + /// The corresponding . + protected async Task> GetRequestValueAsync(HttpRequest request, CancellationToken cancellationToken) + { + if (request.ContentLength == 0) + return new ValidationException(_requestBodyRequiredText).WithErrorType(_requestBodyErrorType); + + try + { + return await request.ReadFromJsonAsync(JsonSerializerOptions, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + return new ValidationException(_requestBodyInvalidText.WithArgs(ex.Message)).WithErrorType(_requestBodyErrorType); + } + } + + /// + /// Creates the extensions for the . + /// + /// The . + /// The . + protected IDictionary CreateProblemDetailsExtensions(IExtendedException? exception) + { + var ext = new Dictionary(); + + if (exception is not null) + { + if (exception.ErrorType is not null) + ext.Add(HttpNames.ErrorTypeName, exception.ErrorType); + + if (exception.ErrorCode is not null) + ext.Add(HttpNames.ErrorCodeName, exception.ErrorCode); + } + + if (Activity.Current is not null) + ext.Add(HttpNames.TraceIdName, Activity.Current.Id); + + return ext; + } + + /// + /// Provides the ability to convert an alternate to an (where required). + /// + /// The . + /// The resulting where applicable; otherwise, indicating no conversion required. + /// Attempts to convert all known error (>= 400) values to their equivalents; otherwise, defaults to an . + protected virtual Exception? OnConvertAlternateStatusCodeToException(HttpStatusCode statusCode) + { + if ((int)statusCode < 400) + return null; + + return (int)statusCode switch + { + (int)HttpStatusCode.Unauthorized => new AuthenticationException(), + (int)HttpStatusCode.Forbidden => new AuthorizationException(), + (int)HttpStatusCode.BadRequest => new ValidationException(), + (int)HttpStatusCode.PreconditionFailed => new ConcurrencyException(), + (int)HttpStatusCode.Conflict => new ConflictException(), + (int)HttpStatusCode.NotFound => new NotFoundException(), + (int)HttpStatusCode.ServiceUnavailable => new TransientException(), + _ => new UnexpectedInternalException { StatusCode = statusCode }, + }; + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApiBase.cs b/src/CoreEx.AspNetCore/Abstractions/WebApiBase.cs new file mode 100644 index 00000000..98b65294 --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApiBase.cs @@ -0,0 +1,74 @@ +namespace CoreEx.AspNetCore.Abstractions; + +/// +/// Provides the base ASP.NET Core Web API capabilities to enable both MVC and HTTP support in a consistent manner. +/// +/// The optional . +/// The optional for the . +/// The optional . +public abstract class WebApiBase(JsonSerializerOptions? jsonSerializerOptions = null, ILogger? logger = null, ExecutionContext? executionContext = null) +{ + private JsonMergePatch? _jsonMergePatch; + + /// + /// The configuration name to indicate whether to include exception details in the . + /// + protected const string IncludeExceptionInProblemDetailsName = "CoreEx:IncludeExceptionInProblemDetails"; + + /// + /// Gets the . + /// + public ILogger? Logger { get; } = logger; + + /// + /// Gets the . + /// + public ExecutionContext? ExecutionContext { get; } = executionContext; + + /// + /// Gets the . + /// + /// Defaults to . + public JsonSerializerOptions JsonSerializerOptions { get; set; } = jsonSerializerOptions ?? JsonDefaults.SerializerOptions; + + /// + /// Indicates whether to convert a to the default on . + /// + public bool ConvertNotfoundToDefaultStatusCodeOnDelete { get; } = true; + + /// + /// Indicates whether to convert unhandled exceptions to ; otherwise, allow to bubble up for middleware to handle. + /// + /// Defaults to to allow the middleware to handle unhandled exceptions in a consistent/standardized manner. + public bool ConvertUnhandledExceptionsToProblemDetails { get; set; } = false; + + /// + /// Gets or sets the optional . + /// + public JsonMergePatch JsonMergePatch { get => _jsonMergePatch ??= new JsonMergePatch(new JsonMergePatchOptions(JsonSerializerOptions)); set => _jsonMergePatch = value; } + + /// + /// Indicates whether to use absolute paths versus relative in the likes of response headers, etc. + /// + /// Defaults to indicates the use of relative paths. + public bool UseAbsolutePaths { get; set; } = false; + + /// + /// Check the to ensure valid to continue. + /// + /// The . + /// The expected names. + /// The calling member name to include in any resulting . + protected static void CheckRequest([NotNull] HttpRequest request, string[] expectedmethods, [CallerMemberName] string? memberName = null) + { + request.ThrowIfNull(); + + foreach (var em in expectedmethods) + { + if (HttpMethods.Equals(request.Method, em)) + return; + } + + throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{string.Join(", ", expectedmethods)}' to use {memberName ?? "??"}.", nameof(request)); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApiHeader.cs b/src/CoreEx.AspNetCore/Abstractions/WebApiHeader.cs new file mode 100644 index 00000000..f2529703 --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApiHeader.cs @@ -0,0 +1,141 @@ +namespace CoreEx.AspNetCore.Abstractions; + +/// +/// Provides the standard headers specification and corresponding . +/// +internal readonly struct WebApiHeader +{ + /// + /// Gets or sets the for additional ad-hoc headers. + /// + public IHeaderDictionary? Headers { get; init; } + + /// + /// Gets or sets the . + /// + public Uri? Location { get; init; } + + /// + /// Gets or sets the for the . + /// + public TimeSpan? RetryAfter { get; init; } + + /// + /// Gets or sets the value. + /// + public string? ETag { get; init; } + + /// + /// Gets or sets the . + /// + public WebApiPagingResult? PagingResult { get; init; } + + /// + /// Applies the to the . + /// + /// The . + /// The . + public void ApplyTo(WebApiBase webApi, HttpResponse httpResponse) + { + var headers = httpResponse.GetTypedHeaders(); + if (ETag is not null) + headers.ETag = new EntityTagHeaderValue(Entities.ETag.FormatETag(ETag), true); + + if (Location is not null) + headers.Location = Location; + + if (RetryAfter.HasValue) + httpResponse.Headers.Append(HeaderNames.RetryAfter, new System.Net.Http.Headers.RetryConditionHeaderValue(RetryAfter.Value).ToString()); + + if (Headers is not null && Headers.Count > 0) + { + foreach (var header in Headers) + { + httpResponse.Headers.Append(header.Key, header.Value); + } + } + + if (PagingResult is not null) + { + httpResponse.Headers.Append(HttpNames.PagingSkipHeaderName, PagingResult.Skip.ToString()); + httpResponse.Headers.Append(HttpNames.PagingTakeHeaderName, PagingResult.Take.ToString()); + if (PagingResult.TotalCount.HasValue) + httpResponse.Headers.Append(HttpNames.PagingTotalCountHeaderName, PagingResult.TotalCount.Value.ToString()); + + ApplyPagingPrevAndNextLinks(webApi, httpResponse); + } + } + + /// + /// Applies the previous and next links to the . + /// + private void ApplyPagingPrevAndNextLinks(WebApiBase webApi, HttpResponse httpResponse) + { + // Get the orginating URI without query string. + var url = webApi.UseAbsolutePaths + ? UriHelper.BuildAbsolute(httpResponse.HttpContext.Request.Scheme, httpResponse.HttpContext.Request.Host, httpResponse.HttpContext.Request.PathBase, httpResponse.HttpContext.Request.Path) + : UriHelper.BuildRelative(httpResponse.HttpContext.Request.PathBase, httpResponse.HttpContext.Request.Path); + + QueryString qs; + PagingArgs? pa = PagingResult?.GetPreviousPage(); + if (pa is not null) + { + qs = RebuildPagingLinkQueryString(httpResponse, pa); + httpResponse.Headers.Append("Link", $"<{url}{qs}>; rel=\"prev\""); + } + + pa = PagingResult?.GetNextPage(); + if (pa is not null) + { + qs = RebuildPagingLinkQueryString(httpResponse, pa); + httpResponse.Headers.Append("Link", $"<{url}{qs}>; rel=\"next\""); + } + } + + /// + /// Rebuild the originating request query string with the updated paging args. + /// + private static QueryString RebuildPagingLinkQueryString(HttpResponse httpResponse, PagingArgs paging) + { + var ps = false; + var pt = false; + var qs = new QueryString(); + + foreach (var item in httpResponse.HttpContext.Request.Query) + { + if (item.Key.Equals(HttpNames.PagingSkipQueryStringName, StringComparison.OrdinalIgnoreCase)) + { + ps = true; + qs = qs.Add(HttpNames.PagingSkipQueryStringName, paging.Skip.ToString("D")); + } + else if (item.Key.Equals(HttpNames.PagingTakeQueryStringName, StringComparison.OrdinalIgnoreCase)) + { + pt = true; + qs = qs.Add(HttpNames.PagingTakeQueryStringName, paging.Take.ToString("D")); + } + else + { + if (item.Value.Count == 0) + qs = qs.Add(new QueryString($"?{item.Key}")); + else + { + foreach (var value in item.Value) + { + if (string.IsNullOrEmpty(value)) + qs = qs.Add(new QueryString($"?{item.Key}")); + else + qs = qs.Add(item.Key, value); + } + } + } + } + + if (!ps) + qs = qs.Add(HttpNames.PagingSkipQueryStringName, paging.Skip.ToString("D")); + + if (!pt) + qs = qs.Add(HttpNames.PagingTakeQueryStringName, paging.Take.ToString("D")); + + return qs; + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApiInvoker.cs b/src/CoreEx.AspNetCore/Abstractions/WebApiInvoker.cs new file mode 100644 index 00000000..5ea08470 --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApiInvoker.cs @@ -0,0 +1,7 @@ +namespace CoreEx.AspNetCore.Abstractions; + +/// +/// Provides the base invoker. +/// +/// The ASP.NET Core result . +public abstract class WebApiInvoker : InvokerBase> { } \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApiOptionsBase.cs b/src/CoreEx.AspNetCore/Abstractions/WebApiOptionsBase.cs new file mode 100644 index 00000000..39364f3e --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApiOptionsBase.cs @@ -0,0 +1,265 @@ +namespace CoreEx.AspNetCore.Abstractions; + +/// +/// Represents the base options. +/// +public class WebApiOptionsBase +{ + private QueryArgs? _queryArgs; + private PagingArgs? _pagingArgs; + private bool _attemptedIncludeFields; + private bool _attemptedExcludeFields; + private List? _includeFields; + private List? _excludeFields; + private bool? _isIncludeRelatedText; + private bool? _isIncludeInactive; + + /// + /// Initializes a new instance of the class. + /// + /// The . + internal WebApiOptionsBase(HttpRequest httpRequest) + { + Request = httpRequest.ThrowIfNull(); + HasQueryString = Request.Query is not null && Request.Query.Count > 0; + + // Get the ETag from the request headers and parse accordingly. + StringSegment? etag; + var rth = httpRequest.GetTypedHeaders(); + + if (HttpMethods.IsGet(httpRequest.Method) || HttpMethods.IsHead(httpRequest.Method)) + etag = rth.IfNoneMatch.FirstOrDefault()?.Tag; + else if (HttpMethods.IsPut(httpRequest.Method) || HttpMethods.IsPatch(httpRequest.Method)) + etag = rth.IfMatch.FirstOrDefault()?.Tag; + else + etag = null; + + if (etag is not null && etag.HasValue) + ETag = Entities.ETag.ParseETag(etag.Value); + + // Also, check whether the include text query string was specified. + if (IsIncludeText && ExecutionContext.TryGetCurrent(out var ec)) + ec.IncludeRelatedText = true; + } + + /// + /// Initializes a new instance of the class from an existing instance. + /// + /// The . + public WebApiOptionsBase(WebApiOptionsBase options) + { + Request = options.Request; + HasQueryString = options.HasQueryString; + OperationType = options.OperationType; + ETag = options.ETag; + StatusCode = options.StatusCode; + LocationUri = options.LocationUri; + + _queryArgs = options._queryArgs; + _pagingArgs = options._pagingArgs; + _attemptedIncludeFields = options._attemptedIncludeFields; + _attemptedExcludeFields = options._attemptedExcludeFields; + _includeFields = options._includeFields; + _excludeFields = options._excludeFields; + _isIncludeRelatedText = options._isIncludeRelatedText; + _isIncludeInactive = options._isIncludeInactive; + } + + /// + /// Gets the originating . + /// + public HttpRequest Request { get; } + + /// + /// Indicates whether the has a query string. + /// + public bool HasQueryString { get; } + + /// + /// Gets the . + /// + /// This is used to set (override) the per request. + public OperationType OperationType + { + get => field; + + internal set + { + field = value; + if (ExecutionContext.TryGetCurrent(out var ec)) + ec.OperationType = value; + } + } = OperationType.Unspecified; + + /// + /// Gets the entity tag that was passed; a) If-None-Match header where , b) If-Match header where , or c) otherwise, . + /// + /// Represents the underlying raw value; i.e. is stripped of any W/"xxxx" formatting. + public string? ETag { get; } + + /// + /// Gets the . + /// + public HttpStatusCode StatusCode { get; internal set; } + + /// + /// Gets the alternate . + /// + public HttpStatusCode? AlternateStatusCode { get; internal set; } + + /// + /// Gets the function that will return the representing the location of the resource. + /// + public Func? LocationUri { get; internal set; } + + /// + /// Gets the list of included fields. + /// + /// The and are mutually exclusive. + public List? IncludeFields + { + get + { + if (_includeFields is null && !_attemptedIncludeFields) + { + _includeFields = GetNamedQueryStrings(Request.Query, HttpNames.IncludeFieldsQueryStringName); + _attemptedIncludeFields = true; + } + + return _includeFields; + } + } + + /// + /// Gets the list of excluded fields. + /// + /// The and are mutually exclusive. + public List? ExcludeFields + { + get + { + if (_excludeFields is null && !_attemptedExcludeFields) + { + _excludeFields = GetNamedQueryStrings(Request.Query, HttpNames.ExcludeFieldsQueryStringName); + _attemptedExcludeFields = true; + } + + return _excludeFields; + } + } + + /// + /// Indicates whether is specified within the current request to include text(s) where available. + /// + public bool IsIncludeText => _isIncludeRelatedText ??= (HasQueryString && ParseBoolValue(GetNamedQueryString(Request.Query, HttpNames.IncludeTextQueryStringName, "true"))); + + /// + /// Indicates whether is specified within the current request to include inactive items for the resulting item(s). + /// + public bool IsIncludeInactive => _isIncludeInactive ??= (HasQueryString && ParseBoolValue(GetNamedQueryString(Request.Query, HttpNames.IncludeInactiveQueryStringName, "true"))); + + /// + /// Gets the . + /// + public QueryArgs QueryArgs => _queryArgs ??= GetQueryArgs(); + + /// + /// Gets the . + /// + public PagingArgs PagingArgs => _pagingArgs ??= GetPagingArgs(); + + /// + /// Gets the from the . + /// + private QueryArgs GetQueryArgs() + { + if (!HasQueryString) + return new QueryArgs(); + + return new QueryArgs() + { + Filter = GetNamedQueryString(Request.Query, HttpNames.QueryFilterQueryStringName), + OrderBy = GetNamedQueryString(Request.Query, HttpNames.QueryOrderByQueryStringName), + IncludeFields = IncludeFields, + ExcludeFields = ExcludeFields, + IsIncludeText = IsIncludeText, + IsIncludeInactive = IsIncludeInactive + }; + } + + /// + /// Gets the from the . + /// + private PagingArgs GetPagingArgs() + { + if (!HasQueryString) + return PagingArgs.Create(); + + string? skip = GetNamedQueryString(Request.Query, HttpNames.PagingSkipQueryStringName); + string? take = GetNamedQueryString(Request.Query, HttpNames.PagingTakeQueryStringName); + string? count = GetNamedQueryString(Request.Query, HttpNames.PagingCountQueryStringName, "true"); + + if (skip is null && take is null && count is null) + return PagingArgs.Create(); + + return new PagingArgs(ParseInt32Value(skip) ?? 0, ParseInt32Value(take) ?? PagingArgs.DefaultTake, ParseBoolValue(count)); + } + + /// + /// Parses the value as a . + /// + private static int? ParseInt32Value(string? value) => int.TryParse(value, out int val) ? (val < 0 ? null : val) : null; + + /// + /// Parses the value as a . + /// + private static bool ParseBoolValue(string? value) => bool.TryParse(value, out bool val) && val; + + /// + /// Gets the first value for the named query string. + /// + private static string? GetNamedQueryString(IQueryCollection query, string name, string? emptyValue = null) + { + var q = query.FirstOrDefault(x => string.Compare(x.Key, name, StringComparison.OrdinalIgnoreCase) == 0); + var val = q.Value.FirstOrDefault(); + return val is null ? null : (string.IsNullOrEmpty(val) ? emptyValue : val); + } + + /// + /// Gets all the named query strings and splits them by comma. + /// + private static List? GetNamedQueryStrings(IQueryCollection query, string name) + { + List? fields = null; + foreach (var q in query.Where(x => string.Compare(x.Key, name, StringComparison.OrdinalIgnoreCase) == 0)) + { + foreach (var v in q.Value) + { + if (v is not null) + (fields ??= []).AddRange(v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + } + + return fields is null || fields.Count == 0 ? null : [.. fields.Distinct()]; + } + + /// + /// Verifies the request from a perspective; i.e. whether the request is valid for further processing. + /// + /// The . + /// This is intended to verify the semantic request; i.e. the request content and structure, rather than syntactic correctness. For example, whether a + /// requires an (ETag). + protected internal virtual Result Verify() => Result.Success; + + /// + /// Tries to verify the request from a perspective; i.e. whether the request is valid for further processing. + /// + /// + /// + internal bool IsInError(out Result result) + { + var r = Verify(); + result = r; + return r.IsFailure; + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApiPagingResult.cs b/src/CoreEx.AspNetCore/Abstractions/WebApiPagingResult.cs new file mode 100644 index 00000000..fcd4fe32 --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApiPagingResult.cs @@ -0,0 +1,57 @@ +namespace CoreEx.AspNetCore.Abstractions; + +/// +/// Provides a with the actual . +/// +internal sealed record class WebApiPagingResult : PagingResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The actual count of the paged elements. + public WebApiPagingResult(PagingResult paging, int pagedCount) : base(paging) + { + WithTotalCount(paging.TotalCount); + PagedCount = pagedCount; + } + + /// + /// Gets the actual count of the paged elements. + /// + public int PagedCount { get; } + + /// + /// Gets the previous . + /// + /// The previous where applicable; otherwise, . + public PagingArgs? GetPreviousPage() + { + if (Skip == 0) + return null; + + int skip = Skip - Take; + if (TotalCount is not null && skip >= TotalCount) + skip = (int)TotalCount - Take; + + if (skip < 0) + skip = 0; + + return new PagingArgs(skip, Take > Skip ? Skip : Take); + } + + /// + /// Gets the next . + /// + /// The next where applicable; otherwise, . + public PagingArgs? GetNextPage() + { + if (PagedCount < Take) + return null; + + if (TotalCount is not null && (Skip + Take) >= TotalCount) + return null; + + return new PagingArgs(Skip + Take, Take); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApiResult.cs b/src/CoreEx.AspNetCore/Abstractions/WebApiResult.cs new file mode 100644 index 00000000..e6c9e759 --- /dev/null +++ b/src/CoreEx.AspNetCore/Abstractions/WebApiResult.cs @@ -0,0 +1,50 @@ +namespace CoreEx.AspNetCore.Abstractions; + +/// +/// Represents a result from a Web API operation. +/// +/// The ASP.NET Core result . +/// The . +internal readonly record struct WebApiResult(HttpResponse httpResponse) +{ + /// + /// Gets the . + /// + public HttpResponse HttpResponse { get; } = httpResponse.ThrowIfNull(); + + /// + /// Gets the content. + /// + public string? Content { get; init; } + + /// + /// Gets the content type. + /// + public string? ContentType { get; init; } + + /// + /// Gets the . + /// + public HttpStatusCode StatusCode { get; init; } + + /// + /// Gets the . + /// + public Exception? Exception { get; init; } + + /// + /// Indicates whether to bypass exception logging for the . + /// + /// This is typically used when the exception is already handled and logged, and you want to prevent duplicate logging. + public bool BypassExceptionLogging { get; init; } + + /// + /// Gets the result where already pre-formed. + /// + public TResult? Result { get; init; } + + /// + /// Gets the . + /// + public WebApiHeader? Headers { get; init; } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/AspNetCoreExtensions.cs b/src/CoreEx.AspNetCore/AspNetCoreExtensions.cs new file mode 100644 index 00000000..9ce659e3 --- /dev/null +++ b/src/CoreEx.AspNetCore/AspNetCoreExtensions.cs @@ -0,0 +1,59 @@ +namespace CoreEx.AspNetCore; + +/// +/// Provides standard extensions. +/// +public static partial class AspNetCoreExtensions +{ + /// + /// Overrides the . + /// + /// The . + /// The . + /// The . + /// The to support fluent-style method-chaining. + public static TRequestOptions WithStatusCode(this TRequestOptions requestOptions, HttpStatusCode statusCode) where TRequestOptions : WebApiOptionsBase + { + requestOptions.ThrowIfNull().StatusCode = statusCode; + return requestOptions; + } + + /// + /// Overrides the . + /// + /// The . + /// The . + /// The . + /// The to support fluent-style method-chaining. + public static TRequestOptions WithAlternateStatusCode(this TRequestOptions requestOptions, HttpStatusCode alternateStatusCode) where TRequestOptions : WebApiOptionsBase + { + requestOptions.ThrowIfNull().AlternateStatusCode = alternateStatusCode; + return requestOptions; + } + + /// + /// Overrides the . + /// + /// The . + /// The . + /// The . + /// The to support fluent-style method-chaining. + public static TRequestOptions WithOperationType(this TRequestOptions requestOptions, OperationType operationType) where TRequestOptions : WebApiOptionsBase + { + requestOptions.ThrowIfNull().OperationType = operationType; + return requestOptions; + } + + /// + /// Overrides the function. + /// + /// The . + /// The . + /// The function to return the location . + /// + public static TRequestOptions WithLocationUri(this TRequestOptions requestOptions, Func locationUri) where TRequestOptions : WebApiOptionsBase + { + requestOptions.ThrowIfNull().LocationUri = locationUri; + return requestOptions; + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj b/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj index 1d8db560..69cc35dc 100644 --- a/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj +++ b/src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj @@ -1,25 +1,11 @@  - - - net6.0;net8.0;net9.0 - CoreEx.AspNetCore - CoreEx - CoreEx ASP.NET Core backend Extensions. - CoreEx ASP.NET backend Extensions. - coreex api aspnet entity microservices - false - - - - - - + + - diff --git a/src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.ApplicationBuilder.cs b/src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.ApplicationBuilder.cs new file mode 100644 index 00000000..4afd81a4 --- /dev/null +++ b/src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.ApplicationBuilder.cs @@ -0,0 +1,148 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Microsoft.AspNetCore.Builder; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static partial class CoreExAspNetCoreExtensions +{ + /// + /// Adds a middleware action to the per request. + /// + /// The . + /// An optional function to update the . + /// The for fluent-style method-chaining. + public static IApplicationBuilder UseExecutionContext(this IApplicationBuilder builder, Func? configure = null) + => builder.ThrowIfNull().UseMiddleware(configure ?? ExecutionContextMiddleware.DefaultConfigure); + + /// + /// Registers the standard ASP.NET Core exception handling middleware leveraging the CoreEx to convert the into a . + /// + /// The . + /// The for fluent-style method-chaining. + /// This is primarily required to support specific handling. + /// Is effectively a convenience method for , i.e.: + /// builder.UseExceptionHandler(CoreEx.AspNetCore.ExceptionHandlingMiddleware.ConvertExceptionToProblemDetails); + public static IApplicationBuilder UseCoreExExceptionHandler(this IApplicationBuilder builder) + => builder.ThrowIfNull().UseExceptionHandler(ExceptionHandlingMiddleware.ConvertExceptionToProblemDetails); + + /// + /// Adds the standard CoreEx OpenTelemetry instrumentation using the application name as the service name. + /// + /// The . + /// The optional version of the service; defaults to 1.0.0. + /// The resulting to support fluent-style method-chaining. + /// Includes and . + public static OpenTelemetryBuilder WithCoreExTelemetry(this IHostApplicationBuilder builder, string? serviceVersion = "1.0.0") + { + var telemetry = builder.ThrowIfNull().Services.AddOpenTelemetry().WithCoreExTelemetry().WithAspNetCoreTelemetry(); + telemetry.ConfigureResource(x => x.AddService(builder.Environment.ApplicationName, serviceVersion: serviceVersion)); + return telemetry; + } + + /// + /// Adds the to handle operations that required idempotency (see ). + /// + /// The . + /// The for fluent-style method-chaining. + public static IApplicationBuilder UseIdempotencyKey(this IApplicationBuilder builder) + => builder.ThrowIfNull().UseMiddleware(); + + /// + /// Maps health check endpoints enabling live, startup, and ready health checks with optional detailed responses as oer the specified . + /// + /// The . + /// The optional . + /// The to support fluent-style method-chaining. + /// The default where not specified. Additionally, the are overridden using configuration settings where applicable. + public static IEndpointRouteBuilder MapHealthChecks(this IEndpointRouteBuilder endpoints, HealthCheckOptions? options = null) + { + endpoints.ThrowIfNull(); + + // Bind options with configuration; also, default options where applicable. + options ??= new HealthCheckOptions(); + endpoints.ServiceProvider.GetService()?.GetSection("CoreEx.AspNetCore.HealthChecks")?.Bind(options); + + // Map health checks for the specified path and tags. + void MapHealthChecks(string path, string[] tags) + { + endpoints.MapHealthChecks(path, new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions + { + Predicate = check => CheckRegistration(check, tags), + }); + + if (options.AreDetailedEndpointsEnabled) + { + endpoints.MapHealthChecks($"{path.TrimEnd('/')}/{options.DetailedPathSuffix.TrimStart('/')}", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions + { + Predicate = check => CheckRegistration(check, tags), + ResponseWriter = options.OnWriteDetailedHealthCheckAsync + }); + } + } + + // Check and update the registration. + bool CheckRegistration(HealthCheckRegistration rego, string[] tags) + { + if (rego.Tags is not null && rego.Tags.Any(tag => tags.Contains(tag))) + { + options.OnConfigureHealthCheckRegistration(rego); + return true; + } + + return false; + } + + // Map the configured health check endpoints. + if (options.IsLiveEndpointEnabled) + MapHealthChecks(options.LivePath, options.LiveTags); + + if (options.IsStartupEndpointEnabled) + MapHealthChecks(options.StartupPath, options.StartupTags); + + if (options.IsReadyEndpointEnabled) + MapHealthChecks(options.ReadyPath, options.ReadyTags); + + return endpoints; + } + + /// + /// Maps endpoints for managing services, including status retrieval, pause, and resume operations. + /// + /// The . + /// The base route name for the hosted services endpoints. + /// An optional action to configure the . + /// The to support fluent-style method-chaining. + /// The mapped endpoints are excluded from OpenAPI documentation by default, as they are typically intended for administrative use only. The allows additional + /// configuration, such as adding authorization policies, etc. which is highly recommended. + public static IEndpointRouteBuilder MapHostedServices(this IEndpointRouteBuilder endpoints, string routeName = "/hosted-services", Action? groupConfigure = null) + { + // Map the endpoint group and hide from OpenAPI description - these are generally for admin use only! + var group = endpoints.ThrowIfNull().MapGroup(routeName.ThrowIfNullOrEmpty()).ExcludeFromDescription(); + + // Provides the all "hosted services" status management. + group.MapGet("/all/status", (HttpRequest request, CoreEx.AspNetCore.Http.WebApi webApi, [Microsoft.AspNetCore.Mvc.FromServices] HostedServiceManager manager, CancellationToken cancellationToken) + => webApi.GetWithResultAsync(request, (_, CancellationToken) => manager.ThrowIfNull().GetAllStatusesAsync(cancellationToken), cancellationToken: cancellationToken)); + + group.MapPost("/all/pause", (HttpRequest request, CoreEx.AspNetCore.Http.WebApi webApi, [Microsoft.AspNetCore.Mvc.FromServices] HostedServiceManager manager, CancellationToken cancellationToken) + => webApi.PostWithResultAsync(request, (_, CancellationToken) => manager.ThrowIfNull().PauseAllAsync(cancellationToken), HttpStatusCode.Accepted, cancellationToken: cancellationToken)); + + group.MapPost("/all/resume", (HttpRequest request, CoreEx.AspNetCore.Http.WebApi webApi, [Microsoft.AspNetCore.Mvc.FromServices] HostedServiceManager manager, CancellationToken cancellationToken) + => webApi.PostWithResultAsync(request, (_, CancellationToken) => manager.ThrowIfNull().ResumeAllAsync(cancellationToken), HttpStatusCode.Accepted, cancellationToken: cancellationToken)); + + // Provides the per "hosted service" status management. + group.MapGet("/{serviceKey}/status", (HttpRequest request, CoreEx.AspNetCore.Http.WebApi webApi, [Microsoft.AspNetCore.Mvc.FromServices] HostedServiceManager manager, string serviceKey, CancellationToken cancellationToken) + => webApi.GetWithResultAsync(request, (_, CancellationToken) => manager.ThrowIfNull().GetStatusAsync(serviceKey, cancellationToken), cancellationToken: cancellationToken)); + + group.MapPost("/{serviceKey}/pause", (HttpRequest request, CoreEx.AspNetCore.Http.WebApi webApi, [Microsoft.AspNetCore.Mvc.FromServices] HostedServiceManager manager, string serviceKey, CancellationToken cancellationToken) + => webApi.PostWithResultAsync(request, (_, CancellationToken) => manager.ThrowIfNull().PauseAsync(serviceKey, cancellationToken), HttpStatusCode.Accepted, cancellationToken: cancellationToken)); + + group.MapPost("/{serviceKey}/resume", (HttpRequest request, CoreEx.AspNetCore.Http.WebApi webApi, [Microsoft.AspNetCore.Mvc.FromServices] HostedServiceManager manager, string serviceKey, CancellationToken cancellationToken) + => webApi.PostWithResultAsync(request, (_, CancellationToken) => manager.ThrowIfNull().ResumeAsync(serviceKey, cancellationToken), HttpStatusCode.Accepted, cancellationToken: cancellationToken)); + + // Enable further group configuration by the consuming developer, such as adding authorization policies, etc. + groupConfigure?.Invoke(group); + return endpoints; + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.DependencyInjection.cs b/src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.DependencyInjection.cs new file mode 100644 index 00000000..80335d37 --- /dev/null +++ b/src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.DependencyInjection.cs @@ -0,0 +1,64 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static partial class CoreExAspNetCoreExtensions +{ + /// + /// Adds a scoped service to instantiate a new instance. + /// + /// The . + /// The action to configure the instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddMvcWebApi(this IServiceCollection services, Action? configure = null) => services.ThrowIfNull().AddScoped(sp => + { + var webApi = new CoreEx.AspNetCore.Mvc.WebApi(sp.GetService(), sp.GetService>(), sp.GetService()); + configure?.Invoke(sp, webApi); + return webApi; + }); + + /// + /// Adds a scoped service to instantiate a new instance. + /// + /// The . + /// The action to configure the instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddHttpWebApi(this IServiceCollection services, Action? configure = null) => services.ThrowIfNull().AddScoped(sp => + { + var webApi = new CoreEx.AspNetCore.Http.WebApi(sp.GetService(), sp.GetService>(), sp.GetService()); + configure?.Invoke(sp, webApi); + return webApi; + }); + + + /// + /// Adds a scoped service as the . + /// + /// The . + /// The action to configure the instance. + /// The for fluent-style method-chaining. + /// Will also try to add the scoped . + public static IServiceCollection AddHybridCacheIdempotencyProvider(this IServiceCollection services, Action? configure = null) + { + services.ThrowIfNull().AddScoped(sp => + { + var provider = new HybridCacheIdempotencyProvider(sp.GetRequiredService()); + configure?.Invoke(sp, provider); + return provider; + }); + + services.TryAddScoped(); + return services; + } + + /// + /// Adds a scoped a scoped service. + /// + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddIdempotencyKeyMiddleware(this IServiceCollection services) + => services.ThrowIfNull().AddScoped(); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.OpenTelemetry.cs b/src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.OpenTelemetry.cs new file mode 100644 index 00000000..31dadc5d --- /dev/null +++ b/src/CoreEx.AspNetCore/CoreExAspNetCoreExtensions.OpenTelemetry.cs @@ -0,0 +1,30 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace OpenTelemetry.Trace; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static class CoreExAspNetCoreExtensions +{ + /// + /// Enables CoreEx OpenTelemetry instrumentation for ASP.NET Core. and . + /// + /// The . + /// The to support fluent-style method-chaining. + /// Also, includes . + public static OpenTelemetryBuilder WithAspNetCoreTelemetry(this OpenTelemetryBuilder builder) + => builder.ThrowIfNull().ThrowIfNull() + .WithTracing(t => t.AddAspNetCoreInstrumentation().WithCoreExAspNetCoreSources()) + .WithMetrics(m => m.AddAspNetCoreInstrumentation()); + + /// + /// Enables (adds) the CoreEx-specified OpenTelemetry tracing sources. + /// + /// The . + /// The to support fluent-style method-chaining. + public static TracerProviderBuilder WithCoreExAspNetCoreSources(this TracerProviderBuilder builder) => builder.ThrowIfNull() + .AddInvokerAsSource() + .AddInvokerAsSource() + .AddInvokerAsSource(); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/ExceptionHandlingMiddleware.cs b/src/CoreEx.AspNetCore/ExceptionHandlingMiddleware.cs new file mode 100644 index 00000000..54563440 --- /dev/null +++ b/src/CoreEx.AspNetCore/ExceptionHandlingMiddleware.cs @@ -0,0 +1,27 @@ +namespace CoreEx.AspNetCore; + +/// +/// Provides unhandled handling to be used by standard middleware to convert into a . +/// +public static class ExceptionHandlingMiddleware +{ + /// + /// Configures the application to use CoreEx exception handling. + /// + /// The . + public static void ConvertExceptionToProblemDetails(this IApplicationBuilder app) => app.Run(async context => + { + // Where the response has already started then do nothing. + if (context.Response.HasStarted) + return; + + // Get the exception and process using standardized handling; note, already logged by ASPNET, so bypass ours to ensure double logging does not occur. + var ex = context.Features.Get()?.Error; + if (ex is not null) + { + var webApi = context.RequestServices.GetRequiredService(); + var ir = webApi.CreateResult(new WebApiResult(context.Response) { Exception = ex, BypassExceptionLogging = true }); + await ir.ExecuteAsync(context).ConfigureAwait(false); + } + }); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/ExecutionContextMiddleware.cs b/src/CoreEx.AspNetCore/ExecutionContextMiddleware.cs new file mode 100644 index 00000000..13a737a1 --- /dev/null +++ b/src/CoreEx.AspNetCore/ExecutionContextMiddleware.cs @@ -0,0 +1,66 @@ +namespace CoreEx.AspNetCore; + +/// +/// Provides an handling middleware that (using dependency injection) enables additional configuration where required. +/// +/// A new instantiated through dependency injection is updated to use the . +/// The next . +/// The optional function to update the . +public class ExecutionContextMiddleware(RequestDelegate next, Func? configure) +{ + private readonly RequestDelegate _next = next.ThrowIfNull(); + private readonly Func _configure = configure ?? DefaultConfigure; + + /// + /// Provides a default configuration for the . + /// + /// The . + /// The . + public static Task DefaultConfigure(HttpContext httpContext, ExecutionContext executionContext) => Task.CompletedTask; + + /// + /// Invokes the . + /// + /// The . + /// The . + public async Task InvokeAsync(HttpContext context) + { + var ec = context.ThrowIfNull().RequestServices.GetRequiredService(); + ec.ServiceProvider ??= context.RequestServices; + + await _configure(context, ec).ConfigureAwait(false); + await _next(context).ConfigureAwait(false); + + AddMessagesHeader(context, ec); + } + + /// + /// Adds the and to the where there are . + /// + /// The . + /// The . + /// Only adds where the is less than 400 and the and does not currently exist. + public static void AddMessagesHeader(HttpContext context, ExecutionContext executionContext) + { + context.ThrowIfNull(); + executionContext.ThrowIfNull(); + + // Where there are info and/or warning messages then add to the response. + if (executionContext.HasMessages && context.Response.StatusCode < 400) + { + if (!context.Response.Headers.ContainsKey(HttpNames.WarningMessagesHeaderName)) + { + var msgs = executionContext.Messages!.Where(x => x.Type == MessageType.Warning).Select(x => x.Text?.ToString()).ToArray(); + if (msgs.Length > 0) + context.Response.Headers.Append(HttpNames.WarningMessagesHeaderName, new Microsoft.Extensions.Primitives.StringValues(msgs)); + } + + if (!context.Response.Headers.ContainsKey(HttpNames.InfoMessagesHeaderName)) + { + var msgs = executionContext.Messages!.Where(x => x.Type == MessageType.Info).Select(x => x.Text.ToString()).ToArray(); + if (msgs.Length > 0) + context.Response.Headers.Append(HttpNames.InfoMessagesHeaderName, new Microsoft.Extensions.Primitives.StringValues(msgs)); + } + } + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/GlobalUsing.cs b/src/CoreEx.AspNetCore/GlobalUsing.cs new file mode 100644 index 00000000..a6cfb974 --- /dev/null +++ b/src/CoreEx.AspNetCore/GlobalUsing.cs @@ -0,0 +1,44 @@ +global using CoreEx; +global using CoreEx.Abstractions; +global using CoreEx.AspNetCore; +global using CoreEx.AspNetCore.Abstractions; +global using CoreEx.AspNetCore.HealthChecks; +global using CoreEx.AspNetCore.Idempotency; +global using CoreEx.Caching; +global using CoreEx.Data; +global using CoreEx.Entities; +global using CoreEx.HealthChecks; +global using CoreEx.Hosting; +global using CoreEx.Http; +global using CoreEx.Invokers; +global using CoreEx.Json; +global using CoreEx.Localization; +global using CoreEx.Results; +global using CoreEx.Validation; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Diagnostics; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Http.Extensions; +global using Microsoft.AspNetCore.Routing; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection.Extensions; +global using Microsoft.Extensions.Diagnostics.HealthChecks; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Primitives; +global using Microsoft.Net.Http.Headers; +global using OpenTelemetry; +global using OpenTelemetry.Metrics; +global using OpenTelemetry.Resources; +global using OpenTelemetry.Trace; +global using System.Buffers; +global using System.Diagnostics; +global using System.Diagnostics.CodeAnalysis; +global using System.Net; +global using System.Net.Mime; +global using System.Runtime.CompilerServices; +global using System.Security.Cryptography; +global using System.Text.Json; +global using System.Text.RegularExpressions; +global using ExecutionContext = CoreEx.ExecutionContext; \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/HealthChecks/HealthCheckOptions.cs b/src/CoreEx.AspNetCore/HealthChecks/HealthCheckOptions.cs new file mode 100644 index 00000000..54e02fd0 --- /dev/null +++ b/src/CoreEx.AspNetCore/HealthChecks/HealthCheckOptions.cs @@ -0,0 +1,110 @@ +namespace CoreEx.AspNetCore.HealthChecks; + +/// +/// Provides configuration options for health checks. +/// +/// Additionally, override the to change the default detailed . +public class HealthCheckOptions +{ + /// + /// Indicates whether the live health check endpoint is enabled. + /// + public bool IsLiveEndpointEnabled { get; set; } = true; + + /// + /// Indicates whether the startup health check endpoint is enabled. + /// + public bool IsStartupEndpointEnabled { get; set; } = true; + + /// + /// Indicates whether the ready health check endpoint is enabled. + /// + public bool IsReadyEndpointEnabled { get; set; } = true; + + /// + /// Indicates whether the detailed health check endpoints are enabled. + /// + public bool AreDetailedEndpointsEnabled { get; set; } = true; + + /// + /// Gets or sets the live health check path. + /// + public string LivePath { get; set; } = "/health/live"; + + /// + /// Gets or sets the startup health check path. + /// + public string StartupPath { get; set; } = "/health/startup"; + + /// + /// Gets or sets the ready health check path. + /// + public string ReadyPath { get; set; } = "/health/ready"; + + /// + /// Gets or sets the detailed path suffix. + /// + public string DetailedPathSuffix { get; set; } = "detailed"; + + /// + /// Gets or sets the live health check tags. + /// + public string[] LiveTags { get; set; } = [nameof(HealthCheckTags.Live)]; + + /// + /// Gets or sets the startup health check tags. + /// + public string[] StartupTags { get; set; } = [nameof(HealthCheckTags.Startup)]; + + /// + /// Gets or sets the ready health check tags. + /// + public string[] ReadyTags { get; set; } = [nameof(HealthCheckTags.Ready)]; + + /// + /// Gets or sets the used for detailed health check responses. + /// + /// This defaults to a clone of the extending the to include the specialized + /// for when a is being reported. + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// Indicates the default detailed health check JSON response should be pretty printed. + /// + /// This sets the underlying value accordingly. + public bool? PrettyPrint { get; set; } = true; + + /// + /// Gets (creates) the . + /// + /// + private JsonSerializerOptions GetJsonSerializerOptions() + { + if (JsonSerializerOptions is not null) + return JsonSerializerOptions; + + var jso = new JsonSerializerOptions(JsonDefaults.SerializerOptions); + jso.Converters.Add(new JsonExceptionConverterFactory()); + + if (PrettyPrint.HasValue) + jso.WriteIndented = PrettyPrint.Value; + + return jso; + } + + /// + /// Writes the detailed health check response from the . + /// + /// The . + /// The . + /// This will only be invoked where accessing a valid detailed endpoint where is . + /// The default implementation simply JSON serializes the as the response using the . + public virtual async Task OnWriteDetailedHealthCheckAsync(HttpContext context, HealthReport report) + => await context.Response.WriteAsJsonAsync(report, GetJsonSerializerOptions()).ConfigureAwait(false); + + /// + /// Provides an opportunity to further configure the health check . + /// + /// The . + public virtual void OnConfigureHealthCheckRegistration(HealthCheckRegistration registration) { } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/HealthChecks/HealthReportStatusWriter.cs b/src/CoreEx.AspNetCore/HealthChecks/HealthReportStatusWriter.cs deleted file mode 100644 index cc2ffcfb..00000000 --- a/src/CoreEx.AspNetCore/HealthChecks/HealthReportStatusWriter.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System; -using System.IO; -using System.Net.Mime; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.HealthChecks -{ - /// - /// Provides additional HealthCheckOptions.ResponseWriter capabilities. - /// - public static class HealthReportStatusWriter - { - /// - /// Writes the as a JSON summary. - /// - /// The . - /// The . - public static Task WriteJsonSummary(HttpContext context, HealthReport healthReport) => WriteJson(context, healthReport, false, false, null); - - /// - /// Writes the as JSON including the results. - /// - /// The . - /// The . - public static Task WriteJsonResults(HttpContext context, HealthReport healthReport) => WriteJson(context, healthReport, true, false, null); - - /// - /// Writes the as JSON including the and results. - /// - /// The . - /// The . - public static async Task WriteJsonDeploymentResults(HttpContext context, HealthReport healthReport) => await WriteJson(context, healthReport, true, true, null).ConfigureAwait(false); - - /// - /// Writes the as JSON to the . - /// - /// The . - /// The . - /// Indicates whether to include results (where applicable). - /// Indicates whether to include information (where applicable). - /// An action to enable extensions to the underlying JSON being written. - public static async Task WriteJson(HttpContext context, HealthReport healthReport, bool includeResults = true, bool includeDeployment = true, Action? extension = null) - { - using var memoryStream = new MemoryStream(); - using (var jsonWriter = new Utf8JsonWriter(memoryStream)) - { - jsonWriter.WriteStartObject(); - jsonWriter.WriteString("status", healthReport.Status.ToString()); - jsonWriter.WriteString("duration", healthReport.TotalDuration.ToString()); - - if (ExecutionContext.HasCurrent) - jsonWriter.WriteString("correlationId", ExecutionContext.Current.CorrelationId); - - jsonWriter.WriteStartObject("results"); - - foreach (var e in healthReport.Entries) - { - jsonWriter.WriteStartObject(e.Key.Replace(' ', '-')); - jsonWriter.WriteString("status", e.Value.Status.ToString()); - jsonWriter.WriteString("description", e.Value.Description); - jsonWriter.WriteString("duration", e.Value.Duration.ToString()); - - if (e.Value.Exception is not null) - { - var settings = ExecutionContext.GetService(); - if (settings is not null && settings.IncludeExceptionInResult) - jsonWriter.WriteString("exception", e.Value.Exception?.ToString()); - else - jsonWriter.WriteString("exception", e.Value.Exception?.Message); - } - - if (includeDeployment) - { - var settings = ExecutionContext.GetService(); - if (settings is not null) - { - jsonWriter.WritePropertyName("deployment"); - JsonSerializer.Serialize(jsonWriter, settings, Text.Json.JsonSerializer.DefaultOptions); - } - } - - if (includeResults && e.Value.Data.Count > 0) - { - jsonWriter.WriteStartObject("data"); - - foreach (var d in e.Value.Data) - { - jsonWriter.WritePropertyName(d.Key); - JsonSerializer.Serialize(jsonWriter, d.Value, d.Value?.GetType() ?? typeof(object), Text.Json.JsonSerializer.DefaultOptions); - } - - jsonWriter.WriteEndObject(); - } - - jsonWriter.WriteEndObject(); - } - - extension?.Invoke(healthReport, jsonWriter); - - jsonWriter.WriteEndObject(); - jsonWriter.WriteEndObject(); - } - - context.Response.ContentType = MediaTypeNames.Application.Json; - await context.Response.WriteAsync(Encoding.UTF8.GetString(memoryStream.ToArray())).ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Http/AspNetCoreHttpExtensions.cs b/src/CoreEx.AspNetCore/Http/AspNetCoreHttpExtensions.cs new file mode 100644 index 00000000..e4f806c2 --- /dev/null +++ b/src/CoreEx.AspNetCore/Http/AspNetCoreHttpExtensions.cs @@ -0,0 +1,66 @@ +using CoreEx.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; + +namespace CoreEx.AspNetCore.Http; + +/// +/// Provides standard extensions. +/// +public static partial class AspNetCoreHttpExtensions +{ + /// + /// Adds metadata to the that the action/operation supports . + /// + /// The . + /// Indicates whether the is supported. + /// The to support fluent-style method-chaining. + public static RouteHandlerBuilder WithPaging(this RouteHandlerBuilder builder, bool supportsCount = false) => builder.ThrowIfNull().WithMetadata(new PagingAttribute(supportsCount)); + + /// + /// Adds metadata to the that the action/operation supports . + /// + /// The . + /// Indicates whether is supported/enabled. + /// Indicates whether is supported/enabled. + /// The to support fluent-style method-chaining. + public static RouteHandlerBuilder WithQuery(this RouteHandlerBuilder builder, bool supportsFilter = true, bool supportsOrderBy = false) => builder.ThrowIfNull().WithMetadata(new QueryAttribute(supportsFilter, supportsOrderBy)); + + /// + /// Adds to for all endpoints produced by the defaulting the content type to . + /// + /// The request Body . + /// The . + /// The to support fluent-style method-chaining. + public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder) => builder.ThrowIfNull().Accepts(typeof(TRequest), MediaTypeNames.Application.Json); + + /// + /// Adds an with a type and to for all endpoints produced by . + /// + /// The . + /// The to support fluent-style method-chaining. + public static RouteHandlerBuilder ProducesNotFoundProblem(this RouteHandlerBuilder builder) => builder.ThrowIfNull().ProducesProblem((int)HttpStatusCode.NotFound); + + /// + /// Adds an with a to for all endpoints produced by . + /// + /// The . + /// The to support fluent-style method-chaining. + public static RouteHandlerBuilder ProducesNoContent(this RouteHandlerBuilder builder) => builder.ThrowIfNull().Produces((int)HttpStatusCode.NoContent); + + /// + /// Adds an with a type and to for all endpoints produced by . + /// + /// The response . + /// The . + /// The to support fluent-style method-chaining. + public static RouteHandlerBuilder ProducesCreated(this RouteHandlerBuilder builder) => builder.ThrowIfNull().Produces((int)HttpStatusCode.Created); + + /// + /// Adds metadata to the that the action/operation supports the . + /// + /// The . + /// Indicates whether an is required for the request. + /// The to support fluent-style method-chaining. + public static RouteHandlerBuilder WithIdempotencyKey(this RouteHandlerBuilder builder, bool isRequired = false) => builder.ThrowIfNull().WithMetadata(new IdempotencyKeyAttribute(isRequired)); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Http/HttpRequestJsonValue.cs b/src/CoreEx.AspNetCore/Http/HttpRequestJsonValue.cs deleted file mode 100644 index c9433e08..00000000 --- a/src/CoreEx.AspNetCore/Http/HttpRequestJsonValue.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.AspNetCore.Http -{ - /// - /// Represents a dynamic HTTP request JSON-deserialized . - /// - public class HttpRequestJsonValue : HttpRequestJsonValueBase - { - /// - /// Gets or sets the deserialized request value. - /// - public object? Value { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Http/HttpRequestJsonValueBase.cs b/src/CoreEx.AspNetCore/Http/HttpRequestJsonValueBase.cs deleted file mode 100644 index b1fbc072..00000000 --- a/src/CoreEx.AspNetCore/Http/HttpRequestJsonValueBase.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.AspNetCore.Http -{ - /// - /// Represents the base for a and . - /// - public abstract class HttpRequestJsonValueBase - { - /// - /// Indicates whether the request value was found to be valid. - /// - public bool IsValid => ValidationException == null; - - /// - /// Indicates whether the request value was found to be invalid. - /// - public bool IsInvalid => !IsValid; - - /// - /// Gets or sets any corresponding related to validation. - /// - /// This is typically set as the result of JSON deserialization. - public Exception? ValidationException { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Http/HttpRequestJsonValueT.cs b/src/CoreEx.AspNetCore/Http/HttpRequestJsonValueT.cs deleted file mode 100644 index 802ed0df..00000000 --- a/src/CoreEx.AspNetCore/Http/HttpRequestJsonValueT.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.AspNetCore.Http -{ - /// - /// Represents a HTTP request JSON-deserialized . - /// - /// The value . - public class HttpRequestJsonValue : HttpRequestJsonValueBase - { - /// - /// Gets or sets the deserialized request value. - /// - public T Value { get; set; } = default!; - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Http/HttpResultExtensions.cs b/src/CoreEx.AspNetCore/Http/HttpResultExtensions.cs deleted file mode 100644 index b0992412..00000000 --- a/src/CoreEx.AspNetCore/Http/HttpResultExtensions.cs +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.AspNetCore.WebApis; -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.Json; -using CoreEx.Validation; -using Microsoft.AspNetCore.Http; -using Microsoft.Net.Http.Headers; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using HttpRequestOptions = CoreEx.Http.HttpRequestOptions; - -namespace CoreEx.AspNetCore.Http -{ - /// - /// Extension methods for . - /// - public static class HttpResultExtensions - { - /// - /// Gets the standard invalid JSON message prefix. - /// - public const string InvalidJsonMessagePrefix = "Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted:"; - - /// - /// Applies the to the . - /// - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// This will automatically invoke where there is an value. - public static HttpRequest ApplyRequestOptions(this HttpRequest httpRequest, HttpRequestOptions? requestOptions) - { - httpRequest.ThrowIfNull(nameof(httpRequest)); - - if (requestOptions == null) - return httpRequest; - - // Apply the ETag header. - httpRequest.ApplyETag(requestOptions.ETag); - - // Apply updates to the query string. - httpRequest.QueryString = QueryString.FromUriComponent(requestOptions.AddToQueryString(httpRequest.QueryString.ToUriComponent())!); - - return httpRequest; - } - - /// - /// Applies the ETag to the as an (where is - /// or ); otherwise, an . - /// - /// The . - /// The ETag value. - /// The to support fluent-style method-chaining. - /// Automatically adds quoting to be ETag Header format compliant. - public static HttpRequest ApplyETag(this HttpRequest httpRequest, string? etag) - { - // Apply the ETag header. - if (!string.IsNullOrEmpty(etag)) - { - if (httpRequest.Method.Equals(HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase) || httpRequest.Method.Equals(HttpMethod.Head.Method, StringComparison.OrdinalIgnoreCase)) - httpRequest.Headers.Append(HeaderNames.IfNoneMatch, ETagGenerator.FormatETag(etag)); - else - httpRequest.Headers.Append(HeaderNames.IfMatch, ETagGenerator.FormatETag(etag)); - } - - return httpRequest; - } - - /// - /// Deserialize the HTTP JSON to a specified .NET object via a . - /// - /// The value . - /// The . - /// The . - /// Indicates whether the value is required; will consider invalid where null. - /// The optional to validate the value (only invoked where the value is not null). - /// The . - /// The . - public static async Task> ReadAsJsonValueAsync(this HttpRequest httpRequest, IJsonSerializer jsonSerializer, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - { - httpRequest.ThrowIfNull(nameof(httpRequest)); - - var content = await BinaryData.FromStreamAsync(httpRequest.Body, cancellationToken).ConfigureAwait(false); - var jv = new HttpRequestJsonValue(); - - // Deserialize the JSON into the selected type. - try - { - if (content.ToMemory().Length > 0) - jv.Value = jsonSerializer.ThrowIfNull(nameof(jsonSerializer)).Deserialize(content)!; - - if (valueIsRequired && jv.Value == null) - jv.ValidationException = new ValidationException($"{InvalidJsonMessagePrefix} Value is mandatory."); - - if (jv.Value != null && validator != null) - { - var vr = await validator.ValidateAsync(jv.Value, cancellationToken).ConfigureAwait(false); - jv.ValidationException = vr.ToException(); - } - } - catch (Exception ex) - { - jv.ValidationException = new ValidationException($"{InvalidJsonMessagePrefix} {ex.Message}", ex); - } - - return jv; - } - - /// - /// Reads the HTTP as and optionally validates whether . - /// - /// The . - /// Indicates whether the value is required; will consider invalid where underlying length is zero. - /// The . - /// The content where successful, otherwise the invalid. - public static async Task<(BinaryData? Content, ValidationException? Exception)> ReadAsBinaryDataAsync(this HttpRequest httpRequest, bool valueIsRequired = true, CancellationToken cancellationToken = default) - { - var content = await BinaryData.FromStreamAsync(httpRequest.Body, cancellationToken).ConfigureAwait(false); - - if (valueIsRequired && content.ToMemory().Length == 0) - return (null, new ValidationException($"{InvalidJsonMessagePrefix} Value is mandatory.")); - else - return (content, null); - } - - /// - /// Gets the from the . - /// - /// The . - /// The . - public static WebApiRequestOptions GetRequestOptions(this HttpRequest httpRequest) => new(httpRequest.ThrowIfNull(nameof(httpRequest))); - - /// - /// Adds the to the . - /// - /// The . - /// The . - public static void AddPagingResult(this IHeaderDictionary headers, PagingResult? paging) - { - if (paging == null) - return; - - switch (paging.Option) - { - case PagingOption.SkipAndTake: - headers[HttpConsts.PagingSkipHeaderName] = paging.Skip!.Value.ToString(CultureInfo.InvariantCulture); - headers[HttpConsts.PagingTakeHeaderName] = paging.Take.ToString(CultureInfo.InvariantCulture); - break; - - case PagingOption.PageAndSize: - headers[HttpConsts.PagingPageNumberHeaderName] = paging.Page!.Value.ToString(CultureInfo.InvariantCulture); - headers[HttpConsts.PagingPageSizeHeaderName] = paging.Take.ToString(CultureInfo.InvariantCulture); - break; - - default: - headers[HttpConsts.PagingTokenHeaderName] = paging.Token; - headers[HttpConsts.PagingTakeHeaderName] = paging.Take.ToString(CultureInfo.InvariantCulture); - break; - } - - if (paging.TotalCount.HasValue) - headers[HttpConsts.PagingTotalCountHeaderName] = paging.TotalCount.Value.ToString(CultureInfo.InvariantCulture); - - if (paging.TotalPages.HasValue) - headers[HttpConsts.PagingTotalPagesHeaderName] = paging.TotalPages.Value.ToString(CultureInfo.InvariantCulture); - } - - /// - /// Adds the to the . - /// - /// The . - /// The . - /// The optional . - public static void AddMessages(this IHeaderDictionary headers, MessageItemCollection? messages, IJsonSerializer? jsonSerializer = null) - { - if (messages is null || messages.Count == 0) - return; - - jsonSerializer ??= JsonSerializer.Default; - headers.TryAdd(HttpConsts.MessagesHeaderName, jsonSerializer.Serialize(messages)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Http/WebApi.cs b/src/CoreEx.AspNetCore/Http/WebApi.cs new file mode 100644 index 00000000..4ab6639e --- /dev/null +++ b/src/CoreEx.AspNetCore/Http/WebApi.cs @@ -0,0 +1,89 @@ +using AspHttp = Microsoft.AspNetCore.Http; + +namespace CoreEx.AspNetCore.Http; + +/// +/// Provides the foundation (, , and ) ASP.NET Core Minimal Web API execution encapsulation. +/// +/// The optional . +/// The optional for the . +/// The optional . +/// The methods within can also be used for a as it is essentially the same operation without a corresponding response; this distinction is handled internally. +public class WebApi(JsonSerializerOptions? jsonSerializerOptions = null, ILogger? logger = null, ExecutionContext? executionContext = null) : WebApi(WebApiInvoker.Default, jsonSerializerOptions, logger, executionContext) +{ + /// + internal override IResult CreateResult(WebApiResult result) + { + if (result.Result is not null) + return result.Result; + + IExtendedException? eex = null; + + if (result.Exception is not null) + { + if (!result.BypassExceptionLogging) + { + if (result.Exception is not IExtendedException reex || !reex.IsError) + { + // Treat the unhandled exception as an error. + var logger = Logger ?? result.HttpResponse.HttpContext.RequestServices.GetRequiredService>(); + if (logger.IsEnabled(LogLevel.Error)) + logger.LogError(result.Exception, "{Error}", result.Exception.Message); + } + else + { + // Log the exception where required. + eex = reex; + var logger = Logger ?? result.HttpResponse.HttpContext.RequestServices.GetRequiredService>(); + if (eex.ShouldBeLogged && logger.IsEnabled(LogLevel.Error)) + logger.LogError(result.Exception, "{Error}", eex.Message); + } + } + else + // Bypass logging, but still determine if the exception is an extended exception for later processing. + eex = result.Exception as IExtendedException; + + // Merge all extensions. + var extensions = CreateProblemDetailsExtensions(eex); + if (eex is not null && eex.HasExtensions) + { + foreach (var kvp in eex.Extensions) + { + extensions.TryAdd(kvp.Key, kvp.Value); + } + } + + // Special case where the exception is a ValidationException and has messages. + if (eex is ValidationException vex && vex.Messages is not null && vex.Messages.Count > 0) + { + var msd = new Dictionary(); + foreach (var item in from m in vex.Messages.GetMessagesForType(MessageType.Error).Where(x => x.Property is not null && x.Text is not null) + group m by m.Property into g + select new { Property = g.Key, Messages = g }) + { + msd.Add(item.Property!, [.. item.Messages.Select(m => m.Text!.ToString()!)]); + } + + return AspHttp.Results.ValidationProblem(msd, title: vex.Message, detail: vex.Detail, extensions: extensions); + } + + // Convert and return exception as problem details. + new WebApiHeader { RetryAfter = eex?.RetryAfter }.ApplyTo(this, result.HttpResponse); + if (eex is not null) + return AspHttp.Results.Problem(statusCode: (int)eex.StatusCode, title: eex.Message, detail: eex.Detail, extensions: extensions); + + var config = result.HttpResponse.HttpContext.RequestServices.GetRequiredService(); + var include = Internal.GetConfigurationValue(IncludeExceptionInProblemDetailsName, false, config); + + if (include) + return AspHttp.Results.Problem(statusCode: (int)HttpStatusCode.InternalServerError, title: result.Exception.Message, detail: result.Exception.ToString(), extensions: extensions); + else + return AspHttp.Results.Problem(statusCode: (int)HttpStatusCode.InternalServerError, title: new UnexpectedInternalException().Message, extensions: extensions); + } + + result.Headers?.ApplyTo(this, result.HttpResponse); + return result.Content is not null + ? AspHttp.Results.Content(result.Content, result.ContentType ?? MediaTypeNames.Text.Plain, statusCode: (int)result.StatusCode) + : AspHttp.Results.StatusCode((int)result.StatusCode); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Http/WebApiInvoker.cs b/src/CoreEx.AspNetCore/Http/WebApiInvoker.cs new file mode 100644 index 00000000..ce477080 --- /dev/null +++ b/src/CoreEx.AspNetCore/Http/WebApiInvoker.cs @@ -0,0 +1,15 @@ +namespace CoreEx.AspNetCore.Http; + +/// +/// Provides the ASP.NET Core Minimal Web API invoker. +/// +[InvokerName("CoreEx.AspNetCore.Http.WebApi")] +public class WebApiInvoker : WebApiInvoker +{ + private static WebApiInvoker? _default; + + /// + /// Gets the default instance. + /// + public static WebApiInvoker Default => ExecutionContext.GetService() ?? (_default ??= new WebApiInvoker()); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Idempotency/HybridCacheIdempotencyProvider.cs b/src/CoreEx.AspNetCore/Idempotency/HybridCacheIdempotencyProvider.cs new file mode 100644 index 00000000..16b02e78 --- /dev/null +++ b/src/CoreEx.AspNetCore/Idempotency/HybridCacheIdempotencyProvider.cs @@ -0,0 +1,219 @@ +using CoreEx.AspNetCore.Mvc; + +namespace CoreEx.AspNetCore.Idempotency; + +/// +/// Provides an implementation that uses an to store and retrieve idempotent request/response data. +/// +/// The . +/// This implementation uses the header to identify idempotent requests and store/retrieve the associated response data in/from the . +/// This allows for efficient handling of repeated requests with the same idempotency key, ensuring that only one request is processed and the result is cached for subsequent requests. +public class HybridCacheIdempotencyProvider(IHybridCache cache) : IIdempotencyProvider +{ + private static readonly TimeSpan _defaultExpiration = TimeSpan.FromHours(8); + + private readonly IdempotencyProviderInvoker _invoker = IdempotencyProviderInvoker.Default; + private readonly IHybridCache _cache = cache.ThrowIfNull(); + + /// + /// Gets or sets the maximum cached response body size in bytes. + /// + /// The default is 512 * 1024 (512 KB). + public int MaxCachedResponseBodySize { get; set => field = value.ThrowWhen(value => value <= 0); } = 512 * 1024; + + /// + /// Gets the list of HTTP request headers to include in the idempotency key fingerprint (see ). + /// + /// Should be a canonical list of request headers to include in fingerprinting; being important, and non-varying, to avoid errant request equality checking. + public List HttpRequestHeadersToIncludeInFingerprint { get; } = ["Content-Type"]; + + /// + /// Gets or sets the to use when storing idempotency key entries. + /// + public HybridCacheEntryOptions? CacheEntryOptions { get; set; } + + /// + public async Task OnInvokeAsync(IdempotencyKeyAttribute attribute, HttpContext context, RequestDelegate next) + { + IdempotencyKey? cached = null; + string? idempotencyKey = null; + string? idempotencyCacheKey = null; + HybridCacheEntryOptions? cacheEntryOptions = CacheEntryOptions; + + // Pre-processing - check for existing cached response and replay where applicable. + var replayedResponse = await _invoker.InvokeAsync(this, async tracer => + { + if (context.Request.Headers.TryGetValue(HttpNames.IdempotencyKeyHeaderName, out var strings)) + { + // Get the key and ensure valid. + idempotencyKey = strings.FirstOrDefault(); + idempotencyCacheKey = $"Idempotency:{idempotencyKey}"; + tracer.Activity?.AddTag("idempotency.key", idempotencyKey); + + if (!IdempotencyKey.IsIdempotencyKeyValid(idempotencyKey, out var exception)) + { + tracer.Activity?.AddTag("idempotency.result", "key-is-invalid"); + throw exception; + } + + // Check the cache for existing entry and ensure valid for this request. + cacheEntryOptions ??= HybridCacheEntryOptions.CreateFor(_defaultExpiration, _defaultExpiration, CacheStrategy.Hybrid); + cacheEntryOptions.WithTags(HttpNames.IdempotencyKeyHeaderName); + + var initial = await IdempotencyKey.CreateFromHttpRequestAsync(context, HttpRequestHeadersToIncludeInFingerprint).ConfigureAwait(false); + cached = await _cache.GetOrCreateByKeyAsync(idempotencyCacheKey, _ => Task.FromResult(initial), cacheEntryOptions).ConfigureAwait(false); + + if (initial.Fingerprint != cached.Fingerprint) + { + tracer.Activity?.AddTag("idempotency.result", "key-used-for-different-request"); + throw IdempotencyKey.CreateUsedForDifferentRequestException(); + } + + // Where in-progress and a different request then throw the relevant concurrency exception. + if (cached.Status == IdempotencyStatus.InProgress) + { + if (!ReferenceEquals(initial, cached)) + { + tracer.Activity?.AddTag("idempotency.result", "key-used-for-different-request"); + throw IdempotencyKey.CreateInProgressException(); + } + } + else if (cached.Status == IdempotencyStatus.CompletedTooLargeToReplay) + { + if (tracer.Logger?.IsEnabled(LogLevel.Warning) ?? false) + tracer.Logger.LogWarning("Idempotent request with key '{IdempotencyKey}' cannot be replayed as the original response was too large to cache.", idempotencyKey); + + tracer.Activity?.AddTag("idempotency.result", "original-response-too-large"); + throw IdempotencyKey.CreateResponseTooLargeException(); + } + else + { + if (tracer.Logger?.IsEnabled(LogLevel.Information) ?? false) + tracer.Logger.LogInformation("Idempotent request with key '{IdempotencyKey}' has resulted in the response being replayed from the cache.", idempotencyKey); + + // Write the cached response to the context and return, done, boom! + tracer.Activity?.AddTag("idempotency.result", "used-cached-response"); + await cached.WriteToHttpResponseAsync(context).ConfigureAwait(false); + + return true; + } + } + else if (attribute.IsRequired) + { + tracer.Activity?.AddTag("idempotency.result", "key-required"); + throw IdempotencyKey.CreateIdempotencyKeyRequiredException(); + } + + tracer.Activity?.AddTag("idempotency.result", "processing"); + return false; + }, memberName: $"{nameof(OnInvokeAsync)}::PreProcessing").ConfigureAwait(false); + + // Have processed before and have just arranged to replay the stored idempotency data; nice one stu (https://www.kiwitv.org.nz/index.php/tv-shows-mainmenu-42/46-kids/276-nice-one)! + if (replayedResponse) + return; + + // Where idempotency not requested for this request; carry on, nothing to see here. + if (cached is null) + { + await next(context).ConfigureAwait(false); + return; + } + + // Prepare to capture the response. + var originalBody = context.Response.Body; + await using var buffer = new MemoryStream(); + context.Response.Body = buffer; + + // Execute the next action then post-process. + try + { + // Execute the next action in the pipeline. + try + { + await next(context).ConfigureAwait(false); + } + catch + { + // Remove the in-progress marker for failed responses - retry with same idempotency-key allowed. + await _invoker.InvokeAsync(this, async tracer => + { + await GuardedCacheRemoveAsync(idempotencyCacheKey!, cacheEntryOptions, tracer.Logger).ConfigureAwait(false); + + tracer.Activity?.AddTag("idempotency.result", "error-and-removed"); + }, memberName: $"{nameof(OnInvokeAsync)}::PostProcessing").ConfigureAwait(false); + + // Keep bubbling... + throw; + } + + // Post-processing - cache the response on successful completion; otherwise, remove the in-progress marker. + await _invoker.InvokeAsync(this, async tracer => + { + // Cache the successful response. + if (context.Response.StatusCode >= 200 && context.Response.StatusCode < 300) + { + cached.StatusCode = context.Response.StatusCode; + cached.Headers = context.Response.Headers.ToDictionary(h => h.Key, h => h.Value.ToArray()); + + if (buffer.Length > MaxCachedResponseBodySize) + { + cached.Body = null; + cached.Status = IdempotencyStatus.CompletedTooLargeToReplay; + await _cache.SetByKeyAsync(idempotencyCacheKey!, cached, cacheEntryOptions).ConfigureAwait(false); + + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Warning)) + tracer.Logger.LogWarning("Idempotent request with key '{IdempotencyKey}' response body size of {ResponseBodySize} bytes exceeds the maximum allowable cached size of {MaxCachedResponseBodySize} bytes; response will not be stored for replay.", + idempotencyKey, buffer.Length, MaxCachedResponseBodySize); + + tracer.Activity?.AddTag("idempotency.result", "success-but-too-large-to-store"); + } + else + { + buffer.Position = 0; + cached.Body = BinaryData.FromStream(buffer); + cached.Status = IdempotencyStatus.CompletedAndReplayable; + await _cache.SetByKeyAsync(idempotencyCacheKey!, cached, cacheEntryOptions).ConfigureAwait(false); + + tracer.Activity?.AddTag("idempotency.result", "success-and-stored"); + } + } + else + { + // Remove the in-progress marker for failed responses - retry with same idempotency-key allowed. + await GuardedCacheRemoveAsync(idempotencyCacheKey!, cacheEntryOptions, tracer.Logger).ConfigureAwait(false); + + tracer.Activity?.AddTag("idempotency.result", "error-and-removed"); + } + + // Rewind and copy the response back to the original stream. + context.Response.Body = originalBody; + context.Response.ContentLength = buffer.Length; + + buffer.Position = 0; + await buffer.CopyToAsync(originalBody, context.RequestAborted).ConfigureAwait(false); + }, memberName: $"{nameof(OnInvokeAsync)}::PostProcessing").ConfigureAwait(false); + } + finally + { + // Rewire the response body. + context.Response.Body = originalBody; + } + } + + /// + /// Guard the cache remove to swallow any exceptions. + /// + private async Task GuardedCacheRemoveAsync(string idempotencyCacheKey, HybridCacheEntryOptions? cacheEntryOptions, ILogger? logger) + { + try + { + await _cache.RemoveByKeyAsync(idempotencyCacheKey, cacheEntryOptions).ConfigureAwait(false); + } + catch (Exception ex) + { + // Log and swallow. + if (logger?.IsEnabled(LogLevel.Warning) ?? false) + logger?.LogWarning(ex, "Attempt to remove Idempotency-Key cache entry '{IdempotencyKey}' failed; underlying response processing has continued.", idempotencyCacheKey); + } + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Idempotency/IIdempotencyProvider.cs b/src/CoreEx.AspNetCore/Idempotency/IIdempotencyProvider.cs new file mode 100644 index 00000000..34e1ca7d --- /dev/null +++ b/src/CoreEx.AspNetCore/Idempotency/IIdempotencyProvider.cs @@ -0,0 +1,18 @@ +using CoreEx.AspNetCore.Mvc; + +namespace CoreEx.AspNetCore.Idempotency; + +/// +/// Provides the underlying implementation services. +/// +/// See also . +public interface IIdempotencyProvider +{ + /// + /// Represents the idempotency handling. + /// + /// The . + /// The . + /// The next . + Task OnInvokeAsync(IdempotencyKeyAttribute attribute, HttpContext context, RequestDelegate next); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Idempotency/IdempotencyKey.Static.cs b/src/CoreEx.AspNetCore/Idempotency/IdempotencyKey.Static.cs new file mode 100644 index 00000000..98de677e --- /dev/null +++ b/src/CoreEx.AspNetCore/Idempotency/IdempotencyKey.Static.cs @@ -0,0 +1,78 @@ +namespace CoreEx.AspNetCore.Idempotency; + +/// +/// Provides utility. +/// +public partial class IdempotencyKey +{ + private const string _errorType = "idempotency-key"; + + /// + /// Checks whether the specified idempotency key is considered valid. + /// + /// The idempotency key to validate. + /// The resulting validation exception where the idempotency key is invalid. + /// indicates that the idempotency key is valid; otherwise, . + /// The idempotency key must be between 8 and 128 characters in length and consist of only letters, numbers, hyphens and underscores. + public static bool IsIdempotencyKeyValid([NotNullWhen(true)] string? idempotencyKey, [NotNullWhen(false)] out ValidationException? exception) + { + if (string.IsNullOrEmpty(idempotencyKey) || !_idempotencyKeyRegex().IsMatch(idempotencyKey)) + { + exception = new ValidationException($"The '{HttpNames.IdempotencyKeyHeaderName}' header is invalid.") + .WithErrorType(_errorType) + .WithErrorCode("header-is-invalid") + .WithDetail("The idempotency key must be between 8 and 128 characters in length and consist of only letters, numbers, hyphens and underscores."); + + return false; + } + + exception = null; + return true; + } + + /// + /// Creates a to indicate that the idempotency key is required. + /// + /// The resulting validation exception. + public static ValidationException CreateIdempotencyKeyRequiredException() + => new ValidationException($"The '{HttpNames.IdempotencyKeyHeaderName}' header is required.") + .WithErrorType(_errorType) + .WithErrorCode("header-is-required") + .WithDetail("An Idempotency key must be provided in the request header; it must be between 8 and 128 characters in length and consist of only letters, numbers, hyphens and underscores."); + + /// + /// Creates a to indicate that the idempotent operation is in progress. + /// + /// The optional retry-after interval; defaults to . + /// The resulting conflict exception. + public static ConflictException CreateInProgressException(TimeSpan? retryAfter = null) + => new ConflictException($"An operation with the specified '{HttpNames.IdempotencyKeyHeaderName}' header is already in progress.") + .WithErrorType(_errorType) + .WithErrorCode("is-in-progress") + .WithDetail("An operation with the specified idempotency key is already in progress; please wait for its completion before retrying.") + .AsTransient(retryAfter); + + /// + /// Creates a to indicate that the idempotency key has already been used for a different request. + /// + /// The resulting conflict exception. + public static ConflictException CreateUsedForDifferentRequestException() + => new ConflictException($"The '{HttpNames.IdempotencyKeyHeaderName}' header has already been used for a different request.") + .WithStatusCode(HttpStatusCode.UnprocessableContent) + .WithErrorType(_errorType) + .WithErrorCode("different-request") + .WithDetail("The specified idempotency key has already been used for a different request; reuse of idempotency keys is not allowed."); + + /// + /// Creates a to indicate that the response associated with the idempotency key was too large can not be replayed. + /// + /// The resulting conflict exception. + public static ConflictException CreateResponseTooLargeException() + => new ConflictException($"The response associated with the specified '{HttpNames.IdempotencyKeyHeaderName}' is no longer available.") + .WithErrorType(_errorType) + .WithErrorCode("response-unavailable") + .WithDetail("The response associated with the specified idempotency key completed successfully; however, it cannot be replayed as the original response representation is no longer available."); + + [GeneratedRegex("^[A-Za-z0-9_-]{8,128}$", RegexOptions.Compiled)] + private static partial Regex _idempotencyKeyRegex(); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Idempotency/IdempotencyKey.cs b/src/CoreEx.AspNetCore/Idempotency/IdempotencyKey.cs new file mode 100644 index 00000000..2ecbda9b --- /dev/null +++ b/src/CoreEx.AspNetCore/Idempotency/IdempotencyKey.cs @@ -0,0 +1,92 @@ +namespace CoreEx.AspNetCore.Idempotency; + +/// +/// Provides implementation agnostic data and utility capabilities. +/// +public sealed partial class IdempotencyKey +{ + /// + /// Creates a new instance of the class. + /// + public static async Task CreateFromHttpRequestAsync(HttpContext context, IEnumerable headersToIncludeInFingerprint) + { + // Fingerprint the request: method, path (no query string) and canonical headers. + using var ih = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + ih.AppendData(new BinaryData(context.Request.Method.ToUpperInvariant())); + ih.AppendData(new BinaryData(context.Request.Path.ToString().ToLowerInvariant())); + + foreach (var header in headersToIncludeInFingerprint) + { + if (context.Request.Headers.TryGetValue(header, out var values)) + { + ih.AppendData(new BinaryData(header)); + + foreach (var value in values) + { + var val = value?.Trim(); + if (!string.IsNullOrEmpty(val)) + ih.AppendData(new BinaryData(val)); + } + } + } + + // Read and include the request body. + context.Request.EnableBuffering(); + await using var ms = new MemoryStream(); + await context.Request.Body.CopyToAsync(ms); + ih.AppendData(ms.ToArray()); + + // Rewind stream position for downstream consumption. + context.Request.Body.Position = 0; + + // Create and return the cached response with the computed request fingerprint. + var hash = ih.GetCurrentHash(); + return new IdempotencyKey { Fingerprint = Convert.ToBase64String(hash) }; + } + + /// + /// Gets or sets the . + /// + public IdempotencyStatus Status { get; set; } = IdempotencyStatus.InProgress; + + /// + /// Gets or sets the originating request fingerprint. + /// + public string? Fingerprint { get; set; } + + /// + /// Gets or sets the response status code. + /// + public int? StatusCode { get; set; } + + /// + /// Gets or sets the response headers. + /// + public IDictionary? Headers { get; set; } + + /// + /// Gets or sets the response body. + /// + public BinaryData? Body { get; set; } + + /// + /// Writes the idempotency data to the specified response. + /// + /// The . + public async Task WriteToHttpResponseAsync(HttpContext context) + { + if (context.Response.HasStarted) + throw new InvalidOperationException("Response already started; cannot replay idempotent response."); + + context.Response.StatusCode = StatusCode ?? 200; + context.Response.Headers.Clear(); + + if (Headers is not null) + { + foreach (var header in Headers) + context.Response.Headers.TryAdd(header.Key, header.Value); + } + + await context.Response.Body.WriteAsync(Body); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Idempotency/IdempotencyKeyMiddleware.cs b/src/CoreEx.AspNetCore/Idempotency/IdempotencyKeyMiddleware.cs new file mode 100644 index 00000000..cf9244f3 --- /dev/null +++ b/src/CoreEx.AspNetCore/Idempotency/IdempotencyKeyMiddleware.cs @@ -0,0 +1,23 @@ +namespace CoreEx.AspNetCore.Idempotency; + +/// +/// Provides the handling middleware to enable idempotent operations via a pluggable . +/// +public sealed class IdempotencyKeyMiddleware(IIdempotencyProvider provider) : IMiddleware +{ + private readonly IIdempotencyProvider? _provider = provider.ThrowIfNull(); + + /// + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + // Check if the endpoint supports idempotency. + var endpoint = context.ThrowIfNull().GetEndpoint(); + var idempotencyAttribute = endpoint?.Metadata.GetMetadata(); + + // Where no idempotency attribute, just continue on as normal; otherwise, Invoke the provider to handle. + if (idempotencyAttribute is null) + await next(context).ConfigureAwait(false); + else + await _provider.ThrowIfNull().OnInvokeAsync(idempotencyAttribute, context, next).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Idempotency/IdempotencyProviderInvoker.cs b/src/CoreEx.AspNetCore/Idempotency/IdempotencyProviderInvoker.cs new file mode 100644 index 00000000..7e2aa1f1 --- /dev/null +++ b/src/CoreEx.AspNetCore/Idempotency/IdempotencyProviderInvoker.cs @@ -0,0 +1,15 @@ +namespace CoreEx.AspNetCore.Idempotency; + +/// +/// Provides the invoker. +/// +[InvokerName("CoreEx.AspNetCore.Idempotency.IdempotencyProviderInvoker")] +public class IdempotencyProviderInvoker : InvokerBase +{ + private static IdempotencyProviderInvoker? _default; + + /// + /// Gets the default instance. + /// + public static IdempotencyProviderInvoker Default => ExecutionContext.GetService() ?? (_default ??= new IdempotencyProviderInvoker()); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Idempotency/IdempotencyStatus.cs b/src/CoreEx.AspNetCore/Idempotency/IdempotencyStatus.cs new file mode 100644 index 00000000..ecf5b6dc --- /dev/null +++ b/src/CoreEx.AspNetCore/Idempotency/IdempotencyStatus.cs @@ -0,0 +1,22 @@ +namespace CoreEx.AspNetCore.Idempotency; + +/// +/// Respresents the status of the request (server-side) with respect to idempotency. +/// +public enum IdempotencyStatus +{ + /// + /// Indicates that the operation is currently being processed. + /// + InProgress, + + /// + /// Indicates that the operation has completed and can be replayed if necessary. + /// + CompletedAndReplayable, + + /// + /// Indicates that the completed operation cannot be replayed because its size exceeds the allowable limit. + /// + CompletedTooLargeToReplay +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Mvc/AcceptsAttribute.cs b/src/CoreEx.AspNetCore/Mvc/AcceptsAttribute.cs new file mode 100644 index 00000000..9ffbdb86 --- /dev/null +++ b/src/CoreEx.AspNetCore/Mvc/AcceptsAttribute.cs @@ -0,0 +1,32 @@ +namespace CoreEx.AspNetCore.Mvc; + +/// +/// An attribute that specifies the expected request body that the action/operation accepts and the supported request content types. +/// +/// The is used to enable OpenApi generated documentation where the operation does not explicitly define the body as a method parameter; i.e. via . +/// The request body ; defaults to . +/// The primary request body content type. +/// The additional request body content type(s). +[AttributeUsage(AttributeTargets.Method)] +public class AcceptsAttribute(Type type, string? contentType = MediaTypeNames.Application.Json, params string[] additionalContentTypes) : Attribute +{ + /// + /// Gets the request body . + /// + public Type BodyType { get; } = type.ThrowIfNull(); + + /// + /// Gets the primary request body content type. + /// + public string ContentType { get; } = contentType.ThrowIfNullOrEmpty(); + + /// + /// Gets the additional request body content type(s). + /// + public string[] AdditionalContentTypes { get; } = [.. additionalContentTypes]; + + /// + /// Indicates whether the request body is optional. + /// + public bool IsOptional { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Mvc/AcceptsAttributeT.cs b/src/CoreEx.AspNetCore/Mvc/AcceptsAttributeT.cs new file mode 100644 index 00000000..5bac8b51 --- /dev/null +++ b/src/CoreEx.AspNetCore/Mvc/AcceptsAttributeT.cs @@ -0,0 +1,11 @@ +namespace CoreEx.AspNetCore.Mvc; + +/// +/// An attribute that specifies the expected request body that the action/operation accepts and the supported request content types. +/// +/// The is used to enable OpenApi generated documentation where the operation does not explicitly define the body as a method parameter; i.e. via . +/// The request body . +/// The primary request body content type. +/// The additional request body content type(s). +[AttributeUsage(AttributeTargets.Method)] +public sealed class AcceptsAttribute(string? contentType = MediaTypeNames.Application.Json, params string[] additionalContentTypes) : AcceptsAttribute(typeof(TRequest), contentType, additionalContentTypes) { } \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Mvc/IdempotencyKeyAttribute.cs b/src/CoreEx.AspNetCore/Mvc/IdempotencyKeyAttribute.cs new file mode 100644 index 00000000..79643ba7 --- /dev/null +++ b/src/CoreEx.AspNetCore/Mvc/IdempotencyKeyAttribute.cs @@ -0,0 +1,14 @@ +namespace CoreEx.AspNetCore.Mvc; + +/// +/// Provides the attribute to indicate that the decorated operation is idempotent and should be handled accordingly. +/// +/// Indicates whether an is required for the request. +[AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = false)] +public partial class IdempotencyKeyAttribute(bool isRequired = false) : Attribute +{ + /// + /// Indicates whether an is required for the request. + /// + public bool IsRequired { get; } = isRequired; +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Mvc/PagingAttribute.cs b/src/CoreEx.AspNetCore/Mvc/PagingAttribute.cs new file mode 100644 index 00000000..d098d22b --- /dev/null +++ b/src/CoreEx.AspNetCore/Mvc/PagingAttribute.cs @@ -0,0 +1,15 @@ +namespace CoreEx.AspNetCore.Mvc; + +/// +/// An attribute that specifies that the action/operation supports (not explicitly defined as a parameter). +/// +/// The is used to enable OpenAPI generated documentation where the operation does not explicitly define the as a method parameter; i.e. via . +/// Indicates whether is supported/enabled. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class PagingAttribute(bool supportsCount = false) : Attribute +{ + /// + /// Indicates whether the is supported. + /// + public bool SupportsCount { get; } = supportsCount; +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Mvc/ProducesNotFoundProblemAttribute.cs b/src/CoreEx.AspNetCore/Mvc/ProducesNotFoundProblemAttribute.cs new file mode 100644 index 00000000..843e9663 --- /dev/null +++ b/src/CoreEx.AspNetCore/Mvc/ProducesNotFoundProblemAttribute.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; + +namespace CoreEx.AspNetCore.Mvc; + +/// +/// An attribute that specifies that the action/operation may return a response. +/// +/// This is shorthand for specifying: [ProducesResponseType(typeof(ProblemDetails), 200, MediaTypeNames.Application.ProblemJson)]. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class ProducesNotFoundProblemAttribute : Attribute { } \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Mvc/QueryAttribute.cs b/src/CoreEx.AspNetCore/Mvc/QueryAttribute.cs new file mode 100644 index 00000000..af93a497 --- /dev/null +++ b/src/CoreEx.AspNetCore/Mvc/QueryAttribute.cs @@ -0,0 +1,23 @@ +namespace CoreEx.AspNetCore.Mvc; + +/// +/// An attribute that specifies that the action/operation supports (not explicitly defined as a parameter). +/// +/// The is used to enable OpenApi generated documentation where the operation does not explicitly define the as a method parameter; i.e. via . +/// Indicates whether is supported/enabled. +/// Indicates whether is supported/enabled. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class QueryAttribute(bool supportsFilter = true, bool supportsOrderBy = false) : Attribute +{ + /// + /// Indicates whether the is supported. + /// + /// Defaults to . + public bool SupportsFilter { get; } = supportsFilter; + + /// + /// Indicates whether the is supported. + /// + /// Defaults to . + public bool SupportsOrderBy { get; } = supportsOrderBy; +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Mvc/WebApi.cs b/src/CoreEx.AspNetCore/Mvc/WebApi.cs new file mode 100644 index 00000000..e0c38d47 --- /dev/null +++ b/src/CoreEx.AspNetCore/Mvc/WebApi.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace CoreEx.AspNetCore.Mvc; + +/// +/// Provides the foundation (, , and ) ASP.NET Core MVC Web API execution encapsulation. +/// +/// The optional . +/// The optional for the . +/// The optional . +/// The methods within can also be used for a as it is essentially the same operation without a corresponding response; this distinction is handled internally. +/// Any that is not an and not will be thrown. +public class WebApi(JsonSerializerOptions? jsonSerializerOptions = null, ILogger? logger = null, ExecutionContext? executionContext = null) : WebApi(WebApiInvoker.Default, jsonSerializerOptions, logger, executionContext) +{ + /// + internal override IActionResult CreateResult(WebApiResult result) + { + static void AddExtensions(IDictionary extensions, IDictionary extended) + { + foreach (var kvp in extended) + { + extensions.TryAdd(kvp.Key, kvp.Value); + } + } + + if (result.Result is not null) + return result.Result; + + IExtendedException? eex = null; + + if (result.Exception is not null) + { + if (!result.BypassExceptionLogging) + { + if (result.Exception is not IExtendedException reex || !reex.IsError) + { + if (!ConvertUnhandledExceptionsToProblemDetails) + throw result.Exception; + + // Treat the unhandled exception as an error. + var logger = Logger ?? result.HttpResponse.HttpContext.RequestServices.GetRequiredService>(); + if (logger.IsEnabled(LogLevel.Error)) + logger.LogError(result.Exception, "{Error}", result.Exception.Message); + } + else + { + // Log the exception where required. + eex = reex; + var logger = Logger ?? result.HttpResponse.HttpContext.RequestServices.GetRequiredService>(); + if (eex.ShouldBeLogged && logger.IsEnabled(LogLevel.Error)) + logger.LogError(result.Exception, "{Error}", eex.Message); + } + } + + var pdf = result.HttpResponse.HttpContext.RequestServices.GetRequiredService(); + + // Special case where the exception is a ValidationException and has messages. + if (eex is ValidationException vex && vex.Messages is not null && vex.Messages.Count > 0) + { + var msd = new ModelStateDictionary(); + foreach (var item in vex.Messages.GetMessagesForType(MessageType.Error)) + { + if (item.Property is not null && item.Text is not null) + msd.AddModelError(item.Property, item.Text.ToString()!); + } + + var vpd = pdf.CreateValidationProblemDetails(result.HttpResponse.HttpContext, msd, title: vex.Message, detail: vex.Detail); + AddExtensions(vpd.Extensions, CreateProblemDetailsExtensions(eex)); + if (eex.HasExtensions) + AddExtensions(vpd.Extensions, eex.Extensions); + + return new BadRequestObjectResult(vpd); + } + + // Apply the Retry-After header where applicable. + if (eex is not null && eex.RetryAfter.HasValue) + new WebApiHeader { RetryAfter = eex.RetryAfter }.ApplyTo(this, result.HttpResponse); + + // Convert and return exception as problem details + ProblemDetails pd; + if (eex is not null) + { + pd = pdf.CreateProblemDetails(result.HttpResponse.HttpContext, (int)eex.StatusCode, title: eex.Message, detail: eex.Detail); + AddExtensions(pd.Extensions, CreateProblemDetailsExtensions(eex)); + if (eex.HasExtensions) + AddExtensions(pd.Extensions, eex.Extensions); + + return new ObjectResult(pd) { StatusCode = (int)eex.StatusCode }; + } + else + { + var config = result.HttpResponse.HttpContext.RequestServices.GetRequiredService(); + var include = Internal.GetConfigurationValue("CoreEx:AspNetCore:IncludeExceptionInProblemDetails", false, config); + + if (include) + pd = pdf.CreateProblemDetails(result.HttpResponse.HttpContext, (int)HttpStatusCode.InternalServerError, title: result.Exception.Message, detail: result.Exception.ToString()); + else + pd = pdf.CreateProblemDetails(result.HttpResponse.HttpContext, (int)HttpStatusCode.InternalServerError, title: new UnexpectedInternalException().Message, detail: null); + + AddExtensions(pd.Extensions, CreateProblemDetailsExtensions(null)); + return new ObjectResult(pd) { StatusCode = (int)HttpStatusCode.InternalServerError }; + } + } + + result.Headers?.ApplyTo(this, result.HttpResponse); + return result.Content is not null + ? new ContentResult { Content = result.Content, ContentType = result.ContentType ?? MediaTypeNames.Text.Plain, StatusCode = (int)result.StatusCode } + : new StatusCodeResult((int)result.StatusCode); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/Mvc/WebApiInvoker.cs b/src/CoreEx.AspNetCore/Mvc/WebApiInvoker.cs new file mode 100644 index 00000000..233a1ca5 --- /dev/null +++ b/src/CoreEx.AspNetCore/Mvc/WebApiInvoker.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; + +namespace CoreEx.AspNetCore.Mvc; + +/// +/// Provides the ASP.NET Core MVC Web API invoker +/// +[InvokerName("CoreEx.AspNetCore.Mvc.WebApiInvoker")] +public class WebApiInvoker : WebApiInvoker +{ + private static WebApiInvoker? _default; + + /// + /// Gets the default instance. + /// + public static WebApiInvoker Default => ExecutionContext.GetService() ?? (_default ??= new WebApiInvoker()); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/OpenApiOptions.cs b/src/CoreEx.AspNetCore/OpenApiOptions.cs new file mode 100644 index 00000000..044e5bfd --- /dev/null +++ b/src/CoreEx.AspNetCore/OpenApiOptions.cs @@ -0,0 +1,111 @@ +namespace CoreEx.AspNetCore; + +/// +/// Provides the OpenAPI generated specification configuration settings. +/// +public class OpenApiOptions +{ + /// + /// Gets or sets the . + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } = JsonDefaults.SerializerOptions; + + /// + /// Gets or sets the and descriptive text. + /// + public string PagingSkipText { get; set; } = "The zero-based offset of the first item to return where paging."; + + /// + /// Gets or sets the and descriptive text. + /// + public string PagingTakeText { get; set; } = "The maximum number of items to return where paging."; + + /// + /// Gets or sets the descriptive text. + /// + public string PagingCountText { get; set; } = "Indicates whether to also attempt to include the total of matching items where paging."; + + /// + /// Gets or sets the descriptive text. + /// + public string PagingTotalCountText { get; set; } = "The total number of matching items (only returned when `count=true`) where paging."; + + /// + /// Gets or sets the descriptive text. + /// + public string QueryFilterText { get; set; } = "The OData-like query filter expression."; + + /// + /// Gets or sets the descriptive text. + /// + public string QueryOrderByText { get; set; } = "The OData-like query order-by expression."; + + /// + /// Gets or sets the descriptive text. + /// + public string IncludeFieldsText { get; set; } = "The comma separated list of JSON field names to include only in the response."; + + /// + /// Gets or sets the descriptive text. + /// + public string ExcludeFieldsText { get; set; } = "The comma separated list of JSON field names to exclude from the response."; + + /// + /// Gets or sets the descriptive text. + /// + public string IdempotencyKeyText { get; set; } = "The idempotency key to use for the request (must be between 8 and 128 characters in length and consist of only letters, numbers, hyphens and underscores)."; + + /// + /// Gets or sets the descriptive text. + /// + public string WarningMessagesText { get; set; } = "Additional resulting warning message(s) where applicable."; + + /// + /// Gets or sets the descriptive text. + /// + public string InfoMessagesText { get; set; } = "Additional resulting informational message(s) where applicable."; + + /// + /// Indicates whether the and response headers should be included in the OpenAPI generated specification. + /// + public bool IncludeMessagesResponseHeaders { get; set; } = true; + + /// + /// Indicates whether the + /// + public bool IncludeFieldsRequestHeaders { get; set; } = true; + + /// + /// Indicates whether the , , and response headers should be included in the OpenAPI generated specification + /// where the operation is a success (i.e. is within the range 200-299). + /// + /// The underlying operation must also have the metadata assigned. + public bool IncludePagingResponseHeaders { get; set; } = true; + + /// + /// Indicates whether the should be included in the OpenAPI generated operation response specification for each specified within the . + /// + public bool IncludeProblemDetailsHttpStatusCodes { get; set; } = true; + + /// + /// Indicates whether the should be included in the OpenAPI generated operation response specification for each specified within the . + /// + public bool IncludeValidationProblemDetailsHttpStatusCodes { get; set; } = true; + + /// + /// Gets the list that are related to a . + /// + public List ProblemDetailsHttpStatusCodes { get; } = + [ + HttpStatusCode.Unauthorized, + HttpStatusCode.Forbidden, + HttpStatusCode.Conflict, + HttpStatusCode.PreconditionFailed, + HttpStatusCode.ServiceUnavailable + ]; + + /// + /// Gets the list that are related to a . + /// + public List ValidationProblemDetailsHttpStatusCodes { get; } = [HttpStatusCode.BadRequest]; +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/README.md b/src/CoreEx.AspNetCore/README.md deleted file mode 100644 index 60ab6263..00000000 --- a/src/CoreEx.AspNetCore/README.md +++ /dev/null @@ -1,258 +0,0 @@ -# CoreEx.AspNetCore - -The `CoreEx.AspNetCore` namespace provides extended capabilities to build Web APIs, for the likes of [ASP.NET](https://dotnet.microsoft.com/en-us/apps/aspnet/apis) or [HTTP-triggered Azure functions](https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-trigger). The [`WebApi`](./WebApis/WebApi.cs) and [`WebApiPublisher`](./WebApis/WebApiPublisher.cs) capabilities (within the `CoreEx.AspNetCore.WebApis` namespace) encapsulate the consistent handling of the HTTP request and corresponding response, whilst also providing additional capabilities that are not available out-of-the-box within the .NET runtime. - -
- -## Motivation - -To standardize, and simplify, the development of JSON-based Web APIs. The key integration patterns currently being addressed are as follows: - -Pattern | Description | Capability --|-|- -Request-response | This represents a real-time request-response, whereby the request is immediately fulfilled (synchronous) with the response representing the result of the request. | [WebApi](#webapi) -Fire-and-forget | This is to enable decoupled asynchronous processing, whereby the request is immediately accepted (queued internally), with a separate internal process that fulfils the request independently of the request. | [WebApiPublish](#webapipublish) - -
- -## Limitations - -Only JSON-based Web APIs are generally supported. Where additional or other content types are needed then this library in its current state will not be able to enable, and these Web APIs will need to be implemented in a traditional custom manner. - -There is provision such that any result of type [`IActionResult`](https://learn.microsoft.com/en-us/aspnet/core/web-api/action-return-types), for example [`FileContentResult`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.filecontentresult), is returned these will be enacted by the ASP.NET Core runtime as-is (i.e. no `CoreEx.AspNetCore` processing will occur on the result). However, all other request handling, exception handling, logging, etc. described below will occur which has a consistency benefit. - -
- -## WebApi - -The [`WebApi`](./WebApis/WebApi.cs) class should be leveraged as the primary means to enable Web API functionality, it provides methods for HTTP `GET`, `POST`, `PUT`, `PATCH` and `DELETE` operations that encapsulates the execution in a standardized manner, providing alternate overloads and options to enable the desired behaviours. - -The `WebApi` extends (inherits) [`WebApiBase`](./WebApis/WebApiBase.cs) that provides the base `RunAsync` method that all other methods invoke to wrap the underlying logic. This in turns invokes the [`WebApiInvoker`](./WebApis/WebApiInvoker.cs) which provides a pluggable mechanism (i.e. can be replaced) that by default handles the following consistently for each request: - -- Infers the standard [`WebApiRequestOptions`](./WebApis/WebApiRequestOptions.cs) from the HTTP request headers and query string (names are configurable). -- Infers the correlation identifier from the HTTP request header (names are configurable). -- Begins a logging scope to include the correlation identifier. -- Invokes the request logic and returns the corresponding [`IActionResult`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.iactionresult). -- Handle all exceptions: - - Where the exception implements [`IExtendedException`](../CoreEx/Abstractions/IExtendedException.cs) then returns `IExtendedException.ToResult()`. Also, where `IExtendedException.ShouldBeLogged` is `true` then a `ILogger.LogError` will occur; some errors, such as `400-BadRequest`, need not be logged as they are not a run-time error per se. - - Invoke the protected `OnUnhandledExceptionAsync` then return resulting `IActionResult` where not `null`. - -
- -### Supported HTTP methods - -`WebApi` provides the following per HTTP method; each with varying overloads depending on need. Where a generic `Type` is specified, either `TValue` being the request content body and/or `TResult` being the response body, this signifies that `WebApi` will manage the underlying JSON serialization: - -HTTP | Method | Description --|-|- -`GET` | `GetAsync()` | Performs a `GET` operation. -`POST` | `PostAsync()`
`PostAsync()`
`PostAsync()`
`Post()` | Performs a `POST` operation. -`PUT` | `PutAsync()`
`PutAsync()` | Performs a `PUT` operation. -`PATCH` | `PatchAsync` | Performs a `PATCH` operation. Support for [`application/merge-patch+json`](https://tools.ietf.org/html/rfc7396) with [`JsonMergePatch`](../CoreEx/Json/Merge/JsonMergePatch.cs). -`DELETE` | `DeleteAsync()` | Performs a `DELETE` operation. -`*` | `RunAsync()`
`RunAsync()` | Performs _any_ operation returning an `IActionResult`. - -
- -### Request - -Where a request contains a content body that contains JSON (content-type of `application/json`) then these methods _can_ (where the `TValue` is defined) perform the deserialization using the appropriate [`IJsonSerailizer`](../CoreEx/Json/IJsonSerializer.cs). The corresponding [`WebApiRequestOptions`](./WebApis/WebApiRequestOptions.cs) are also automatically inferred as described above. - -Where using `CoreEx` to perform the JSON deserialization then the value is _not_ specified as an argument within the method (typically with the [`FromBody`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.frombodyattribute) attribute). However, this will mean that the value type is not output when _Swagger_ output is generated; to enable, use the [`AcceptsBody`](./WebApis/AcceptsBodyAttribute.cs) attribute to specify. - -
- -### Response - -Where a `TResult` value is returned then these methods will perform the JSON serialization, using the appropriate `IJsonSerailizer`. This is managed by the underlying [`ValueContentResult.CreateResult`](./WebApis/ValueContentResult.cs) which additionally performs the following: - -Step | Description --|- -[`PagingResult`](../CoreEx/Entities/PagingResult.cs) headers | Where response value is [`ICollectionResult`](..//CoreEx/Entities/ICollectionResult.cs) then sets `PagingResult` headers and returns underlying collection (`ICollectionResult.Collection`). -JSON serialization | Serializes the `TResult` value using the `IJsonSerailizer`. Where include or exclude fields were specified within the request query string then these will be applied (`IJsonSerializer.TryApplyFilter`) to the JSON response to limit the response content. -`ETag` generation | Checks if value implements [`IETag`](../CoreEx/Entities/IETag.cs), where non-null leave as-is; otherwise, automatically [generate](../CoreEx/Abstractions/ETagGenerator.cs) `ETag` hash from serialized value (excluding filters). -`GET` [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) | Where the value/generated `ETag` equals the `GET` request `If-Match` value then return an HTTP status code of `304-NotModified` with no content. -[`ETag`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) header | Sets the HTTP `ETag` header using either `IETag.ETag` or generated hash. -[Status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) | Sets the response HTTP status code as configured. -[`Location`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location) | Sets the HTTP `Location` header where specified (where applicable). - -As described earlier, the above will _not_ occur for `IActionResult` results. - -
- -### ASP.NET example - -The following demonstrates usage when creating an ASP.NET `Controller`: - -``` csharp -[Route("api/employees")] -public class EmployeeController : ControllerBase -{ - private readonly WebApi _webApi; - private readonly EmployeeService _service; - - public EmployeeController(WebApi webApi, EmployeeService service) - { - _webApi = webApi; - _service = service; - } - - [HttpGet("{id}", Name = "Get")] - public Task GetAsync(Guid id) - => _webApi.GetAsync(Request, _ => _service.GetEmployeeAsync(id)); - - [HttpGet("", Name = "GetAll")] - public Task GetAllAsync() - => _webApi.GetAsync(Request, p => _service.GetAllAsync(p.RequestOptions.Paging)); - - [HttpPost("", Name = "Create")] - public Task CreateAsync() - => _webApi.PostAsync(Request, p => _service.AddEmployeeAsync(p.Validate()), - statusCode: HttpStatusCode.Created, locationUri: e => new Uri($"api/employees/{e.Id}", UriKind.RelativeOrAbsolute)); - - [HttpPut("{id}", Name = "Update")] - public Task UpdateAsync(Guid id) - => _webApi.PutAsync(Request, p => _service.UpdateEmployeeAsync(p.Validate(), id)); - - [HttpPatch("{id}", Name = "Patch")] - public Task PatchAsync(Guid id) - => _webApi.PatchAsync(Request, get: _ => _service.GetEmployeeAsync(id), put: p => _service.UpdateEmployeeAsync(p.Validate(), id)); - - [HttpDelete("{id}", Name = "Delete")] - public Task DeleteAsync(Guid id) - => _webApi.DeleteAsync(Request, _ => _service.DeleteEmployeeAsync(id)); -``` - -
- -### Azure HTTP-triggered Function example - -The following demonstrates usage when creating an Azure HTTP-triggered Function (essentially the same `_webApi` invocation code to `Controller` above): - -``` csharp -public class EmployeeFunction -{ - private readonly WebApi _webApi; - private readonly EmployeeService _service; - - public EmployeeFunction(WebApi webApi, EmployeeService service) - { - _webApi = webApi; - _service = service; - } - - [FunctionName("Get")] - public Task GetAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/employees/{id}")] HttpRequest request, Guid id) - => _webApi.GetAsync(request, _ => _service.GetEmployeeAsync(id)); - - [FunctionName("GetAll")] - public Task GetAllAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/employees")] HttpRequest request) - => _webApi.GetAsync(request, p => _service.GetAllAsync(p.RequestOptions.Paging)); - - [FunctionName("Create")] - public Task CreateAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "api/employees")] HttpRequest request) - => _webApi.PostAsync(request, p => _service.AddEmployeeAsync(p.Validate()), - statusCode: HttpStatusCode.Created, locationUri: e => new Uri($"api/employees/{e.Id}", UriKind.RelativeOrAbsolute)); - - [FunctionName("Update")] - public Task UpdateAsync([HttpTrigger(AuthorizationLevel.Function, "put", Route = "api/employees/{id}")] HttpRequest request, Guid id) - => _webApi.PutAsync(request, p => _service.UpdateEmployeeAsync(p.Validate(), id)); - - [FunctionName("Patch")] - public Task PatchAsync([HttpTrigger(AuthorizationLevel.Function, "patch", Route = "api/employees/{id}")] HttpRequest request, Guid id) - => _webApi.PatchAsync(request, get: _ => _service.GetEmployeeAsync(id), put: p => _service.UpdateEmployeeAsync(p.Validate(), id)); - - [FunctionName("Delete")] - public Task DeleteAsync([HttpTrigger(AuthorizationLevel.Function, "delete", Route = "api/employees/{id}")] HttpRequest request, Guid id) - => _webApi.DeleteAsync(request, _ => _service.DeleteEmployeeAsync(id)); -``` - -
- -## WebApiPublish - -The [`WebApiPublisher`](./WebApis/WebApiPublisher.cs) class should be leveraged for _fire-and-forget_ style APIs, where the message is received, validated and then published as an event for out-of-process decoupled processing. - -The `WebApiPublish` extends (inherits) [`WebApiBase`](./WebApis/WebApiBase.cs) that provides the base `RunAsync` method described [above](#WebApi). - -The `WebApiPublisher` constructor takes an [`IEventPublisher`](../CoreEx/Events/IEventPublisher.cs) that is responsible for formatting and sending the event to the requisite messaging platform. See [Events](./CoreEx/Events) for more information regarding events. - -
- -### Supported HTTP methods - -A publish should be performed using an HTTP `POST` and as such this is the only HTTP method supported. The `WebApiPublish` provides the following overloads depending on need. - -HTTP | Method | Description --|-|- -`POST` | `PublishAsync()` | Publish a single message/event with `TValue` being the request content body. -`POST` | `PublishValueAsync()` | Publish a single message/event with `TValue` being the specified value (preivously deserialized). -`POST` | `PublishAsync()` | Publish a single message/event with `TValue` being the request content body mapping to the specified event value type. -`POST` | `PublishValueAsync()` | Publish a single message/event with `TValue` being the specified value (preivously deserialized) mapping to the specified event value type. -- | - -`POST` | `PublishCollectionAsync()` | Publish zero or more message/event(s) from the `TColl` collection with an item type of `TItem` being the request content body. -`POST` | `PublishCollectionValueAsync()` | Publish zero or more message/event(s) from the `TColl` collection with an item type of `TItem` being the specified value (preivously deserialized). -`POST` | `PublishCollectionAsync()` | Publish zero or more message/event(s) from the `TColl` collection with an item type of `TItem` being the request content body mapping to the specified event value type. -`POST` | `PublishCollectionValueAsync()` | Publish zero or more message/event(s) from the `TColl` collection with an item type of `TItem` being the specified value (preivously deserialized) mapping to the specified event value type. - -
- -### Argument - -Depending on the overload used (as defined above), an optional _argument_ can be specified that provides additional opportunities to configure and add additional logic into the underlying publishing orchestration. - -The following argurment types are supported: -- [`WebApiPublisherArgs`](./WebApis/WebApiPublisherArgsT.cs) - single message with no mapping. -- [`WebApiPublisherArgs`](./WebApis/WebApiPublisherArgsT2.cs) - single message _with_ [mapping](https://github.com/Avanade/CoreEx/tree/main/src/CoreEx/Mapping). -- [`WebApiPublisherCollectionArgs`](./WebApis/WebApiPublisherCollectionArgsT.cs) - collection of messages with no mapping. -- [`WebApiPublisherCollectionArgs`](./WebApis/WebApiPublisherCollectionArgsT2.cs) - collection of messages _with_ [mapping](https://github.com/Avanade/CoreEx/tree/main/src/CoreEx/Mapping). - -The arguments will have the following properties depending on the supported functionality. The sequence defines the order in which each of the properties is enacted (orchestrated) internally. Where a failure or exception occurs then the execution will be aborted and the corresponding `IActionResult` returned (including the likes of logging etc. where applicable). - -Property | Description | Sequence --|- -`EventName` | The event destintion name (e.g. Queue or Topic name) where applicable. | N/A -`EventTemplate` | The [`EventData`](../CoreEx/Events/EventData.cs) template to be used to create the message/event. | N/A -`StatusCode` | The resulting status code where successful. Defaults to `204-Accepted`. | N/A -`OperationType` | The [`OperationType`](../CoreEx/OperationType.cs). Defaults to `OperationType.Unspecified`. | N/A -`MaxCollectionSize` | The maximum collection size allowed/supported (where applicable). | 1 -`OnBeforeValidateAsync` | The function to be invoked before the request value is validated; opportunity to modify contents. | 2 -`Validator` | The `IValidator` to validate the request value. | 3 -`OnBeforeEventAsync` | The function to be invoked after validation / before event; opportunity to modify contents. | 4 -`Mapper` | The `IMapper` override (where applicable). | 5 -`OnEvent` | The action to be invoked once converted to an [`EventData`](../CoreEx/Events/EventData.cs); opportunity to modify contents. | 6 -`CreateSuccessResult` | The function to be invoked to create/override the success `IActionResult`. Defaults to returning specified `StatusCode`. | 7 - -
- -### Request - -A request body is mandatory and must be serialized JSON as per the specified generic types. - -
- -### Response - -The response HTTP status code is `204-Accepted` (default) with no content. This can be overridden using the arguments `StatusCode` property. - -
- -### Azure HTTP-triggered Function example - -The following demonstrates usage when creating an Azure HTTP-triggered Function: - -``` csharp -public class HttpTriggerQueueVerificationFunction -{ - private readonly WebApiPublisher _webApiPublisher; - private readonly HrSettings _settings; - - public HttpTriggerQueueVerificationFunction(WebApiPublisher webApiPublisher, HrSettings settings) - { - _webApiPublisher = webApiPublisher; - _settings = settings; - } - - public Task RunAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "employee/verify")] HttpRequest request) - => _webApiPublisher.PublishAsync(request, new WebApiPublisherArgs(_settings.VerificationQueueName) { Validator = new EmployeeVerificationValidator().Wrap() }); -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApiOptions.cs b/src/CoreEx.AspNetCore/WebApiOptions.cs new file mode 100644 index 00000000..e58bdfe0 --- /dev/null +++ b/src/CoreEx.AspNetCore/WebApiOptions.cs @@ -0,0 +1,19 @@ +namespace CoreEx.AspNetCore; + +/// +/// Represents the options. +/// +public sealed class WebApiOptions : WebApiOptionsBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The . + public WebApiOptions(HttpRequest httpRequest) : base(httpRequest) { } + + /// + /// Initializes a new instance of the class from an existing instance. + /// + /// The . + public WebApiOptions(WebApiOptionsBase options) : base(options) { } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApiRequestOptions.cs b/src/CoreEx.AspNetCore/WebApiRequestOptions.cs new file mode 100644 index 00000000..bd584968 --- /dev/null +++ b/src/CoreEx.AspNetCore/WebApiRequestOptions.cs @@ -0,0 +1,66 @@ +namespace CoreEx.AspNetCore; + +/// +/// Represents the request options. +/// +/// The request . +public sealed class WebApiRequestOptions : WebApiOptionsBase, IWebApiRequestOptions +{ + private static readonly LText _concurrencyMessage = new($"{typeof(WebApiOptionsBase).FullName}.IfMatchRequired" , "A concurrency error occurred; an ETag is required either as an IF-MATCH header (preferred) or specified within the request body (where supported)."); + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The deserialized request value. + public WebApiRequestOptions(HttpRequest httpRequest, TRequest? value) : base(httpRequest) + { + ValueOrDefault = value; + + // Override the ETag where specified as a request IF-MATCH header. + if (value is not null && ETag is not null && value is IETag etag) + etag.ETag = ETag; + } + + /// + /// Initializes a new instance of the class from an existing instance. + /// + /// The . + /// The deserialized request value. + public WebApiRequestOptions(WebApiOptionsBase options, TRequest? value) : base(options) + { + ValueOrDefault = value; + + // Override the ETag where specified as a request IF-MATCH header. + if (value is not null && ETag is not null && value is IETag etag) + etag.ETag = ETag; + } + + /// + public TRequest? ValueOrDefault { get; } + + /// + [NotNull] + public TRequest Value => ValueOrDefault.Required(); + + /// + protected internal override Result Verify() => VerifyRequest(this, ValueOrDefault).Then(() => base.Verify()); + + /// + /// Enables standard verification of the , such as ensuring an ETag is provided for PUT and PATCH requests. + /// + /// The . + /// The . + /// The request value. + /// The of the verification. + internal static Result VerifyRequest(TOptions options, TRequest? value) where TOptions : WebApiOptionsBase, IWebApiRequestOptions + { + if (HttpMethods.IsPut(options.Request.Method) || HttpMethods.IsPatch(options.Request.Method)) + { + if (value is IETag etag && etag.ETag is null) + return Result.Fail(new ConcurrencyException(_concurrencyMessage).WithStatusCode(HttpStatusCode.PreconditionRequired)); + } + + return Result.Success; + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs b/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs new file mode 100644 index 00000000..547762e7 --- /dev/null +++ b/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs @@ -0,0 +1,84 @@ +namespace CoreEx.AspNetCore; + +/// +/// Represents the request and response options. +/// +/// The request . +/// The response . +public sealed class WebApiRequestResponseOptions : WebApiOptionsBase, IWebApiRequestOptions, IWebApiResponseOptions +{ + private Func? _locationUri; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The deserialized request value. + public WebApiRequestResponseOptions(HttpRequest httpRequest, TRequest? value) : base(httpRequest) + { + ValueOrDefault = value; + + // Override the ETag where specified as a request IF-MATCH header. + if (value is not null && ETag is not null && value is IETag etag) + etag.ETag = ETag; + } + + /// + /// Initializes a new instance of the class from an existing instance. + /// + /// The . + /// The deserialized request value. + public WebApiRequestResponseOptions(WebApiOptionsBase options, TRequest? value) : base(options) + { + ValueOrDefault = value; + + // Override the ETag where specified as a request IF-MATCH header. + if (value is not null && ETag is not null && value is IETag etag) + etag.ETag = ETag; + + // Override the location function; + if (options is IWebApiResponseOptions ro) + _locationUri = ro.LocationUri; + } + + /// + public TRequest? ValueOrDefault { get; } + + /// + [NotNull] + public TRequest Value => ValueOrDefault.Required(); + + /// + Func? IWebApiResponseOptions.LocationUri => _locationUri; + + /// + /// Sets (overrides) the response to use the function. + /// + /// The function to return the representing the location of the resource. + /// The to support fluent-style method-chaining. + public WebApiRequestResponseOptions WithLocationUri(Func? locationUri) + { + _locationUri = locationUri; + return this; + } + + /// + Uri? IWebApiResponseOptions.CreateLocationUri(object? value) + { + // Check if there is a valued-version and invoke. + if (_locationUri is not null) + { + if (value is null) + throw new InvalidOperationException($"The resulting value cannot be null when {nameof(LocationUri)} is specified."); + + var val = (TResponse)value; + return _locationUri(val); + } + + // Invoke the default parameterless version. + return LocationUri?.Invoke(); + } + + /// + protected internal override Result Verify() => WebApiRequestOptions.VerifyRequest(this, ValueOrDefault).Then(() => base.Verify()); +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApiResponseOptions.cs b/src/CoreEx.AspNetCore/WebApiResponseOptions.cs new file mode 100644 index 00000000..252095d5 --- /dev/null +++ b/src/CoreEx.AspNetCore/WebApiResponseOptions.cs @@ -0,0 +1,42 @@ +namespace CoreEx.AspNetCore; + +/// +/// Represents the response options. +/// +/// The response . +/// The . +public sealed class WebApiResponseOptions(HttpRequest httpRequest) : WebApiOptionsBase(httpRequest), IWebApiResponseOptions +{ + private Func? _locationUri; + + /// + Func? IWebApiResponseOptions.LocationUri => _locationUri; + + /// + /// Sets (overrides) the response to use the function. + /// + /// The function to return the representing the location of the resource. + /// The to support fluent-style method-chaining. + public WebApiResponseOptions WithLocationUri(Func? locationUri) + { + _locationUri = locationUri; + return this; + } + + /// + Uri? IWebApiResponseOptions.CreateLocationUri(object? value) + { + // Check if there is a valued-version and invoke. + if (_locationUri is not null) + { + if (value is null) + throw new InvalidOperationException($"The resulting value cannot be null when {nameof(LocationUri)} is specified."); + + var val = (TResponse)value; + return _locationUri(val); + } + + // Invoke the default parameterless version. + return LocationUri?.Invoke(); + } +} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/AcceptsBodyAttribute.cs b/src/CoreEx.AspNetCore/WebApis/AcceptsBodyAttribute.cs deleted file mode 100644 index 1813c122..00000000 --- a/src/CoreEx.AspNetCore/WebApis/AcceptsBodyAttribute.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Net.Mime; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// An attribute that specifies the expected request body that the action/operation accepts and the supported request content types. - /// - /// The is used to enable Swagger/Swashbuckle generated documentation where the operation does not explicitly define the body as a method parameter; i.e. via . - /// The body . - /// The body content type(s). Defaults to . - /// - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public sealed class AcceptsBodyAttribute(Type type, params string[] contentTypes) : Attribute - { - /// - /// Gets the body . - /// - public Type BodyType { get; } = type.ThrowIfNull(nameof(type)); - - /// - /// Gets the body content type(s). - /// - public string[] ContentTypes { get; } = contentTypes.Length == 0 ? [MediaTypeNames.Application.Json] : contentTypes; - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/AcceptsBodyOperationFilter.cs b/src/CoreEx.AspNetCore/WebApis/AcceptsBodyOperationFilter.cs deleted file mode 100644 index b94e5175..00000000 --- a/src/CoreEx.AspNetCore/WebApis/AcceptsBodyOperationFilter.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerGen; -using System.Linq; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// A Swagger/Swashbuckle to infer the from the specification of the . - /// - /// The must be added when registering services (DI) during application startup; example as follows: - /// - /// services.AddSwaggerGen(c => c.OperationFilter<AcceptsBodyOperationFilter>()); - /// - public sealed class AcceptsBodyOperationFilter : IOperationFilter - { - /// - /// Applies the filter. - /// - /// The . - /// The . - public void Apply(OpenApiOperation operation, OperationFilterContext context) - { - // Inspired by: https://stackoverflow.com/questions/66171439/swagger-parameter-on-method-with-parameters-from-body-but-no-model-binding - var att = context.ApiDescription.CustomAttributes().OfType().FirstOrDefault(); - if (att == null) - return; - - var schema = context.SchemaGenerator.GenerateSchema(att.BodyType, context.SchemaRepository); - operation.RequestBody = new OpenApiRequestBody(); - att.ContentTypes.ForEach(item => operation.RequestBody.Content.Add(item, new OpenApiMediaType { Schema = schema })); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs b/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs deleted file mode 100644 index 0884fd82..00000000 --- a/src/CoreEx.AspNetCore/WebApis/ExtendedContentResult.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.AspNetCore.Http; -using CoreEx.Entities; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents an extended that enables customization of the . - /// - public class ExtendedContentResult : ContentResult, IExtendedActionResult - { - /// - /// Gets or sets the . - /// - /// Defaults to the . - /// Note: These are only written to the headers where the is considered successful; i.e. is in the 200-299 range. - public MessageItemCollection? Messages { get; set; } = ExecutionContext.HasCurrent && ExecutionContext.Current.HasMessages ? ExecutionContext.Current.Messages : null; - - /// - [JsonIgnore] - public Func? BeforeExtension { get; set; } - - /// - [JsonIgnore] - public Func? AfterExtension { get; set; } - - /// - public override async Task ExecuteResultAsync(ActionContext context) - { - if (StatusCode >= 200 || StatusCode <= 299) - context.HttpContext.Response.Headers.AddMessages(Messages); - - if (BeforeExtension != null) - await BeforeExtension(context.HttpContext.Response).ConfigureAwait(false); - - await base.ExecuteResultAsync(context).ConfigureAwait(false); - - if (AfterExtension != null) - await AfterExtension(context.HttpContext.Response).ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs b/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs deleted file mode 100644 index 4e903f74..00000000 --- a/src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.AspNetCore.Http; -using CoreEx.Entities; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Net; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents an extended that enables customization of the . - /// - /// The status code value. - public class ExtendedStatusCodeResult(int statusCode) : StatusCodeResult(statusCode), IExtendedActionResult - { - /// - /// Initializes a new instance of the class with the specified . - /// - /// The . - public ExtendedStatusCodeResult(HttpStatusCode statusCode) : this((int)statusCode) { } - - /// - /// Gets or sets the . - /// - public Uri? Location { get; set; } - - /// - /// Gets or sets the . - /// - /// Defaults to the . - /// Note: These are only written to the headers where the is considered successful; i.e. is in the 200-299 range. - public MessageItemCollection? Messages { get; set; } = ExecutionContext.HasCurrent && ExecutionContext.Current.HasMessages ? ExecutionContext.Current.Messages : null; - - /// - [JsonIgnore] - public Func? BeforeExtension { get; set; } - - /// - [JsonIgnore] - public Func? AfterExtension { get; set; } - - /// - public override async Task ExecuteResultAsync(ActionContext context) - { - if (StatusCode >= 200 || StatusCode <= 299) - context.HttpContext.Response.Headers.AddMessages(Messages); - - if (Location != null) - context.HttpContext.Response.GetTypedHeaders().Location = Location; - - if (BeforeExtension != null) - await BeforeExtension(context.HttpContext.Response).ConfigureAwait(false); - - await base.ExecuteResultAsync(context).ConfigureAwait(false); - - if (AfterExtension != null) - await AfterExtension(context.HttpContext.Response).ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/IExtendedActionResult.cs b/src/CoreEx.AspNetCore/WebApis/IExtendedActionResult.cs deleted file mode 100644 index 0c1fc618..00000000 --- a/src/CoreEx.AspNetCore/WebApis/IExtendedActionResult.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Extends an to enable customization of the resulting using the and functions. - /// - public interface IExtendedActionResult : IActionResult - { - /// - /// Gets or sets the function to perform the extended customization. - /// - [JsonIgnore] - Func? BeforeExtension { get; set; } - - /// - /// Gets or sets the function to perform the extended customization. - /// - [JsonIgnore] - Func? AfterExtension { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/IWebApiPublisherArgs.cs b/src/CoreEx.AspNetCore/WebApis/IWebApiPublisherArgs.cs deleted file mode 100644 index f264fb9f..00000000 --- a/src/CoreEx.AspNetCore/WebApis/IWebApiPublisherArgs.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using CoreEx.Hosting.Work; -using CoreEx.Mapping; -using CoreEx.Results; -using CoreEx.Validation; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Enables the arguments. - /// - /// The request JSON content value . - /// The (where different then a will be required). - public interface IWebApiPublisherArgs - { - /// - /// Indicates whether the and are the same . - /// - internal bool AreSameType => typeof(TValue) == typeof(TEventValue); - - /// - /// Gets or sets the optional event destintion name (e.g. Queue or Topic name). - /// - /// Will leverage either or depending on whether name is specified or not. - string? EventName { get; } - - /// - /// Gets or sets the optional to use as a template when instantiating the for publishing. - /// - /// Will use the constructor to copy from the template. - EventData? EventTemplate { get; } - - /// - /// Gets or sets the where successful. - /// - /// Defaults to . - HttpStatusCode StatusCode { get; } - - /// - /// Indicates whether the is required. - /// - bool ValueIsRequired { get; } - - /// - /// Gets or sets the optional validator. - /// - IValidator? Validator { get; } - - /// - /// Gets or sets the . - /// - /// Defaults to . - OperationType OperationType { get; } - - /// - /// Gets or sets the on before validation function. - /// - /// Enables the likes of security, value modification, etc., before validation. The will allow failures and alike to be returned where applicable. - Func, CancellationToken, Task>? OnBeforeValidationAsync { get; } - - /// - /// Gets or sets the after validation / on before event function. - /// - /// Enables the likes of security, value modification, etc., after validation. The will allow failures and alike to be returned where applicable. - Func, CancellationToken, Task>? OnBeforeEventAsync { get; } - - /// - /// Gets or sets the modifier function. - /// - /// Enables the corresponding to be modified prior to publish beyond the application. - Action? OnEvent { get; } - - /// - /// Gets or sets the to override. - /// - /// Where null the will be used to get the corresponding instance to perform the underlying mapping. - IMapper? Mapper { get; } - - /// - /// Gets or sets the function to override the creation of the success . - /// - /// Defaults to a using the defined . - Func>? CreateSuccessResultAsync { get; } - - /// - /// Gets or sets the function to create the for the . - /// - /// - /// Where enabling the likes of the asynchronous request-response pattern then the represents the which is the unique identifier for the work instance and is therefore required. - /// This will not be invoked automatically where the is overridden. - Func, EventData, Uri>? CreateLocation { get; } - - /// - /// Gets or sets the function to create the . - /// - /// The and will be overridden by the equivalents after creation to ensure consistencey; therefore, these properties need - /// not be set during create. The will be set to the where null, so also does not need to be explicitly set. - /// An will occur where this is set and the corresponding is null. The combination of the two enables - /// the automatic create () of tracking to enable the likes of the asynchronous request-response pattern. - Func? CreateWorkStateArgs { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/IWebApiPublisherCollectionArgs.cs b/src/CoreEx.AspNetCore/WebApis/IWebApiPublisherCollectionArgs.cs deleted file mode 100644 index ed357fc0..00000000 --- a/src/CoreEx.AspNetCore/WebApis/IWebApiPublisherCollectionArgs.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using CoreEx.Events; -using CoreEx.Mapping; -using CoreEx.Results; -using CoreEx.Validation; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Enables the collection-based arguments. - /// - /// The request JSON collection . - /// The collection item . - /// The -equivalent (where different then a will be required). - public interface IWebApiPublisherCollectionArgs where TColl : IEnumerable - { - /// - /// Indicates whether the and are the same . - /// - internal bool AreSameType => typeof(TItem) == typeof(TEventItem); - - /// - /// Gets or sets the optional event destintion name (e.g. Queue or Topic name). - /// - /// Will leverage either or depending on whether name is specified or not. - string? EventName { get; } - - /// - /// Gets or sets the optional to use as a template when instantiating the for publishing. - /// - /// Will use the constructor to copy from the template. - EventData? EventTemplate { get; } - - /// - /// Gets or sets the where successful. - /// - /// Defaults to . - HttpStatusCode StatusCode { get; } - - /// - /// Gets or sets the maximum collection size. - /// - /// Defaults to . - int? MaxCollectionSize { get; } - - /// - /// Gets or sets the optional validator - /// - IValidator? Validator { get; } - - /// - /// Gets or sets the . - /// - /// Defaults to . - OperationType OperationType { get; } - - /// - /// Gets or sets the on before validation modifier function. - /// - /// Enables the value to be modified before validation. The will allow failures and alike to be returned where applicable. - Func, CancellationToken, Task>? OnBeforeValidationAsync { get; } - - /// - /// Gets or sets the after validation / on before event modifier function. - /// - /// Enables the value to be modified after validation. The will allow failures and alike to be returned where applicable. - Func, CancellationToken, Task>? OnBeforeEventAsync { get; } - - /// - /// Gets or sets the modifier function. - /// - /// Enables the corresponding to be modified prior to publish. - Action? OnEvent { get; } - - /// - /// Gets or sets the to override. - /// - /// Where null the will be used to get the corresponding instance to perform the underlying mapping. - IMapper? Mapper { get; } - - /// - /// Gets or sets the function to override the creation of the success . - /// - /// Defaults to a using the defined . - Func>? CreateSuccessResultAsync { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/PagingAttribute.cs b/src/CoreEx.AspNetCore/WebApis/PagingAttribute.cs deleted file mode 100644 index abd5c658..00000000 --- a/src/CoreEx.AspNetCore/WebApis/PagingAttribute.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// An attribute that specifies that the action/operation supports (not explicitly defined as a parameter). - /// - /// The is used to enable Swagger/Swashbuckle generated documentation where the operation does not explicitly define the as a method parameter. - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public class PagingAttribute : Attribute { } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/PagingOperationFilter.cs b/src/CoreEx.AspNetCore/WebApis/PagingOperationFilter.cs deleted file mode 100644 index 9367ef0c..00000000 --- a/src/CoreEx.AspNetCore/WebApis/PagingOperationFilter.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Http; -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerGen; -using System.Linq; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// A Swagger/Swashbuckle to add the parameters from the specification of the . - /// - /// The must be added when registering services (DI) during application startup; example as follows: - /// - /// services.AddSwaggerGen(c => c.OperationFilter<PagingOperationFilter>(PagingOperationFilterFields.SkipTakeCount)); - /// - /// - public class PagingOperationFilter : IOperationFilter - { - /// - /// Initializes a new instance of the class with a default of . - /// - public PagingOperationFilter() { } - - /// - /// Initializes a new instance of the class with the selected . - /// - /// The . - public PagingOperationFilter(PagingOperationFilterFields fields) => Fields = fields; - - /// - /// Gets the to apply. - /// - public PagingOperationFilterFields Fields { get; } = PagingOperationFilterFields.SkipTakeCount; - - /// - /// Applies the filter. - /// - /// The . - /// The . - public void Apply(OpenApiOperation operation, OperationFilterContext context) - { - var att = context.ApiDescription.CustomAttributes().OfType().FirstOrDefault(); - if (att == null) - return; - - if (Fields.HasFlag(PagingOperationFilterFields.Skip)) - operation.Parameters.Add(CreateParameter(HttpConsts.PagingArgsSkipQueryStringName, "The specified number of elements in a sequence to bypass.", "number", "int64")); - - if (Fields.HasFlag(PagingOperationFilterFields.Page)) - operation.Parameters.Add(CreateParameter(HttpConsts.PagingArgsPageQueryStringName, "The page number for the elements in a sequence to select.", "number", "int64")); - - if (Fields.HasFlag(PagingOperationFilterFields.Token)) - operation.Parameters.Add(CreateParameter(HttpConsts.PagingArgsTokenQueryStringName, "The token to get the next page of elements.", "string")); - - if (Fields.HasFlag(PagingOperationFilterFields.Take)) - operation.Parameters.Add(CreateParameter(HttpConsts.PagingArgsTakeQueryStringName, "The specified number of contiguous elements from the start of a sequence.", "number", "int64")); - - if (Fields.HasFlag(PagingOperationFilterFields.Size)) - operation.Parameters.Add(CreateParameter(HttpConsts.PagingArgsSizeQueryStringName, "The page size being the specified number of contiguous elements from the start of a sequence.", "number", "int64")); - - if (Fields.HasFlag(PagingOperationFilterFields.Count)) - operation.Parameters.Add(CreateParameter(HttpConsts.PagingArgsCountQueryStringName, "Indicates whether to get the total count when performing the underlying query.", "boolean")); - } - - /// - /// Create the parameter definition. - /// - internal static OpenApiParameter CreateParameter(string name, string description, string typeName, string? format = null) => new() - { - Name = name, - Description = description, - In = ParameterLocation.Query, - Required = false, - Schema = new OpenApiSchema { Type = typeName, Format = format } - }; - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/PagingOperationFilterFields.cs b/src/CoreEx.AspNetCore/WebApis/PagingOperationFilterFields.cs deleted file mode 100644 index ae802a7e..00000000 --- a/src/CoreEx.AspNetCore/WebApis/PagingOperationFilterFields.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Http; -using System; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Provides the fields. - /// - [Flags] - public enum PagingOperationFilterFields - { - /// - /// Indicates to include field (named ). - /// - Skip = 1, - - /// - /// Indicates to include field (named ). - /// - Take = 2, - - /// - /// Indicates to include field (named ). - /// - Page = 4, - - /// - /// Indicates to include field (named ). - /// - Size = 8, - - /// - /// Indicates to include field (named ). - /// - Count = 16, - - /// - /// Indicates to include field (named ). - /// - Token = 32, - - /// - /// Indicates to include and fields. - /// - SkipTake = Skip | Take, - - /// - /// Indicates to include , and fields. - /// - SkipTakeCount = Skip | Take | Count, - - /// - /// Indicates to include and fields. - /// - PageSize = Page | Size, - - /// - /// Indicates to include , and fields. - /// - PageSizeCount = Page | Size | Count, - - /// - /// Indicates to include and fields. - /// - TokenTake = Token | Take, - - /// - /// Indicates to include , and fields. - /// - TokenTakeCount = Token | Size | Count, - - /// - /// Indicates to include all fields. - /// - All = Skip | Take | Page | Size | Count | Token - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/QueryAttribute.cs b/src/CoreEx.AspNetCore/WebApis/QueryAttribute.cs deleted file mode 100644 index 5eee176a..00000000 --- a/src/CoreEx.AspNetCore/WebApis/QueryAttribute.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// An attribute that specifies that the action/operation supports (not explicitly defined as a parameter). - /// - /// The is used to enable Swagger/Swashbuckle generated documentation where the operation does not explicitly define the as a method parameter. - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public class QueryAttribute : Attribute { } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs b/src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs deleted file mode 100644 index bcd4c4e5..00000000 --- a/src/CoreEx.AspNetCore/WebApis/QueryOperationFilter.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Http; -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerGen; -using System.Linq; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// A Swagger/Swashbuckle to add the parameters from the specification of the . - /// - /// The must be added when registering services (DI) during application startup; example as follows: - /// - /// services.AddSwaggerGen(c => c.OperationFilter<PagingOperationFilter>(PagingOperationFilterFields.SkipTakeCount)); - /// - /// - public class QueryOperationFilter : IOperationFilter - { - /// - /// Initializes a new instance of the class with a default of . - /// - public QueryOperationFilter() { } - - /// - /// Initializes a new instance of the class with the selected . - /// - /// The . - public QueryOperationFilter(QueryOperationFilterFields fields) => Fields = fields; - - /// - /// Gets the to apply. - /// - public QueryOperationFilterFields Fields { get; } = QueryOperationFilterFields.FilterAndOrderby; - - /// - /// Applies the filter. - /// - /// The . - /// The . - public void Apply(OpenApiOperation operation, OperationFilterContext context) - { - var att = context.ApiDescription.CustomAttributes().OfType().FirstOrDefault(); - if (att == null) - return; - - if (Fields.HasFlag(QueryOperationFilterFields.Filter)) - operation.Parameters.Add(PagingOperationFilter.CreateParameter(HttpConsts.QueryArgsFilterQueryStringName, "The basic dynamic OData-like filter statement.", "string", null)); - - if (Fields.HasFlag(QueryOperationFilterFields.OrderBy)) - operation.Parameters.Add(PagingOperationFilter.CreateParameter(HttpConsts.QueryArgsOrderByQueryStringName, "The basic dynamic OData-like order-by statement.", "string", null)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/QueryOperationFilterFields.cs b/src/CoreEx.AspNetCore/WebApis/QueryOperationFilterFields.cs deleted file mode 100644 index f72e7091..00000000 --- a/src/CoreEx.AspNetCore/WebApis/QueryOperationFilterFields.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Http; -using System; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Provides the fields. - /// - [Flags] - public enum QueryOperationFilterFields - { - /// - /// Indicates to include the field (named ). - /// - Filter = 1, - - /// - /// Indicates to include the field (named ). - /// - OrderBy = 2, - - /// - /// Indicates to include both the and . - /// - FilterAndOrderby = Filter | OrderBy - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/ReferenceDataContentWebApi.cs b/src/CoreEx.AspNetCore/WebApis/ReferenceDataContentWebApi.cs deleted file mode 100644 index 9911a55a..00000000 --- a/src/CoreEx.AspNetCore/WebApis/ReferenceDataContentWebApi.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using CoreEx.Json; -using CoreEx.Json.Merge; -using CoreEx.RefData; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Provides the core (, , and ) Web API execution encapsulation that uses the - /// to allow types to serialize contents. - /// - /// The . - /// The . - /// The . - /// The . - /// The ; defaults where not specified. - /// The to support the operations. - public class ReferenceDataContentWebApi(ExecutionContext executionContext, SettingsBase settings, IReferenceDataContentJsonSerializer jsonSerializer, ILogger logger, WebApiInvoker? invoker = null, IJsonMergePatch? jsonMergePatch = null) - : WebApi(executionContext, settings, jsonSerializer, logger, invoker, jsonMergePatch) { } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs b/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs deleted file mode 100644 index 405be475..00000000 --- a/src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.AspNetCore.Http; -using CoreEx.Entities; -using CoreEx.Json; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Mime; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents an with a JSON serialized value. - /// - /// This contains extended functionality to manage the setting of response headers related to , and . - /// The and will return the value as-is where it is an instance of ; i.e. will bypass all related functionality. - public sealed class ValueContentResult : ExtendedContentResult - { - /// - /// Initializes a new instance of the class. - /// - /// The value serialized as JSON content. - /// The . - /// The related . - /// The related . - /// The . - public ValueContentResult(string content, HttpStatusCode statusCode, string? etag, PagingResult? pagingResult, Uri? location) - { - Content = content; - ContentType = MediaTypeNames.Application.Json; - StatusCode = (int)statusCode; - ETag = etag; - PagingResult = pagingResult; - Location = location; - } - - /// - /// Gets or sets the value. - /// - public string? ETag { get; set; } - - /// - /// Gets or sets the corresponding (where the originating value was an ). - /// - public PagingResult? PagingResult { get; set; } - - /// - /// Gets or sets the . - /// - public Uri? Location { get; set; } - - /// - /// Gets or sets the for the . - /// - public TimeSpan? RetryAfter { get; set; } - - /// - public override Task ExecuteResultAsync(ActionContext context) - { - context.HttpContext.Response.Headers.AddPagingResult(PagingResult); - - var headers = context.HttpContext.Response.GetTypedHeaders(); - if (ETag != null) - headers.ETag = new EntityTagHeaderValue(ETagGenerator.FormatETag(ETag), true); - - if (Location != null) - headers.Location = Location; - - if (RetryAfter is not null) - context.HttpContext.Response.Headers.Append(HeaderNames.RetryAfter, new System.Net.Http.Headers.RetryConditionHeaderValue(RetryAfter.Value).ToString()); - - return base.ExecuteResultAsync(context); - } - - /// - /// Creates the as either or as per ; unless is an instance of which will return as-is. - /// - /// The value. - /// The primary status code where there is a value. - /// The alternate status code where there is not a value (i.e. null). - /// The . - /// The . - /// Indicates whether to check for by comparing request and response values. - /// The . - /// The . - internal static IActionResult CreateResult(T value, HttpStatusCode statusCode, HttpStatusCode? alternateStatusCode, IJsonSerializer jsonSerializer, WebApiRequestOptions requestOptions, bool checkForNotModified, Uri? location) - => TryCreateValueContentResult(value, statusCode, alternateStatusCode, jsonSerializer, requestOptions, checkForNotModified, location, out var pr, out var ar) ? pr! : ar!; - - /// - /// Try and create an as either or as per ; unless is an instance of which will return as-is. - /// - /// The value. - /// The primary status code where there is a value. - /// The alternate status code where there is not a value (i.e. null). - /// The . - /// The . - /// Indicates whether to check for by comparing request and response values. - /// The . - /// The where created. - /// The alternate result where no . - /// true indicates that the was created; otherwise, false for creation. - internal static bool TryCreateValueContentResult(T value, HttpStatusCode statusCode, HttpStatusCode? alternateStatusCode, IJsonSerializer jsonSerializer, WebApiRequestOptions requestOptions, bool checkForNotModified, Uri? location, out IActionResult? primaryResult, out IActionResult? alternateResult) - { - if (value is Results.IResult) - throw new ArgumentException($"The {nameof(value)} must not implement {nameof(Results.IResult)}; the underlying {nameof(Results.IResult.Value)} must be unwrapped before invoking.", nameof(value)); - - // Where already an IActionResult then return as-is. - if (value is IActionResult iar) - { - primaryResult = iar; - alternateResult = null; - return true; - } - - object? val; - PagingResult? paging; - - // Special case when ICollectionResult, as it is the Result only that is serialized and returned. - if (value is ICollectionResult cr) - { - val = cr.Items ?? Array.Empty(); // Where there is an ICollectionResult, then there should always be a value, at least an empty array versus null. - paging = cr.Paging; - } - else - { - val = value; - paging = null; - } - - // Handle null result; generally either not-found, or no-content, depending on context. - if (val == null) - { - if (alternateStatusCode.HasValue) - { - primaryResult = null; - alternateResult = new StatusCodeResult((int)alternateStatusCode); - return false; - } - else - throw new InvalidOperationException("Function has not returned a result; no AlternateStatusCode has been configured to return."); - } - - // Where there is etag support and it is null (assumes auto-generation) then generate from the full value JSON contents as the baseline value. - var isTextSerializationEnabled = ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled; - var etag = value is IETag vetag ? vetag.ETag : null; - if (etag is null) - { - if (isTextSerializationEnabled) - ExecutionContext.Current.IsTextSerializationEnabled = false; - - etag = ETagGenerator.Generate(jsonSerializer, value); - if (value is IETag vetag2) - vetag2.ETag = etag; - } - - // Where IncludeText is selected then enable before serialization occurs. - if (requestOptions.IncludeText && ExecutionContext.HasCurrent) - ExecutionContext.Current.IsTextSerializationEnabled = true; - - // Serialize the value performing any filtering as per the request options. - string? json = null; - if (requestOptions.IncludeFields != null && requestOptions.IncludeFields.Length > 0) - jsonSerializer.TryApplyFilter(val, requestOptions.IncludeFields, out json, JsonPropertyFilter.Include); - else if (requestOptions.ExcludeFields != null && requestOptions.ExcludeFields.Length > 0) - jsonSerializer.TryApplyFilter(val, requestOptions.ExcludeFields, out json, JsonPropertyFilter.Exclude); - else - json = jsonSerializer.Serialize(val); - - // Generate the etag from the final JSON serialization and check for not-modified. - var result = GenerateETag(requestOptions, val, json, jsonSerializer); - - // Reset the text serialization flag. - if (ExecutionContext.HasCurrent) - ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled; - - // Check for not-modified and return status accordingly. - if (checkForNotModified && requestOptions.ETag is not null && result.etag == requestOptions.ETag) - { - primaryResult = null; - alternateResult = new ExtendedStatusCodeResult(HttpStatusCode.NotModified); - return false; - } - - // Create and return the ValueContentResult. - primaryResult = new ValueContentResult(result.json!, statusCode, result.etag ?? etag, paging, location); - alternateResult = null; - return true; - } - - /// - /// Establish (use existing or generate) the ETag for the value/json. - /// - /// The . - /// The value. - /// The value serialized to JSON. - /// The . - /// The etag and serialized JSON (where performed). - internal static (string? etag, string? json) GenerateETag(WebApiRequestOptions requestOptions, T value, string? json, IJsonSerializer jsonSerializer) - { - // Where not a GET or HEAD then no etag is generated; just use what we have. - if (!HttpMethods.IsGet(requestOptions.Request.Method) && !HttpMethods.IsHead(requestOptions.Request.Method)) - return (value is IETag etag ? etag.ETag : null, json); - - // Where no query string and there is an etag then that value should be leveraged as the fast-path. - if (!requestOptions.HasQueryString) - { - if (value is IETag etag && etag.ETag != null) - return (etag.ETag, json); - - // Where there is a collection then we need to generate a hash that represents the collection. - if (json is null && value is not string && value is IEnumerable coll) - { - var hasEtags = true; - var list = new List(); - - foreach (var item in coll) - { - if (item is IETag cetag && cetag.ETag is not null) - { - list.Add(cetag.ETag); - continue; - } - - // No longer can fast-path as there is no ETag. - hasEtags = false; - break; - } - - // Where fast-path then return the hash for the etag list. - if (hasEtags) - return (ETagGenerator.GenerateHash([.. list]), json); - } - } - - // Serialize and then generate a hash to represent the etag. - json ??= jsonSerializer.Serialize(value); - return (ETagGenerator.GenerateHash(requestOptions.HasQueryString ? [json, requestOptions.Request.QueryString.ToString()] : [json]), json); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApi.cs b/src/CoreEx.AspNetCore/WebApis/WebApi.cs deleted file mode 100644 index 20a4446d..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApi.cs +++ /dev/null @@ -1,884 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.AspNetCore.Http; -using CoreEx.Configuration; -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.Json; -using CoreEx.Json.Merge; -using CoreEx.Validation; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System; -using System.Net; -using System.Net.Mime; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Provides the core (, , and ) Web API execution encapsulation. - /// - /// The . - /// The . - /// The . - /// The . - /// The ; defaults where not specified. - /// The to support the operations. - public partial class WebApi(ExecutionContext executionContext, SettingsBase settings, IJsonSerializer jsonSerializer, ILogger logger, WebApiInvoker? invoker = null, IJsonMergePatch? jsonMergePatch = null) : WebApiBase(executionContext, settings, jsonSerializer, logger, invoker) - { - /// - /// Gets the . - /// - public IJsonMergePatch? JsonMergePatch { get; } = jsonMergePatch; - - /// - /// Indicates whether to convert a to the default on delete (see . - /// - public bool ConvertNotfoundToDefaultStatusCodeOnDelete { get; } = true; - - /// - /// Encapsulates the execution of an returning a corresponding . - /// - /// The . - /// The function logic to invoke. - /// The . - /// The resulting . - /// This is, and must be, used by all methods that process an to ensure that the standardized before and after, success and error, handling occurs as required. - public Task RunAsync(HttpRequest request, Func> function, OperationType operationType = OperationType.Unspecified) - => base.RunAsync(request, (p, _) => function(p), operationType, CancellationToken.None); - - /// - /// Encapsulates the execution of an returning a corresponding . - /// - /// The . - /// The function logic to invoke. - /// The . - /// The resulting . - /// The . - /// This is, and must be, used by all methods that process an to ensure that the standardized before and after, success and error, handling occurs as required. - public Task RunAsync(HttpRequest request, Func> function, OperationType operationType = OperationType.Unspecified, CancellationToken cancellationToken = default) - => base.RunAsync(request, function, operationType, cancellationToken); - - /// - /// Encapsulates the execution of an with a request JSON content value of returning a corresponding . - /// - /// The request JSON content value . - /// The . - /// The function logic to invoke. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The resulting . - public Task RunAsync(HttpRequest request, Func, Task> function, OperationType operationType = OperationType.Unspecified, - bool valueIsRequired = true, IValidator? validator = null) - => RunAsync(request, (p, ct) => function(p), operationType, valueIsRequired, validator, CancellationToken.None); - - /// - /// Encapsulates the execution of an with a request JSON content value of returning a corresponding . - /// - /// The request JSON content value . - /// The . - /// The function logic to invoke. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The . - /// The resulting . - public async Task RunAsync(HttpRequest request, Func, CancellationToken, Task> function, OperationType operationType = OperationType.Unspecified, - bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - return await RunAsync(request, async (wap, ct) => - { - var vr = await request.ReadAsJsonValueAsync(JsonSerializer, valueIsRequired, validator, ct).ConfigureAwait(false); - if (vr.IsInvalid) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vr.ValidationException!, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - return await function(new WebApiParam(wap, vr.Value), ct).ConfigureAwait(false); - }, operationType, cancellationToken, nameof(RunAsync)).ConfigureAwait(false); - } - - #region GetAsync - - /// - /// Performs a operation returning a response of . - /// - /// The result . - /// The . - /// The function to execute. - /// The where result is not null. - /// The alternate where result is null. - /// The . - /// The (either on non-null result; otherwise, a ). - public Task GetAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NotFound, OperationType operationType = OperationType.Read) - => GetAsync(request, (p, _) => function(p), statusCode, alternateStatusCode, operationType, CancellationToken.None); - - /// - /// Performs a operation returning a response of . - /// - /// The result . - /// The . - /// The function to execute. - /// The where result is not null. - /// The alternate where result is null. - /// The . - /// The . - /// The (either on non-null result; otherwise, a ). - public async Task GetAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NotFound, OperationType operationType = OperationType.Read, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsGet(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Get}' to use {nameof(GetAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var result = await function(wap, ct).ConfigureAwait(false); - return ValueContentResult.CreateResult(result, statusCode, alternateStatusCode, JsonSerializer, wap.RequestOptions, checkForNotModified: true, location: null); - }, operationType, cancellationToken, nameof(GetAsync)).ConfigureAwait(false); - } - - #endregion - - #region PostAsync - - /// - /// Performs a operation with no request content or response value. - /// - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// The optional function to set the location . - public Task PostAsync(HttpRequest request, Func function, HttpStatusCode statusCode = HttpStatusCode.OK, OperationType operationType = OperationType.Create, Func? locationUri = null) - => PostAsync(request, (p, _) => function(p), statusCode, operationType, locationUri, CancellationToken.None); - - /// - /// Performs a operation with no request content or response value. - /// - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// The optional function to set the location . - /// The . - /// The corresponding where successful. - public async Task PostAsync(HttpRequest request, Func function, HttpStatusCode statusCode = HttpStatusCode.OK, OperationType operationType = OperationType.Create, - Func? locationUri = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPost(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Post}' to use {nameof(PostAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - await function(wap, ct).ConfigureAwait(false); - return new ExtendedStatusCodeResult(statusCode) { Location = locationUri?.Invoke() }; - }, operationType, cancellationToken, nameof(PostAsync)).ConfigureAwait(false); - } - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The corresponding where successful. - public Task PostAsync(HttpRequest request, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, OperationType operationType = OperationType.Create, - bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null) - => PostAsync(request, (p, _) => function(p), statusCode, operationType, valueIsRequired, validator, locationUri, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The . - /// The corresponding where successful. - public Task PostAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, OperationType operationType = OperationType.Create, - bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - => PostInternalAsync(request, false, default!, function, statusCode, operationType, valueIsRequired, validator, locationUri, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The corresponding where successful. - public Task PostAsync(HttpRequest request, TValue value, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, OperationType operationType = OperationType.Create, - bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null) - => PostAsync(request, value, (p, _) => function(p), statusCode, operationType, valueIsRequired, validator, locationUri, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The . - /// The corresponding where successful. - public Task PostAsync(HttpRequest request, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - => PostInternalAsync(request, true, value, function, statusCode, operationType, valueIsRequired, validator, locationUri, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value. - /// - private async Task PostInternalAsync(HttpRequest request, bool useValue, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPost(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Post}' to use {nameof(PostAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, valueIsRequired, validator, cancellationToken).ConfigureAwait(false); - if (vex != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - await function(wapv!, ct).ConfigureAwait(false); - return new ExtendedStatusCodeResult(statusCode) { Location = locationUri?.Invoke() }; - }, operationType, cancellationToken, nameof(PostAsync)).ConfigureAwait(false); - } - - /// - /// Performs a operation with no request body and a response of . - /// - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// The optional function to set the location . - /// The (either on non-null result; otherwise, a ). - public Task PostAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Create, Func? locationUri = null) - => PostAsync(request, (p, _) => function(p), statusCode, alternateStatusCode, operationType, locationUri, CancellationToken.None); - - /// - /// Performs a operation with no request body and a response of . - /// - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// The optional function to set the location . - /// The . - /// The (either on non-null result; otherwise, a ). - public async Task PostAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Create, Func? locationUri = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPost(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Post}' to use {nameof(PostAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var result = await function(wap, ct).ConfigureAwait(false); - return ValueContentResult.CreateResult(result, statusCode, alternateStatusCode, JsonSerializer, wap.RequestOptions, checkForNotModified: false, location: locationUri?.Invoke(result)); - }, operationType, cancellationToken, nameof(PostAsync)).ConfigureAwait(false); - } - - /// - /// Performs a operation with a request JSON content value of and a response of . - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The (either on non-null result; otherwise, a ). - public Task PostAsync(HttpRequest request, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null) - => PostAsync(request, (p, _) => function(p), statusCode, alternateStatusCode, operationType, valueIsRequired, validator, locationUri, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and a response of . - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The . - /// The (either on non-null result; otherwise, a ). - public Task PostAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - => PostInternalAsync(request, false, default!, function, statusCode, alternateStatusCode, operationType, valueIsRequired, validator, locationUri, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and a response of . - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The (either on non-null result; otherwise, a ). - public Task PostAsync(HttpRequest request, TValue value, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null) - => PostAsync(request, value, (p, _) => function(p), statusCode, alternateStatusCode, operationType, valueIsRequired, validator, locationUri, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and a response of . - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The . - /// The (either on non-null result; otherwise, a ). - public Task PostAsync(HttpRequest request, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - => PostInternalAsync(request, true, value, function, statusCode, alternateStatusCode, operationType, valueIsRequired, validator, locationUri, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and a response of . - /// - private async Task PostInternalAsync(HttpRequest request, bool useValue, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPost(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Post}' to use {nameof(PostAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, valueIsRequired, validator, cancellationToken).ConfigureAwait(false); - if (vex != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - var result = await function(wapv!, ct).ConfigureAwait(false); - return ValueContentResult.CreateResult(result, statusCode, alternateStatusCode, JsonSerializer, wap.RequestOptions, checkForNotModified: false, location: locationUri?.Invoke(result)); - }, operationType, cancellationToken, nameof(PostAsync)).ConfigureAwait(false); - } - - #endregion - - #region PutAsync - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The corresponding where successful. - public Task PutAsync(HttpRequest request, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null) - => PutAsync(request, (p, _) => function(p), statusCode, operationType, valueIsRequired, validator, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The . - /// The corresponding where successful. - public Task PutAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - => PutInternalAsync(request, false, default!, function, statusCode, operationType, valueIsRequired, validator, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The corresponding where successful. - public Task PutAsync(HttpRequest request, TValue value, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null) - => PutInternalAsync(request, true, value, (p, _) => function(p), statusCode, operationType, valueIsRequired, validator, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The . - /// The corresponding where successful. - public Task PutAsync(HttpRequest request, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - => PutInternalAsync(request, true, value, function, statusCode, operationType, valueIsRequired, validator, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value. - /// - private async Task PutInternalAsync(HttpRequest request, bool useValue, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPut(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Put}' to use {nameof(PutAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, valueIsRequired, validator, cancellationToken).ConfigureAwait(false); - if (vex != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - await function(wapv!, ct).ConfigureAwait(false); - return new ExtendedStatusCodeResult(statusCode); - }, operationType, cancellationToken, nameof(PutAsync)).ConfigureAwait(false); - } - - /// - /// Performs a operation with a request JSON content value of and a response of . - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The (either on non-null result; otherwise, a ). - public Task PutAsync(HttpRequest request, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null) - => PutAsync(request, (p, _) => function(p), statusCode, alternateStatusCode, operationType, valueIsRequired, validator, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and a response of . - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The . - /// The (either on non-null result; otherwise, a ). - public Task PutAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - => PutInternalAsync(request, false, default!, function, statusCode, alternateStatusCode, operationType, valueIsRequired, validator, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and a response of . - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The (either on non-null result; otherwise, a ). - public Task PutAsync(HttpRequest request, TValue value, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null) - => PutAsync(request, value, (p, _) => function(p), statusCode, alternateStatusCode, operationType, valueIsRequired, validator, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and a response of . - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The . - /// The (either on non-null result; otherwise, a ). - public Task PutAsync(HttpRequest request, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - => PutInternalAsync(request, true, value, function, statusCode, alternateStatusCode, operationType, valueIsRequired, validator, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and a response of . - /// - public async Task PutInternalAsync(HttpRequest request, bool useValue, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPut(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Put}' to use {nameof(PutAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, valueIsRequired, validator, cancellationToken).ConfigureAwait(false); - if (vex != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - var result = await function(wapv!, ct).ConfigureAwait(false); - return ValueContentResult.CreateResult(result, statusCode, alternateStatusCode, JsonSerializer, wap.RequestOptions, checkForNotModified: false, location: null); - }, operationType, cancellationToken, nameof(PutAsync)).ConfigureAwait(false); - } - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The function to execute the get to retrieve the value. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The corresponding where successful. - public Task PutAsync(HttpRequest request, Func> get, Func, Task> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false) where TValue : class - => PutAsync(request, (p, _) => get(p), (p, _) => put(p), statusCode, operationType, validator, simulatedConcurrency, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The function to execute the get to retrieve the value. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The . - /// The corresponding where successful. - public Task PutAsync(HttpRequest request, Func> get, Func, CancellationToken, Task> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false, CancellationToken cancellationToken = default) where TValue : class - => PutInternalAsync(request, false, default!, get, put, statusCode, operationType, validator, simulatedConcurrency, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute the get to retrieve the value. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The corresponding where successful. - public Task PutAsync(HttpRequest request, TValue value, Func> get, Func, Task> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false) where TValue : class - => PutAsync(request, value, (p, _) => get(p), (p, _) => put(p), statusCode, operationType, validator, simulatedConcurrency, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute the get to retrieve the value. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The . - /// The corresponding where successful. - public Task PutAsync(HttpRequest request, TValue value, Func> get, Func, CancellationToken, Task> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false, CancellationToken cancellationToken = default) where TValue : class - => PutInternalAsync(request, true, value, get, put, statusCode, operationType, validator, simulatedConcurrency, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value. - /// - private async Task PutInternalAsync(HttpRequest request, bool useValue, TValue value, Func> get, Func, CancellationToken, Task> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false, CancellationToken cancellationToken = default) where TValue : class - { - request.ThrowIfNull(nameof(request)); - get.ThrowIfNull(nameof(get)); - put.ThrowIfNull(nameof(put)); - - if (!HttpMethods.IsPut(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Put}' to use {nameof(PutAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, true, validator, cancellationToken).ConfigureAwait(false); - if (vex != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - // Get the current value before we perform the update; also performing a concurrency match. - var cvalue = await get(wap, ct).ConfigureAwait(false); - var ex = cvalue == null ? (Exception)new NotFoundException() : ConcurrencyETagMatching(wap, cvalue, wapv!.Value, simulatedConcurrency); - if (ex is not null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, ex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - // Update the value. - var result = await put(wapv!, ct).ConfigureAwait(false); - return ValueContentResult.CreateResult(result, statusCode, null, JsonSerializer, wap.RequestOptions, checkForNotModified: false, location: null); - }, operationType, cancellationToken, nameof(PutAsync)).ConfigureAwait(false); - } - - /// - /// Where etags are supported or automatic concurrency then we need to make sure one was provided up-front and match. - /// - private ConcurrencyException? ConcurrencyETagMatching(WebApiParam wap, TValue getValue, TValue putValue, bool autoConcurrency) - { - var ptag = putValue as IETag; - if (ptag != null || autoConcurrency) - { - string? etag = wap.RequestOptions.ETag ?? ptag?.ETag; - if (string.IsNullOrEmpty(etag)) - return new ConcurrencyException($"An 'If-Match' header is required for an HTTP {wap.Request.Method} where the underlying entity supports concurrency (ETag)."); - - if (etag != null) - { - var gtag = getValue is IETag getag ? getag.ETag : null; - if (gtag is null) - { - var isTextSerializationEnabled = ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled; - if (isTextSerializationEnabled) - ExecutionContext.Current.IsTextSerializationEnabled = false; - - gtag = ETagGenerator.Generate(JsonSerializer, getValue); - - if (ExecutionContext.HasCurrent) - ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled; - } - - if (etag != gtag) - return new ConcurrencyException(); - } - } - - return null; - } - - #endregion - - #region DeleteAsync - - /// - /// Performs a operation. - /// - /// The . - /// The function to execute. - /// The where result is not null. - /// The . - /// The corresponding where successful. - public Task DeleteAsync(HttpRequest request, Func function, HttpStatusCode statusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Delete) - => DeleteAsync(request, (p, _) => function(p), statusCode, operationType, CancellationToken.None); - - /// - /// Performs a operation. - /// - /// The . - /// The function to execute. - /// The where result is not null. - /// The . - /// The . - /// The corresponding where successful. - public async Task DeleteAsync(HttpRequest request, Func function, HttpStatusCode statusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Delete, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsDelete(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Delete}' to use {nameof(DeleteAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - try - { - await function(wap, ct).ConfigureAwait(false); - } - catch (NotFoundException) when (ConvertNotfoundToDefaultStatusCodeOnDelete) { /* Return default status code. */ } - - return new ExtendedStatusCodeResult(statusCode); - }, operationType, cancellationToken, nameof(DeleteAsync)).ConfigureAwait(false); - } - - #endregion - - #region PatchAsync - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The function to execute the get to retrieve the value to patch into. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the patched value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The corresponding where successful. - /// Currently on the is supported. - public Task PatchAsync(HttpRequest request, Func> get, Func, Task> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false) where TValue : class - => PatchAsync(request, (p, _) => get(p), (p, _) => put(p), statusCode, operationType, validator, simulatedConcurrency, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value. - /// - /// The request JSON content value . - /// The . - /// The function to execute the get to retrieve the value to patch into. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the patched value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The . - /// The corresponding where successful. - /// Currently only the is supported. - public async Task PatchAsync(HttpRequest request, Func> get, Func, CancellationToken, Task> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false, CancellationToken cancellationToken = default) where TValue : class - { - request.ThrowIfNull(nameof(request)); - get.ThrowIfNull(nameof(get)); - put.ThrowIfNull(nameof(put)); - - if (JsonMergePatch == null) - throw new InvalidOperationException($"To use the '{nameof(PatchAsync)}' methods the '{nameof(JsonMergePatch)}' object must be passed in the constructor. Where using dependency injection consider using '{nameof(Microsoft.Extensions.DependencyInjection.IServiceCollectionExtensions.AddJsonMergePatch)}' to add and configure the supported options."); - - if (!HttpMethods.IsPatch(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Patch}' to use {nameof(PatchAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - // Make sure that the only the support content types are used. - var hct = request.GetTypedHeaders()?.ContentType?.MediaType.Value; - if (StringComparer.OrdinalIgnoreCase.Compare(hct, HttpConsts.MergePatchMediaTypeName) != 0 && StringComparer.OrdinalIgnoreCase.Compare(hct, MediaTypeNames.Application.Json) != 0) - return new ContentResult - { - StatusCode = (int)HttpStatusCode.UnsupportedMediaType, - ContentType = MediaTypeNames.Text.Plain, - Content = $"Unsupported 'Content-Type' for a PATCH; only JSON Merge Patch is supported using either: 'application/merge-patch+json' or '{MediaTypeNames.Application.Json}'." - }; - - // Retrieve the JSON content string; there must be some content of some type. - var json = await request.ReadAsBinaryDataAsync(true, ct).ConfigureAwait(false); - if (json.Exception != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, json.Exception, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - // Note: the JsonMergePatch will throw JsonMergePatchException on error which will be automatically converted to an appropriate IActionResult by the invoking RunAsync method. - var (HasChanges, Value) = await JsonMergePatch.MergeAsync(json.Content!, async (jpv, ct2) => - { - // Get the current value and perform a concurrency match before we perform the merge. - var value = await get(wap, ct2).ConfigureAwait(false); - var ex = value is null ? (Exception)new NotFoundException() : ConcurrencyETagMatching(wap, value, jpv, simulatedConcurrency); - if (ex is not null) - throw ex; - - return value; - }, ct).ConfigureAwait(false); - - // Only invoke the put function where something was *actually* changed. - if (HasChanges) - { - if (validator != null) - { - var vr = await validator.ValidateAsync(Value!, ct).ConfigureAwait(false); - if (vr.HasErrors) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vr.ToException()!, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - } - - Value = await put(new WebApiParam(wap, Value!), ct).ConfigureAwait(false); - } - - return ValueContentResult.CreateResult(Value, statusCode, null, JsonSerializer, wap.RequestOptions, checkForNotModified: false, location: null); - }, operationType, cancellationToken, nameof(PatchAsync)).ConfigureAwait(false); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs b/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs deleted file mode 100644 index 8eeba4c7..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiBase.cs +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.AspNetCore.Http; -using CoreEx.Configuration; -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.Json; -using CoreEx.Validation; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Mime; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Provides the base Web API execution encapsulation to the underlying logic in a consistent manner. - /// - /// The . - /// The . - /// The . - /// The . - /// The ; defaults where not specified. - public abstract class WebApiBase(ExecutionContext executionContext, SettingsBase settings, IJsonSerializer jsonSerializer, ILogger logger, WebApiInvoker? invoker) - { - /// - /// Gets the . - /// - public ExecutionContext ExecutionContext { get; } = executionContext.ThrowIfNull(nameof(executionContext)); - - /// - /// Gets the . - /// - public SettingsBase Settings { get; } = settings.ThrowIfNull(nameof(settings)); - - /// - /// Gets the . - /// - public IJsonSerializer JsonSerializer { get; } = jsonSerializer.ThrowIfNull(nameof(jsonSerializer)); - - /// - /// Gets the . - /// - public ILogger Logger { get; } = logger.ThrowIfNull(nameof(logger)); - - /// - /// Gets the . - /// - public WebApiInvoker Invoker { get; } = invoker ?? WebApiInvoker.Current; - - /// - /// Gets or sets the list of secondary correlation identifier names. - /// - /// Searches the for or one of the other to determine the (uses first value found in sequence). - public IEnumerable SecondaryCorrelationIdNames { get; set; } = ["x-ms-client-tracking-id"]; - - /// - /// Gets or sets the creator function used by . - /// - /// This allows an alternate serialization or handling as required. Defaults to the . - public Func ExtendedExceptionActionResultCreator { get; set; } = DefaultExtendedExceptionActionResultCreator; - - /// - /// Gets the list of correlation identifier names, being and (inclusive). - /// - /// The list of correlation identifier names. - public virtual IEnumerable GetCorrelationIdNames() - { - var list = new List([HttpConsts.CorrelationIdHeaderName]); - list.AddRange(SecondaryCorrelationIdNames); - return list; - } - - /// - /// Encapsulates the execution of an returning a corresponding . - /// - /// The . - /// The function logic to invoke. - /// The . - /// The resulting . - /// The . - /// The calling member name (uses to default). - /// This is, and must be, used by all methods that process an to ensure that the standardized before and after, success and error, handling occurs as required. - protected async Task RunAsync(HttpRequest request, Func> function, OperationType operationType = OperationType.Unspecified, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - // Invoke the "actual" function via the pluggable invoker. - ExecutionContext.OperationType = operationType; - var wap = new WebApiParam(this, new WebApiRequestOptions(request), operationType); - return await Invoker.InvokeAsync(this, wap, (_, w, ct) => function(w, ct), wap, cancellationToken, memberName).ConfigureAwait(false); - } - - /// - /// Validate the . - /// - /// The value . - /// The . - /// Indicates whether to use the ; otherwise, deserialize the JSON from the . - /// The value (already deserialized). - /// Indicates whether the value is required; will consider invalid where null. - /// The optional to validate the value (only invoked where the value is not null). - /// The . - /// The where there is an error; otherwise, for success. - protected internal async Task<(WebApiParam?, Exception?)> ValidateValueAsync(WebApiParam wap, bool useValue, TValue value, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - { - wap.ThrowIfNull(nameof(wap)); - - WebApiParam wapv; - if (useValue) - { - if (valueIsRequired && value == null) - return (null, new ValidationException($"{HttpResultExtensions.InvalidJsonMessagePrefix} Value is mandatory.")); - - if (value != null && validator != null) - { - var vr = await validator.ValidateAsync(value, cancellationToken).ConfigureAwait(false); - return (null, vr.ToException()); - } - - wapv = new WebApiParam(wap, value); - } - else - { - var vr = await wap.Request.ReadAsJsonValueAsync(JsonSerializer, valueIsRequired, validator, cancellationToken).ConfigureAwait(false); - if (vr.IsInvalid) - return (null, vr.ValidationException); - - wapv = new WebApiParam(wap, vr.Value); - } - - return (wapv, null); - } - - /// - /// Provides an opportunity to handle an unhandled exception. - /// - /// The unhandled . - /// The . - /// The . - /// The to return where handled; otherwise, null which in turn will result in it be handled by . - protected internal virtual Task OnUnhandledExceptionAsync(Exception ex, ILogger logger, CancellationToken cancellationToken) => UnhandledExceptionAsync(ex, logger, cancellationToken); - - /// - /// Gets or sets the delegate that is invoked as an opportunity to handle an unhandled exception. - /// - /// This is invoked by . - /// This should also include any logging requirements; not performed by default as it may not be required for all exception types. - public Func> UnhandledExceptionAsync { get; set; } = (_, _, _) => Task.FromResult(null!); - - /// - /// Creates an from an . - /// - /// The optional owning . - /// The . - /// The . - /// The . - /// The . - /// The delegate that is invoked as an opportunity to handle an unhandled exception. - /// The . - /// The corresponding . - public static async Task CreateActionResultFromExceptionAsync(WebApiBase? owner, HttpContext context, Exception exception, SettingsBase settings, ILogger logger, Func>? unhandledExceptionAsync = null, CancellationToken cancellationToken = default) - { - context.ThrowIfNull(nameof(context)); - exception.ThrowIfNull(nameof(exception)); - logger.ThrowIfNull(nameof(logger)); - settings.ThrowIfNull(nameof(settings)); - - if (owner is not null && !owner.Invoker.CatchAndHandleExceptions) - throw exception; - - // Also check for an inner IExtendedException where the outer is an AggregateException; if so, then use. - if (exception is AggregateException aex && aex.InnerException is not null && aex.InnerException is IExtendedException) - exception = aex.InnerException; - - IActionResult? ar = null; - if (exception is IExtendedException eex) - { - ar = owner is null - ? DefaultExtendedExceptionActionResultCreator(exception, logger) - : owner.CreateActionResultFromExtendedException(exception, logger); - } - else - { - if (unhandledExceptionAsync is not null) - ar = await unhandledExceptionAsync(exception, logger, cancellationToken).ConfigureAwait(false); - - if (ar is null) - { - logger.LogCritical(exception, "WebApi unhandled exception: {Error}", exception.Message); - ar = CreateActionResultForUnexpectedResult(exception, settings.IncludeExceptionInResult); - } - } - - return ar; - } - - /// - /// Creates an from an . - /// - /// The . - /// The . - /// The resulting . - public IActionResult CreateActionResultFromExtendedException(Exception extendedException, ILogger? logger) => ExtendedExceptionActionResultCreator(extendedException, logger); - - /// - /// The default . - /// - /// The . - /// The . - /// The resulting . - public static IActionResult DefaultExtendedExceptionActionResultCreator(Exception extendedException, ILogger? logger) - { - if (extendedException.ThrowIfNull(nameof(extendedException)) is not IExtendedException eex) - throw new ArgumentException($"The exception must implement {nameof(IExtendedException)}.", nameof(extendedException)); - - if (eex.ShouldBeLogged) - { - if (logger is null) - throw new ArgumentNullException(nameof(logger), $"The logger is required to log the exception (see {nameof(IExtendedException)}.{nameof(IExtendedException.ShouldBeLogged)})."); - - logger?.LogError(extendedException, "{Error}", extendedException.Message); - } - - if (extendedException is ValidationException vex && vex.Messages is not null && vex.Messages.Count > 0) - { - var msd = new ModelStateDictionary(); - foreach (var item in vex.Messages.GetMessagesForType(MessageType.Error)) - { - if (item.Property is not null && item.Text is not null) - msd.AddModelError(item.Property, item.Text); - } - - return new BadRequestObjectResult(msd); - } - - return new ExtendedContentResult - { - Content = extendedException.Message, - ContentType = MediaTypeNames.Text.Plain, - StatusCode = (int)eex.StatusCode, - BeforeExtension = r => - { - var th = r.GetTypedHeaders(); - th.Set(HttpConsts.ErrorTypeHeaderName, eex.ErrorType); - th.Set(HttpConsts.ErrorCodeHeaderName, eex.ErrorCode.ToString()); - if (extendedException is TransientException tex) - th.Set(HeaderNames.RetryAfter, tex.RetryAfterSeconds); - - return Task.CompletedTask; - } - }; - } - - /// - /// Creates an from an unexpected . - /// - private static ContentResult CreateActionResultForUnexpectedResult(Exception exception, bool includeExceptionInResult) => includeExceptionInResult - ? new ContentResult { StatusCode = (int)HttpStatusCode.InternalServerError, ContentType = MediaTypeNames.Text.Plain, Content = $"An unexpected internal server error has occurred. CorrelationId={ExecutionContext.Current.CorrelationId} Exception={exception}" } - : new ContentResult { StatusCode = (int)HttpStatusCode.InternalServerError, ContentType = MediaTypeNames.Text.Plain, Content = $"An unexpected internal server error has occurred. CorrelationId={ExecutionContext.Current.CorrelationId}" }; - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiExceptionHandlerMiddleware.cs b/src/CoreEx.AspNetCore/WebApis/WebApiExceptionHandlerMiddleware.cs deleted file mode 100644 index d473be04..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiExceptionHandlerMiddleware.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Configuration; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Logging; -using System; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Provides a CoreEx oriented handling middleware that is result aware. - /// - /// The next . - /// The . - /// The . - public class WebApiExceptionHandlerMiddleware(RequestDelegate next, SettingsBase settings, ILogger logger) - { - private readonly RequestDelegate _next = next.ThrowIfNull(nameof(next)); - private readonly SettingsBase _settings = settings.ThrowIfNull(nameof(settings)); - private readonly ILogger _logger = logger.ThrowIfNull(nameof(logger)); - - /// - /// Invokes the . - /// - /// The . - public async Task InvokeAsync(HttpContext context) - { - try - { - await _next(context).ConfigureAwait(false); - } - catch (Exception ex) - { - var ar = await WebApiBase.CreateActionResultFromExceptionAsync(null, context, ex, _settings, _logger, null, default).ConfigureAwait(false); - var ac = new ActionContext(context, new RouteData(), new ActionDescriptor()); - await ar.ExecuteResultAsync(ac).ConfigureAwait(false); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiExecutionContextMiddleware.cs b/src/CoreEx.AspNetCore/WebApis/WebApiExecutionContextMiddleware.cs deleted file mode 100644 index 882c36e3..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiExecutionContextMiddleware.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Provides an handling middleware that (using dependency injection) enables additional configuration where required. - /// - /// A new is instantiated through dependency injection using the . - /// The next . - /// The optional function to update the . Defaults to where not specified. - public class WebApiExecutionContextMiddleware(RequestDelegate next, Func? executionContextUpdate = null) - { - private readonly RequestDelegate _next = next.ThrowIfNull(nameof(next)); - private readonly Func _updateFunc = executionContextUpdate ?? DefaultExecutionContextUpdate; - - /// - /// Gets the default username where it is unable to be inferred ( from the ). - /// - /// Defaults to 'Anonymous'. - public static string DefaultUsername { get; set; } = "Anonymous"; - - /// - /// Represents the default update function. - /// - /// The . - /// The . - /// The will be set to the from the ; otherwise, where null. - public static Task DefaultExecutionContextUpdate(HttpContext context, ExecutionContext ec) - { - context.ThrowIfNull(nameof(context)); - ec.ThrowIfNull(nameof(ec)); - - ec.UserName = context.User?.Identity?.Name ?? DefaultUsername; - ec.Timestamp = context.RequestServices.GetService()?.UtcNow ?? SystemTime.Default.UtcNow; - return Task.CompletedTask; - } - - /// - /// Invokes the . - /// - /// The . - /// The . - public async Task InvokeAsync(HttpContext context) - { - context.ThrowIfNull(nameof(context)); - - var ec = context.RequestServices.GetRequiredService(); - ec.ServiceProvider ??= context.RequestServices; - - await _updateFunc(context, ec).ConfigureAwait(false); - await _next(context).ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiInvoker.cs b/src/CoreEx.AspNetCore/WebApis/WebApiInvoker.cs deleted file mode 100644 index 45cfd480..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiInvoker.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Http; -using CoreEx.Invokers; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Provides pluggable invocation orchestration, logging and exception handling for . - /// - public class WebApiInvoker : InvokerBase - { - private static WebApiInvoker? _default; - - /// - /// Gets the current configured instance (see ). - /// - public static WebApiInvoker Current => CoreEx.ExecutionContext.GetService() ?? (_default ??= new WebApiInvoker()); - - /// - /// Indicates whether to catch and handle any thrown as a result of executing the underlying logic. - /// - /// Defaults to true. - public bool CatchAndHandleExceptions { get; set; } = true; - - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, WebApiBase invoker, Func func, WebApiParam? args) => throw new NotSupportedException(); - - /// - protected async override Task OnInvokeAsync(InvokeArgs invokeArgs, WebApiBase owner, Func> func, WebApiParam? param, CancellationToken cancellationToken) - { - param = param.ThrowIfNull(); - - // Get and override the correlation-id. - foreach (var name in owner.GetCorrelationIdNames()) - { - if (param.Request.Headers.TryGetValue(name, out var values)) - { - owner.ExecutionContext.CorrelationId = values[0]!; - break; - } - } - - // Set correlation-id for the response. - param.Request.HttpContext.Response.Headers.Append(HttpConsts.CorrelationIdHeaderName, owner.ExecutionContext.CorrelationId); - - // Start logging scope and begin work. - using (owner.Logger.BeginScope(new Dictionary() { { HttpConsts.CorrelationIdHeaderName, owner.ExecutionContext.CorrelationId } })) - { - try - { - return await func(invokeArgs, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (CatchAndHandleExceptions) - { - return (TResult)await WebApiBase.CreateActionResultFromExceptionAsync(owner, param.Request.HttpContext, ex, owner.Settings, owner.Logger, owner.OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (!CatchAndHandleExceptions) - { - owner.Logger.LogDebug("WebApi unhandled exception: {Error} [{Type}]", ex.Message, ex.GetType().Name); - throw; - } - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiParam.cs b/src/CoreEx.AspNetCore/WebApis/WebApiParam.cs deleted file mode 100644 index f5b999ad..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiParam.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Net; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents a parameter. - /// - /// The parent instance. - /// The . - /// The . - /// This enables access to the corresponding , , , etc. - public class WebApiParam(WebApiBase webApi, WebApiRequestOptions requestOptions, OperationType operationType = OperationType.Unspecified) - { - /// - /// Gets the parent (invoking) . - /// - public WebApiBase WebApi { get; } = webApi.ThrowIfNull(nameof(webApi)); - - /// - /// Gets or sets the . - /// - public HttpRequest Request => RequestOptions.Request; - - /// - /// Gets or sets the . - /// - public WebApiRequestOptions RequestOptions { get; } = requestOptions.ThrowIfNull(nameof(requestOptions)); - - /// - /// Gets the . - /// - public PagingArgs? Paging => RequestOptions.Paging; - - /// - /// Gets the . - /// - public OperationType OperationType { get; } = operationType; - - /// - /// Inspects the to either update the or where appropriate. - /// - /// The . - /// The value. - /// The value to support fluent-style method-chaining. - /// The takes precedence over the and will override value where specified. - public T InspectValue(T value) - { - if (RequestOptions.ETag != null && value != null && value is IETag etag) - etag.ETag = RequestOptions.ETag; - else if (RequestOptions.ETag == null && value != null && value is IETag etag2 && etag2.ETag != null) - RequestOptions.ETag = etag2.ETag; - - return value; - } - - /// - /// Creates the as either or () as per ; unless is an instance of which will return as-is. - /// - /// The value. - /// The primary status code where there is a value. - /// The alternate status code where there is not a value (i.e. null). - /// Indicates whether to check for by comparing request and response values. - /// The . - /// The . - public IActionResult CreateActionResult(T value, HttpStatusCode statusCode, HttpStatusCode? alternateStatusCode = null, bool checkForNotModified = true, Uri? location = null) - => ValueContentResult.CreateResult(value, statusCode, alternateStatusCode, WebApi.JsonSerializer, RequestOptions, checkForNotModified, location); - - /// - /// Creates the as a (). - /// - /// The status code. - public static IActionResult CreateActionResult(HttpStatusCode statusCode) => new ExtendedStatusCodeResult(statusCode); - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiParamT.cs b/src/CoreEx.AspNetCore/WebApis/WebApiParamT.cs deleted file mode 100644 index 28d024e5..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiParamT.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents a parameter with a request . - /// - /// This enables access to the corresponding , , , deserialized , etc. - public class WebApiParam : WebApiParam - { - /// - /// Initializes a new instance of the class. - /// - /// The to copy from. - /// The deserialized request value. - public WebApiParam(WebApiParam wap, T value) : base(wap.ThrowIfNull(nameof(wap)).WebApi, wap.RequestOptions, wap.OperationType) => Value = InspectValue(value); - - /// - /// Gets the deserialized request value. - /// - public T? Value { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiPublisher.cs b/src/CoreEx.AspNetCore/WebApis/WebApiPublisher.cs deleted file mode 100644 index d41b720a..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiPublisher.cs +++ /dev/null @@ -1,476 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.AspNetCore.Http; -using CoreEx.Configuration; -using CoreEx.Events; -using CoreEx.Hosting.Work; -using CoreEx.Json; -using CoreEx.Mapping; -using CoreEx.Results; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Provides the core Web API execution encapsulation. - /// - /// Support to change/map request into a different published event type is also enabled where required (see also ). - /// By adding a then the beginnings of the asynchronous request-response pattern implementation can be achieved. - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - public class WebApiPublisher(IEventPublisher eventPublisher, ExecutionContext executionContext, SettingsBase settings, IJsonSerializer jsonSerializer, ILogger logger, WebApiInvoker? invoker = null) : WebApiBase(executionContext, settings, jsonSerializer, logger, invoker) - { - private IMapper? _mapper; - - /// - /// Gets the . - /// - public IEventPublisher EventPublisher { get; } = eventPublisher.ThrowIfNull(nameof(eventPublisher)); - - /// - /// Gets or sets the . - /// - /// Where not explicity set will attempt to use the on first use; will throw an exception where not configured. - /// This is required where one of the underlying publishing methods is invoked that enables mapping between request and event types and the corresponding beforeEvent parameter is null; - /// i.e. the default behaviour is to perform a to enable. - public IMapper Mapper - { - get => _mapper ??= ExecutionContext.GetRequiredService(); - set => _mapper = value; - } - - /// - /// Gets or sets the . - /// - /// See for corresponding asynchronous request-response pattern implementation. - public WorkStateOrchestrator? WorkStateOrchestrator { get; set; } - - #region PublishAsync - - /// - /// Performs a operation with no content body that is to be published using the . - /// - /// The . - /// The . - /// The . - /// The corresponding where successful. - /// The must have an of . - public Task PublishAsync(HttpRequest request, WebApiPublisherArgs args, CancellationToken cancellationToken = default) - => PublishOrchestrateAsync(request, false, default!, args.ThrowIfNull(nameof(args)), cancellationToken); - - /// - /// Performs a operation with a request JSON body content value of that is to be published using the . - /// - /// The request JSON content value . - /// The . - /// The . - /// The . - /// The corresponding where successful. - /// The must have an of . - public Task PublishAsync(HttpRequest request, WebApiPublisherArgs? args = null, CancellationToken cancellationToken = default) - => PublishOrchestrateAsync(request, false, default!, args ?? new WebApiPublisherArgs(), cancellationToken); - - /// - /// Performs a operation with a request JSON content value of that is to be published using the . - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The . - /// The . - /// The corresponding where successful. - /// The must have an of . - public Task PublishValueAsync(HttpRequest request, TValue value, WebApiPublisherArgs? args = null, CancellationToken cancellationToken = default) - => PublishOrchestrateAsync(request, true, value, args ?? new WebApiPublisherArgs(), cancellationToken); - - /// - /// Performs a operation with a request JSON body content value of that is to be published using the . - /// - /// The request JSON content value . - /// The (where different to the request). - /// The . - /// The . - /// The . - /// The corresponding where successful. - /// The must have an of . - public Task PublishAsync(HttpRequest request, WebApiPublisherArgs? args = null, CancellationToken cancellationToken = default) - => PublishOrchestrateAsync(request, false, default!, args ?? new WebApiPublisherArgs(), cancellationToken); - - /// - /// Performs a operation with a request JSON content value of that is to be published using the . - /// - /// The request JSON content value . - /// The (where different to the request). - /// The . - /// The value (already deserialized). - /// The . - /// The . - /// The corresponding where successful. - /// The must have an of . - public Task PublishValueAsync(HttpRequest request, TValue value, WebApiPublisherArgs? args = null, CancellationToken cancellationToken = default) - => PublishOrchestrateAsync(request, true, value, args ?? new WebApiPublisherArgs(), cancellationToken); - - /// - /// Performs the publish orchestration. - /// - private async Task PublishOrchestrateAsync(HttpRequest request, bool useValue, TValue value, IWebApiPublisherArgs args, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(); - args.ThrowIfNull(); - - if (request.Method != HttpMethods.Post) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Post}' to use {nameof(PublishAsync)}.", nameof(request)); - - if (args.CreateWorkStateArgs is not null && WorkStateOrchestrator is null) - throw new InvalidOperationException($"The {nameof(WorkStateOrchestrator)} must be set to use {nameof(IWebApiPublisherArgs)}.{nameof(IWebApiPublisherArgs.CreateWorkStateArgs)}."); - - return await RunAsync(request, async (wap, ct) => - { - // Use specified value or get from the request. - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, args.ValueIsRequired, null, cancellationToken).ConfigureAwait(false); - if (vex is not null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - // Process the publishing as configured. - WorkState? ws = null; - var r = await Result.Go() - .WhenAsAsync(() => args.OnBeforeValidationAsync is not null, () => args.OnBeforeValidationAsync!(wapv!, ct)) - .WhenAsAsync(_ => args.Validator is not null, async _ => (await args.Validator!.ValidateAsync(wapv!.Value!, ct).ConfigureAwait(false)).ToResult()) - .WhenAsync(_ => args.OnBeforeEventAsync is not null, _ => args.OnBeforeEventAsync!(wapv!, ct)) - .ThenAs(_ => args.EventTemplate is null ? new EventData() : new EventData(args.EventTemplate)) - .WhenAs(e => args.AreSameType, e => e.Adjust(x => x.Value = wapv!.Value), e => e.Adjust(x => x.Value = args.Mapper is not null ? args.Mapper.Map(wapv!.Value) : Mapper.Map(wapv!.Value))) - .Then(e => args.OnEvent?.Invoke(e)) - .Then(e => - { - if (args.EventName is null) - EventPublisher.Publish(e); - else - EventPublisher.PublishNamed(args.EventName, e); - }) - .ThenAsync(async e => - { - await EventPublisher.SendAsync(ct).ConfigureAwait(false); - }) - .WhenAsync(e => WorkStateOrchestrator is not null && args.CreateWorkStateArgs is not null, async e => - { - var wsa = args.CreateWorkStateArgs!(); - wsa.Id = e.Id; - wsa.CorrelationId = e.CorrelationId; - wsa.Key ??= e.Key; - ws = await WorkStateOrchestrator!.CreateAsync(wsa).ConfigureAwait(false); - }); - - if (r.IsFailure) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, r.Error, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - if (args.CreateSuccessResultAsync is not null) - return await args.CreateSuccessResultAsync().ConfigureAwait(false) ?? throw new InvalidOperationException($"The {nameof(IWebApiPublisherArgs.CreateSuccessResultAsync)} must return a result."); - - return ValueContentResult.CreateResult(ws, HttpStatusCode.Accepted, HttpStatusCode.Accepted, JsonSerializer, request.GetRequestOptions(), false, args.CreateLocation?.Invoke(wapv!, r.Value)); - }, args.OperationType, cancellationToken, nameof(PublishAsync)).ConfigureAwait(false); - } - - #endregion - - #region PublishCollectionAsync - - /// - /// Performs a operation with a request JSON content value of where each item is to be published using the . - /// - /// The request JSON collection . - /// The collection item . - /// The . - /// The . - /// The . - /// The corresponding where successful. - /// The must have an of . - public Task PublishCollectionAsync(HttpRequest request, WebApiPublisherCollectionArgs? args = null, CancellationToken cancellationToken = default) where TColl : IEnumerable - => PublishCollectionOrchestrateAsync(request, false, default!, args ?? new WebApiPublisherCollectionArgs(), cancellationToken); - - /// - /// Performs a operation with a request JSON content value of where each item is to be published using the . - /// - /// The request JSON collection . - /// The collection item . - /// The . - /// The value (already deserialized). - /// The . - /// The . - /// The corresponding where successful. - /// The must have an of . - public Task PublishCollectionValueAsync(HttpRequest request, TColl value, WebApiPublisherCollectionArgs? args = null, CancellationToken cancellationToken = default) where TColl : IEnumerable - => PublishCollectionOrchestrateAsync(request, true, value, args ?? new WebApiPublisherCollectionArgs(), cancellationToken); - - /// - /// Performs a operation with a request JSON content value of where each item is to be published using the . - /// - /// The request JSON collection . - /// The collection item . - /// The -equivalent (where different to the request). - /// The . - /// The . - /// The . - /// The corresponding where successful. - /// The must have an of . - public Task PublishCollectionAsync(HttpRequest request, WebApiPublisherCollectionArgs? args = null, CancellationToken cancellationToken = default) where TColl : IEnumerable - => PublishCollectionOrchestrateAsync(request, false, default!, args ?? new WebApiPublisherCollectionArgs(), cancellationToken); - - /// - /// Performs a operation with a request JSON content value of where each item is to be published using the . - /// - /// The request JSON collection . - /// The collection item . - /// The -equivalent (where different to the request). - /// The . - /// The value (already deserialized). - /// The . - /// The . - /// The corresponding where successful. - /// The must have an of . - public Task PublishCollectionValueAsync(HttpRequest request, TColl value, WebApiPublisherCollectionArgs? args = null, CancellationToken cancellationToken = default) where TColl : IEnumerable - => PublishCollectionOrchestrateAsync(request, true, value, args ?? new WebApiPublisherCollectionArgs(), cancellationToken); - - /// - /// Performs the publish orchestration. - /// - private async Task PublishCollectionOrchestrateAsync(HttpRequest request, bool useValue, TColl coll, IWebApiPublisherCollectionArgs args, CancellationToken cancellationToken = default) where TColl : IEnumerable - { - request.ThrowIfNull(); - args.ThrowIfNull(); - - if (request.Method != HttpMethods.Post) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Post}' to use {nameof(PublishAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - // Use specified value or get from the request. - var (wapc, vex) = await ValidateValueAsync(wap, useValue, coll, true, null, cancellationToken).ConfigureAwait(false); - if (vex is not null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - // Check the collection size. - var max = args.MaxCollectionSize ?? Settings.MaxPublishCollSize; - if (max <= 0) - throw new InvalidOperationException($"The maximum collection size must be greater than zero."); - - var count = wapc!.Value?.Count() ?? 0; - if (count > max) - { - Logger.LogWarning("The publish collection contains {EventsCount} items where only a maximum size of {MaxCollSize} is supported; request has been rejected.", count, max); - var bex = new BusinessException($"The publish collection contains {count} items where only a maximum size of {max} is supported."); - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, bex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - } - - if (count == 0) - return new AcceptedResult(); - - // Process the publishing as configured. - var r = await Result.Go() - .WhenAsAsync(() => args.OnBeforeValidationAsync is not null, () => args.OnBeforeValidationAsync!(wapc!, ct)) - .WhenAsAsync(_ => args.Validator is not null, async _ => (await args.Validator!.ValidateAsync(wapc!.Value!, ct).ConfigureAwait(false)).ToResult()) - .WhenAsync(_ => args.OnBeforeEventAsync is not null, _ => args.OnBeforeEventAsync!(wapc!, ct)); - - if (r.IsFailure) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, r.Error, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - // Create the events (also performing mapping where applicable). - foreach (var item in wapc!.Value!) - { - var @event = args.EventTemplate is null ? new EventData() : new EventData(args.EventTemplate); - @event.Value = args.AreSameType ? item : (args.Mapper is not null ? args.Mapper.Map(item) : Mapper.Map(item)); - args.OnEvent?.Invoke(@event); - - if (args.EventName is null) - EventPublisher.Publish(@event); - else - EventPublisher.PublishNamed(args.EventName, @event); - } - - await EventPublisher.SendAsync(ct).ConfigureAwait(false); - - if (args.CreateSuccessResultAsync is not null) - return await args.CreateSuccessResultAsync().ConfigureAwait(false) ?? throw new InvalidOperationException($"The {nameof(IWebApiPublisherCollectionArgs.CreateSuccessResultAsync)} must return a result."); - - return new ExtendedStatusCodeResult(args.StatusCode); - }, args.OperationType, cancellationToken, nameof(PublishAsync)).ConfigureAwait(false); - } - - #endregion - - #region Async Request-Response - - /// - /// Performs a operation to get the to enable the likes of the asynchronous request-response pattern. - /// - /// The . - /// The . - /// The . - /// The corresponding . - public Task GetWorkStatusAsync(HttpRequest request, WebApiPublisherStatusArgs args, CancellationToken cancellationToken = default) - { - if (WorkStateOrchestrator is null) - throw new InvalidOperationException($"The {nameof(GetWorkStatusAsync)} operation requires that the {nameof(WorkStateOrchestrator)} is not null."); - - request.ThrowIfNull(nameof(request)); - args.ThrowIfNull(nameof(args)); - - if (request.Method != HttpMethods.Get) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Get}' to use {nameof(GetWorkStatusAsync)}.", nameof(request)); - - return RunAsync(request, async (wap, ct) => - { - var ws = string.IsNullOrEmpty(args.Id) ? null : await WorkStateOrchestrator!.GetAsync(args.TypeName, args.Id, ct).ConfigureAwait(false); - if (ws is null) - return new ExtendedStatusCodeResult(HttpStatusCode.NotFound); - - if (args.OnBeforeResponseAsync is not null) - { - var r = await args.OnBeforeResponseAsync(ws).ConfigureAwait(false); - if (r.IsFailure) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, r.Error, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - } - - // Check for the completed status and redirect to the result location where applicable. - if (ws.Status == WorkStatus.Completed && args.CreateResultLocation is not null) - return new ExtendedStatusCodeResult(HttpStatusCode.Redirect) { Location = args.CreateResultLocation(ws) ?? throw new InvalidOperationException("A result location is ") }; - - // Return the work status as either a BadRequest or OK. - var res = ValueContentResult.CreateResult(ws, WorkStatus.Terminated.HasFlag(ws.Status) ? HttpStatusCode.BadRequest : HttpStatusCode.OK, null, JsonSerializer, request.GetRequestOptions(), true, null); - if (res is ValueContentResult vcr && WorkStatus.Executing.HasFlag(ws.Status)) - vcr.RetryAfter = args.ExecutingRetryAfter; - - return res; - - }, OperationType.Unspecified, cancellationToken, nameof(GetWorkStatusAsync)); - } - - /// - /// Performs a operation to get the result with no resulting value by default. - /// - /// The . - /// The . - /// The . - /// The corresponding . - public Task GetWorkResultAsync(HttpRequest request, WebApiPublisherResultArgs args, CancellationToken cancellationToken = default) - { - if (WorkStateOrchestrator is null) - throw new InvalidOperationException($"The {nameof(GetWorkResultAsync)} operation requires that the {nameof(WorkStateOrchestrator)} is not null."); - - request.ThrowIfNull(nameof(request)); - args.ThrowIfNull(nameof(args)); - - if (request.Method != HttpMethods.Get) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Get}' to use {nameof(GetWorkResultAsync)}.", nameof(request)); - - return RunAsync(request, async (wap, ct) => - { - var ws = string.IsNullOrEmpty(args.Id) ? null : await WorkStateOrchestrator!.GetAsync(args.TypeName, args.Id, ct).ConfigureAwait(false); - if (ws is null || ws.Status != WorkStatus.Completed) - return new ExtendedStatusCodeResult(HttpStatusCode.NotFound); - - if (args.OnBeforeResponseAsync is not null) - { - var r = await args.OnBeforeResponseAsync(ws).ConfigureAwait(false); - if (r.IsFailure) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, r.Error, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - } - - if (args.CreateSuccessResultAsync is not null) - return await args.CreateSuccessResultAsync(ws).ConfigureAwait(false) ?? throw new InvalidOperationException($"The {nameof(WebApiPublisherResultArgs.CreateSuccessResultAsync)} must return a result."); - - return new ExtendedStatusCodeResult(HttpStatusCode.NoContent); - - }, OperationType.Unspecified, cancellationToken, nameof(GetWorkResultAsync)); - } - - /// - /// Performs a operation to get the result with a result of . - /// - /// The . - /// The . - /// The . - /// The corresponding . - public Task GetWorkResultAsync(HttpRequest request, WebApiPublisherResultArgs args, CancellationToken cancellationToken = default) - { - if (WorkStateOrchestrator is null) - throw new InvalidOperationException($"The {nameof(GetWorkResultAsync)} operation requires that the {nameof(WorkStateOrchestrator)} is not null."); - - request.ThrowIfNull(nameof(request)); - args.ThrowIfNull(nameof(args)); - - if (request.Method != HttpMethods.Get) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Get}' to use {nameof(GetWorkResultAsync)}.", nameof(request)); - - return RunAsync(request, async (wap, ct) => - { - var ws = string.IsNullOrEmpty(args.Id) ? null : await WorkStateOrchestrator!.GetAsync(args.TypeName, args.Id, ct).ConfigureAwait(false); - if (ws is null || ws.Status != WorkStatus.Completed) - return new ExtendedStatusCodeResult(HttpStatusCode.NotFound); - - if (args.OnBeforeResponseAsync is not null) - { - var r = await args.OnBeforeResponseAsync(ws).ConfigureAwait(false); - if (r.IsFailure) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, r.Error, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - } - - var value = await WorkStateOrchestrator.GetDataAsync(args.Id!, ct).ConfigureAwait(false); - return ValueContentResult.CreateResult(value, HttpStatusCode.OK, HttpStatusCode.NoContent, JsonSerializer, request.GetRequestOptions(), true, null); - }, OperationType.Unspecified, cancellationToken, nameof(GetWorkResultAsync)); - } - - /// - /// Performs a operation to cancel the to enable the likes of the asynchronous request-response pattern. - /// - /// The . - /// The . - /// The . - /// The corresponding . - public Task CancelWorkStatusAsync(HttpRequest request, WebApiPublisherCancelArgs args, CancellationToken cancellationToken = default) - { - if (WorkStateOrchestrator is null) - throw new InvalidOperationException($"The {nameof(CancelWorkStatusAsync)} operation requires that the {nameof(WorkStateOrchestrator)} is not null."); - - request.ThrowIfNull(nameof(request)); - args.ThrowIfNull(nameof(args)); - - if (request.Method != HttpMethods.Get) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Get}' to use {nameof(CancelWorkStatusAsync)}.", nameof(request)); - - return RunAsync(request, async (wap, ct) => - { - var ws = string.IsNullOrEmpty(args.Id) ? null : await WorkStateOrchestrator!.GetAsync(args.TypeName, args.Id, ct).ConfigureAwait(false); - if (ws is null) - return new ExtendedStatusCodeResult(HttpStatusCode.NotFound); - - if (args.OnBeforeResponseAsync is not null) - { - var r = await args.OnBeforeResponseAsync(ws).ConfigureAwait(false); - if (r.IsFailure) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, r.Error, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - } - - // Check for the completed status and redirect to the result location where applicable. - var cr = await WorkStateOrchestrator!.CancelAsync(args.Id!, args.Reason ?? WebApiPublisherCancelArgs.NotSpecifiedReason, ct).ConfigureAwait(false); - if (cr.IsFailure) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, cr.Error, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - return ValueContentResult.CreateResult(cr.Value, HttpStatusCode.OK, null, JsonSerializer, request.GetRequestOptions(), true, null); - }, OperationType.Unspecified, cancellationToken, nameof(CancelWorkStatusAsync)); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgs.cs b/src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgs.cs deleted file mode 100644 index 371934ca..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgs.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using CoreEx.Hosting.Work; -using CoreEx.Mapping; -using CoreEx.Results; -using CoreEx.Validation; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents the arguments; being the opportunity to further configure the standard processing. - /// - /// - /// Initializes a new instance of the class. - /// - /// The event destination name (e.g. Queue or Topic name). - public class WebApiPublisherArgs(string eventName) : IWebApiPublisherArgs - { - /// - public string? EventName { get; set; } = eventName.ThrowIfNull(nameof(eventName)); - - /// - public EventData? EventTemplate { get; set; } - - /// - public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Accepted; - - /// - bool IWebApiPublisherArgs.ValueIsRequired => false; - - /// - IValidator? IWebApiPublisherArgs.Validator => null; - - /// - public OperationType OperationType { get; set; } = OperationType.Unspecified; - - /// - public Func, CancellationToken, Task>? OnBeforeValidationAsync { get; set; } - - /// - public Func, CancellationToken, Task>? OnBeforeEventAsync { get; set; } - - /// - public Action? OnEvent { get; set; } - - /// - IMapper? IWebApiPublisherArgs.Mapper => null; - - /// - public Func>? CreateSuccessResultAsync { get; set; } - - /// - public Func, EventData, Uri>? CreateLocation { get; set; } - - /// - public Func? CreateWorkStateArgs { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgsT.cs b/src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgsT.cs deleted file mode 100644 index 75cd7c41..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgsT.cs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using CoreEx.Hosting.Work; -using CoreEx.Mapping; -using CoreEx.Results; -using CoreEx.Validation; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents the arguments; being the opportunity to further configure the standard processing. - /// - /// The request JSON content value . - /// The optional validator. - public class WebApiPublisherArgs(IValidator? validator = null) : IWebApiPublisherArgs - { - /// - /// Initializes a new instance of the class. - /// - /// The event destination name (e.g. Queue or Topic name). - /// The optional validator. - public WebApiPublisherArgs(string eventName, IValidator? validator = null) : this(validator) => EventName = eventName; - - /// - public string? EventName { get; set; } - - /// - public EventData? EventTemplate { get; set; } - - /// - public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Accepted; - - /// - public bool ValueIsRequired { get; set; } = true; - - /// - public IValidator? Validator { get; set; } = validator; - - /// - public OperationType OperationType { get; set; } = OperationType.Unspecified; - - /// - public Func, CancellationToken, Task>? OnBeforeValidationAsync { get; set; } - - /// - public Func, CancellationToken, Task>? OnBeforeEventAsync { get; set; } - - /// - public Action? OnEvent { get; set; } - - /// - IMapper? IWebApiPublisherArgs.Mapper => null; - - /// - public Func>? CreateSuccessResultAsync { get; set; } - - /// - public Func, EventData, Uri>? CreateLocation { get; set; } - - /// - public Func? CreateWorkStateArgs { get; set; } - - /// - /// Sets the to use the . - /// - /// The to infer the enabling state separation. - /// The to support fluent-style method-chaining. - public WebApiPublisherArgs WithWorkState() - { - CreateWorkStateArgs = () => WorkStateArgs.Create(); - return this; - } - - /// - /// Sets the to use the specified or where null automatically set using the . - /// - /// The type name. - /// The to support fluent-style method-chaining. - public WebApiPublisherArgs WithWorkState(string? typeName = null) - { - if (typeName is null) - return WithWorkState(); - - CreateWorkStateArgs = () => new WorkStateArgs(typeName); - return this; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgsT2.cs b/src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgsT2.cs deleted file mode 100644 index 27428a36..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherArgsT2.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using CoreEx.Hosting.Work; -using CoreEx.Mapping; -using CoreEx.Results; -using CoreEx.Validation; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents the arguments; being the opportunity to further configure the standard processing. - /// - /// The request JSON content value . - /// (where different to the request). - /// The optional validator. - public class WebApiPublisherArgs(IValidator? validator = null) : IWebApiPublisherArgs - { - /// - /// Initializes a new instance of the class. - /// - /// The event destination name (e.g. Queue or Topic name). - /// The optional validator. - public WebApiPublisherArgs(string eventName, IValidator? validator = null) : this(validator) => EventName = eventName; - - /// - public string? EventName { get; set; } = default!; - - /// - public EventData? EventTemplate { get; set; } - - /// - public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Accepted; - - /// - public bool ValueIsRequired { get; set; } = true; - - /// - public IValidator? Validator { get; set; } = validator; - - /// - public OperationType OperationType { get; set; } = OperationType.Unspecified; - - /// - public Func, CancellationToken, Task>? OnBeforeValidationAsync { get; set; } - - /// - public Func, CancellationToken, Task>? OnBeforeEventAsync { get; set; } - - /// - public Action? OnEvent { get; set; } - - /// - public IMapper? Mapper { get; set; } - - /// - public Func>? CreateSuccessResultAsync { get; set; } - - /// - public Func, EventData, Uri>? CreateLocation { get; set; } - - /// - public Func? CreateWorkStateArgs { get; set; } - - /// - /// Sets the to use the . - /// - /// The to infer the enabling state separation. - /// The to support fluent-style method-chaining. - public WebApiPublisherArgs WithWorkState() - { - CreateWorkStateArgs = () => WorkStateArgs.Create(); - return this; - } - - /// - /// Sets the to use the specified or where null automatically set using the . - /// - /// The type name. - /// The to support fluent-style method-chaining. - public WebApiPublisherArgs WithWorkState(string? typeName = null) - { - if (typeName is null) - return WithWorkState(); - - CreateWorkStateArgs = () => new WorkStateArgs(typeName); - return this; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherCancelArgs.cs b/src/CoreEx.AspNetCore/WebApis/WebApiPublisherCancelArgs.cs deleted file mode 100644 index abfa98b4..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherCancelArgs.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Hosting.Work; -using CoreEx.Localization; -using CoreEx.Results; -using System; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents the arguments; being the opportunity to further configure the standard processing. - /// - /// The enabling state separation. - /// The . - /// The cancellation reason. - public class WebApiPublisherCancelArgs(string typeName, string? id, string? reason = null) - { - /// - /// Gets the default . - /// - public static LText NotSpecifiedReason { get; } = "No reason was specified."; - - /// - /// Gets the type name. - /// - /// Enables separation between one or more types. - public string TypeName => typeName.ThrowIfNullOrEmpty(nameof(typeName)); - - /// - /// Gets or sets the . - /// - public string? Id { get; set; } = id; - - /// - /// Gets or sets the cancellation reason; defaults to . - /// - public string? Reason { get; set; } = reason; - - /// - /// Gets or sets the function to execute prior to returning the response. - /// - /// Enables the likes of security, etc., before returning the response. The will allow failures and alike to be returned where applicable. - public Func>? OnBeforeResponseAsync { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherCollectionArgsT.cs b/src/CoreEx.AspNetCore/WebApis/WebApiPublisherCollectionArgsT.cs deleted file mode 100644 index 2540504c..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherCollectionArgsT.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using CoreEx.Mapping; -using CoreEx.Results; -using CoreEx.Validation; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents the collection-based arguments; being the opportunity to further configure the standard processing. - /// - /// The request JSON collection . - /// The collection item . - /// The optional validator. - public class WebApiPublisherCollectionArgs(IValidator? validator = null) : IWebApiPublisherCollectionArgs where TColl : IEnumerable - { - /// - /// Initializes a new instance of the class. - /// - /// The event destination name (e.g. Queue or Topic name). - /// The optional validator. - public WebApiPublisherCollectionArgs(string eventName, IValidator? validator = null) : this(validator) => EventName = eventName; - - /// - public string? EventName { get; set; } - - /// - public EventData? EventTemplate { get; set; } - - /// - public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Accepted; - - /// - public int? MaxCollectionSize { get; set; } - - /// - public IValidator? Validator { get; set; } = validator; - - /// - public OperationType OperationType { get; set; } = OperationType.Unspecified; - - /// - public Func, CancellationToken, Task>? OnBeforeValidationAsync { get; set; } - - /// - public Func, CancellationToken, Task>? OnBeforeEventAsync { get; set; } - - /// - public Action? OnEvent { get; set; } - - /// - IMapper? IWebApiPublisherCollectionArgs.Mapper => null; - - /// - public Func>? CreateSuccessResultAsync { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherCollectionArgsT2.cs b/src/CoreEx.AspNetCore/WebApis/WebApiPublisherCollectionArgsT2.cs deleted file mode 100644 index 1e7d4be7..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherCollectionArgsT2.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using CoreEx.Mapping; -using CoreEx.Results; -using CoreEx.Validation; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents the collection-based arguments; being the opportunity to further configure the standard processing. - /// - /// The request JSON collection . - /// The collection item . - /// The -equivalent (where different then a will be required). - /// The optional validator. - public class WebApiPublisherCollectionArgs(IValidator? validator = null) : IWebApiPublisherCollectionArgs where TColl : IEnumerable - { - /// - /// Initializes a new instance of the class. - /// - /// The event destination name (e.g. Queue or Topic name). - /// The optional validator. - public WebApiPublisherCollectionArgs(string eventName, IValidator? validator = null) : this(validator) => EventName = eventName; - - /// - public string? EventName { get; set; } - - /// - public EventData? EventTemplate { get; set; } - - /// - public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Accepted; - - /// - public int? MaxCollectionSize { get; set; } - - /// - public IValidator? Validator { get; set; } = validator; - - /// - public OperationType OperationType { get; set; } = OperationType.Unspecified; - - /// - public Func, CancellationToken, Task>? OnBeforeValidationAsync { get; set; } - - /// - public Func, CancellationToken, Task>? OnBeforeEventAsync { get; set; } - - /// - public Action? OnEvent { get; set; } - - /// - public IMapper? Mapper { get; set; } - - /// - public Func>? CreateSuccessResultAsync { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherResultArgs.cs b/src/CoreEx.AspNetCore/WebApis/WebApiPublisherResultArgs.cs deleted file mode 100644 index 820c3939..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherResultArgs.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Hosting.Work; -using CoreEx.Results; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Net; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents the arguments with no result value; being the opportunity to further configure the standard processing. - /// - /// The enabling state separation. - /// The . - public class WebApiPublisherResultArgs(string typeName, string? id) - { - /// - /// Gets the type name. - /// - /// Enables separation between one or more types. - public string TypeName => typeName.ThrowIfNullOrEmpty(nameof(typeName)); - - /// - /// Gets or sets the . - /// - public string? Id { get; set; } = id; - - /// - /// Gets or sets the function to execute prior to returning the response. - /// - /// Enables the likes of security, etc., before returning the response. The will allow failures and alike to be returned where applicable. - public Func>? OnBeforeResponseAsync { get; set; } - - /// - /// Gets or sets the function to override the creation of the success . - /// - /// Defaults to a with . - public Func>? CreateSuccessResultAsync { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherResultArgsT.cs b/src/CoreEx.AspNetCore/WebApis/WebApiPublisherResultArgsT.cs deleted file mode 100644 index 354a478d..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherResultArgsT.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Hosting.Work; -using CoreEx.Results; -using System; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents the arguments with a result value; being the opportunity to further configure the standard processing. - /// - /// The resulting value . - /// The enabling state separation. - /// The . - public class WebApiPublisherResultArgs(string typeName, string? id) - { - /// - /// Gets the resulting . - /// - public Type ValueType => typeof(TValue); - - /// - /// Gets the type name. - /// - /// Enables separation between one or more types. - public string TypeName => typeName.ThrowIfNullOrEmpty(nameof(typeName)); - - /// - /// Gets or sets the . - /// - public string? Id { get; set; } = id; - - /// - /// Gets or sets the function to execute prior to returning the response. - /// - /// Enables the likes of security, etc., before returning the response. The will allow failures and alike to be returned where applicable. - public Func>? OnBeforeResponseAsync { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherStatusArgs.cs b/src/CoreEx.AspNetCore/WebApis/WebApiPublisherStatusArgs.cs deleted file mode 100644 index be2a993b..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiPublisherStatusArgs.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Hosting.Work; -using CoreEx.Results; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents the arguments; being the opportunity to further configure the standard processing. - /// - /// The enabling state separation. - /// The . - public class WebApiPublisherStatusArgs(string typeName, string? id) - { - /// - /// Gets the type name. - /// - /// Enables separation between one or more types. - public string TypeName => typeName.ThrowIfNullOrEmpty(nameof(typeName)); - - /// - /// Gets or sets the . - /// - public string? Id { get; set; } = id; - - /// - /// Gets or sets the function to execute prior to returning the response. - /// - /// Enables the likes of security, etc., before returning the response. The will allow failures and alike to be returned where applicable. - public Func>? OnBeforeResponseAsync { get; set; } - - /// - /// Gets or sets the function to create the for the result . - /// - /// This will only be invoked when the is and enables the behavior. - public Func? CreateResultLocation { get; set; } - - /// - /// Gets or sets the for the . - /// - /// This will only be invoked when the is . Defaults to 30 seconds. - public TimeSpan? ExecutingRetryAfter { get; set; } = TimeSpan.FromSeconds(30); - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs b/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs deleted file mode 100644 index 88599441..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.RefData; -using Microsoft.AspNetCore.Http; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; - -namespace CoreEx.AspNetCore.WebApis -{ - /// - /// Represents the request options; the server-side representation of the . - /// - /// Usage assumes that the HTTP endpoint supports and actions accordingly; i.e. by sending there is no guarantee that the desired outcome will occur as selected. - public class WebApiRequestOptions - { - /// - /// Initializes a new instance of the class. - /// - /// The . - public WebApiRequestOptions(HttpRequest httpRequest) - { - Request = httpRequest.ThrowIfNull(nameof(httpRequest)); - HasQueryString = GetQueryStringOptions(Request.Query); - - // Get the raw ETag from the request headers. - var rth = httpRequest.GetTypedHeaders(); - var etag = rth.IfNoneMatch.FirstOrDefault()?.Tag ?? rth.IfMatch.FirstOrDefault()?.Tag; - if (etag.HasValue) - ETag = etag.Value.Substring(1, etag.Value.Length - 2); - } - - /// - /// Gets the originating . - /// - public HttpRequest Request { get; } - - /// - /// Indicates whether the has a query string. - /// - public bool HasQueryString { get; } - - /// - /// Gets or sets the entity tag that was passed as either a If-None-Match header where ; otherwise, an If-Match header. - /// - /// Represents the underlying ray value; i.e. is stripped of any W/"xxxx" formatting. - public string? ETag { get; set; } - - /// - /// Gets the list of included fields (JSON property names) to limit the serialized data payload (results in url query string: "$fields=x,y,z"). - /// - public string[]? IncludeFields { get; private set; } - - /// - /// Gets the list of excluded fields (JSON property names) to limit the serialized data payload (results in url query string: "$excludefields=x,y,z"). - /// - public string[]? ExcludeFields { get; private set; } - - /// - /// Gets the . - /// - public PagingArgs? Paging { get; private set; } - - /// - /// Gets the dynamic . - /// - public QueryArgs? Query { get; private set; } - - /// - /// Indicates whether to include any related texts for the item(s). - /// - /// For example, include corresponding for any ReferenceData values returned in the JSON response payload. - public bool IncludeText { get; private set; } - - /// - /// Indicates whether to include any inactive item(s); - /// - /// For example, include item(s) where is false. - public bool IncludeInactive { get; private set; } - - /// - /// Gets the options from the . - /// - private bool GetQueryStringOptions(IQueryCollection query) - { - if (query == null || query.Count == 0) - return false; - - Paging = GetPagingArgs(query); - Query = GetQueryArgs(query); - - var fields = GetNamedQueryString(query, HttpConsts.IncludeFieldsQueryStringNames); - if (!string.IsNullOrEmpty(fields)) - { -#if NET6_0_OR_GREATER - IncludeFields = fields.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); -#else - IncludeFields = fields.Split(',', StringSplitOptions.RemoveEmptyEntries); -#endif - Query ??= new QueryArgs(); - Query.Include(IncludeFields); - } - - fields = GetNamedQueryString(query, HttpConsts.ExcludeFieldsQueryStringNames); - if (!string.IsNullOrEmpty(fields)) - { -#if NET6_0_OR_GREATER - ExcludeFields = fields.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); -#else - ExcludeFields = fields.Split(',', StringSplitOptions.RemoveEmptyEntries); -#endif - Query ??= new QueryArgs(); - Query.Exclude(ExcludeFields); - } - - IncludeText = HttpExtensions.ParseBoolValue(GetNamedQueryString(query, HttpConsts.IncludeTextQueryStringNames)); - IncludeInactive = HttpExtensions.ParseBoolValue(GetNamedQueryString(query, HttpConsts.IncludeInactiveQueryStringNames, "true")); - return true; - } - - /// - /// Gets the from an . - /// - private static PagingArgs? GetPagingArgs(IQueryCollection query) - { - long? skip = HttpExtensions.ParseLongValue(GetNamedQueryString(query, HttpConsts.PagingArgsSkipQueryStringNames)); - long? take = HttpExtensions.ParseLongValue(GetNamedQueryString(query, HttpConsts.PagingArgsTakeQueryStringNames)); - long? page = skip.HasValue ? null : HttpExtensions.ParseLongValue(GetNamedQueryString(query, HttpConsts.PagingArgsPageQueryStringNames)); - string? token = GetNamedQueryString(query, HttpConsts.PagingArgsTokenQueryStringNames); - bool isGetCount = HttpExtensions.ParseBoolValue(GetNamedQueryString(query, HttpConsts.PagingArgsCountQueryStringNames)); - - if (skip == null && take == null && page == null && string.IsNullOrEmpty(token) && !isGetCount) - return null; - - PagingArgs paging; - if (!string.IsNullOrEmpty(token)) - paging = PagingArgs.CreateTokenAndTake(token, take); - else if (skip == null && page == null) - paging = take.HasValue ? PagingArgs.CreateSkipAndTake(0, take) : new PagingArgs(); - else - paging = skip.HasValue ? PagingArgs.CreateSkipAndTake(skip.Value, take) : PagingArgs.CreatePageAndSize(page == null ? 0 : page.Value, take); - - paging.IsGetCount = isGetCount; - return paging; - } - - /// - /// Gets the first value for the named query string. - /// - private static string? GetNamedQueryString(IQueryCollection query, IEnumerable names, string? defaultValue = null) - { - var q = query.Where(x => names.Contains(x.Key, StringComparer.OrdinalIgnoreCase)).FirstOrDefault(); - if (q.Key == null) - return null; - - var val = q.Value.FirstOrDefault(); - return string.IsNullOrEmpty(val) ? defaultValue : val; - } - - /// - /// Gets the from an . - /// - private static QueryArgs? GetQueryArgs(IQueryCollection query) - { - var filter = GetNamedQueryString(query, HttpConsts.QueryArgsFilterQueryStringNames); - var orderBy = GetNamedQueryString(query, HttpConsts.QueryArgsOrderByQueryStringNames); - return string.IsNullOrEmpty(filter) && string.IsNullOrEmpty(orderBy) ? null : new QueryArgs { Filter = filter, OrderBy = orderBy }; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/WebApis/WebApiWithResult.cs b/src/CoreEx.AspNetCore/WebApis/WebApiWithResult.cs deleted file mode 100644 index 96233e05..00000000 --- a/src/CoreEx.AspNetCore/WebApis/WebApiWithResult.cs +++ /dev/null @@ -1,784 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.AspNetCore.Http; -using CoreEx.Http; -using CoreEx.Results; -using CoreEx.Validation; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Net; -using System.Net.Mime; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.AspNetCore.WebApis -{ - public partial class WebApi - { - #region GetWithResultAsync - - /// - /// Performs a operation returning a response of (with a ). - /// - /// The result . - /// The . - /// The function to execute. - /// The where result is not null. - /// The alternate where result is null. - /// The . - /// The (either on non-null result; otherwise, a ). - public Task GetWithResultAsync(HttpRequest request, Func>> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NotFound, OperationType operationType = OperationType.Read) - => GetWithResultAsync(request, (p, _) => function(p), statusCode, alternateStatusCode, operationType, CancellationToken.None); - - /// - /// Performs a operation returning a response of (with a ). - /// - /// The result . - /// The . - /// The function to execute. - /// The where result is not null. - /// The alternate where result is null. - /// The . - /// The . - /// The (either on non-null result; otherwise, a ). - public async Task GetWithResultAsync(HttpRequest request, Func>> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NotFound, OperationType operationType = OperationType.Read, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsGet(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Get}' to use {nameof(GetAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var result = await function(wap, ct).ConfigureAwait(false); - return result.IsFailure - ? await CreateActionResultFromExceptionAsync(this, request.HttpContext, result.Error, Settings, Logger, UnhandledExceptionAsync, ct).ConfigureAwait(false) - : ValueContentResult.CreateResult(result.Value, statusCode, alternateStatusCode, JsonSerializer, wap.RequestOptions, checkForNotModified: true, location: null); - }, operationType, cancellationToken, nameof(GetWithResultAsync)).ConfigureAwait(false); - } - - #endregion - - #region PostWithResultAsync - - /// - /// Performs a operation with no request content or response value (with a ). - /// - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// The optional function to set the location . - public Task PostWithResultAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.OK, OperationType operationType = OperationType.Create, Func? locationUri = null) - => PostWithResultAsync(request, (p, _) => function(p), statusCode, operationType, locationUri, CancellationToken.None); - - /// - /// Performs a operation with no request content or response value (with a ). - /// - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// The optional function to set the location . - /// The . - /// The corresponding where successful. - public async Task PostWithResultAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.OK, OperationType operationType = OperationType.Create, - Func? locationUri = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPost(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Post}' to use {nameof(PostAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var result = await function(wap, ct).ConfigureAwait(false); - return result.IsFailure - ? await CreateActionResultFromExceptionAsync(this, request.HttpContext, result.Error, Settings, Logger, UnhandledExceptionAsync, ct).ConfigureAwait(false) - : new ExtendedStatusCodeResult(statusCode) { Location = locationUri?.Invoke() }; - }, operationType, cancellationToken, nameof(PostWithResultAsync)).ConfigureAwait(false); - } - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The corresponding where successful. - public Task PostWithResultAsync(HttpRequest request, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, OperationType operationType = OperationType.Create, - bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null) - => PostWithResultAsync(request, (p, _) => function(p), statusCode, operationType, valueIsRequired, validator, locationUri, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The . - /// The corresponding where successful. - public Task PostWithResultAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, OperationType operationType = OperationType.Create, - bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - => PostWithResultInternalAsync(request, false, default!, function, statusCode, operationType, valueIsRequired, validator, locationUri, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The corresponding where successful. - public Task PostWithResultAsync(HttpRequest request, TValue value, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, OperationType operationType = OperationType.Create, - bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null) - => PostWithResultAsync(request, value, (p, _) => function(p), statusCode, operationType, valueIsRequired, validator, locationUri, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The . - /// The corresponding where successful. - public Task PostWithResultAsync(HttpRequest request, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - => PostWithResultInternalAsync(request, true, value, function, statusCode, operationType, valueIsRequired, validator, locationUri, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value (with a ). - /// - private async Task PostWithResultInternalAsync(HttpRequest request, bool useValue, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPost(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Post}' to use {nameof(PostAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, valueIsRequired, validator, cancellationToken).ConfigureAwait(false); - if (vex != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - var result = await function(wapv!, ct).ConfigureAwait(false); - return result.IsFailure - ? await CreateActionResultFromExceptionAsync(this, request.HttpContext, result.Error, Settings, Logger, UnhandledExceptionAsync, ct).ConfigureAwait(false) - : new ExtendedStatusCodeResult(statusCode) { Location = locationUri?.Invoke() }; - }, operationType, cancellationToken, nameof(PostWithResultAsync)).ConfigureAwait(false); - } - - /// - /// Performs a operation with no request body and a response of (with a ). - /// - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// The optional function to set the location . - /// The (either on non-null result; otherwise, a ). - public Task PostWithResultAsync(HttpRequest request, Func>> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Create, Func? locationUri = null) - => PostWithResultAsync(request, (p, _) => function(p), statusCode, alternateStatusCode, operationType, locationUri, CancellationToken.None); - - /// - /// Performs a operation with no request body and a response of (with a ). - /// - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// The optional function to set the location . - /// The . - /// The (either on non-null result; otherwise, a ). - public async Task PostWithResultAsync(HttpRequest request, Func>> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Create, Func? locationUri = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPost(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Post}' to use {nameof(PostAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var result = await function(wap, ct).ConfigureAwait(false); - return result.IsFailure - ? await CreateActionResultFromExceptionAsync(this, request.HttpContext, result.Error, Settings, Logger, UnhandledExceptionAsync, ct).ConfigureAwait(false) - : ValueContentResult.CreateResult(result.Value, statusCode, alternateStatusCode, JsonSerializer, wap.RequestOptions, checkForNotModified: false, location: locationUri?.Invoke(result)); - }, operationType, cancellationToken, nameof(PostWithResultAsync)).ConfigureAwait(false); - } - - /// - /// Performs a operation with a request JSON content value of and a response of (with a ). - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The (either on non-null result; otherwise, a ). - public Task PostWithResultAsync(HttpRequest request, Func, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null) - => PostWithResultAsync(request, (p, _) => function(p), statusCode, alternateStatusCode, operationType, valueIsRequired, validator, locationUri, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and a response of (with a ). - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The . - /// The (either on non-null result; otherwise, a ). - public Task PostWithResultAsync(HttpRequest request, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - => PostWithResultInternalAsync(request, false, default!, function, statusCode, alternateStatusCode, operationType, valueIsRequired, validator, locationUri, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and a response of (with a ). - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The (either on non-null result; otherwise, a ). - public Task PostWithResultAsync(HttpRequest request, TValue value, Func, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null) - => PostWithResultAsync(request, value, (p, _) => function(p), statusCode, alternateStatusCode, operationType, valueIsRequired, validator, locationUri, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and a response of (with a ). - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The optional function to set the location . - /// The . - /// The (either on non-null result; otherwise, a ). - public Task PostWithResultAsync(HttpRequest request, TValue value, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - => PostWithResultInternalAsync(request, true, value, function, statusCode, alternateStatusCode, operationType, valueIsRequired, validator, locationUri, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and a response of (with a ). - /// - private async Task PostWithResultInternalAsync(HttpRequest request, bool useValue, TValue value, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Create, bool valueIsRequired = true, IValidator? validator = null, Func? locationUri = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPost(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Post}' to use {nameof(PostAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, valueIsRequired, validator, ct).ConfigureAwait(false); - if (vex != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - var result = await function(wapv!, ct).ConfigureAwait(false); - return result.IsFailure - ? await CreateActionResultFromExceptionAsync(this, request.HttpContext, result.Error, Settings, Logger, UnhandledExceptionAsync, ct).ConfigureAwait(false) - : ValueContentResult.CreateResult(result.Value, statusCode, alternateStatusCode, JsonSerializer, wap.RequestOptions, checkForNotModified: false, location: locationUri?.Invoke(result)); - }, operationType, cancellationToken, nameof(PostWithResultAsync)).ConfigureAwait(false); - } - - #endregion - - #region PutWithResultAsync - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The corresponding where successful. - public Task PutWithResultAsync(HttpRequest request, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null) - => PutWithResultAsync(request, (p, _) => function(p), statusCode, operationType, valueIsRequired, validator, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The . - /// The corresponding where successful. - public Task PutWithResultAsync(HttpRequest request, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - => PutWithResultInternalAsync(request, false, default!, function, statusCode, operationType, valueIsRequired, validator, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The corresponding where successful. - public Task PutWithResultAsync(HttpRequest request, TValue value, Func, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null) - => PutWithResultInternalAsync(request, true, value, (p, _) => function(p), statusCode, operationType, valueIsRequired, validator, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The . - /// The corresponding where successful. - public Task PutWithResultAsync(HttpRequest request, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - => PutWithResultInternalAsync(request, true, value, function, statusCode, operationType, valueIsRequired, validator, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and no corresponding response value (with a ). - /// - private async Task PutWithResultInternalAsync(HttpRequest request, bool useValue, TValue value, Func, CancellationToken, Task> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, - OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPut(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Put}' to use {nameof(PutWithResultAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, valueIsRequired, validator, cancellationToken).ConfigureAwait(false); - if (vex != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - var result = await function(wapv!, ct).ConfigureAwait(false); - return result.IsFailure - ? await CreateActionResultFromExceptionAsync(this, request.HttpContext, result.Error, Settings, Logger, UnhandledExceptionAsync, ct).ConfigureAwait(false) - : new ExtendedStatusCodeResult(statusCode); - }, operationType, cancellationToken, nameof(PutWithResultAsync)).ConfigureAwait(false); - } - - /// - /// Performs a operation with a request JSON content value of and a response of (with a ). - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The (either on non-null result; otherwise, a ). - public Task PutWithResultAsync(HttpRequest request, Func, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null) - => PutWithResultAsync(request, (p, _) => function(p), statusCode, alternateStatusCode, operationType, valueIsRequired, validator, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and a response of (with a ). - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The . - /// The (either on non-null result; otherwise, a ). - public Task PutWithResultAsync(HttpRequest request, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - => PutWithResultInternalAsync(request, false, default!, function, statusCode, alternateStatusCode, operationType, valueIsRequired, validator, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and a response of (with a ). - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The (either on non-null result; otherwise, a ). - public Task PutWithResultAsync(HttpRequest request, TValue value, Func, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null) - => PutWithResultAsync(request, value, (p, _) => function(p), statusCode, alternateStatusCode, operationType, valueIsRequired, validator, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of and a response of (with a ). - /// - /// The request JSON content value . - /// The response result . - /// The . - /// The value (already deserialized). - /// The function to execute. - /// The where successful. - /// The alternate where result is null. - /// The . - /// Indicates whether the request value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The . - /// The (either on non-null result; otherwise, a ). - public Task PutWithResultAsync(HttpRequest request, TValue value, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - => PutWithResultInternalAsync(request, true, value, function, statusCode, alternateStatusCode, operationType, valueIsRequired, validator, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of and a response of (with a ). - /// - public async Task PutWithResultInternalAsync(HttpRequest request, bool useValue, TValue value, Func, CancellationToken, Task>> function, HttpStatusCode statusCode = HttpStatusCode.OK, - HttpStatusCode alternateStatusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Update, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsPut(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Put}' to use {nameof(PutWithResultAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, valueIsRequired, validator, ct).ConfigureAwait(false); - if (vex != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - var result = await function(wapv!, ct).ConfigureAwait(false); - return result.IsFailure - ? await CreateActionResultFromExceptionAsync(this, request.HttpContext, result.Error, Settings, Logger, UnhandledExceptionAsync, ct).ConfigureAwait(false) - : ValueContentResult.CreateResult(result.Value, statusCode, alternateStatusCode, JsonSerializer, wap.RequestOptions, checkForNotModified: false, location: null); - }, operationType, cancellationToken, nameof(PutWithResultAsync)).ConfigureAwait(false); - } - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The function to execute the get to retrieve the value. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The corresponding where successful. - public Task PutWithResultAsync(HttpRequest request, Func>> get, Func, Task>> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false) where TValue : class - => PutWithResultAsync(request, (p, _) => get(p), (p, _) => put(p), statusCode, operationType, validator, simulatedConcurrency, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The function to execute the get to retrieve the value. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The . - /// The corresponding where successful. - public Task PutWithResultAsync(HttpRequest request, Func>> get, Func, CancellationToken, Task>> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false, CancellationToken cancellationToken = default) where TValue : class - => PutWithResultInternalAsync(request, false, default!, get, put, statusCode, operationType, validator, simulatedConcurrency, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute the get to retrieve the value. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The corresponding where successful. - public Task PutWithResultAsync(HttpRequest request, TValue value, Func>> get, Func, Task>> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false) where TValue : class - => PutWithResultAsync(request, value, (p, _) => get(p), (p, _) => put(p), statusCode, operationType, validator, simulatedConcurrency, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The value (already deserialized). - /// The function to execute the get to retrieve the value. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The . - /// The corresponding where successful. - public Task PutWithResultAsync(HttpRequest request, TValue value, Func>> get, Func, CancellationToken, Task>> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false, CancellationToken cancellationToken = default) where TValue : class - => PutWithResultInternalAsync(request, true, value, get, put, statusCode, operationType, validator, simulatedConcurrency, cancellationToken); - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value (with a ). - /// - private async Task PutWithResultInternalAsync(HttpRequest request, bool useValue, TValue value, Func>> get, Func, CancellationToken, Task>> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false, CancellationToken cancellationToken = default) where TValue : class - { - request.ThrowIfNull(nameof(request)); - get.ThrowIfNull(nameof(get)); - put.ThrowIfNull(nameof(put)); - - if (!HttpMethods.IsPut(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Put}' to use {nameof(PutWithResultAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var (wapv, vex) = await ValidateValueAsync(wap, useValue, value, true, validator, ct).ConfigureAwait(false); - if (vex != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, vex, Settings, Logger, OnUnhandledExceptionAsync, cancellationToken).ConfigureAwait(false); - - // Get the current value before we perform the update; also performing a concurrency match. - var cresult = await get(wap, ct).ConfigureAwait(false); - var ex = cresult.IsFailure ? cresult.Error : (cresult.Value == null ? new NotFoundException() : ConcurrencyETagMatching(wap, cresult.Value, wapv!.Value, simulatedConcurrency)); - - if (ex is not null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, ex, Settings, Logger, OnUnhandledExceptionAsync, ct).ConfigureAwait(false); - - // Update the value. - var result = await put(wapv!, ct).ConfigureAwait(false); - return result.IsFailure - ? await CreateActionResultFromExceptionAsync(this, request.HttpContext, result.Error, Settings, Logger, UnhandledExceptionAsync, ct).ConfigureAwait(false) - : ValueContentResult.CreateResult(result.Value, statusCode, null, JsonSerializer, wap.RequestOptions, checkForNotModified: false, location: null); - }, operationType, cancellationToken, nameof(PutWithResultAsync)).ConfigureAwait(false); - } - - #endregion - - #region DeleteWithResultAsync - - /// - /// Performs a operation (with a ). - /// - /// The . - /// The function to execute. - /// The where result is not null. - /// The . - /// The corresponding where successful. - public Task DeleteWithResultAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Delete) - => DeleteWithResultAsync(request, (p, _) => function(p), statusCode, operationType, CancellationToken.None); - - /// - /// Performs a operation (with a ). - /// - /// The . - /// The function to execute. - /// The where result is not null. - /// The . - /// The . - /// The corresponding where successful. - public async Task DeleteWithResultAsync(HttpRequest request, Func> function, HttpStatusCode statusCode = HttpStatusCode.NoContent, OperationType operationType = OperationType.Delete, CancellationToken cancellationToken = default) - { - request.ThrowIfNull(nameof(request)); - function.ThrowIfNull(nameof(function)); - - if (!HttpMethods.IsDelete(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Delete}' to use {nameof(DeleteWithResultAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - var result = await function(wap, ct).ConfigureAwait(false); - if (result.IsFailure) - { - // Return default status code where configured for a NotFoundException. - if (!(ConvertNotfoundToDefaultStatusCodeOnDelete && (result.Error is NotFoundException || (result.Error is AggregateException ae && ae.InnerException is NotFoundException)))) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, result.Error, Settings, Logger, OnUnhandledExceptionAsync, ct).ConfigureAwait(false); - } - - return new ExtendedStatusCodeResult(statusCode); - }, operationType, cancellationToken, nameof(DeleteWithResultAsync)).ConfigureAwait(false); - } - - #endregion - - #region PatchWithResultAsync - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The function to execute the get to retrieve the value to patch into. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the patched value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The corresponding where successful. - /// Currently on the is supported. - public Task PatchWithResultAsync(HttpRequest request, Func>> get, Func, Task>> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false) where TValue : class - => PatchWithResultAsync(request, (p, _) => get(p), (p, _) => put(p), statusCode, operationType, validator, simulatedConcurrency, CancellationToken.None); - - /// - /// Performs a operation with a request JSON content value of returning a corresponding response value (with a ). - /// - /// The request JSON content value . - /// The . - /// The function to execute the get to retrieve the value to patch into. Where this returns a null then this will result in a of . - /// The function to execute the put to replace (update) the patched value. - /// The where successful. - /// The . - /// The to validate the deserialized value. - /// Indicates whether simulated concurrency (ETag) checking/generation is performed as underlying data source does not support. - /// The . - /// The corresponding where successful. - /// Currently on the is supported. - public async Task PatchWithResultAsync(HttpRequest request, Func>> get, Func, CancellationToken, Task>> put, HttpStatusCode statusCode = HttpStatusCode.OK, - OperationType operationType = OperationType.Update, IValidator? validator = null, bool simulatedConcurrency = false, CancellationToken cancellationToken = default) where TValue : class - { - request.ThrowIfNull(nameof(request)); - get.ThrowIfNull(nameof(get)); - put.ThrowIfNull(nameof(put)); - - if (JsonMergePatch == null) - throw new InvalidOperationException($"To use the '{nameof(PatchWithResultAsync)}' methods the '{nameof(JsonMergePatch)}' object must be passed in the constructor. Where using dependency injection consider using '{nameof(Microsoft.Extensions.DependencyInjection.IServiceCollectionExtensions.AddJsonMergePatch)}' to add and configure the supported options."); - - if (!HttpMethods.IsPatch(request.Method)) - throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{HttpMethods.Patch}' to use {nameof(PatchWithResultAsync)}.", nameof(request)); - - return await RunAsync(request, async (wap, ct) => - { - // Make sure that the only the support content types are used. - var hct = request.GetTypedHeaders()?.ContentType?.MediaType.Value; - if (StringComparer.OrdinalIgnoreCase.Compare(hct, HttpConsts.MergePatchMediaTypeName) != 0 && StringComparer.OrdinalIgnoreCase.Compare(hct, MediaTypeNames.Application.Json) != 0) - return new ContentResult - { - StatusCode = (int)HttpStatusCode.UnsupportedMediaType, - ContentType = MediaTypeNames.Text.Plain, - Content = $"Unsupported 'Content-Type' for a PATCH; only JSON Merge Patch is supported using either: 'application/merge-patch+json' or '{MediaTypeNames.Application.Json}'." - }; - - // Retrieve the JSON content string; there must be some content of some type. - var json = await request.ReadAsBinaryDataAsync(true, ct).ConfigureAwait(false); - if (json.Exception != null) - return await CreateActionResultFromExceptionAsync(this, request.HttpContext, json.Exception, Settings, Logger, UnhandledExceptionAsync, ct).ConfigureAwait(false); - - // Note: the JsonMergePatch will throw JsonMergePatchException on error which will be automatically converted to an appropriate IActionResult by the invoking RunAsync method. - var mresult = await JsonMergePatch.MergeWithResultAsync(json.Content!, async (jpv, ct2) => - { - // Get the current value and perform a concurrency match before we perform the merge. - var rv = await get(wap, ct2).ConfigureAwait(false); - var ex = rv.IsFailure ? rv.Error : (rv.Value is null ? new NotFoundException() : ConcurrencyETagMatching(wap, rv.Value, jpv, simulatedConcurrency)); - return ex is null ? Result.Ok(rv.Value) : Result.Fail(ex); - }, ct).ConfigureAwait(false); - - // Only invoke the put function where something was *actually* changed. - var result = await mresult.ThenAsAsync(async v => - { - if (v.HasChanges) - { - if (validator != null) - { - var vr = await validator.ValidateAsync(v.Value!, ct).ConfigureAwait(false); - if (vr.HasErrors) - return vr.ToException()!; - } - - return await put(new WebApiParam(wap, v.Value!), ct).ConfigureAwait(false); - } - - return Result.Ok(v.Value!); - }).ConfigureAwait(false); - - return result.IsFailure - ? await CreateActionResultFromExceptionAsync(this, request.HttpContext, result.Error, Settings, Logger, UnhandledExceptionAsync, cancellationToken).ConfigureAwait(false) - : ValueContentResult.CreateResult(result.Value, statusCode, null, JsonSerializer, wap.RequestOptions, checkForNotModified: false, location: null); - }, operationType, cancellationToken, nameof(PatchWithResultAsync)).ConfigureAwait(false); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.AspNetCore/strong-name-key.snk b/src/CoreEx.AspNetCore/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.AutoMapper/AutoMapperConverterWrapper.cs b/src/CoreEx.AutoMapper/AutoMapperConverterWrapper.cs deleted file mode 100644 index 25060832..00000000 --- a/src/CoreEx.AutoMapper/AutoMapperConverterWrapper.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Wraps an to enable to/from capabilities. - /// - /// The source . - /// The destination . - /// The . - /// The declaring itself to enable . - /// The optional function to create the instance. - public abstract class AutoMapperConverterWrapper(Func? create = null) - where TConverter : IConverter, new() - where TSelf : AutoMapperConverterWrapper, new() - { - private readonly Func _create = create ?? (() => new TConverter()); - - /// - /// Gets or sets the default (singleton) instance. - /// - public static TSelf Default { get; set; } = new(); - - /// - /// Gets the source to destination . - /// - public AutoMapper.IValueConverter ToDestination => new ToDestinationMapper(_create().ToDestination); - - /// - /// Gets the destination to source . - /// - public AutoMapper.IValueConverter ToSource => new ToSourceMapper(_create().ToSource); - - /// - /// Represents the source to destination struct. - /// - public readonly struct ToDestinationMapper : AutoMapper.IValueConverter - { - private readonly IValueConverter _valueConverter; - - internal ToDestinationMapper(IValueConverter valueConverter) => _valueConverter = valueConverter; - - /// - public TDestination Convert(TSource sourceMember, AutoMapper.ResolutionContext context) => _valueConverter.Convert(sourceMember)!; - } - - /// - /// Represents the destination to source struct. - /// - public readonly struct ToSourceMapper : AutoMapper.IValueConverter - { - private readonly IValueConverter _valueConverter; - - internal ToSourceMapper(IValueConverter valueConverter) => _valueConverter = valueConverter; - - /// - public TSource Convert(TDestination sourceMember, AutoMapper.ResolutionContext context) => _valueConverter.Convert(sourceMember)!; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AutoMapper/AutoMapperExtensions.cs b/src/CoreEx.AutoMapper/AutoMapperExtensions.cs deleted file mode 100644 index 7cb5766c..00000000 --- a/src/CoreEx.AutoMapper/AutoMapperExtensions.cs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using AutoMapper; -using AutoMapper.Configuration; -using System; - -namespace CoreEx.Mapping -{ - /// - /// Adds additional extension methods for AutoMapper. - /// - public static class AutoMapperExtensions - { - /// - /// Gets the name used for indexing . - /// - public const string OperationTypesName = nameof(OperationTypes); - - /// - /// Conditionally map this member with the specified the against the value, evaluated before accessing the source value. - /// - /// The source entity . - /// The destination entity . - /// The source entity member . - /// The . - /// The . - /// Uses the . - public static IMemberConfigurationExpression OperationTypes(this IMemberConfigurationExpression mce, OperationTypes operationTypes) - { - mce.ThrowIfNull(nameof(mce)).PreCondition((ResolutionContext rc) => !rc.Items.TryGetValue(OperationTypesName, out var ot) || operationTypes.HasFlag((OperationTypes)ot)); - return mce; - } - - /// - /// Conditionally map this path with the specified the against the value, evaluated before accessing the source value. - /// - /// The source entity . - /// The destination entity . - /// The member . - /// The . - /// The . - /// Uses the . - public static IPathConfigurationExpression OperationTypes(this IPathConfigurationExpression pce, OperationTypes operationTypes) - { - pce.ThrowIfNull(nameof(pce)).Condition(cp => !cp.Context.Items.TryGetValue(OperationTypesName, out var ot) || operationTypes.HasFlag((OperationTypes)ot)); - return pce; - } - - /// - /// Maps the (inferring ) value to a new value. - /// - /// The destination . - /// The . - /// The source value. - /// The singluar CRUD value being performed. - /// The destination value. - public static TDestination Map(this AutoMapper.IMapper mapper, object source, OperationTypes operationType) - => mapper.ThrowIfNull(nameof(mapper)).Map(source, o => o.Items.Add(OperationTypesName, operationType)); - - /// - /// Maps the value to a new value. - /// - /// The source . - /// The destination . - /// The . - /// The source value. - /// The singluar CRUD value being performed. - /// The destination value. - public static TDestination Map(this AutoMapper.IMapper mapper, TSource source, OperationTypes operationType) - => mapper.ThrowIfNull(nameof(mapper)).Map(source, o => o.Items.Add(OperationTypesName, operationType)); - - /// - /// Maps the value into the existing value. - /// - /// The source . - /// The destination . - /// The . - /// The source value. - /// The destination value. - /// The singluar CRUD value being performed. - /// The value. - public static TDestination Map(this AutoMapper.IMapper mapper, TSource source, TDestination destination, OperationTypes operationType) - => mapper.ThrowIfNull(nameof(mapper)).Map(source, destination, o => o.Items.Add(OperationTypesName, operationType)); - } -} \ No newline at end of file diff --git a/src/CoreEx.AutoMapper/AutoMapperProfile.cs b/src/CoreEx.AutoMapper/AutoMapperProfile.cs deleted file mode 100644 index 6eeda98b..00000000 --- a/src/CoreEx.AutoMapper/AutoMapperProfile.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using AutoMapper; -using CoreEx.Entities; -using System.Reflection; - -namespace CoreEx.Mapping -{ - /// - /// Represents the core AutoMapper for CoreEx. - /// - /// Automatically sets the mapping between and . - public class AutoMapperProfile : Profile - { - /// - /// Gets the CoreEx . - /// - public static Assembly Assembly => typeof(AutoMapperProfile).Assembly; - - /// - /// Initializes a new instance of the class. - /// - public AutoMapperProfile() - { - // Standardize ChangeLog -> Changelog mapping with the OperationTypes condition. - CreateMap() - .ForMember(d => d.CreatedBy, o => o.OperationTypes(OperationTypes.AnyExceptUpdate).MapFrom(s => s.CreatedBy)) - .ForMember(d => d.CreatedDate, o => o.OperationTypes(OperationTypes.AnyExceptUpdate).MapFrom(s => s.CreatedDate)) - .ForMember(d => d.UpdatedBy, o => o.OperationTypes(OperationTypes.AnyExceptCreate).MapFrom(s => s.UpdatedBy)) - .ForMember(d => d.UpdatedDate, o => o.OperationTypes(OperationTypes.AnyExceptCreate).MapFrom(s => s.UpdatedDate)); - - CreateMap() - .ForMember(d => d.CreatedBy, o => o.OperationTypes(OperationTypes.AnyExceptUpdate).MapFrom(s => s.CreatedBy)) - .ForMember(d => d.CreatedDate, o => o.OperationTypes(OperationTypes.AnyExceptUpdate).MapFrom(s => s.CreatedDate)) - .ForMember(d => d.UpdatedBy, o => o.OperationTypes(OperationTypes.AnyExceptCreate).MapFrom(s => s.UpdatedBy)) - .ForMember(d => d.UpdatedDate, o => o.OperationTypes(OperationTypes.AnyExceptCreate).MapFrom(s => s.UpdatedDate)) - .ReverseMap(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.AutoMapper/AutoMapperServiceCollectionExtensions.cs b/src/CoreEx.AutoMapper/AutoMapperServiceCollectionExtensions.cs deleted file mode 100644 index 5a7b9508..00000000 --- a/src/CoreEx.AutoMapper/AutoMapperServiceCollectionExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Mapping; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extenstion methods. - /// - public static class AutoMapperServiceCollectionExtensions - { - /// - /// Adds an to wrap the as a singleton services. - /// - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddAutoMapperWrapper(this IServiceCollection services) - => services.ThrowIfNull(nameof(services)).AddSingleton(sp => new AutoMapperWrapper(sp.GetRequiredService())); - } -} \ No newline at end of file diff --git a/src/CoreEx.AutoMapper/AutoMapperWrapper.cs b/src/CoreEx.AutoMapper/AutoMapperWrapper.cs deleted file mode 100644 index 575274ac..00000000 --- a/src/CoreEx.AutoMapper/AutoMapperWrapper.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Mapping -{ - /// - /// Represents an wrapper to enable CoreEx . - /// - /// The being wrapped. - public class AutoMapperWrapper(AutoMapper.IMapper autoMapper) : IMapper - { - /// - /// Gets the wrapped - /// - public AutoMapper.IMapper Mapper { get; } = autoMapper.ThrowIfNull(nameof(autoMapper)); - - /// - public TDestination? Map(object? source, OperationTypes operationType = OperationTypes.Unspecified) => Mapper.Map(source!, operationType); - - /// - public TDestination? Map(TSource? source, OperationTypes operationType = OperationTypes.Unspecified) => Mapper.Map(source!, operationType); - - /// - public TDestination? Map(TSource? source, TDestination? destination, OperationTypes operationType = OperationTypes.Unspecified) => Mapper.Map(source, destination, operationType); - } -} \ No newline at end of file diff --git a/src/CoreEx.AutoMapper/Converters/AutoMapperObjectToJsonConverter.cs b/src/CoreEx.AutoMapper/Converters/AutoMapperObjectToJsonConverter.cs deleted file mode 100644 index 2f8203fc..00000000 --- a/src/CoreEx.AutoMapper/Converters/AutoMapperObjectToJsonConverter.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Mapping.Converters -{ - /// - /// Wraps a to enable for AutoMapper. - /// - public class AutoMapperObjectToJsonConverter : AutoMapperConverterWrapper, AutoMapperObjectToJsonConverter> { } -} \ No newline at end of file diff --git a/src/CoreEx.AutoMapper/Converters/AutoMapperReferenceDataCodeConverter.cs b/src/CoreEx.AutoMapper/Converters/AutoMapperReferenceDataCodeConverter.cs deleted file mode 100644 index 1193d5cb..00000000 --- a/src/CoreEx.AutoMapper/Converters/AutoMapperReferenceDataCodeConverter.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Wraps a to enable for AutoMapper. - /// - public class AutoMapperReferenceDataCodeConverter : AutoMapperConverterWrapper, AutoMapperReferenceDataCodeConverter> where TRef : IReferenceData, new() { } -} \ No newline at end of file diff --git a/src/CoreEx.AutoMapper/Converters/AutoMapperReferenceDataIdConverter.cs b/src/CoreEx.AutoMapper/Converters/AutoMapperReferenceDataIdConverter.cs deleted file mode 100644 index a002b05c..00000000 --- a/src/CoreEx.AutoMapper/Converters/AutoMapperReferenceDataIdConverter.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Wraps a to enable for AutoMapper. - /// - public class AutoMapperReferenceDataIdConverter : AutoMapperConverterWrapper, AutoMapperReferenceDataIdConverter> where TRef : IReferenceData, new() { } -} \ No newline at end of file diff --git a/src/CoreEx.AutoMapper/Converters/AutoMapperStringToBase64Converter.cs b/src/CoreEx.AutoMapper/Converters/AutoMapperStringToBase64Converter.cs deleted file mode 100644 index 7135c68a..00000000 --- a/src/CoreEx.AutoMapper/Converters/AutoMapperStringToBase64Converter.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Mapping.Converters -{ - /// - /// Wraps a to enable for AutoMapper. - /// - public class AutoMapperStringToBase64Converter : AutoMapperConverterWrapper { } -} \ No newline at end of file diff --git a/src/CoreEx.AutoMapper/Converters/AutoMapperTypeToStringConverter.cs b/src/CoreEx.AutoMapper/Converters/AutoMapperTypeToStringConverter.cs deleted file mode 100644 index 825abd9e..00000000 --- a/src/CoreEx.AutoMapper/Converters/AutoMapperTypeToStringConverter.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Mapping.Converters -{ - /// - /// Wraps a to enable for AutoMapper. - /// - public class AutoMapperTypeToStringConverter : AutoMapperConverterWrapper, AutoMapperTypeToStringConverter> { } -} \ No newline at end of file diff --git a/src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj b/src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj deleted file mode 100644 index 17cfcf32..00000000 --- a/src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net6.0;net8.0;net9.0 - CoreEx.Mapping - CoreEx.AutoMapper - CoreEx .NET AutoMapper Extensions. - CoreEx .NET AutoMapper Extensions. - coreex automapper mapper imapper - - - - - - - - - - - - - diff --git a/src/CoreEx.AutoMapper/strong-name-key.snk b/src/CoreEx.AutoMapper/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ProcessMessageEventArgsActions.cs b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ProcessMessageEventArgsActions.cs new file mode 100644 index 00000000..4fe9f811 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ProcessMessageEventArgsActions.cs @@ -0,0 +1,27 @@ +namespace CoreEx.Azure.Messaging.ServiceBus.Abstractions; + +/// +/// Provides an implementation of for the . +/// +/// The . +public sealed class ProcessMessageEventArgsActions(ProcessMessageEventArgs args) : ServiceBusMessageActionsBase +{ + private readonly ProcessMessageEventArgs _args = args.ThrowIfNull(); + + /// + public override string EntityPath => _args.EntityPath; + + /// + public override Amqp.AmqpAnnotatedMessage AmqpMessage => _args.Message.GetRawAmqpMessage(); + + /// + protected override Task OnCompletedMessageAsync(CancellationToken cancellationToken) => _args.CompleteMessageAsync(_args.Message, cancellationToken); + + /// + protected override Task OnAbandonedMessageAsync(Exception exception, CancellationToken cancellationToken) + => _args.AbandonMessageAsync(_args.Message, new Dictionary { { AbandonReasonName, FormatText(exception.Message, NoneReasonText)! } }, cancellationToken); + + /// + protected override Task OnDeadLetteredMessageAsync(Exception exception, CancellationToken cancellationToken) + => _args.DeadLetterMessageAsync(_args.Message, FormatText(exception.Message, NoneReasonText), FormatText(exception.StackTrace), cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ProcessSessionMessageEventArgsActions.cs b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ProcessSessionMessageEventArgsActions.cs new file mode 100644 index 00000000..ce2cbcb1 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ProcessSessionMessageEventArgsActions.cs @@ -0,0 +1,27 @@ +namespace CoreEx.Azure.Messaging.ServiceBus.Abstractions; + +/// +/// Provides an implementation of for the . +/// +/// The . +public sealed class ProcessSessionMessageEventArgsActions(ProcessSessionMessageEventArgs args) : ServiceBusMessageActionsBase +{ + private readonly ProcessSessionMessageEventArgs _args = args.ThrowIfNull(); + + /// + public override string EntityPath => _args.EntityPath; + + /// + public override Amqp.AmqpAnnotatedMessage AmqpMessage => _args.Message.GetRawAmqpMessage(); + + /// + protected override Task OnCompletedMessageAsync(CancellationToken cancellationToken) => _args.CompleteMessageAsync(_args.Message, cancellationToken); + + /// + protected override Task OnAbandonedMessageAsync(Exception exception, CancellationToken cancellationToken) + => _args.AbandonMessageAsync(_args.Message, new Dictionary { { ProcessMessageEventArgsActions.AbandonReasonName, FormatText(exception.Message, ProcessMessageEventArgsActions.NoneReasonText)! } }, cancellationToken); + + /// + protected override Task OnDeadLetteredMessageAsync(Exception exception, CancellationToken cancellationToken) + => _args.DeadLetterMessageAsync(_args.Message, FormatText(exception.Message, ProcessMessageEventArgsActions.NoneReasonText), FormatText(exception.StackTrace), cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusErrorClassifier.cs b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusErrorClassifier.cs new file mode 100644 index 00000000..c3345874 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusErrorClassifier.cs @@ -0,0 +1,87 @@ +namespace CoreEx.Azure.Messaging.ServiceBus.Abstractions; + +/// +/// Provides utility methods for classifying and interpreting a . +/// +public static class ServiceBusErrorClassifier +{ + /// + /// Classifies the specified Azure Service Bus error and logs an appropriate message based on its severity. + /// + /// The . + /// The containing details about the error. + /// where the error was classified as a known scenario (e.g., lock lost, transient, idle connection closed); otherwise, . + public static bool ClassifyAndLogError(ILogger logger, ProcessErrorEventArgs args) + { + if (args.Exception is ServiceBusException sbex) + { + if (IsLockLost(sbex)) + { + if (logger.IsEnabled(LogLevel.Information)) + logger.LogInformation(sbex, "A lock lost scenario occurred on entity {EntityPath} with error source {ErrorSource}.", args.EntityPath, args.ErrorSource); + + return true; + } + else if (IsTransient(sbex)) + { + if (logger.IsEnabled(LogLevel.Information)) + logger.LogInformation(sbex, "A transient error occurred on entity {EntityPath} with error source {ErrorSource}.", args.EntityPath, args.ErrorSource); + + return true; + } + else if (IsIdleConnectionClosed(sbex)) + { + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug(sbex, "An idle connection closed scenario occurred on entity {EntityPath} with error source {ErrorSource}.", args.EntityPath, args.ErrorSource); + + return true; + } + } + + if (logger.IsEnabled(LogLevel.Error)) + logger.LogError(args.Exception, "An error occurred within the Service Bus Session Processor on entity {EntityPath} with error source {ErrorSource}.", args.EntityPath, args.ErrorSource); + + return false; + } + + + /// + /// Indicates whether the given is a lock lost scenario, which typically occurs when a message lock expires before processing is completed, or when a session lock is lost. + /// + /// The . + /// This can happen due to various reasons such as long processing times, network issues, or other transient conditions that cause the lock to be released by the Service Bus. + public static bool IsLockLost(ServiceBusException exception) => exception.Reason == ServiceBusFailureReason.MessageLockLost || exception.Reason == ServiceBusFailureReason.SessionLockLost; + + /// + /// Indicates whether the given is considered transient, meaning it is likely to be resolved by retrying the operation after a delay. + /// + /// The . + /// This typically includes exceptions that occur due to temporary issues such as network connectivity problems, service unavailability, or throttling by the Service Bus. + public static bool IsTransient(ServiceBusException exception) + { + // Best broad signal the SDK gives you. + if (exception.IsTransient) + return true; + + // Optional explicit belt-and-braces cases. + return exception.Reason == ServiceBusFailureReason.ServiceTimeout + || exception.Reason == ServiceBusFailureReason.ServiceBusy + || exception.Reason == ServiceBusFailureReason.ServiceCommunicationProblem + || IsIdleConnectionClosed(exception); + } + + /// + /// Indicates whether the given is due to an idle connection being closed by the Service Bus, which can occur when a connection remains idle for an extended period and is automatically closed by the Service Bus to free up resources. + /// + /// The . + public static bool IsIdleConnectionClosed(ServiceBusException exception) + { + if (exception.Message is null) + return false; + + // This is a bit of a hack as the SDK does not provide a specific reason for this scenario, but it is a known pattern that can be identified by the message content. + return exception.Reason == ServiceBusFailureReason.GeneralError + && exception.Message.Contains("did not have any active links", StringComparison.OrdinalIgnoreCase) + && exception.Message.Contains("The connection was closed by container", StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusMessageActionsBase.cs b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusMessageActionsBase.cs new file mode 100644 index 00000000..72b8b099 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusMessageActionsBase.cs @@ -0,0 +1,72 @@ +namespace CoreEx.Azure.Messaging.ServiceBus.Abstractions; + +/// +/// Provides the base implementation for Azure Service Bus, including the recording of metrics for each action. +/// +public abstract class ServiceBusMessageActionsBase : IServiceBusMessageActions +{ + /// + public abstract string EntityPath { get; } + + /// + public abstract Amqp.AmqpAnnotatedMessage AmqpMessage { get; } + + /// + public Task CompleteMessageAsync(CancellationToken cancellationToken) + { + ServiceBusMetrics.MessagesReceivedComplete.Add(1, [new(ServiceBusMetrics.SourceTagName, EntityPath)]); + return OnCompletedMessageAsync(cancellationToken); + } + + /// + /// Marks the current message as completed, indicating that it has been processed successfully. + /// + /// The . + protected abstract Task OnCompletedMessageAsync(CancellationToken cancellationToken); + + /// + public Task AbandonMessageAsync(Exception exception, CancellationToken cancellationToken) + { + ServiceBusMetrics.MessagesReceivedAbandoned.Add(1, [new(ServiceBusMetrics.SourceTagName, EntityPath)]); + return OnAbandonedMessageAsync(exception, cancellationToken); + } + + /// + /// Marks the current message as abandoned, indicating that it could not be processed successfully, and records the specified as the reason. + /// + /// The that describes the reason for abandoning the message. + /// The . + protected abstract Task OnAbandonedMessageAsync(Exception exception, CancellationToken cancellationToken); + + /// + public Task DeadLetterMessageAsync(Exception exception, CancellationToken cancellationToken) + { + ServiceBusMetrics.MessagesReceivedDeadLetter.Add(1, [new(ServiceBusMetrics.SourceTagName, EntityPath)]); + return OnDeadLetteredMessageAsync(exception, cancellationToken); + } + + /// + /// Marks the current message as abandoned, indicating that it could not be processed successfully, and records the specified as the reason. + /// + /// The that describes the reason for abandoning the message. + /// The . + protected abstract Task OnDeadLetteredMessageAsync(Exception exception, CancellationToken cancellationToken); + + /// + /// Gets the name of the property within the that contains the abandon reason. + /// + public const string AbandonReasonName = "AbandonReason"; + + /// + /// Gets the text used where no reason is available. + /// + public static LText NoneReasonText { get; } = new LText("CoreEx.Azure.Messaging.ServiceBus.None", "None."); + + /// + /// Formats the text for logging purposes, truncating to 512 characters. + /// + /// The text to format. + /// The default where the is . + /// The formatted text. + public static string? FormatText(string? text, string? @default = null) => text?[..Math.Min(text.Length, 512)] ?? @default; +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBase.Static.cs b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBase.Static.cs new file mode 100644 index 00000000..8e6cf18d --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBase.Static.cs @@ -0,0 +1,115 @@ +namespace CoreEx.Azure.Messaging.ServiceBus.Abstractions; + +public abstract partial class ServiceBusReceiverBase +{ + /// + /// Gets or sets the maximum text length used by . + /// + /// Default is '512'. + public static int MaxFormatTextLength { get; set; } = 512; + + /// + /// Formats the for the likes of logging, etc., truncating to characters. + /// + /// The text to format. + /// The default where the is . + /// The formatted text. + public static string? FormatText(string? text, string? @default = null) => text?[..Math.Min(text.Length, MaxFormatTextLength)] ?? @default; + + /// + /// Provides a standardized/reusable means for actioning known exceptions in a consistent manner. + /// + /// The exception that occurred. + /// The to perform message actions. + /// The . + /// The of the error handling operation. + /// Where the has been actioned then the returned will be ; otherwise, . + public static async Task MessageErrorActionAsync(Exception exception, IServiceBusMessageActions actions, CancellationToken cancellationToken) + { + if (exception.ThrowIfNull() is IEventSubscriberException esex) + { + switch (esex.ErrorHandling) + { + case ErrorHandling.CompleteAsSilent: + case ErrorHandling.CompleteAsInformation: + case ErrorHandling.CompleteAsWarning: + case ErrorHandling.CompleteAsError: + await actions.CompleteMessageAsync(cancellationToken).ConfigureAwait(false); + return Result.Success; + + case ErrorHandling.Retry: + await actions.AbandonMessageAsync(exception, cancellationToken).ConfigureAwait(false); + return Result.Success; + + case ErrorHandling.DeadLetter: + await actions.DeadLetterMessageAsync(exception, cancellationToken).ConfigureAwait(false); + return Result.Success; + + default: + return exception; + } + } + else + return exception; + } + + /// + /// Logs the conversion of an exception to a different handling as per the configured options; e.g. retry to dead-letter, etc. + /// + private static Exception LogConversion(ILogger logger, Exception exception) + { + if (logger.IsEnabled(LogLevel.Information)) + logger.LogInformation(exception, "{ConversionMessage}", exception.Message); + + return exception; + } + + /// + /// Determine the final handling of an exhausted retry error based on the configured options. + /// + protected static Result MessageRetryErrorDetermination(Result result, ServiceBusReceiverOptionsBase options, ILogger logger) + { + static Result HandleAsPerConfiguredOptions(Exception exception, ServiceBusReceiverOptionsBase options, ILogger logger) + { + return options.RetryErrorHandling switch + { + ErrorHandling.DeadLetter => LogConversion(logger, new EventSubscriberDeadLetterException("Service bus receiver has converted Retry error to DeadLetter (as configured).", exception)), + ErrorHandling.Catastrophic => LogConversion(logger, new EventSubscriberCatastrophicException("Service bus receiver has converted Retry error to Catastrophic (as configured).", exception)), + _ => exception + }; + } + + return result.OnFailure(r => + { + if (r.Error is IEventSubscriberException esex) + return esex.ErrorHandling == ErrorHandling.Retry ? HandleAsPerConfiguredOptions(r.Error, options, logger) : r.Error; + else + return HandleAsPerConfiguredOptions(r.Error, options, logger); + }); + } + + /// + /// Determine the final handling of an unhandled error based on the configured options. + /// + protected static Result MessageUnhandledErrorDetermination(Result result, ServiceBusReceiverOptionsBase options, ILogger logger) + { + static Result HandleAsPerConfiguredOptions(Exception exception, ServiceBusReceiverOptionsBase options, ILogger logger) + { + return options.UnhandledErrorHandling switch + { + ErrorHandling.Retry => LogConversion(logger, new EventSubscriberRetryException("Service bus receiver has converted Unhandled to Retry error (as configured).", exception)), + ErrorHandling.DeadLetter => LogConversion(logger, new EventSubscriberDeadLetterException("Service bus receiver has converted Unhandled error to DeadLetter (as configured).", exception)), + ErrorHandling.Catastrophic => LogConversion(logger, new EventSubscriberCatastrophicException("Service bus receiver has converted Unhandled error to Catastrophic (as configured).", exception)), + _ => exception + }; + } + + return result.OnFailure(r => + { + if (r.Error is IEventSubscriberException esex) + return esex.ErrorHandling == ErrorHandling.None ? HandleAsPerConfiguredOptions(r.Error, options, logger) : r.Error; + else + return HandleAsPerConfiguredOptions(r.Error, options, logger); + }); + } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBase.cs b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBase.cs new file mode 100644 index 00000000..9990831f --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBase.cs @@ -0,0 +1,232 @@ +namespace CoreEx.Azure.Messaging.ServiceBus.Abstractions; + +/// +/// Provides the base Azure Service Bus receiver functionality. +/// +/// The . +/// The . +/// The . +/// The . +public abstract partial class ServiceBusReceiverBase(ServiceBusClient serviceBusClient, ServiceBusReceiverOptionsBase options, IServiceProvider serviceProvider, ILogger logger) : IAsyncDisposable +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + private bool _disposed; + + /// + /// Gets the . + /// + protected ServiceBusClient ServiceBusClient { get; } = serviceBusClient.ThrowIfNull(); + + /// + /// Gets the . + /// + public ServiceBusReceiverOptionsBase Options { get; } = options.ThrowIfNull(); + + /// + /// Gets the . + /// + public IServiceProvider ServiceProvider { get; } = serviceProvider.ThrowIfNull(); + + /// + /// Gets the . + /// + public ILogger Logger { get; } = logger.ThrowIfNull(); + + /// + /// Gets the . + /// + protected ServiceBusReceiverInvoker Invoker { get; } = serviceProvider.ThrowIfNull().GetService() ?? new(); + + /// + /// Gets the . + /// + public ServiceStatus Status { get; private set; } + + /// + /// Gets the reason for the current (where applicable). + /// + public string? StatusReason { get; set; } + + /// + /// Starts the underlying message processor. + /// + /// The . + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + + try + { + if (!Status.CanStart) + return; + + LogStatusChange(Status = ServiceStatus.Starting); + + await OnStartAsync(cancellationToken).ConfigureAwait(false); + + LogStatusChange(Status = ServiceStatus.Running); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Starts the underlying message processor. + /// + /// The . + protected abstract Task OnStartAsync(CancellationToken cancellationToken); + + /// + /// Pauses the underlying message processor. + /// + /// The reason for the pause. + /// The . + public async Task PauseAsync(string reason, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + if (!Status.CanPause) + return; + + StatusReason = reason; + LogStatusChange(Status = ServiceStatus.Pausing); + + await OnPauseAsync(cancellationToken).ConfigureAwait(false); + + LogStatusChange(Status = ServiceStatus.Paused); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Pauses the underlying message processor. + /// + /// The . + protected abstract Task OnPauseAsync(CancellationToken cancellationToken); + + /// + /// Resumes the underlying message processor. + /// + /// The . + public async Task ResumeAsync(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + if (!Status.CanResume) + return; + + LogStatusChange(Status = ServiceStatus.Resuming); + + await OnResumeAsync(cancellationToken).ConfigureAwait(false); + + LogStatusChange(Status = ServiceStatus.Running); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Resumes the underlying message processor. + /// + /// The . + protected abstract Task OnResumeAsync(CancellationToken cancellationToken); + + /// + /// Stops the underlying message processor. + /// + /// The . + public async Task StopAsync(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + + try + { + LogStatusChange(Status = ServiceStatus.Stopping); + + if (!Status.IsInitializing) + await OnStopAsync(cancellationToken).ConfigureAwait(false); + + LogStatusChange(Status = ServiceStatus.Stopped); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Stops the underlying message processor. + /// + /// The . + protected abstract Task OnStopAsync(CancellationToken cancellationToken); + + /// + /// Logs the status change. + /// + private void LogStatusChange(ServiceStatus status) + { + // Only a pause should have a reason, so clear the reason for any other status. + if (!status.IsPause) + StatusReason = null; + + // Log the status change. + if (Logger.IsEnabled(LogLevel.Debug)) + Logger.LogDebug("Azure Service Bus receiver: {Status}.", status); + } + + /// + /// Encapsulates the standardized processing of the . + /// + /// The . + /// The to perform message actions. + /// The . + protected Task ProcessMessageAsync(ServiceBusReceivedMessage message, IServiceBusMessageActions actions, CancellationToken cancellationToken) + => Invoker.InvokeAsync(this, actions, async (_, _, cancellationToken) => + { + var result = await OnProcessMessageAsync(message, actions, cancellationToken).ConfigureAwait(false); + MessageProcessed?.Invoke(this, new(result)); + return result; + }, cancellationToken); + + /// + /// Provides the standardized processing of the . + /// + /// The . + /// The to perform message actions. + /// The . + /// The of the message processing. + protected abstract Task OnProcessMessageAsync(ServiceBusReceivedMessage message, IServiceBusMessageActions actions, CancellationToken cancellationToken); + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _semaphore.Dispose(); + + _disposed = true; + await DisposeAsync(true).ConfigureAwait(false); + GC.SuppressFinalize(this); + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources asynchronously. + /// + /// Indicates whether to dispose of managed resources. + protected virtual ValueTask DisposeAsync(bool disposing) => ValueTask.CompletedTask; + + /// + /// Gets or sets the event that is raised when a message has been processed (either successfully or unsuccessfully). + /// + public event EventHandler? MessageProcessed; +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBaseT.cs b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBaseT.cs new file mode 100644 index 00000000..346ddb23 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverBaseT.cs @@ -0,0 +1,98 @@ +namespace CoreEx.Azure.Messaging.ServiceBus.Abstractions; + +/// +/// Provides the base Azure Service Bus receiver functionality including underlying . +/// +/// The . +/// The . +/// The . +/// The . +public abstract class ServiceBusReceiverBase(ServiceBusClient client, ServiceBusReceiverOptionsBase options, IServiceProvider serviceProvider, ILogger> logger) + : ServiceBusReceiverBase(client, options, serviceProvider, logger) where TSubscriber : ServiceBusSubscriberBase +{ + private const string _destination = "destination"; + + /// + protected sealed async override Task OnProcessMessageAsync(ServiceBusReceivedMessage message, IServiceBusMessageActions actions, CancellationToken cancellationToken) + { + // Get a resilience context from the pool to use for this execution. + var ctx = ResilienceContextPool.Shared.Get(cancellationToken); + + // Wrapper for the resilience context to ensure it is returned to the pool after execution, even in the case of an exception. + try + { + // Create a scope in which to perform the execution. + await using var scope = ServiceProvider.CreateAsyncScope(); + + // Instantiate and configure the execution context. + var ec = scope.ServiceProvider.GetRequiredService(); + Options.ExecutionContextConfigure?.Invoke(ec); + + // Get the subscriber to process the message. + var subscriber = Options.SubscriberServiceKey is null + ? scope.ServiceProvider.GetRequiredService() + : scope.ServiceProvider.GetRequiredKeyedService(Options.SubscriberServiceKey); + + // Set the resilience property within the context for access during processing. + ctx.Properties.Set(ServiceBusReceiverResiliency.ResiliencePropertyKey, this); + + // Execute the receive with resiliency. + var esa = new EventSubscriberArgs(); + var result = await Options.MessageResiliency.ExecuteAsync(async static (ctx, state) => + { + // Invoke the subscriber's receive to process the message. + Result result; + try + { + result = await state.Subscriber.ReceiveAsync(state.Message, state.Args, ctx.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + // Capture any exception as a Result for standardized handling. + result = Result.Fail(ex); + } + + // Determine where unhandled what the final error handling will be; otherwise, success. + return MessageUnhandledErrorDetermination(result, state.Owner.Options, state.Owner.Logger); + }, ctx, (subscriber, message, esa, this)); + + // On success complete the message and exit. + if (result.IsSuccess) + { + await actions.CompleteMessageAsync(cancellationToken).ConfigureAwait(false); + return result; + } + + // Handle the error accordingly; a) retry error conversion, b) invoke message action, then c) pause where critical. + return await result + .OnFailure(r => MessageRetryErrorDetermination(r, Options, Logger)) + .OnFailureAsync(r => MessageErrorActionAsync(r.Error!, actions, cancellationToken)) + .OnFailure(async r => + { + if (r.Error is IEventSubscriberException esex && esex.ErrorHandling == ErrorHandling.Catastrophic && Options.PauseReceiverOnCatastrophicError) + { + if (Logger.IsEnabled(LogLevel.Critical)) + Logger.LogCritical(r.Error, "A Catastrophic error has occurred within the service bus receiver for subscriber '{SubscriberTypeName}'. Abandoning the message and pausing the receiver.", typeof(TSubscriber).Name); + + await actions.AbandonMessageAsync(r.Error, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Do not await the pause as we want to allow the message to be abandoned and the error logged without delay. + _ = Task.Run(() => PauseAsync("A Catastrophic error occurred within the service bus receiver.", default)); + } + else + { + // Basically unhandled, and we don't really have any other course of action other than to abandon the message and log the error. + if (Logger.IsEnabled(LogLevel.Error)) + Logger.LogError(r.Error, "An unhandled error has occurred within the service bus receiver for subscriber '{SubscriberTypeName}'. Abandoning the message.", typeof(TSubscriber).Name); + + await actions.AbandonMessageAsync(r.Error, cancellationToken: cancellationToken).ConfigureAwait(false); + } + }).ConfigureAwait(false); + } + finally + { + // Return the context to the pool to ensure it is available for reuse and to prevent memory leaks. + ResilienceContextPool.Shared.Return(ctx); + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverOptionsBase.cs b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverOptionsBase.cs new file mode 100644 index 00000000..d6b8aea9 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/Abstractions/ServiceBusReceiverOptionsBase.cs @@ -0,0 +1,105 @@ +namespace CoreEx.Azure.Messaging.ServiceBus.Abstractions; + +/// +/// Provides the base options to be used when creating a instance. +/// +public abstract class ServiceBusReceiverOptionsBase +{ + /// + /// Gets the default configuration key for the . + /// + public const string QueueOrTopicNameAsConfigKey = "^Aspire:Azure:Messaging:ServiceBus:QueueOrTopicName"; + + /// + /// Gets the default configuration key for the . + /// + public const string SubscriptionNameAsConfigKey = "^Aspire:Azure:Messaging:ServiceBus:SubscriptionName"; + + /// + /// Initializes a new instance of the class. + /// + /// The queue or topic name to receive from. + /// The subscription name to receive from (where applicable). + public ServiceBusReceiverOptionsBase(string queueOrTopicName, string? subscriptionName) + { + QueueOrTopicName = queueOrTopicName.ThrowIfNullOrEmpty(); + SubscriptionName = subscriptionName.ThrowIfEmpty(); + + ReceiverResiliency = ServiceBusReceiverResiliency.CreateReceiverCircuitBreakerResiliency(); + MessageResiliency = ServiceBusReceiverResiliency.CreateMessageRetryResiliency(); + } + + /// + /// Get the name of the queue or topic to receive from. + /// + /// Supports the retrieval of the from where prefixed with 'config:' or '^', or is wrapped with '%'. + public string QueueOrTopicName { get; } + + /// + /// Get the name of the subscription to receive from (where applicable). + /// + /// Supports the retrieval of the from where prefixed with 'config:' or '^', or is wrapped with '%'. + public string? SubscriptionName { get; } + + /// + /// Indicates whether the receiver is for a topic/subscription or a queue. + /// + public bool IsSubscription => SubscriptionName is not null; + + /// + /// Gets the optional service key to be used when resolving the from the . + /// + public object? SubscriberServiceKey { get; set; } + + /// + /// Gets or sets the action to configure the prior to processing an individual message. + /// + public Action? ExecutionContextConfigure { get; set; } + + /// + /// Gets or sets the to use after the configured has been exhausted; defaults to . + /// + /// Valid values are , , or . + public ErrorHandling RetryErrorHandling + { + get; + set => field = value.ThrowWhen(value => value != ErrorHandling.Catastrophic && value != ErrorHandling.DeadLetter && value != ErrorHandling.Retry); + } = ErrorHandling.Retry; + + /// + /// Gets or sets the unhandled to use as the final catch-all handling. + /// + /// Defaults to ; which indicates that unhandled exception will be rethrown allowing the receivers native exception handling to occur. + /// This is a catch all for any not explicitly handled; valid values are , , + /// , or . + public ErrorHandling UnhandledErrorHandling + { + get; + set => field = value.ThrowWhen(value => value != ErrorHandling.Catastrophic && value != ErrorHandling.DeadLetter && value != ErrorHandling.Retry && value != ErrorHandling.None); + } = ErrorHandling.None; + + /// + /// Indicates whether to initiate a pause on the underlying receiver when a error occurs; defaults to . + /// + /// Where the receiver has been paused, it will then need to be manually resumed. + public bool PauseReceiverOnCatastrophicError { get; set; } = true; + + /// + /// Gets or sets the used to apply the likes of circuit breaker logic to protect the service bus receiver from unhandled exceptions and allow for automatic recovery. + /// + public ResiliencePipeline ReceiverResiliency { get; set => field = value.ThrowIfNull(); } + + /// + /// Gets or sets the used to apply the likes of retry logic where message processing results in a that is an . + /// + /// Consider using which provides a standardized retry strategy. This is used by default. + public ResiliencePipeline MessageResiliency { get; set => field = value.ThrowIfNull(); } + + /// + /// Gets or sets the duration to delay after each unhandled error (see ) occurs. + /// + /// This is outside of the resiliency pipelines and is intended as a means to slow-down the processing of messages as these type of errors occur. + /// Defaults to '2' seconds. + /// Be careful that the value is not set too high as to impact the as this will occur within the scope of this execution. + public TimeSpan PerUnhandledErrorDelayDuration { get; set; } = TimeSpan.FromSeconds(2); +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/CoreEx.Azure.Messaging.ServiceBus.csproj b/src/CoreEx.Azure.Messaging.ServiceBus/CoreEx.Azure.Messaging.ServiceBus.csproj new file mode 100644 index 00000000..8a358d52 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/CoreEx.Azure.Messaging.ServiceBus.csproj @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.DependencyInjection.cs b/src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.DependencyInjection.cs new file mode 100644 index 00000000..87d08d0f --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.DependencyInjection.cs @@ -0,0 +1,425 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static class CoreExServiceBusExtensions +{ + /// + /// Adds a keyed scoped Azure service. + /// + /// The . + /// An optional action to configure the instance. + /// Indicates whether to also register as the default (non-keyed) service. + /// The service key to use for the keyed registration. + /// The for fluent-style method-chaining. + /// See for more information + /// related to the underlying registration implementation. + public static IServiceCollection AddAzureServiceBusPublisher(this IServiceCollection services, Action? configure = null, bool addAsDefaultIEventPublisher = true, string serviceKey = ServiceBusPublisher.DefaultServiceKey) + => services.ThrowIfNull().AddEventPublisher(serviceKey, sp => + { + var sbp = ActivatorUtilities.CreateInstance(sp); + configure?.Invoke(sp, sbp); + return sbp; + }, addAsDefaultIEventPublisher); + + /// + /// Adds a singleton Azure service. + /// + /// The . + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddAzureServiceBusSubscribedSubscriber(this IServiceCollection services, Action? configure = null) + { + return services.ThrowIfNull().AddSingleton(sp => + { + var sbss = ActivatorUtilities.CreateInstance(sp); + configure?.Invoke(sp, sbss); + return sbss; + }); + } + + /// + /// Provides a builder to register the Azure Service Bus receiving services. + /// + /// The . + /// The . + /// Provides a fluent-style builder for configuring and registering the related Azure Service Bus receiver services to simplify usage and minimize challenges with the configuration hierarchy. + public static AzureServiceBusReceiveServiceBuilder AzureServiceBusReceiving(this IServiceCollection services) => new(services); + + /// + /// Provides a builder for configuring and registering Azure Service Bus receiver services . + /// + public sealed class AzureServiceBusReceiveServiceBuilder + { + private readonly IServiceCollection _services; + + /// + /// Initializes a new instance of the class. + /// + /// The . + internal AzureServiceBusReceiveServiceBuilder(IServiceCollection services) => _services = services.ThrowIfNull(); + + /// + /// Adds a singleton Azure service enabling ongoing fluent-style method-chaining registration. + /// + /// The factory to create the required . + /// The for fluent-style method-chaining. + public AzureServiceBusReceiverService WithReceiver(Func optionsFactory) => new(_services, null, optionsFactory); + + /// + /// Adds a singleton Azure service enabling ongoing fluent-style method-chaining registration. + /// + /// The factory to create the required . + /// The for fluent-style method-chaining. + public AzureServiceBusSessionReceiverService WithSessionReceiver(Func optionsFactory) => new(_services, null, optionsFactory); + + /// + /// Provides the service registration. + /// + public sealed class AzureServiceBusReceiverService + { + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The service key. + /// The factory to create the required . + internal AzureServiceBusReceiverService(IServiceCollection services, object? serviceKey, Func optionsFactory) + { + Services = services.ThrowIfNull(); + ServiceKey = serviceKey; + OptionsFactory = optionsFactory.ThrowIfNull(); + } + + /// + /// Gets the . + /// + internal IServiceCollection Services { get; } + + /// + /// Gets the service key. + /// + internal object? ServiceKey { get; } + + /// + /// Gets the options factory. + /// + internal Func OptionsFactory { get; } + + /// + /// Adds a singleton Azure Service Bus (see ). + /// + /// The Azure . + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public WithSubscriberService WithSubscriber(Action? configure = null) where TSubscriber : ServiceBusSubscriberBase + => new(this, null, configure); + + /// + /// Adds a singleton Azure Service Bus (see ). + /// + /// The Azure . + /// The service key. + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public WithSubscriberService WithKeyedSubscriber(object serviceKey, Action? configure = null) where TSubscriber : ServiceBusSubscriberBase + => new(this, serviceKey, configure); + + /// + /// Adds a singleton Azure as the subscriber. + /// + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public WithSubscriberService WithSubscribedSubscriber(Action? configure = null) => WithSubscriber(configure); + + /// + /// Adds a singleton Azure as the subscriber. + /// + /// The service key. + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public WithSubscriberService WithKeyedSubscribedSubscriber(object serviceKey, Action? configure = null) => WithKeyedSubscriber(serviceKey, configure); + } + + /// + /// Provides the service registration. + /// + /// + public sealed class WithSubscriberService where TSubscriber : ServiceBusSubscriberBase + { + private readonly AzureServiceBusReceiverService _owner; + private readonly object? _serviceKey; + private readonly Action? _configure; + + /// + /// Initializes a new instance of the class. + /// + /// The owner instance. + /// The service key. + /// An optional action to configure the instance. + internal WithSubscriberService(AzureServiceBusReceiverService owner, object? serviceKey, Action? configure) + { + _owner = owner.ThrowIfNull(); + _serviceKey = serviceKey; + _configure = configure; + } + + /// + /// Builds and registers all of the chained services. + /// + /// Where a hosted service is also required then the chained should be used instead. + public void Build() + { + // Add the subscriber service. + if (_serviceKey is null) + _owner.Services.AddSingleton(sp => + { + var subscriber = ActivatorUtilities.CreateInstance(sp); + _configure?.Invoke(sp, subscriber); + return subscriber; + }); + else + _owner.Services.AddKeyedSingleton(_serviceKey, (sp, _) => + { + var subscriber = ActivatorUtilities.CreateInstance(sp); + _configure?.Invoke(sp, subscriber); + return subscriber; + }); + + // Add the receiver service. + if (_owner.ServiceKey is null) + _owner.Services.AddSingleton(sp => + { + var options = _owner.OptionsFactory(sp) ?? throw new InvalidOperationException("The options factory must return a non-null."); + options.SubscriberServiceKey = _serviceKey; + var receiver = ActivatorUtilities.CreateInstance>(sp, options); + return receiver; + }); + else + _owner.Services.AddKeyedSingleton(_owner.ServiceKey, (sp, _) => + { + var options = _owner.OptionsFactory(sp) ?? throw new InvalidOperationException("The options factory must return a non-null."); + options.SubscriberServiceKey = _serviceKey; + var receiver = ActivatorUtilities.CreateInstance>(sp, options); + return receiver; + }); + } + + /// + /// Create the receiver instance. + /// + private ServiceBusReceiver GetReceiverInstance(IServiceProvider serviceProvider) => _serviceKey is null + ? serviceProvider.GetRequiredService>() + : serviceProvider.GetRequiredKeyedService>(_serviceKey); + + /// + /// Adds a singleton Azure keyed service that will be executed as a hosted service (i.e. in the background). + /// + /// The keyed singleton and health check key. + /// An optional action to configure the instance. + /// The instance for fluent-style method-chaining. + /// No services are added until the chained method is called. + public WithHostedServiceService> WithHostedService(string serviceKey = "azure-service-bus-receiver", Action>>? configure = null) + => new(_owner.Services, serviceKey.ThrowIfNullOrEmpty(), configure, Build, GetReceiverInstance); + } + + /// + /// Provides the service registration. + /// + public sealed class AzureServiceBusSessionReceiverService + { + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The service key. + /// The factory to create the required . + internal AzureServiceBusSessionReceiverService(IServiceCollection services, object? serviceKey, Func optionsFactory) + { + Services = services.ThrowIfNull(); + ServiceKey = serviceKey; + OptionsFactory = optionsFactory.ThrowIfNull(); + } + + /// + /// Gets the . + /// + internal IServiceCollection Services { get; } + + /// + /// Gets the service key. + /// + internal object? ServiceKey { get; } + + /// + /// Gets the options factory. + /// + internal Func OptionsFactory { get; } + + /// + /// Adds a singleton Azure Service Bus (see ). + /// + /// The Azure . + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public WithSessionSubscriberService WithSubscriber(Action? configure = null) where TSubscriber : ServiceBusSubscriberBase + => new(this, null, configure); + + /// + /// Adds a singleton Azure Service Bus (see ). + /// + /// The Azure . + /// The service key. + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public WithSessionSubscriberService WithKeyedSubscriber(object serviceKey, Action? configure = null) where TSubscriber : ServiceBusSubscriberBase + => new(this, serviceKey, configure); + + /// + /// Adds a singleton Azure as the subscriber. + /// + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public WithSessionSubscriberService WithSubscribedSubscriber(Action? configure = null) => WithSubscriber(configure); + + /// + /// Adds a singleton Azure as the subscriber. + /// + /// The service key. + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public WithSessionSubscriberService WithKeyedSubscribedSubscriber(object serviceKey, Action? configure = null) => WithKeyedSubscriber(serviceKey, configure); + } + + /// + /// Provides the service registration. + /// + /// The . + public sealed class WithSessionSubscriberService where TSubscriber : ServiceBusSubscriberBase + { + private readonly AzureServiceBusSessionReceiverService _owner; + private readonly object? _serviceKey; + private readonly Action? _configure; + + /// + /// Initializes a new instance of the class. + /// + /// The owner instance. + /// The service key. + /// An optional action to configure the instance. + internal WithSessionSubscriberService(AzureServiceBusSessionReceiverService owner, object? serviceKey, Action? configure) + { + _owner = owner.ThrowIfNull(); + _serviceKey = serviceKey; + _configure = configure; + } + + /// + /// Builds and registers all of the chained services. + /// + /// Where a hosted service is also required then the chained should be used instead. + public void Build() + { + // Add the subscriber service. + if (_serviceKey is null) + _owner.Services.AddSingleton(sp => + { + var subscriber = ActivatorUtilities.CreateInstance(sp); + _configure?.Invoke(sp, subscriber); + return subscriber; + }); + else + _owner.Services.AddKeyedSingleton(_serviceKey, (sp, _) => + { + var subscriber = ActivatorUtilities.CreateInstance(sp); + _configure?.Invoke(sp, subscriber); + return subscriber; + }); + + // Add the receiver service. + if (_owner.ServiceKey is null) + _owner.Services.AddSingleton(sp => + { + var options = _owner.OptionsFactory(sp) ?? throw new InvalidOperationException("The options factory must return a non-null."); + options.SubscriberServiceKey = _serviceKey; + var receiver = ActivatorUtilities.CreateInstance>(sp, options); + return receiver; + }); + else + _owner.Services.AddKeyedSingleton(_owner.ServiceKey, (sp, _) => + { + var options = _owner.OptionsFactory(sp) ?? throw new InvalidOperationException("The options factory must return a non-null."); + options.SubscriberServiceKey = _serviceKey; + var receiver = ActivatorUtilities.CreateInstance>(sp, options); + return receiver; + }); + } + + /// + /// Create the receiver instance. + /// + private ServiceBusSessionReceiver GetReceiverInstance(IServiceProvider serviceProvider) => _serviceKey is null + ? serviceProvider.GetRequiredService>() + : serviceProvider.GetRequiredKeyedService>(_serviceKey); + + /// + /// Adds a singleton Azure keyed service that will be executed as a hosted service (i.e. in the background). + /// + /// The keyed singleton and health check key. + /// An optional action to configure the instance. + /// The instance for fluent-style method-chaining. + /// No services are added until the chained method is called. + public WithHostedServiceService> WithHostedService(string serviceKey = "azure-service-bus-session-receiver", Action>>? configure = null) + => new(_owner.Services, serviceKey.ThrowIfNullOrEmpty(), configure, Build, GetReceiverInstance); + } + + /// + /// Provides the service registration. + /// + /// The Azure . + public sealed class WithHostedServiceService where TReceiver : ServiceBusReceiverBase + { + private readonly IServiceCollection _services; + private readonly string _serviceKey; + private readonly Action>? _configure; + private readonly Action _buildParentServices; + private readonly Func _createReceiverInstance; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The service key. + /// An optional action to configure the instance. + /// The action to build the parent services (i.e. the subscriber and receiver). + /// The function to create the receiver instance. + internal WithHostedServiceService(IServiceCollection services, string serviceKey, Action>? configure, Action buildParentServices, Func createReceiverInstance) + { + _services = services.ThrowIfNull(); + _serviceKey = serviceKey.ThrowIfNullOrEmpty(); + _configure = configure; + _buildParentServices = buildParentServices; + _createReceiverInstance = createReceiverInstance; + } + + /// + /// Builds and registers all of the chained services. + /// + public void Build() + { + // Adds the parent service registrations. + _buildParentServices(); + + // Adds the hosted service registration. + _services.AddHostedService(_serviceKey, sp => + { + var receiver = _createReceiverInstance(sp); + return ActivatorUtilities.CreateInstance>(sp, receiver); + }); + } + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.OpenTelemetry.cs b/src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.OpenTelemetry.cs new file mode 100644 index 00000000..77bf959b --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.OpenTelemetry.cs @@ -0,0 +1,21 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace OpenTelemetry.Trace; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static class CoreExServiceBusExtensions +{ + /// + /// Enables CoreEx OpenTelemetry instrumentation. + /// + /// The . + /// The to support fluent-style method-chaining. + public static OpenTelemetryBuilder WithCoreExServiceBusTelemetry(this OpenTelemetryBuilder builder) => builder.ThrowIfNull() + .WithCoreExEventsSources() + .WithTracing(t => t.AddInvokerAsSource() + .AddSource("Azure.Messaging.ServiceBus") + .AddSource("Azure.Messaging.ServiceBus.*")) + .WithMetrics(m => m.AddMeter(ServiceBusMetrics.Meter.Name)); +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/GlobalUsing.cs b/src/CoreEx.Azure.Messaging.ServiceBus/GlobalUsing.cs new file mode 100644 index 00000000..518f23a8 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/GlobalUsing.cs @@ -0,0 +1,27 @@ +global using Azure.Messaging.ServiceBus; +global using CloudNative.CloudEvents; +global using CloudNative.CloudEvents.Extensions; +global using CoreEx; +global using CoreEx.Abstractions; +global using CoreEx.Azure.Messaging.ServiceBus; +global using CoreEx.Azure.Messaging.ServiceBus.Abstractions; +global using CoreEx.Data; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Events.Subscribing; +global using CoreEx.Events.Subscribing.Exceptions; +global using CoreEx.Hosting; +global using CoreEx.Invokers; +global using CoreEx.Localization; +global using CoreEx.Results; +global using CoreEx.Security; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Diagnostics.HealthChecks; +global using Microsoft.Extensions.Logging; +global using Polly; +global using Polly.CircuitBreaker; +global using Polly.Retry; +global using System.Diagnostics; +global using System.Diagnostics.Metrics; +global using Amqp = Azure.Core.Amqp; \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/IServiceBusMessageActions.cs b/src/CoreEx.Azure.Messaging.ServiceBus/IServiceBusMessageActions.cs new file mode 100644 index 00000000..71bf5abe --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/IServiceBusMessageActions.cs @@ -0,0 +1,37 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Enables the standard actions for a . +/// +public interface IServiceBusMessageActions +{ + /// + /// Gets the entity path that represents the source of the message; e.g. topic or queue name. + /// + string EntityPath { get; } + + /// + /// Gets the representation. + /// + Amqp.AmqpAnnotatedMessage AmqpMessage { get; } + + /// + /// Marks the current message as completed, indicating that it has been processed successfully. + /// + /// The . + Task CompleteMessageAsync(CancellationToken cancellationToken); + + /// + /// Marks the current message as abandoned, indicating that it could not be processed successfully, and records the specified as the reason. + /// + /// The that describes the reason for abandoning the message. + /// The . + Task AbandonMessageAsync(Exception exception, CancellationToken cancellationToken); + + /// + /// Marks the current message as dead-lettered, indicating that it cannot be processed successfully, and records the specified as the reason. + /// + /// The that describes the reason for dead-lettering the message. + /// The . + Task DeadLetterMessageAsync(Exception exception, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/MessageProcessedEventArgs.cs b/src/CoreEx.Azure.Messaging.ServiceBus/MessageProcessedEventArgs.cs new file mode 100644 index 00000000..4a23c4ec --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/MessageProcessedEventArgs.cs @@ -0,0 +1,13 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides event data. +/// +/// The . +public class MessageProcessedEventArgs(Result result) : EventArgs() +{ + /// + /// Gets the . + /// + public Result Result { get; } = result; +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusExtensions.CloudEvent.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusExtensions.CloudEvent.cs new file mode 100644 index 00000000..c41acd7c --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusExtensions.CloudEvent.cs @@ -0,0 +1,147 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +public static partial class ServiceBusExtensions +{ + /// + /// Gets the attribute prefix. + /// + public const string CloudEventPrefix = "ce_"; + + /// + /// Gets the specification version () property name: 'ce_specversion'. + /// + public const string CloudEventSpecVersionPropertyName = CloudEventPrefix + "specversion"; + + /// + /// Gets the source () property name: 'ce_source'. + /// + public const string CloudEventSourcePropertyName = CloudEventPrefix + "source"; + + /// + /// Gets the trace parent property name: 'ce_traceparent'. + /// + public const string CloudEventTraceParentPropertyName = CloudEventPrefix + "traceparent"; + + /// + /// Gets the trace state property name: 'ce_tracestate'. + /// + public const string CloudEventTraceStatePropertyName = CloudEventPrefix + "tracestate"; + + /// + /// Gets the trace baggage property name: 'ce_baggage'. + /// + public const string CloudEventTraceBaggagePropertyName = CloudEventPrefix + "baggage"; + + /// + /// Gets the trace parent property name: 'traceparent'. + /// + public const string MessageTraceParentPropertyName = "traceparent"; + + /// + /// Gets the trace state property name: 'tracestate'. + /// + public const string MessageTraceStatePropertyName = "tracestate"; + + /// + /// Gets the trace baggage property name: 'baggage'. + /// + public const string MessageTraceBaggagePropertyName = "baggage"; + + /// + /// Converts a to a . + /// + /// The . + /// The to use; defaults to . + /// Indicates whether to include all as ; defaults to . + /// The . + /// The is set to the . + public static ServiceBusMessage ToServiceBusMessage(this CloudEvent cloudEvent, ContentMode contentMode = ContentMode.Structured, bool includeAttributes = true) + { + var bd = cloudEvent.ThrowIfNull().EncodeToBinaryData(contentMode); + + var msg = new ServiceBusMessage(bd) + { + ContentType = bd.MediaType, + Subject = cloudEvent.Type, + MessageId = cloudEvent.Id, + PartitionKey = cloudEvent.GetPartitionKey() + }; + + if (includeAttributes) + { + msg.ApplicationProperties.TryAdd(CloudEventSpecVersionPropertyName, cloudEvent.SpecVersion.VersionId); + + foreach (var attr in cloudEvent.GetPopulatedAttributes()) + { + msg.ApplicationProperties.TryAdd($"{CloudEventPrefix}{attr.Key.Name}", attr.Value); + } + } + + foreach (var attr in cloudEvent.GetPopulatedAttributes()) + { + if (attr.Key.Name == CloudEventTraceParentPropertyName) + msg.ApplicationProperties.TryAdd(MessageTraceParentPropertyName, attr.Value); + + if (attr.Key.Name == CloudEventTraceStatePropertyName) + msg.ApplicationProperties.TryAdd(MessageTraceStatePropertyName, attr.Value); + + if (attr.Key.Name == CloudEventTraceBaggagePropertyName) + msg.ApplicationProperties.TryAdd(MessageTraceBaggagePropertyName, attr.Value); + } + + return msg; + } + + /// + /// Indicates whether the is a . + /// + /// The . + /// where the is a ; otherwise, . + /// Determines if the contains the property. + public static bool IsCloudEvent(this ServiceBusReceivedMessage message) + { + message.ThrowIfNull(); + return message.ApplicationProperties.ContainsKey(CloudEventSpecVersionPropertyName); + } + + /// + /// Converts a to a . + /// + /// The . + /// The to use; defaults to . + /// The . + /// Where is , the is inferred from the (see ). + public static CloudEvent ToCloudEvent(this ServiceBusReceivedMessage message, ContentMode? contentMode = null) + { + var bd = message.ThrowIfNull().Body.WithMediaType(message.ContentType); + contentMode ??= bd.InferContentMode(); + var ce = bd.DecodeToCloudEvent(contentMode.Value); + + if (contentMode == ContentMode.Binary && message.IsCloudEvent()) + { + // Manually populate CloudEvent properties from ApplicationProperties for Binary mode. + ce.Id = message.MessageId; + ce.Type = message.Subject; + ce.Time = message.EnqueuedTime; + + foreach (var ap in message.ApplicationProperties) + { + if (ap.Key.StartsWith(CloudEventPrefix) && ap.Key != CloudEventSpecVersionPropertyName) + { + ce[ap.Key[CloudEventPrefix.Length..]] = ap.Value; + } + + if (ap.Key == MessageTraceParentPropertyName) + ce[CloudEventTraceParentPropertyName] = ap.Value; + + if (ap.Key == MessageTraceStatePropertyName) + ce[CloudEventTraceStatePropertyName] = ap.Value; + + if (ap.Key == MessageTraceBaggagePropertyName) + ce[CloudEventTraceBaggagePropertyName] = ap.Value; + } + } + + return ce; + } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusExtensions.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusExtensions.cs new file mode 100644 index 00000000..c77252ea --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusExtensions.cs @@ -0,0 +1,21 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides standard extensions for Azure Service Bus. +/// +public static partial class ServiceBusExtensions +{ + private const string MessageKey = "CoreEx:ServiceBusMessage"; + + extension(EventSubscriberArgs args) + { + /// + /// Gets or sets the originating . + /// + public ServiceBusReceivedMessage Message + { + get => args.Properties.TryGetValue(MessageKey, out var value) && value is ServiceBusReceivedMessage sbm ? sbm : throw new InvalidOperationException($"The {MessageKey} property has not been set."); + internal set => args.Properties[MessageKey] = value; + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusMetrics.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusMetrics.cs new file mode 100644 index 00000000..2c9ba0f4 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusMetrics.cs @@ -0,0 +1,62 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Defines the Azure Service Bus metrics. +/// +public static class ServiceBusMetrics +{ + /// + /// Gets the source tag name used for all Azure Service Bus metrics; represents the entity path that represents the source of the message; e.g. topic or queue name. + /// + public const string SourceTagName = "source"; + + /// + /// Gets the destination tag name used for all Azure Service Bus metrics; represents the entity path that represents the destination of the message; e.g. topic or queue name. + /// + public const string DestinationTagName = "destination"; + + /// + /// Gets the used for recording metrics related to Azure Service Bus operations. + /// + public static Meter Meter { get; } = new("CoreEx.Azure.Messaging.ServiceBus"); + + /// + /// Gets the counter that tracks the number of Azure Service Bus messages sent successfully. + /// + public static Counter MessagesSendSent { get; } = Meter.CreateCounter("servicebus.messages.send.sent", unit: "{message}", description: "Number of Azure Service Bus messages sent successfully."); + + /// + /// Gets the counter that tracks the number of Azure Service Bus messages sent successfully. + /// + public static Counter MessagesSendFailed { get; } = Meter.CreateCounter("servicebus.messages.send.failed", unit: "{message}", description: "Number of Azure Service Bus messages that failed to send."); + + /// + /// Gets the histogram that tracks the duration, in milliseconds, of Azure Service Bus message send operations, regardless of success or failure. + /// + public static Histogram MessagesSendDuration { get; } = Meter.CreateHistogram("servicebus.messages.send.duration", unit: "ms", description: "Duration of Azure Service Bus messages send (success or failure)."); + + /// + /// Gets the counter that tracks the number of Azure Service Bus messages received and completed. + /// + public static Counter MessagesReceivedComplete { get; } = Meter.CreateCounter("servicebus.messages.received.completed", unit: "{message}", description: "Number of Azure Service Bus messages received and completed."); + + /// + /// Gets the counter that tracks the number of Azure Service Bus messages received and dead-lettered. + /// + public static Counter MessagesReceivedDeadLetter { get; } = Meter.CreateCounter("servicebus.messages.received.deadlettered", unit: "{message}", description: "Number of Azure Service Bus messages received and dead-lettered."); + + /// + /// Gets the counter that tracks the number of Azure Service Bus messages received and abandoned. + /// + public static Counter MessagesReceivedAbandoned { get; } = Meter.CreateCounter("servicebus.messages.received.abandoned", unit: "{message}", description: "Number of Azure Service Bus messages received and abandoned."); + + /// + /// Gets the histogram that tracks the duration, in milliseconds, of Azure Service Bus message receive operations, regardless of success or failure. + /// + public static Histogram MessagesReceivedDuration { get; } = Meter.CreateHistogram("servicebus.messages.received.duration", unit: "ms", description: "Duration of Azure Service Bus messages receive processing (success or failure)."); + + /// + /// Gets the histogram that tracks the lag duration (now - enqueued time), in milliseconds, of Azure Service Bus message receive operations, regardless of success or failure. + /// + public static Histogram MessagesReceivedLagDuration { get; } = Meter.CreateHistogram("servicebus.messages.received.lag_duration", unit: "ms", description: "Lag duration (now - enqueued time) of Azure Service Bus messages receive processing (success or failure)."); +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisher.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisher.cs new file mode 100644 index 00000000..9e788080 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisher.cs @@ -0,0 +1,148 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides the implementation for Azure Service Bus. +/// +/// The . +/// The optional . +/// The optional . +/// The optional logger. +/// Sends using safe-batching. +/// This implementation enables at-least once delivery; i.e. there are no guarantees that events are not delivered more than once where an underlying is thrown. +/// Where are required then the must be configured accordingly. +public sealed class ServiceBusPublisher(ServiceBusClient serviceBusClient, IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger? logger = null) : EventPublisherBase(destinationProvider, formatter, logger) +{ + private readonly ServiceBusClient _serviceBusClient = serviceBusClient.ThrowIfNull(); + + /// + /// Gets the default service key used when registering the service. + /// + /// See related . + public const string DefaultServiceKey = "AzureServiceBus"; + + /// + /// Gets or sets the to use when sending a as a ; defaults to . + /// + /// See also . + public ContentMode ContentMode { get; set; } = ContentMode.Structured; + + /// + /// Indicates whether to include all as ; defaults to . + /// + /// See also . + public bool IncludeCloudEventAttributes { get; set; } = true; + + /// + /// Gets or sets the strategy to use when sending messages; defaults to . + /// + public ServiceBusSessionStrategy SessionIdStrategy { get; set; } = ServiceBusSessionStrategy.None; + + /// + /// Gets or sets the size of the partition used for when the is . + /// + /// Where not specified the is used. + public int? SessionIdPartitionSize { get; set; } + + /// + protected async override Task OnPublishAsync(DestinationEvent[] events, CancellationToken cancellationToken = default) + { + var groups = events + .GroupBy(e => e.Destination ?? string.Empty, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => new Queue(g), StringComparer.Ordinal); + + if (Logger?.IsEnabled(LogLevel.Debug) ?? false) + Logger.LogDebug("Preparing to send {EventCount} event(s) to {DestinationCount} destination(s).", events.Length, groups.Count); + + foreach (var group in groups) + { + await SendBatchAsync(events.Length, group.Key, group.Value, cancellationToken).ConfigureAwait(false); + } + + if (Logger?.IsEnabled(LogLevel.Debug) ?? false) + Logger.LogDebug("Published {Count} event(s) to Azure Service Bus.", events.Length); + } + + /// + /// Send using safe-batching. + /// + private async Task SendBatchAsync(int totalEventCount, string destination, Queue events, CancellationToken cancellationToken) + { + var eventsSent = 0; + + // Create a sender for the queue/topic (destination). + await using var sender = _serviceBusClient.CreateSender(destination); + + while (events.Count > 0) + { + // Start a new batch. + using var batch = await sender.CreateMessageBatchAsync(cancellationToken).ConfigureAwait(false); + + // Add first message to the batch. + if (batch.TryAddMessage(SetSessionId(events.Peek().Event.ToServiceBusMessage(ContentMode, IncludeCloudEventAttributes)))) + { + events.Dequeue(); + + // Keep adding messages until we run out of messages or batch is full. + while (events.Count > 0 && batch.TryAddMessage(SetSessionId(events.Peek().Event.ToServiceBusMessage(ContentMode, IncludeCloudEventAttributes)))) + { + events.Dequeue(); + } + } + else + { + if (Logger?.IsEnabled(LogLevel.Error) ?? false) + { + var ce = events.Peek().Event; + Logger.LogError("A single event (Id={MessageId}, Type='{MessageType}') is too large to fit in the Azure Service Bus message batch for destination '{Destination}'; {EventsSent} of the {EventsCount} event(s) have already been successfully sent.", ce.Id, ce.Type, destination, eventsSent, totalEventCount); + } + + throw new InvalidOperationException("A single event is too large to fit in the Azure Service Bus message batch."); + } + + // Send the batch of messages. + if (Logger?.IsEnabled(LogLevel.Debug) ?? false) + Logger.LogDebug("Sending batch of {BatchCount} event(s) to destination '{Destination}'.", batch.Count, destination); + + await Invoker.InvokeAsync(this, async (tracer, cancellationToken) => + { + tracer.Activity?.AddTag("servicebus.destination", destination); + var stopwatch = Stopwatch.StartNew(); + + try + { + await sender.SendMessagesAsync(batch, cancellationToken).ConfigureAwait(false); + ServiceBusMetrics.MessagesSendSent.Add(batch.Count, [ new (ServiceBusMetrics.DestinationTagName, destination) ]); + } + catch (Exception) + { + ServiceBusMetrics.MessagesSendFailed.Add(batch.Count, [new(ServiceBusMetrics.DestinationTagName, destination)]); + throw; + } + finally + { + stopwatch.Stop(); + ServiceBus.ServiceBusMetrics.MessagesSendDuration.Record(stopwatch.Elapsed.TotalMilliseconds, [new(ServiceBusMetrics.DestinationTagName, destination)]); + } + + tracer.Activity?.AddTag("servicebus.messages.sent", batch.Count); + }, cancellationToken).ConfigureAwait(false); + + eventsSent += batch.Count; + } + } + + /// + /// Sets the based on the configured . + /// + private ServiceBusMessage SetSessionId(ServiceBusMessage message) + { + message.ThrowIfNull(); + + return SessionIdStrategy switch + { + ServiceBusSessionStrategy.UsePartitionKeyAsIs => message.Adjust(message => message.SessionId = message.PartitionKey ?? Guid.NewGuid().ToString()), + ServiceBusSessionStrategy.UsePartitionKeyConvertedToAnId => message.Adjust(message => message.SessionId = message.PartitionKey = PartitionKey.GetPartitionIdAsString(message.PartitionKey ?? Guid.NewGuid().ToString(), SessionIdPartitionSize ?? PartitionKey.DefaultPartitionSize)), + _ => message + }; + } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiver.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiver.cs new file mode 100644 index 00000000..ab974653 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiver.cs @@ -0,0 +1,71 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Encapsulates the lifetime and message receiving management. +/// +/// The to be used for receiving each . +public sealed class ServiceBusReceiver : ServiceBusReceiverBase where TSubscriber : ServiceBusSubscriberBase +{ + private readonly ServiceBusProcessor _processor; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + public ServiceBusReceiver(ServiceBusClient client, ServiceBusReceiverOptions options, IServiceProvider serviceProvider, ILogger> logger) + : base(client, options, serviceProvider, logger) + { + var config = serviceProvider.GetRequiredService(); + var queueOrTopicName = Internal.GetValueFromConfigurationWhereApplicable(options.QueueOrTopicName, config); + + _processor = options.IsSubscription + ? client.CreateProcessor(queueOrTopicName, Internal.GetValueFromConfigurationWhereApplicable(options.SubscriptionName!, config), options.ProcessorOptions) + : client.CreateProcessor(queueOrTopicName, options.ProcessorOptions); + + _processor.ProcessMessageAsync += OnProcessMessageAsync; + _processor.ProcessErrorAsync += OnProcessErrorAsync; + } + + /// + /// Gets the to be used when creating the instance. + /// + public new ServiceBusReceiverOptions Options => (ServiceBusReceiverOptions)base.Options; + + /// + protected override Task OnStartAsync(CancellationToken cancellationToken) => _processor.StartProcessingAsync(cancellationToken); + + /// + protected override Task OnPauseAsync(CancellationToken cancellationToken) => _processor.StopProcessingAsync(cancellationToken); + + /// + protected override Task OnResumeAsync(CancellationToken cancellationToken) => _processor.StartProcessingAsync(cancellationToken); + + /// + protected override Task OnStopAsync(CancellationToken cancellationToken) => _processor.StopProcessingAsync(cancellationToken); + + /// + /// Handles the processing of a message. + /// + private Task OnProcessMessageAsync(ProcessMessageEventArgs args) => ProcessMessageAsync(args.Message, new ProcessMessageEventArgsActions(args), args.CancellationToken); + + /// + /// Handles the processing of an error/exception. + /// + private Task OnProcessErrorAsync(ProcessErrorEventArgs args) + { + ServiceBusErrorClassifier.ClassifyAndLogError(Logger, args); + return Task.CompletedTask; + } + + /// + protected async override ValueTask DisposeAsync(bool disposing) + { + if (disposing) + await _processor.DisposeAsync().ConfigureAwait(false); + + await base.DisposeAsync(disposing).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverHostedService.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverHostedService.cs new file mode 100644 index 00000000..183644ac --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverHostedService.cs @@ -0,0 +1,50 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides the Azure Service Bus receiver hosted service functionality. +/// +/// The . +public sealed class ServiceBusReceiverHostedService : HostedServiceBase where TReceiver : ServiceBusReceiverBase +{ + private readonly TReceiver _receiver; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + public ServiceBusReceiverHostedService(TReceiver receiver, IServiceProvider serviceProvider, ILogger> logger) : base(serviceProvider, logger) + { + _receiver = receiver.ThrowIfNull(); + ArePauseAndResumeSupported = true; + } + + /// + protected async override Task OnStartAsync(CancellationToken cancellationToken) + { + await _receiver.StartAsync(cancellationToken).ConfigureAwait(false); + return ServiceStatus.Running; + } + + /// + protected override Task OnPauseAsync(CancellationToken cancellationToken) + => _receiver.PauseAsync($"Hosted service externally paused by '{(ExecutionContext.TryGetCurrent(out var ec) ? ec.User ?? AuthenticationUser.EnvironmentUser : AuthenticationUser.EnvironmentUser)}.", cancellationToken); + + /// + protected override Task OnResumeAsync(CancellationToken cancellationToken) => _receiver.ResumeAsync(cancellationToken); + + /// + protected override Task OnStopAsync(CancellationToken cancellationToken) => _receiver.StopAsync(cancellationToken); + + /// + protected override HealthCheckResult OnReportHealthStatus(Dictionary data) + { + if (_receiver.StatusReason is not null) + data.Add("statusReason", _receiver.StatusReason); + + return Status.IsPause + ? HealthCheckResult.Degraded("Service is in a paused state.", null, data) + : HealthCheckResult.Healthy(null, data); + } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverInvoker.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverInvoker.cs new file mode 100644 index 00000000..d8e6a355 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverInvoker.cs @@ -0,0 +1,61 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides the invoker and underlying resiliency management. +/// +[InvokerName("CoreEx.Azure.Messaging.ServiceBus.ServiceBusReceiver")] +public class ServiceBusReceiverInvoker : InvokerBase +{ + /// + protected override async Task OnInvokeAsync(InvokerTracer tracer, ServiceBusReceiverBase caller, IServiceBusMessageActions args, Func> func, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + + // Get a resilience context from the pool to use for this execution. + var ctx = ResilienceContextPool.Shared.Get(cancellationToken); + + try + { + // Set the resilience property within the context for access during processing. + ctx.Properties.Set(ServiceBusReceiverResiliency.ResiliencePropertyKey, caller); + + // Execute the function within the resiliency policy. + var r = await caller.Options.ReceiverResiliency.ExecuteAsync> Func)>(async static (ctx, state) => + { + // Should always return a Result and never an unhandled exception. + var tresult = await state.Self.BaseOnInvokeAsync(state.Tracer, state.Caller, state.Args, state.Func, ctx.CancellationToken).ConfigureAwait(false); + var result = Internal.Cast(tresult); + + if (result.IsFailure && result.Error is EventSubscriberUnhandledException) + await Task.Delay(state.Caller.Options.PerUnhandledErrorDelayDuration, ctx.CancellationToken).ConfigureAwait(false); + + return result; + }, ctx, (this, tracer, caller, args, func)).ConfigureAwait(false); + + // Return the result of the execution. + return Internal.Cast(r); + } + finally + { + // Return the resilience context to the pool. + ResilienceContextPool.Shared.Return(ctx); + + // Emit the received duration and lag metrics. + stopwatch.Stop(); + ServiceBus.ServiceBusMetrics.MessagesReceivedDuration.Record(stopwatch.Elapsed.TotalMilliseconds, [new(ServiceBusMetrics.SourceTagName, args.EntityPath)]); + ServiceBus.ServiceBusMetrics.MessagesReceivedLagDuration.Record((DateTimeOffset.UtcNow - GetMessageEnqueuedTime(args)).TotalMilliseconds, [new(ServiceBusMetrics.SourceTagName, args.EntityPath)]); + } + } + + /// + /// Wrapper to pass and avoid closure on the lambda. + /// + private Task BaseOnInvokeAsync(InvokerTracer tracer, ServiceBusReceiverBase caller, IServiceBusMessageActions args, Func> func, CancellationToken cancellationToken) + => base.OnInvokeAsync(tracer, caller, args, func, cancellationToken); + + /// + /// Gets the message enqueued time from the AMQP message annotations, or returns the default value if not available. + /// + private static DateTimeOffset GetMessageEnqueuedTime(IServiceBusMessageActions args) + => args.AmqpMessage.MessageAnnotations.TryGetValue("x-opt-enqueued-time", out var enqueuedTime) && enqueuedTime is DateTime dt ? dt : default; +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverOptions.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverOptions.cs new file mode 100644 index 00000000..ac949ee5 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverOptions.cs @@ -0,0 +1,54 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides the options to be used when creating a instance. +/// +/// The defaults to a new with: +/// +/// = . +/// = . +/// = '1'. +/// = '00:05:00' (five minutes). +/// = '0'. +/// +/// +public sealed class ServiceBusReceiverOptions : ServiceBusReceiverOptionsBase +{ + /// + /// Create a for the specified . + /// + /// The queue name. + /// The . + /// Supports the retrieval of the from where prefixed with 'config:' or '^', or is wrapped with '%'. + public static ServiceBusReceiverOptions CreateForQueue(string queueName = QueueOrTopicNameAsConfigKey) => new(queueName, null); + + /// + /// Create a for the specified and . + /// + /// The topic name. + /// The subscription name. + /// The . + /// Supports the retrieval of the and from where prefixed with 'config:' or '^', or is wrapped with '%'. + public static ServiceBusReceiverOptions CreateForTopicSubscription(string topicName = QueueOrTopicNameAsConfigKey, string subscriptionName = SubscriptionNameAsConfigKey) + => new(topicName, subscriptionName.ThrowIfNullOrEmpty()); + + /// + /// Initializes a new instance of the class. + /// + private ServiceBusReceiverOptions(string queueOrTopicName, string? subscriptionName) : base(queueOrTopicName, subscriptionName) + { + ProcessorOptions = new ServiceBusProcessorOptions + { + ReceiveMode = ServiceBusReceiveMode.PeekLock, + AutoCompleteMessages = false, + MaxConcurrentCalls = 1, + MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(5), + PrefetchCount = 0 + }; + } + + /// + /// Gets or sets the to be used when creating a instance. + /// + public ServiceBusProcessorOptions ProcessorOptions { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverResiliency.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverResiliency.cs new file mode 100644 index 00000000..2312ecf3 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverResiliency.cs @@ -0,0 +1,127 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides factory methods for creating standardized resilience pipelines for service bus message receivers () via either the +/// or . +/// +/// Their usage is intended as follows: +/// +/// -> . +/// -> . +/// +public static class ServiceBusReceiverResiliency +{ + /// + /// Creates a standardized with circuit breaker capabilities to protect the service bus receiver from unhandled exceptions and allow for automatic recovery. + /// + /// The . + /// The . + /// The . + /// The initial duration for which the circuit breaker remains open before attempting to reset (exponentially increasing with each subsequent open). + /// The maximum duration for which the circuit breaker can remain open. + /// A configured instance. + /// The circuit breaker strategy is configured to handle failures that are not of type . The breaker will open based on the specified minimum throughput, + /// sampling duration, failure ratio, and break duration settings, and will log events at the warning level. + /// The default settings are: minimumThroughput = 5, samplingDuration = 30s, breakDuration = 15s, maxBreakDuration = 5m, failureRatio = 0.1 + public static ResiliencePipeline CreateReceiverCircuitBreakerResiliency(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null, TimeSpan? maxBreakDuration = null, double failureRatio = 0.1) + { + int circuitBreakerOpens = 0; + + samplingDuration ??= TimeSpan.FromSeconds(30); + breakDuration ??= TimeSpan.FromSeconds(15); + maxBreakDuration ??= TimeSpan.FromMinutes(5); + + return new ResiliencePipelineBuilder() + .AddCircuitBreaker(new CircuitBreakerStrategyOptions() + { + ShouldHandle = args => ValueTask.FromResult(args.Outcome.Result.IsFailure && args.Outcome.Result.Error is not EventSubscriberDeadLetterException), + MinimumThroughput = minimumThroughput, + SamplingDuration = samplingDuration.Value, + FailureRatio = failureRatio, + BreakDurationGenerator = args => + { + // Exponential backoff on each open, similar to: 15s, 30s, 60s, ... with a cap at 5 minutes (the default). + var n = Interlocked.Increment(ref circuitBreakerOpens); + var seconds = Math.Min(breakDuration.Value.TotalSeconds * Math.Pow(2, n - 1), maxBreakDuration.Value.TotalSeconds); + return ValueTask.FromResult(TimeSpan.FromSeconds(seconds)); + }, + OnOpened = args => + { + // Breaker is open; pause the receiver. + var owner = GetOwner(args.Context); + if (owner.Logger.IsEnabled(LogLevel.Warning)) + owner.Logger.LogWarning("Service bus receiver circuit breaker has been tripped for {BreakDuration}ms due to unhandled errors; receiver will be paused.", args.BreakDuration.TotalMilliseconds); + + var pause = args.BreakDuration.Add(TimeSpan.FromMilliseconds(100)); // Add a small buffer to ensure the breaker has fully opened before resuming. + + _ = Task.Run(async () => + { + await owner.PauseAsync($"Service bus receiver circuit breaker has been tripped; will resume automatically at: {DateTimeOffset.UtcNow.Add(pause):R}.").ConfigureAwait(false); + await Task.Delay(pause).ConfigureAwait(false); + await owner.ResumeAsync().ConfigureAwait(false); + }); + + return ValueTask.CompletedTask; + }, + OnHalfOpened = args => + { + var owner = GetOwner(args.Context); + if (owner.Logger.IsEnabled(LogLevel.Information)) + owner.Logger.LogInformation("Service bus receiver circuit breaker is attempting to recover in a limited state; receiver has been resumed."); + + return ValueTask.CompletedTask; + }, + OnClosed = args => + { + var owner = GetOwner(args.Context); + if (owner.Logger.IsEnabled(LogLevel.Information)) + owner.Logger.LogInformation("Service bus receiver circuit breaker has fully recovered; receiver is running."); + + // Reset after recovery. + Interlocked.Exchange(ref circuitBreakerOpens, 0); + return ValueTask.CompletedTask; + } + }) + .Build(); + } + + /// + /// Creates a standardized with retry capabilities for transient message processing errors. + /// + /// The delay between retry attempts. + /// The maximum number of retry attempts. + /// The strategy. + /// A configured instance. + /// The retry strategy is configured to handle failures that are specifically of type . The retry attempts will be made with a specified delay (defaults to two seconds) and + /// backoff strategy, and the retry attempts will be logged at the information level. + public static ResiliencePipeline CreateMessageRetryResiliency(TimeSpan? delay = null, int maxRetryAttempts = 3, DelayBackoffType backoffType = DelayBackoffType.Exponential) + { + return new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions() + { + ShouldHandle = args => ValueTask.FromResult(args.Outcome.Result.IsFailure && args.Outcome.Result.Error is EventSubscriberRetryException), + Delay = delay ?? TimeSpan.FromSeconds(2), + MaxRetryAttempts = maxRetryAttempts, + BackoffType = backoffType, + OnRetry = args => + { + var owner = GetOwner(args.Context); + if (owner.Logger.IsEnabled(LogLevel.Information)) + owner.Logger.LogInformation("Service bus message retry attempt {AttemptCount} in {AttemptDelay}ms.", args.AttemptNumber + 1, args.RetryDelay.TotalMilliseconds); + + return ValueTask.CompletedTask; + } + }) + .Build(); + } + + /// + /// Gets the used to configure and manage resilience strategies for the . + /// + public static ResiliencePropertyKey ResiliencePropertyKey { get; } = new(nameof(ServiceBusReceiverBase)); + + /// + /// Gets the owning/invoking from the . + /// + public static ServiceBusReceiverBase GetOwner(ResilienceContext context) => context.Properties.GetValue(ResiliencePropertyKey, default!); +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionReceiver.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionReceiver.cs new file mode 100644 index 00000000..0670181b --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionReceiver.cs @@ -0,0 +1,71 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Encapsulates the lifetime and message receiving management. +/// +/// The to be used for receiving each . +public sealed class ServiceBusSessionReceiver : ServiceBusReceiverBase where TSubscriber : ServiceBusSubscriberBase +{ + private readonly ServiceBusSessionProcessor _processor; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + public ServiceBusSessionReceiver(ServiceBusClient client, ServiceBusSessionReceiverOptions options, IServiceProvider serviceProvider, ILogger> logger) + : base(client, options, serviceProvider, logger) + { + var config = serviceProvider.GetRequiredService(); + var queueOrTopicName = Internal.GetValueFromConfigurationWhereApplicable(options.QueueOrTopicName, config); + + _processor = options.IsSubscription + ? client.CreateSessionProcessor(queueOrTopicName, Internal.GetValueFromConfigurationWhereApplicable(options.SubscriptionName!, config), options.SessionProcessorOptions) + : client.CreateSessionProcessor(queueOrTopicName, options.SessionProcessorOptions); + + _processor.ProcessMessageAsync += OnProcessMessageAsync; + _processor.ProcessErrorAsync += OnProcessErrorAsync; + } + + /// + /// Gets the to be used when creating the instance. + /// + public new ServiceBusSessionReceiverOptions Options => (ServiceBusSessionReceiverOptions)base.Options; + + /// + protected override Task OnStartAsync(CancellationToken cancellationToken) => _processor.StartProcessingAsync(cancellationToken); + + /// + protected override Task OnPauseAsync(CancellationToken cancellationToken) => _processor.StopProcessingAsync(cancellationToken); + + /// + protected override Task OnResumeAsync(CancellationToken cancellationToken) => _processor.StartProcessingAsync(cancellationToken); + + /// + protected override Task OnStopAsync(CancellationToken cancellationToken) => _processor.StopProcessingAsync(cancellationToken); + + /// + /// Handles the processing of a message. + /// + private Task OnProcessMessageAsync(ProcessSessionMessageEventArgs args) => ProcessMessageAsync(args.Message, new ProcessSessionMessageEventArgsActions(args), args.CancellationToken); + + /// + /// Handles the processing of an error/exception. + /// + private Task OnProcessErrorAsync(ProcessErrorEventArgs args) + { + ServiceBusErrorClassifier.ClassifyAndLogError(Logger, args); + return Task.CompletedTask; + } + + /// + protected async override ValueTask DisposeAsync(bool disposing) + { + if (disposing) + await _processor.DisposeAsync().ConfigureAwait(false); + + await base.DisposeAsync(disposing).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionReceiverOptions.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionReceiverOptions.cs new file mode 100644 index 00000000..0dc1ab41 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionReceiverOptions.cs @@ -0,0 +1,54 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides the options to be used when creating a instance. +/// +/// The defaults to a new with: +/// +/// = . +/// = . +/// = '4'. +/// = '00:05:00' (five minutes). +/// = '0'. +/// +/// +public class ServiceBusSessionReceiverOptions : ServiceBusReceiverOptionsBase +{ + /// + /// Create a for the specified . + /// + /// The queue name. + /// The . + /// Supports the retrieval of the from where prefixed with 'config:' or '^', or is wrapped with '%'. + public static ServiceBusSessionReceiverOptions CreateForQueue(string queueName = QueueOrTopicNameAsConfigKey) => new(queueName, null); + + /// + /// Create a for the specified and . + /// + /// The topic name. + /// The subscription name. + /// The . + /// Supports the retrieval of the and from where prefixed with 'config:' or '^', or is wrapped with '%'. + public static ServiceBusSessionReceiverOptions CreateForTopicSubscription(string topicName = QueueOrTopicNameAsConfigKey, string subscriptionName = SubscriptionNameAsConfigKey) + => new(topicName, subscriptionName.ThrowIfNullOrEmpty()); + + /// + /// Initializes a new instance of the class. + /// + private ServiceBusSessionReceiverOptions(string queueOrTopicName, string? subscriptionName) : base(queueOrTopicName, subscriptionName) + { + SessionProcessorOptions = new ServiceBusSessionProcessorOptions + { + ReceiveMode = ServiceBusReceiveMode.PeekLock, + AutoCompleteMessages = false, + MaxConcurrentSessions = 4, + MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(5), + PrefetchCount = 0 + }; + } + + /// + /// Gets or sets the to be used when creating a instance. + /// + public ServiceBusSessionProcessorOptions SessionProcessorOptions { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionStrategy.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionStrategy.cs new file mode 100644 index 00000000..198efa4c --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSessionStrategy.cs @@ -0,0 +1,29 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides the publishing strategy. +/// +/// See for more information on sessions. +public enum ServiceBusSessionStrategy +{ + /// + /// No is required; i.e. messages are not session-enabled. + /// + None, + + /// + /// Uses the as-is (unchanged) as the . + /// + /// The is generally set from the corresponding . + UsePartitionKeyAsIs, + + /// + /// Uses the converted to a using . + /// + /// Where the underlying partition-key value is such that there may be 100s/1000s/10000s+ of possible values, then leveraging this strategy with a sensible partition-size will help to ensure that the number of sessions is kept to a manageable level; e.g. 8, 16, 32, 64, etc. + /// This will aid the receiver-side where sessions are used to ensure that concurrent processing is spread across a smaller number of sessions (and thus more efficient) rather than having a large number of sessions with only a few messages in each. However, + /// note that there should be at least as many session receivers as the number of sessions to ensure that all sessions are being processed concurrently; in a fair and equable rate - this will avoid "hot" and "cold" sessions where some sessions are receiving more messages than + /// others and thus processing is not spread across the session receivers as well as it could be. + /// The is generally set from the corresponding . + UsePartitionKeyConvertedToAnId +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSubscribedSubscriber.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSubscribedSubscriber.cs new file mode 100644 index 00000000..70b20c9b --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSubscribedSubscriber.cs @@ -0,0 +1,47 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides the -based Azure Service Bus subscribing capabilities. +/// +/// The . +/// The . +/// The . +/// Leverages the to determine the appropriate for processing the received event. The +/// is used as the Title and the () as the Source. +public sealed class ServiceBusSubscribedSubscriber(SubscribedManager subscribedManager, IEventFormatter formatter, ILogger logger) : ServiceBusSubscriberBase(formatter, logger) +{ + /// + /// Gets the . + /// + public SubscribedManager SubscribedManager { get; } = subscribedManager.ThrowIfNull(); + + /// + /// Gets or sets the name of the property within the that contains the source . + /// + /// Defaults to . + public string SourcePropertyName { get; set; } = ServiceBusExtensions.CloudEventSourcePropertyName; + + /// + protected override Task OnBeforeReceiveAsync(ServiceBusReceivedMessage message, EventSubscriberArgs args, CancellationToken cancellationToken = default) + { + if (Logger.IsEnabled(LogLevel.Debug)) + Logger.LogDebug("Received ServiceBusMessage with Id='{ServiceBusMessageId}', Subject='{ServiceBusMessageSubject}'.", message.MessageId, message.Subject); + + // Determine the Source Uri where specified as a property. + var source = !string.IsNullOrEmpty(SourcePropertyName) && message.ApplicationProperties.TryGetValue(SourcePropertyName, out var src) && src is string s + ? (Uri.TryCreate(s, UriKind.RelativeOrAbsolute, out var uri) ? uri : null) + : null; + + // Determine if subscribed; where no single subscriber then a failure is returned from the match to be handled as configured by the caller. + var subscribed = SubscribedManager.Match(ExecutionContext.TryGetCurrent(out var ctx, true) ? ctx : null!, args, message.Subject, source); + if (subscribed.IsFailure) + return subscribed.AsResult().AsTask(); + + return Result.SuccessTask; + } + + /// + /// Orchestrates the execution of the underlying . + protected override Task OnReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) + => SubscribedManager.ReceiveAsync(ExecutionContext.TryGetCurrent(out var ctx, true) ? ctx : null!, args.Subscriber ?? throw new InvalidOperationException("The Subscriber cannot be null after a successful match; something in wrong internally."), @event, args, cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSubscriberBase.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSubscriberBase.cs new file mode 100644 index 00000000..8afc2c8c --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusSubscriberBase.cs @@ -0,0 +1,51 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides the base Azure Service Bus subscribing capabilities. +/// +/// The . +/// The . +public abstract class ServiceBusSubscriberBase(IEventFormatter formatter, ILogger logger) : EventSubscriberBase(formatter, logger) +{ + /// + /// Gets or sets the to use when receiving messages. + /// + /// Where (the default), then the will be inferred from the (see ); + /// this offers the greatest flexibility and is the recommended value. + public ContentMode? ContentMode { get; set; } + + /// + /// Receives a . + /// + /// The . + /// The optional . + /// The . + public Task ReceiveAsync(ServiceBusReceivedMessage message, EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) + { + args ??= new EventSubscriberArgs(); + args.Owner ??= this; + args.Message = message.ThrowIfNull(); + + Activity.Current?.AddTag("messaging.subject", message.Subject); + + return EventSubscriberMetrics.ReceiveMessageAsync(args, async () => + { + // Pre-process. + var br = await OnBeforeReceiveAsync(message, args, cancellationToken).ConfigureAwait(false); + if (br.IsFailure) + return br; + + // Convert to CloudEvent and process. + return await ReceiveAsync(message.ToCloudEvent(ContentMode), args, cancellationToken).ConfigureAwait(false); + }); + } + + /// + /// Receives the providing an opportunity to perform actions before further processing as a converted . + /// + /// The . + /// The . + /// The . + /// The . + protected virtual Task OnBeforeReceiveAsync(ServiceBusReceivedMessage message, EventSubscriberArgs args, CancellationToken cancellationToken = default) => Result.SuccessTask; +} \ No newline at end of file diff --git a/src/CoreEx.Azure/AppConfig/Extensions.cs b/src/CoreEx.Azure/AppConfig/Extensions.cs deleted file mode 100644 index 8a68c154..00000000 --- a/src/CoreEx.Azure/AppConfig/Extensions.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using Azure.Identity; -using Microsoft.Extensions.Configuration; - -namespace CoreEx.Azure.AppConfig; - -/// -/// Extensions for using Azure App Configuration Service -/// -public static class Extensions -{ - /// - /// Adds the Azure App Configuration provider for services using configuration builder - /// - /// The . - /// Name of the connection to use for Azure App Configuration in local configuration - /// The label filter to use for Azure App Configuration in local configuration - /// The application configuration key filters. - /// The instance to support fluent-style method-chaining. - /// Thrown when configuration value for provided key doesn't exist. - /// Use "UseLocal=true" value for app configuration connection string to skip connecting to Azure Service and use local values only. - /// To use with Azure functions call it with IFunctionsConfigurationBuilder.ConfigurationBuilder - public static IConfigurationBuilder AddAzureAppConfiguration(this IConfigurationBuilder builder, string connectionName = "AppConfigConnectionString", string? labelFilter = null, params string[] keyPrefixes) - { - // build configuration and get connection string to Azure App Configuration (this most likely will be environment variable set in app service) - var config = builder.ThrowIfNull(nameof(builder)).Build(); - var accs = config.GetValue(connectionName); - - if (string.IsNullOrEmpty(accs)) - throw new InvalidOperationException(@$"{nameof(AddAzureAppConfiguration)}: {connectionName} setting not found. If Azure App Service Configuration is not needed - - do not call {nameof(AddAzureAppConfiguration)}. For local development or unit testing, use ""UseLocal=true"" in the configuration. - Custom connection string name can be provided by calling {nameof(AddAzureAppConfiguration)}(connectionName: ""AzureAppConfigurationConnection"")."); - - if (accs.Equals("UseLocal=true", StringComparison.OrdinalIgnoreCase)) - { - System.Diagnostics.Debug.WriteLine("Using local configuration because UseLocal=true was set in Azure App Configuration connection string."); - return builder; - } - - builder.AddAzureAppConfiguration(o => - { - if (accs.Contains(";Secret=", StringComparison.OrdinalIgnoreCase)) - { - // connect to Azure App Configuration with secret - o.Connect(accs); - } - else - { - // connect to Azure App Configuration with managed identity - o.Connect(new Uri(accs), new DefaultAzureCredential()) - // since managed identity is used - let it resolve keyvault secrets - .ConfigureKeyVault(kv => - { - kv.SetCredential(new DefaultAzureCredential()); - }); - } - - foreach (var prefix in keyPrefixes) - { - // note: azure app config doesn't like null/empty - o.Select(prefix + "*"); - - // if label filter is provided - use it, after default keys are loaded - // the idea is that labeled values override some (or all) values without label - if (!string.IsNullOrEmpty(labelFilter)) - o.Select(prefix + "*", labelFilter); - } - }); - - return builder; - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/CoreEx.Azure.csproj b/src/CoreEx.Azure/CoreEx.Azure.csproj deleted file mode 100644 index 248ca758..00000000 --- a/src/CoreEx.Azure/CoreEx.Azure.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net6.0;net8.0;net9.0 - CoreEx.Azure - CoreEx - CoreEx .NET Azure Extensions. - CoreEx .NET Azure Extensions. - coreex api function aspnet azure servicebus - - - - - - - - - - - - - - - - - - - - diff --git a/src/CoreEx.Azure/README.md b/src/CoreEx.Azure/README.md deleted file mode 100644 index 411c6d97..00000000 --- a/src/CoreEx.Azure/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# About - -tbd - -## Health Checks - -Popular health check library [AspNetCore.Diagnostics.HealthChecks](https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks) provides checks for azure service bus. - -CoreEx.Azure has two checks, which use `SettingsBase` class for reading configuration: - -* `CoreEx.Azure.Health.AzureServiceBusQueueHealthCheck` -* `CoreEx.Azure.Health.AzureServiceBusTopicHealthCheck` - -which can be registered using following code: - -```csharp -builder.Services - .AddScoped() - .AddHealthChecks() - .AddServiceBusQueueHealthCheck("Health check for Service Bus trigger (inbound) connection", nameof(SampleSettings.ServiceBusConnection__fullyQualifiedNamespace), nameof(SampleSettings.QueueName)) - .AddServiceBusQueueHealthCheck("Health check for Service Bus publisher (outbound) connection", nameof(SampleSettings.PublisherServiceBusConnection), nameof(SampleSettings.QueueName)) -``` diff --git a/src/CoreEx.Azure/ServiceBus/Abstractions/ServiceBusMessageActions.cs b/src/CoreEx.Azure/ServiceBus/Abstractions/ServiceBusMessageActions.cs deleted file mode 100644 index 9326ddf4..00000000 --- a/src/CoreEx.Azure/ServiceBus/Abstractions/ServiceBusMessageActions.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using WebJobs = Microsoft.Azure.WebJobs.ServiceBus; -using Worker = Microsoft.Azure.Functions.Worker; - -namespace CoreEx.Azure.ServiceBus.Abstractions -{ - /// - /// Provides the message actions that can be performed in an implementation agnostic manner. - /// - /// This is intended to encapsulate either a or providing a single means to manage. - public class ServiceBusMessageActions - { - private readonly WebJobs.ServiceBusMessageActions? _webJobMessageActions; - private readonly Worker.ServiceBusMessageActions? _workerMessageActions; - - /// - /// Initializes a new instance of the class. - /// - /// The . - public ServiceBusMessageActions(WebJobs.ServiceBusMessageActions webJobMessageActions) => _webJobMessageActions = webJobMessageActions.ThrowIfNull(); - - /// - /// Initializes a new instance of the class. - /// - /// The . - public ServiceBusMessageActions(Worker.ServiceBusMessageActions workerMessageActions) => _workerMessageActions = workerMessageActions.ThrowIfNull(); - - /// - public virtual async Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) - => await (_webJobMessageActions?.CompleteMessageAsync(message, cancellationToken) - ?? _workerMessageActions?.CompleteMessageAsync(message, cancellationToken) - ?? Task.CompletedTask); - - /// - public virtual async Task AbandonMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = default, CancellationToken cancellationToken = default) - => await (_webJobMessageActions?.AbandonMessageAsync(message, propertiesToModify, cancellationToken) - ?? _workerMessageActions?.AbandonMessageAsync(message, propertiesToModify, cancellationToken) - ?? Task.CompletedTask); - - /// - public virtual async Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, Dictionary? propertiesToModify = default, string? deadLetterReason = default, string? deadLetterErrorDescription = default, CancellationToken cancellationToken = default) - => await (_webJobMessageActions?.DeadLetterMessageAsync(message, propertiesToModify, deadLetterReason, deadLetterErrorDescription, cancellationToken) - ?? _workerMessageActions?.DeadLetterMessageAsync(message, propertiesToModify, deadLetterReason, deadLetterErrorDescription, cancellationToken) - ?? Task.CompletedTask); - - /// . - public virtual async Task DeferMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = default, CancellationToken cancellationToken = default) - => await (_webJobMessageActions?.DeferMessageAsync(message, propertiesToModify, cancellationToken) - ?? _workerMessageActions?.DeferMessageAsync(message, propertiesToModify, cancellationToken) - ?? Task.CompletedTask); - - /// - /// Implicitly converts a to a . - /// - /// The . - public static implicit operator ServiceBusMessageActions(WebJobs.ServiceBusMessageActions actions) => new(actions); - - /// - /// Implicitly converts a to a . - /// - /// The . - public static implicit operator ServiceBusMessageActions(Worker.ServiceBusMessageActions actions) => new(actions); - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/EventDataToServiceBusConverter.cs b/src/CoreEx.Azure/ServiceBus/EventDataToServiceBusConverter.cs deleted file mode 100644 index a76f13d3..00000000 --- a/src/CoreEx.Azure/ServiceBus/EventDataToServiceBusConverter.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using CoreEx.Invokers; -using CoreEx.Events; -using CoreEx.Mapping.Converters; -using CoreEx.Text.Json; -using System; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Converts an to a . - /// - /// Internally converts an to a corresponding using the , then converts to the using the . - /// The to serialize the into a corresponding . - /// The to convert an to a corresponding . - public class EventDataToServiceBusConverter(IEventSerializer? eventSerializer = null, IValueConverter? valueConverter = null) : IValueConverter - { - /// - /// Gets the to serialize the into for the . - /// - protected IEventSerializer EventSerializer { get; } = eventSerializer ?? ExecutionContext.GetService() ?? new EventDataSerializer(); - - /// - /// Gets the to convert an to a corresponding . - /// - protected IValueConverter EventSendDataConverter { get; } = valueConverter ?? ExecutionContext.GetService>() ?? new EventSendDataToServiceBusConverter(); - - /// - public ServiceBusMessage Convert(EventData @event) - { - EventSerializer.EventDataFormatter.Format(@event); - var esd = new EventSendData(@event) { Data = Invoker.RunSync(() => EventSerializer.SerializeAsync(@event)) }; - return EventSendDataConverter.Convert(esd); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/EventSendDataToServiceBusConverter.cs b/src/CoreEx.Azure/ServiceBus/EventSendDataToServiceBusConverter.cs deleted file mode 100644 index 87c7ecfa..00000000 --- a/src/CoreEx.Azure/ServiceBus/EventSendDataToServiceBusConverter.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using CoreEx.Events; -using CoreEx.Mapping.Converters; -using System; -using System.Linq; -using System.Net.Mime; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Converts an to a . - /// - public class EventSendDataToServiceBusConverter : IValueConverter - { - /// - /// Gets or sets the property selection; where a property is selected it will be set as one of the properties. - /// - /// Defaults to . - public EventDataProperty PropertySelection { get; set; } = EventDataProperty.All; - - /// - /// Gets or sets the name for the . - /// - /// Defaults to '_SessionId'. - public string SessionIdAttributeName { get; set; } = $"_{nameof(ServiceBusMessage.SessionId)}"; - - /// - /// Gets or sets the name for the . - /// - /// Defaults to '_TimeToLive'. - public string TimeToLiveAttributeName { get; set; } = $"_{nameof(ServiceBusMessage.TimeToLive)}"; - - /// - /// Indicates whether to use the as the . - /// - public bool UsePartitionKeyAsSessionId { get; set; } = true; - - /// - /// By default the will be used to update the from the , followed by the - /// option, until not null; otherwise, will be left as null. - /// Similarily, the will be used to update the from the . - public ServiceBusMessage Convert(EventSendData @event) - { - var message = new ServiceBusMessage(@event.Data) - { - MessageId = @event.Id, - ContentType = MediaTypeNames.Application.Json, - CorrelationId = @event.CorrelationId ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current.CorrelationId : null), - Subject = @event.Subject - }; - - if (@event.Action != null && PropertySelection.HasFlag(EventDataProperty.Action)) - message.ApplicationProperties.Add(nameof(EventData.Action), @event.Action); - - if (@event.Source != null && PropertySelection.HasFlag(EventDataProperty.Source)) - message.ApplicationProperties.Add(nameof(EventData.Source), @event.Source.ToString()); - - if (@event.Type != null && PropertySelection.HasFlag(EventDataProperty.Type)) - message.ApplicationProperties.Add(nameof(EventData.Type), @event.Type); - - if (@event.TenantId != null && PropertySelection.HasFlag(EventDataProperty.TenantId)) - message.ApplicationProperties.Add(nameof(EventData.TenantId), @event.TenantId); - - if (@event.PartitionKey != null && PropertySelection.HasFlag(EventDataProperty.PartitionKey)) - message.ApplicationProperties.Add(nameof(EventData.PartitionKey), @event.PartitionKey); - - if (@event.ETag != null && PropertySelection.HasFlag(EventDataProperty.ETag)) - message.ApplicationProperties.Add(nameof(EventData.ETag), @event.ETag); - - if (@event.Key != null && PropertySelection.HasFlag(EventDataProperty.Key)) - message.ApplicationProperties.Add(nameof(EventData.Key), @event.Key); - - if (@event.Attributes != null && @event.Attributes.Count > 0 && PropertySelection.HasFlag(EventDataProperty.Attributes)) - { - // Attrtibutes that start with an underscore are considered internal and will not be sent automatically; i.e. _SessionId and _TimeToLive. - foreach (var attribute in @event.Attributes.Where(x => !string.IsNullOrEmpty(x.Key) && !x.Key.StartsWith('_'))) - { - message.ApplicationProperties.Add(attribute.Key, attribute.Value); - } - } - - if (message.SessionId == null) - { - if (@event.Attributes != null && @event.Attributes.TryGetValue(SessionIdAttributeName, out var sessionId)) - message.SessionId = sessionId; - else - message.SessionId = UsePartitionKeyAsSessionId ? @event.PartitionKey : null; - } - - if (@event.Attributes != null && @event.Attributes.TryGetValue(TimeToLiveAttributeName, out var ttl) && TimeSpan.TryParse(ttl, out var timeToLive)) - message.TimeToLive = timeToLive; - - return message; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/HealthChecks/ServiceBusReceiverHealthCheck.cs b/src/CoreEx.Azure/ServiceBus/HealthChecks/ServiceBusReceiverHealthCheck.cs deleted file mode 100644 index 6d682c19..00000000 --- a/src/CoreEx.Azure/ServiceBus/HealthChecks/ServiceBusReceiverHealthCheck.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Azure.ServiceBus.HealthChecks -{ - /// - /// Provides a to verify the receiver is accessible by peeking a message. - /// - /// The create factory. - public class ServiceBusReceiverHealthCheck(Func receiverFactory) : IHealthCheck - { - private readonly Func _receiverFactory = receiverFactory.ThrowIfNull(nameof(receiverFactory)); - - /// - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - await using var receiver = _receiverFactory() ?? throw new InvalidOperationException("The ServiceBusReceiver factory returned null."); - var msg = await receiver.PeekMessageAsync(null, cancellationToken).ConfigureAwait(false); - return HealthCheckResult.Healthy(null, new Dictionary{ { "message", msg is null ? "none" : new Message { MessageId = msg.MessageId, CorrelationId = msg.CorrelationId, Subject = msg.Subject, SessionId = msg.SessionId, PartitionKey = msg.PartitionKey } } }); - } - - private class Message - { - public string? MessageId { get; set; } - public string? CorrelationId { get; set; } - public string? Subject { get; set; } - public string? SessionId { get; set; } - public string? PartitionKey { get; set; } - } - } -} diff --git a/src/CoreEx.Azure/ServiceBus/IEventPurger.cs b/src/CoreEx.Azure/ServiceBus/IEventPurger.cs deleted file mode 100644 index 0009039e..00000000 --- a/src/CoreEx.Azure/ServiceBus/IEventPurger.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Defines the standardized Event purging via the actual messaging platform/protocol. - /// - public interface IEventPurger - { - /// - /// Purges the dead letter of all the messages. - /// - /// The queue name. - /// The optional action for each purged message. - /// The . - Task PurgeDeadLetterAsync(string queueName, Action? messageAction = null, CancellationToken cancellationToken = default); - - /// - /// Purges the of all the messages. - /// - /// The queue name. - /// The optional action for each purged message. - /// The . - Task PurgeAsync(string queueName, Action? messageAction = null, CancellationToken cancellationToken = default); - - /// - /// Purges the dead letter and of all the messages. - /// - /// The topic name. - /// The subscription name. - /// The optional action for each purged message. - /// The . - Task PurgeDeadLetterAsync(string topicName, string subscriptionName, Action? messageAction = null, CancellationToken cancellationToken = default); - - /// - /// Purges the and of all the messages. - /// - /// The topic name. - /// The subscription name. - /// The optional action for each purged message. - /// The . - Task PurgeAsync(string topicName, string subscriptionName, Action? messageAction = null, CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/IServiceBusSender.cs b/src/CoreEx.Azure/ServiceBus/IServiceBusSender.cs deleted file mode 100644 index bf2f8534..00000000 --- a/src/CoreEx.Azure/ServiceBus/IServiceBusSender.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Defines the standardized Event sending via Azure Service Bus. - /// - public interface IServiceBusSender : IEventSender { } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/IServiceBusSubscriber.cs b/src/CoreEx.Azure/ServiceBus/IServiceBusSubscriber.cs deleted file mode 100644 index a98edbb0..00000000 --- a/src/CoreEx.Azure/ServiceBus/IServiceBusSubscriber.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using CoreEx.Abstractions; -using System; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Defines the standardized Azure Service Bus subscriber properties. - /// - public interface IServiceBusSubscriber - { - /// - /// Indicates when true that an should be issued when a is encounterd; otherwise, when false allow the to bubble up the stack. - /// - bool AbandonOnTransient { get; set; } - - /// - /// Gets or sets the optional maximum delivery count before a corresponding will be issued. - /// - /// Where null this indicates that this checking is solely the responsibility of the Azure Service Bus infrastructure. This value can not exceed the corresponding Azure Service Bus configuration setting as that takes precedence. - int? MaxDeliveryCount { get; set; } - - /// - /// Get or sets the optional retry to define a multiplicative delay where an is encounterd. - /// - /// The is multiplied by this value to achieve the final multiplicative delay value. - /// This is performed after an unsuccessful transient processing attempt effectively continuing to lock the the for the duration of the delay before finally handling as defined by . - TimeSpan? RetryDelay { get; set; } - - /// - /// Gets or sets the optional maximum retry that represents the upper bounds of the multiplicative . - /// - /// Where a value is specified and the corresponding is null this value will be used only achieving a fixed delay value. - TimeSpan? MaxRetryDelay { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/IServiceCollectionExtensions.cs b/src/CoreEx.Azure/ServiceBus/IServiceCollectionExtensions.cs deleted file mode 100644 index ab4ecafc..00000000 --- a/src/CoreEx.Azure/ServiceBus/IServiceCollectionExtensions.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Azure.ServiceBus; -using CoreEx.Azure.ServiceBus.HealthChecks; -using CoreEx.Configuration; -using CoreEx.Events; -using CoreEx.Events.Subscribing; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using Asb = Azure.Messaging.ServiceBus; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extensions. - /// - public static class ServiceCollectionExtensions - { - /// - /// Adds the Azure as a scoped service. - /// - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddAzureServiceBusSubscriber(this IServiceCollection services, Action? configure = null) => services.AddScoped(sp => - { - var sbs = new ServiceBusSubscriber(sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetService(), sp.GetService(), sp.GetService>(), sp.GetService()); - configure?.Invoke(sp, sbs); - return sbs; - }); - - /// - /// Adds the Azure as a scoped service. - /// - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddAzureServiceBusOrchestratedSubscriber(this IServiceCollection services, Action? configure = null) => services.AddScoped(sp => - { - var sbos = new ServiceBusOrchestratedSubscriber(sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetService(), sp.GetService(), sp.GetService>(), sp.GetService()); - configure?.Invoke(sp, sbos); - return sbos; - }); - - /// - /// Adds the Azure as the scoped service. - /// - /// The . - /// The . - public static IServiceCollection AddAzureServiceBusReceivedMessageConverter(this IServiceCollection services) => services.AddScoped(); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddAzureServiceBusSender(this IServiceCollection services, Action? configure = null) => services.AddScoped(sp => - { - var sbs = new ServiceBusSender(sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>()); - configure?.Invoke(sp, sbs); - return sbs; - }); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddAzureServiceBusPurger(this IServiceCollection services, Action? configure = null) => services.AddScoped(sp => - { - var sbp = new ServiceBusPurger(sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>()); - configure?.Invoke(sp, sbp); - return sbp; - }); - - /// - /// Adds a that will peek a message from the Azure Service Bus receiver to confirm health. - /// - /// The . - /// The health check name. Defaults to 'azure-service-bus-receiver'. - /// The factory. - /// The that should be reported when the health check reports a failure. If the provided value is null, then will be reported. - /// A list of tags that can be used for filtering health checks. - /// An optional representing the timeout of the check. - public static IHealthChecksBuilder AddServiceBusReceiverHealthCheck(this IHealthChecksBuilder builder, Func serviceBusReceiverFactory, string? name = null, HealthStatus? failureStatus = default, IEnumerable? tags = default, TimeSpan? timeout = default) - { - serviceBusReceiverFactory.ThrowIfNull(nameof(serviceBusReceiverFactory)); - - return builder.Add(new HealthCheckRegistration(name ?? "azure-service-bus-receiver", sp => - { - return new ServiceBusReceiverHealthCheck(() => serviceBusReceiverFactory(sp)); - }, failureStatus, tags, timeout)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/README.md b/src/CoreEx.Azure/ServiceBus/README.md deleted file mode 100644 index f14177dd..00000000 --- a/src/CoreEx.Azure/ServiceBus/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# CoreEx.Azure.ServiceBus - -Provides the key [Azure Service Bus](https://docs.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview) capabilities, leveraging and extending the [`Azure.Messaging.ServiceBus`](https://docs.microsoft.com/en-us/dotnet/api/overview/azure/messaging.servicebus-readme) library. - -
- -## Publishing - -A _CoreEx_ [`ServiceBusSender`](./ServiceBusSender.cs) provides the [`IEventSender.SendAsync`](../../CoreEx/Events/IEventSender.cs) capabilities to batch send one or more events/mesages to Azure Service Bus. - -
- -## Subscribing - -A _CoreEx_ [`ServiceBusSubscriber`](../../CoreEx.Azure/ServiceBus/ServiceBusSubscriber.cs) implementation is provided to encapsulate the standard capabilities to ensure consistency with respect to the processing and underlying management of the message. - -The `ReceiveAsync` method requires the [`ServiceBusReceivedMessage`](https://docs.microsoft.com/en-us/dotnet/api/azure.messaging.servicebus.servicebusreceivedmessage) and [`ServiceBusMessageActions`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.webjobs.servicebus.servicebusmessageactions) and performs the following steps: -- Begins a logging scope to include the correlation identifier from the message. -- Deserializes the `ServiceBusReceivedMessage` into the corresponding [`EventData`](../../CoreEx/Events/EventDataT.cs). -- Invokes the processing logic for the event and where successful calls `ServiceBusMessageActions.CompleteMessageAsync`. -- Handle all exceptions: - - Where the exception implements [`IExtendedException`](../../CoreEx/Abstractions/IExtendedException.cs) and `IsTransient` then log a warning and bubble the exception for the host process to manage a retry. - - Finally, log the error and invoke `ServiceBusMessageActions.DeadLetterMessageAsync`. - -
- -### Azure ServiceBus-triggered Function example - -The following demonstrates usage when using the [`ServiceBusTrigger`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.webjobs.servicebustriggerattribute) within an Azure Function: - -``` csharp -public class ServiceBusExecuteVerificationFunction -{ - private readonly ServiceBusSubscriber _subscriber; - private readonly VerificationService _service; - - public ServiceBusExecuteVerificationFunction(ServiceBusSubscriber subscriber, VerificationService service) - { - _subscriber = subscriber; - _service = service; - } - - [FunctionName(nameof(ServiceBusExecuteVerificationFunction))] - public Task RunAsync([ServiceBusTrigger("%" + nameof(HrSettings.VerificationQueueName) + "%", Connection = nameof(HrSettings.ServiceBusConnection))] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions) - => _subscriber.ReceiveAsync(message, messageActions, ed => _service.VerifyAndPublish(ed.Value), validator: new EmployeeVerificationValidator().Wrap()); -} -``` - -
- -### Instrumentation - -To get further insights into the processing of the messages an [`IEventSubscriberInstrumentation`](../../CoreEx/Events/IEventSubscriberInstrumentation.cs) can be implemented. The corresponding `EventSubscriberBase.Instrumentation` property should be set during construction; typically performed during dependency injection. Determine whether the instrumentation instance should also be registered as a _singleton_. - -An example implementation for Azure Application Insights would be similar to as follows: - -``` csharp -public class AppInsightInstrumentation : EventSubscriberInstrumentationBase -{ - private readonly TelemetryClient _telemetryClient; - - public AppInsightInstrumentation(TelemetryClient telemetryClient) - => _telemetryClient = telemetryClient.ThrowIfNull(nameof(telemetryClient)); - - public override void Instrument(ErrorHandling? errorHandling = null, Exception? exception = null) - => _telemetryClient.TrackEvent(GetInstrumentName("Subscriber", errorHandling, exception)); -} -``` diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusOrchestratedSubscriber.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusOrchestratedSubscriber.cs deleted file mode 100644 index 9a0797c5..00000000 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusOrchestratedSubscriber.cs +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using CoreEx.Abstractions; -using CoreEx.Azure.ServiceBus.Abstractions; -using CoreEx.Configuration; -using CoreEx.Events; -using CoreEx.Events.Subscribing; -using Microsoft.Extensions.Logging; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Provides the -managed subscribe (receive) execution encapsulation to run the underlying function logic in a consistent manner. - /// - /// The ReceiveAsync enables the standardized logic. The correlation identifier is set using the ; where null a will be used as the - /// default. A with the and is performed to wrap the logic logging with the correlation and - /// message identifiers. Where the unhandled is this will bubble out for the Azure Function runtime/fabric to retry and automatically deadletter; otherwise, it will be - /// immediately deadletted with a reason of or depending on the exception . - /// The is invoked after each deserialization. - public class ServiceBusOrchestratedSubscriber : EventSubscriberBase, IServiceBusSubscriber - { - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// The . - /// The . - /// The optional . - /// The optional . - /// The optional . - /// The optional . - public ServiceBusOrchestratedSubscriber(EventSubscriberOrchestrator orchestrator, ExecutionContext executionContext, SettingsBase settings, ILogger logger, EventSubscriberInvoker? eventSubscriberInvoker = null, ServiceBusSubscriberInvoker? serviceBusSubscriberInvoker = null, IEventDataConverter? eventDataConverter = null, IEventSerializer? eventSerializer = null) - : base(eventDataConverter ?? new ServiceBusReceivedMessageEventDataConverter(eventSerializer ?? new CoreEx.Text.Json.EventDataSerializer()), executionContext, settings, logger, eventSubscriberInvoker) - { - Orchestrator = orchestrator.ThrowIfNull(nameof(orchestrator)); - ServiceBusSubscriberInvoker = serviceBusSubscriberInvoker ?? (ServiceBusSubscriber._invoker ??= new ServiceBusSubscriberInvoker()); - AbandonOnTransient = settings.GetCoreExValue($"{GetType().Name}:{nameof(AbandonOnTransient)}", false); - MaxDeliveryCount = settings.GetCoreExValue($"{GetType().Name}:{nameof(MaxDeliveryCount)}"); - RetryDelay = settings.GetCoreExValue($"{GetType().Name}:{nameof(RetryDelay)}"); - MaxRetryDelay = settings.GetCoreExValue($"{GetType().Name}:{nameof(MaxRetryDelay)}"); - } - - /// - /// Gets the . - /// - protected EventSubscriberOrchestrator Orchestrator { get; } - - /// - /// Gets the . - /// - protected ServiceBusSubscriberInvoker ServiceBusSubscriberInvoker { get; } - - /// - /// Gets the . - /// - protected new IEventDataConverter EventDataConverter => (IEventDataConverter)base.EventDataConverter; - - /// - public bool AbandonOnTransient { get; set; } - - /// - public int? MaxDeliveryCount { get; set; } - - /// - public TimeSpan? RetryDelay { get; set; } - - /// - public TimeSpan? MaxRetryDelay { get; set; } - - /// - /// Encapsulates the execution of an leveraging the underlying to receive and process the message. - /// - /// The . - /// The . - /// The . - /// The . - public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) - { - message.ThrowIfNull(nameof(message)); - messageActions.ThrowIfNull(nameof(messageActions)); - - return ServiceBusSubscriberInvoker.InvokeAsync(this, async (_, ct) => - { - // Perform any pre-processing. - args ??= []; - var canProceed = await OnBeforeProcessingAsync(message.MessageId, message, args, cancellationToken).ConfigureAwait(false); - if (!canProceed) - return; - - // Get the event (without value as type unknown). - var @event = await DeserializeEventAsync(message.MessageId, message, cancellationToken); - if (@event is null) - return; - - ServiceBusSubscriber.UpdateEventSubscriberArgsWithServiceBusMessage(args, message, messageActions); - - // Match subscriber to metadata. - var match = Orchestrator.TryMatchSubscriber(this, @event, args); - if (!match.Matched) - { - var txt = $"Subject: {(string.IsNullOrEmpty(@event.Subject) ? "" : @event.Subject)}, Action: {(string.IsNullOrEmpty(@event.Action) ? "" : @event.Action)}, Type: {(string.IsNullOrEmpty(@event.Type) ? "" : @event.Type)}"; - var esex = new EventSubscriberException(match.Subscriber == null ? $"No corresponding Subscriber could be matched; {txt}" : $"More than one Subscriber was matched (ambiguous); {txt}") - { ExceptionSource = match.Subscriber == null ? EventSubscriberExceptionSource.OrchestratorNotSubscribed : EventSubscriberExceptionSource.OrchestratorAmbiquousSubscriber }; - - await ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(@event.Id, esex, match.Subscriber == null ? Orchestrator.NotSubscribedHandling : Orchestrator.AmbiquousSubscriberHandling, Logger) { Instrumentation = Instrumentation, WorkOrchestrator = WorkStateOrchestrator }, cancellationToken); - return; - } - - // Deserialize the event (again) where there is a value as value not deserialized previously. - if (match.ValueType is not null) - { - @event = await DeserializeEventAsync(message.MessageId, message, match.ValueType, cancellationToken).ConfigureAwait(false); - if (@event is null) - return; - } - - // Execute subscriber receive with the event. - await Orchestrator.ReceiveAsync(this, match.Subscriber!, @event, args, cancellationToken).ConfigureAwait(false); - }, (message, messageActions), cancellationToken); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusPurger.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusPurger.cs deleted file mode 100644 index d4713b05..00000000 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusPurger.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; -using Azure.Messaging.ServiceBus; -using CoreEx.Configuration; -using Microsoft.Extensions.Logging; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Provides the Azure ServiceBus purging capability. - /// - /// The underlying . - /// The . - /// The . - public class ServiceBusPurger(ServiceBusClient client, SettingsBase settings, ILogger logger) : IEventPurger - { - private readonly ServiceBusClient _client = client.ThrowIfNull(nameof(client)); - private readonly SettingsBase Settings = settings.ThrowIfNull(nameof(settings)); - private readonly ILogger Logger = logger.ThrowIfNull(nameof(logger)); - - /// - public Task PurgeDeadLetterAsync(string queueName, Action? messageAction = null, CancellationToken cancellationToken = default) - => PurgeAsync(queueName, null, SubQueue.DeadLetter, messageAction, cancellationToken); - - /// - public Task PurgeAsync(string queueName, Action? messageAction = null, CancellationToken cancellationToken = default) - => PurgeAsync(queueName, null, SubQueue.None, messageAction, cancellationToken); - - /// - public Task PurgeDeadLetterAsync(string topicName, string subscriptionName, Action? messageAction = null, CancellationToken cancellationToken = default) - => PurgeAsync(topicName, subscriptionName, SubQueue.DeadLetter, messageAction, cancellationToken); - - /// - public Task PurgeAsync(string topicName, string subscriptionName, Action? messageAction = null, CancellationToken cancellationToken = default) - => PurgeAsync(topicName, subscriptionName, SubQueue.None, messageAction, cancellationToken); - - /// - /// Purges the queue or topic subscription. - /// - private async Task PurgeAsync(string queueOrTopicName, string? subscriptionName, SubQueue subQueue, Action? messageAction, CancellationToken cancellationToken) - { - queueOrTopicName.ThrowIfNullOrEmpty(nameof(queueOrTopicName)); - - // Get queue name and subscription name by checking settings override. - var qn = Settings.GetCoreExValue($"Publisher_ServiceBusQueueName_{queueOrTopicName}", defaultValue: queueOrTopicName); - var sn = string.IsNullOrEmpty(subscriptionName) ? null : Settings.GetCoreExValue($"Publisher_ServiceBusSubscriptionName_{subscriptionName}", defaultValue: subscriptionName); - - // Receive from Dead letter - var o = new ServiceBusReceiverOptions { SubQueue = subQueue, PrefetchCount = 500, ReceiveMode = ServiceBusReceiveMode.ReceiveAndDelete }; - await using var receiver = sn == null ? _client.CreateReceiver(qn, o) : _client.CreateReceiver(qn, sn, o); - - // Purge messages. - try - { - var messages = await receiver.ReceiveMessagesAsync(500, maxWaitTime: TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false); - - while (messages != null && messages.Count > 0) - { - if (messageAction != null) - { - foreach (var message in messages) - { - messageAction(message); - } - } - - messages = await receiver.ReceiveMessagesAsync(500, maxWaitTime: TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false); - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, $"Service Bus message(s) couldn't be purged from {qn} {(subscriptionName == null ? "" : $"{subscriptionName} ")}sub-queue: {subQueue}."); - throw; - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusReceivedMessageEventDataConverter.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusReceivedMessageEventDataConverter.cs deleted file mode 100644 index bab48686..00000000 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusReceivedMessageEventDataConverter.cs +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using CoreEx.Events; -using CoreEx.Text.Json; -using System; -using System.Net.Mime; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Converts a to an or . - /// - /// The to deserialize the into the corresponding or . - public class ServiceBusReceivedMessageEventDataConverter(IEventSerializer? eventSerializer = null) : IEventDataConverter - { - /// - /// Gets the to deserialize the into the corresponding or . - /// - protected IEventSerializer EventSerializer { get; } = eventSerializer ?? ExecutionContext.GetService() ?? new EventDataSerializer(); - - /// - /// This method is not supported. - /// This method is not supported; throws a . - public Task ConvertToAsync(EventData @event, CancellationToken cancellationToken) => throw new NotSupportedException($"The {nameof(ServiceBusReceivedMessage)} constructor is internal; therefore, can not be instantiated."); - - /// - public Task ConvertFromMetadataOnlyAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken) - { - var @event = new EventData(); - UpdateMetaDataWhereApplicable(message, @event); - return Task.FromResult(@event); - } - - /// - public async Task ConvertFromAsync(ServiceBusReceivedMessage message, Type? valueType, CancellationToken cancellationToken) - { - EventData @event; - if (valueType is null) - { - if (message.ContentType == MediaTypeNames.Application.Json) - @event = await EventSerializer.DeserializeAsync(message.Body, cancellationToken).ConfigureAwait(false); - else if (message.ContentType == MediaTypeNames.Text.Plain) - @event = new EventData { Value = message.Body.ToString() }; - else - @event = new EventData(); - } - else - { - if (message.ContentType == MediaTypeNames.Text.Plain && valueType == typeof(string)) - @event = new EventData { Value = message.Body.ToString() }; - else - @event = await EventSerializer.DeserializeAsync(message.Body, valueType, cancellationToken).ConfigureAwait(false)!; - } - - UpdateMetaDataWhereApplicable(message, @event); - return @event; - } - - /// - public async Task> ConvertFromAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken) - { - EventData @event = await EventSerializer.DeserializeAsync(message.Body, cancellationToken).ConfigureAwait(false); - UpdateMetaDataWhereApplicable(message, @event); - return @event; - } - - /// - /// Updates the metadata from the where the is not null; otherwise, assume already updated. - /// - /// The - /// The . - private static void UpdateMetaDataWhereApplicable(ServiceBusReceivedMessage message, EventData @event) - { - if (@event.Id is not null) - return; - - @event.Id = message.MessageId; - @event.CorrelationId = message.CorrelationId; - @event.Subject = message.Subject; - @event.PartitionKey = message.SessionId; - - foreach (var p in message.ApplicationProperties) - { - switch (p.Key) - { - case nameof(EventData.Action): @event.Action = p.Value?.ToString(); break; - case nameof(EventData.Source): @event.Source = p.Value == null ? null : new Uri(p.Value.ToString()!, UriKind.RelativeOrAbsolute); break; - case nameof(EventData.Type): @event.Type = p.Value?.ToString(); break; - case nameof(EventData.TenantId): @event.Type = p.Value?.ToString(); break; - case nameof(EventData.PartitionKey): @event.PartitionKey = p.Value?.ToString(); break; - case nameof(EventData.ETag): @event.ETag = p.Value?.ToString(); break; - case nameof(EventData.Key): @event.Key = p.Value?.ToString(); break; - default: @event.AddAttribute(p.Key, p.Value?.ToString() ?? string.Empty); break; - } - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusReceiverActions.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusReceiverActions.cs deleted file mode 100644 index e2940d04..00000000 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusReceiverActions.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using Microsoft.Azure.WebJobs.ServiceBus; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Represents the set of message actions that can be performed on a and related . - /// - /// This is required as the base contains internal constructor for therefore this is needed to override methods and implement same. - /// The . - /// - public class ServiceBusReceiverActions(ServiceBusReceiver serviceBusReceiver) : ServiceBusMessageActions - { - private readonly ServiceBusReceiver _serviceBusReceiver = serviceBusReceiver.ThrowIfNull(nameof(serviceBusReceiver)); - - /// - public override Task AbandonMessageAsync(ServiceBusReceivedMessage message, IDictionary propertiesToModify = default!, CancellationToken cancellationToken = default) - => _serviceBusReceiver.AbandonMessageAsync(message, propertiesToModify, cancellationToken); - - /// - public override Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) - => _serviceBusReceiver.CompleteMessageAsync(message, cancellationToken); - - /// - public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, Dictionary propertiesToModify, string deadLetterReason, string deadLetterErrorDescription = default!, CancellationToken cancellationToken = default) - => _serviceBusReceiver.DeadLetterMessageAsync(message, propertiesToModify, deadLetterReason, deadLetterErrorDescription, cancellationToken); - - /// - public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, IDictionary propertiesToModify = default!, CancellationToken cancellationToken = default) - => _serviceBusReceiver.DeadLetterMessageAsync(message, propertiesToModify, cancellationToken); - - /// - public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, string deadLetterReason, string deadLetterErrorDescription = default!, CancellationToken cancellationToken = default) - => _serviceBusReceiver.DeadLetterMessageAsync(message, deadLetterReason, deadLetterErrorDescription, cancellationToken); - - /// - public override Task DeferMessageAsync(ServiceBusReceivedMessage message, IDictionary propertiesToModify = default!, CancellationToken cancellationToken = default) - => _serviceBusReceiver.DeferMessageAsync(message, propertiesToModify, cancellationToken); - - /// - public override Task RenewMessageLockAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) - => base.RenewMessageLockAsync(message, cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusSender.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusSender.cs deleted file mode 100644 index db9e3ead..00000000 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusSender.cs +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Azure.Messaging.ServiceBus; -using CoreEx.Configuration; -using CoreEx.Events; -using CoreEx.Mapping.Converters; -using Microsoft.Extensions.Logging; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Represents an Azure (see also ). - /// - /// See for details of automatic and allocation. - /// Note, that any where the starts with an underscore character ('_') will not be included in the . - /// The underlying . - /// The . - /// The . - /// The optional . - /// The optional to convert an to a corresponding . - public class ServiceBusSender(ServiceBusClient client, SettingsBase settings, ILogger logger, ServiceBusSenderInvoker? invoker = null, IValueConverter? converter = null) : IServiceBusSender - { - private const string _unspecifiedQueueOrTopicName = "$default"; - private static ServiceBusSenderInvoker? _invoker; - private readonly ServiceBusClient _client = client.ThrowIfNull(nameof(client)); - - /// - /// Gets the . - /// - protected SettingsBase Settings { get; } = settings.ThrowIfNull(nameof(settings)); - - /// - /// Gets the . - /// - protected ILogger Logger { get; } = logger.ThrowIfNull(nameof(logger)); - - /// - /// Gets the . - /// - protected ServiceBusSenderInvoker Invoker { get; } = invoker ?? (_invoker ??= new ServiceBusSenderInvoker()); - - /// - /// Gets the to convert an to a corresponding . - /// - protected IValueConverter Converter { get; } = converter ?? new EventSendDataToServiceBusConverter(); - - /// - /// Gets or sets the default queue or topic name used by where is null. - /// - public string? DefaultQueueOrTopicName { get; set; } - - /// - public Task SendAsync(IEnumerable events, CancellationToken cancellationToken = default) - { - if (events == null || !events.Any()) - return Task.CompletedTask; - - return Invoker.InvokeAsync(this, events, async (_, events, cancellationToken) => - { - var totalCount = events.Count(); - Logger.LogDebug("{TotalCount} events in total are to be sent.", totalCount); - - if (events.Count() != events.Select(x => x.Id).Distinct().Count()) - throw new EventSendException(PrependStats($"All events must have a unique identifier ({nameof(EventSendData)}.{nameof(EventSendData.Id)}).", totalCount, totalCount), events); - - // Sets up the list of unsent events. - var unsentEvents = new List(events); - - // Why this logic: https://github.com/Azure/azure-sdk-for-net/tree/Azure.Messaging.ServiceBus_7.1.0/sdk/servicebus/Azure.Messaging.ServiceBus/#send-and-receive-a-batch-of-messages - var queueDict = new Dictionary>(); - var index = 0; - foreach (var @event in events) - { - var message = Converter.Convert(@event) ?? throw new EventSendException($"The {nameof(Converter)} must return a {nameof(ServiceBusMessage)} instance."); - var name = @event.Destination ?? DefaultQueueOrTopicName ?? _unspecifiedQueueOrTopicName; - - if (queueDict.TryGetValue(name, out var queue)) - queue.Enqueue((message, index++)); - else - { - queue = new Queue<(ServiceBusMessage, int)>(); - queue.Enqueue((message, index++)); - queueDict.Add(name, queue); - } - } - - Logger.LogDebug("There are {QueueTopicCount} queues/topics specified; as such there will be that many batches sent as a minimum.", queueDict.Keys.Count); - - // Get queue name by checking configuration override. - foreach (var qitem in queueDict) - { - var n = qitem.Key == _unspecifiedQueueOrTopicName ? null : qitem.Key; - var key = $"{GetType().Name}_QueueOrTopicName{(n is null ? "" : $"_{n}")}"; - var qn = Settings.GetCoreExValue($"{GetType().Name}:QueueOrTopicName{(n is null ? "" : $"_{n}")}", defaultValue: n) ?? throw new EventSendException(PrependStats($"'{key}' configuration setting must have a non-null value.", totalCount, unsentEvents.Count), unsentEvents); - var queue = qitem.Value; - var sentIds = new List(); - - // Send in batches. - await using var sender = _client.CreateSender(qn); - while (queue.Count > 0) - { - sentIds.Clear(); - using var batch = await sender.CreateMessageBatchAsync(cancellationToken).ConfigureAwait(false); - - // Add the first message to the batch. - var firstMsg = queue.Peek(); - if (batch.TryAddMessage(firstMsg.Message)) - { - sentIds.Add(firstMsg.Message.MessageId); - queue.Dequeue(); - } - else - throw new EventSendException(PrependStats("ServiceBusMessage is too large and cannot be sent.", totalCount, unsentEvents.Count), unsentEvents); - - // Keep adding until done or max size reached for batch. - while (queue.Count > 0 && batch.TryAddMessage(queue.Peek().Message)) - { - sentIds.Add(queue.Peek().Message.MessageId); - queue.Dequeue(); - } - - try - { - Logger.LogDebug("Sending {Count} message(s) to {Name}.", batch.Count, qn); - await sender.SendMessagesAsync(batch, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.LogDebug("{UnsentCount} of the total {TotalCount} events were not successfully sent.", unsentEvents.Count, totalCount); - throw new EventSendException(PrependStats($"ServiceBusMessage cannot be sent: {ex.Message}", totalCount, unsentEvents.Count), ex, unsentEvents); - } - - // Begin next batch after confirming sent events; continue ^ where any left. - unsentEvents.RemoveAll(esd => sentIds.Contains(esd.Id ?? string.Empty)); - } - } - - // Raise the event. - AfterSend?.Invoke(this, EventArgs.Empty); - }, cancellationToken, nameof(SendAsync)); - } - - /// - /// Prepend the sent stats to the message. - /// - private static string PrependStats(string message, int totalCount, int unsentCount) => $"{unsentCount} of the total {totalCount} events were not successfully sent. {message}"; - - /// - public event EventHandler? AfterSend; - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusSenderInvoker.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusSenderInvoker.cs deleted file mode 100644 index ab17c2e0..00000000 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusSenderInvoker.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; -using System; -using System.Threading; -using System.Threading.Tasks; -using System.Transactions; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Provides the standard invoker functionality. - /// - /// Suppresses the as Azure Service Bus does not support distributed transactions and this may be invoked in the context of an already enlisted transaction. - public class ServiceBusSenderInvoker : InvokerBase - { - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, ServiceBusSender invoker, Func func) => throw new NotSupportedException(); - - /// - protected async override System.Threading.Tasks.Task OnInvokeAsync(InvokeArgs invokeArgs, ServiceBusSender invoker, Func> func, CancellationToken cancellationToken) - { - TransactionScope? txn = null; - try - { - txn = new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled); - return await base.OnInvokeAsync(invokeArgs, invoker, func, cancellationToken).ConfigureAwait(false); - } - finally - { - txn?.Dispose(); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriber.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriber.cs deleted file mode 100644 index f85c26e7..00000000 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriber.cs +++ /dev/null @@ -1,262 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using CoreEx.Abstractions; -using CoreEx.Azure.ServiceBus.Abstractions; -using CoreEx.Configuration; -using CoreEx.Events; -using CoreEx.Events.Subscribing; -using CoreEx.Results; -using CoreEx.Validation; -using Microsoft.Extensions.Logging; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Provides the standard subscribe (receive) execution encapsulation to run the underlying function logic in a consistent manner. - /// - /// The ReceiveAsync enables the standardized logic. The correlation identifier is set using the ; where null a will be used as the - /// default. A with the and is performed to wrap the logic logging with the correlation and - /// message identifiers. Where the unhandled is this will bubble out for the Azure Function runtime/fabric to retry and automatically deadletter; otherwise, it will be - /// immediately deadletted with a reason of or depending on the exception . - /// The is invoked after each deserialization. - public class ServiceBusSubscriber : EventSubscriberBase, IServiceBusSubscriber - { - /// - /// Gets the name to access the . - /// - public const string ServiceBusReceivedMessageName = nameof(ServiceBusReceivedMessage); - - /// - /// Gets the name to access the . - /// - public const string ServiceBusMessageActionsName = nameof(ServiceBusMessageActions); - - internal static ServiceBusSubscriberInvoker? _invoker; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// The . - /// The optional . - /// The optional . - /// The optional . - /// The optional . - public ServiceBusSubscriber(ExecutionContext executionContext, SettingsBase settings, ILogger logger, EventSubscriberInvoker? eventSubscriberInvoker = null, ServiceBusSubscriberInvoker? serviceBusSubscriberInvoker = null, IEventDataConverter? eventDataConverter = null, IEventSerializer? eventSerializer = null) - : base(eventDataConverter ?? new ServiceBusReceivedMessageEventDataConverter(eventSerializer ?? new CoreEx.Text.Json.EventDataSerializer()), executionContext, settings, logger, eventSubscriberInvoker) - { - ServiceBusSubscriberInvoker = serviceBusSubscriberInvoker ?? (_invoker ??= new ServiceBusSubscriberInvoker()); - AbandonOnTransient = settings.GetCoreExValue($"{GetType().Name}:{nameof(AbandonOnTransient)}", false); - MaxDeliveryCount = settings.GetCoreExValue($"{GetType().Name}:{nameof(MaxDeliveryCount)}"); - RetryDelay = settings.GetCoreExValue($"{GetType().Name}:{nameof(RetryDelay)}"); - MaxRetryDelay = settings.GetCoreExValue($"{GetType().Name}:{nameof(MaxRetryDelay)}"); - } - - /// - /// Gets the . - /// - protected ServiceBusSubscriberInvoker ServiceBusSubscriberInvoker { get; } - - /// - /// Gets the . - /// - protected new IEventDataConverter EventDataConverter => (IEventDataConverter)base.EventDataConverter; - - /// - public bool AbandonOnTransient { get; set; } - - /// - public int? MaxDeliveryCount { get; set; } - - /// - public TimeSpan? RetryDelay { get; set; } - - /// - public TimeSpan? MaxRetryDelay { get; set; } - - /// - /// Encapsulates the execution of an converting the into a corresponding (with no value) for processing. - /// - /// The . - /// The . - /// The function logic to invoke. - /// The . - /// The . - public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, Func function, EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) - => ReceiveAsync(message, messageActions, (ed, ea, _) => Result.Success.ThenAsync(() => function(ed, ea)), args, cancellationToken); - - /// - /// Encapsulates the execution of an converting the into a corresponding (with no value) for processing. - /// - /// The . - /// The . - /// The function logic to invoke. - /// The . - /// The . - public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, Func function, EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) - => ReceiveAsync(message, messageActions, (ed, ea, ct) => Result.Success.ThenAsync(() => function(ed, ea, ct)), args, cancellationToken); - - /// - /// Encapsulates the execution of an converting the into a corresponding (with no value) for processing. - /// - /// The . - /// The . - /// The function logic to invoke. - /// The . - /// The . - public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, Func> function, EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) - => ReceiveAsync(message, messageActions, (ed, ea, _) => function(ed, ea), args, cancellationToken); - - /// - /// Encapsulates the execution of an converting the into a corresponding (with no value) for processing. - /// - /// The . - /// The . - /// The function logic to invoke. - /// The . - /// The . - public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, Func> function, EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) - { - message.ThrowIfNull(nameof(message)); - messageActions.ThrowIfNull(nameof(messageActions)); - function.ThrowIfNull(nameof(function)); - - return ServiceBusSubscriberInvoker.InvokeAsync(this, async (_, ct) => - { - // Perform any pre-processing. - args ??= []; - var canProceed = await OnBeforeProcessingAsync(message.MessageId, message, args, cancellationToken).ConfigureAwait(false); - if (!canProceed) - return; - - // Deserialize the JSON into the selected type. - var @event = await DeserializeEventAsync(message.MessageId, message, cancellationToken).ConfigureAwait(false); - if (@event is null) - return; - - UpdateEventSubscriberArgsWithServiceBusMessage(args, message, messageActions); - - // Invoke the actual function logic. - Result.Go(await function(@event!, args, ct).ConfigureAwait(false)) - .Then(() => Logger.LogDebug("{Type} executed successfully - Service Bus message '{Message}'.", GetType().Name, message.MessageId)) - .ThrowOnError(); - - // Perform the complete/success instrumentation. - if (WorkStateOrchestrator is not null) - await WorkStateOrchestrator.CompleteAsync(@event.Id!, cancellationToken); - - Instrumentation?.Instrument(); - }, (message, messageActions), cancellationToken); - } - - /// - /// Encapsulates the execution of an converting the into a corresponding for processing. - /// - /// The event value . - /// The . - /// The . - /// The function logic to invoke. - /// The to validate the deserialized value. - /// Indicates whether the value is required; will consider invalid where null. - /// The . - /// The . - public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, Func, EventSubscriberArgs, Task> function, IValidator? validator = null, bool valueIsRequired = true, - EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) - => ReceiveAsync(message, messageActions, (ed, ea, _) => Result.Success.ThenAsync(() => function(ed, ea)), validator, valueIsRequired, args, cancellationToken); - - /// - /// Encapsulates the execution of an converting the into a corresponding for processing. - /// - /// The event value . - /// The . - /// The . - /// The function logic to invoke. - /// The to validate the deserialized value. - /// Indicates whether the value is required; will consider invalid where null. - /// The . - /// The . - public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, Func, EventSubscriberArgs, CancellationToken, Task> function, IValidator? validator = null, bool valueIsRequired = true, - EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) - => ReceiveAsync(message, messageActions, (ed, ea, ct) => Result.Success.ThenAsync(() => function(ed, ea, ct)), validator, valueIsRequired, args, cancellationToken); - - /// - /// Encapsulates the execution of an converting the into a corresponding for processing. - /// - /// The event value . - /// The . - /// The . - /// The function logic to invoke. - /// The to validate the deserialized value. - /// Indicates whether the value is required; will consider invalid where null. - /// The . - /// The . - public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, Func, EventSubscriberArgs, Task> function, IValidator? validator = null, bool valueIsRequired = true, - EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) - => ReceiveAsync(message, messageActions, (ed, ea, _) => function(ed, ea), validator, valueIsRequired, args, cancellationToken); - - /// - /// Encapsulates the execution of an converting the into a corresponding for processing. - /// - /// The event value . - /// The . - /// The . - /// The function logic to invoke. - /// The to validate the deserialized value. - /// Indicates whether the value is required; will consider invalid where null. - /// The . - /// The . - public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, Func, EventSubscriberArgs, CancellationToken, Task> function, IValidator? validator = null, bool valueIsRequired = true, - EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) - { - message.ThrowIfNull(nameof(message)); - messageActions.ThrowIfNull(nameof(messageActions)); - function.ThrowIfNull(nameof(function)); - - return ServiceBusSubscriberInvoker.InvokeAsync(this, async (_, ct) => - { - // Perform any pre-processing. - args ??= []; - var canProceed = await OnBeforeProcessingAsync(message.MessageId, message, args, cancellationToken).ConfigureAwait(false); - if (!canProceed) - return; - - // Deserialize the JSON into the selected type. - var @event = await DeserializeEventAsync(message.MessageId, message, valueIsRequired, validator, cancellationToken).ConfigureAwait(false); - if (@event is null) - return; - - UpdateEventSubscriberArgsWithServiceBusMessage(args, message, messageActions); - - // Invoke the actual function logic. - Result.Go(await function(@event!, args, ct).ConfigureAwait(false)) - .Then(() => Logger.LogDebug("{Type} executed successfully - Service Bus message '{Message}'.", GetType().Name, message.MessageId)) - .ThrowOnError(); - - // Perform the complete/success instrumentation. - if (WorkStateOrchestrator is not null) - await WorkStateOrchestrator.CompleteAsync(@event.Id!, cancellationToken); - - Instrumentation?.Instrument(); - }, (message, messageActions), cancellationToken); - } - - /// - /// Updates the dictionary with the corresponding and . - /// - /// The . - /// The . - /// The . - /// This will allow access to these values from within the event processing logic and is intended for advanced scenarios only; care should be taken to not perform an action that would result in the underlying host to fail - /// unexpectantly. - internal static void UpdateEventSubscriberArgsWithServiceBusMessage(EventSubscriberArgs args, ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions) - { - args.TryAdd(ServiceBusReceivedMessageName, message); - args.TryAdd(ServiceBusMessageActionsName, messageActions); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriberInvoker.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriberInvoker.cs deleted file mode 100644 index d0e9aa44..00000000 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriberInvoker.cs +++ /dev/null @@ -1,287 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Messaging.ServiceBus; -using CoreEx.Abstractions; -using CoreEx.Azure.ServiceBus.Abstractions; -using CoreEx.Events; -using CoreEx.Events.Subscribing; -using CoreEx.Invokers; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Azure.ServiceBus -{ - /// - /// Provides the standard invoker functionality. - /// - public class ServiceBusSubscriberInvoker : InvokerBase - { - private const string SubscriberExceptionPropertyName = "SubscriberException"; - private const string SubscriberAbandonReasonPropertyName = "SubscriberAbandonReason"; - - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, EventSubscriberBase invoker, Func func, (ServiceBusReceivedMessage Message, ServiceBusMessageActions MessageActions) args) => throw new NotSupportedException(); - - /// - protected async override Task OnInvokeAsync(InvokeArgs invokeArgs, EventSubscriberBase invoker, Func> func, (ServiceBusReceivedMessage Message, ServiceBusMessageActions MessageActions) args, CancellationToken cancellationToken) - { - if (args.Message == null) - throw new ArgumentException($"The {nameof(ServiceBusReceivedMessage)} value is required.", nameof(args)); - - if (args.MessageActions == null) - throw new ArgumentException($"The {nameof(ServiceBusMessageActions)} value is required.", nameof(args)); - - var stopwatch = invoker.Logger.IsEnabled(LogLevel.Debug) ? System.Diagnostics.Stopwatch.StartNew() : null; - invoker.Logger.LogDebug("ServiceBusSubscriber start."); - - if (!string.IsNullOrEmpty(args.Message.CorrelationId)) - invoker.ExecutionContext.CorrelationId = args.Message.CorrelationId; - - var state = new Dictionary - { - { nameof(ServiceBusReceivedMessage.MessageId), args.Message.MessageId }, - { nameof(EventData.CorrelationId), invoker.ExecutionContext.CorrelationId } - }; - - // Convert to metadata only to enable logging of standard metadata. - var @event = await invoker.EventDataConverter.ConvertFromMetadataOnlyAsync(args.Message, cancellationToken).ConfigureAwait(false); - UpdateLoggerState(args.Message, @event, state); - var scope = invoker.Logger.BeginScope(state); - - OnBeforeMessageProcessing(invoker, args.Message); - - try - { - return await OnInvokeInternalAsync(invokeArgs, invoker, func, args, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - var keepThrowing = await HandleExceptionAsync(invoker, args.Message, args.MessageActions, ex, cancellationToken).ConfigureAwait(false); - OnAfterMessageProcessing(invoker, args.Message, ex); - - if (keepThrowing) - throw; - - return default!; - } - finally - { - if (stopwatch is not null) - { - stopwatch.Stop(); - invoker.Logger.LogDebug("ServiceBusSubscriber elapsed {Elapsed}ms.", stopwatch.Elapsed.TotalMilliseconds); - } - - scope?.Dispose(); - } - } - - /// - /// Performs the internal service bus message processing and error handling. - /// - private async Task OnInvokeInternalAsync(InvokeArgs invokeArgs, EventSubscriberBase invoker, Func> func, (ServiceBusReceivedMessage Message, ServiceBusMessageActions MessageActions) args, CancellationToken cancellationToken) - { - TResult result = default!; - - try - { - invoker.Logger.LogDebug("Received - Service Bus message '{Message}'.", args.Message.MessageId); - - // Leverage the EventSubscriberInvoker to manage execution and standardized exception handling. - result = await invoker.EventSubscriberInvoker.InvokeAsync(invoker, async (_, ct) => - { - // Execute the logic. - return await base.OnInvokeAsync(invokeArgs, invoker, func, args, cancellationToken).ConfigureAwait(false); - }, cancellationToken).ConfigureAwait(false); - } - catch (EventSubscriberException) { throw; } - catch (Exception ex) when (ex is IExtendedException eex) - { - // Handle the exception based on the subscriber configuration. - var handling = ErrorHandler.DetermineErrorHandling(invoker, eex); - if (handling == ErrorHandling.HandleByHost) - { - if (invoker.WorkStateOrchestrator is not null) - await invoker.WorkStateOrchestrator.IndeterminateAsync(args.Message.MessageId, ex.Message, cancellationToken).ConfigureAwait(false); - - invoker.Instrumentation?.Instrument(ErrorHandling.HandleByHost, ex); - throw; - } - - await invoker.ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(args.Message.MessageId, new EventSubscriberException(ex.Message, ex), handling, invoker.Logger) { Instrumentation = invoker.Instrumentation, WorkOrchestrator = invoker.WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (invoker.UnhandledHandling != ErrorHandling.HandleByHost) - { - await invoker.ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(args.Message.MessageId, new EventSubscriberException(ex.Message, ex), invoker.UnhandledHandling, invoker.Logger) { Instrumentation = invoker.Instrumentation, WorkOrchestrator = invoker.WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (invoker.UnhandledHandling == ErrorHandling.HandleByHost) - { - if (invoker.WorkStateOrchestrator is not null) - await invoker.WorkStateOrchestrator.IndeterminateAsync(args.Message.MessageId!, ex.Message, cancellationToken).ConfigureAwait(false); - - invoker.Instrumentation?.Instrument(ErrorHandling.HandleByHost, ex); - throw; - } - finally - { - OnAfterMessageProcessing(invoker, args.Message, null); - } - - // Everything is good, so complete the message. - invoker.Logger.LogDebug("Completing - Service Bus message '{Message}'.", args.Message.MessageId); - await args.MessageActions.CompleteMessageAsync(args.Message, cancellationToken).ConfigureAwait(false); - invoker.Logger.LogDebug("Completed - Service Bus message '{Message}'.", args.Message.MessageId); - - return result; - } - - /// - /// Handle the exception. - /// - private static async Task HandleExceptionAsync(EventSubscriberBase invoker, ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, Exception exception, CancellationToken cancellationToken) - { - // Handle a known exception type. - if (exception is EventSubscriberException eex) - { - if (eex.ErrorHandling == ErrorHandling.HandleByHost) - return true; // Keep throwing; i.e. bubble exception. - - // Where not considered transient then dead-letter. - if (!eex.IsTransient) - { - await DeadLetterExceptionAsync(invoker, message, messageActions, eex.ErrorType, exception, cancellationToken).ConfigureAwait(false); - if (invoker.WorkStateOrchestrator is not null) - await invoker.WorkStateOrchestrator.FailAsync(message.MessageId, exception.Message, cancellationToken).ConfigureAwait(false); - - return false; - } - - // Determine the delay, if any. - var sbs = invoker as IServiceBusSubscriber; - var delay = sbs is not null && sbs.RetryDelay.HasValue ? (int)(sbs.RetryDelay.Value.TotalMilliseconds * message.DeliveryCount) : -1; - if (sbs is not null && sbs.MaxRetryDelay.HasValue) - { - if (delay < 0 || delay > sbs.MaxRetryDelay.Value.TotalMilliseconds) - delay = (int)sbs.MaxRetryDelay.Value.TotalMilliseconds; - } - - // Where the exception is known then exception and stack trace need not be logged. - var ex = eex.HasInnerExtendedException ? null : exception; - - // Log the transient retry as a warning. - if (delay <= 0) - invoker.Logger.LogWarning(ex, "Retry - Service Bus message '{Message}'. [{Reason}] Processing attempt {Count}. {Error}", message.MessageId, eex.ErrorType, message.DeliveryCount, exception.Message); - else - invoker.Logger.LogWarning(ex, "Retry - Service Bus message '{Message}'. [{Reason}] Processing attempt {Count}; retry delay {Delay}ms. {Error}", message.MessageId, eex.ErrorType, message.DeliveryCount, delay, exception.Message); - - if (sbs is not null) - { - if (sbs.MaxDeliveryCount.HasValue && message.DeliveryCount >= sbs.MaxDeliveryCount.Value) - { - // Dead-letter when maximum delivery count achieved. - var msg = $"Message could not be consumed after {sbs.MaxDeliveryCount.Value} attempts (as defined by {invoker.GetType().Name})."; - await DeadLetterExceptionAsync(invoker, message, messageActions, "MaxDeliveryCountExceeded", new EventSubscriberException(msg, exception), cancellationToken).ConfigureAwait(false); - if (invoker.WorkStateOrchestrator is not null) - await invoker.WorkStateOrchestrator.FailAsync(message.MessageId, msg, cancellationToken).ConfigureAwait(false); - - return false; - } - - if (delay > 0) - { - // Renew the lock to maximize time and then delay. - invoker.Logger.LogDebug("Retry delaying - Service Bus message '{Message}'. Retry delay {Delay}ms.", message.MessageId, delay); - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - invoker.Logger.LogDebug("Retry delayed - Service Bus message '{Message}'.", message.MessageId, delay); - } - - if (sbs.AbandonOnTransient) - { - // Abandon message versus bubbling. - invoker.Logger.LogDebug("Abandoning - Service Bus message '{Message}'.", message.MessageId); - await messageActions.AbandonMessageAsync(message, new Dictionary { { SubscriberAbandonReasonPropertyName, FormatText(exception.Message) } }, cancellationToken).ConfigureAwait(false); - invoker.Logger.LogDebug("Abandoned - Service Bus message '{Message}'.", message.MessageId); - return false; - } - } - - return true; // Keep throwing; i.e. bubble exception. - } - - // For the known exceptions it can be assumed that it only got this far because error handling for it was None so keep bubbling. - if (exception is IExtendedException) - return true; - - // Where the unhandled handling is set to None then keep bubbling; do not dead-letter. - if (invoker.UnhandledHandling == ErrorHandling.HandleByHost) - return true; - - // Dead-letter the unhandled exception. - await DeadLetterExceptionAsync(invoker, message, messageActions, ErrorType.UnhandledError.ToString(), exception, cancellationToken).ConfigureAwait(false); - if (invoker.WorkStateOrchestrator is not null) - await invoker.WorkStateOrchestrator.FailAsync(message.MessageId, exception.Message, cancellationToken).ConfigureAwait(false); - - return false; - } - - /// - /// Performs the dead-lettering. - /// - public static async Task DeadLetterExceptionAsync(EventSubscriberBase invoker, ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, string errorReason, Exception exception, CancellationToken cancellationToken) - { - var ex = exception is EventSubscriberException esex && esex.HasInnerExtendedException ? null : exception; - - invoker.Logger.LogDebug("Dead Lettering - Service Bus message '{Message}'. [{Reason}] {Error}", message.MessageId, errorReason, exception.Message); - await messageActions.DeadLetterMessageAsync(message, new Dictionary { { SubscriberExceptionPropertyName, FormatText(exception.ToString()) } }, errorReason, FormatText(exception.Message), cancellationToken).ConfigureAwait(false); - invoker.Logger.LogError(ex, "Dead Lettered - Service Bus message '{Message}'. [{Reason}] {Error}", message.MessageId, errorReason, exception.Message); - } - - /// - /// Shortens the text to 2048 characters; should be enough to given context - otherwise, full context should have be written to the log. - /// - private static string FormatText(string? text) => text?[..Math.Min(text.Length, 2048)] ?? string.Empty; - - /// - /// Update the from the . - /// - /// The . - /// The metadata only representation of the . - /// The state . - /// The and are automatically added prior. - /// The , , and properties represent the default implementation. - protected virtual void UpdateLoggerState(ServiceBusReceivedMessage message, EventData @event, IDictionary state) - { - if (!string.IsNullOrEmpty(@event.Subject)) - state.Add(nameof(EventData.Subject), @event.Subject); - - if (!string.IsNullOrEmpty(@event.Action)) - state.Add(nameof(EventData.Action), @event.Action); - - if (@event.Source != null) - state.Add(nameof(EventData.Source), @event.Source.ToString()); - - if (!string.IsNullOrEmpty(@event.Type)) - state.Add(nameof(EventData.Type), @event.Type); - } - - /// - /// Provides an opportunity to perform additional logging/monitoring before the processing occurs. - /// - /// The invoking . - /// The . - /// An should not be thrown within as this may result in an unexpected error. - protected virtual void OnBeforeMessageProcessing(EventSubscriberBase subscriber, ServiceBusReceivedMessage message) { } - - /// - /// Provides an opportunity to perform additional logging/monitoring after the processing occurs (including any corresponding invocation). - /// - /// The invoking . - /// The . - /// The corresponding where an error occured. - /// An should not be thrown within as this may result in an unexpected error. - protected virtual void OnAfterMessageProcessing(EventSubscriberBase subscriber, ServiceBusReceivedMessage message, Exception? exception) { } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/Storage/BlobAttachmentStorage.cs b/src/CoreEx.Azure/Storage/BlobAttachmentStorage.cs deleted file mode 100644 index 71d2dae7..00000000 --- a/src/CoreEx.Azure/Storage/BlobAttachmentStorage.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Storage.Blobs; -using CoreEx.Events; -using CoreEx.Events.Attachments; -using System; -using System.Net.Mime; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Azure.Storage -{ - /// - /// Provides the reading and writing of a attachment that exceeds the as identified by a corresponding within - /// Azure Blob Storage. - /// - /// The . - public class BlobAttachmentStorage(BlobContainerClient blobContainerClient) : IAttachmentStorage - { - private readonly BlobContainerClient _blobContainerClient = blobContainerClient.ThrowIfNull(nameof(blobContainerClient)); - - /// - public int MaxDataSize { get; set; } - - /// - /// Gets or sets the content type of the attachment. - /// - /// Defaults to - public string ContentType { get; set; } = MediaTypeNames.Application.Json; - - /// - public async Task ReadAync(EventAttachment attachment, CancellationToken cancellationToken) - { - var blobClient = _blobContainerClient.GetBlobClient(attachment.Attachment); - var blobDownloadInfo = await blobClient.DownloadAsync(cancellationToken).ConfigureAwait(false); - - return BinaryData.FromStream(blobDownloadInfo.Value.Content); - } - - /// - public async Task WriteAsync(EventData @event, BinaryData attachmentData, CancellationToken cancellationToken) - { - var blobName = @event.Id ?? Guid.NewGuid().ToString(); - if (ContentType == MediaTypeNames.Application.Json) - blobName = $"{blobName}.json"; - - // Where @event.tenantId is set, prepend to create a tenant specific folder. - if (@event.TenantId != null) - blobName = $"{@event.TenantId}/{blobName}"; - - var blobClient = _blobContainerClient.GetBlobClient(blobName); - await blobClient.UploadAsync(attachmentData.ToStream(), cancellationToken).ConfigureAwait(false); - - return new EventAttachment { Attachment = blobName, ContentType = ContentType }; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs b/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs deleted file mode 100644 index 8ebc276e..00000000 --- a/src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure; -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using Azure.Storage.Blobs.Specialized; -using CoreEx.Hosting; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading; - -namespace CoreEx.Azure.Storage -{ - /// - /// An that performs synchronization by acquiring a lease (see ) on a blob. - /// - /// A blob is created per with a name of and extension of '.lock'; e.g. 'Namespace.Class.lock'. For this to function correctly all running instances must be referencing the same blob container. - /// The duration to acquire the lease is generally unknown and an and can not be guaranteed in the case of failure. Therefore, an infinite value can not be used as the lease - /// would then need to be released manually under a failure. To mitigate this a lease is taken for the specified ; with an internal automatically renewing on . - /// This will result in worst case a lease of on failure. - public class BlobLeaseSynchronizer : IServiceSynchronizer - { - private readonly BlobContainerClient _client; - private readonly string _leaseId = Guid.NewGuid().ToString(); - private readonly ConcurrentDictionary _dict = new(); - private readonly Lazy _timer; - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// Performs a to ensure the container exists. - public BlobLeaseSynchronizer(BlobContainerClient client) - { - _client = client.ThrowIfNull(nameof(client)); - _timer = new Lazy(() => new Timer(_ => - { - foreach (var kvp in _dict.ToArray()) - { - try - { - kvp.Value.RenewAsync(); - } - catch { } // Swallow and carry on. - } - }, null, AutoRenewLeaseDuration, AutoRenewLeaseDuration), isThreadSafe: true); - } - - /// - /// Gets the duration. - /// - /// The value must be greater than the to function correctly. - public virtual TimeSpan LeaseDuration => TimeSpan.FromSeconds(60); - - /// - /// Gets the duration. - /// - /// The value must be less than the to function correctly. - public virtual TimeSpan AutoRenewLeaseDuration => TimeSpan.FromSeconds(30); - - /// - public bool Enter(string? name = null) - { - try - { - // Is exclusive for this invocation only where genuinely creating. - bool exclusiveLock = false; - - _dict.GetOrAdd(GetName(name), fn => - { - _client.CreateIfNotExists(); - - var blob = _client.GetBlobClient(GetName(name)); - try - { - var bp = blob.GetProperties(); - switch (bp.Value.LeaseState) - { - case LeaseState.Available: - case LeaseState.Expired: - case LeaseState.Broken: - break; - - default: - throw new RequestFailedException((int)HttpStatusCode.Conflict, "Invalid lease state."); - } - } - catch (RequestFailedException rfex) when (rfex.Status == (int)HttpStatusCode.NotFound) - { - // Does not exist, so create. - using var s = blob.OpenWrite(true); - } - - var lease = blob.GetBlobLeaseClient(_leaseId); - lease.Acquire(LeaseDuration); - exclusiveLock = true; - return lease; - }); - - // Start timer on first enter. - if (!_timer.IsValueCreated) - _ = _timer.Value; - - return exclusiveLock; - } - catch (RequestFailedException rfex) when (rfex.Status == (int)HttpStatusCode.PreconditionFailed || rfex.Status == (int)HttpStatusCode.Conflict) { return false; } // Already exists and locked! - catch (Exception ex) - { - throw new InvalidOperationException($"Unexpected exception whilst attempting to get an exclusive lease on blob '{GetName(name)}': {ex.Message}", ex); - } - } - - /// - public void Exit(string? name = null) - { - if (_dict.TryRemove(GetName(name), out var lease)) - ReleaseLease(lease); - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases the unmanaged resources used by the and optionally releases the managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (disposing && !_disposed) - { - _disposed = true; - if (_timer.IsValueCreated) - _timer.Value.Dispose(); - - _dict.Values.ForEach(ReleaseLease); - } - } - - /// - /// Gets the full name. - /// - private static string GetName(string? name) => $"{typeof(T).FullName}{(name == null ? "" : $".{name}")}.lock"; - - /// - /// Release the lease swallowing any and all exceptions. - /// - private static void ReleaseLease(BlobLeaseClient lease) - { - try { lease.Release(); } - catch (Exception) { /* Swallow and carry on. */ } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/Storage/BlobSasAttachmentStorage.cs b/src/CoreEx.Azure/Storage/BlobSasAttachmentStorage.cs deleted file mode 100644 index 6caf4edc..00000000 --- a/src/CoreEx.Azure/Storage/BlobSasAttachmentStorage.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Storage.Blobs; -using Azure.Storage.Sas; -using CoreEx.Events; -using CoreEx.Events.Attachments; -using System; -using System.Net.Mime; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Azure.Storage -{ - /// - /// Provides the reading and writing of a attachment that exceeds the as identified by a corresponding within - /// Azure Blob Storage using a SAS token. - /// - public class BlobSasAttachmentStorage : IAttachmentStorage - { - private readonly BlobContainerClient? _blobContainerClient; - - /// - /// Initializes a new instance of class with a - /// - /// The . - public BlobSasAttachmentStorage(BlobContainerClient blobContainerClient) => _blobContainerClient = blobContainerClient.ThrowIfNull(nameof(blobContainerClient)); - - /// - /// Initializes a new instance of class. - /// - public BlobSasAttachmentStorage() { } - - /// - public int MaxDataSize { get; set; } - - /// - /// Gets or sets the expiratation that is added to to create the SAS token. - /// - /// Defaults to two (2) days. - public TimeSpan Expiration { get; set; } = TimeSpan.FromDays(2); - - /// - /// Gets or sets the content type of the attachment. - /// - /// Defaults to - public string ContentType { get; set; } = MediaTypeNames.Application.Json; - - /// - public async Task ReadAync(EventAttachment attachment, CancellationToken cancellationToken) - { - var blobClient = new BlobClient(new Uri(attachment.Attachment!)); - var blobDownloadInfo = await blobClient.DownloadAsync(cancellationToken).ConfigureAwait(false); - - return BinaryData.FromStream(blobDownloadInfo.Value.Content); - } - - /// - /// Writes the attachment data to Azure Blob Storage and returns a SAS token to the blob - /// - /// - /// - /// - /// Reference to Event Atttachment as - public async Task WriteAsync(EventData @event, BinaryData attachmentData, CancellationToken cancellationToken) - { - if(_blobContainerClient == null) - throw new InvalidOperationException($"The {nameof(BlobContainerClient)} must be passed in the constructor in order to write."); - - var blobName = @event.Id ?? Guid.NewGuid().ToString(); - if (ContentType == MediaTypeNames.Application.Json) - blobName = $"{blobName}.json"; - - // Where @event.tenantId is set, prepend to create a tenant specific folder - if (@event.TenantId != null) - blobName = $"{@event.TenantId}/{blobName}"; - - var blobClient = _blobContainerClient.GetBlobClient(blobName); - var sasUri = blobClient.GenerateSasUri(BlobSasPermissions.Read, DateTimeOffset.UtcNow.Add(Expiration)); - await blobClient.UploadAsync(attachmentData.ToStream(), cancellationToken).ConfigureAwait(false); - - return new EventAttachment { Attachment = sasUri.ToString(), ContentType = ContentType }; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs b/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs deleted file mode 100644 index 9c42c138..00000000 --- a/src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs +++ /dev/null @@ -1,237 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure; -using Azure.Data.Tables; -using CoreEx.Hosting.Work; -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Azure.Storage -{ - /// - /// An that persists the using an Azure and the related to a separate . - /// - /// The maximum size currently supported is 960,000 bytes. - public class TableWorkStatePersistence : IWorkStatePersistence - { - private readonly TableClient _workStateTableClient; - private readonly TableClient _workDataTableClient; - private readonly SemaphoreSlim _semaphore = new(1, 1); - private volatile bool _firstTime = true; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The work state table name. - /// The work data table name. - public TableWorkStatePersistence(TableServiceClient tableServiceClient, string workStateTableName = "workstate", string workDataTableName = "workdata") - : this(tableServiceClient.ThrowIfNull(nameof(tableServiceClient)).GetTableClient(workStateTableName), tableServiceClient.GetTableClient(workDataTableName)) { } - - /// - /// Initializes a new instance of the class. - /// - /// The work state . - /// The work data . - public TableWorkStatePersistence(TableClient workStateTableClient, TableClient workDataTableClient) - { - if (workStateTableClient.ThrowIfNull(nameof(workStateTableClient)).Name == workDataTableClient.ThrowIfNull(nameof(workDataTableClient)).Name) - throw new ArgumentException("The work state and data table names must be different.", nameof(workDataTableClient)); - - _workDataTableClient = workDataTableClient; - _workStateTableClient = workStateTableClient; - - _workDataTableClient.CreateIfNotExists(); - _workStateTableClient.CreateIfNotExists(); - } - - private class WorkStateEntity() : WorkState, ITableEntity - { - public WorkStateEntity(WorkState state) : this() - { - RowKey = state.Id!; - TypeName = state.TypeName; - Key = state.Key; - CorrelationId = state.CorrelationId; - UserName = state.UserName; - Status = state.Status; - Created = state.Created; - Expiry = state.Expiry; - Started = state.Started; - Indeterminate = state.Indeterminate; - Finished = state.Finished; - Reason = state.Reason; - } - - public string PartitionKey { get; set; } = GetPartitionKey(); - public string RowKey { get; set; } = null!; - public DateTimeOffset? Timestamp { get; set; } - public ETag ETag { get; set; } - } - - private class WorkDataEntity() : ITableEntity - { - private const int _chunkSize = 64000; - private const int _maxChunks = 15; - private const int _maxSize = _chunkSize * _maxChunks; - private readonly BinaryData?[] _data = new BinaryData?[_maxChunks]; - - public WorkDataEntity(BinaryData data) : this() - { - var arr = data.ToArray(); - if (arr.Length <= _chunkSize) - { - Data00 = data; - return; - } - else if (arr.Length > _maxSize) - throw new ArgumentException($"Data too large ({arr.Length}B versus {_maxSize}B) to store in Azure table storage.", nameof(data)); - - var i = 0; - foreach (var chunk in data.ToArray().Chunk(_chunkSize)) - { - _data[i++] = BinaryData.FromBytes(chunk); - } - } - - public string PartitionKey { get; set; } = GetPartitionKey(); - public string RowKey { get; set; } = null!; - public DateTimeOffset? Timestamp { get; set; } - public ETag ETag { get; set; } - public BinaryData? Data00 { get => _data[0]; set => _data[0] = value; } - public BinaryData? Data01 { get => _data[1]; set => _data[1] = value; } - public BinaryData? Data02 { get => _data[2]; set => _data[2] = value; } - public BinaryData? Data03 { get => _data[3]; set => _data[3] = value; } - public BinaryData? Data04 { get => _data[4]; set => _data[4] = value; } - public BinaryData? Data05 { get => _data[5]; set => _data[5] = value; } - public BinaryData? Data06 { get => _data[6]; set => _data[6] = value; } - public BinaryData? Data07 { get => _data[7]; set => _data[7] = value; } - public BinaryData? Data08 { get => _data[8]; set => _data[8] = value; } - public BinaryData? Data09 { get => _data[9]; set => _data[9] = value; } - public BinaryData? Data10 { get => _data[10]; set => _data[10] = value; } - public BinaryData? Data11 { get => _data[11]; set => _data[11] = value; } - public BinaryData? Data12 { get => _data[12]; set => _data[12] = value; } - public BinaryData? Data13 { get => _data[13]; set => _data[13] = value; } - public BinaryData? Data14 { get => _data[14]; set => _data[14] = value; } - - /// - /// Unchunks the data properties into a single . - /// - /// The . - public BinaryData? ToSingleData() - { - if (Data00 is null || Data01 is null) - return Data00; - - using var ms = new MemoryStream(); - for (int i = 0; i < _maxChunks; i++) - { - if (_data[i] is null) - break; - - ms.Write(_data[i]!.ToArray()); - } - - ms.Position = 0; - return BinaryData.FromStream(ms); - } - } - - /// - /// Gets the partition key. - /// - private static string GetPartitionKey() => (ExecutionContext.HasCurrent ? ExecutionContext.Current.TenantId : null) ?? "default"; - - /// - /// Creates the tables if they do not already exist. - /// - private async Task CreateIfNotExistsAsync(CancellationToken cancellationToken) - { - if (_firstTime) - { - await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - if (_firstTime) - { - await _workDataTableClient.CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); - await _workStateTableClient.CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); - _firstTime = false; - } - } - finally - { - _semaphore.Release(); - } - } - } - - /// - public async Task GetAsync(string id, CancellationToken cancellationToken) - { - await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); - - var er = await _workStateTableClient.GetEntityIfExistsAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false); - if (!er.HasValue) - return null; - - return new WorkState - { - Id = er.Value!.RowKey, - TypeName = er.Value.TypeName, - Key = er.Value.Key, - CorrelationId = er.Value.CorrelationId, - UserName = er.Value.UserName, - Status = er.Value.Status, - Created = er.Value.Created, - Expiry = er.Value.Expiry, - Started = er.Value.Started, - Indeterminate = er.Value.Indeterminate, - Finished = er.Value.Finished, - Reason = er.Value.Reason - }; - } - - /// - public Task CreateAsync(WorkState state, CancellationToken cancellationToken) => UpsertAsync(state, cancellationToken); - - /// - public Task UpdateAsync(WorkState state, CancellationToken cancellationToken) => UpsertAsync(state, cancellationToken); - - /// - /// Performs an upsert (create/update). - /// - private async Task UpsertAsync(WorkState state, CancellationToken cancellationToken) - { - await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); - await _workStateTableClient.UpsertEntityAsync(new WorkStateEntity(state), TableUpdateMode.Replace, cancellationToken).ConfigureAwait(false); - } - - /// - public async Task DeleteAsync(string id, CancellationToken cancellationToken) - { - await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); - await _workDataTableClient.DeleteEntityAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false); - await _workStateTableClient.DeleteEntityAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false); - } - - /// - public async Task GetDataAsync(string id, CancellationToken cancellationToken) - { - await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); - - var er = await _workDataTableClient.GetEntityIfExistsAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false); - return er.HasValue ? er.Value!.ToSingleData() : null; - } - - /// - public async Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken) - { - await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false); - await _workDataTableClient.UpsertEntityAsync(new WorkDataEntity(data) { PartitionKey = GetPartitionKey(), RowKey = id }, TableUpdateMode.Replace, cancellationToken: cancellationToken).ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Azure/strong-name-key.snk b/src/CoreEx.Azure/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Caching.FusionCache/CoreEx.Caching.FusionCache.csproj b/src/CoreEx.Caching.FusionCache/CoreEx.Caching.FusionCache.csproj new file mode 100644 index 00000000..e734ffea --- /dev/null +++ b/src/CoreEx.Caching.FusionCache/CoreEx.Caching.FusionCache.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/CoreEx.Caching.FusionCache/CoreExFusionCacheExtensions.DependencyInjection.cs b/src/CoreEx.Caching.FusionCache/CoreExFusionCacheExtensions.DependencyInjection.cs new file mode 100644 index 00000000..9d1bceac --- /dev/null +++ b/src/CoreEx.Caching.FusionCache/CoreExFusionCacheExtensions.DependencyInjection.cs @@ -0,0 +1,16 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static partial class CoreExFusionCacheExtensions +{ + /// + /// Adds a scoped service for the using the . + /// + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddFusionHybridCache(this IServiceCollection services) => services.ThrowIfNull().AddScoped(); +} \ No newline at end of file diff --git a/src/CoreEx.Caching.FusionCache/FusionCacheExtensions.cs b/src/CoreEx.Caching.FusionCache/FusionCacheExtensions.cs new file mode 100644 index 00000000..82e640f0 --- /dev/null +++ b/src/CoreEx.Caching.FusionCache/FusionCacheExtensions.cs @@ -0,0 +1,22 @@ +namespace CoreEx.Caching.FusionCache; + +/// +/// Provides standard extensions for FusionCache. +/// +public static partial class FusionCacheExtensions +{ + /// + /// Converts the into a equivalent. + /// + /// The . + /// The resulting . + public static FusionCacheEntryOptions ToFusionCacheEntryOptions(this HybridCacheEntryOptions? options) => new() + { + Duration = options?.LocalExpiration ?? HybridCacheEntryOptions.DefaultLocalExpiration, + DistributedCacheDuration = options?.DistributedExpiration ?? HybridCacheEntryOptions.DefaultDistributedExpiration, + SkipDistributedCacheRead = (options?.Strategy ?? HybridCacheEntryOptions.DefaultStrategy) == CacheStrategy.Local, + SkipDistributedCacheWrite = (options?.Strategy ?? HybridCacheEntryOptions.DefaultStrategy) == CacheStrategy.Local, + SkipMemoryCacheRead = (options?.Strategy ?? HybridCacheEntryOptions.DefaultStrategy) == CacheStrategy.Distributed, + SkipMemoryCacheWrite = (options?.Strategy ?? HybridCacheEntryOptions.DefaultStrategy) == CacheStrategy.Distributed + }; +} \ No newline at end of file diff --git a/src/CoreEx.Caching.FusionCache/FusionHybridCache.cs b/src/CoreEx.Caching.FusionCache/FusionHybridCache.cs new file mode 100644 index 00000000..50804554 --- /dev/null +++ b/src/CoreEx.Caching.FusionCache/FusionHybridCache.cs @@ -0,0 +1,67 @@ +namespace CoreEx.Caching.FusionCache; + +/// +/// Provides the implementation based on FusionCache. +/// +/// The underlying . +/// The . +public class FusionHybridCache(IFusionCache fusionCache, ICacheKeyProvider cacheKeyProvider) : IHybridCache +{ + private readonly IFusionCache _fusionCache = fusionCache.ThrowIfNull(); + private Action? _configure; + + /// + public ICacheKeyProvider KeyProvider { get; } = cacheKeyProvider.ThrowIfNull(); + + /// + public async Task<(bool Exists, T? Value)> TryGetByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + var fr = await _fusionCache.TryGetAsync(KeyProvider.GetFullyQualifiedCacheKey(key), ConfigureEntryOptions(options), cancellationToken).ConfigureAwait(false); + return (fr.HasValue, fr.GetValueOrDefault()); + } + + /// + public async Task GetOrDefaultByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + => await _fusionCache.GetOrDefaultAsync(KeyProvider.GetFullyQualifiedCacheKey(key), default, ConfigureEntryOptions(options), cancellationToken).ConfigureAwait(false); + + /// + public async Task GetOrCreateByKeyAsync(string key, Func> factory, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + => await _fusionCache.GetOrSetAsync(KeyProvider.GetFullyQualifiedCacheKey(key), async ct => await factory(ct).ConfigureAwait(false), ConfigureEntryOptions(options), options?.Tags, cancellationToken).ConfigureAwait(false); + + /// + public async Task SetByKeyAsync(string key, T value, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + => await _fusionCache.SetAsync(KeyProvider.GetFullyQualifiedCacheKey(key), value, ConfigureEntryOptions(options), options?.Tags, cancellationToken).ConfigureAwait(false); + + /// + public async Task RemoveByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + => await _fusionCache.RemoveAsync(KeyProvider.GetFullyQualifiedCacheKey(key), ConfigureEntryOptions(options), cancellationToken).ConfigureAwait(false); + + /// + public async Task RemoveByTagAsync(string tag, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + => await _fusionCache.RemoveByTagAsync(tag, ConfigureEntryOptions(options), cancellationToken).ConfigureAwait(false); + + /// + public async Task RemoveByTagAsync(IEnumerable tags, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + => await _fusionCache.RemoveByTagAsync(tags, ConfigureEntryOptions(options), cancellationToken).ConfigureAwait(false); + + /// + /// Convert and configure. + /// + private FusionCacheEntryOptions ConfigureEntryOptions(HybridCacheEntryOptions? options) + { + var fco = (options ?? HybridCacheEntryOptions.CreateDefault()).ToFusionCacheEntryOptions(); + _configure?.Invoke(fco); + return fco; + } + + /// + /// Provides an opportunity to further the directly before use. + /// + /// The action to configure the . + /// The to support fluent-style method-chaining. + public FusionHybridCache ConfigureEntryOptions(Action? configure) + { + _configure = configure; + return this; + } +} \ No newline at end of file diff --git a/src/CoreEx.Caching.FusionCache/GlobalUsings.cs b/src/CoreEx.Caching.FusionCache/GlobalUsings.cs new file mode 100644 index 00000000..33974ccb --- /dev/null +++ b/src/CoreEx.Caching.FusionCache/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using CoreEx; +global using CoreEx.Caching; +global using ZiggyCreatures.Caching.Fusion; \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Batch/CosmosDbBatch.cs b/src/CoreEx.Cosmos/Batch/CosmosDbBatch.cs deleted file mode 100644 index 4c2d0dae..00000000 --- a/src/CoreEx.Cosmos/Batch/CosmosDbBatch.cs +++ /dev/null @@ -1,370 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Json.Data; -using Microsoft.Azure.Cosmos; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos.Batch -{ - /// - /// Provides CosmosDb-related batch extension methods. The implementation is bulk-ready as per ; to properly - /// enable use new CosmosClientOptions() { AllowBulkExecution = true } as described. - /// - public static class CosmosDbBatch - { - /// - /// Indicates whether the items in the batch are executed sequentially. - /// - /// true results in sequenital (order-based and slower) execution; otherwise, false results in parallel (no order guarantees and faster) execution. Also, see - /// to further improve throughput. - public static bool SequentialExecution { get; set; } - - /// - /// Imports (creates) a batch of . - /// - /// The cosmos model . - /// The . - /// The . - /// The batch of items to create. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// Each item is added individually and is not transactional. - public static async Task ImportBatchAsync(this ICosmosDb cosmosDb, string containerId, IEnumerable items, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where TModel : class, IEntityKey - { - var container = cosmosDb.ThrowIfNull(nameof(cosmosDb)).Database.GetContainer(containerId.ThrowIfNull(nameof(containerId))); - - if (items == null) - return; - - dbArgs ??= cosmosDb.DbArgs; - List tasks = []; - foreach (var item in items) - { - if (SequentialExecution) - await container.CreateItemAsync(modelUpdater?.Invoke(item) ?? item, dbArgs.Value.PartitionKey, dbArgs.Value.ItemRequestOptions, cancellationToken).ConfigureAwait(false); - else - tasks.Add(container.CreateItemAsync(modelUpdater?.Invoke(item) ?? item, dbArgs.Value.PartitionKey, dbArgs.Value.ItemRequestOptions, cancellationToken)); - } - - await Task.WhenAll(tasks).ConfigureAwait(false); - } - - /// - /// Imports (creates) a batch of . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The batch of items to create. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// Each item is added individually and is not transactional. - public static Task ImportBatchAsync(this CosmosDbContainer container, IEnumerable items, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => ImportBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.CosmosContainer.Id!, items, modelUpdater, dbArgs ?? container.DbArgs, cancellationToken); - - /// - /// Imports (creates) a batch of . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The batch of items to create. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// Each item is added individually and is not transactional. - public static Task ImportBatchAsync(this CosmosDbContainer container, IEnumerable items, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => ImportBatchAsync(container.ThrowIfNull(nameof(container)).Container, items, modelUpdater, dbArgs, cancellationToken); - - /// - /// Imports (creates) a batch of named items from the into the specified . - /// - /// The cosmos model . - /// The . - /// The . - /// The . - /// The element name where the array of items to deserialize are housed within the . Defaults to the name. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. - /// Each item is added individually and is not transactional. - public static async Task ImportBatchAsync(this ICosmosDb cosmosDb, string containerId, JsonDataReader jsonDataReader, string? name = null, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - { - var container = cosmosDb.ThrowIfNull(nameof(cosmosDb)).Database.GetContainer(containerId.ThrowIfNull(nameof(containerId))); - if (!jsonDataReader.ThrowIfNull(nameof(jsonDataReader)).TryDeserialize(name, out var items)) - return false; - - await ImportBatchAsync(cosmosDb, containerId, items, modelUpdater, dbArgs, cancellationToken).ConfigureAwait(false); - return true; - } - - /// - /// Imports (creates) a batch of named items from the into the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The element name where the array of items to deserialize are housed within the . Defaults to the name. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. - /// Each item is added individually and is not transactional. - public static Task ImportBatchAsync(this CosmosDbContainer container, JsonDataReader jsonDataReader, string? name = null, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => ImportBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.CosmosContainer.Id!, jsonDataReader, name, modelUpdater, dbArgs ?? container.DbArgs, cancellationToken); - - /// - /// Imports (creates) a batch of named items from the into the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The element name where the array of items to deserialize are housed within the . Defaults to the name. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. - /// Each item is added individually and is not transactional. - public static Task ImportBatchAsync(this CosmosDbContainer container, JsonDataReader jsonDataReader, string? name = null, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => ImportBatchAsync(container.ThrowIfNull(nameof(container)).Container, jsonDataReader, name, modelUpdater, dbArgs, cancellationToken); - - /// - /// Imports (creates) a batch of . - /// - /// The cosmos model . - /// The . - /// The . - /// The batch of items to create. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// Each item is added individually and is not transactional. - public static async Task ImportValueBatchAsync(this ICosmosDb cosmosDb, string containerId, IEnumerable items, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - { - var container = cosmosDb.ThrowIfNull(nameof(cosmosDb)).Database.GetContainer(containerId.ThrowIfNull(nameof(containerId))); - - if (items == null) - return; - - dbArgs ??= cosmosDb.DbArgs; - List tasks = []; - foreach (var item in items) - { - var cdv = new CosmosDbValue(item); - ((ICosmosDbValue)cdv).PrepareBefore(dbArgs.Value, null); - - if (SequentialExecution) - await container.CreateItemAsync(modelUpdater?.Invoke(cdv) ?? cdv, dbArgs.Value.PartitionKey, dbArgs.Value.ItemRequestOptions, cancellationToken).ConfigureAwait(false); - else - tasks.Add(container.CreateItemAsync(modelUpdater?.Invoke(cdv) ?? cdv, dbArgs.Value.PartitionKey, dbArgs.Value.ItemRequestOptions, cancellationToken)); - } - - await Task.WhenAll(tasks).ConfigureAwait(false); - } - - /// - /// Imports (creates) a batch of . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The batch of items to create. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// Each item is added individually and is not transactional. - public static Task ImportValueBatchAsync(this CosmosDbContainer container, IEnumerable items, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - CosmosDbValue func(CosmosDbValue cvm) - { - cvm.Type = container.Model.GetModelName(); - return modelUpdater?.Invoke(cvm) ?? cvm; - } - - return ImportValueBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.CosmosContainer.Id!, items, func, dbArgs ?? container.DbArgs, cancellationToken); - } - - /// - /// Imports (creates) a batch of . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The batch of items to create. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// Each item is added individually and is not transactional. - public static Task ImportValueBatchAsync(this CosmosDbValueContainer container, IEnumerable items, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => ImportValueBatchAsync(container.ThrowIfNull(nameof(container)).Container, items, modelUpdater, dbArgs, cancellationToken); - - /// - /// Imports (creates) a batch of named items from the into the specified . - /// - /// The cosmos model . - /// The . - /// The . - /// The . - /// The element name where the array of items to deserialize are housed within the . Defaults to the name. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. - /// Each item is added individually and is not transactional. - public static async Task ImportValueBatchAsync(this ICosmosDb cosmosDb, string containerId, JsonDataReader jsonDataReader, string? name = null, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - { - var container = cosmosDb.ThrowIfNull(nameof(cosmosDb)).Database.GetContainer(containerId.ThrowIfNull(nameof(containerId))); - if (!jsonDataReader.ThrowIfNull(nameof(jsonDataReader)).TryDeserialize(name, out var items)) - return false; - - await ImportValueBatchAsync(cosmosDb, containerId, items, modelUpdater, dbArgs, cancellationToken).ConfigureAwait(false); - return true; - } - - /// - /// Imports (creates) a batch of named items from the into the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The element name where the array of items to deserialize are housed within the . Defaults to the name. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. - /// Each item is added individually and is not transactional. - public static Task ImportValueBatchAsync(this CosmosDbContainer container, JsonDataReader jsonDataReader, string? name = null, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - CosmosDbValue func(CosmosDbValue cvm) - { - cvm.Type = container.Model.GetModelName(); - return modelUpdater?.Invoke(cvm) ?? cvm; - } - - return ImportValueBatchAsync(container.ThrowIfNull(nameof(container)).CosmosDb!, container.CosmosContainer.Id!, jsonDataReader, name ?? container.Model.GetModelName(), (Func, CosmosDbValue>)func, dbArgs ?? container.DbArgs, cancellationToken); - } - - /// - /// Imports (creates) a batch of named items from the into the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The element name where the array of items to deserialize are housed within the . Defaults to the name. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. - /// Each item is added individually and is not transactional. - public static Task ImportValueBatchAsync(this CosmosDbValueContainer container, JsonDataReader jsonDataReader, string? name = null, Func, CosmosDbValue>? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => ImportValueBatchAsync(container.ThrowIfNull(nameof(container)).Container, jsonDataReader, name, modelUpdater, dbArgs, cancellationToken); - - /// - /// Imports (creates) a batch of named items from the into the specified . - /// - /// The . - /// The . - /// The . - /// The list to find and import. - /// The function that enables the deserialized model value to be further updated. - /// The . - /// The . - /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. - /// Each item is added individually and is not transactional. - public static async Task ImportValueBatchAsync(this ICosmosDb cosmosDb, string containerId, JsonDataReader jsonDataReader, IEnumerable types, Func? modelUpdater = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) - { - var container = cosmosDb.ThrowIfNull(nameof(cosmosDb)).Database.GetContainer(containerId.ThrowIfNull(nameof(containerId))); - jsonDataReader.ThrowIfNull(nameof(jsonDataReader)); - - dbArgs ??= cosmosDb.DbArgs; - var tasks = new List(); - - foreach (var type in types) - { - if (jsonDataReader.TryDeserialize(type, type.Name, out var items)) - { - var t = typeof(CosmosDbValue<>).MakeGenericType(type); - - foreach (var item in items.Where(x => x is not null)) - { - var cdv = Activator.CreateInstance(t, item)!; - ((ICosmosDbValue)cdv).PrepareBefore(dbArgs.Value, null); - - if (SequentialExecution) - await container.CreateItemAsync(modelUpdater?.Invoke(cdv) ?? cdv, dbArgs.Value.PartitionKey, dbArgs.Value.ItemRequestOptions, cancellationToken).ConfigureAwait(false); - else - tasks.Add(container.CreateItemAsync(modelUpdater?.Invoke(cdv) ?? cdv, dbArgs.Value.PartitionKey, dbArgs.Value.ItemRequestOptions, cancellationToken)); - } - } - } - - await Task.WhenAll(tasks).ConfigureAwait(false); - return tasks.Count > 0; - } - - /// - /// Imports (creates) a batch of JSON items from the as-is into the specified . - /// - /// The . - /// The . - /// The element name where the array of items are housed within the . Defaults to the underlying . - /// The . - /// The . - /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. - /// Each item is added individually and is not transactional. - public static Task ImportJsonBatchAsync(this CosmosDbContainer container, JsonDataReader jsonDataReader, string? name = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) - => ImportJsonBatchAsync(container.CosmosDb, container.CosmosContainer.Id, jsonDataReader, name, dbArgs, cancellationToken); - - /// - /// Imports (creates) a batch of JSON items from the as-is into the specified . - /// - /// The . - /// The . - /// The . - /// The element name where the array of items are housed within the . Defaults to the . - /// The . - /// The . - /// true indicates that one or more items were deserialized and imported; otherwise, false for none found. - /// Each item is added individually and is not transactional. - public static async Task ImportJsonBatchAsync(this ICosmosDb cosmosDb, string containerId, JsonDataReader jsonDataReader, string? name = null, CosmosDbArgs? dbArgs = null, CancellationToken cancellationToken = default) - { - var container = cosmosDb.ThrowIfNull(nameof(cosmosDb)).Database.GetContainer(containerId.ThrowIfNull(nameof(containerId))); - jsonDataReader.ThrowIfNull(nameof(jsonDataReader)); - - dbArgs ??= cosmosDb.DbArgs; - var pk = dbArgs.Value.PartitionKey ?? PartitionKey.None; - - var tasks = new List(); - - var result = await jsonDataReader.EnumerateJsonAsync(name ?? containerId, async json => - { - using var ms = new MemoryStream(Encoding.UTF8.GetBytes(json.ToString())); - - if (SequentialExecution) - { - var resp = await container.CreateItemStreamAsync(ms, pk, dbArgs.Value.ItemRequestOptions, cancellationToken).ConfigureAwait(false); - resp.EnsureSuccessStatusCode(); - } - else - tasks.Add(container.CreateItemAsync(ms, pk, dbArgs.Value.ItemRequestOptions, cancellationToken)); - }); - - await Task.WhenAll(tasks).ConfigureAwait(false); - return result; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj b/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj deleted file mode 100644 index 9f92eaed..00000000 --- a/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx.Cosmos - CoreEx - CoreEx .NET Cosmos DB extras. - CoreEx .NET Cosmos DB extras. - coreex api data nosql documentdb cosmos cosmosdb - - - - - - - - - - - - - - - diff --git a/src/CoreEx.Cosmos/CosmosDb.cs b/src/CoreEx.Cosmos/CosmosDb.cs deleted file mode 100644 index f9214e3f..00000000 --- a/src/CoreEx.Cosmos/CosmosDb.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Collections.Concurrent; - -namespace CoreEx.Cosmos -{ - /// - /// Provides extended CosmosDb data access. - /// - /// The . - /// The ; defaults to . - /// Enables the to be overridden; defaults to . - /// It is recommended that the is registered as a scoped service to enable capabilities such as that must be scoped. - /// Use to - /// register the scoped instance. - /// The dependent should however be registered as a singleton as is best practice. - public class CosmosDb(Database database, IMapper? mapper = null, CosmosDbInvoker? invoker = null) : ICosmosDb - { - private static CosmosDbInvoker? _invoker; - private readonly ConcurrentDictionary _containers = new(); - - /// - public Database Database { get; } = database.ThrowIfNull(nameof(database)); - - /// - public IMapper Mapper { get; } = mapper ?? Mapping.Mapper.Empty; - - /// - public CosmosDbInvoker Invoker { get; } = invoker ?? (_invoker ??= new CosmosDbInvoker()); - - /// - public CosmosDbArgs DbArgs { get; set; } = new CosmosDbArgs(); - - /// - public Container GetCosmosContainer(string containerId) => Database.GetContainer(containerId); - - /// - /// Gets the named leveraging the method. - /// - /// The identifier. - /// The . - /// Provides indexing to the method; note that the configuration is expected to have been previously specified where required. - public CosmosDbContainer this[string containerId] => Container(containerId); - - /// - public CosmosDbContainer Container(string containerId) => _containers.GetOrAdd(containerId.ThrowIfNullOrEmpty(nameof(containerId)), containerId => new CosmosDbContainer(this, containerId)); - - /// - public CosmosDbContainer Container(string containerId) where T : class, IEntityKey, new () where TModel : class, IEntityKey, new () - => Container(containerId).AsTyped(); - - /// - public CosmosDbValueContainer ValueContainer(string containerId) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => Container(containerId).AsValueTyped(); - - /// - public Result? HandleCosmosException(CosmosException cex) => OnCosmosException(cex); - - /// - /// Provides the handling as a result of . - /// - /// The . - /// The containing the appropriate . - /// Where overridding and the is not specifically handled then invoke the base to ensure any standard handling is executed. - protected virtual Result? OnCosmosException(CosmosException cex) => cex.ThrowIfNull(nameof(cex)).StatusCode switch - { - System.Net.HttpStatusCode.NotFound => Result.Fail(new NotFoundException(null, cex)), - System.Net.HttpStatusCode.Conflict => Result.Fail(new DuplicateException(null, cex)), - System.Net.HttpStatusCode.PreconditionFailed => Result.Fail(new ConcurrencyException(null, cex)), - _ => Result.Fail(cex) - }; - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbArgs.cs b/src/CoreEx.Cosmos/CosmosDbArgs.cs deleted file mode 100644 index 322f188c..00000000 --- a/src/CoreEx.Cosmos/CosmosDbArgs.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using Microsoft.Azure.Cosmos; -using Microsoft.Azure.Cosmos.Linq; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Net; - -namespace CoreEx.Cosmos -{ - /// - /// Provides the CosmosDb Container arguments. - /// - public struct CosmosDbArgs - { - /// - /// Initializes a new instance of the struct. - /// - public CosmosDbArgs() { } - - /// - /// Initializes a new instance of the struct. - /// - /// The template to copy from. - /// The override . - public CosmosDbArgs(CosmosDbArgs template, PartitionKey? partitionKey = null) - { - PartitionKey = partitionKey ?? template.PartitionKey; - ItemRequestOptions = template.ItemRequestOptions; - QueryRequestOptions = template.QueryRequestOptions; - NullOnNotFound = template.NullOnNotFound; - AutoMapETag = template.AutoMapETag; - CleanUpResult = template.CleanUpResult; - FilterByTenantId = template.FilterByTenantId; - FilterByIsDeleted = template.FilterByIsDeleted; - GetTenantId = template.GetTenantId; - FormatIdentifier = template.FormatIdentifier; - } - - /// - /// Initializes a new instance of the struct. - /// - /// The . - public CosmosDbArgs(PartitionKey partitionKey) => PartitionKey = partitionKey; - - /// - /// Initializes a new instance of the struct for Get, Create, Update, and Delete operations with the specified . - /// - /// The . - /// The . - public CosmosDbArgs(ItemRequestOptions requestOptions, PartitionKey? partitionKey = null) - { - ItemRequestOptions = requestOptions; - PartitionKey = partitionKey; - } - - /// - /// Initializes a new instance of the struct for Query operations with the specified . - /// - /// The . - /// The . - public CosmosDbArgs(QueryRequestOptions requestOptions, PartitionKey? partitionKey = null) - { - QueryRequestOptions = requestOptions; - PartitionKey = partitionKey; - } - - /// - /// Gets the . - /// - public PartitionKey? PartitionKey { get; } = null; - - /// - /// Gets the used for Get, Create, Update, and Delete (). - /// - public ItemRequestOptions? ItemRequestOptions { get; } = null; - - /// - /// Gets (or creates new) the . - /// - public readonly ItemRequestOptions GetItemRequestOptions() => ItemRequestOptions ?? new ItemRequestOptions(); - - /// - /// Gets the used for Query (). - /// - public QueryRequestOptions? QueryRequestOptions { get; } = null; - - /// - /// Gets (or creates new) the . - /// - public readonly QueryRequestOptions GetQueryRequestOptions() => UpdateQueryRequestionOptionsPartitionKey(QueryRequestOptions ?? new QueryRequestOptions()); - - /// - /// Updates the with the where not already set. - /// - private readonly QueryRequestOptions UpdateQueryRequestionOptionsPartitionKey(QueryRequestOptions qro) - { - qro.PartitionKey ??= PartitionKey; - return qro; - } - - /// - /// Indicates whether a null is to be returned where the response has a of on Get. Defaults to true. - /// - public bool NullOnNotFound { get; set; } = true; - - /// - /// Indicates whether when mapping the model to the corresponding entity that the is to be automatically mapped. Defaults to true. - /// - public bool AutoMapETag { get; set; } = true; - - /// - /// Indicates whether the result should be cleaned up. Defaults to false. - /// - public bool CleanUpResult { get; set; } = false; - - /// - /// Indicates that when the underlying model implements it is to be filtered by the corresponding value. Defaults to true. - /// - public bool FilterByTenantId { get; set; } - - /// - /// Indicates that when the underlying model implements it should filter out any models where the equals true. Defaults to true. - /// - public bool FilterByIsDeleted { get; set; } = true; - - /// - /// Gets or sets the get tenant identifier function; defaults to . - /// - public Func GetTenantId { get; set; } = () => ExecutionContext.HasCurrent ? ExecutionContext.Current.TenantId : null; - - /// - /// Formats a to a representation (used by and ). - /// - /// The identifier as a . - /// Defaults to . - public Func FormatIdentifier { get; set; } = DefaultFormatIdentifier; - - /// - /// Provides the default implementation; being the . - /// - public static Func DefaultFormatIdentifier { get; } = key => key.ToString(); - - /// - /// Determines whether the model is considered valid; i.e. is not null, and where applicable, checks the and properties. - /// - /// The model . - /// The model value. - /// Indicates whether to perform the check. - /// Indicates whether to perform the check. - /// true indicates that the model is valid; otherwise, false. - /// This is used by the underlying operations to ensure the model is considered valid or not, and then handled accordingly. The query-based operations leverage the corresponding filter. - /// This leverages the to perform the check to ensure consistency of implementation. - public readonly bool IsModelValid([NotNullWhen(true)] TModel? model, bool checkIsDeleted = true, bool checkTenantId = true) where TModel : class - => model != default && WhereModelValid(new[] { model }.AsQueryable(), checkIsDeleted, checkTenantId).Any(); - - /// - /// Filters the to include only valid models; i.e. checks the and properties. - /// - /// The model . - /// The current query. - /// Indicates whether to perform the check. - /// Indicates whether to perform the check. - /// The updated query. - /// This is used by the underlying , , and to apply standardized filtering. - public readonly IQueryable WhereModelValid(IQueryable query, bool checkIsDeleted = true, bool checkTenantId = true) where TModel : class - { - query = query.ThrowIfNull(nameof(query)); - - if (checkTenantId && FilterByTenantId && typeof(ITenantId).IsAssignableFrom(typeof(TModel))) - { - var tenantId = GetTenantId(); - query = query.Where(x => ((ITenantId)x).TenantId == tenantId); - } - - if (checkIsDeleted && FilterByIsDeleted && typeof(ILogicallyDeleted).IsAssignableFrom(typeof(TModel))) - query = query.Where(x => !((ILogicallyDeleted)x).IsDeleted.IsDefined() || ((ILogicallyDeleted)x).IsDeleted == null || ((ILogicallyDeleted)x).IsDeleted == false); - - return query; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbContainer.cs b/src/CoreEx.Cosmos/CosmosDbContainer.cs deleted file mode 100644 index 281eeed9..00000000 --- a/src/CoreEx.Cosmos/CosmosDbContainer.cs +++ /dev/null @@ -1,965 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Cosmos.Model; -using CoreEx.Entities; -using CoreEx.Json; -using CoreEx.Mapping; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Stj = System.Text.Json; - -namespace CoreEx.Cosmos -{ - /// - /// Provides capabilities. - /// - /// The property () provides the underlying capabilities for direct model-based access. - public partial class CosmosDbContainer - { - private readonly Lazy _model; - private Func? _dbArgsFactory; - private readonly ConcurrentDictionary<(Type, Type), object> _containers = new(); - private readonly ConcurrentDictionary<(Type, Type), object> _valueContainers = new(); - - /// - /// Initializes a new instance of the . - /// - /// The . - /// The identifier. - public CosmosDbContainer(ICosmosDb cosmosDb, string containerId) - { - CosmosDb = cosmosDb.ThrowIfNull(nameof(cosmosDb)); - CosmosContainer = cosmosDb.GetCosmosContainer(containerId.ThrowIfNullOrEmpty(nameof(containerId))); - _model = new(() => new(this)); - } - - /// - /// Gets the owning . - /// - public ICosmosDb CosmosDb { get; } - - /// - /// Gets the . - /// - public Container CosmosContainer { get; } - - /// - /// Gets the Container-specific . - /// - /// Defaults to ; otherwise, . - public CosmosDbArgs DbArgs => _dbArgsFactory?.Invoke() ?? new CosmosDbArgs(CosmosDb.DbArgs); - - /// - /// Sets the container-specific . - /// - /// The creation factory. - /// This can only be set once; otherwise, a will be thrown. - public CosmosDbContainer UseDbArgs(Func dbArgsFactory) - { - dbArgsFactory.ThrowIfNull(nameof(dbArgsFactory)); - if (_dbArgsFactory is not null) - throw new InvalidOperationException($"The {nameof(UseDbArgs)} can only be specified once."); - - _dbArgsFactory = dbArgsFactory; - return this; - } - - /// - /// Gets the that encapsulates the direct-to-model operations. - /// - public CosmosDbModelContainer Model => _model.Value; - - /// - /// Gets or sets the SQL statement format for the MultiSet operation. - /// - /// The SQL statement format must have the {0}> place holder for the list of types represented as comma-separated strings; e.g. "Customer", "Address". - public string MultiSetSqlStatementFormat { get; private set; } = "SELECT * FROM c WHERE c.type in ({0})"; - - /// - /// Sets the for the MultiSet operations. - /// - /// The SQL statement format. - public CosmosDbContainer UseMultiSetSqlStatement(string format) - { - if (!format.ThrowIfNullOrEmpty(nameof(format)).Contains("{0}")) - throw new ArgumentException("The format must contain '{0}' to insert the 'in' list (contents).", nameof(format)); - - return this; - } - - /// - /// Gets the CosmosDb identifier from the . - /// - /// The . - /// The CosmosDb identifier. - /// Uses the to format the as a string (as required). - public virtual string GetCosmosId(CompositeKey key) => DbArgs.FormatIdentifier(key) ?? throw new InvalidOperationException("The CompositeKey formatting into an identifier must not result in a null."); - - /// - /// Gets the CosmosDb identifier from the . - /// - /// The model value. - /// The CosmosDb identifier. - public string GetCosmosId(TModel model) where TModel : class, IEntityKey => GetCosmosId(model.ThrowIfNull(nameof(model)).EntityKey); - - /// - /// Gets the . - /// - public PartitionKey GetPartitionKey(PartitionKey? partitionKey) => partitionKey ?? DbArgs.PartitionKey ?? PartitionKey.None; - - /// - /// Sets the function to get the from the model used by the (used by only by the Create and Update operations). - /// - /// The cosmos model . - /// The function to get the from the model. - /// This can only be set once; otherwise, a will be thrown. - public CosmosDbContainer UsePartitionKey(Func? getPartitionKey) where TModel : class, IEntityKey, new() - { - Model.UsePartitionKey(getPartitionKey); - return this; - } - - /// - /// Sets the function to get the from the used by the (used by only by the Create and Update operations). - /// - /// The cosmos model . - /// The function to get the from the model. - /// This can only be set once; otherwise, a will be thrown. - public CosmosDbContainer UseValuePartitionKey(Func, PartitionKey>? getPartitionKey) where TModel : class, IEntityKey, new() - { - Model.UsePartitionKey(getPartitionKey); - return this; - } - - /// - /// Sets (overrides) the name for the model . - /// - /// The cosmos model . - /// The model name. - public CosmosDbContainer UseModelName(string name) where TModel : class, IEntityKey, new() - { - Model.UseModelName(name); - return this; - } - - /// - /// Sets the filter for all operations performed on the to ensure authorisation is applied. Applies automatically to all queries, plus create, update, delete and get operations. - /// - /// The cosmos model . - /// The authorization filter query. - public CosmosDbContainer UseAuthorizeFilter(Func, IQueryable>? filter) where TModel : class, IEntityKey, new() - { - Model.UseAuthorizeFilter(filter); - return this; - } - - /// - /// Sets the filter for all operations performed on the to ensure authorisation is applied. Applies automatically to all queries, plus create, update, delete and get operations. - /// - /// The cosmos model . - /// The authorization filter query. - public CosmosDbContainer UseValueAuthorizeFilter(Func>, IQueryable>>? filter) where TModel : class, IEntityKey, new() - { - Model.UseAuthorizeFilter(filter); - return this; - } - - /// - /// Maps to the entity value formatting/updating any special properties as required. - /// - /// The entity . - /// The cosmos model . - /// The model value. - /// The . - /// The entity value. - [return: NotNullIfNotNull(nameof(model))] - public T? MapToValue(TModel? model, CosmosDbArgs dbArgs) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - if (model is null) - return default; - - // Map the model to the entity value. - var val = CosmosDb.Mapper.Map(model, OperationTypes.Get)!; - if (dbArgs.AutoMapETag && val is IETag et && et.ETag != null) - et.ETag = ETagGenerator.ParseETag(et.ETag); - - return dbArgs.CleanUpResult ? Cleaner.Clean(val) : val; - } - - /// - /// Maps to the entity value formatting/updating any special properties as required. - /// - /// The entity . - /// The cosmos model . - /// The value. - /// The . - /// The entity value. - [return: NotNullIfNotNull(nameof(model))] - public T? MapToValue(CosmosDbValue? model, CosmosDbArgs dbArgs) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - if (model is null) - return default; - - ((ICosmosDbValue)model).PrepareAfter(dbArgs); - var val = CosmosDb.Mapper.Map(model.Value, OperationTypes.Get)!; - if (dbArgs.AutoMapETag && val is IETag et) - { - if (et.ETag is not null) - et.ETag = ETagGenerator.ParseETag(et.ETag); - else - et.ETag = ETagGenerator.ParseETag(model.ETag); - } - - return DbArgs.CleanUpResult ? Cleaner.Clean(val) : val; - } - - /// - /// Gets (or adds) the typed for the specified and . - /// - /// The entity . - /// The cosmos model . - /// An optional action to perform one-off configuration on initial access. - /// The typed - public CosmosDbContainer AsTyped(Action>? configure = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (CosmosDbContainer)_containers.GetOrAdd((typeof(T), typeof(TModel)), _ => - { - var c = new CosmosDbContainer(this); - configure?.Invoke(c); - return c; - }); - - /// - /// Gets (or adds) the typed for the specified and . - /// - /// The entity . - /// The cosmos model . - /// An optional action to perform one-off configuration on initial access. - /// The typed - public CosmosDbValueContainer AsValueTyped(Action>? configure = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (CosmosDbValueContainer)_valueContainers.GetOrAdd((typeof(T), typeof(TModel)), _ => - { - var c = new CosmosDbValueContainer(this); - configure?.Invoke(c); - return c; - }); - - #region Query - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The entity . - /// The cosmos model . - /// The function to perform additional query execution. - /// The . - public CosmosDbQuery Query(Func, IQueryable>? query) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => Query(new CosmosDbArgs(DbArgs), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbQuery Query(PartitionKey? partitionKey = null, Func, IQueryable>? query = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => Query(new CosmosDbArgs(DbArgs, partitionKey), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The entity . - /// The cosmos model . - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbQuery Query(CosmosDbArgs dbArgs, Func, IQueryable>? query = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => new(this, dbArgs, query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The entity . - /// The cosmos model . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueQuery ValueQuery(Func>, IQueryable>>? query) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => ValueQuery(new CosmosDbArgs(DbArgs), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The entity . - /// The cosmos model . - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueQuery ValueQuery(PartitionKey? partitionKey = null, Func>, IQueryable>>? query = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => ValueQuery(new CosmosDbArgs(DbArgs, partitionKey), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The entity . - /// The cosmos model . - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueQuery ValueQuery(CosmosDbArgs dbArgs, Func>, IQueryable>>? query = null) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => new(this, dbArgs, query); - - #endregion - - #region Get - - /// - /// Gets the entity for the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public async Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await GetWithResultAsync(key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the entity for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => GetWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); - - /// - /// Gets the entity for the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - /// The entity value where found; otherwise, null (see ). - public async Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await GetWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the entity for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => GetWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); - - /// - /// Gets the entity for the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public async Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the entity for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public async Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - var result = await Model.GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false); - return result.ThenAs(m => MapToValue(m, dbArgs)); - } - - /// - /// Gets the entity (using underlying ) for the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public async Task GetValueAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await GetValueWithResultAsync(key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the entity (using underlying ) for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetValueWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => GetValueWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); - - /// - /// Gets the entity (using underlying ) for the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - /// The entity value where found; otherwise, null (see ). - public async Task GetValueAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await GetValueWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the entity (using underlying ) for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetValueWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => GetValueWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); - - /// - /// Gets the entity (using underlying ) for the specified .. - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public async Task GetValueAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await GetValueWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the entity (using underlying ) for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public async Task> GetValueWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - var result = await Model.GetValueWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false); - return result.ThenAs(m => MapToValue(m, dbArgs)); - } - - #endregion - - #region Create - - /// - /// Creates the entity. - /// - /// The entity . - /// The cosmos model . - /// The value to create. - /// The . - /// The created value. - public async Task CreateAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await CreateWithResultAsync(value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Creates the entity with a . - /// - /// The entity . - /// The cosmos model . - /// The value to create. - /// The . - /// The created value. - public Task> CreateWithResultAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => CreateWithResultAsync(new CosmosDbArgs(DbArgs), value, cancellationToken); - - /// - /// Creates the entity. - /// - /// The entity . - /// The cosmos model . - /// The . - /// The value to create. - /// The . - /// The created value. - public async Task CreateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await CreateWithResultAsync(dbArgs, value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Creates the entity with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The value to create. - /// The . - /// The created value. - public async Task> CreateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - TModel model = CosmosDb.Mapper.Map(Cleaner.PrepareCreate(value.ThrowIfNull(nameof(value))), OperationTypes.Create)!; - - var result = await Model.CreateWithResultAsync(dbArgs, Cleaner.PrepareCreate(model), cancellationToken).ConfigureAwait(false); - return result.ThenAs(model => MapToValue(model, dbArgs)!); - } - - /// - /// Creates the entity (using underlying ). - /// - /// The entity . - /// The cosmos model . - /// The value to create. - /// The . - /// The created value. - public async Task CreateValueAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await CreateValueWithResultAsync(value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Creates the entity (using underlying ) with a . - /// - /// The entity . - /// The cosmos model . - /// The value to create. - /// The . - /// The created value. - public Task> CreateValueWithResultAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => CreateValueWithResultAsync(new CosmosDbArgs(DbArgs), value, cancellationToken); - - /// - /// Creates the entity (using underlying ). - /// - /// The entity . - /// The cosmos model . - /// The . - /// The value to create. - /// The . - /// The created value. - public async Task CreateValueAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await CreateValueWithResultAsync(dbArgs, value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Creates the entity (using underlying ) with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The value to create. - /// The . - /// The created value. - public async Task> CreateValueWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - TModel model = CosmosDb.Mapper.Map(Cleaner.PrepareCreate(value.ThrowIfNull(nameof(value))), OperationTypes.Create); - var cdv = new CosmosDbValue(Model.GetModelName(), Cleaner.PrepareCreate(model)!); - - var result = await Model.CreateValueWithResultAsync(dbArgs, cdv, cancellationToken).ConfigureAwait(false); - return result.ThenAs(model => MapToValue(model, dbArgs)!); - } - - #endregion - - #region Update - - /// - /// Updates the entity. - /// - /// The entity . - /// The cosmos model . - /// The value to update. - /// The . - /// The updated value. - public async Task UpdateAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await UpdateWithResultAsync(value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Updates the entity with a . - /// - /// The entity . - /// The cosmos model . - /// The value to update. - /// The . - /// The updated value. - public Task> UpdateWithResultAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => UpdateWithResultAsync(new CosmosDbArgs(DbArgs), value, cancellationToken); - - /// - /// Updates the entity. - /// - /// The entity . - /// The cosmos model . - /// The . - /// The value to update. - /// The . - /// The updated value. - public async Task UpdateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await UpdateWithResultAsync(dbArgs, value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Updates the entity with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The value to update. - /// The . - /// The updated value. - public async Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - var model = CosmosDb.Mapper.Map(Cleaner.PrepareUpdate(value.ThrowIfNull(nameof(value))), OperationTypes.Update); - var result = await Model.UpdateWithResultInternalAsync(dbArgs, Cleaner.PrepareUpdate(model), m => CosmosDb.Mapper.Map(value, m, OperationTypes.Update), cancellationToken).ConfigureAwait(false); - return result.ThenAs(model => MapToValue(model, dbArgs)!); - } - - /// - /// Updates the entity (using underlying ). - /// - /// The entity . - /// The cosmos model . - /// The value to update. - /// The . - /// The updated value. - public async Task UpdateValueAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await UpdateValueWithResultAsync(value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Updates the entity (using underlying ) with a . - /// - /// The entity . - /// The cosmos model . - /// The value to update. - /// The . - /// The updated value. - public Task> UpdateValueWithResultAsync(T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => UpdateValueWithResultAsync(new CosmosDbArgs(DbArgs), value, cancellationToken); - - /// - /// Updates the entity (using underlying ). - /// - /// The entity . - /// The cosmos model . - /// The . - /// The value to update. - /// The . - /// The updated value. - public async Task UpdateValueAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await UpdateValueWithResultAsync(dbArgs, value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Updates the entity (using underlying ) with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The value to update. - /// The . - /// The updated value. - public async Task> UpdateValueWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - var model = CosmosDb.Mapper.Map(Cleaner.PrepareUpdate(value.ThrowIfNull(nameof(value))), OperationTypes.Update)!; - var cdv = new CosmosDbValue(Model.GetModelName(), Cleaner.PrepareUpdate(model!)); - - var result = await Model.UpdateValueWithResultInternalAsync(dbArgs, cdv, cdv => CosmosDb.Mapper.Map(value, cdv.Value, OperationTypes.Update), cancellationToken).ConfigureAwait(false); - return result.ThenAs(model => MapToValue(model, dbArgs)!); - } - - #endregion - - #region Delete - - /// - /// Deletes the entity for the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - public async Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await DeleteWithResultAsync(key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the entity for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => DeleteWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); - - /// - /// Deletes the entity for the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - public async Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await DeleteWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the entity for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => DeleteWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); - - /// - /// Deletes the entity for the specified . - /// - /// The entity . - /// The cosmos model . - /// The .. - /// The . - /// The . - public async Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await DeleteWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the entity for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The .. - /// The . - /// The . - public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => Model.DeleteWithResultAsync(dbArgs, key, cancellationToken); - - /// - /// Deletes the entity (using underlying ) for the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - public async Task DeleteValueAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await DeleteValueWithResultAsync(key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the entity (using underlying ) for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . - public Task DeleteValueWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => DeleteValueWithResultAsync(new CosmosDbArgs(DbArgs), key, cancellationToken); - - /// - /// Deletes the entity (using underlying ) for the specified . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - public async Task DeleteValueAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await DeleteValueWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the entity (using underlying ) for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - public Task DeleteValueWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => DeleteValueWithResultAsync(new CosmosDbArgs(DbArgs, partitionKey), key, cancellationToken); - - /// - /// Deletes the entity (using underlying ) for the specified . - /// - /// The entity . - /// The cosmos model . - /// The .. - /// The . - /// The . - public async Task DeleteValueAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => (await DeleteValueWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the entity (using underlying ) for the specified with a . - /// - /// The entity . - /// The cosmos model . - /// The .. - /// The . - /// The . - public Task DeleteValueWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - => Model.DeleteValueWithResultAsync(dbArgs, key, cancellationToken); - - #endregion - - #region MultiSet - - /// - /// Executes a multi-dataset query command with one or more . - /// - /// The . - /// One or more . - /// See for further details. - public Task SelectValueMultiSetAsync(PartitionKey partitionKey, params IMultiSetValueArgs[] multiSetArgs) => SelectValueMultiSetAsync(partitionKey, multiSetArgs, default); - - /// - /// Executes a multi-dataset query command with one or more . - /// - /// The . - /// One or more . - /// The . - /// See for further details. - public Task SelectValueMultiSetAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectValueMultiSetAsync(partitionKey, null, multiSetArgs, cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more . - /// - /// The . - /// The override SQL statement; will default where not specified. - /// One or more . - /// The . - /// See for further details. - public async Task SelectValueMultiSetAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - => (await SelectValueMultiSetWithResultAsync(partitionKey, sql, multiSetArgs, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// See for further details. - public Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, params IMultiSetValueArgs[] multiSetArgs) => SelectValueMultiSetWithResultAsync(partitionKey, multiSetArgs, default); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// The . - /// See for further details. - public Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectValueMultiSetWithResultAsync(partitionKey, null, multiSetArgs, cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// The override SQL statement; will default to where not specified. - /// One or more . - /// The . - /// The must be of type . Each is verified and executed in the order specified. - /// The underlying SQL will be automatically created from the specified where not explicitly supplied. Essentially, it is a simple query where all types inferred from the - /// are included, for example: SELECT * FROM c WHERE c.type in ("TypeNameA", "TypeNameB") - /// - public async Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - { - // Verify that the multi set arguments are valid for this type of get query. - var multiSetList = multiSetArgs?.ToArray() ?? null; - if (multiSetList == null || multiSetList.Length == 0) - throw new ArgumentException($"At least one {nameof(IMultiSetValueArgs)} must be supplied.", nameof(multiSetArgs)); - - // Build the Cosmos SQL statement. - var name = multiSetList[0].GetModelName(this); - var types = new Dictionary([new KeyValuePair(name, multiSetList[0])]); - var sb = string.IsNullOrEmpty(sql) ? new StringBuilder($"\"{name}\"") : null; - - if (sb is not null) - { - for (int i = 1; i < multiSetList.Length; i++) - { - name = multiSetList[i].GetModelName(this); - if (!types.TryAdd(name, multiSetList[i])) - throw new ArgumentException($"All {nameof(IMultiSetValueArgs)} must be of different model type.", nameof(multiSetArgs)); - - sb.Append($", \"{name}\""); - } - - sql = string.Format(MultiSetSqlStatementFormat, sb.ToString()); - } - - // Execute the Cosmos DB query. - var result = await CosmosDb.Invoker.InvokeAsync(CosmosDb, this, sql, types, async (_, container, sql, types, ct) => - { - // Set up for work. - var da = new CosmosDbArgs(container.DbArgs, partitionKey); - var qsi = container.CosmosContainer.GetItemQueryStreamIterator(sql, requestOptions: da.GetQueryRequestOptions()); - IJsonSerializer js = ExecutionContext.GetService() ?? CoreEx.Json.JsonSerializer.Default; - var isStj = js is Text.Json.JsonSerializer; - - while (qsi.HasMoreResults) - { - var rm = await qsi.ReadNextAsync(ct).ConfigureAwait(false); - if (!rm.IsSuccessStatusCode) - return Result.Fail(new InvalidOperationException(rm.ErrorMessage)); - - var json = Stj.JsonDocument.Parse(rm.Content); - if (!json.RootElement.TryGetProperty("Documents", out var jds) || jds.ValueKind != Stj.JsonValueKind.Array) - return Result.Fail(new InvalidOperationException("Cosmos response JSON 'Documents' property either not found in result or is not an array.")); - - foreach (var jd in jds.EnumerateArray()) - { - if (!jd.TryGetProperty("type", out var jt) || jt.ValueKind != Stj.JsonValueKind.String) - return Result.Fail(new InvalidOperationException("Cosmos response documents item 'type' property either not found in result or is not a string.")); - - if (!types.TryGetValue(jt.GetString()!, out var msa)) - continue; // Ignore any unexpected type. - - var model = isStj - ? jd.Deserialize(msa.Type, (Stj.JsonSerializerOptions)js.Options) - : js.Deserialize(jd.ToString(), msa.Type); - - if (model is null) - return Result.Fail(new InvalidOperationException($"Cosmos response documents item type '{jt.GetRawText()}' deserialization resulted in a null.")); - - var result = msa.AddItem(container, da, model); - if (result.IsFailure) - return result; - } - } - - return Result.Success; - }, cancellationToken).ConfigureAwait(false); - - if (result.IsFailure) - return result; - - // Validate the multi-set args and action each accordingly. - foreach (var msa in multiSetList) - { - var r = msa.Verify(); - if (r.IsFailure) - return r.AsResult(); - - if (!r.Value && msa.StopOnNull) - break; - - msa.Invoke(); - } - - return Result.Success; - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbContainerT.cs b/src/CoreEx.Cosmos/CosmosDbContainerT.cs deleted file mode 100644 index c4c6497c..00000000 --- a/src/CoreEx.Cosmos/CosmosDbContainerT.cs +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Cosmos.Model; -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos -{ - /// - /// Provides a typed interface for the primary operations. - /// - /// The entity . - /// The cosmos model . - public sealed class CosmosDbContainer where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - private CosmosDbModelContainer? _model; - - /// - /// Initializes a new instance of the class. - /// - /// The owning . - internal CosmosDbContainer(CosmosDbContainer owner) - { - Container = owner.ThrowIfNull(nameof(owner)); - CosmosContainer = Container.CosmosContainer; - } - - /// - /// Gets the owning . - /// - public CosmosDbContainer Container { get; } - - /// - /// Gets the . - /// - public Container CosmosContainer { get; } - - /// - /// Gets the typed . - /// - public CosmosDbModelContainer Model => _model ??= new(Container); - - #region Query - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The function to perform additional query execution. - /// The . - public CosmosDbQuery Query(Func, IQueryable>? query) => Container.Query(query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbQuery Query(PartitionKey? partitionKey = null, Func, IQueryable>? query = null) => Container.Query(partitionKey, query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbQuery Query(CosmosDbArgs dbArgs, Func, IQueryable>? query = null) => Container.Query(dbArgs, query); - - #endregion - - #region Get - - /// - /// Gets the entity for the specified . - /// - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.GetAsync(key, cancellationToken); - - /// - /// Gets the entity for the specified with a . - /// - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.GetWithResultAsync(key, cancellationToken); - - /// - /// Gets the entity for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.GetAsync(key, partitionKey, cancellationToken); - - /// - /// Gets the entity for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.GetWithResultAsync(key, partitionKey, cancellationToken); - - /// - /// Gets the entity for the specified . - /// - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.GetAsync(dbArgs, key, cancellationToken); - - /// - /// Gets the entity for the specified with a . - /// - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.GetWithResultAsync(dbArgs, key, cancellationToken); - - #endregion - - #region Create - - /// - /// Creates the entity. - /// - /// The value to create. - /// The . - /// The created value. - public Task CreateAsync(T value, CancellationToken cancellationToken = default) => Container.CreateAsync(value, cancellationToken); - - /// - /// Creates the entity with a . - /// - /// The value to create. - /// The . - /// The created value. - public Task> CreateWithResultAsync(T value, CancellationToken cancellationToken = default) => Container.CreateWithResultAsync(value, cancellationToken); - - /// - /// Creates the entity. - /// - /// The . - /// The value to create. - /// The . - /// The created value. - public Task CreateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.CreateAsync(dbArgs, value, cancellationToken); - - /// - /// Creates the entity with a . - /// - /// The . - /// The value to create. - /// The . - /// The created value. - public Task> CreateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.CreateWithResultAsync(dbArgs, value, cancellationToken); - - #endregion - - #region Update - - /// - /// Updates the entity. - /// - /// The value to update. - /// The . - /// The updated value. - public Task UpdateAsync(T value, CancellationToken cancellationToken = default) => Container.UpdateAsync(value, cancellationToken); - - /// - /// Updates the entity with a . - /// - /// The value to update. - /// The . - /// The updated value. - public Task> UpdateWithResultAsync(T value, CancellationToken cancellationToken = default) => Container.UpdateWithResultAsync(value, cancellationToken); - - /// - /// Updates the entity. - /// - /// The . - /// The value to update. - /// The . - /// The updated value. - public Task UpdateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.UpdateAsync(dbArgs, value, cancellationToken); - - /// - /// Updates the entity with a . - /// - /// The . - /// The value to update. - /// The . - /// The updated value. - public Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.UpdateWithResultAsync(dbArgs, value, cancellationToken); - - #endregion - - #region Delete - - /// - /// Deletes the entity for the specified . - /// - /// The . - /// The . - public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteAsync(key, cancellationToken); - - /// - /// Deletes the entity for the specified with a . - /// - /// The . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteWithResultAsync(key, cancellationToken); - - /// - /// Deletes the entity for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - public Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.DeleteAsync(key, partitionKey, cancellationToken); - - /// - /// Deletes the entity for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.DeleteWithResultAsync(key, partitionKey, cancellationToken); - - /// - /// Deletes the entity for the specified . - /// - /// The .. - /// The . - /// The . - public Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteAsync(dbArgs, key, cancellationToken); - - /// - /// Deletes the entity for the specified with a . - /// - /// The .. - /// The . - /// The . - public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteWithResultAsync(dbArgs, key, cancellationToken); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbInvoker.cs b/src/CoreEx.Cosmos/CosmosDbInvoker.cs deleted file mode 100644 index 35f93519..00000000 --- a/src/CoreEx.Cosmos/CosmosDbInvoker.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos -{ - /// - /// Provides the standard invoker functionality. - /// - /// Catches any unhandled and invokes to handle before bubbling up. - public class CosmosDbInvoker : InvokerBase - { - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, ICosmosDb cosmos, Func func) => throw new NotSupportedException(); - - /// - protected async override Task OnInvokeAsync(InvokeArgs invokeArgs, ICosmosDb cosmos, Func> func, CancellationToken cancellationToken) - { - try - { - return await base.OnInvokeAsync(invokeArgs, cosmos, func, cancellationToken).ConfigureAwait(false); - } - catch (CosmosException cex) - { - var eresult = cosmos.HandleCosmosException(cex); - if (eresult.HasValue && eresult.Value.IsFailure && eresult.Value.Error is CoreEx.Abstractions.IExtendedException) - { - var dresult = default(TResult); - if (dresult is IResult dir) - return (TResult)dir.ToFailure(eresult.Value.Error); - else - eresult.Value.ThrowOnError(); - } - - throw; - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbQuery.cs b/src/CoreEx.Cosmos/CosmosDbQuery.cs deleted file mode 100644 index 709260f0..00000000 --- a/src/CoreEx.Cosmos/CosmosDbQuery.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.Azure.Cosmos.Linq; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos -{ - /// - /// Encapsulates a CosmosDb query enabling all select-like capabilities. - /// - /// The resultant . - /// The cosmos model . - /// The . - /// The . - /// A function to modify the underlying . - public class CosmosDbQuery(CosmosDbContainer container, CosmosDbArgs dbArgs, Func, IQueryable>? query) : CosmosDbQueryBase>(container, dbArgs) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - private readonly Func, IQueryable>? _query = query; - - /// - /// Instantiates the . - /// - private IQueryable AsQueryable(bool allowSynchronousQueryExecution, bool pagingSupported) - { - if (!pagingSupported && Paging is not null) - throw new NotSupportedException("Paging is not supported when accessing AsQueryable directly; paging must be applied directly to the resulting IQueryable instance."); - - IQueryable query = Container.CosmosContainer.GetItemLinqQueryable(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); - query = _query == null ? query : _query(query); - - var filter = Container.Model.GetAuthorizeFilter(); - if (filter != null) - query = filter(query); - - return QueryArgs.WhereModelValid(query); - } - - /// - /// Gets a pre-prepared with filtering applied as applicable. - /// - /// The . - /// The is not supported. The query will not be automatically included within an execution. - public IQueryable AsQueryable() => AsQueryable(true, false); - - /// - public override Task SelectQueryWithResultAsync(TColl coll, CancellationToken cancellationToken = default) => Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, coll, async (_, items, ct) => - { - var q = AsQueryable(false, true); - - using var iterator = q.WithPaging(Paging).ToFeedIterator(); - while (iterator.HasMoreResults) - { - foreach (var item in await iterator.ReadNextAsync(ct).ConfigureAwait(false)) - { - if (item is not null) - items.Add(Container.MapToValue(item, QueryArgs)!); - } - } - - if (Paging != null && Paging.IsGetCount) - Paging.TotalCount = (await q.CountAsync(cancellationToken).ConfigureAwait(false)).Resource; - - return Result.Success; - }, cancellationToken, nameof(SelectQueryWithResultAsync)); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbQueryBase.cs b/src/CoreEx.Cosmos/CosmosDbQueryBase.cs deleted file mode 100644 index d40f4e1c..00000000 --- a/src/CoreEx.Cosmos/CosmosDbQueryBase.cs +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos -{ - /// - /// Enables the common CosmosDb query capabilities. - /// - /// The resultant . - /// The cosmos model . - /// The itself. - /// The owning . - /// The . - public abstract class CosmosDbQueryBase(CosmosDbContainer container, CosmosDbArgs dbArgs) where T : class, new() where TModel : class, new() where TSelf : CosmosDbQueryBase - { - /// - /// Gets the owning . - /// - public CosmosDbContainer Container { get; } = container.ThrowIfNull(nameof(container)); - - /// - /// Gets the . - /// - public CosmosDbArgs QueryArgs = dbArgs; - - /// - /// Gets the . - /// - public PagingResult? Paging { get; protected set; } - - /// - /// Adds to the query. - /// - /// The . - /// The instance to suport fluent-style method-chaining. - public TSelf WithPaging(PagingArgs? paging) - { - Paging = paging == null ? null : (paging is PagingResult pr ? pr : new PagingResult(paging)); - return (TSelf)this; - } - - /// - /// Adds to the query. - /// - /// The specified number of elements in a sequence to bypass. - /// The specified number of contiguous elements from the start of a sequence. - /// The instance to suport fluent-style method-chaining. - public TSelf WithPaging(long skip, long? take = null) => WithPaging(PagingArgs.CreateSkipAndTake(skip, take)); - - /// - /// Selects a single item. - /// - /// The . - /// The single item. - /// is not supported for this operation. - public async Task SelectSingleAsync(CancellationToken cancellationToken = default) => await SelectSingleWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects a single item with a . - /// - /// The . - /// The single item. - /// is not supported for this operation. - public async Task> SelectSingleWithResultAsync(CancellationToken cancellationToken = default) - { - var result = await SelectArrayWithResultAsync(nameof(SelectSingleAsync), 2, cancellationToken).ConfigureAwait(false); - return result.ThenAs(coll => coll.Single()); - } - - /// - /// Selects a single item or default. - /// - /// The . - /// The single item or default. - /// is not supported for this operation. - public async Task SelectSingleOrDefaultAsync(CancellationToken cancellationToken = default) => await SelectSingleOrDefaultWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects a single item or default with a . - /// - /// The . - /// The single item or default. - /// is not supported for this operation. - public async Task> SelectSingleOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - { - var result = await SelectArrayWithResultAsync(nameof(SelectSingleOrDefaultAsync), 2, cancellationToken).ConfigureAwait(false); - return result.ThenAs(coll => Result.Ok(coll.SingleOrDefault())); - } - - /// - /// Selects first item. - /// - /// The . - /// The first item. - /// is not supported for this operation. - public async Task SelectFirstAsync(CancellationToken cancellationToken = default) => await SelectFirstWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects first item with a . - /// - /// The . - /// The first item. - /// is not supported for this operation. - public async Task> SelectFirstWithResultAsync(CancellationToken cancellationToken = default) - { - var result = await SelectArrayWithResultAsync(nameof(SelectFirstAsync), 1, cancellationToken).ConfigureAwait(false); - return result.ThenAs(coll => coll.First()); - } - - /// - /// Selects first item or default. - /// - /// The . - /// The single item or default. - /// is not supported for this operation. - public async Task SelectFirstOrDefaultAsync(CancellationToken cancellationToken = default) => await SelectFirstOrDefaultWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects first item or default with a . - /// - /// The . - /// The single item or default. - /// is not supported for this operation. - public async Task> SelectFirstOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - { - var result = await SelectArrayWithResultAsync(nameof(SelectFirstOrDefaultAsync), 1, cancellationToken).ConfigureAwait(false); - return result.ThenAs(coll => Result.Ok(coll.FirstOrDefault())); - } - - /// - /// Selects an array by limiting the data retrieved. - /// - private Task> SelectArrayWithResultAsync(string caller, long take, CancellationToken cancellationToken) - { - if (Paging != null) - throw new InvalidOperationException($"The {nameof(Paging)} must be null for a {caller}; internally applied paging is needed to limit unnecessary data retrieval."); - - WithPaging(0, take); - return ToArrayWithResultAsync(cancellationToken); - } - - /// - /// Executes the query command creating a resultant collection. - /// - /// The collection . - /// The . - /// A resultant collection. - /// The is also applied, including where requested. - public async Task SelectQueryAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() => await SelectQueryWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Executes the query command creating a resultant collection with a . - /// - /// The collection . - /// The . - /// A resultant collection. - /// The is also applied, including where requested. - public async Task> SelectQueryWithResultAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() - { - var coll = new TColl(); - var result = await SelectQueryWithResultAsync(coll, cancellationToken).ConfigureAwait(false); - return result.ThenAs(() => coll); - } - - /// - /// Executes the query adding to the passed collection. - /// - /// The collection . - /// The collection to add items to. - /// The . - /// The is also applied, including where requested. - public async Task SelectQueryAsync(TColl coll, CancellationToken cancellationToken = default) where TColl : ICollection => (await SelectQueryWithResultAsync(coll, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes the query adding to the passed collection with a . - /// - /// The collection . - /// The collection to add items to. - /// The . - /// The is also applied, including where requested. - public abstract Task SelectQueryWithResultAsync(TColl coll, CancellationToken cancellationToken = default) where TColl : ICollection; - - /// - /// Executes the query command creating a resultant array. - /// - /// The . - /// A resultant array. - /// The is also applied, including where requested. - public async Task ToArrayAsync(CancellationToken cancellationToken = default) => await ToArrayWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Executes the query command creating a resultant array with a . - /// - /// The . - /// A resultant array. - /// The is also applied, including where requested. - public async Task> ToArrayWithResultAsync(CancellationToken cancellationToken = default) - { - var list = new List(); - var result = await SelectQueryWithResultAsync(list, cancellationToken).ConfigureAwait(false); - return result.ThenAs(list.ToArray); - } - - /// - /// Executes the query command creating a . - /// - /// The . - /// The . - /// The . - /// The resulting . - /// The is also applied, including where requested. - public async Task SelectResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() - => await SelectResultWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Executes the query command creating a with a . - /// - /// The . - /// The . - /// The . - /// The resulting . - /// The is also applied, including where requested. - public async Task> SelectResultWithResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() - { - var result = await SelectQueryWithResultAsync(cancellationToken).ConfigureAwait(false); - return result.ThenAs(coll => new TCollResult { Items = coll, Paging = Paging ?? new PagingResult() }); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbServiceCollectionExtensions.cs b/src/CoreEx.Cosmos/CosmosDbServiceCollectionExtensions.cs deleted file mode 100644 index e4aaa348..00000000 --- a/src/CoreEx.Cosmos/CosmosDbServiceCollectionExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Cosmos; -using CoreEx.Cosmos.HealthChecks; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extension methods. - /// - public static class CosmosDbServiceCollectionExtensions - { - /// - /// Adds an as a scoped service. - /// - /// The . - /// The . - /// The function to create the instance. - /// Indicates whether a corresponding should be configured. - /// The to support fluent-style method-chaining. - public static IServiceCollection AddCosmosDb(this IServiceCollection services, Func create, bool healthCheck = true) where TCosmosDb : class, ICosmosDb - { - services.ThrowIfNull(nameof(services)).AddScoped(sp => create.ThrowIfNull(nameof(create)).Invoke(sp)); - if (healthCheck) - services.AddHealthChecks().AddCosmosDbHealthCheck(); - - return services; - } - - /// - /// Adds an as a scoped service including a corresponding health check. - /// - /// The . - /// The . - /// The function to create the instance. - /// The health check name; defaults to 'cosmos-db'. - /// The to support fluent-style method-chaining. - public static IServiceCollection AddCosmosDb(this IServiceCollection services, Func create, string? healthCheckName) where TCosmosDb : class, ICosmosDb - { - services.ThrowIfNull(nameof(services)).AddScoped(sp => create.ThrowIfNull(nameof(create)).Invoke(sp)); - services.AddHealthChecks().AddCosmosDbHealthCheck(healthCheckName); - return services; - } - - /// - /// Adds an to verify that the database is accessible by performing a read operation. - /// - /// The . - /// The . - /// The health check name; defaults to 'cosmos-db'. - /// The to support fluent-style method-chaining. - public static IHealthChecksBuilder AddCosmosDbHealthCheck(this IHealthChecksBuilder builder, string? healthCheckName = null) where TCosmosDb : class, ICosmosDb - { - builder.ThrowIfNull(nameof(builder)).AddTypeActivatedCheck>(healthCheckName ?? "cosmos-db", HealthStatus.Unhealthy, tags: default!, timeout: TimeSpan.FromSeconds(30)); - return builder; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbValue.cs b/src/CoreEx.Cosmos/CosmosDbValue.cs deleted file mode 100644 index 0dbe10f4..00000000 --- a/src/CoreEx.Cosmos/CosmosDbValue.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Cosmos.Model; -using CoreEx.Entities; -using Newtonsoft.Json; -using System.Text.Json.Serialization; - -namespace CoreEx.Cosmos -{ - /// - /// Represents a special-purpose CosmosDb object that houses an underlying model-, including name, and flexible , for persistence. - /// - /// The model . - /// The , and are updated internally, where possible, when interacting directly with CosmosDB. - public sealed class CosmosDbValue : CosmosDbModelBase, ICosmosDbValue where TModel : class, IEntityKey, new() - { - private TModel _value; - private string _type; - - /// - /// Initializes a new instance of the class. - /// - public CosmosDbValue() - { - _type = typeof(TModel).Name; - _value = new(); - } - - /// - /// Initializes a new instance of the class with a . - /// - /// The value. - public CosmosDbValue(TModel value) - { - _type = typeof(TModel).Name; - _value = value.ThrowIfNull(nameof(value)); - } - - /// - /// Initializes a new instance of the class with a and . - /// - /// The name override. - /// The value. - public CosmosDbValue(string? type, TModel value) - { - _type = type ?? typeof(TModel).Name; - _value = value.ThrowIfNull(nameof(value)); - } - - /// - /// Gets or sets the name. - /// - [JsonProperty("type")] - [JsonPropertyName("type")] - public string Type { get => _type; set => _type = value.ThrowIfNullOrEmpty(nameof(Type)); } - - /// - /// Gets or sets the value. - /// - [JsonProperty("value")] - [JsonPropertyName("value")] - public TModel Value { get => _value; set => _value = value.ThrowIfNull(nameof(Value)); } - - /// - /// Gets the value. - /// - object ICosmosDbValue.Value => _value; - - /// - void ICosmosDbValue.PrepareBefore(CosmosDbArgs dbArgs, string? type) - { - if (Value != default) - { - Id = dbArgs.FormatIdentifier(Value.EntityKey); - - if (Value is IETag etag) - ETag = ETagGenerator.FormatETag(etag.ETag); - - if (Value is IPartitionKey pk) - PartitionKey = pk.PartitionKey; - } - - if (string.IsNullOrEmpty(type)) - Type ??= typeof(TModel).Name; - else - Type = type; - } - - /// - void ICosmosDbValue.PrepareAfter(CosmosDbArgs dbArgs) - { - if (Value == default) - return; - - if (Value is IETag etag) - etag.ETag = ETagGenerator.ParseETag(ETag); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbValueContainerT.cs b/src/CoreEx.Cosmos/CosmosDbValueContainerT.cs deleted file mode 100644 index 006b93d4..00000000 --- a/src/CoreEx.Cosmos/CosmosDbValueContainerT.cs +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Cosmos.Model; -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos -{ - /// - /// Provides a typed interface for the primary operations. - /// - /// The entity . - /// The cosmos model . - public sealed class CosmosDbValueContainer where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - private CosmosDbValueModelContainer? _model; - - /// - /// Initializes a new instance of the class. - /// - /// The owning . - internal CosmosDbValueContainer(CosmosDbContainer owner) - { - Container = owner.ThrowIfNull(nameof(owner)); - CosmosContainer = owner.CosmosContainer; - } - - /// - /// Gets the owning . - /// - public CosmosDbContainer Container { get; } - - /// - /// Gets the . - /// - public Container CosmosContainer { get; } - - /// - /// Gets the typed . - /// - public CosmosDbValueModelContainer Model => _model ??= new(Container); - - #region Query - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The function to perform additional query execution. - /// The . - public CosmosDbValueQuery Query(Func>, IQueryable>>? query) => Container.ValueQuery(query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueQuery Query(PartitionKey? partitionKey = null, Func>, IQueryable>>? query = null) => Container.ValueQuery(partitionKey, query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueQuery Query(CosmosDbArgs dbArgs, Func>, IQueryable>>? query = null) => Container.ValueQuery(dbArgs, query); - - #endregion - - #region Get - - /// - /// Gets the entity (using underlying ) for the specified . - /// - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.GetValueAsync(key, cancellationToken); - - /// - /// Gets the entity (using underlying ) for the specified with a . - /// - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.GetValueWithResultAsync(key, cancellationToken); - - /// - /// Gets the entity (using underlying ) for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.GetValueAsync(key, partitionKey, cancellationToken); - - /// - /// Gets the entity (using underlying ) for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.GetValueWithResultAsync(key, partitionKey, cancellationToken); - - /// - /// Gets the entity (using underlying ) for the specified . - /// - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.GetValueAsync(dbArgs, key, cancellationToken); - - /// - /// Gets the entity (using underlying ) for the specified with a . - /// - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.GetValueWithResultAsync(dbArgs, key, cancellationToken); - - #endregion - - #region Create - - /// - /// Creates the entity (using underlying ). - /// - /// The value to create. - /// The . - /// The created value. - public Task CreateAsync(T value, CancellationToken cancellationToken = default) => Container.CreateValueAsync(value, cancellationToken); - - /// - /// Creates the entity (using underlying ) with a . - /// - /// The value to create. - /// The . - /// The created value. - public Task> CreateWithResultAsync(T value, CancellationToken cancellationToken = default) => Container.CreateValueWithResultAsync(value, cancellationToken); - - /// - /// Creates the entity (using underlying ). - /// - /// The . - /// The value to create. - /// The . - /// The created value. - public Task CreateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.CreateValueAsync(dbArgs, value, cancellationToken); - - /// - /// Creates the entity (using underlying ) with a . - /// - /// The . - /// The value to create. - /// The . - /// The created value. - public Task> CreateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.CreateValueWithResultAsync(dbArgs, value, cancellationToken); - - #endregion - - #region Update - - /// - /// Updates the entity (using underlying ). - /// - /// The value to update. - /// The . - /// The updated value. - public Task UpdateAsync(T value, CancellationToken cancellationToken = default) => Container.UpdateValueAsync(value, cancellationToken); - - /// - /// Updates the entity (using underlying ) with a . - /// - /// The value to update. - /// The . - /// The updated value. - public Task> UpdateWithResultAsync(T value, CancellationToken cancellationToken = default) => Container.UpdateValueWithResultAsync(value, cancellationToken); - - /// - /// Updates the entity (using underlying ). - /// - /// The . - /// The value to update. - /// The . - /// The updated value. - public Task UpdateAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.UpdateValueAsync(dbArgs, value, cancellationToken); - - /// - /// Updates the entity (using underlying ) with a . - /// - /// The . - /// The value to update. - /// The . - /// The updated value. - public Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, T value, CancellationToken cancellationToken = default) => Container.UpdateValueWithResultAsync(dbArgs, value, cancellationToken); - - #endregion - - #region Delete - - /// - /// Deletes the entity (using underlying ) for the specified . - /// - /// The . - /// The . - public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteValueAsync(key, cancellationToken); - - /// - /// Deletes the entity (using underlying ) for the specified with a . - /// - /// The . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteValueWithResultAsync(key, cancellationToken); - - /// - /// Deletes the entity (using underlying ) for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - public Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.DeleteValueAsync(key, partitionKey, cancellationToken); - - /// - /// Deletes the entity (using underlying ) for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Container.DeleteValueWithResultAsync(key, partitionKey, cancellationToken); - - /// - /// Deletes the entity (using underlying ) for the specified . - /// - /// The .. - /// The . - /// The . - public Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteValueAsync(dbArgs, key, cancellationToken); - - /// - /// Deletes the entity (using underlying ) for the specified with a . - /// - /// The .. - /// The . - /// The . - public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteValueWithResultAsync(dbArgs, key, cancellationToken); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbValueQuery.cs b/src/CoreEx.Cosmos/CosmosDbValueQuery.cs deleted file mode 100644 index 5edd51a9..00000000 --- a/src/CoreEx.Cosmos/CosmosDbValueQuery.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.Azure.Cosmos.Linq; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos -{ - /// - /// Encapsulates a CosmosDb query enabling all select-like capabilities. - /// - /// The resultant . - /// The cosmos model . - /// The . - /// The . - /// A function to modify the underlying . - public class CosmosDbValueQuery(CosmosDbContainer container, CosmosDbArgs dbArgs, Func>, IQueryable>>? query) : CosmosDbQueryBase>(container, dbArgs) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - private readonly Func>, IQueryable>>? _query = query; - - /// - /// Instantiates the . - /// - private IQueryable> AsQueryable(bool allowSynchronousQueryExecution, bool pagingSupported) - { - if (!pagingSupported && Paging is not null) - throw new NotSupportedException("Paging is not supported when accessing AsQueryable directly; paging must be applied directly to the resulting IQueryable instance."); - - IQueryable> query = Container.CosmosContainer.GetItemLinqQueryable>(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); - query = (_query == null ? query : _query(query)).WhereType(Container.Model.GetModelName()); - - var filter = Container.Model.GetValueAuthorizeFilter(); - if (filter != null) - query = filter(query); - - return QueryArgs.WhereModelValid(query); - } - - /// - /// Gets a pre-prepared with filtering applied as applicable. - /// - /// The . - /// The is not supported. The query will not be automatically included within an execution. - public IQueryable> AsQueryable() => AsQueryable(true, false); - - /// - public override Task SelectQueryWithResultAsync(TColl coll, CancellationToken cancellationToken = default) => Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, coll, async (_, items, ct) => - { - var q = AsQueryable(false, true); - - using var iterator = q.WithPaging(Paging).ToFeedIterator(); - while (iterator.HasMoreResults) - { - foreach (var item in await iterator.ReadNextAsync(ct).ConfigureAwait(false)) - { - items.Add(Container.MapToValue(item, QueryArgs)!); - } - } - - if (Paging != null && Paging.IsGetCount) - Paging.TotalCount = (await q.CountAsync(cancellationToken).ConfigureAwait(false)).Resource; - - return Result.Success; - }, cancellationToken, nameof(SelectQueryWithResultAsync)); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosDbValueQueryableExtensions.cs b/src/CoreEx.Cosmos/CosmosDbValueQueryableExtensions.cs deleted file mode 100644 index d4b2638f..00000000 --- a/src/CoreEx.Cosmos/CosmosDbValueQueryableExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Linq; -using System.Linq.Dynamic.Core; - -namespace CoreEx.Cosmos -{ - /// - /// Adds additional extension methods to for where T is . - /// - public static class CosmosDbValueQueryableExtensions - { - /// - /// Filters a sequence of values based on the equalling the . - /// - /// The being queried. - /// The query. - /// The name. - /// The query. - public static IQueryable> WhereType(this IQueryable> query, string? typeName = null) where T : class, IEntityKey, new() => query.Where("type = @0", string.IsNullOrEmpty(typeName) ? typeof(T).Name : typeName); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/CosmosExtensions.cs b/src/CoreEx.Cosmos/CosmosExtensions.cs deleted file mode 100644 index 8eba5f8f..00000000 --- a/src/CoreEx.Cosmos/CosmosExtensions.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Azure.Cosmos; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos -{ - /// - /// Provides CosmosDb-related extension methods. - /// - public static class CosmosExtensions - { - /// - /// Deletes the from the . - /// - /// The . - /// The container identifier. - /// The . - /// The . - /// The . - public static Task DeleteContainerAsync(this Database database, string containerId, ContainerRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - { - database.ThrowIfNull(nameof(database)); - containerId.ThrowIfNull(nameof(containerId)); - - var container = database.GetContainer(containerId); - return container.DeleteContainerAsync(requestOptions, cancellationToken); - } - - /// - /// Replace or create the . - /// - /// The . - /// The . - /// The throughput (RU/S). - /// The . - /// The . - /// The . - public static async Task ReplaceOrCreateContainerAsync(this Database database, ContainerProperties containerProperties, int? throughput = null, RequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - { - database.ThrowIfNull(nameof(database)); - containerProperties.ThrowIfNull(nameof(containerProperties)); - - try - { - await database.DeleteContainerAsync(containerProperties.Id, null, cancellationToken).ConfigureAwait(false); - } - catch (CosmosException cex) - { - if (cex.StatusCode != System.Net.HttpStatusCode.NotFound) - throw; - } - - return await database.CreateContainerAsync(containerProperties, throughput, requestOptions, cancellationToken).ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/HealthChecks/CosmosDbHealthCheck.cs b/src/CoreEx.Cosmos/HealthChecks/CosmosDbHealthCheck.cs deleted file mode 100644 index 030b7211..00000000 --- a/src/CoreEx.Cosmos/HealthChecks/CosmosDbHealthCheck.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos.HealthChecks -{ - /// - /// Provides a generic to verify that the database is accessible by performing a read operation. - /// - /// The . - /// The to health check. - public class CosmosDbHealthCheck(TCosmosDb cosmosDb) : IHealthCheck where TCosmosDb : class, ICosmosDb - { - private readonly TCosmosDb _cosmosDb = cosmosDb = cosmosDb.ThrowIfNull(nameof(cosmosDb)); - - /// - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - var data = new Dictionary { { "database-id", _cosmosDb.Database.Id } }; - - try - { - var dr = await _cosmosDb.Database.ReadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - return HealthCheckResult.Healthy(null, data); - } - catch (Exception ex) - { - return new HealthCheckResult(context.Registration.FailureStatus, $"An unexpected CosmosDB database error has occurred: {ex.Message}", ex, data); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/ICosmosDb.cs b/src/CoreEx.Cosmos/ICosmosDb.cs deleted file mode 100644 index 19b37526..00000000 --- a/src/CoreEx.Cosmos/ICosmosDb.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; - -namespace CoreEx.Cosmos -{ - /// - /// Provides the CosmosDb capabilities. - /// - public interface ICosmosDb - { - /// - /// Gets the . - /// - Database Database { get; } - - /// - /// Gets the . - /// - IMapper Mapper { get; } - - /// - /// Gets the . - /// - CosmosDbInvoker Invoker { get; } - - /// - /// Gets the default used where not expliticly specified for an operation. - /// - CosmosDbArgs DbArgs { get; } - - /// - /// Gets the specified . - /// - /// The identifier. - /// The selected . - Container GetCosmosContainer(string containerId); - - /// - /// Gets (or adds) the for the specified . - /// - /// The identifier. - /// The . - CosmosDbContainer Container(string containerId); - - /// - /// Gets (or adds) the typed for the specified and . - /// - /// The entity . - /// The cosmos model . - /// The identifier. - /// The typed - CosmosDbContainer Container(string containerId) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new(); - - /// - /// Gets (or adds) the typed for the specified and . - /// - /// The entity . - /// The cosmos model . - /// The identifier. - /// The typed - CosmosDbValueContainer ValueContainer(string containerId) where T : class, IEntityKey, new() where TModel : class, IEntityKey, new(); - - /// - /// Invoked where a has been thrown. - /// - /// The . - /// The containing the appropriate where handled; otherwise, null indicating that the exception is unexpected and will continue to be thrown as such. - /// Provides an opportunity to inspect and handle the exception before it is returned. A resulting that is is not considered sensical; therefore, will result in the originating - /// exception being thrown. - Result? HandleCosmosException(CosmosException cex); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/ICosmosDbType.cs b/src/CoreEx.Cosmos/ICosmosDbType.cs deleted file mode 100644 index 23070c96..00000000 --- a/src/CoreEx.Cosmos/ICosmosDbType.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Cosmos -{ - /// - /// Defines the property. - /// - public interface ICosmosDbType - { - /// - /// Gets the model name. - /// - /// Enables multiple models to be managed within a single container leveraging different types. - string Type { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/ICosmosDbValue.cs b/src/CoreEx.Cosmos/ICosmosDbValue.cs deleted file mode 100644 index 7d4022d9..00000000 --- a/src/CoreEx.Cosmos/ICosmosDbValue.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; - -namespace CoreEx.Cosmos -{ - /// - /// Defines the core capabilities for the special-purpose CosmosDb object that houses an underlying model-. - /// - public interface ICosmosDbValue : IIdentifier, ICosmosDbType - { - /// - /// Gets the model value. - /// - object Value { get; } - - /// - /// Prepares the object before sending to Cosmos. - /// - /// The . - /// The model name override. - void PrepareBefore(CosmosDbArgs dbArgs, string? type); - - /// - /// Prepares the object after getting from Cosmos. - /// - /// The . - void PrepareAfter(CosmosDbArgs dbArgs); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/IMultiSetValueArgs.cs b/src/CoreEx.Cosmos/IMultiSetValueArgs.cs deleted file mode 100644 index ebaec70b..00000000 --- a/src/CoreEx.Cosmos/IMultiSetValueArgs.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Cosmos.Model; - -namespace CoreEx.Cosmos -{ - /// - /// Enables the multi-set arguments. - /// - public interface IMultiSetValueArgs : IMultiSetArgs { } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbModelBase.cs b/src/CoreEx.Cosmos/Model/CosmosDbModelBase.cs deleted file mode 100644 index dbd04ce8..00000000 --- a/src/CoreEx.Cosmos/Model/CosmosDbModelBase.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using Newtonsoft.Json; -using System.Text.Json.Serialization; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Provides the base CosmosDb model capabilities. - /// - public abstract class CosmosDbModelBase : IIdentifier, IETag, IPartitionKey - { - /// - /// Gets or sets the . - /// - [JsonProperty("id")] - public string? Id { get; set; } - - /// - /// Gets or sets the . - /// - [JsonProperty("_etag")] - [JsonPropertyName("_etag")] - public string? ETag { get; set; } - - /// - /// Gets or sets the time-to-live (https://docs.microsoft.com/en-us/azure/cosmos-db/time-to-live). - /// - [JsonProperty("ttl", DefaultValueHandling = DefaultValueHandling.Ignore)] - [JsonPropertyName("ttl")] - public int? TimeToLive { get; set; } - - /// - /// Gets or sets an optional CosmosDb partition key that can be used when required. - /// - /// This property exists to support scenarios where the partition key is not represented in the underlying itself. - [JsonProperty("_partitionKey")] - [JsonPropertyName("_partitionKey")] - public string? PartitionKey { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbModelContainer.cs b/src/CoreEx.Cosmos/Model/CosmosDbModelContainer.cs deleted file mode 100644 index 67ec6143..00000000 --- a/src/CoreEx.Cosmos/Model/CosmosDbModelContainer.cs +++ /dev/null @@ -1,1120 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using CoreEx.Json; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Stj = System.Text.Json; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Provides the underlying operations for model-based access within the . - /// - public sealed class CosmosDbModelContainer - { - private readonly CosmosDbContainer _owner; - private readonly Lazy>> _partitionKeyGets = new(); - private readonly Lazy> _typeNames = new(); - private readonly Lazy> _filters = new(); - - /// - /// Initializes a new instance of the class. - /// - /// The owning . - internal CosmosDbModelContainer(CosmosDbContainer owner) => _owner = owner; - - /// - /// Checks whether the is in a valid state for the operation. - /// - /// The cosmos model . - /// The model value. - /// The specific for the operation. - /// Indicates whether an additional authorization check should be performed against the . - /// true indicates that the model is in a valid state; otherwise, false. - public bool IsModelValid(TModel? model, CosmosDbArgs dbArgs, bool checkAuthorized) where TModel : class, IEntityKey, new() - => !(!dbArgs.IsModelValid(model) - || (model is ICosmosDbType mt && mt.Type != GetModelName()) - || (checkAuthorized && IsAuthorized(model).IsFailure)); - - /// - /// Checks whether the is in a valid state for the operation. - /// - /// The cosmos model . - /// The model value. - /// The specific for the operation. - /// Indicates whether an additional authorization check should be performed against the . - /// true indicates that the model is in a valid state; otherwise, false. - public bool IsModelValid(CosmosDbValue? model, CosmosDbArgs dbArgs, bool checkAuthorized) where TModel : class, IEntityKey, new() - => !(model is null - || !dbArgs.IsModelValid(model.Value) - || model.Type != GetModelName() - || (checkAuthorized && IsAuthorized(model).IsFailure)); - - /// - /// Sets the function to get the from the model used by the (used by only the Create and Update operations). - /// - /// The cosmos model . - /// The function to get the from the model. - internal void UsePartitionKey(Func? getPartitionKey) where TModel : class, IEntityKey, new() - { - // Where the function is null we should ignore unless previously set. - if (getPartitionKey is null) - { - if (_partitionKeyGets.IsValueCreated && _partitionKeyGets.Value.ContainsKey(typeof(TModel))) - throw new InvalidOperationException($"PartitionKey already set for {typeof(TModel).Name}."); - - return; - } - - if (!_partitionKeyGets.Value.TryAdd(typeof(TModel), model => getPartitionKey.ThrowIfNull(nameof(getPartitionKey)).Invoke((TModel)model))) - throw new InvalidOperationException($"PartitionKey already set for {typeof(TModel).Name}."); - } - - /// - /// Gets the from the (used by only by the Create and Update operations). - /// - /// The cosmos model . - /// The . - /// The . - /// Will be thrown where the infered is not equal to (where not null). - public PartitionKey GetPartitionKey(TModel model, CosmosDbArgs dbArgs) where TModel : class, IEntityKey, new() - { - var dbpk = _owner.DbArgs.PartitionKey; - var pk = _partitionKeyGets.IsValueCreated && _partitionKeyGets.Value.TryGetValue(typeof(TModel), out var gpk) ? gpk(model!) : null; - - if (!pk.HasValue) - pk = dbArgs.PartitionKey ?? _owner.DbArgs.PartitionKey ?? PartitionKey.None; - - if (dbpk is not null && dbpk != PartitionKey.None && dbpk != pk) - throw new AuthorizationException(); - - return pk.Value; - } - - /// - /// Sets the function to get the from the used by the (used by only the Create and Update operations). - /// - /// The cosmos model . - /// The function to get the from the model. - internal void UsePartitionKey(Func, PartitionKey>? getPartitionKey) where TModel : class, IEntityKey, new() - { - // Where the function is null we should ignore unless previously set. - if (getPartitionKey is null) - { - if (_partitionKeyGets.IsValueCreated && _partitionKeyGets.Value.ContainsKey(typeof(CosmosDbValue))) - throw new InvalidOperationException($"PartitionKey already set for {typeof(CosmosDbValue).Name}."); - - return; - } - - if (!_partitionKeyGets.Value.TryAdd(typeof(CosmosDbValue), model => getPartitionKey.ThrowIfNull(nameof(getPartitionKey)).Invoke((CosmosDbValue)model))) - throw new InvalidOperationException($"PartitionKey already set for {typeof(CosmosDbValue).Name}."); - } - - /// - /// Gets the from the (used by only by the Create and Update operations). - /// - /// The cosmos model . - /// The . - /// The . - /// Will be thrown where the infered is not equal to (where not null). - public PartitionKey GetPartitionKey(CosmosDbValue model, CosmosDbArgs dbArgs) where TModel : class, IEntityKey, new() - { - var dbpk = _owner.DbArgs.PartitionKey; - var pk = _partitionKeyGets.IsValueCreated && _partitionKeyGets.Value.TryGetValue(typeof(CosmosDbValue), out var gpk) ? gpk(model!) : null; - - if (!pk.HasValue) - pk = dbArgs.PartitionKey ?? _owner.DbArgs.PartitionKey ?? PartitionKey.None; - - if (dbpk is not null && dbpk != PartitionKey.None && dbpk != pk) - throw new AuthorizationException(); - - return pk.Value; - } - - /// - /// Sets the name for the model . - /// - /// The cosmos model . - /// The model name. - internal void UseModelName(string name) where TModel : class, IEntityKey, new() - { - if (!_typeNames.Value.TryAdd(typeof(TModel), name)) - throw new InvalidOperationException($"Model Type Name already set for {typeof(TModel).Name}."); - } - - /// - /// Gets the name for the model . - /// - /// The cosmos model . - /// The model name where configured (see ); otherwise, defaults to . - public string GetModelName() where TModel : class, IEntityKey, new() => _typeNames.IsValueCreated && _typeNames.Value.TryGetValue(typeof(TModel), out var name) ? name : typeof(TModel).Name; - - /// - /// Sets the filter for all operations performed on the to ensure authorisation is applied. Applies automatically to all queries, plus create, update, delete and get operations. - /// - /// The cosmos model . - /// The authorization filter query. - internal void UseAuthorizeFilter(Func, IQueryable>? filter) where TModel : class, IEntityKey, new() - { - if (filter is null) - { - if (_filters.IsValueCreated && _filters.Value.ContainsKey(typeof(TModel))) - throw new InvalidOperationException($"Filter already set for {typeof(TModel).Name}."); - - return; - } - - if (!_filters.Value.TryAdd(typeof(TModel), filter)) - throw new InvalidOperationException($"Filter already set for {typeof(TModel).Name}."); - } - - /// - /// Checks the value to determine whether the user is authorized with the . - /// - /// The cosmos model . - /// The model value. - /// Either or . - public Result IsAuthorized(TModel model) where TModel : class, IEntityKey, new() - { - if (model != default) - { - var filter = GetAuthorizeFilter(); - if (filter != null && !filter(new [] { model }.AsQueryable()).Any()) - return Result.AuthorizationError(); - } - - return Result.Success; - } - - /// - /// Gets the authorization filter for the . - /// - /// The cosmos model . - /// The authorization filter query where configured; otherwise, null. - public Func, IQueryable>? GetAuthorizeFilter() where TModel : class, IEntityKey, new() - => _filters.IsValueCreated && _filters.Value.TryGetValue(typeof(TModel), out var filter) ? (Func, IQueryable>)filter : null; - - /// - /// Sets the filter for all operations performed on the to ensure authorization is applied. Applies automatically to all queries, plus create, update, delete and get operations. - /// - /// The cosmos model . - /// The authorization filter query. - internal void UseAuthorizeFilter(Func>, IQueryable>>? filter) where TModel : class, IEntityKey, new() - { - if (filter is null) - { - if (_filters.IsValueCreated && _filters.Value.ContainsKey(typeof(CosmosDbValue))) - throw new InvalidOperationException($"Filter already set for {typeof(CosmosDbValue).Name}."); - - return; - } - - if (!_filters.Value.TryAdd(typeof(CosmosDbValue), filter)) - throw new InvalidOperationException($"Filter already set for {typeof(CosmosDbValue).Name}."); - } - - /// - /// Gets the authorization filter for the . - /// - /// The cosmos model . - /// The authorization filter query where configured; otherwise, null. - public Func>, IQueryable>>? GetValueAuthorizeFilter() where TModel : class, IEntityKey, new() - => _filters.IsValueCreated && _filters.Value.TryGetValue(typeof(CosmosDbValue), out var filter) ? (Func>, IQueryable>>)filter : null; - - /// - /// Checks the value to determine whether the user is authorized with the . - /// - /// The cosmos model . - /// The model value. - /// Either or . - public Result IsAuthorized(CosmosDbValue model) where TModel : class, IEntityKey, new() - { - if (model != null && model.Value != default) - { - var filter = GetValueAuthorizeFilter(); - if (filter != null && !filter(new CosmosDbValue[] { model }.AsQueryable()).Any()) - return Result.AuthorizationError(); - } - - return Result.Success; - } - - /// - /// Gets the value from the response updating any special properties as required. - /// - /// The cosmos model . - /// The response value. - /// The entity value. - internal static TModel? GetResponseValue(Response resp) where TModel : class, IEntityKey, new() => resp?.Resource == null ? default : resp.Resource; - - /// - /// Gets the value from the response updating any special properties as required. - /// - /// The cosmos model . - /// The response value. - /// The entity value. - internal static CosmosDbValue? GetResponseValue(Response> resp) where TModel : class, IEntityKey, new() => resp?.Resource == null ? default : resp.Resource; - - #region Query - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The function to perform additional query execution. - /// The . - public CosmosDbModelQuery Query(Func, IQueryable>? query) where TModel : class, IEntityKey, new() => Query(new CosmosDbArgs(_owner.DbArgs), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbModelQuery Query(PartitionKey? partitionKey = null, Func, IQueryable>? query = null) where TModel : class, IEntityKey, new() => Query(new CosmosDbArgs(_owner.DbArgs, partitionKey), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbModelQuery Query(CosmosDbArgs dbArgs, Func, IQueryable>? query = null) where TModel : class, IEntityKey, new() => new(_owner, dbArgs, query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The function to perform additional query execution. - /// The . - public CosmosDbValueModelQuery ValueQuery(Func>, IQueryable>>? query) where TModel : class, IEntityKey, new() => ValueQuery(new CosmosDbArgs(_owner.DbArgs), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueModelQuery ValueQuery(PartitionKey? partitionKey = null, Func>, IQueryable>>? query = null) where TModel : class, IEntityKey, new() => ValueQuery(new CosmosDbArgs(_owner.DbArgs, partitionKey), query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueModelQuery ValueQuery(CosmosDbArgs dbArgs, Func>, IQueryable>>? query = null) where TModel : class, IEntityKey, new() => new(_owner, dbArgs, query); - - #endregion - - #region Get - - /// - /// Gets the model for the specified . - /// - /// The cosmos model . - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public async Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await GetWithResultAsync(key, cancellationToken)).Value; - - /// - /// Gets the model for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => GetWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken); - - /// - /// Gets the model for the specified . - /// - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - /// The model value where found; otherwise, null (see ). - public async Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await GetWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the model for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - /// The model value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => GetWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken); - - /// - /// Gets the model for the specified . - /// - /// The cosmos model . - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public async Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await GetWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the model for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - { - return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, _owner.GetCosmosId(key), dbArgs, async (_, id, args, ct) => - { - try - { - var pk = _owner.GetPartitionKey(dbArgs.PartitionKey); - var resp = await _owner.CosmosContainer.ReadItemAsync(id, pk, args.GetItemRequestOptions(), ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return args.NullOnNotFound ? Result.None : Result.NotFoundError(); - - return Result.Go(IsAuthorized(resp)).ThenAs(() => GetResponseValue(resp)); - } - catch (CosmosException dcex) when (args.NullOnNotFound && dcex.StatusCode == System.Net.HttpStatusCode.NotFound) { return args.NullOnNotFound ? Result.None : Result.NotFoundError(); } - }, cancellationToken, nameof(GetWithResultAsync)); - } - - /// - /// Gets the for the specified . - /// - /// The cosmos model . - /// The . - /// The . - /// The value where found; otherwise, null (see ). - public async Task?> GetValueAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await GetValueWithResultAsync(key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . - /// The value where found; otherwise, null (see ). - public Task?>> GetValueWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => GetValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken); - - /// - /// Gets the for the specified . - /// - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - /// The value where found; otherwise, null (see ). - public async Task?> GetValueAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await GetValueWithResultAsync(key, partitionKey, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - /// The value where found; otherwise, null (see ). - public Task?>> GetValueWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => GetValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken); - - /// - /// Gets the for the specified . - /// - /// The cosmos model . - /// The . - /// The . - /// The . - /// The value where found; otherwise, null (see ). - public async Task?> GetValueAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await GetValueWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . - /// The . - /// The value where found; otherwise, null (see ). - public Task?>> GetValueWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - { - return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, _owner.GetCosmosId(key), dbArgs, async (_, id, args, ct) => - { - try - { - var pk = _owner.GetPartitionKey(dbArgs.PartitionKey); - var resp = await _owner.CosmosContainer.ReadItemAsync>(id, pk, args.GetItemRequestOptions(), ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return args.NullOnNotFound ? Result?>.None : Result?>.NotFoundError(); - - return Result.Go(IsAuthorized(resp)).ThenAs(() => GetResponseValue(resp)); - } - catch (CosmosException dcex) when (args.NullOnNotFound && dcex.StatusCode == System.Net.HttpStatusCode.NotFound) { return args.NullOnNotFound ? Result?>.None : Result?>.NotFoundError(); } - }, cancellationToken, nameof(GetWithResultAsync)); - } - - #endregion - - #region Create - - /// - /// Creates the model. - /// - /// The cosmos model . - /// The model to create. - /// The . - /// The created model. - public async Task CreateAsync(TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await CreateWithResultAsync(new CosmosDbArgs(_owner.DbArgs), model, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Creates the model. - /// - /// The cosmos model . - /// The . - /// The model to create. - /// The . - /// The created model. - public async Task CreateAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await CreateWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Creates the model with a . - /// - /// The cosmos model . - /// The model to create. - /// The . - /// The created model. - public Task> CreateWithResultAsync(TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => CreateWithResultAsync(new CosmosDbArgs(_owner.DbArgs), model, cancellationToken); - - /// - /// Creates the model with a . - /// - /// The cosmos model . - /// The . - /// The model to create. - /// The . - /// The created model. - public Task> CreateWithResultAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - { - return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, Cleaner.PrepareCreate(model.ThrowIfNull(nameof(model))), dbArgs, async (_, m, args, ct) => - { - var pk = GetPartitionKey(m, args); - return await Result - .Go(IsAuthorized(model)) - .ThenAsAsync(() => _owner.CosmosContainer.CreateItemAsync(Cleaner.PrepareCreate(model), pk, args.GetItemRequestOptions(), ct)) - .ThenAs(resp => GetResponseValue(resp!)!); - }, cancellationToken, nameof(CreateWithResultAsync)); - } - - /// - /// Creates the with a . - /// - /// The cosmos model . - /// The model to create. - /// The . - /// The created model. - public async Task> CreateValueAsync(CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await CreateValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs), model, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Creates the with a . - /// - /// The cosmos model . - /// The . - /// The model to create. - /// The . - /// The created model. - public async Task> CreateValueAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await CreateValueWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Creates the with a . - /// - /// The cosmos model . - /// The model to create. - /// The . - /// The created model. - public Task>> CreateValueWithResultAsync(CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => CreateValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs), model, cancellationToken); - - /// - /// Creates the with a . - /// - /// The cosmos model . - /// The . - /// The model to create. - /// The . - /// The created model. - public Task>> CreateValueWithResultAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - { - return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, Cleaner.PrepareCreate(model.ThrowIfNull(nameof(model))), dbArgs, async (_, m, args, ct) => - { - var pk = GetPartitionKey(m, args); - return await Result - .Go(IsAuthorized(m)) - .ThenAsAsync(async () => - { - ((ICosmosDbValue)m).PrepareBefore(args, typeof(TModel).Name); - Cleaner.PrepareCreate(m.Value); - var resp = await _owner.CosmosContainer.CreateItemAsync(m, pk, args.GetItemRequestOptions(), ct).ConfigureAwait(false); - return GetResponseValue(resp)!; - }); - }, cancellationToken, nameof(CreateWithResultAsync)); - } - - #endregion - - #region Update - - /// - /// Updates the model. - /// - /// The cosmos model . - /// The model to update. - /// The . - /// The updated model. - public async Task UpdateAsync(TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await UpdateWithResultInternalAsync(new CosmosDbArgs(_owner.DbArgs), model, null, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Updates the model. - /// - /// The cosmos model . - /// The . - /// The model to update. - /// The . - /// The updated model. - public async Task UpdateAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await UpdateWithResultInternalAsync(dbArgs, model, null, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Updates the model with a . - /// - /// The cosmos model . - /// The model to update. - /// The . - /// The updated model. - public Task> UpdateWithResultAsync(TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => UpdateWithResultInternalAsync(new CosmosDbArgs(_owner.DbArgs), model, null, cancellationToken); - - /// - /// Updates the model with a . - /// - /// The cosmos model . - /// The . - /// The model to update. - /// The . - /// The updated model. - public Task> UpdateWithResultAsync(CosmosDbArgs dbArgs, TModel model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => UpdateWithResultInternalAsync(dbArgs, model, null, cancellationToken); - - /// - /// Updates the model with a (internal). - /// - /// The cosmos model . - /// The . - /// The model to update. - /// The action to update the model after the read. - /// The . - /// The updated model. - internal Task> UpdateWithResultInternalAsync(CosmosDbArgs dbArgs, TModel model, Action? modelUpdater, CancellationToken cancellationToken) where TModel : class, IEntityKey, new() - { - return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, Cleaner.PrepareUpdate(model.ThrowIfNull(nameof(model))), dbArgs, async (_, m, args, ct) => - { - // Where supporting etag then use IfMatch for concurrency. - var ro = args.GetItemRequestOptions(); - if (ro.IfMatchEtag == null && m is IETag etag && etag.ETag != null) - ro.IfMatchEtag = ETagGenerator.FormatETag(etag.ETag); - - // Must read existing to update. - var id = _owner.GetCosmosId(m); - var pk = GetPartitionKey(model, dbArgs); - var resp = await _owner.CosmosContainer.ReadItemAsync(id, pk, ro, ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return Result.NotFoundError(); - - return await Result - .Go(IsAuthorized(resp)) - .When(() => m is IETag etag2 && etag2.ETag != null && ETagGenerator.FormatETag(etag2.ETag) != resp.ETag, () => Result.ConcurrencyError()) - .Then(() => - { - ro.SessionToken = resp.Headers?.Session; - modelUpdater?.Invoke(resp.Resource); - Cleaner.ResetTenantId(resp.Resource); - - // Re-check auth to make sure not updating to something not allowed. - return IsAuthorized(resp); - }) - .ThenAsAsync(async () => - { - resp = await _owner.CosmosContainer.ReplaceItemAsync(Cleaner.PrepareUpdate(resp.Resource), id, pk, ro, ct).ConfigureAwait(false); - return GetResponseValue(resp)!; - }); - }, cancellationToken, nameof(UpdateWithResultAsync)); - } - - /// - /// Updates the . - /// - /// The cosmos model . - /// The model to update. - /// The . - /// The updated model. - public async Task> UpdateValueAsync(CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await UpdateWithResultAsync(new CosmosDbArgs(_owner.DbArgs), model, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Updates the . - /// - /// The cosmos model . - /// The . - /// The model to update. - /// The . - /// The updated model. - public async Task> UpdateValueAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await UpdateWithResultAsync(dbArgs, model, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Updates the with a . - /// - /// The cosmos model . - /// The model to update. - /// The . - /// The updated model. - public Task>> UpdateValueWithResultAsync(CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => UpdateValueWithResultInternalAsync(new CosmosDbArgs(_owner.DbArgs), model, null, cancellationToken); - - /// - /// Updates the with a . - /// - /// The cosmos model . - /// The . - /// The model to update. - /// The . - /// The updated model. - public Task>> UpdateValueWithResultAsync(CosmosDbArgs dbArgs, CosmosDbValue model, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => UpdateValueWithResultInternalAsync(dbArgs, model, null, cancellationToken); - - /// - /// Updates the with a (internal). - /// - /// The cosmos model . - /// The . - /// The model to update. - /// The action to update the model after the read. - /// The . - /// The updated model. - internal Task>> UpdateValueWithResultInternalAsync(CosmosDbArgs dbArgs, CosmosDbValue model, Action>? modelUpdater, CancellationToken cancellationToken) where TModel : class, IEntityKey, new() - { - return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, Cleaner.PrepareUpdate(model.ThrowIfNull(nameof(model))), dbArgs, async (_, m, args, ct) => - { - // Where supporting etag then use IfMatch for concurrency. - var ro = args.GetItemRequestOptions(); - if (ro.IfMatchEtag == null && m is IETag etag && etag.ETag != null) - ro.IfMatchEtag = ETagGenerator.FormatETag(etag.ETag); - - // Must read existing to update. - ((ICosmosDbValue)m).PrepareBefore(dbArgs, GetModelName()); - var id = m.Id; - var pk = GetPartitionKey(m, dbArgs); - var resp = await _owner.CosmosContainer.ReadItemAsync>(id, pk, ro, ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return Result>.NotFoundError(); - - return await Result - .Go(IsAuthorized(resp.Resource)) - .When(() => m is IETag etag2 && etag2.ETag != null && ETagGenerator.FormatETag(etag2.ETag) != resp.ETag, () => Result.ConcurrencyError()) - .Then(() => - { - ro.SessionToken = resp.Headers?.Session; - modelUpdater?.Invoke(resp.Resource); - Cleaner.ResetTenantId(m.Value); - ((ICosmosDbValue)resp.Resource).PrepareBefore(dbArgs, GetModelName()); - - // Re-check auth to make sure not updating to something not allowed. - return IsAuthorized(resp); - }) - .ThenAsAsync(async () => - { - Cleaner.PrepareUpdate(resp.Resource.Value); - resp = await _owner.CosmosContainer.ReplaceItemAsync(resp.Resource, id, pk, ro, ct).ConfigureAwait(false); - return GetResponseValue(resp)!; - }); - }, cancellationToken, nameof(UpdateValueWithResultAsync)); - } - - #endregion - - #region Delete - - /// - /// Deletes the model for the specified . - /// - /// The cosmos model . - /// The . - /// The . - public async Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await DeleteWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the model for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => DeleteWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken); - - /// - /// Deletes the model for the specified . - /// - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - public async Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await DeleteWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the model for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => DeleteWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken); - - /// - /// Deletes the model for the specified . - /// - /// The cosmos model . - /// The . - /// The . - /// The . - public async Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await DeleteWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the model for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . - /// The . - public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - { - return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, _owner.GetCosmosId(key), dbArgs, async (_, id, args, ct) => - { - try - { - // Must read the existing to validate. - var ro = args.GetItemRequestOptions(); - var pk = _owner.GetPartitionKey(dbArgs.PartitionKey); - var resp = await _owner.CosmosContainer.ReadItemAsync(id, pk, ro, ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return Result.Success; - - // Delete; either logically or physically. - if (resp.Resource is ILogicallyDeleted ild) - { - if (ild.IsDeleted.HasValue && ild.IsDeleted.Value) - return Result.Success; - - ild.IsDeleted = true; - return await Result - .Go(IsAuthorized(resp.Resource)) - .ThenAsync(async () => - { - ro.SessionToken = resp.Headers?.Session; - await _owner.CosmosContainer.ReplaceItemAsync(Cleaner.PrepareUpdate(resp.Resource), id, pk, ro, ct).ConfigureAwait(false); - return Result.Success; - }); - } - - return await Result - .Go(IsAuthorized(resp.Resource)) - .ThenAsync(async () => - { - ro.SessionToken = resp.Headers?.Session; - await _owner.CosmosContainer.DeleteItemAsync(id, pk, ro, ct).ConfigureAwait(false); - return Result.Success; - }); - } - catch (CosmosException cex) when (cex.StatusCode == System.Net.HttpStatusCode.NotFound) { return Result.NotFoundError(); } - }, cancellationToken, nameof(DeleteWithResultAsync)); - } - - /// - /// Deletes the for the specified . - /// - /// The cosmos model . - /// The . - /// The . - public async Task DeleteValueAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await DeleteWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . - public Task DeleteValueWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => DeleteValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs), key, cancellationToken); - - /// - /// Deletes the for the specified . - /// - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - public async Task DeleteValueAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await DeleteValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . Defaults to . - /// The . - public Task DeleteValueWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => DeleteValueWithResultAsync(new CosmosDbArgs(_owner.DbArgs, partitionKey), key, cancellationToken); - - /// - /// Deletes the for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . - /// The . - public async Task DeleteValueAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - => (await DeleteValueWithResultAsync(dbArgs, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the for the specified with a . - /// - /// The cosmos model . - /// The . - /// The . - /// The . - public Task DeleteValueWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, IEntityKey, new() - { - return _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, _owner.GetCosmosId(key), dbArgs, async (_, id, args, ct) => - { - try - { - // Must read existing to delete and to make sure we are deleting for the correct Type; don't just trust the key. - var ro = args.GetItemRequestOptions(); - var pk = _owner.GetPartitionKey(dbArgs.PartitionKey); - var resp = await _owner.CosmosContainer.ReadItemAsync>(id, pk, ro, ct).ConfigureAwait(false); - if (!IsModelValid(resp.Resource, args, false)) - return Result.Success; - - // Delete; either logically or physically. - if (resp.Resource.Value is ILogicallyDeleted ild) - { - if (ild.IsDeleted.HasValue && ild.IsDeleted.Value) - return Result.Success; - - ild.IsDeleted = true; - return await Result - .Go(IsAuthorized(resp.Resource)) - .ThenAsync(async () => - { - ro.SessionToken = resp.Headers?.Session; - Cleaner.PrepareUpdate(resp.Resource.Value); - await _owner.CosmosContainer.ReplaceItemAsync(resp.Resource, id, pk, ro, ct).ConfigureAwait(false); - return Result.Success; - }); - } - - return await Result - .Go(IsAuthorized(resp.Resource)) - .ThenAsync(async () => - { - ro.SessionToken = resp.Headers?.Session; - await _owner.CosmosContainer.DeleteItemAsync(id, pk, ro, ct).ConfigureAwait(false); - return Result.Success; - }); - } - catch (CosmosException cex) when (cex.StatusCode == System.Net.HttpStatusCode.NotFound) { return Result.NotFoundError(); } - }, cancellationToken, nameof(DeleteValueWithResultAsync)); - } - - #endregion - - #region MultiSet - - /// - /// Executes a multi-dataset query command with one or more . - /// - /// The . - /// One or more . - /// See for further details. - public Task SelectMultiSetAsync(PartitionKey partitionKey, params IMultiSetModelArgs[] multiSetArgs) => SelectMultiSetAsync(partitionKey, multiSetArgs, default); - - /// - /// Executes a multi-dataset query command with one or more . - /// - /// The . - /// One or more . - /// The . - /// See for further details. - public Task SelectMultiSetAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectMultiSetAsync(partitionKey, null, multiSetArgs, cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more . - /// - /// The . - /// The override SQL statement; will default where not specified. - /// One or more . - /// The . - /// See for further details. - public async Task SelectMultiSetAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - => (await SelectMultiSetWithResultAsync(partitionKey, sql, multiSetArgs, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// See for further details. - public Task SelectMultiSetWithResultAsync(PartitionKey partitionKey, params IMultiSetModelArgs[] multiSetArgs) => SelectMultiSetWithResultAsync(partitionKey, multiSetArgs, default); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// The . - /// See for further details. - public Task SelectMultiSetWithResultAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectMultiSetWithResultAsync(partitionKey, null, multiSetArgs, cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// The override SQL statement; will default where not specified. - /// One or more . - /// The . - /// The must be of type . Each is verified and executed in the order specified. - /// The underlying SQL will be automatically created from the specified where not explicitly supplied. Essentially, it is a simple query where all types inferred from the - /// are included, for example: SELECT * FROM c WHERE c.type in ("TypeNameA", "TypeNameB") - /// - public async Task SelectMultiSetWithResultAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - { - // Verify that the multi set arguments are valid for this type of get query. - var multiSetList = multiSetArgs?.ToArray() ?? null; - if (multiSetList == null || multiSetList.Length == 0) - throw new ArgumentException($"At least one {nameof(IMultiSetModelArgs)} must be supplied.", nameof(multiSetArgs)); - - // Build the Cosmos SQL statement. - var name = multiSetList[0].GetModelName(_owner); - var types = new Dictionary([new KeyValuePair(name, multiSetList[0])]); - var sb = string.IsNullOrEmpty(sql) ? new StringBuilder($"\"{name}\"") : null; - - if (sb is not null) - { - for (int i = 1; i < multiSetList.Length; i++) - { - name = multiSetList[i].GetModelName(_owner); - if (!types.TryAdd(name, multiSetList[i])) - throw new ArgumentException($"All {nameof(IMultiSetValueArgs)} must be of different model type.", nameof(multiSetArgs)); - - sb.Append($", \"{name}\""); - } - - sql = string.Format(_owner.MultiSetSqlStatementFormat, sb.ToString()); - } - - // Execute the Cosmos DB query. - var result = await _owner.CosmosDb.Invoker.InvokeAsync(_owner.CosmosDb, _owner, sql, types, async (_, container, sql, types, ct) => - { - // Set up for work. - var da = new CosmosDbArgs(container.DbArgs, partitionKey); - var qsi = container.CosmosContainer.GetItemQueryStreamIterator(sql, requestOptions: da.GetQueryRequestOptions()); - IJsonSerializer js = ExecutionContext.GetService() ?? CoreEx.Json.JsonSerializer.Default; - var isStj = js is Text.Json.JsonSerializer; - - while (qsi.HasMoreResults) - { - var rm = await qsi.ReadNextAsync(ct).ConfigureAwait(false); - if (!rm.IsSuccessStatusCode) - return Result.Fail(new InvalidOperationException(rm.ErrorMessage)); - - var json = Stj.JsonDocument.Parse(rm.Content); - if (!json.RootElement.TryGetProperty("Documents", out var jds) || jds.ValueKind != Stj.JsonValueKind.Array) - return Result.Fail(new InvalidOperationException("Cosmos response JSON 'Documents' property either not found in result or is not an array.")); - - foreach (var jd in jds.EnumerateArray()) - { - if (!jd.TryGetProperty("type", out var jt) || jt.ValueKind != Stj.JsonValueKind.String) - return Result.Fail(new InvalidOperationException("Cosmos response documents item 'type' property either not found in result or is not a string.")); - - if (!types.TryGetValue(jt.GetString()!, out var msa)) - continue; // Ignore any unexpected type. - - var model = isStj - ? jd.Deserialize(msa.Type, (Stj.JsonSerializerOptions)js.Options) - : js.Deserialize(jd.ToString(), msa.Type); - - if (model is null) - return Result.Fail(new InvalidOperationException($"Cosmos response documents item type '{jt.GetRawText()}' deserialization resulted in a null.")); - - var result = msa.AddItem(container, da, model); - if (result.IsFailure) - return result; - } - } - - return Result.Success; - }, cancellationToken).ConfigureAwait(false); - - if (result.IsFailure) - return result; - - // Validate the multi-set args and action each accordingly. - foreach (var msa in multiSetList) - { - var r = msa.Verify(); - if (r.IsFailure) - return r.AsResult(); - - if (!r.Value && msa.StopOnNull) - break; - - msa.Invoke(); - } - - return Result.Success; - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbModelContainerT.cs b/src/CoreEx.Cosmos/Model/CosmosDbModelContainerT.cs deleted file mode 100644 index 3538f65b..00000000 --- a/src/CoreEx.Cosmos/Model/CosmosDbModelContainerT.cs +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Provides a typed interface for the primary operations. - /// - /// The cosmos model . - public class CosmosDbModelContainer where TModel : class, IEntityKey, new() - { - /// - /// Initializes a new instance of the class. - /// - /// The owning . - internal CosmosDbModelContainer(CosmosDbContainer owner) => Owner = owner.ThrowIfNull(nameof(owner)); - - /// - /// Gets the owning . - /// - public CosmosDbContainer Owner { get; } - - /// - /// Gets the . - /// - public Container CosmosContainer => Owner.CosmosContainer; - - #region Query - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The function to perform additional query execution. - /// The . - public CosmosDbModelQuery Query(Func, IQueryable>? query) => Owner.Model.Query(query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbModelQuery Query(PartitionKey? partitionKey = null, Func, IQueryable>? query = null) => Owner.Model.Query(partitionKey, query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbModelQuery Query(CosmosDbArgs dbArgs, Func, IQueryable>? query = null) => Owner.Model.Query(dbArgs, query); - - #endregion - - #region Get - - /// - /// Gets the model for the specified . - /// - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetAsync(key, cancellationToken); - - /// - /// Gets the model for the specified with a . - /// - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetWithResultAsync(key, cancellationToken); - - /// - /// Gets the model for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - /// The model value where found; otherwise, null (see ). - public Task GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.GetAsync(key, partitionKey, cancellationToken); - - /// - /// Gets the model for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - /// The model value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.GetWithResultAsync(key, partitionKey, cancellationToken); - - /// - /// Gets the model for the specified . - /// - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetAsync(dbArgs, key, cancellationToken); - - /// - /// Gets the model for the specified with a . - /// - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetWithResultAsync(dbArgs, key, cancellationToken); - - #endregion - - #region Create - - /// - /// Creates the model. - /// - /// The value to create. - /// The . - /// The created value. - public Task CreateAsync(TModel value, CancellationToken cancellationToken = default) => Owner.Model.CreateAsync(value, cancellationToken); - - /// - /// Creates the model with a . - /// - /// The value to create. - /// The . - /// The created value. - public Task> CreateWithResultAsync(TModel value, CancellationToken cancellationToken = default) => Owner.Model.CreateWithResultAsync(value, cancellationToken); - - /// - /// Creates the model. - /// - /// The . - /// The value to create. - /// The . - /// The created value. - public Task CreateAsync(CosmosDbArgs dbArgs, TModel value, CancellationToken cancellationToken = default) => Owner.Model.CreateAsync(dbArgs, value, cancellationToken); - - /// - /// Creates the model with a . - /// - /// The . - /// The value to create. - /// The . - /// The created value. - public Task> CreateWithResultAsync(CosmosDbArgs dbArgs, TModel value, CancellationToken cancellationToken = default) => Owner.Model.CreateWithResultAsync(dbArgs, value, cancellationToken); - - #endregion - - #region Update - - /// - /// Updates the model. - /// - /// The value to update. - /// The . - /// The updated value. - public Task UpdateAsync(TModel value, CancellationToken cancellationToken = default) => Owner.Model.UpdateAsync(value, cancellationToken); - - /// - /// Updates the model with a . - /// - /// The value to update. - /// The . - /// The updated value. - public Task> UpdateWithResultAsync(TModel value, CancellationToken cancellationToken = default) => Owner.Model.UpdateWithResultAsync(value, cancellationToken); - - /// - /// Updates the model. - /// - /// The . - /// The value to update. - /// The . - /// The updated value. - public Task UpdateAsync(CosmosDbArgs dbArgs, TModel value, CancellationToken cancellationToken = default) => Owner.Model.UpdateAsync(dbArgs, value, cancellationToken); - - #endregion - - #region Delete - - /// - /// Deletes the model for the specified . - /// - /// The . - /// The . - public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteAsync(key, cancellationToken); - - /// - /// Deletes the model for the specified with a . - /// - /// The . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteWithResultAsync(key, cancellationToken); - - /// - /// Deletes the model for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - public Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.DeleteAsync(key, partitionKey, cancellationToken); - - /// - /// Deletes the model for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.DeleteWithResultAsync(key, partitionKey, cancellationToken); - - /// - /// Deletes the model for the specified . - /// - /// The .. - /// The . - /// The . - public Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteAsync(dbArgs, key, cancellationToken); - - /// - /// Deletes the model for the specified with a . - /// - /// The .. - /// The . - /// The . - public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteWithResultAsync(dbArgs, key, cancellationToken); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbModelQuery.cs b/src/CoreEx.Cosmos/Model/CosmosDbModelQuery.cs deleted file mode 100644 index 45d36e62..00000000 --- a/src/CoreEx.Cosmos/Model/CosmosDbModelQuery.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.Azure.Cosmos.Linq; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Encapsulates a CosmosDb model-only query enabling all select-like capabilities. - /// - /// The cosmos model . - /// The . - /// The . - /// A function to modify the underlying . - public class CosmosDbModelQuery(CosmosDbContainer container, CosmosDbArgs dbArgs, Func, IQueryable>? query) : CosmosDbModelQueryBase>(container, dbArgs) where TModel : class, IEntityKey, new() - { - private readonly Func, IQueryable>? _query = query; - - /// - /// Instantiates the . - /// - private IQueryable AsQueryable(bool allowSynchronousQueryExecution, bool pagingSupported) - { - if (!pagingSupported && Paging is not null) - throw new NotSupportedException("Paging is not supported when accessing AsQueryable directly; paging must be applied directly to the resulting IQueryable instance."); - - IQueryable query = Container.CosmosContainer.GetItemLinqQueryable(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); - query = _query == null ? query : _query(query); - - var filter = Container.Model.GetAuthorizeFilter(); - if (filter != null) - query = filter(query); - - return QueryArgs.WhereModelValid(query); - } - - /// - /// Gets a pre-prepared with filtering applied as applicable. - /// - /// The . - /// The is not supported. The query will not be automatically included within an execution. - public IQueryable AsQueryable() => AsQueryable(true, false); - - /// - public override Task SelectQueryWithResultAsync(TColl coll, CancellationToken cancellationToken = default) => Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, coll, async (_, items, ct) => - { - var q = AsQueryable(false, true); - - using var iterator = q.WithPaging(Paging).ToFeedIterator(); - while (iterator.HasMoreResults) - { - foreach (var item in await iterator.ReadNextAsync(ct).ConfigureAwait(false)) - { - items.Add(item); - } - } - - if (Paging != null && Paging.IsGetCount) - Paging.TotalCount = (await q.CountAsync(cancellationToken).ConfigureAwait(false)).Resource; - - return Result.Success; - }, cancellationToken, nameof(SelectQueryWithResultAsync)); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbModelQueryBase.cs b/src/CoreEx.Cosmos/Model/CosmosDbModelQueryBase.cs deleted file mode 100644 index c1f7fc89..00000000 --- a/src/CoreEx.Cosmos/Model/CosmosDbModelQueryBase.cs +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Enables the common CosmosDb model-only query capabilities. - /// - /// The cosmos model . - /// The itself. - /// The . - /// The . - public abstract class CosmosDbModelQueryBase(CosmosDbContainer container, CosmosDbArgs dbArgs) where TModel : new() where TSelf : CosmosDbModelQueryBase - { - /// - /// Gets the . - /// - public CosmosDbContainer Container { get; } = container.ThrowIfNull(nameof(container)); - - /// - /// Gets the . - /// - public CosmosDbArgs QueryArgs = dbArgs; - - /// - /// Gets the . - /// - public PagingResult? Paging { get; protected set; } - - /// - /// Adds to the query. - /// - /// The . - /// The instance to suport fluent-style method-chaining. - public TSelf WithPaging(PagingArgs? paging) - { - Paging = paging == null ? null : paging is PagingResult pr ? pr : new PagingResult(paging); - return (TSelf)this; - } - - /// - /// Adds to the query. - /// - /// The specified number of elements in a sequence to bypass. - /// The specified number of contiguous elements from the start of a sequence. - /// The instance to suport fluent-style method-chaining. - public TSelf WithPaging(long skip, long? take = null) => WithPaging(PagingArgs.CreateSkipAndTake(skip, take)); - - /// - /// Selects a single item. - /// - /// The . - /// The single item. - /// is not supported for this operation. - public async Task SelectSingleAsync(CancellationToken cancellationToken = default) => await SelectSingleWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects a single item with a . - /// - /// The . - /// The single item. - /// is not supported for this operation. - public async Task> SelectSingleWithResultAsync(CancellationToken cancellationToken = default) - { - var result = await SelectArrayWithResultAsync(nameof(SelectSingleAsync), 2, cancellationToken).ConfigureAwait(false); - return result.ThenAs(coll => coll.Single()); - } - - /// - /// Selects a single item or default. - /// - /// The . - /// The single item or default. - /// is not supported for this operation. - public async Task SelectSingleOrDefaultAsync(CancellationToken cancellationToken = default) => await SelectSingleOrDefaultWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects a single item or default with a . - /// - /// The . - /// The single item or default. - /// is not supported for this operation. - public async Task> SelectSingleOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - { - var result = await SelectArrayWithResultAsync(nameof(SelectSingleOrDefaultAsync), 2, cancellationToken).ConfigureAwait(false); - return result.ThenAs(coll => Result.Ok(coll.SingleOrDefault())); - } - - /// - /// Selects first item. - /// - /// The . - /// The first item. - /// is not supported for this operation. - public async Task SelectFirstAsync(CancellationToken cancellationToken = default) => await SelectFirstWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects first item with a . - /// - /// The . - /// The first item. - /// is not supported for this operation. - public async Task> SelectFirstWithResultAsync(CancellationToken cancellationToken = default) - { - var result = await SelectArrayWithResultAsync(nameof(SelectFirstAsync), 1, cancellationToken).ConfigureAwait(false); - return result.ThenAs(coll => coll.First()); - } - - /// - /// Selects first item or default. - /// - /// The . - /// The single item or default. - /// is not supported for this operation. - public async Task SelectFirstOrDefaultAsync(CancellationToken cancellationToken = default) => await SelectFirstOrDefaultWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects first item or default with a . - /// - /// The . - /// The single item or default. - /// is not supported for this operation. - public async Task> SelectFirstOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - { - var result = await SelectArrayWithResultAsync(nameof(SelectFirstOrDefaultAsync), 1, cancellationToken).ConfigureAwait(false); - return result.ThenAs(coll => Result.Ok(coll.FirstOrDefault())); - } - - /// - /// Selects an array by limiting the data retrieved. - /// - private Task> SelectArrayWithResultAsync(string caller, long take, CancellationToken cancellationToken) - { - if (Paging != null) - throw new InvalidOperationException($"The {nameof(Paging)} must be null for a {caller}; internally applied paging is needed to limit unnecessary data retrieval."); - - WithPaging(0, take); - return ToArrayWithResultAsync(cancellationToken); - } - - /// - /// Executes the query command creating a resultant collection. - /// - /// The collection . - /// The . - /// A resultant collection. - /// The is also applied, including where requested. - public async Task SelectQueryAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() => await SelectQueryWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Executes the query command creating a resultant collection with a . - /// - /// The collection . - /// The . - /// A resultant collection. - /// The is also applied, including where requested. - public async Task> SelectQueryWithResultAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() - { - var coll = new TColl(); - var result = await SelectQueryWithResultAsync(coll, cancellationToken).ConfigureAwait(false); - return result.ThenAs(() => coll); - } - - /// - /// Executes the query adding to the passed collection. - /// - /// The collection . - /// The collection to add items to. - /// The . - /// The is also applied, including where requested. - public async Task SelectQueryAsync(TColl coll, CancellationToken cancellationToken = default) where TColl : ICollection => (await SelectQueryWithResultAsync(coll, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes the query adding to the passed collection with a . - /// - /// The collection . - /// The collection to add items to. - /// The . - /// The is also applied, including where requested. - public abstract Task SelectQueryWithResultAsync(TColl coll, CancellationToken cancellationToken = default) where TColl : ICollection; - - /// - /// Executes the query command creating a resultant array. - /// - /// The . - /// A resultant array. - /// The is also applied, including where requested. - public async Task ToArrayAsync(CancellationToken cancellationToken = default) => await ToArrayWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Executes the query command creating a resultant array with a . - /// - /// The . - /// A resultant array. - /// The is also applied, including where requested. - public async Task> ToArrayWithResultAsync(CancellationToken cancellationToken = default) - { - var list = new List(); - var result = await SelectQueryWithResultAsync(list, cancellationToken).ConfigureAwait(false); - return result.ThenAs(() => list.ToArray()); - } - - /// - /// Executes the query command creating a . - /// - /// The . - /// The . - /// The . - /// The resulting . - /// The is also applied, including where requested. - public async Task SelectResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() - => await SelectResultWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Executes the query command creating a with a . - /// - /// The . - /// The . - /// The . - /// The resulting . - /// The is also applied, including where requested. - public async Task> SelectResultWithResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() - { - var result = await SelectQueryWithResultAsync(cancellationToken).ConfigureAwait(false); - return result.ThenAs(coll => new TCollResult { Items = coll, Paging = Paging ?? new PagingResult() }); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbValueModelContainerT.cs b/src/CoreEx.Cosmos/Model/CosmosDbValueModelContainerT.cs deleted file mode 100644 index 8385d21f..00000000 --- a/src/CoreEx.Cosmos/Model/CosmosDbValueModelContainerT.cs +++ /dev/null @@ -1,394 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Json; -using CoreEx.Results; -using Microsoft.Azure.Cosmos; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Stj = System.Text.Json; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Provides a typed interface for the primary operations. - /// - /// The cosmos model . - public sealed class CosmosDbValueModelContainer where TModel : class, IEntityKey, new() - { - /// - /// Initializes a new instance of the class. - /// - /// The owning . - internal CosmosDbValueModelContainer(CosmosDbContainer owner) => Owner = owner.ThrowIfNull(nameof(owner)); - - /// - /// Gets the owning . - /// - public CosmosDbContainer Owner { get; } - - /// - /// Gets the . - /// - public Container CosmosContainer => Owner.CosmosContainer; - - #region Query - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The function to perform additional query execution. - /// The . - public CosmosDbValueModelQuery Query(Func>, IQueryable>>? query) => Owner.Model.ValueQuery(query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueModelQuery Query(PartitionKey? partitionKey = null, Func>, IQueryable>>? query = null) => Owner.Model.ValueQuery(partitionKey, query); - - /// - /// Gets (creates) a to enable LINQ-style queries. - /// - /// The . - /// The function to perform additional query execution. - /// The . - public CosmosDbValueModelQuery Query(CosmosDbArgs dbArgs, Func>, IQueryable>>? query = null) => Owner.Model.ValueQuery(dbArgs, query); - - #endregion - - #region Get - - /// - /// Gets the model (using underlying ) for the specified . - /// - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task?> GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetValueAsync(key, cancellationToken); - - /// - /// Gets the model (using underlying ) for the specified with a . - /// - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task?>> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetValueWithResultAsync(key, cancellationToken); - - /// - /// Gets the model (using underlying ) for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - /// The model value where found; otherwise, null (see ). - public Task?> GetAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.GetValueAsync(key, partitionKey, cancellationToken); - - /// - /// Gets the model (using underlying ) for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - /// The model value where found; otherwise, null (see ). - public Task?>> GetWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.GetValueWithResultAsync(key, partitionKey, cancellationToken); - - /// - /// Gets the model (using underlying ) for the specified . - /// - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task?> GetAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetValueAsync(dbArgs, key, cancellationToken); - - /// - /// Gets the model (using underlying ) for the specified with a . - /// - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null (see ). - public Task?>> GetWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.GetValueWithResultAsync(dbArgs, key, cancellationToken); - - #endregion - - #region Create - - /// - /// Creates the model (using underlying ). - /// - /// The value to create. - /// The . - /// The created value. - public Task> CreateAsync(CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.CreateValueAsync(value, cancellationToken); - - /// - /// Creates the model (using underlying ) with a . - /// - /// The value to create. - /// The . - /// The created value. - public Task>> CreateWithResultAsync(CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.CreateValueWithResultAsync(value, cancellationToken); - - /// - /// Creates the model (using underlying ). - /// - /// The . - /// The value to create. - /// The . - /// The created value. - public Task> CreateAsync(CosmosDbArgs dbArgs, CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.CreateValueAsync(dbArgs, value, cancellationToken); - - /// - /// Creates the model (using underlying ) with a . - /// - /// The . - /// The value to create. - /// The . - /// The created value. - public Task>> CreateWithResultAsync(CosmosDbArgs dbArgs, CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.CreateValueWithResultAsync(dbArgs, value, cancellationToken); - - #endregion - - #region Update - - /// - /// Updates the model (using underlying ). - /// - /// The value to update. - /// The . - /// The updated value. - public Task> UpdateAsync(CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.UpdateValueAsync(value, cancellationToken); - - /// - /// Updates the model (using underlying ) with a . - /// - /// The value to update. - /// The . - /// The updated value. - public Task>> UpdateWithResultAsync(CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.UpdateValueWithResultAsync(value, cancellationToken); - - /// - /// Updates the model (using underlying ). - /// - /// The . - /// The value to update. - /// The . - /// The updated value. - public Task> UpdateAsync(CosmosDbArgs dbArgs, CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.UpdateValueAsync(dbArgs, value, cancellationToken); - - /// - /// Updates the model (using underlying ) with a . - /// - /// The . - /// The value to update. - /// The . - /// The updated value. - public Task>> UpdateWithResultAsync(CosmosDbArgs dbArgs, CosmosDbValue value, CancellationToken cancellationToken = default) => Owner.Model.UpdateValueWithResultAsync(dbArgs, value, cancellationToken); - - #endregion - - #region Delete - - /// - /// Deletes the model (using underlying ) for the specified . - /// - /// The . - /// The . - public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueAsync(key, cancellationToken); - - /// - /// Deletes the model (using underlying ) for the specified with a . - /// - /// The . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueWithResultAsync(key, cancellationToken); - - /// - /// Deletes the model (using underlying ) for the specified . - /// - /// The . - /// The . Defaults to . - /// The . - public Task DeleteAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueAsync(key, partitionKey, cancellationToken); - - /// - /// Deletes the model (using underlying ) for the specified with a . - /// - /// The . - /// The . Defaults to . - /// The . - public Task DeleteWithResultAsync(CompositeKey key, PartitionKey? partitionKey, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueWithResultAsync(key, partitionKey, cancellationToken); - - /// - /// Deletes the model (using underlying ) for the specified . - /// - /// The .. - /// The . - /// The . - public Task DeleteAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueAsync(dbArgs, key, cancellationToken); - - /// - /// Deletes the model (using underlying ) for the specified with a . - /// - /// The .. - /// The . - /// The . - public Task DeleteWithResultAsync(CosmosDbArgs dbArgs, CompositeKey key, CancellationToken cancellationToken = default) => Owner.Model.DeleteValueWithResultAsync(dbArgs, key, cancellationToken); - - #endregion - - #region MultiSet - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// See for further details. - public Task SelectValueMultiSetAsync(PartitionKey partitionKey, params IMultiSetValueArgs[] multiSetArgs) => SelectValueMultiSetAsync(partitionKey, multiSetArgs, default); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// The . - /// See for further details. - public Task SelectValueMultiSetAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectValueMultiSetAsync(partitionKey, null, multiSetArgs, cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// The override SQL statement; will default where not specified. - /// One or more . - /// The . - /// See for further details. - public async Task SelectValueMultiSetAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - => (await SelectValueMultiSetWithResultAsync(partitionKey, sql, multiSetArgs, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// See for further details. - public Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, params IMultiSetValueArgs[] multiSetArgs) => SelectValueMultiSetWithResultAsync(partitionKey, multiSetArgs, default); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// One or more . - /// The . - /// See for further details. - public Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectValueMultiSetWithResultAsync(partitionKey, null, multiSetArgs, cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// The . - /// The override SQL statement; will default where not specified. - /// One or more . - /// The . - /// The must be of type . Each is verified and executed in the order specified. - /// The underlying SQL will be automatically created from the specified where not explicitly supplied. Essentially, it is a simple query where all types inferred from the - /// are included, for example: SELECT * FROM c WHERE c.type in ("TypeNameA", "TypeNameB") - /// - public async Task SelectValueMultiSetWithResultAsync(PartitionKey partitionKey, string? sql, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - { - // Verify that the multi set arguments are valid for this type of get query. - var multiSetList = multiSetArgs?.ToArray() ?? null; - if (multiSetList == null || multiSetList.Length == 0) - throw new ArgumentException($"At least one {nameof(IMultiSetValueArgs)} must be supplied.", nameof(multiSetArgs)); - - // Build the Cosmos SQL statement. - var name = multiSetList[0].GetModelName(Owner); - var types = new Dictionary([new KeyValuePair(name, multiSetList[0])]); - var sb = string.IsNullOrEmpty(sql) ? new StringBuilder($"SELECT * FROM c WHERE c.type in (\"{name}\"") : null; - - for (int i = 1; i < multiSetList.Length; i++) - { - name = multiSetList[i].GetModelName(Owner); - if (!types.TryAdd(name, multiSetList[i])) - throw new ArgumentException($"All {nameof(IMultiSetValueArgs)} must be of different model type.", nameof(multiSetArgs)); - - sb?.Append($", \"{name}\""); - } - - sb?.Append(')'); - - // Execute the Cosmos DB query. - var result = await Owner.CosmosDb.Invoker.InvokeAsync(Owner.CosmosDb, Owner, sb?.ToString() ?? sql, types, async (_, container, sql, types, ct) => - { - // Set up for work. - var da = new CosmosDbArgs(container.DbArgs, partitionKey); - var qsi = CosmosContainer.GetItemQueryStreamIterator(sql, requestOptions: da.GetQueryRequestOptions()); - IJsonSerializer js = ExecutionContext.GetService() ?? CoreEx.Json.JsonSerializer.Default; - var isStj = js is Text.Json.JsonSerializer; - - while (qsi.HasMoreResults) - { - var rm = await qsi.ReadNextAsync(ct).ConfigureAwait(false); - if (!rm.IsSuccessStatusCode) - return Result.Fail(new InvalidOperationException(rm.ErrorMessage)); - - var json = Stj.JsonDocument.Parse(rm.Content); - if (!json.RootElement.TryGetProperty("Documents", out var jds) || jds.ValueKind != Stj.JsonValueKind.Array) - return Result.Fail(new InvalidOperationException("Cosmos response JSON 'Documents' property either not found in result or is not an array.")); - - foreach (var jd in jds.EnumerateArray()) - { - if (!jd.TryGetProperty("type", out var jt) || jt.ValueKind != Stj.JsonValueKind.String) - return Result.Fail(new InvalidOperationException("Cosmos response documents item 'type' property either not found in result or is not a string.")); - - if (!types.TryGetValue(jt.GetString()!, out var msa)) - continue; // Ignore any unexpected type. - - var model = isStj - ? jd.Deserialize(msa.Type, (Stj.JsonSerializerOptions)js.Options) - : js.Deserialize(jd.ToString(), msa.Type); - - if (model is null) - return Result.Fail(new InvalidOperationException($"Cosmos response documents item type '{jt.GetRawText()}' deserialization resulted in a null.")); - - var result = msa.AddItem(Owner, da, model); - if (result.IsFailure) - return result; - } - } - - return Result.Success; - }, cancellationToken).ConfigureAwait(false); - - if (result.IsFailure) - return result; - - // Validate the multi-set args and action each accordingly. - foreach (var msa in multiSetList) - { - var r = msa.Verify(); - if (r.IsFailure) - return r.AsResult(); - - if (!r.Value && msa.StopOnNull) - break; - - msa.Invoke(); - } - - return Result.Success; - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/CosmosDbValueModelQuery.cs b/src/CoreEx.Cosmos/Model/CosmosDbValueModelQuery.cs deleted file mode 100644 index 0b0e2629..00000000 --- a/src/CoreEx.Cosmos/Model/CosmosDbValueModelQuery.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.Azure.Cosmos.Linq; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Encapsulates a CosmosDb model-only query enabling all select-like capabilities. - /// - /// The cosmos model . - /// The . - /// The . - /// A function to modify the underlying . - public class CosmosDbValueModelQuery(CosmosDbContainer container, CosmosDbArgs dbArgs, Func>, IQueryable>>? query) : CosmosDbModelQueryBase, CosmosDbValueModelQuery>(container, dbArgs) where TModel : class, IEntityKey, new() - { - private readonly Func>, IQueryable>>? _query = query; - - /// - /// Instantiates the . - /// - private IQueryable> AsQueryable(bool allowSynchronousQueryExecution, bool pagingSupported) - { - if (!pagingSupported && Paging is not null) - throw new NotSupportedException("Paging is not supported when accessing AsQueryable directly; paging must be applied directly to the resulting IQueryable instance."); - - IQueryable> query = Container.CosmosContainer.GetItemLinqQueryable>(allowSynchronousQueryExecution: allowSynchronousQueryExecution, requestOptions: QueryArgs.GetQueryRequestOptions()); - query = (_query == null ? query : _query(query)).WhereType(Container.Model.GetModelName()); - - var filter = Container.Model.GetValueAuthorizeFilter(); - if (filter != null) - query = filter(query); - - return QueryArgs.WhereModelValid(query); - } - - /// - /// Gets a pre-prepared with filtering applied as applicable. - /// - /// The . - /// The is not supported. The query will not be automatically included within an execution. - public IQueryable> AsQueryable() => AsQueryable(true, false); - - /// - public override Task SelectQueryWithResultAsync(TColl coll, CancellationToken cancellationToken = default) => Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, coll, async (_, items, ct) => - { - var q = AsQueryable(false, true); - - using var iterator = q.WithPaging(Paging).ToFeedIterator(); - while (iterator.HasMoreResults) - { - foreach (var item in await iterator.ReadNextAsync(ct).ConfigureAwait(false)) - { - items.Add(item); - } - } - - if (Paging != null && Paging.IsGetCount) - Paging.TotalCount = (await q.CountAsync(cancellationToken).ConfigureAwait(false)).Resource; - - return Result.Success; - }, cancellationToken, nameof(SelectQueryWithResultAsync)); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/IMultiSetArgs.cs b/src/CoreEx.Cosmos/Model/IMultiSetArgs.cs deleted file mode 100644 index 77e0d99d..00000000 --- a/src/CoreEx.Cosmos/Model/IMultiSetArgs.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Results; -using System; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Enables the CosmosDb multi-set arguments. - /// - public interface IMultiSetArgs - { - /// - /// Gets the minimum number of items allowed. - /// - int MinItems { get; } - - /// - /// Gets the maximum number of items allowed. - /// - int? MaxItems { get; } - - /// - /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). - /// - bool StopOnNull { get; } - - /// - /// Gets the model . - /// - Type Type { get; } - - /// - /// Adds a model for its respective dataset. - /// - /// The . - /// The . - /// The model item. - Result AddItem(CosmosDbContainer container, CosmosDbArgs dbArgs, object item); - - /// - /// Verify against contraints. - /// - /// true indicates that at least one item exists to action; otherwise, false. - Result Verify(); - - /// - /// Invokes the underlying action. - /// - void Invoke(); - - /// - /// Gets the name for the model . - /// - /// The . - /// The model name. - string GetModelName(CosmosDbContainer container); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/IMultiSetModelArgs.cs b/src/CoreEx.Cosmos/Model/IMultiSetModelArgs.cs deleted file mode 100644 index 8ba74e4d..00000000 --- a/src/CoreEx.Cosmos/Model/IMultiSetModelArgs.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Cosmos.Model -{ - /// - /// Enables the model with multi-set arguments. - /// - public interface IMultiSetModelArgs : IMultiSetArgs { } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/MultiSetModelCollArgs.cs b/src/CoreEx.Cosmos/Model/MultiSetModelCollArgs.cs deleted file mode 100644 index c1cadd1d..00000000 --- a/src/CoreEx.Cosmos/Model/MultiSetModelCollArgs.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Provides the CosmosDb when expecting a collection of items. - /// - /// The cosmos model . - public class MultiSetModelCollArgs : IMultiSetModelArgs where TModel : class, ICosmosDbType, IEntityKey, new() - { - private List? _items; - private readonly Action> _result; - - /// - /// Initializes a new instance of the class. - /// - /// The action that will be invoked with the result of the set. - /// The minimum number of items allowed. - /// The maximum numner of items allowed. - /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). - public MultiSetModelCollArgs(Action> result, int minItems = 0, int? maxItems = null, bool stopOnNull = false) - { - _result = result.ThrowIfNull(nameof(result)); - if (maxItems.HasValue && minItems <= maxItems.Value) - throw new ArgumentException("Max Items is less than Min Items.", nameof(maxItems)); - - MinItems = minItems; - MaxItems = maxItems; - StopOnNull = stopOnNull; - } - - /// - public int MinItems { get; } - - /// - public int? MaxItems { get; } - - /// - public bool StopOnNull { get; set; } - - /// - Type IMultiSetArgs.Type => typeof(TModel); - - /// - Result IMultiSetArgs.AddItem(CosmosDbContainer container, CosmosDbArgs dbArgs, object item) - => MultiSetModelSingleArgs.Validate(container, dbArgs, (TModel)item) - .WhenAs(m => m is not null, m => - { - _items ??= []; - _items.Add(m); - return !MaxItems.HasValue || _items.Count <= MaxItems.Value - ? Result.Success - : Result.Fail(new InvalidOperationException($"MultiSetCollArgs has returned more items ({_items.Count}) than expected ({MaxItems.Value}).")); - }); - - /// - Result IMultiSetArgs.Verify() - { - var count = _items?.Count ?? 0; - if (count < MinItems) - return Result.Fail(new InvalidOperationException($"MultiSetCollArgs has returned less items ({count}) than expected ({MinItems}).")); - - return count > 0; - } - - /// - void IMultiSetArgs.Invoke() - { - if (_items is not null) - _result(_items.AsEnumerable()); - } - - /// - string IMultiSetArgs.GetModelName(CosmosDbContainer container) => container.Model.GetModelName(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/Model/MultiSetModelSingleArgs.cs b/src/CoreEx.Cosmos/Model/MultiSetModelSingleArgs.cs deleted file mode 100644 index a5b67502..00000000 --- a/src/CoreEx.Cosmos/Model/MultiSetModelSingleArgs.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Collections.Generic; - -namespace CoreEx.Cosmos.Model -{ - /// - /// Provides the CosmosDb when expecting a single item only. - /// - /// The cosmos model . - /// The action that will be invoked with the result of the set. - /// Indicates whether the value is mandatory; defaults to true. - /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). - public class MultiSetModelSingleArgs(Action result, bool isMandatory = true, bool stopOnNull = false) : IMultiSetModelArgs where TModel : class, ICosmosDbType, IEntityKey, new() - { - private List? _items; - private readonly Action _result = result.ThrowIfNull(nameof(result)); - - /// - /// Indicates whether the value is mandatory; i.e. a corresponding record must be read. - /// - public bool IsMandatory { get; set; } = isMandatory; - - /// - public int MinItems => IsMandatory ? 1 : 0; - - /// - public int? MaxItems => 1; - - /// - public bool StopOnNull { get; set; } = stopOnNull; - - /// - Type IMultiSetArgs.Type => typeof(TModel); - - /// - Result IMultiSetArgs.AddItem(CosmosDbContainer container, CosmosDbArgs dbArgs, object item) - => Validate(container, dbArgs, (TModel)item) - .WhenAs(v => v is not null, v => - { - _items ??= []; - _items.Add(v); - return !MaxItems.HasValue || _items.Count <= MaxItems.Value - ? Result.Success - : Result.Fail(new InvalidOperationException($"MultiSetSingleArgs has returned more items ({_items.Count}) than expected ({MaxItems.Value}).")); - }); - - /// - /// Validate and map the to the . - /// - /// The . - /// The . - /// The value. - /// The validated and converted value. - internal static Result Validate(CosmosDbContainer container, CosmosDbArgs dbArgs, TModel model) - { - if (!container.Model.IsModelValid(model, dbArgs, true)) - return Result.Success; - - return model; - } - - /// - Result IMultiSetArgs.Verify() - { - var count = _items?.Count ?? 0; - if (count < MinItems) - return Result.Fail(new InvalidOperationException($"MultiSetSingleArgs has returned less items ({count}) than expected ({MinItems}).")); - - return count > 0; - } - - /// - void IMultiSetArgs.Invoke() - { - if (_items is not null && _items.Count == 1) - _result(_items[0]); - } - - /// - string IMultiSetArgs.GetModelName(CosmosDbContainer container) => container.Model.GetModelName(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/MultiSetValueCollArgs.cs b/src/CoreEx.Cosmos/MultiSetValueCollArgs.cs deleted file mode 100644 index df64215f..00000000 --- a/src/CoreEx.Cosmos/MultiSetValueCollArgs.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Cosmos.Model; -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CoreEx.Cosmos -{ - /// - /// Provides the CosmosDb when expecting a collection of items. - /// - /// The entity . - /// The cosmos model . - public class MultiSetValueCollArgs : IMultiSetValueArgs where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - private List? _items; - private readonly Action> _result; - - /// - /// Initializes a new instance of the class. - /// - /// The action that will be invoked with the result of the set. - /// The minimum number of items allowed. - /// The maximum numner of items allowed. - /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). - public MultiSetValueCollArgs(Action> result, int minItems = 0, int? maxItems = null, bool stopOnNull = false) - { - _result = result.ThrowIfNull(nameof(result)); - if (maxItems.HasValue && minItems <= maxItems.Value) - throw new ArgumentException("Max Items is less than Min Items.", nameof(maxItems)); - - MinItems = minItems; - MaxItems = maxItems; - StopOnNull = stopOnNull; - } - - /// - public int MinItems { get; } - - /// - public int? MaxItems { get; } - - /// - public bool StopOnNull { get; set; } - - /// - Type IMultiSetArgs.Type => typeof(CosmosDbValue); - - /// - Result IMultiSetArgs.AddItem(CosmosDbContainer container, CosmosDbArgs dbArgs, object item) - => MultiSetValueSingleArgs.ValidateAndMap(container, dbArgs, (CosmosDbValue)item) - .WhenAs(v => v is not null, v => - { - _items ??= []; - _items.Add(v); - return !MaxItems.HasValue || _items.Count <= MaxItems.Value - ? Result.Success - : Result.Fail(new InvalidOperationException($"MultiSetCollArgs has returned more items ({_items.Count}) than expected ({MaxItems.Value}).")); - }); - - /// - Result IMultiSetArgs.Verify() - { - var count = _items?.Count ?? 0; - if (count < MinItems) - return Result.Fail(new InvalidOperationException($"MultiSetCollArgs has returned less items ({count}) than expected ({MinItems}).")); - - return count > 0; - } - - /// - void IMultiSetArgs.Invoke() - { - if (_items is not null) - _result(_items.AsEnumerable()); - } - - /// - string IMultiSetArgs.GetModelName(CosmosDbContainer container) => container.Model.GetModelName(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/MultiSetValueSingleArgs.cs b/src/CoreEx.Cosmos/MultiSetValueSingleArgs.cs deleted file mode 100644 index c9d15a04..00000000 --- a/src/CoreEx.Cosmos/MultiSetValueSingleArgs.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Cosmos.Model; -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Collections.Generic; - -namespace CoreEx.Cosmos -{ - /// - /// Provides the CosmosDb when expecting a single item only. - /// - /// The entity . - /// The cosmos model . - /// The action that will be invoked with the result of the set. - /// Indicates whether the value is mandatory; defaults to true. - /// Indicates whether to stop further result set processing where the current set has resulted in a null (i.e. no items). - public class MultiSetValueSingleArgs(Action result, bool isMandatory = true, bool stopOnNull = false) : IMultiSetValueArgs where T : class, IEntityKey, new() where TModel : class, IEntityKey, new() - { - private List? _items; - private readonly Action _result = result.ThrowIfNull(nameof(result)); - - /// - /// Indicates whether the value is mandatory; i.e. a corresponding record must be read. - /// - public bool IsMandatory { get; set; } = isMandatory; - - /// - public int MinItems => IsMandatory ? 1 : 0; - - /// - public int? MaxItems => 1; - - /// - public bool StopOnNull { get; set; } = stopOnNull; - - /// - Type IMultiSetArgs.Type => typeof(CosmosDbValue); - - /// - Result IMultiSetArgs.AddItem(CosmosDbContainer container, CosmosDbArgs dbArgs, object item) - => ValidateAndMap(container, dbArgs, (CosmosDbValue)item) - .WhenAs(v => v is not null, v => - { - _items ??= []; - _items.Add(v); - return !MaxItems.HasValue || _items.Count <= MaxItems.Value - ? Result.Success - : Result.Fail(new InvalidOperationException($"MultiSetSingleArgs has returned more items ({_items.Count}) than expected ({MaxItems.Value}).")); - }); - - /// - /// Validate and map the to the . - /// - /// The . - /// The . - /// The . - /// The validated and converted value. - internal static Result ValidateAndMap(CosmosDbContainer container, CosmosDbArgs dbArgs, CosmosDbValue model) - { - if (!container.Model.IsModelValid(model, dbArgs, true)) - return Result.Success; - - return container.MapToValue(model, dbArgs); - } - - /// - Result IMultiSetArgs.Verify() - { - var count = _items?.Count ?? 0; - if (count < MinItems) - return Result.Fail(new InvalidOperationException($"MultiSetSingleArgs has returned less items ({count}) than expected ({MinItems}).")); - - return count > 0; - } - - /// - void IMultiSetArgs.Invoke() - { - if (_items is not null && _items.Count == 1) - _result(_items[0]); - } - - /// - string IMultiSetArgs.GetModelName(CosmosDbContainer container) => container.Model.GetModelName(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Cosmos/README.md b/src/CoreEx.Cosmos/README.md deleted file mode 100644 index f79f80c8..00000000 --- a/src/CoreEx.Cosmos/README.md +++ /dev/null @@ -1,218 +0,0 @@ -# CoreEx.Cosmos - -The `CoreEx.Cosmos` namespace provides extended [_Azure Cosmos DB_](https://learn.microsoft.com/en-us/azure/cosmos-db/) capabilities, specifically focused on the [API for NoSQL](https://learn.microsoft.com/en-us/azure/cosmos-db/choose-api#api-for-nosql). - -
- -## Motivation - -The motivation is to provide supporting Cosmos DB capabilities for [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) related access that support standardized _CoreEx_ data access patterns. This for the most part will simplify and unify the approach to ensure consistency of implementation where needed. - -
- -## Requirements - -The requirements for usage are as follows. -- An **entity** (DTO) that represents the data that must as a minimum implement [`IEntityKey`](../CoreEx/Entities/IEntityKey.cs); generally via either the implementation of [`IIdentifier`](../CoreEx/Entities/IIdentifierT.cs) or [`IPrimaryKey`](../CoreEx/Entities/IPrimaryKey.cs). -- A **model** being the underlying data representation that will be persisted within Cosmos DB itself. -- An [`IMapper`](../CoreEx/Mapping/IMapper.cs) that contains the mapping logic to map to and from the **entity** and **model**. - -The **entity** and **model** are different types to encourage separation between the externalized **entity** representation and the underlying **model**; which may be shaped differently, and have different property naming conventions, internalized properties, etc. - -
- -## Railway-oriented programming - -To support [railway-oriented programming](../CoreEx/Results/README.md) whenever a method name includes `WithResult` this indicates that it will return a `Result` or `Result` including the resulting success or failure information. In these instances an `Exception` will only be thrown when considered truly exceptional. - -
- -## Resource model - -This article provides a good overview of the [Azure Cosmos DB resource model](https://learn.microsoft.com/en-us/azure/cosmos-db/resource-model); these concepts are important to understand when working with Cosmos DB. - -_CoreEx_ provides encapsulated capabilities for each of the following: -- [Databases](#Database) - contains one-or-more Containers. -- [Containers](#Containers) (Collections) - contains one-or-more Items. -- [Items](#Items) (Documents) - the JSON object being persisted. - -Each of the above are further described, in reverse order, as this is intended to make it easier to understand. - -
- -## Items - -From a Cosmos DB perspective, an Item (aka Document) is a JSON object that represents the data that is being persisted. The are two key patterns for persisting a Document that _CoreEx_ enables: - -- **Untyped** - where a single Document _type_ is persisted within the Container; being one or more Documents of the same _type_ (schema/structure). This is the simpliest pattern, and requires no special support to enable; i.e. works out-of-the-box. -- **Typed** - where one or more Document _types_ are persisted within the Container; being one or more Documents of different _types_ (schema/structure). This is a more complex pattern, and requires additional support to enable - this is enabled in a consistent manner via the [`CosmosDbValue`](./CosmosDbValue.cs) class. - -The **Typed** document JSON structure is as follows (standard Cosmos DB properties have been removed for brevity purposes): - -``` json -{ - "type": "document-type-name", # The unique name of the document type; used to query/filter the documents. - "value": { # The actual document _model_ data. - "property1": "value1", - "property2": "value2" - } -} -``` - -
- -## Containers - -From a Cosmos DB perspective, a [Container](https://learn.microsoft.com/en-us/azure/cosmos-db/resource-model#azure-cosmos-db-containers) is a logical entity that represents a collection of items. The are two key patterns for interacting with a container that _CoreEx_ enables: -- **Entity** - pattern in which there is separation between the externalized **entity** and the underlying **model**, and the requisite mapping between the two is fully integrated. This is the preferred pattern as it allows for a clear separation of concerns. These capabilities largely exist in the root `CoreEx.Cosmos` namespace. -- **Model** - pattern in which the persisted **model** is directly interacted with, with the expectation that the developer would handle any mapping manually. This is useful in scenarios where the full **entity** is an overhead to the operations that needs to be performed. These capabilities largely exist in the `CoreEx.Cosmos.Model` namespace. - -A Cosmos DB Container is encapsulated within one of the following _CoreEx_ capabilities depending on the patterns required: - -Type | Container Pattern | Document Pattern | [`IMapper`](../CoreEx/Mapping/IMapper.cs) support --|-|-|- -[`CosmosDbContainer`](CosmosDbContainer.cs) | Entity | Untyped | Yes -[`CosmosDbValueContainer`](CosmosDbValueContainer.cs) | Entity | Typed | Yes -[`CosmosDbModelContainer`](Model/CosmosDbModelContainer.cs) | Model | Untyped | No -[`CosmosDbValueModelContainer`](Model/CosmosDbValueModelContainer.cs) | Model | Typed | No - -Where more advanced CosmosDB capabilities are required, for example, Partitioning, etc., then the [`CosmosDbArgs`](./CosmosDbArgs.cs) enables the configuration of these capabilities, as well as other extended _CoreEx_ capabilities such as multi-tenancy support. - -Additionally, given how important Partitioning is to Cosmos DB performance, many methods have been provided with an optional `partitionKey` parameter to enable the developer to specify the partition key for the operation. - -Finally, where a Container contains multiple typed documents, an advanced query capability is provided to select and return one-or-more types in a single performant operation; see [`CosmosDb.SelectMultiSetWithResultAsync`](./CosmosDb.cs). The [`SelectMultiSetAsync`](../../tests/CoreEx.Cosmos.Test/CosmosDbContainerPartitioningTest.cs) unit test provides example usage. - -
- -## Database - -From a Cosmos DB perspective, a [Database](https://learn.microsoft.com/en-us/azure/cosmos-db/resource-model#azure-cosmos-db-databases) is a means to group one-or-more Containers. - -The [`ICosmoDb`](./ICosmosDb.cs) and corresponding [`CosmosDb`](./CosmosDb.cs) provides the base Database capabilities: -- `Container()` - instantiates a `CosmosDbContainer` instance. -- `ValueContainer()` - instantiates a `CosmosDbValueContainer` instance. -- `ModelContainer()` - instantiates a `CosmosDbModelContainer` instance. -- `ValueModelContainer()` - instantiates a `CosmosDbValueModelContainer` instance. -- `UserAuhtorizeFilter()` - enables an authorization filter to be applied to a specified Container. - -The following represents an example usage of the [`CosmosDb`](https://github.com/Avanade/Beef/blob/master/samples/Cdr.Banking/Cdr.Banking.Business/Data/CosmosDb.cs) class: - -``` csharp -public class CosmosDb : CoreEx.Cosmos.CosmosDb -{ - private readonly Lazy> _accounts; - private readonly Lazy> _accountDetails; - private readonly Lazy> _transactions; - - /// - /// Initializes a new instance of the class. - /// - public CosmosDb(Mac.Database database, IMapper mapper) : base(database, mapper) - { - // Apply an authorization filter to all operations to ensure only the valid data is available based on the users context; i.e. only allow access to Accounts within list defined on ExecutionContext. - UseAuthorizeFilter("Account", (q) => ((IQueryable)q).Where(x => ExecutionContext.Current.Accounts.Contains(x.Id!))); - UseAuthorizeFilter("Transaction", (q) => ((IQueryable)q).Where(x => ExecutionContext.Current.Accounts.Contains(x.AccountId!))); - - // Lazy create the containers. - _accounts = new(() => Container("Account")); - _accountDetails = new(() => Container("Account")); - _transactions = new(() => Container("Transaction")); - } - - /// - /// Exposes entity from Account container. - /// - public CosmosDbContainer Accounts => _accounts.Value; - - /// - /// Exposes entity from Account container. - /// - public CosmosDbContainer AccountDetails => _accountDetails.Value; - - /// - /// Exposes entity from Account container. - /// - public CosmosDbContainer Transactions => _transactions.Value; -} -``` - -
- -## CRUD capabilities - -The **entity** [`ICosmosDbContainer`](./ICosmosDbContainerT.cs) and **model** [`CosmosDbModelContainer`](./Model/CosmosDbModelContainer.cs) provides the base CRUD capabilities as follows. - -
- -### Query (Read) - -A query is actioned using the [`CosmosDbQuery`](./CosmosDbQuery.cs) and [`CosmosDbModelQuery`](./Model/CosmosDbModelQuery.cs) which is ostensibly a lightweight wrapper over an `IQueryable` that automatically maps from the **model** to the **entity** (where applicable). - -Uses the [`Container.GetItemLinqQueryable`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.container.getitemlinqqueryable?view=azure-dotnet) internally to create. - -The following methods provide additional capabilities: - -Method | Description --|- -`WithPaging` | Adds `Skip` and `Take` paging to the query. -`SelectSingleAsync`, `SelectSingleWithResult` | Selects a single item. -`SelectSingleOrDefaultAsync`, `SelectSingleOrDefaultWithResultAsync` | Selects a single item or default. -`SelectFirstAsync`, `SelectFirstWithResultAsync` | Selects first item. -`SelectFirstOrDefaultAsync`, `SelectFirstOrDefaultWithResultAsync` | Selects first item or default. -`SelectQueryAsync`, `SelectQueryWithResultAsync` | Select items into or creating a resultant collection. -`SelectResultAsync`, `SelectResultWithResultAsync` | Select items creating a [`ICollectionResult`](../CoreEx/Entities/ICollectionResultT2.cs) which also contains corresponding [`PagingResult`](../CoreEx/Entities/PagingResult.cs). -`ToArrayAsync`, `ToArrayWithResultAsync` | Select items into a resulting array. - -
- -### Get (Read) - -Gets (`GetAsync` or `GetWithResultAsync`) the **entity** for the specified key mapping from the **model**. Uses the [`Container.ReadItemAsync`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.container.readitemasync?view=azure-dotnet) internally for the **model** and specified key. - -Where the data is not found, then a `null` will be returned. Where the **model** implements [`ILogicallyDeleted`](../CoreEx/Entities/ILogicallyDeleted.cs) and `IsDeleted` then this acts as if not found and returns a `null`. - -
- -### Create - -Creates (`CreateAsync` or `CreateWithResultAsync`) the **entity** by firstly mapping to the **model**. Uses the [`Container.CreateItemAsync`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.container.createitemasync?view=azure-dotnet) internally to create. - -Where the **entity** implements [`IChangeLogAuditLog`](../CoreEx/Entities/IChangeLogAuditLog.cs) generally via [`ChangeLog`](../CoreEx/Entities/IChangeLog.cs) or [`ChangeLogEx`](../CoreEx/Entities/Extended/IChangeLogEx.cs), then the `CreatedBy` and `CreatedDate` properties will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -Where the **entity** and/or **model** implements [`ITenantId`](../CoreEx/Entities/ITenantId.cs) then the `TenantId` property will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -The inserted **model** is then re-mapped to the **entity** and returned; this will ensure all properties updated as part of the _insert_ are included in the refreshed **entity**. - -
- -### Update - -Updates (`UpdateAsync` or `UpdateWithResultAsync`) the **entity** by firstly mapping to the **model**. Uses the [`Container.ReplaceItemAsync`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.container.replaceitemasync?view=azure-dotnet) internally to update. - -First will check existence of the **model** by performing a [`Container.ReadItemAsync`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.container.readitemasync?view=azure-dotnet). Where the data is not found, then a [`NotFoundException`](../CoreEx/NotFoundException.cs) will be thrown. Where the **model** implements [`ILogicallyDeleted`](../CoreEx/Entities/ILogicallyDeleted.cs) and `IsDeleted` then this acts as if not found and will also result in a `NotFoundException`. - -Where the entity implements [`IETag`](../CoreEx/Entities/IETag.cs) this will be checked against the just read version, and where not matched a [`ConcurrencyException`](../CoreEx/ConcurrencyException.cs) will be thrown. Also, any [`CosmosException`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.cosmosexception?view=azure-dotnet) with a `HttpStatusCode.PreconditionFailed` thrown will be converted to a corresponding `ConcurrencyException` for consistency. - -Where the **entity** implements [`IChangeLogAuditLog`](../CoreEx/Entities/IChangeLogAuditLog.cs) generally via [`ChangeLog`](../CoreEx/Entities/IChangeLog.cs) or [`ChangeLogEx`](../CoreEx/Entities/Extended/IChangeLogEx.cs), then the `UpdatedBy` and `UpdatedDate` properties will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -Where the **entity** and/or **model** implements [`ITenantId`](../CoreEx/Entities/ITenantId.cs) then the `TenantId` property will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -The updated **model** is then re-mapped to the **entity** and returned; this will ensure all properties updated as part of the _update_ are included in the refreshed **entity**. - -
- -### Delete - -Deletes (`DeleteAsync` or `DeleteWithResultAsync`) the **entity**/**model** either physically or logically. - -First will check existence of the **model** by performing a [`Container.ReadItemAsync`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.container.readitemasync?view=azure-dotnet). Where the data is not found, then a [`NotFoundException`](../CoreEx/NotFoundException.cs) will be thrown. Where the **model** implements [`ILogicallyDeleted`](../CoreEx/Entities/ILogicallyDeleted.cs) and `IsDeleted` then this acts as if not found and will also result in a `NotFoundException`. - -Where the **model** implements [`ILogicallyDeleted`](../CoreEx/Entities/ILogicallyDeleted.cs) then an update will occur after setting `IsDeleted` to `true`. Uses the [`Container.ReplaceItemAsync`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.container.replaceitemasync?view=azure-dotnet) internally to update. - -Otherwise, will physically delete. Uses the [`Container.DeleteItemAsync`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.container.deleteitemasync?view=azure-dotnet) internally to delete. - -
- -## Usage - -Review the unit tests and/or _Beef_ [Cdr.Banking](https://github.com/Avanade/Beef/tree/master/samples/Cdr.Banking) sample implementation. \ No newline at end of file diff --git a/src/CoreEx.Cosmos/strong-name-key.snk b/src/CoreEx.Cosmos/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Data/CoreEx.Data.csproj b/src/CoreEx.Data/CoreEx.Data.csproj index a3660597..58aa7eb4 100644 --- a/src/CoreEx.Data/CoreEx.Data.csproj +++ b/src/CoreEx.Data/CoreEx.Data.csproj @@ -1,22 +1,9 @@  - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx.Data - CoreEx - CoreEx .NET Data extras. - CoreEx .NET Data extras. - coreex api data odata filter order linq - - - - - + - + - + - diff --git a/src/CoreEx.Data/DataResult.cs b/src/CoreEx.Data/DataResult.cs new file mode 100644 index 00000000..e7f0a13c --- /dev/null +++ b/src/CoreEx.Data/DataResult.cs @@ -0,0 +1,44 @@ +namespace CoreEx.Data; + +/// +/// Represents the result of a data mutation without a value; typically, a . +/// +public readonly record struct DataResult +{ + /// + /// Initializes a new instance of the struct. + /// + /// Indicates whether the value was mutated; e.g. created, updated or deleted. + public DataResult(bool wasMutated) => WasMutated = wasMutated; + + /// + /// Gets a indicating is . + /// + public static DataResult True { get; } = new DataResult(true); + + /// + /// Gets a indicating is . + /// + public static DataResult False { get; } = new DataResult(false); + + /// + /// Indicates whether the value was mutated; e.g. created, updated or deleted. + /// + /// A would be returned, for example, as follows: + /// + /// A operation where the specified item to delete was not found. + /// An operation where no changes were made to the underlying data. + /// + public bool WasMutated { get; } + + /// + /// Invokes the specified where the result is . + /// + /// The action to execute. + /// This is a convenience method to allow fluent-style method-chaining. + public readonly void WhereMutated(Action action) + { + if (WasMutated) + action?.Invoke(); + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/DataResultT.cs b/src/CoreEx.Data/DataResultT.cs new file mode 100644 index 00000000..6d6c9459 --- /dev/null +++ b/src/CoreEx.Data/DataResultT.cs @@ -0,0 +1,54 @@ +namespace CoreEx.Data; + +/// +/// Represents the result of a data mutation with a ; typically, a or . +/// +public readonly record struct DataResult +{ + /// + /// Initializes a new instance of the struct. + /// + /// The data operation result value. + /// Indicates whether the value was mutated; e.g. created, updated or deleted. + public DataResult(T value, bool wasMutated = true) + { + Value = value; + WasMutated = wasMutated; + } + + /// + /// Gets the data operation result value. + /// + public T Value { get; } + + /// + /// Indicates whether the value was mutated; e.g. created, updated or deleted. + /// + /// A would be returned, for example, as follows: + /// + /// A operation where the specified item to delete was not found. + /// An operation where no changes were made to the underlying data. + /// + public bool WasMutated { get; } + + /// + /// Invokes the specified where the result is . + /// + /// The action to execute. + /// The . + /// This is a convenience method to allow fluent-style method-chaining. + public readonly T WhereMutated(Action action) + { + if (WasMutated) + action?.Invoke(Value); + + return Value; + } + + /// + /// Implicitly converts a to a . + /// + /// The . + /// The . + public static implicit operator T(DataResult result) => result.Value; +} \ No newline at end of file diff --git a/src/CoreEx.Data/GlobalUsing.cs b/src/CoreEx.Data/GlobalUsing.cs new file mode 100644 index 00000000..dfbe3e60 --- /dev/null +++ b/src/CoreEx.Data/GlobalUsing.cs @@ -0,0 +1,14 @@ +global using CoreEx.Abstractions; +global using CoreEx.Data.Querying; +global using CoreEx.Data.Querying.Expressions; +global using CoreEx.Entities; +global using CoreEx.Events.Publishing; +global using CoreEx.Http; +global using CoreEx.RefData; +global using CoreEx.RefData.Abstractions; +global using CoreEx.Results; +global using CoreEx.Results.Abstractions; +global using System.Diagnostics.CodeAnalysis; +global using System.Linq.Dynamic.Core; +global using System.Text; +global using System.Text.Json; \ No newline at end of file diff --git a/src/CoreEx.Data/IDataArgs.cs b/src/CoreEx.Data/IDataArgs.cs new file mode 100644 index 00000000..89b6af4e --- /dev/null +++ b/src/CoreEx.Data/IDataArgs.cs @@ -0,0 +1,6 @@ +namespace CoreEx.Data; + +/// +/// Enables data respository specific arguments. +/// +public interface IDataArgs { } \ No newline at end of file diff --git a/src/CoreEx.Data/IUnitOfWork.WithoutCancellation.cs b/src/CoreEx.Data/IUnitOfWork.WithoutCancellation.cs new file mode 100644 index 00000000..46367fb5 --- /dev/null +++ b/src/CoreEx.Data/IUnitOfWork.WithoutCancellation.cs @@ -0,0 +1,34 @@ +namespace CoreEx.Data; + +public partial interface IUnitOfWork +{ + /// + /// Executes either a new or flows an existing transaction managing its lifetime and underlying execution. + /// + /// The work to be executed within the transaction. + public Task ExecuteAsync(Func work) => TransactionAsync(async _ => await work().ConfigureAwait(false), default); + + /// + /// Executes either a new or flows an existing transaction managing its lifetime and underlying execution that returns a value. + /// + /// The resulting value . + /// The work to be executed within the transaction. + /// The resulting value. + public Task ExecuteAsync(Func> work) => TransactionAsync(async _ => await work().ConfigureAwait(false), default); + + /// + /// Executes either a new or flows an existing transaction managing its lifetime and underlying execution. + /// + /// The . + /// The work to be executed within the transaction. + public Task ExecuteAsync(IDataArgs args, Func work) => TransactionAsync(args, async _ => await work().ConfigureAwait(false), default); + + /// + /// Executes either a new or flows an existing transaction managing its lifetime and underlying execution that returns a value. + /// + /// The resulting value . + /// The . + /// The work to be executed within the transaction. + /// The resulting value. + public Task ExecuteAsync(IDataArgs args, Func> work) => TransactionAsync(args, async _ => await work().ConfigureAwait(false), default); +} \ No newline at end of file diff --git a/src/CoreEx.Data/IUnitOfWork.cs b/src/CoreEx.Data/IUnitOfWork.cs new file mode 100644 index 00000000..ed0d7437 --- /dev/null +++ b/src/CoreEx.Data/IUnitOfWork.cs @@ -0,0 +1,56 @@ +namespace CoreEx.Data; + +/// +/// Enables standardized repository-agnostic transactional unit-of-work orchestration. +/// +/// Also, includes where supporting a transactional outbox. +/// The method overloads are enabled for specific advanced/configurable scenarios which would typically be rare. The consumer will need to ensure that the correct is provided. +/// Where implementing this interface the resulting value should be checked to determines if it is an ; if so, and then this should rollback in the same manner +/// that would occur where an had been thrown. +public partial interface IUnitOfWork +{ + /// + /// Indicates whether are supported; i.e. a transactional outbox. + /// + bool AreEventsSupported { get; } + + /// + /// Gets the for managing events (transactional outbox) within the unit-of-work. + /// + /// Should throw a where is . + IEventQueue Events { get; } + + /// + /// Orchestrates either a new or flows an existing transaction managing its lifetime and underlying execution. + /// + /// The work to be executed within the transaction. + /// The . + Task TransactionAsync(Func work, CancellationToken cancellationToken = default); + + /// + /// Orchestrates either a new or flows an existing transaction managing its lifetime and underlying execution that returns a value. + /// + /// The resulting value . + /// The work to be executed within the transaction. + /// The . + /// The resulting value. + Task TransactionAsync(Func> work, CancellationToken cancellationToken = default); + + /// + /// Orchestrates either a new or flows an existing transaction managing its lifetime and underlying execution. + /// + /// The . + /// The work to be executed within the transaction. + /// The . + Task TransactionAsync(IDataArgs args, Func work, CancellationToken cancellationToken = default); + + /// + /// Orchestrates either a new or flows an existing transaction managing its lifetime and underlying execution that returns a value. + /// + /// The resulting value . + /// The . + /// The work to be executed within the transaction. + /// The . + /// The resulting value. + Task TransactionAsync(IDataArgs args, Func> work, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/CoreEx.Data/Models/ModelBase.cs b/src/CoreEx.Data/Models/ModelBase.cs new file mode 100644 index 00000000..b031ffce --- /dev/null +++ b/src/CoreEx.Data/Models/ModelBase.cs @@ -0,0 +1,29 @@ +namespace CoreEx.Data.Models; + +/// +/// Provides a convenience base class for data models implementing the common properties and interfaces such as , , and . +/// +/// The identifier . +/// Usage is purely optional; there is no other specific requirement for its use. +public abstract class ModelBase : IIdentifier, IChangeLogEx, IETag +{ + /// + /// Gets or sets the identifier. + /// + public TId Id { get; set; } = default!; + + /// + public string? CreatedBy { get; set; } + + /// + public DateTimeOffset? CreatedOn { get; set; } + + /// + public string? UpdatedBy { get; set; } + + /// + public DateTimeOffset? UpdatedOn { get; set; } + + /// + public string? ETag { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Models/ReferenceDataModelBase.cs b/src/CoreEx.Data/Models/ReferenceDataModelBase.cs new file mode 100644 index 00000000..d8590647 --- /dev/null +++ b/src/CoreEx.Data/Models/ReferenceDataModelBase.cs @@ -0,0 +1,45 @@ +namespace CoreEx.Data.Models; + +/// +/// Provides a convenience base class for reference data models implementing the common properties (extends ). +/// +/// The identifier . +/// Usage is purely optional; there is no other specific requirement for its use. +/// Does not implement by design, as it is not intended to support the base functionality. +public abstract class ReferenceDataModelBase : ModelBase +{ + /// + /// Gets or sets the unique code. + /// + public string Code { get; set; } = default!; + + /// + /// Gets or sets the text. + /// + public string? Text { get; set; } + + /// + /// Gets or sets the description. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the sort order. + /// + public int SortOrder { get; set; } + + /// + /// Indicates whether the reference data is active. + /// + public bool IsActive { get; set; } + + /// + /// Gets or sets the validity start . + /// + public DateTimeOffset? StartsOn { get; init; } + + /// + /// Gets or sets the validity end . + /// + public DateTimeOffset? EndsOn { get; init; } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/IQueryFilterFieldStatementExpression.cs b/src/CoreEx.Data/Querying/Expressions/IQueryFilterFieldStatementExpression.cs index 64edf7e5..08010ab0 100644 --- a/src/CoreEx.Data/Querying/Expressions/IQueryFilterFieldStatementExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/IQueryFilterFieldStatementExpression.cs @@ -1,20 +1,17 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying.Expressions; -namespace CoreEx.Data.Querying.Expressions +/// +/// Identifies a query filter statement expression. +/// +public interface IQueryFilterFieldStatementExpression { /// - /// Identifies a query filter statement expression. + /// Gets the field . /// - public interface IQueryFilterFieldStatementExpression - { - /// - /// Gets the field . - /// - IQueryFilterFieldConfig FieldConfig { get; } + IQueryFilterFieldConfig FieldConfig { get; } - /// - /// Gets the field . - /// - QueryFilterToken Field { get; } - } + /// + /// Gets the field . + /// + QueryFilterToken Field { get; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs index 017a51eb..493c39a1 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs @@ -1,21 +1,18 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying.Expressions; -namespace CoreEx.Data.Querying.Expressions +/// +/// Represents a query filter expression. +/// +/// The . +/// The originating query filter. +/// The syntax . +public sealed class QueryFilterCloseParenthesisExpression(QueryFilterParser parser, string filter, QueryFilterToken syntax) : QueryFilterExpressionBase(parser, filter, syntax) { - /// - /// Represents a query filter expression. - /// - /// The . - /// The originating query filter. - /// The syntax . - public sealed class QueryFilterCloseParenthesisExpression(QueryFilterParser parser, string filter, QueryFilterToken syntax) : QueryFilterExpressionBase(parser, filter, syntax) - { - private QueryFilterToken _syntax; + private QueryFilterToken _syntax; - /// - protected override void AddToken(int index, QueryFilterToken token) => _syntax = token; + /// + protected override void AddToken(int index, QueryFilterToken token) => _syntax = token; - /// - public override void WriteToResult(QueryFilterParserResult result) => result.FilterBuilder.Append(_syntax.ToLinq(Filter)); - } + /// + public override void WriteAsDynamicLinq(QueryFilterParserWriter result) => result.Append(_syntax.ToLinq(Filter)); } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs index 8e9d2f53..17393283 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs @@ -1,74 +1,71 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying.Expressions; -namespace CoreEx.Data.Querying.Expressions +/// +/// Provides a query filter expression. +/// +public abstract class QueryFilterExpressionBase { /// - /// Provides a query filter expression. + /// Initlializes a new instance of the . /// - public abstract class QueryFilterExpressionBase + /// The . + /// The originating query filter. + /// The first to be added. + public QueryFilterExpressionBase(QueryFilterParser parser, string filter, QueryFilterToken first) { - /// - /// Initlializes a new instance of the . - /// - /// The . - /// The originating query filter. - /// The first to be added. - public QueryFilterExpressionBase(QueryFilterParser parser, string filter, QueryFilterToken first) - { - Parser = parser.ThrowIfNull(nameof(parser)); - Filter = filter.ThrowIfNull(nameof(filter)); - AddToken(first); - } + Parser = parser.ThrowIfNull(); + Filter = filter.ThrowIfNull(); + AddToken(first); + } - /// - /// Gets the owning . - /// - public QueryFilterParser Parser { get; } + /// + /// Gets the owning . + /// + public QueryFilterParser Parser { get; } - /// - /// Gets the originating query filter. - /// - public string Filter { get; } + /// + /// Gets the originating query filter. + /// + public string Filter { get; } - /// - /// Gets the count of tokens added. - /// - public int TokenCount { get; private set; } + /// + /// Gets the count of tokens added. + /// + public int TokenCount { get; private set; } - /// - /// Indicates whether the expression is considered in a complete and valid state. - /// - public virtual bool IsComplete => true; + /// + /// Indicates whether the expression is considered in a complete and valid state. + /// + public virtual bool IsComplete => true; - /// - /// Indicates whether the can be added to the expression. - /// - /// The . - /// indicates that the can and should be added; otherwise, signifies that the is for the next expression. - /// Used to determine whether the next can be added; allows an expression to support multiple complete states. - public virtual bool CanAddToken(QueryFilterToken token) => !IsComplete; + /// + /// Indicates whether the can be added to the expression. + /// + /// The . + /// indicates that the can and should be added; otherwise, signifies that the is for the next expression. + /// Used to determine whether the next can be added; allows an expression to support multiple complete states. + public virtual bool CanAddToken(QueryFilterToken token) => !IsComplete; - /// - /// Adds the to the expression. - /// - /// The . - public void AddToken(QueryFilterToken token) - { - AddToken(TokenCount, token); - TokenCount++; - } + /// + /// Adds the to the expression. + /// + /// The . + public void AddToken(QueryFilterToken token) + { + AddToken(TokenCount, token); + TokenCount++; + } - /// - /// Adds the to the expression. - /// - /// The index. - /// The . - protected abstract void AddToken(int index, QueryFilterToken token); + /// + /// Adds the to the expression. + /// + /// The index. + /// The . + protected abstract void AddToken(int index, QueryFilterToken token); - /// - /// Converts the query filter expression into the corresponding dynamic LINQ appending to the . - /// - /// The . - public abstract void WriteToResult(QueryFilterParserResult result); - } + /// + /// Writes the query filter expression into the corresponding dynamic LINQ. + /// + /// The . + public abstract void WriteAsDynamicLinq(QueryFilterParserWriter writer); } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs index 7a0849f3..c5028aee 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs @@ -1,54 +1,51 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying.Expressions; -namespace CoreEx.Data.Querying.Expressions +/// +/// Represents a query filter expression. +/// +/// The . +/// The originating query filter. +/// The logical +public class QueryFilterLogicalExpression(QueryFilterParser parser, string filter, QueryFilterToken logical) : QueryFilterExpressionBase(parser, filter, logical) { - /// - /// Represents a query filter expression. - /// - /// The . - /// The originating query filter. - /// The logical - public class QueryFilterLogicalExpression(QueryFilterParser parser, string filter, QueryFilterToken logical) : QueryFilterExpressionBase(parser, filter, logical) - { - private QueryFilterToken _logical = QueryFilterToken.Unspecified; - private QueryFilterToken _not = QueryFilterToken.Unspecified; - private bool _isComplete = true; + private QueryFilterToken _logical = QueryFilterToken.Unspecified; + private QueryFilterToken _not = QueryFilterToken.Unspecified; + private bool _isComplete = true; - /// - public override bool IsComplete => _isComplete; + /// + public override bool IsComplete => _isComplete; - /// - public override bool CanAddToken(QueryFilterToken token) - { - if (TokenCount == 1) - return token.Kind == QueryFilterTokenKind.Not; + /// + public override bool CanAddToken(QueryFilterToken token) + { + if (TokenCount == 1) + return token.Kind == QueryFilterTokenKind.Not; - _isComplete = token.Kind == QueryFilterTokenKind.OpenParenthesis; - return _isComplete - ? false - : throw new QueryFilterParserException($"A '{_not.GetRawToken(Filter).ToString()}' expects an opening '(' to start an expression versus a syntactically incorrect '{token.GetValueToken(Filter)}' token."); - } + _isComplete = token.Kind == QueryFilterTokenKind.OpenParenthesis; + return _isComplete + ? false + : throw new QueryFilterParserException($"A '{_not.GetRawToken(Filter)}' expects an opening '(' to start an expression versus a syntactically incorrect '{token.GetValueToken(Filter)}' token."); + } - /// - protected override void AddToken(int index, QueryFilterToken token) + /// + protected override void AddToken(int index, QueryFilterToken token) + { + if (index == 0 && token.Kind != QueryFilterTokenKind.Not) + _logical = token; + else { - if (index == 0 && token.Kind != QueryFilterTokenKind.Not) - _logical = token; - else - { - _not = token; - _isComplete = false; - } + _not = token; + _isComplete = false; } + } - /// - public override void WriteToResult(QueryFilterParserResult result) - { - if (_logical.Kind != QueryFilterTokenKind.Unspecified) - result.Append(_logical.ToLinq(Filter)); + /// + public override void WriteAsDynamicLinq(QueryFilterParserWriter writer) + { + if (_logical.Kind != QueryFilterTokenKind.Unspecified) + writer.AppendWithSpacing(_logical.ToLinq(Filter)); - if (_not.Kind != QueryFilterTokenKind.Unspecified) - result.Append(_not.ToLinq(Filter)); - } + if (_not.Kind != QueryFilterTokenKind.Unspecified) + writer.AppendWithSpacing(_not.ToLinq(Filter)); } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs index 9658d929..22a218ad 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs @@ -1,21 +1,18 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying.Expressions; -namespace CoreEx.Data.Querying.Expressions +/// +/// Represents a query filter expression. +/// +/// The . +/// The originating query filter. +/// The syntax . +public sealed class QueryFilterOpenParenthesisExpression(QueryFilterParser parser, string filter, QueryFilterToken syntax) : QueryFilterExpressionBase(parser, filter, syntax) { - /// - /// Represents a query filter expression. - /// - /// The . - /// The originating query filter. - /// The syntax . - public sealed class QueryFilterOpenParenthesisExpression(QueryFilterParser parser, string filter, QueryFilterToken syntax) : QueryFilterExpressionBase(parser, filter, syntax) - { - private QueryFilterToken _syntax; + private QueryFilterToken _syntax; - /// - protected override void AddToken(int index, QueryFilterToken token) => _syntax = token; + /// + protected override void AddToken(int index, QueryFilterToken token) => _syntax = token; - /// - public override void WriteToResult(QueryFilterParserResult result) => result.Append(_syntax.ToLinq(Filter)); - } + /// + public override void WriteAsDynamicLinq(QueryFilterParserWriter writer) => writer.AppendWithSpacing(_syntax.ToLinq(Filter)); } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs index 4af0f2f5..ebb9879f 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs @@ -1,173 +1,180 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying.Expressions; + +/// +/// Represents a query filter expression. +/// +/// The . +/// The originating query filter. +/// The field . +public sealed class QueryFilterOperatorExpression(QueryFilterParser parser, string filter, QueryFilterToken field) : QueryFilterExpressionBase(parser, filter, field), IQueryFilterFieldStatementExpression +{ + private IQueryFilterFieldConfig? _fieldConfig; + private bool _isComplete; -using System; -using System.Collections.Generic; + /// + /// Gets the field . + /// + public IQueryFilterFieldConfig FieldConfig => _fieldConfig ?? throw new InvalidOperationException($"{nameof(FieldConfig)} must be set before it can be accessed."); -namespace CoreEx.Data.Querying.Expressions -{ /// - /// Represents a query filter expression. + /// Gets the field . /// - /// The . - /// The originating query filter. - /// The field . - public sealed class QueryFilterOperatorExpression(QueryFilterParser parser, string filter, QueryFilterToken field) : QueryFilterExpressionBase(parser, filter, field), IQueryFilterFieldStatementExpression - { - private IQueryFilterFieldConfig? _fieldConfig; - private bool _isComplete; + public QueryFilterToken Field { get; private set; } - /// - /// Gets the field . - /// - public IQueryFilterFieldConfig FieldConfig => _fieldConfig ?? throw new InvalidOperationException($"{nameof(FieldConfig)} must be set before it can be accessed."); + /// + /// Gets the operator . + /// + public QueryFilterToken Operator { get; private set; } - /// - /// Gets the field . - /// - public QueryFilterToken Field { get; private set; } + /// + /// Gets the constant list. + /// + public List Constants { get; } = []; - /// - /// Gets the operator . - /// - public QueryFilterToken Operator { get; private set; } + /// + public override bool IsComplete => _isComplete; - /// - /// Gets the constant list. - /// - public List Constants { get; } = []; + /// + public override bool CanAddToken(QueryFilterToken token) => !_isComplete || TokenCount == 1 && QueryFilterTokenKind.ComparisonOperators.HasFlag(token.Kind); - /// - public override bool IsComplete => _isComplete; + /// + protected override void AddToken(int index, QueryFilterToken token) + { + switch (index) + { + case 0: + Field = token; + _fieldConfig = Parser.GetFieldConfig(Field, Filter); + _isComplete = FieldConfig.FieldType == QueryFilterFieldType.Boolean; + break; - /// - public override bool CanAddToken(QueryFilterToken token) => !_isComplete || TokenCount == 1 && QueryFilterTokenKind.ComparisonOperators.HasFlag(token.Kind); + case 1: + if (!QueryFilterTokenKind.AllStringOperators.HasFlag(token.Kind)) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter)}' does not support '{token.GetRawToken(Filter)}' as an operator."); - /// - protected override void AddToken(int index, QueryFilterToken token) - { - switch (index) - { - case 0: - Field = token; - _fieldConfig = Parser.GetFieldConfig(Field, Filter); - _isComplete = FieldConfig.IsTypeBoolean; - break; + var op = (QueryFilterOperator)(int)token.Kind; + if (!FieldConfig.Operators.HasFlag(op)) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter)}' does not support the '{token.GetRawToken(Filter)}' operator."); - case 1: - if (!QueryFilterTokenKind.AllStringOperators.HasFlag(token.Kind)) - throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' does not support '{token.GetRawToken(Filter).ToString()}' as an operator."); + _isComplete = false; + Operator = token; + break; - var op = (QueryFilterOperator)(int)token.Kind; - if (!FieldConfig.Operators.HasFlag(op)) - throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' does not support the '{token.GetRawToken(Filter).ToString()}' operator."); + case 2: + if (Operator.Kind == QueryFilterTokenKind.In) + { + if (token.Kind != QueryFilterTokenKind.OpenParenthesis) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter)}' must specify an opening '(' for the '{Operator.GetRawToken(Filter)}' operator."); - _isComplete = false; - Operator = token; break; + } - case 2: - if (Operator.Kind == QueryFilterTokenKind.In) - { - if (token.Kind != QueryFilterTokenKind.OpenParenthesis) - throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' must specify an opening '(' for the '{Operator.GetRawToken(Filter).ToString()}' operator."); + if (token.Kind == QueryFilterTokenKind.Null && !QueryFilterTokenKind.EqualityOperators.HasFlag(Operator.Kind)) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter)}' constant must not be null for an '{Operator.GetRawToken(Filter)}' operator."); - break; - } + FieldConfig.ValidateConstant(Field, token, Filter); + Constants.Add(token); + _isComplete = true; + break; + + default: + if (index % 2 != 0) + { + if (token.Kind == QueryFilterTokenKind.CloseParenthesis) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter)}' constant must be specified before the closing ')' for the '{Operator.GetRawToken(Filter)}' operator."); - if (token.Kind == QueryFilterTokenKind.Null && !QueryFilterTokenKind.EqualityOperators.HasFlag(Operator.Kind)) - throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' constant must not be null for an '{Operator.GetRawToken(Filter).ToString()}' operator."); + if (token.Kind == QueryFilterTokenKind.OpenParenthesis) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter)}' must close ')' the '{Operator.GetRawToken(Filter)}' operator before specifying a further open '('."); + + if (token.Kind == QueryFilterTokenKind.Null) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter)}' constant must not be null for an '{Operator.GetRawToken(Filter)}' operator."); FieldConfig.ValidateConstant(Field, token, Filter); Constants.Add(token); - _isComplete = true; - break; - - default: - if (index % 2 != 0) + } + else + { + if (token.Kind == QueryFilterTokenKind.CloseParenthesis) { - if (token.Kind == QueryFilterTokenKind.CloseParenthesis) - throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' constant must be specified before the closing ')' for the '{Operator.GetRawToken(Filter).ToString()}' operator."); - - if (token.Kind == QueryFilterTokenKind.OpenParenthesis) - throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' must close ')' the '{Operator.GetRawToken(Filter).ToString()}' operator before specifying a further open '('."); + if (Constants.Count == 0) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter)}' expects at least one constant value for an '{Operator.GetRawToken(Filter)}' operator."); - if (token.Kind == QueryFilterTokenKind.Null) - throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' constant must not be null for an '{Operator.GetRawToken(Filter).ToString()}' operator."); - - FieldConfig.ValidateConstant(Field, token, Filter); - Constants.Add(token); + _isComplete = true; + break; } - else - { - if (token.Kind == QueryFilterTokenKind.CloseParenthesis) - { - if (Constants.Count == 0) - throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' expects at least one constant value for an '{Operator.GetRawToken(Filter).ToString()}' operator."); - _isComplete = true; - break; - } + if (token.Kind != QueryFilterTokenKind.Comma) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter)}' expects a ',' separator between constant values for an '{Operator.GetRawToken(Filter)}' operator."); + } - if (token.Kind != QueryFilterTokenKind.Comma) - throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' expects a ',' separator between constant values for an '{Operator.GetRawToken(Filter).ToString()}' operator."); - } + break; + } + } - break; - } + /// + /// Gets the converted value using the specified . + /// + /// The index. + /// The converted value. + public object GetConstantValue(int index) => Constants[index].GetConvertedValue(Operator, Field, FieldConfig, Filter); + + /// + public override void WriteAsDynamicLinq(QueryFilterParserWriter writer) + { + // Handle the prepended check for null. + if (Operator.Kind != QueryFilterTokenKind.In && (Constants.Count == 0 || Constants[0].Kind != QueryFilterTokenKind.Null) && FieldConfig.IsCheckForNotNull) + { + writer.AppendWithSpacing("("); + writer.Config.WriteNullFilterExpression(writer, FieldConfig, QueryFilterTokenKind.NotEqual); + writer.Append(" && "); + } + else if (Constants.Count == 1 && Constants[0].Kind == QueryFilterTokenKind.Null) + { + // Handle the simple straight-up check for null. + writer.Config.WriteNullFilterExpression(writer, FieldConfig, Operator.Kind); + return; } - /// - /// Gets the converted value using the specified . - /// - /// The index. - /// The converted value. - public object GetConstantValue(int index) => Constants[index].GetConvertedValue(Operator, Field, FieldConfig, Filter); + // Handle the generic expression. + writer.AppendWithSpacing(FieldConfig.FullyQualifiedModelName); - /// - public override void WriteToResult(QueryFilterParserResult result) + if (Constants.Count > 0) { - if (Operator.Kind != QueryFilterTokenKind.In && (Constants.Count == 0 || Constants[0].Kind != QueryFilterTokenKind.Null) && FieldConfig.IsCheckForNotNull) + if (FieldConfig.FieldType == QueryFilterFieldType.String && FieldConfig.IsToUpper.HasValue) { - result.Append("("); - result.FilterBuilder.Append(FieldConfig.Model); - result.FilterBuilder.Append(" != null && "); + if (FieldConfig.IsToUpper.Value) + writer.Append(".ToUpper()"); + else + writer.Append(".ToLower()"); } - result.Append(FieldConfig.Model); + writer.Append(' '); + writer.Append(Operator.ToLinq(Filter)); + writer.Append(' '); - if (Constants.Count > 0) + if (Operator.Kind == QueryFilterTokenKind.In) { - if (FieldConfig.IsTypeString && FieldConfig.IsToUpper) - result.FilterBuilder.Append(".ToUpper()"); - - result.FilterBuilder.Append(' '); - result.FilterBuilder.Append(Operator.ToLinq(Filter)); - result.FilterBuilder.Append(' '); - - if (Operator.Kind == QueryFilterTokenKind.In) + writer.Append('('); + for (int i = 0; i < Constants.Count; i++) { - result.FilterBuilder.Append('('); - for (int i = 0; i < Constants.Count; i++) - { - if (i > 0) - result.FilterBuilder.Append(", "); - - result.AppendValue(GetConstantValue(i)); - } + if (i > 0) + writer.Append(", "); - result.FilterBuilder.Append(')'); + writer.AppendValue(GetConstantValue(i)); } + + writer.Append(')'); + } + else + { + if (Constants[0].Kind == QueryFilterTokenKind.Value || Constants[0].Kind == QueryFilterTokenKind.Literal) + writer.AppendValue(GetConstantValue(0)); else - { - if (Constants[0].Kind == QueryFilterTokenKind.Value || Constants[0].Kind == QueryFilterTokenKind.Literal) - result.AppendValue(GetConstantValue(0)); - else - result.FilterBuilder.Append(Constants[0].ToLinq(Filter)); - } + writer.Append(Constants[0].ToLinq(Filter)); } - - if (Operator.Kind != QueryFilterTokenKind.In && (Constants.Count == 0 || Constants[0].Kind != QueryFilterTokenKind.Null) && FieldConfig.IsCheckForNotNull) - result.FilterBuilder.Append(')'); } + + if (Operator.Kind != QueryFilterTokenKind.In && (Constants.Count == 0 || Constants[0].Kind != QueryFilterTokenKind.Null) && FieldConfig.IsCheckForNotNull) + writer.Append(')'); } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs index 4510bedb..4f5194db 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs @@ -1,122 +1,122 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Data.Querying.Expressions +namespace CoreEx.Data.Querying.Expressions; + +/// +/// Represents a query filter expression. +/// +/// The . +/// The originating query filter. +/// The function +public sealed class QueryFilterStringFunctionExpression(QueryFilterParser parser, string filter, QueryFilterToken function) : QueryFilterExpressionBase(parser, filter, function), IQueryFilterFieldStatementExpression { + private IQueryFilterFieldConfig? _fieldConfig; + private bool _isComplete; + /// - /// Represents a query filter expression. + /// Gets the function . /// - /// The . - /// The originating query filter. - /// The function - public sealed class QueryFilterStringFunctionExpression(QueryFilterParser parser, string filter, QueryFilterToken function) : QueryFilterExpressionBase(parser, filter, function), IQueryFilterFieldStatementExpression - { - private IQueryFilterFieldConfig? _fieldConfig; - private bool _isComplete; - - /// - /// Gets the function . - /// - public QueryFilterToken Function { get; private set; } + public QueryFilterToken Function { get; private set; } - /// - /// Gets the . - /// - public IQueryFilterFieldConfig FieldConfig => _fieldConfig ?? throw new InvalidOperationException($"{nameof(FieldConfig)} must be set before it can be accessed."); + /// + /// Gets the . + /// + public IQueryFilterFieldConfig FieldConfig => _fieldConfig ?? throw new InvalidOperationException($"{nameof(FieldConfig)} must be set before it can be accessed."); - /// - /// Gets the field . - /// - public QueryFilterToken Field { get; private set; } + /// + /// Gets the field . + /// + public QueryFilterToken Field { get; private set; } - /// - /// Gets the constant . - /// - public QueryFilterToken Constant { get; private set; } + /// + /// Gets the constant . + /// + public QueryFilterToken Constant { get; private set; } - /// - public override bool IsComplete => _isComplete; + /// + public override bool IsComplete => _isComplete; - /// - public override bool CanAddToken(QueryFilterToken token) => !_isComplete; + /// + public override bool CanAddToken(QueryFilterToken token) => !_isComplete; - /// - protected override void AddToken(int index, QueryFilterToken token) + /// + protected override void AddToken(int index, QueryFilterToken token) + { + switch (index) { - switch (index) - { - case 0: - Function = token; - break; + case 0: + Function = token; + break; - case 1: - if (token.Kind != QueryFilterTokenKind.OpenParenthesis) - throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter).ToString()}' function expects an opening '(' not a '{token.GetValueToken(Filter)}'."); + case 1: + if (token.Kind != QueryFilterTokenKind.OpenParenthesis) + throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter)}' function expects an opening '(' not a '{token.GetValueToken(Filter)}'."); - break; + break; - case 2: - Field = token; - _fieldConfig = Parser.GetFieldConfig(Field, Filter); + case 2: + Field = token; + _fieldConfig = Parser.GetFieldConfig(Field, Filter); - var op = (QueryFilterOperator)(int)Function.Kind; - if (!FieldConfig.Operators.HasFlag(op)) - throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' does not support the '{Function.GetRawToken(Filter).ToString()}' function."); + var op = (QueryFilterOperator)(int)Function.Kind; + if (!FieldConfig.Operators.HasFlag(op)) + throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter)}' does not support the '{Function.GetRawToken(Filter)}' function."); - break; + break; - case 3: - if (token.Kind != QueryFilterTokenKind.Comma) - throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter).ToString()}' function expects a ',' separator between the field and its constant."); + case 3: + if (token.Kind != QueryFilterTokenKind.Comma) + throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter)}' function expects a ',' separator between the field and its constant."); - break; + break; - case 4: - if (token.Kind == QueryFilterTokenKind.Null) - throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter).ToString()}' function references a null constant which is not supported."); + case 4: + if (token.Kind == QueryFilterTokenKind.Null) + throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter)}' function references a null constant which is not supported."); - FieldConfig.ValidateConstant(Field, token, Filter); - Constant = token; - break; + FieldConfig.ValidateConstant(Field, token, Filter); + Constant = token; + break; - case 5: - if (token.Kind != QueryFilterTokenKind.CloseParenthesis) - throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter).ToString()}' function expects a closing ')' not a '{token.GetValueToken(Filter)}'."); + case 5: + if (token.Kind != QueryFilterTokenKind.CloseParenthesis) + throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter)}' function expects a closing ')' not a '{token.GetValueToken(Filter)}'."); - _isComplete = true; - break; - } + _isComplete = true; + break; } + } + + /// + /// Gets the converted value. + /// + /// The converted value. + public object GetConstantValue() => Constant!.GetConvertedValue(Function, Field, FieldConfig, Filter); - /// - /// Gets the converted value. - /// - /// The converted value. - public object GetConstantValue() => Constant!.GetConvertedValue(Function, Field, FieldConfig, Filter); + /// + public override void WriteAsDynamicLinq(QueryFilterParserWriter writer) + { + if (FieldConfig.IsCheckForNotNull) + { + writer.AppendWithSpacing('('); + writer.Append(FieldConfig.FullyQualifiedModelName); + writer.Append(" != null &&"); + } - /// - public override void WriteToResult(QueryFilterParserResult result) + writer.AppendWithSpacing(FieldConfig!.FullyQualifiedModelName); + if (FieldConfig.FieldType == QueryFilterFieldType.String && FieldConfig.IsToUpper.HasValue) { - if (FieldConfig.IsCheckForNotNull) - { - result.Append('('); - result.FilterBuilder.Append(FieldConfig.Model); - result.FilterBuilder.Append(" != null &&"); - } - - result.Append(FieldConfig!.Model); - if (FieldConfig.IsTypeString && FieldConfig.IsToUpper) - result.FilterBuilder.Append(".ToUpper()"); - - result.FilterBuilder.Append('.'); - result.FilterBuilder.Append(Function.ToLinq(Filter)); - result.FilterBuilder.Append('('); - result.AppendValue(Constant.GetConvertedValue(Function, Field, FieldConfig, Filter)); - result.FilterBuilder.Append(')'); - - if (FieldConfig.IsCheckForNotNull) - result.FilterBuilder.Append(')'); + if (FieldConfig.IsToUpper.Value) + writer.Append(".ToUpper()"); + else + writer.Append(".ToLower()"); } + + writer.Append('.'); + writer.Append(Function.ToLinq(Filter)); + writer.Append('('); + writer.AppendValue(Constant.GetConvertedValue(Function, Field, FieldConfig, Filter)); + writer.Append(')'); + + if (FieldConfig.IsCheckForNotNull) + writer.Append(')'); } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterToken.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterToken.cs index 1b4b03f4..eabb41de 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterToken.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterToken.cs @@ -1,120 +1,119 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying.Expressions; -using System; - -namespace CoreEx.Data.Querying.Expressions +/// +/// Represents a token. +/// +/// The token kind. +/// The token index. +/// The token length. +public readonly struct QueryFilterToken(QueryFilterTokenKind kind, int index, int length) { /// - /// Represents a token. + /// Gets an unspecified . /// - /// The token kind. - /// The token index. - /// The token length. - public readonly struct QueryFilterToken(QueryFilterTokenKind kind, int index, int length) - { - /// - /// Gets an unspecified . - /// - public static QueryFilterToken Unspecified { get; } = new QueryFilterToken(QueryFilterTokenKind.Unspecified, 0, 0); + public static QueryFilterToken Unspecified { get; } = new QueryFilterToken(QueryFilterTokenKind.Unspecified, 0, 0); - /// - /// Gets the token kind. - /// - public QueryFilterTokenKind Kind { get; } = kind; + /// + /// Gets the token kind. + /// + public QueryFilterTokenKind Kind { get; } = kind; - /// - /// Gets the token start position. - /// - public int Index { get; } = index; + /// + /// Gets the token start position. + /// + public int Index { get; } = index; + + /// + /// Gets the token length. + /// + public int Length { get; } = length; - /// - /// Gets the token length. - /// - public int Length { get; } = length; + /// + /// Gets the raw token from the . + /// + /// The query filter. + /// The raw token. + public readonly ReadOnlySpan GetRawToken(string filter) => filter.ThrowIfNull().AsSpan(Index, Length); - /// - /// Gets the raw token from the . - /// - /// The query filter. - /// The raw token. - public readonly ReadOnlySpan GetRawToken(string filter) => filter.ThrowIfNull(nameof(filter)).AsSpan(Index, Length); + /// + /// Gets the value from the token removing leading and trailing quotes, and replacing all escaped quotes where applicable. + /// + /// The query filter. + /// The value token. + public readonly string GetValueToken(string filter) + { + var raw = GetRawToken(filter); + return raw.Length >= 2 && raw[0] == '\'' && raw[^1] == '\'' ? raw[1..^1].ToString().Replace("''", "'") : raw.ToString(); + } - /// - /// Gets the value from the token removing leading and trailing quotes, and replacing all escaped quotes where applicable. - /// - /// The query filter. - /// The value token. - public readonly string GetValueToken(string filter) + /// + /// Performs a and converts using the configured . + /// + /// The operation being performed on the . + /// The field . + /// The . + /// The query filter. + /// The converted value. + public readonly object GetConvertedValue(QueryFilterToken operation, QueryFilterToken field, IQueryFilterFieldConfig config, string filter) + { + if (Kind != QueryFilterTokenKind.Value && Kind != QueryFilterTokenKind.Literal) + throw new InvalidOperationException($"A {nameof(GetConvertedValue)} for a token with a {nameof(Kind)} of '{Kind}' is not supported."); + + try { - var raw = GetRawToken(filter); - return raw.Length >= 2 && raw[0] == '\'' && raw[^1] == '\'' ? raw[1..^1].ToString().Replace("''", "'") : raw.ToString(); + return config.ConvertToValue(operation, this, filter) ?? throw new InvalidOperationException($"Field '{field.GetRawToken(filter)}' has a value '{GetValueToken(filter)}' which has been converted to null."); } - - /// - /// Performs a and converts using the configured . - /// - /// The operation being performed on the . - /// The field . - /// The . - /// The query filter. - /// The converted value. - public readonly object GetConvertedValue(QueryFilterToken operation, QueryFilterToken field, IQueryFilterFieldConfig config, string filter) + catch (QueryFilterParserException) { - if (Kind != QueryFilterTokenKind.Value && Kind != QueryFilterTokenKind.Literal) - throw new InvalidOperationException($"A {nameof(GetConvertedValue)} for a token with a {nameof(Kind)} of '{Kind}' is not supported."); - - try - { - return config.ConvertToValue(operation, this, filter) ?? throw new InvalidOperationException($"Field '{field.GetRawToken(filter).ToString()}' has a value '{GetValueToken(filter)}' which has been converted to null."); - } - catch (QueryFilterParserException) - { - throw; - } - catch (Exception ex) when (ex is FormatException || ex is InvalidCastException || ex is ValidationException) - { - throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' with value '{GetValueToken(filter)}' is invalid: {ex.Message}"); - } - catch (Exception) - { - throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' has a value '{GetValueToken(filter)}' that is not a valid {config.Type.Name}."); - } + throw; } - - /// - /// Clones and updates the token with the specified . - /// - /// The overridding . - /// The new . - public readonly QueryFilterToken CloneAs(QueryFilterTokenKind kind) => new(kind, Index, Length); - - /// - /// Converts the token to the dynamic LINQ equivalent. - /// - /// The originating filter. - /// The dynamic LINQ expression. - public readonly string ToLinq(string filter) => Kind switch + catch (ValidationException vex) + { + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter)}' with value '{GetValueToken(filter)}' is invalid: {vex.Message}"); + } + catch (FormatException fex) + { + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter)}' has a value '{GetValueToken(filter)}' that is not a valid {config.Type.Name}: {fex.Message}"); + } + catch (Exception) { - QueryFilterTokenKind.Field => GetRawToken(filter).ToString(), - QueryFilterTokenKind.True => "true", - QueryFilterTokenKind.False => "false", - QueryFilterTokenKind.Null => "null", - QueryFilterTokenKind.And => "&&", - QueryFilterTokenKind.Or => "||", - QueryFilterTokenKind.Not => "!", - QueryFilterTokenKind.Equal => "==", - QueryFilterTokenKind.NotEqual => "!=", - QueryFilterTokenKind.GreaterThan => ">", - QueryFilterTokenKind.GreaterThanOrEqual => ">=", - QueryFilterTokenKind.LessThan => "<", - QueryFilterTokenKind.LessThanOrEqual => "<=", - QueryFilterTokenKind.In => "in", - QueryFilterTokenKind.OpenParenthesis => "(", - QueryFilterTokenKind.CloseParenthesis => ")", - QueryFilterTokenKind.StartsWith => nameof(QueryFilterTokenKind.StartsWith), - QueryFilterTokenKind.EndsWith => nameof(QueryFilterTokenKind.EndsWith), - QueryFilterTokenKind.Contains => nameof(QueryFilterTokenKind.Contains), - _ => throw new InvalidOperationException($"A {nameof(ToLinq)} for a token with a {nameof(Kind)} of '{Kind}' is not supported."), - }; + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter)}' has a value '{GetValueToken(filter)}' that is not a valid {config.Type.Name}."); + } } + + /// + /// Clones and updates the token with the specified . + /// + /// The overriding . + /// The new . + public readonly QueryFilterToken CloneAs(QueryFilterTokenKind kind) => new(kind, Index, Length); + + /// + /// Converts the token to the dynamic LINQ equivalent. + /// + /// The originating filter. + /// The dynamic LINQ expression. + public readonly string ToLinq(string filter) => Kind switch + { + QueryFilterTokenKind.Field => GetRawToken(filter).ToString(), + QueryFilterTokenKind.True => "true", + QueryFilterTokenKind.False => "false", + QueryFilterTokenKind.Null => "null", + QueryFilterTokenKind.And => "&&", + QueryFilterTokenKind.Or => "||", + QueryFilterTokenKind.Not => "!", + QueryFilterTokenKind.Equal => "==", + QueryFilterTokenKind.NotEqual => "!=", + QueryFilterTokenKind.GreaterThan => ">", + QueryFilterTokenKind.GreaterThanOrEqual => ">=", + QueryFilterTokenKind.LessThan => "<", + QueryFilterTokenKind.LessThanOrEqual => "<=", + QueryFilterTokenKind.In => "in", + QueryFilterTokenKind.OpenParenthesis => "(", + QueryFilterTokenKind.CloseParenthesis => ")", + QueryFilterTokenKind.StartsWith => nameof(QueryFilterTokenKind.StartsWith), + QueryFilterTokenKind.EndsWith => nameof(QueryFilterTokenKind.EndsWith), + QueryFilterTokenKind.Contains => nameof(QueryFilterTokenKind.Contains), + _ => throw new InvalidOperationException($"A {nameof(ToLinq)} for a token with a {nameof(Kind)} of '{Kind}' is not supported."), + }; } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterTokenKind.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterTokenKind.cs index d0852bbf..8e10767b 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterTokenKind.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterTokenKind.cs @@ -1,163 +1,158 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying.Expressions; -using System; - -namespace CoreEx.Data.Querying.Expressions +/// +/// Provides the kind. +/// +[Flags] +public enum QueryFilterTokenKind { /// - /// Provides the kind. - /// - [Flags] - public enum QueryFilterTokenKind - { - /// - /// An unspecified/undetermined token. - /// - Unspecified = 1, - - /// - /// The field token. - /// - Field = 2, - - /// - /// The equal operator token. - /// - Equal = 4, - - /// - /// The not equal operator token. - /// - NotEqual = 8, - - /// - /// The less than operator token. - /// - LessThan = 16, - - /// - /// The less than or equal operator token. - /// - LessThanOrEqual = 32, - - /// - /// The greater than or equal operator token. - /// - GreaterThanOrEqual = 64, - - /// - /// The greater than operator token. - /// - GreaterThan = 128, - - /// - /// The logical IN operator token. - /// - In = 256, - - /// - /// The value token. - /// - Value = 512, - - /// - /// The string literal token. - /// - Literal = 1024, - - /// - /// The token. - /// - True = 2048, - - /// - /// The token. - /// - False = 4096, - - /// - /// The token. - /// - Null = 8192, - - /// - /// The logical AND operator token. - /// - And = 16384, - - /// - /// The logical OR operator token. - /// - Or = 32768, - - /// - /// The open parenthesis token. - /// - OpenParenthesis = 65536, - - /// - /// The close parenthesis token. - /// - CloseParenthesis = 131072, - - /// - /// The comma token. - /// - Comma = 262144, - - /// - /// The starts with token. - /// - StartsWith = 524288, - - /// - /// The contains token. - /// - Contains = 1048576, - - /// - /// The ends with token. - /// - EndsWith = 2097152, - - /// - /// The logical NOT operator token. - /// - Not = 4194304, - - /// - /// An expression operator token. - /// - ComparisonOperators = Equal | NotEqual | GreaterThan | GreaterThanOrEqual | LessThan | LessThanOrEqual | In, - - /// - /// An expression equality operator token. - /// - EqualityOperators = Equal | NotEqual | In, - - /// - /// An expression constant token. - /// - Constant = Value | Literal | True | False | Null, - - /// - /// A logical operator token. - /// - Logical = And | Or, - - /// - /// A general syntax token. - /// - Syntax = OpenParenthesis | CloseParenthesis | Comma, - - /// - /// A string oriented function-based operator. - /// - StringFunctions = StartsWith | EndsWith | Contains, - - /// - /// All string oriented operators. - /// - AllStringOperators = ComparisonOperators | StringFunctions - } + /// An unspecified/undetermined token. + /// + Unspecified = 1, + + /// + /// The field token. + /// + Field = 2, + + /// + /// The equal operator token. + /// + Equal = 4, + + /// + /// The not equal operator token. + /// + NotEqual = 8, + + /// + /// The less than operator token. + /// + LessThan = 16, + + /// + /// The less than or equal operator token. + /// + LessThanOrEqual = 32, + + /// + /// The greater than or equal operator token. + /// + GreaterThanOrEqual = 64, + + /// + /// The greater than operator token. + /// + GreaterThan = 128, + + /// + /// The logical IN operator token. + /// + In = 256, + + /// + /// The value token. + /// + Value = 512, + + /// + /// The string literal token. + /// + Literal = 1024, + + /// + /// The token. + /// + True = 2048, + + /// + /// The token. + /// + False = 4096, + + /// + /// The token. + /// + Null = 8192, + + /// + /// The logical AND operator token. + /// + And = 16384, + + /// + /// The logical OR operator token. + /// + Or = 32768, + + /// + /// The open parenthesis token. + /// + OpenParenthesis = 65536, + + /// + /// The close parenthesis token. + /// + CloseParenthesis = 131072, + + /// + /// The comma token. + /// + Comma = 262144, + + /// + /// The starts with token. + /// + StartsWith = 524288, + + /// + /// The contains token. + /// + Contains = 1048576, + + /// + /// The ends with token. + /// + EndsWith = 2097152, + + /// + /// The logical NOT operator token. + /// + Not = 4194304, + + /// + /// An expression operator token. + /// + ComparisonOperators = Equal | NotEqual | GreaterThan | GreaterThanOrEqual | LessThan | LessThanOrEqual | In, + + /// + /// An expression equality operator token. + /// + EqualityOperators = Equal | NotEqual | In, + + /// + /// An expression constant token. + /// + Constant = Value | Literal | True | False | Null, + + /// + /// A logical operator token. + /// + Logical = And | Or, + + /// + /// A general syntax token. + /// + Syntax = OpenParenthesis | CloseParenthesis | Comma, + + /// + /// A string-specific oriented function-based operator. + /// + StringFunctions = StartsWith | EndsWith | Contains, + + /// + /// All string oriented operators. + /// + AllStringOperators = ComparisonOperators | StringFunctions } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs b/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs index 90a5030a..7dc874f1 100644 --- a/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs +++ b/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs @@ -1,108 +1,119 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using CoreEx.Data.Querying.Expressions; -using CoreEx.Mapping.Converters; -using System; -using System.Text; - -namespace CoreEx.Data.Querying +/// +/// Represents the base field configuration. +/// +public interface IQueryFilterFieldConfig { /// - /// Represents the base field configuration. - /// - public interface IQueryFilterFieldConfig - { - /// - /// Gets the owning . - /// - QueryFilterParser Parser { get; } - - /// - /// Gets the field type. - /// - Type Type { get; } - - /// - /// Indicates whether the field type is a . - /// - bool IsTypeString { get; } - - /// - /// Indicates whether the field type is a . - /// - bool IsTypeBoolean { get; } - - /// - /// Gets the field name. - /// - string Field { get; } - - /// - /// Gets or sets model name to be used for the dynamic LINQ expression. - /// - /// Defaults to the name. - string? Model { get; } - - /// - /// Gets the supported kinds. - /// - /// Where defaults to both and only; otherwise, defaults to . - QueryFilterOperator Operators { get; } - - /// - /// Indicates whether the comparison should ignore case or not; will use when selected for comparisons. - /// - /// This is only applicable where the . - bool IsToUpper { get; } - - /// - /// Indicates whether the field can be or not. - /// - bool IsNullable { get; } - - /// - /// Indicates whether a not- check should also be performed before the comparion occurs. - /// - bool IsCheckForNotNull { get; } - - /// - /// Gets the default LINQ function to be used where no filtering is specified. - /// - Func? DefaultStatement { get; } - - /// - /// Gets the additional help text. - /// - string? HelpText { get; } - - /// - /// Converts to the destination type using the configurations where specified. - /// - /// The operation being performed on the . - /// The field . - /// The query filter. - /// The converted value. - /// - object? ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter); - - /// - /// Validate the token against the field configuration. - /// - /// The field . - /// The constant . - /// The query filter. - void ValidateConstant(QueryFilterToken field, QueryFilterToken constant, string filter); - - /// - /// Gets the . - /// - QueryFilterFieldResultWriter? ResultWriter { get; } - - /// - /// Appends the field configuration to the . - /// - /// The . - /// The . - StringBuilder AppendToString(StringBuilder stringBuilder); - } + /// Gets the owning . + /// + QueryFilterParser Parser { get; } + + /// + /// Gets the field type. + /// + Type Type { get; } + + /// + /// Gets the . + /// + QueryFilterFieldType FieldType { get; } + + /// + /// Gets the field name. + /// + string Field { get; } + + /// + /// Gets the model name to be used for the dynamic LINQ expression. + /// + /// Defaults to the name. + string Model { get; } + + /// + /// Gets the optional prefix to be used where referencing the underlying model. + /// + /// This will default from when instantiated. + string? ModelPrefix { get; } + + /// + /// Gets the fully-qualified name (including any where specified). + /// + string FullyQualifiedModelName { get; } + + /// + /// Gets the supported kinds. + /// + QueryFilterOperator Operators { get; } + + /// + /// Indicates whether the comparison should ignore case or not; will use (where ) or (where ) when selected for comparisons. + /// + bool? IsToUpper { get; } + + /// + /// Indicates whether the field can be or not. + /// + bool IsNullable { get; } + + /// + /// Indicates whether a not- check should also be performed before the comparion occurs. + /// + bool IsCheckForNotNull { get; } + + /// + /// Gets the default LINQ function to be used where no filtering is specified. + /// + Func? DefaultStatement { get; } + + /// + /// Gets the . + /// + QueryFilterSchemaType SchemaType { get; } + + /// + /// Gets the corresponding format for the (where applicable). + /// + /// + string? SchemaFormat { get; } + + /// + /// Gets the additional help text. + /// + string? HelpText { get; } + + /// + /// Converts to the underlying type. + /// + /// The operation being performed on the . + /// The field . + /// The query filter. + /// The converted value. + object? ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter); + + /// + /// Validate the token against the field configuration. + /// + /// The field . + /// The constant . + /// The query filter. + void ValidateConstant(QueryFilterToken field, QueryFilterToken constant, string filter); + + /// + /// Gets the . + /// + QueryFilterFieldResultWriter? ResultWriter { get; } + + /// + /// Appends the field configuration to the . + /// + /// The . + /// The . + StringBuilder AppendToString(StringBuilder stringBuilder); + + /// + /// Returns a dictionary representation of the schema configuration. + /// + IDictionary ToSchemaDictionary(); } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/IQueryParseError.cs b/src/CoreEx.Data/Querying/IQueryParseError.cs new file mode 100644 index 00000000..8df35afb --- /dev/null +++ b/src/CoreEx.Data/Querying/IQueryParseError.cs @@ -0,0 +1,17 @@ +namespace CoreEx.Data.Querying; + +/// +/// Represents an that occurred during parsing. +/// +internal interface IQueryParseError +{ + /// + /// Indicates whether there was an during parsing. + /// + bool HasError { get; } + + /// + /// Gets the error represented as an that occurred during parsing, if any. + /// + ExtendedException? Error { get; } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryArgsConfig.cs b/src/CoreEx.Data/Querying/QueryArgsConfig.cs index 71c13c1c..3dc77084 100644 --- a/src/CoreEx.Data/Querying/QueryArgsConfig.cs +++ b/src/CoreEx.Data/Querying/QueryArgsConfig.cs @@ -1,105 +1,137 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using CoreEx.Entities; -using CoreEx.Results; -using System; - -namespace CoreEx.Data.Querying +/// +/// Provides the ODATA-esque dynamic LINQ queries execution configuration. +/// +public class QueryArgsConfig { + private QueryFilterParser? _filterParser; + private QueryOrderByParser? _orderByParser; + + /// + /// Creates a new . + /// + /// The . + public static QueryArgsConfig Create() => new(); + + /// + /// Gets the . + /// + public ParsingConfig ParsingConfig { get; protected set; } = new ParsingConfig(); + + /// + /// Gets the . + /// + public QueryFilterParser FilterParser => _filterParser ??= new QueryFilterParser(this); + + /// + /// Indicates whether there is a . + /// + public bool HasFilterParser => _filterParser is not null; + + /// + /// Gets the . + /// + public QueryOrderByParser OrderByParser => _orderByParser ??= new QueryOrderByParser(this); + + /// + /// Indicates whether there is an . + /// + public bool HasOrderByParser => _orderByParser is not null; + + /// + /// Enables fluent-style method-chaining configuration for the . + /// + /// The . + /// The instance to support fluent-style method-chaining. + public QueryArgsConfig WithFilter(Action filter) + { + filter.ThrowIfNull()(FilterParser); + return this; + } + + /// + /// Enables fluent-style method-chaining configuration for the . + /// + /// The . + /// The instance to support fluent-style method-chaining. + public QueryArgsConfig WithOrderBy(Action orderBy) + { + orderBy.ThrowIfNull()(OrderByParser); + return this; + } + /// - /// Provides the configuration. + /// Parses and converts the and to dynamic LINQ. /// - public class QueryArgsConfig + /// The . + /// The . + public QueryArgsParseResult Parse(QueryArgs? queryArgs) { - private QueryFilterParser? _filterParser; - private QueryOrderByParser? _orderByParser; - - /// - /// Creates a new . - /// - /// The . - public static QueryArgsConfig Create() => new(); - - /// - /// Gets the . - /// - public QueryFilterParser FilterParser => _filterParser ??= new QueryFilterParser(this); - - /// - /// Indicates whether there is a . - /// - public bool HasFilterParser => _filterParser is not null; - - /// - /// Gets the . - /// - public QueryOrderByParser OrderByParser => _orderByParser ??= new QueryOrderByParser(this); - - /// - /// Indicates whether there is an . - /// - public bool HasOrderByParser => _orderByParser is not null; - - /// - /// Enables fluent-style method-chaining configuration for the . - /// - /// The . - /// The instance to support fluent-style method-chaining. - public QueryArgsConfig WithFilter(Action filter) + var result = new QueryArgsParseResult(this); + if (queryArgs is null) + return result; + + QueryFilterParserResult? filterParserResult = null; + if (!string.IsNullOrEmpty(queryArgs.Filter)) { - filter.ThrowIfNull(nameof(filter))(FilterParser); - return this; + if (HasFilterParser) + { + filterParserResult = FilterParser.Parse(queryArgs.Filter); + if (filterParserResult.HasError) + return result.Adjust(x => x.Error = filterParserResult.Error); + } + else + return result.Adjust(x => x.Error = new QueryFilterParserException("Filter statement is not currently supported.")); } - /// - /// Enables fluent-style method-chaining configuration for the . - /// - /// The . - /// The instance to support fluent-style method-chaining. - public QueryArgsConfig WithOrderBy(Action orderBy) + QueryOrderByParserResult? orderByParserResult = null; + if (HasOrderByParser) { - orderBy.ThrowIfNull(nameof(orderBy))(OrderByParser); - return this; + orderByParserResult = OrderByParser.Parse(queryArgs.OrderBy); + if (orderByParserResult.HasError) + return result.Adjust(x => x.Error = orderByParserResult.Error); } + else if (!string.IsNullOrEmpty(queryArgs.OrderBy)) + return result.Adjust(x => x.Error = new QueryOrderByParserException("OrderBy statement is not currently supported.")); + + return new QueryArgsParseResult(this, filterParserResult, orderByParserResult); + } - /// - /// Parses and converst the and to dynamic LINQ. - /// - /// The . - /// The . - public Result Parse(QueryArgs? queryArgs) - { - if (queryArgs is null) - return new QueryArgsParseResult(); + /// + /// Executes the underlying to write the dynamic LINQ equality expression. + /// + /// The + /// The . + /// The (either or ). + internal void WriteNullFilterExpression(QueryFilterParserWriter writer, IQueryFilterFieldConfig fieldConfig, QueryFilterTokenKind filterOperator) => OnWriteNullFilterExpression(writer, fieldConfig, filterOperator); + + /// + /// Provides an opportunity to override the dynamic LINQ equality expression write. For some data sources, such as NoSQL, the existence of the field (has a value) and whether it is + /// are two different operations and may be data source specific. This method allows for this logic to be overridden to write the filter expression result that is data source specific. + /// + /// The + /// The . + /// The (either or ). + protected virtual void OnWriteNullFilterExpression(QueryFilterParserWriter writer, IQueryFilterFieldConfig fieldConfig, QueryFilterTokenKind filterOperator) + { + writer.Append(fieldConfig.FullyQualifiedModelName); + writer.Append($" {(filterOperator == QueryFilterTokenKind.Equal ? "==" : "!=")} null"); + } - Result filterParserResult = default; - Result orderByParserResult = default; + /// + /// Produces the JSON schema for the configuration. + /// + /// The . + public JsonElement ToJsonSchema() + { + var dict = new Dictionary(); + if (HasFilterParser) + dict["filter"] = FilterParser.ToJsonSchema(); - if (!string.IsNullOrEmpty(queryArgs.Filter)) - { - if (HasFilterParser) - { - filterParserResult = FilterParser.Parse(queryArgs.Filter); - if (filterParserResult.IsFailure) - return filterParserResult.AsResult(); - } - else - return new QueryFilterParserException("Filter statement is not currently supported."); - } + if (HasOrderByParser) + dict["orderby"] = OrderByParser.ToJsonSchema(); - if (!string.IsNullOrEmpty(queryArgs.OrderBy)) - { - if (HasOrderByParser) - { - orderByParserResult = OrderByParser.Parse(queryArgs.OrderBy); - if (orderByParserResult.IsFailure) - return orderByParserResult.AsResult(); - } - else - return new QueryOrderByParserException("OrderBy statement is not currently supported."); - } - - return new QueryArgsParseResult(filterParserResult, orderByParserResult); - } + return JsonSerializer.SerializeToElement(dict); } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryArgsParseResult.cs b/src/CoreEx.Data/Querying/QueryArgsParseResult.cs index 2cb0b31e..55e831d6 100644 --- a/src/CoreEx.Data/Querying/QueryArgsParseResult.cs +++ b/src/CoreEx.Data/Querying/QueryArgsParseResult.cs @@ -1,31 +1,55 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -namespace CoreEx.Data.Querying +/// +/// Represents the result. +/// +public sealed class QueryArgsParseResult : IQueryParseError, IToResult { /// - /// Represents the result. + /// Initializes a new instance of the class. /// - public sealed class QueryArgsParseResult + /// The owning . + /// The . + /// The . + internal QueryArgsParseResult(QueryArgsConfig config, QueryFilterParserResult? filterResult = null, QueryOrderByParserResult? orderByResult = null) { - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - internal QueryArgsParseResult(QueryFilterParserResult? filterResult = null, QueryOrderByParserResult? orderByResult = null) - { - FilterResult = filterResult; - OrderByResult = orderByResult; - } - - /// - /// Gets the . - /// - public QueryFilterParserResult? FilterResult { get; } - - /// - /// Gets the . - /// - public QueryOrderByParserResult? OrderByResult { get; } + Config = config; + FilterResult = filterResult; + OrderByResult = orderByResult; } + + /// + /// Gets the owning . + /// + public QueryArgsConfig Config { get; } + + /// + /// Gets the . + /// + public QueryFilterParserResult? FilterResult { get; } + + /// + /// Gets the . + /// + public QueryOrderByParserResult? OrderByResult { get; } + + /// + public bool HasError => Error is not null; + + /// + public ExtendedException? Error { get; internal set; } + + /// + /// Throws the where ; otherwise, does nothing. + /// + public QueryArgsParseResult ThrowOnError() + { + if (HasError) + throw Error!; + + return this; + } + + /// + public Result ToResult() => HasError ? Result.Fail(Error!) : Result.Success; } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryExtensions.cs b/src/CoreEx.Data/Querying/QueryExtensions.cs new file mode 100644 index 00000000..079dc7c1 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryExtensions.cs @@ -0,0 +1,105 @@ +namespace CoreEx.Data.Querying; + +/// +/// Provides query-oriented extension methods. +/// +public static class QueryExtensions +{ + /// + /// Adds a dynamic query (basic dynamic OData-like $filter statement). + /// + /// The element . + /// The sequence of elements. + /// The . + /// The . + /// The query. + public static IQueryable Where(this IQueryable source, QueryArgsConfig queryConfig, QueryArgs? queryArgs = null) => Where(source, queryConfig, queryArgs?.Filter); + + /// + /// Adds a dynamic query (basic dynamic OData-like $filter statement). + /// + /// The element . + /// The sequence of elements. + /// The . + /// The basic dynamic OData-like $filter statement. + /// The query. + public static IQueryable Where(this IQueryable source, QueryArgsConfig queryConfig, string? filter) + { + queryConfig.ThrowIfNull(); + if (!queryConfig.HasFilterParser && !string.IsNullOrEmpty(filter)) + throw new QueryFilterParserException("Query filter statement is not currently supported."); + + if (!queryConfig.HasFilterParser) + return source; + + var result = queryConfig.FilterParser.Parse(filter).ThrowOnError(); + var linq = result.ToLinqString(out var args); + return string.IsNullOrEmpty(linq) ? source : source.Where(result.Config.ParsingConfig, linq, [.. args]); + } + + /// + /// Adds a dynamic query filter (basic dynamic OData-like $filter statement). + /// + /// The element . + /// The sequence of elements. + /// The that contains the parsed dynamic OData-like $filter statement. + /// The query. + public static IQueryable Where(this IQueryable source, QueryArgsParseResult result) + { + result.ThrowIfNull().ThrowOnError(); + if (result.FilterResult is null) + return source; + + var linq = result.FilterResult.ToLinqString(out var args); + return string.IsNullOrEmpty(linq) ? source : source.Where(result.Config.ParsingConfig, linq, [.. args]); + } + + /// + /// Adds a dynamic query (basic dynamic OData-like $orderby statement). + /// + /// The element . + /// The sequence of elements. + /// The . + /// The . + /// The query. + public static IQueryable OrderBy(this IQueryable source, QueryArgsConfig queryConfig, QueryArgs? queryArgs = null) => OrderBy(source, queryConfig, queryArgs?.OrderBy); + + /// + /// Adds a dynamic query (basic dynamic OData-like $orderby statement). + /// + /// The element . + /// The sequence of elements. + /// The . + /// The basic dynamic OData-like $orderby statement. + /// The query. + public static IQueryable OrderBy(this IQueryable source, QueryArgsConfig queryConfig, string? orderby) + { + queryConfig.ThrowIfNull(); + if (!queryConfig.HasOrderByParser && !string.IsNullOrEmpty(orderby)) + throw new QueryOrderByParserException("OrderBy filter is not currently supported."); + + if (!queryConfig.HasOrderByParser) + return source; + + var result = queryConfig.OrderByParser.Parse(orderby).ThrowOnError(); + var linq = result.ToLinqString(); + return string.IsNullOrEmpty(linq) ? source : source.OrderBy(linq); + } + + /// + /// Adds a dynamic query order-by (basic dynamic OData-like $orderby statement). + /// + /// The element . + /// The sequence of elements. + /// The that contains the parsed dynamic OData-like $orderby statement. + /// The query. + public static IQueryable OrderBy(this IQueryable source, QueryArgsParseResult result) + { + result.ThrowIfNull().ThrowOnError(); + if (result.OrderByResult is null) + return source; + + var linq = result.OrderByResult.ToLinqString(); + return string.IsNullOrEmpty(linq) ? source : source.OrderBy(linq); + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterEnumFieldConfigT.cs b/src/CoreEx.Data/Querying/QueryFilterEnumFieldConfigT.cs new file mode 100644 index 00000000..0b60f015 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterEnumFieldConfigT.cs @@ -0,0 +1,76 @@ + +namespace CoreEx.Data.Querying; + +/// +/// Provides the field configuration. +/// +/// The field type. +/// The owning . +/// The field name. +/// The model name (defaults to ). +public class QueryFilterEnumFieldConfig(QueryFilterParser parser, string field, string? model) + : QueryFilterFieldConfigBase>(parser, typeof(T), field, model) where T : notnull, Enum +{ + private Func? _converterFunc; + private Func? _valueFunc; + + /// + /// Sets (overrides) the operator . + /// + /// The supported (s). + /// The to support fluent-style method-chaining. + /// Defaults to the . + public QueryFilterEnumFieldConfig WithOperators(QueryFilterOperator operators) + { + Operators = operators; + return this; + } + + /// + /// Sets (overrides) the to convert the field value from a to the field type . + /// + /// The converter function. + /// The to support fluent-style method-chaining. + /// The is invoked before the as the resulting value is passed through to enable further conversion and/or validation where applicable. + public QueryFilterEnumFieldConfig WithConverter(Func? converter) + { + _converterFunc = converter; + return this; + } + + /// + /// Sets (overrides) the function to, a) further convert the field value to the final value that will be used in the LINQ query; and/or, b) to provide additional validation. + /// + /// The value function. + /// The final value that will be used in the LINQ query. + /// This is an opportunity to further validate the query as needed. Throw a to have the exception message formatted correctly and consistently. + /// This in invoked after the has been invoked. + public QueryFilterEnumFieldConfig WithValue(Func? value) + { + _valueFunc = value; + return this; + } + + /// + protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) + { + T value = default!; + var val = field.GetValueToken(filter); + + if (_converterFunc is null) + value = val is null ? default! : (T)Enum.Parse(typeof(T), val, true); + else + value = _converterFunc(val); + + // Convert the underlying type to the final value. + return _valueFunc?.Invoke(value) ?? value!; + } + + /// + public override IDictionary ToSchemaDictionary() + { + var dict = base.ToSchemaDictionary(); + dict["enum"] = Enum.GetNames(typeof(T)); + return dict; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterExtensions.cs b/src/CoreEx.Data/Querying/QueryFilterExtensions.cs deleted file mode 100644 index 7a06d7b4..00000000 --- a/src/CoreEx.Data/Querying/QueryFilterExtensions.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Data.Querying; -using CoreEx.Entities; -using System.Linq; -using System.Linq.Dynamic.Core; - -namespace System.Linq -{ - /// - /// Adds additional extension methods to the . - /// - public static class QueryFilterExtensions - { - /// - /// Adds a dynamic query filter as specified by the (uses the where not ). - /// - /// The being queried. - /// The query. - /// The . - /// The query. - public static IQueryable Where(this IQueryable query, QueryArgsParseResult result) - { - result.ThrowIfNull(nameof(result)); - if (result.FilterResult is not null) - { - var linq = result.FilterResult.ToLinqString(); - query = string.IsNullOrEmpty(linq) ? query : query.Where(linq, [.. result.FilterResult.Args]); - } - - return query; - } - - /// - /// Adds a dynamic query filter as specified by the (uses the where not ). - /// - /// The being queried. - /// The query. - /// The . - /// The . - /// The query. - public static IQueryable Where(this IQueryable query, QueryArgsConfig queryConfig, QueryArgs? queryArgs) => query.Where(queryConfig, queryArgs?.Filter); - - /// - /// Adds a dynamic query (basic dynamic OData-like $filter statement). - /// - /// The being queried. - /// The query. - /// The . - /// The basic dynamic OData-like $filter statement. - /// The query. - public static IQueryable Where(this IQueryable query, QueryArgsConfig queryConfig, string? filter) - { - queryConfig.ThrowIfNull(nameof(queryConfig)); - if (!queryConfig.HasFilterParser && !string.IsNullOrEmpty(filter)) - throw new QueryFilterParserException("Filter statement is not currently supported."); - - var result = queryConfig.FilterParser.Parse(filter).ThrowOnError(); - var linq = result.Value.ToLinqString(); - return string.IsNullOrEmpty(linq) ? query : query.Where(linq, [.. result.Value.Args]); - } - - /// - /// Adds a dynamic query order by as specified by the (uses the where not ). - /// - /// The being queried. - /// The query. - /// The . - /// The query. - public static IQueryable OrderBy(this IQueryable query, QueryArgsParseResult result) - { - result.ThrowIfNull(nameof(result)); - if (result.OrderByResult is not null) - { - var linq = result.OrderByResult.ToLinqString(); - query = string.IsNullOrEmpty(linq) ? query : query.OrderBy(linq); - } - - return query; - } - - /// - /// Adds a dynamic query order by as specified by the (uses the where not ). - /// - /// The being queried. - /// The query. - /// The . - /// The . - /// The query. - /// Where the is or is , then the will be used (where also not ). - public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig queryConfig, QueryArgs? queryArgs = null) => query.OrderBy(queryConfig, queryArgs?.OrderBy); - - /// - /// Adds a dynamic query order (basic dynamic OData-like $orderby statement). - /// - /// The being queried. - /// The query. - /// The . - /// The basic dynamic OData-like $orderby statement. - /// The query. - public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig queryConfig, string? orderby) - { - queryConfig.ThrowIfNull(nameof(queryConfig)); - if (!queryConfig.HasOrderByParser && !string.IsNullOrEmpty(orderby)) - throw new QueryOrderByParserException("OrderBy statement is not currently supported."); - - var result = queryConfig.OrderByParser.Parse(orderby).ThrowOnError(); - var linq = result.Value.ToLinqString(); - return string.IsNullOrEmpty(linq) ? query : query.OrderBy(linq); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs index b310d7b0..8ae661be 100644 --- a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs +++ b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs @@ -1,265 +1,322 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using CoreEx.Data.Querying.Expressions; -using CoreEx.Mapping.Converters; -using System; -using System.Text; - -namespace CoreEx.Data.Querying +/// +/// Provides the base field configuration. +/// +public abstract class QueryFilterFieldConfigBase : IQueryFilterFieldConfig { + private readonly QueryFilterParser _parser; + private readonly Type _type; + private readonly string _field; + private readonly string? _model; + /// - /// Provides the base field configuration. + /// Initializes a new instance of the class. /// - public abstract class QueryFilterFieldConfigBase : IQueryFilterFieldConfig + /// The owning . + /// The field type. + /// The field name. + /// The model name (defaults to ). + /// The defaults: + /// + /// Where is then defaults to . + /// Where is then defaults to . + /// Where is then defaults to . + /// Otherwise, is then defaults to . + /// + public QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string field, string? model) { - private readonly QueryFilterParser _parser; - private readonly Type _type; - private readonly string _field; - private readonly string? _model; - - /// - /// Initializes a new instance of the class. - /// - /// The owning . - /// The field type. - /// The field name. - /// The model name (defaults to . - public QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string field, string? model) + _parser = parser.ThrowIfNull(); + _type = type.ThrowIfNull(); + _field = field.ThrowIfNullOrEmpty(); + _model = model.ThrowIfEmpty(); + ModelPrefix = parser.DefaultModelPrefix; + + if (_type.IsEnum) { - _parser = parser.ThrowIfNull(nameof(parser)); - _type = type.ThrowIfNull(nameof(type)); - _field = field.ThrowIfNullOrEmpty(nameof(field)); - _model = model; - - IsTypeString = type == typeof(string); - IsTypeBoolean = type == typeof(bool); - - if (IsTypeBoolean) - Operators = QueryFilterOperator.Equal | QueryFilterOperator.NotEqual; - else - Operators = QueryFilterOperator.ComparisonOperators; + FieldType = QueryFilterFieldType.Enum; + Operators = QueryFilterOperator.Equal | QueryFilterOperator.NotEqual | QueryFilterOperator.In; } - - /// - QueryFilterParser IQueryFilterFieldConfig.Parser => _parser; - - /// - Type IQueryFilterFieldConfig.Type => Type; - - /// - /// Gets the field type. - /// - protected Type Type => _type; - - /// - string IQueryFilterFieldConfig.Field => Field; - - /// - /// Gets the field name. - /// - protected string Field => _field; - - /// - string? IQueryFilterFieldConfig.Model => Model; - - /// - /// Gets the model name to be used for the dynamic LINQ expression. - /// - protected string? Model => _model ?? _field; - - /// - bool IQueryFilterFieldConfig.IsTypeString => IsTypeString; - - /// - /// Indicates whether the field type is a . - /// - protected bool IsTypeString { get; set; } - - /// - /// Indicates whether the field type is a . - /// - bool IQueryFilterFieldConfig.IsTypeBoolean => IsTypeBoolean; - - /// - /// Indicates whether the field type is a . - /// - protected bool IsTypeBoolean { get; set; } - - /// - QueryFilterOperator IQueryFilterFieldConfig.Operators => Operators; - - /// - /// Gets the supported (s). - /// - /// Where defaults to both and ; otherwise, defaults to . - protected QueryFilterOperator Operators { get; set; } - - /// - bool IQueryFilterFieldConfig.IsToUpper => IsToUpper; - - /// - /// Indicates whether the comparison should ignore case or not (default); will use when selected for comparisons. - /// - /// This is only applicable where the . - protected bool IsToUpper { get; set; } = false; - - /// - bool IQueryFilterFieldConfig.IsNullable => IsNullable; - - /// - /// Indicates whether the field can be or not. - /// - protected bool IsNullable { get; set; } = false; - - /// - bool IQueryFilterFieldConfig.IsCheckForNotNull => IsCheckForNotNull; - - /// - /// Indicates whether a not- check should also be performed before the comparion occurs (defaults to false). - /// - protected bool IsCheckForNotNull { get; set; } = false; - - /// - Func? IQueryFilterFieldConfig.DefaultStatement => DefaultStatement; - - /// - /// Gets or sets the default LINQ function to be used where no filtering is specified. - /// - protected Func? DefaultStatement { get; set; } - - /// - QueryFilterFieldResultWriter? IQueryFilterFieldConfig.ResultWriter => ResultWriter; - - /// - /// Gets or sets the . - /// - protected QueryFilterFieldResultWriter? ResultWriter { get; set; } - - /// - string? IQueryFilterFieldConfig.HelpText => HelpText; - - /// - /// Gets or sets the additional help text. - /// - protected string? HelpText { get; set; } - - /// - object? IQueryFilterFieldConfig.ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) => ConvertToValue(operation, field, filter); - - /// - /// Converts to the destination type using the configurations where specified. - /// - /// The operation being performed on the . - /// The field . - /// The query filter. - /// The converted value. - /// Note: A converted value of is considered invalid and will result in an . - protected abstract object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter); - - /// - /// Validate the token against the field configuration. - /// - /// The field . - /// The constant . - /// The query filter. - void IQueryFilterFieldConfig.ValidateConstant(QueryFilterToken field, QueryFilterToken constant, string filter) + else if (_type == typeof(string)) { - if (!QueryFilterTokenKind.Constant.HasFlag(constant.Kind)) - throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' is not considered valid."); - - if (constant.Kind == QueryFilterTokenKind.Null && !IsNullable) - throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' is not supported."); + FieldType = QueryFilterFieldType.String; + Operators = QueryFilterOperator.ComparisonOperators; + } + else if (_type == typeof(bool)) + { + FieldType = QueryFilterFieldType.Boolean; + Operators = QueryFilterOperator.Equal | QueryFilterOperator.NotEqual; + SchemaType = QueryFilterSchemaType.Boolean; + } + else + { + FieldType = QueryFilterFieldType.Other; + Operators = QueryFilterOperator.ComparisonOperators; - if (IsTypeString) + if (_type == typeof(DateTime) || _type == typeof(DateTimeOffset)) { - if (!(constant.Kind == QueryFilterTokenKind.Literal || constant.Kind == QueryFilterTokenKind.Null)) - throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' must be specified as a {QueryFilterTokenKind.Literal} where the underlying type is a string."); + SchemaType = QueryFilterSchemaType.String; + SchemaFormat = "date-time"; } - else if (IsTypeBoolean) + else if (_type == typeof(DateOnly)) { - if (!(constant.Kind == QueryFilterTokenKind.True || constant.Kind == QueryFilterTokenKind.False || constant.Kind == QueryFilterTokenKind.Null)) - throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' is not considered a valid boolean."); + SchemaType = QueryFilterSchemaType.String; + SchemaFormat = "date"; } - else + else if (IsIntegerType(_type)) { - if (!(constant.Kind == QueryFilterTokenKind.Value || constant.Kind == QueryFilterTokenKind.Null)) - throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' must not be specified as a {QueryFilterTokenKind.Literal} where the underlying type is not a string."); + SchemaType = QueryFilterSchemaType.Integer; + SchemaFormat = _type.Name.ToLowerInvariant(); + } + else if (IsNumberType(_type)) + { + SchemaType = QueryFilterSchemaType.Number; + SchemaFormat = _type.Name.ToLowerInvariant(); } } + } - /// - public override string ToString() => AppendToString(new StringBuilder()).ToString(); + /// + /// Indicates whether the specified is an integer type. + /// + private static bool IsIntegerType(Type type) => type == typeof(int) || type == typeof(long) || type == typeof(short) || type == typeof(byte) || + type == typeof(uint) || type == typeof(ulong) || type == typeof(ushort) || + type == typeof(int?) || type == typeof(long?) || type == typeof(short?) || type == typeof(byte?) || + type == typeof(uint?) || type == typeof(ulong?) || type == typeof(ushort?); - /// - /// Appends the field configuration to the . - /// - /// The . - /// The . - public virtual StringBuilder AppendToString(StringBuilder stringBuilder) - { - stringBuilder.Append(_field); - stringBuilder.Append(" (Type: ").Append(_type.Name); - stringBuilder.Append(", Null: ").Append(IsNullable ? "true" : "false"); - stringBuilder.Append(", Operators: "); + /// + /// Indicates whether the specified is a number type. + /// + private static bool IsNumberType(Type type) => type == typeof(double) || type == typeof(decimal) || type == typeof(float) || + type == typeof(double?) || type == typeof(decimal?) || type == typeof(float?); - AppendOperatorsToString(stringBuilder); + /// + QueryFilterParser IQueryFilterFieldConfig.Parser => _parser; - stringBuilder.Append(')'); - if (!string.IsNullOrEmpty(HelpText)) - stringBuilder.Append(" - ").Append(HelpText); + /// + Type IQueryFilterFieldConfig.Type => Type; - return stringBuilder; - } + /// + /// Gets the field type. + /// + protected Type Type => _type; - /// - /// Appends the to the . - /// - /// The . - /// The . - protected StringBuilder AppendOperatorsToString(StringBuilder stringBuilder) - { - var first = true; -#if NET6_0_OR_GREATER - foreach (var e in Enum.GetValues()) -#else - foreach (var e in Enum.GetValues(typeof(QueryFilterOperator))) -#endif - { - if (Operators.HasFlag((QueryFilterOperator)e)) - { - var op = GetODataOperator((QueryFilterOperator)e); - if (op is not null) - { - if (first) - first = false; - else - stringBuilder.Append(", "); - - stringBuilder.Append(op); - } - } - } + /// + QueryFilterFieldType IQueryFilterFieldConfig.FieldType => FieldType; + + /// + /// Gets the . + /// + protected QueryFilterFieldType FieldType { get; set; } - return stringBuilder; + /// + string IQueryFilterFieldConfig.Field => Field; + + /// + /// Gets the field name. + /// + protected string Field => _field; + + /// + string IQueryFilterFieldConfig.Model => Model; + + /// + /// Gets the model name to be used for the dynamic LINQ expression. + /// + protected string Model => _model ?? _field; + + /// + public string? ModelPrefix { get; protected set; } + + /// + public string FullyQualifiedModelName => ModelPrefix is null ? Model : $"{ModelPrefix}.{Model}"; + + /// + QueryFilterOperator IQueryFilterFieldConfig.Operators => Operators; + + /// + /// Gets the supported (s). + /// + protected QueryFilterOperator Operators { get; set; } + + /// + bool? IQueryFilterFieldConfig.IsToUpper => IsToUpper; + + /// + /// Indicates whether the comparison should ignore case or not; will use (where ) or (where ) when selected for comparisons. + /// + protected bool? IsToUpper { get; set; } + + /// + bool IQueryFilterFieldConfig.IsNullable => IsNullable; + + /// + /// Indicates whether the field can be or not. + /// + protected bool IsNullable { get; set; } = false; + + /// + bool IQueryFilterFieldConfig.IsCheckForNotNull => IsCheckForNotNull; + + /// + /// Indicates whether a not- check should also be performed before the comparion occurs (defaults to ). + /// + protected bool IsCheckForNotNull { get; set; } = false; + + /// + Func? IQueryFilterFieldConfig.DefaultStatement => DefaultStatement; + + /// + /// Gets or sets the default LINQ function to be used where no filtering is specified. + /// + protected Func? DefaultStatement { get; set; } + + /// + QueryFilterFieldResultWriter? IQueryFilterFieldConfig.ResultWriter => ResultWriter; + + /// + /// Gets or sets the . + /// + protected QueryFilterFieldResultWriter? ResultWriter { get; set; } + + /// + QueryFilterSchemaType IQueryFilterFieldConfig.SchemaType => SchemaType; + + /// + /// Gets or sets the . + /// + protected QueryFilterSchemaType SchemaType { get; set; } = QueryFilterSchemaType.String; + + /// + string? IQueryFilterFieldConfig.SchemaFormat => SchemaFormat; + + /// + /// Gets or sets the corresponding format for the (where applicable). + /// + protected string? SchemaFormat { get; set; } + + /// + string? IQueryFilterFieldConfig.HelpText => HelpText; + + /// + /// Gets or sets the additional help text. + /// + protected string? HelpText { get; set; } + + /// + object? IQueryFilterFieldConfig.ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) => ConvertToValue(operation, field, filter); + + /// + /// Converts to the underlying type. + /// + /// The operation being performed on the . + /// The field . + /// The query filter. + /// The converted value. + /// Note: A converted value of is considered invalid and will result in an . + protected abstract object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter); + + /// + /// Validate the token against the field configuration. + /// + /// The field . + /// The constant . + /// The query filter. + void IQueryFilterFieldConfig.ValidateConstant(QueryFilterToken field, QueryFilterToken constant, string filter) + { + if (!QueryFilterTokenKind.Constant.HasFlag(constant.Kind)) + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter)}' constant '{constant.GetValueToken(filter)}' is not considered valid."); + + if (constant.Kind == QueryFilterTokenKind.Null && !IsNullable) + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter)}' constant '{constant.GetValueToken(filter)}' is not supported."); + + if (FieldType == QueryFilterFieldType.String || FieldType == QueryFilterFieldType.Enum) + { + if (!(constant.Kind == QueryFilterTokenKind.Literal || constant.Kind == QueryFilterTokenKind.Null)) + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter)}' constant '{constant.GetValueToken(filter)}' must be specified as a {QueryFilterTokenKind.Literal} where the underlying type is a string."); } + else if (FieldType == QueryFilterFieldType.Boolean) + { + if (!(constant.Kind == QueryFilterTokenKind.True || constant.Kind == QueryFilterTokenKind.False || constant.Kind == QueryFilterTokenKind.Null)) + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter)}' constant '{constant.GetValueToken(filter)}' is not considered a valid boolean."); + } + else + { + if (!(constant.Kind == QueryFilterTokenKind.Value || constant.Kind == QueryFilterTokenKind.Null)) + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter)}' constant '{constant.GetValueToken(filter)}' must not be specified as a {QueryFilterTokenKind.Literal} where the underlying type is not a string."); + } + } + + /// + public override string ToString() => AppendToString(new StringBuilder()).ToString(); + + /// + /// Appends the field configuration to the . + /// + /// The . + /// The . + public virtual StringBuilder AppendToString(StringBuilder stringBuilder) + { + stringBuilder.Append(_field); + stringBuilder.Append(" (Type: ").Append(_type.Name); + stringBuilder.Append(", Null: ").Append(IsNullable ? "true" : "false"); + stringBuilder.Append(", Operators: "); + + AppendOperatorsToString(stringBuilder); + + stringBuilder.Append(')'); + if (!string.IsNullOrEmpty(HelpText)) + stringBuilder.Append(" - ").Append(HelpText); + + return stringBuilder; + } - /// - /// Gets the ODATA operator for the specified - /// - /// The . - protected static string? GetODataOperator(QueryFilterOperator @operator) => @operator switch + /// + /// Appends the to the . + /// + /// The . + /// The . + protected StringBuilder AppendOperatorsToString(StringBuilder stringBuilder) + => stringBuilder.Append(String.Join(", ", Enum.GetValues().Where(x => Operators.HasFlag(x)).Select(x => GetODataOperator(x)).Where(x => x is not null))); + + /// + /// Gets the ODATA operator for the specified + /// + /// The . + protected static string? GetODataOperator(QueryFilterOperator @operator) => @operator switch + { + QueryFilterOperator.Equal => "EQ", + QueryFilterOperator.NotEqual => "NE", + QueryFilterOperator.GreaterThan => "GT", + QueryFilterOperator.GreaterThanOrEqual => "GE", + QueryFilterOperator.LessThan => "LT", + QueryFilterOperator.LessThanOrEqual => "LE", + QueryFilterOperator.In => "IN", + QueryFilterOperator.StartsWith => nameof(QueryFilterOperator.StartsWith), + QueryFilterOperator.EndsWith => nameof(QueryFilterOperator.EndsWith), + QueryFilterOperator.Contains => nameof(QueryFilterOperator.Contains), + _ => null + }; + + /// + public virtual IDictionary ToSchemaDictionary() + { + var dict = new Dictionary { - QueryFilterOperator.Equal => "EQ", - QueryFilterOperator.NotEqual => "NE", - QueryFilterOperator.GreaterThan => "GT", - QueryFilterOperator.GreaterThanOrEqual => "GE", - QueryFilterOperator.LessThan => "LT", - QueryFilterOperator.LessThanOrEqual => "LE", - QueryFilterOperator.In => "IN", - QueryFilterOperator.StartsWith => nameof(QueryFilterOperator.StartsWith), - QueryFilterOperator.EndsWith => nameof(QueryFilterOperator.EndsWith), - QueryFilterOperator.Contains => nameof(QueryFilterOperator.Contains), - _ => null + { "type", SchemaType.ToString().ToLowerInvariant() }, }; - } + + if (SchemaFormat is not null) + dict.Add("format", SchemaFormat); + + if (IsNullable) + dict.Add("nullable", true); + + dict.Add("operators", Enum.GetValues().Where(x => Operators.HasFlag(x)).Select(x => GetODataOperator(x)?.ToLowerInvariant()).Where(x => x is not null).ToArray()); + + if (HelpText is not null) + dict.Add("description", HelpText); + + return dict; + } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs index 0d59cc21..ba5c966c 100644 --- a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs +++ b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs @@ -1,86 +1,113 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using CoreEx.Data.Querying.Expressions; -using System; - -namespace CoreEx.Data.Querying +/// +/// Provides the base field configuration extending with fluent-style method-chaining capabilities. +/// +/// The self for support fluent-style method-chaining. +/// The owning . +/// The field type. +/// The field name. +/// The model name (defaults to ). +public abstract class QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string field, string? model) + : QueryFilterFieldConfigBase(parser, type, field, model) where TSelf : QueryFilterFieldConfigBase { /// - /// Provides the base field configuration extending with fluent-style method-chaining capabilities. + /// Sets (overrides) the optional to be used where referencing the underlying model. /// - /// The self for support fluent-style method-chaining. - /// The owning . - /// The field type. - /// The field name. - /// The model name (defaults to . - public abstract class QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string field, string? model) - : QueryFilterFieldConfigBase(parser, type, field, model) where TSelf : QueryFilterFieldConfigBase + /// The model prefix. + /// The to support fluent-style method-chaining. + public TSelf WithModelPrefix(string? modelPrefix = null) { - /// - /// Indicates that the field can be . - /// - /// The to support fluent-style method-chaining. - /// Sets the to . - public TSelf AsNullable() - { - IsNullable = true; - return (TSelf)this; - } + ModelPrefix = modelPrefix; + return (TSelf)this; + } - /// - /// Indicates that a not- check should also be performed before a comparion occurs. - /// - /// The to support fluent-style method-chaining. - /// Sets the and to . - public TSelf AlsoCheckNotNull() - { - IsCheckForNotNull = true; - IsNullable = true; - return (TSelf)this; - } + /// + /// Indicates that the field can be . + /// + /// The to support fluent-style method-chaining. + /// Sets the to . + public TSelf AsNullable() + { + IsNullable = true; + return (TSelf)this; + } + + /// + /// Indicates that a not- check should also be performed before a comparion occurs. + /// + /// The to support fluent-style method-chaining. + /// Sets the and to . + public TSelf AlsoCheckNotNull() + { + IsCheckForNotNull = true; + IsNullable = true; + return (TSelf)this; + } + + /// + /// Sets (overrides) the default LINQ statement to be used where no filtering is specified. + /// + /// The LINQ . + /// The to support fluent-style method-chaining. + /// To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement. + /// This must be the required expression only. It will be appended as an and to the final LINQ statement. + public TSelf WithDefault(QueryStatement? statement) => WithDefault(statement is null ? null : () => statement); + + /// + /// Sets (overrides) the default LINQ statement function to be used where no filtering is specified. + /// + /// The LINQ . + /// The to support fluent-style method-chaining. + /// To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement. + /// This must be the required expression only. It will be appended as an and to the final LINQ statement. + public TSelf WithDefault(Func? statement) + { + DefaultStatement = statement; + return (TSelf)this; + } - /// - /// Sets (overrides) the default LINQ statement to be used where no filtering is specified. - /// - /// The LINQ . - /// The to support fluent-style method-chaining. - /// To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement. - /// This must be the required expression only. It will be appended as an and to the final LINQ statement. - public TSelf WithDefault(QueryStatement? statement) => WithDefault(statement is null ? null : () => statement); + /// + /// Sets (overrides) the function that will be used to write the dynamic LINQ statement. + /// + /// The . + /// The to support fluent-style method-chaining. + public TSelf WithResultWriter(QueryFilterFieldResultWriter? resultWriter) + { + ResultWriter = resultWriter; + return (TSelf)this; + } - /// - /// Sets (overrides) the default LINQ statement function to be used where no filtering is specified. - /// - /// The LINQ . - /// The to support fluent-style method-chaining. - /// To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement. - /// This must be the required expression only. It will be appended as an and to the final LINQ statement. - public TSelf WithDefault(Func? statement) - { - DefaultStatement = statement; - return (TSelf)this; - } + /// + /// Sets (overrides) the . + /// + /// The . + /// The to support fluent-style method-chaining. + public TSelf WithSchemaType(QueryFilterSchemaType schemaType) + { + SchemaType = schemaType; + return (TSelf)this; + } - /// - /// Sets (overrides) the function that will be used to write the LINQ statement to the . - /// - /// The . - /// The to support fluent-style method-chaining. - public TSelf WithResultWriter(QueryFilterFieldResultWriter? resultWriter) - { - ResultWriter = resultWriter; - return (TSelf)this; - } + /// + /// Sets (overrides) the . + /// + /// The schema format. + /// The to support fluent-style method-chaining. + public TSelf WithSchemaFormat(string? schemaFormat) + { + SchemaFormat = schemaFormat; + return (TSelf)this; + } - /// - /// Sets (overrides) the additional help text. - /// - /// The additional help text. - /// The to support fluent-style method-chaining. - public TSelf WithHelpText(string text) - { - HelpText = text.ThrowIfNullOrEmpty(nameof(text)); - return (TSelf)this; - } + /// + /// Sets (overrides) any additional help text. + /// + /// The additional help text. + /// The to support fluent-style method-chaining. + public TSelf WithHelpText(string text) + { + HelpText = text.ThrowIfNullOrEmpty(); + return (TSelf)this; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs deleted file mode 100644 index 431e6ff7..00000000 --- a/src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Data.Querying.Expressions; -using CoreEx.Mapping.Converters; -using System; - -namespace CoreEx.Data.Querying -{ - /// - /// Provides the field configuration. - /// - /// The field type. - /// The owning . - /// The field name. - /// The model name (defaults to . - public class QueryFilterFieldConfig(QueryFilterParser parser, string field, string? model) : QueryFilterFieldConfigBase>(parser, typeof(T), field, model) - { - private IConverter _converter = StringToTypeConverter.Default; - private Func? _valueFunc; - - /// - /// Sets (overrides) the operator . - /// - /// The supported (s). - /// The to support fluent-style method-chaining. - /// The default is . - public QueryFilterFieldConfig WithOperators(QueryFilterOperator operators) - { - if (((IQueryFilterFieldConfig)this).IsTypeBoolean) - throw new NotSupportedException($"{nameof(WithOperators)} is not supported where {nameof(IQueryFilterFieldConfig.IsTypeBoolean)}."); - - Operators = operators; - return this; - } - - /// - /// Indicates that the operation should ignore case by performing an explicit comparison and value conversion. - /// - /// The to support fluent-style method-chaining. - /// Sets the to . - public QueryFilterFieldConfig WithUpperCase() - { - if (!((IQueryFilterFieldConfig)this).IsTypeString) - throw new ArgumentException($"A {nameof(WithUpperCase)} can only be specified where the field type is a string."); - - IsToUpper = true; - return this; - } - - /// - /// Sets (overrides) the to convert the field value from a to the field type . - /// - /// The . - /// The to support fluent-style method-chaining. - /// The is invoked before the as the resulting value is passed through to enable further conversion and/or validation where applicable. - public QueryFilterFieldConfig WithConverter(IConverter converter) - { - _converter = converter.ThrowIfNull(nameof(converter)); - return this; - } - - /// - /// Sets (overrides) the function to, a) further convert the field value to the final value that will be used in the LINQ query; and/or, b) to provide additional validation. - /// - /// The value function. - /// The final value that will be used in the LINQ query. - /// This is an opportunity to further validate the query as needed. Throw a to have the validation message formatted correctly and consistently. - /// This in invoked after the has been invoked. - public QueryFilterFieldConfig WithValue(Func? value) - { - _valueFunc = value; - return this; - } - - /// - protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) - { - // Convert from string to the underlying type and consider the upper case requirements. - T value = _converter.ConvertToDestination(field.GetValueToken(filter)); - if (typeof(T) == typeof(string)) - { - var str = value?.ToString(); - if (str is null) - return null!; - - if (IsToUpper) - str = str?.ToUpper(System.Globalization.CultureInfo.CurrentCulture); - - value = _converter.ConvertToDestination(str!); - return _valueFunc?.Invoke(value) ?? value!; - } - - // Convert the underlying type to the final value. - return _valueFunc?.Invoke(value) ?? value!; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldResultWriter.cs b/src/CoreEx.Data/Querying/QueryFilterFieldResultWriter.cs index 6a004e58..e1b355b0 100644 --- a/src/CoreEx.Data/Querying/QueryFilterFieldResultWriter.cs +++ b/src/CoreEx.Data/Querying/QueryFilterFieldResultWriter.cs @@ -1,15 +1,9 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using CoreEx.Data.Querying.Expressions; - -namespace CoreEx.Data.Querying -{ - /// - /// The writing function that will be used to write the to the . - /// - /// The expression. - /// The . - /// indicates that a LINQ statement has been written to the and that the standard writer should not be invoked; - /// otherwise, indicates that the standard writer is to be invoked. - public delegate bool QueryFilterFieldResultWriter(IQueryFilterFieldStatementExpression expression, QueryFilterParserResult result); -} \ No newline at end of file +/// +/// The function that will be used to write the as dynamic LINQ. +/// +/// The expression. +/// The . +/// indicates that a LINQ statement has been written and that the standard writer should not be invoked; otherwise, indicates that the standard writer is to be invoked. +public delegate bool QueryFilterFieldResultWriter(IQueryFilterFieldStatementExpression expression, QueryFilterParserWriter writer); \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldType.cs b/src/CoreEx.Data/Querying/QueryFilterFieldType.cs new file mode 100644 index 00000000..a3e23f26 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterFieldType.cs @@ -0,0 +1,27 @@ +namespace CoreEx.Data.Querying; + +/// +/// Represents the field type for a . +/// +public enum QueryFilterFieldType +{ + /// + /// Indicates a . + /// + String, + + /// + /// Indicates a . + /// + Boolean, + + /// + /// Indicates a . + /// + Enum, + + /// + /// Indicates another type. + /// + Other +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs b/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs index 2eb6d81c..1273dd0e 100644 --- a/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs +++ b/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs @@ -1,52 +1,45 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using System; -using System.Data; -using System.Text; -using CoreEx.Data.Querying.Expressions; - -namespace CoreEx.Data.Querying +/// +/// Provides the null only comparison field configuration. +/// +public class QueryFilterNullFieldConfig : QueryFilterFieldConfigBase { /// - /// Provides the null only comparison field configuration. + /// Initializes a new instance of the class. /// - public class QueryFilterNullFieldConfig : QueryFilterFieldConfigBase + /// The owning . + /// The field name. + /// The model name (defaults to . + public QueryFilterNullFieldConfig(QueryFilterParser parser, string field, string? model) : base(parser, typeof(object), field, model) { - /// - /// Initializes a new instance of the class. - /// - /// The owning . - /// The field name. - /// The model name (defaults to . - public QueryFilterNullFieldConfig(QueryFilterParser parser, string field, string? model) : base(parser, typeof(object), field, model) - { - Operators = QueryFilterOperator.Equal | QueryFilterOperator.NotEqual; - IsNullable = true; - } + Operators = QueryFilterOperator.Equal | QueryFilterOperator.NotEqual; + IsNullable = true; + SchemaType = QueryFilterSchemaType.Object; + SchemaFormat = null; + } - /// - protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) - => throw new FormatException("Only null comparisons are supported."); + /// + protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) => throw new ValidationException("Only null comparisons are supported."); - /// - /// Appends the field configuration to the . - /// - /// The . - /// The . - public override StringBuilder AppendToString(StringBuilder stringBuilder) - { - stringBuilder.Append(Field); - stringBuilder.Append(" (Type: "); - stringBuilder.Append(", Null: ").Append(IsNullable ? "true" : "false"); - stringBuilder.Append(", Operators: "); + /// + /// Appends the field configuration to the . + /// + /// The . + /// The . + public override StringBuilder AppendToString(StringBuilder stringBuilder) + { + stringBuilder.Append(Field); + stringBuilder.Append(" (Type: "); + stringBuilder.Append(", Null: ").Append(IsNullable ? "true" : "false"); + stringBuilder.Append(", Operators: "); - AppendOperatorsToString(stringBuilder); + AppendOperatorsToString(stringBuilder); - stringBuilder.Append(')'); - if (!string.IsNullOrEmpty(HelpText)) - stringBuilder.Append(" - ").Append(HelpText); + stringBuilder.Append(')'); + if (!string.IsNullOrEmpty(HelpText)) + stringBuilder.Append(" - ").Append(HelpText); - return stringBuilder; - } + return stringBuilder; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterOperator.cs b/src/CoreEx.Data/Querying/QueryFilterOperator.cs index 9b8f3deb..ef428d16 100644 --- a/src/CoreEx.Data/Querying/QueryFilterOperator.cs +++ b/src/CoreEx.Data/Querying/QueryFilterOperator.cs @@ -1,83 +1,84 @@ -using System; -using CoreEx.Data.Querying.Expressions; +namespace CoreEx.Data.Querying; -namespace CoreEx.Data.Querying +/// +/// Enables the . +/// +/// Values are the same as the to simplify usage. +[Flags] +public enum QueryFilterOperator { /// - /// Enables the . + /// The equal operator. /// - /// Values are the same as the to simplify usage. - [Flags] - public enum QueryFilterOperator - { - /// - /// The equal operator. - /// - Equal = 4, + Equal = 4, - /// - /// The not equal operator. - /// - NotEqual = 8, + /// + /// The not equal operator. + /// + NotEqual = 8, - /// - /// The less than operator. - /// - LessThan = 16, + /// + /// The less than operator. + /// + LessThan = 16, - /// - /// The less than or equal operator. - /// - LessThanOrEqual = 32, + /// + /// The less than or equal operator. + /// + LessThanOrEqual = 32, - /// - /// The greater than or equal operator. - /// - GreaterThanOrEqual = 64, + /// + /// The greater than or equal operator. + /// + GreaterThanOrEqual = 64, - /// - /// The greater than operator. - /// - GreaterThan = 128, + /// + /// The greater than operator. + /// + GreaterThan = 128, - /// - /// The logical IN operator. - /// - In = 256, + /// + /// The logical IN operator. + /// + In = 256, - /// - /// The starts with function. - /// - StartsWith = 524288, + /// + /// The starts with function. + /// + StartsWith = 524288, - /// - /// The contains function. - /// - Contains = 1048576, + /// + /// The contains function. + /// + Contains = 1048576, - /// - /// The ends with function. - /// - EndsWith = 2097152, + /// + /// The ends with function. + /// + EndsWith = 2097152, - /// - /// The equality operators. - /// - EqualityOperators = Equal | NotEqual | In, + /// + /// The equality operators. + /// + BooleanEqualityOperators = Equal | NotEqual, + + /// + /// The equality operators. + /// + EqualityOperators = Equal | NotEqual | In, - /// - /// The comparison operators. - /// - ComparisonOperators = EqualityOperators | GreaterThan | GreaterThanOrEqual | LessThan | LessThanOrEqual, + /// + /// The comparison operators. + /// + ComparisonOperators = EqualityOperators | GreaterThan | GreaterThanOrEqual | LessThan | LessThanOrEqual, - /// - /// The string oriented function-based operators. - /// - StringFunctions = StartsWith | EndsWith | Contains, + /// + /// The -specific function-based operators. + /// + StringFunctions = StartsWith | EndsWith | Contains, - /// - /// All string oriented operators and functions. - /// - AllStringOperators = ComparisonOperators | StringFunctions - } + /// + /// All -specific operators and functions. + /// + AllStringOperators = ComparisonOperators | StringFunctions, } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterParseableFieldConfigT.cs b/src/CoreEx.Data/Querying/QueryFilterParseableFieldConfigT.cs new file mode 100644 index 00000000..24fa031f --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterParseableFieldConfigT.cs @@ -0,0 +1,121 @@ +namespace CoreEx.Data.Querying; + +/// +/// Provides the field configuration. +/// +/// The field type. +/// The owning . +/// The field name. +/// The model name (defaults to ). +public class QueryFilterParseableFieldConfig(QueryFilterParser parser, string field, string? model) + : QueryFilterFieldConfigBase>(parser, typeof(T), field, model) where T : notnull, IParsable +{ + private Func? _converterFunc; + private Func? _valueFunc; + + /// + /// Sets (overrides) the operator . + /// + /// The supported (s). + /// The to support fluent-style method-chaining. + /// The defaults are: + /// + /// Where is then defaults to . + /// Where is then defaults to . + /// Otherwise, is then defaults to . + /// + public QueryFilterParseableFieldConfig WithOperators(QueryFilterOperator operators) + { + if (((IQueryFilterFieldConfig)this).FieldType == QueryFilterFieldType.Boolean) + throw new NotSupportedException($"{nameof(WithOperators)} is not supported where {nameof(QueryFilterFieldType.Boolean)}."); + + Operators = operators; + return this; + } + + /// + /// Indicates that the operation should ignore case by performing an explicit comparison and value conversion. + /// + /// The to support fluent-style method-chaining. + /// Sets the to . + public QueryFilterParseableFieldConfig AsUpperCase() + { + if (((IQueryFilterFieldConfig)this).FieldType != QueryFilterFieldType.String) + throw new ArgumentException($"A {nameof(AsUpperCase)} can only be specified where the field type is a string."); + + IsToUpper = true; + return this; + } + + /// + /// Indicates that the operation should ignore case by performing an explicit comparison and value conversion. + /// + /// The to support fluent-style method-chaining. + /// Sets the to . + public QueryFilterParseableFieldConfig AsLowerCase() + { + if (((IQueryFilterFieldConfig)this).FieldType != QueryFilterFieldType.String) + throw new ArgumentException($"A {nameof(AsLowerCase)} can only be specified where the field type is a string."); + + IsToUpper = false; + return this; + } + + /// + /// Sets (overrides) the to convert the field value from a to the field type . + /// + /// The converter function. + /// The to support fluent-style method-chaining. + /// The is invoked before the as the resulting value is passed through to enable further conversion and/or validation where applicable. + public QueryFilterParseableFieldConfig WithConverter(Func? converter) + { + _converterFunc = converter; + return this; + } + + /// + /// Sets (overrides) the function to, a) further convert the field value to the final value that will be used in the LINQ query; and/or, b) to provide additional validation. + /// + /// The value function. + /// The to support fluent-style method-chaining. + /// This is an opportunity to further validate the query as needed. Throw a to have the exception message formatted correctly and consistently. + /// This in invoked after the has been invoked. + public QueryFilterParseableFieldConfig WithValue(Func? value) + { + _valueFunc = value; + return this; + } + + /// + protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) + { + T value = default!; + var val = field.GetValueToken(filter); + + if (_converterFunc is null) + value = val is null ? default! : T.Parse(val, null); + else + value = _converterFunc(val); + + // Convert from string to the underlying type and consider the casing requirements. + if (typeof(T) == typeof(string)) + { + var str = value?.ToString(); + if (str is null) + return null!; + + if (IsToUpper.HasValue) + { + if (IsToUpper.Value) + value = T.Parse(str!.ToUpperInvariant(), null)!; + else + value = T.Parse(str!.ToLowerInvariant(), null)!; + } + + return _valueFunc?.Invoke(value!) ?? value!; + } + + // Convert the underlying type to the final value. + return _valueFunc?.Invoke(value) ?? value!; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterParser.cs b/src/CoreEx.Data/Querying/QueryFilterParser.cs index da69fe7e..d231c825 100644 --- a/src/CoreEx.Data/Querying/QueryFilterParser.cs +++ b/src/CoreEx.Data/Querying/QueryFilterParser.cs @@ -1,481 +1,535 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Data.Querying.Expressions; -using CoreEx.RefData; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; - -namespace CoreEx.Data.Querying +namespace CoreEx.Data.Querying; + +/// +/// Represents a basic query filter parser and LINQ translator with explicitly defined field support. +/// +/// Enables basic query filtering with similar syntax to the OData $filter. +/// Support is limited to the filter tokens as specified by the . +/// This is not intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to filter an underlying query. +/// Example configuration is as follows: +/// +/// private static readonly QueryArgsConfig _config = QueryArgsConfig.Create() +/// .WithFilter(filter => filter +/// .AddField<string>(nameof(Employee.LastName), c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) +/// .AddField<string>(nameof(Employee.FirstName), c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) +/// .AddField<DateTime>(nameof(Employee.StartDate)) +/// .AddNullField(nameof(Employee.Termination), nameof(EfModel.Employee.TerminationDate), c => c.Default(new QueryStatement($"{nameof(EfModel.Employee.TerminationDate)} is null")))); +/// +/// The owning . +public sealed class QueryFilterParser(QueryArgsConfig owner) { + private readonly Dictionary _fields = new(StringComparer.OrdinalIgnoreCase); + private Func? _defaultStatement; + private Action? _onQuery; + private string? _helpText; + + /// + /// Gets the owning . + /// + public QueryArgsConfig Owner => owner.ThrowIfNull(); + /// - /// Represents a basic query filter parser and LINQ translator with explicitly defined field support. + /// Indicates that at least a single field has been configured. /// - /// Enables basic query filtering with similar syntax to the OData $filter. - /// Support is limited to the filter tokens as specified by the . - /// This is not intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to filter an underlying query. - /// Example configuration is as follows: - /// - /// private static readonly QueryArgsConfig _config = QueryArgsConfig.Create() - /// .WithFilter(filter => filter - /// .AddField<string>(nameof(Employee.LastName), c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) - /// .AddField<string>(nameof(Employee.FirstName), c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) - /// .AddReferenceDataField<Gender>(nameof(Employee.Gender), nameof(EfModel.Employee.GenderCode), c => c.MustBeValid()) - /// .AddField<DateTime>(nameof(Employee.StartDate)) - /// .AddNullField(nameof(Employee.Termination), nameof(EfModel.Employee.TerminationDate), c => c.Default(new QueryStatement($"{nameof(EfModel.Employee.TerminationDate)} == null")))); - /// - /// The owning . - public sealed class QueryFilterParser(QueryArgsConfig owner) + public bool HasFields => _fields.Count > 0; + + /// + /// Gets the default model prefix (if any). + /// + /// This will be automatically applied to all subsequent field additions; for example . + public string? DefaultModelPrefix { get; private set; } + + /// + /// Sets (overrides) the (if any). + /// + /// + /// The to support fluent-style method-chaining. + public QueryFilterParser WithDefaultModelPrefix(string? modelPrefix = null) { - private readonly Dictionary _fields = new(StringComparer.OrdinalIgnoreCase); - private Func? _defaultStatement; - private Action? _onQuery; - private string? _helpText; - - /// - /// Gets the owning . - /// - public QueryArgsConfig Owner => owner.ThrowIfNull(nameof(owner)); - - /// - /// Indicates that at least a single field has been configured. - /// - public bool HasFields => _fields.Count > 0; - - /// - /// Adds a to the parser for the specified as-is. - /// - /// The field name used in the query filter specified with the correct casing. - /// The optional action enabling further field configuration. - /// The to support fluent-style method-chaining. - public QueryFilterParser AddField(string field, Action>? configure = null) where T : notnull => AddField(field, null, configure); - - /// - /// Adds a to the parser using the specified and (overrides the ). - /// - /// The field name used in the query filter. - /// The model name (defaults to ). - /// The optional action to perform further field configuration. - /// The to support fluent-style method-chaining. - public QueryFilterParser AddField(string field, string? model, Action>? configure = null) where T : notnull - { - var config = new QueryFilterFieldConfig(this, field, model); - configure?.Invoke(config); - _fields.Add(field, config); - return this; - } + DefaultModelPrefix = modelPrefix; + return this; + } - /// - /// Adds a to the parser for the specified as-is. - /// - /// The field name used in the query filter specified with the correct casing. - /// The optional action enabling further field configuration. - /// The to support fluent-style method-chaining. - public QueryFilterParser AddReferenceDataField(string field, Action>? configure = null) where TRef : IReferenceData, new() => AddReferenceDataField(field, null, configure); - - /// - /// Adds a to the parser using the specified and (overrides the ). - /// - /// The field name used in the query filter. - /// The model name (defaults to ). - /// The optional action to perform further field configuration. - /// The to support fluent-style method-chaining. - public QueryFilterParser AddReferenceDataField(string field, string? model, Action>? configure = null) where TRef : IReferenceData, new() - { - var config = new QueryFilterReferenceDataFieldConfig(this, field, model); - configure?.Invoke(config); - _fields.Add(field, config); - return this; - } + /// + /// Adds a to the parser for the specified as-is. + /// + /// The field name used in the query filter specified with the correct casing. + /// The optional action enabling further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddField(string field, Action>? configure = null) where T : notnull, Enum => AddField(field, null, configure); - /// - /// Adds a to the parser using the specified as-is. - /// - /// The field name used in the query filter. - /// The optional action to perform further field configuration. - /// The to support fluent-style method-chaining. - public QueryFilterParser AddNullField(string field, Action? configure = null) => AddNullField(field, null, configure); - - /// - /// Adds a to the parser using the specified and (overrides the ). - /// - /// The field name used in the query filter. - /// The model name (defaults to ). - /// The optional action to perform further field configuration. - /// The to support fluent-style method-chaining. - public QueryFilterParser AddNullField(string field, string? model, Action? configure = null) - { - var config = new QueryFilterNullFieldConfig(this, field, model); - configure?.Invoke(config); - _fields.Add(field, config); - return this; - } + /// + /// Adds a to the parser using the specified and (overrides the ). + /// + /// The field name used in the query filter. + /// The model name (defaults to ). + /// The optional action to perform further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddField(string field, string? model, Action>? configure = null) where T : notnull, Enum + { + var config = new QueryFilterEnumFieldConfig(this, field, model); + configure?.Invoke(config); + _fields.Add(field, config); + return this; + } - /// - /// Sets (overrides) the default LINQ to be used where no field filtering is specified (including defaults). - /// - public QueryFilterParser WithDefault(QueryStatement? statement) => WithDefault(statement is null ? null : () => statement); + /// + /// Adds a to the parser for the specified as-is. + /// + /// The field name used in the query filter specified with the correct casing. + /// The optional action enabling further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddField(string field, Action>? configure = null) where T : notnull, IParsable => AddField(field, null, configure); - /// - /// Sets (overrides) the default LINQ function to be used where no field filtering is specified (including defaults). - /// - public QueryFilterParser WithDefault(Func? statement) - { - _defaultStatement = statement; - return this; - } + /// + /// Adds a to the parser using the specified and (overrides the ). + /// + /// The field name used in the query filter. + /// The model name (defaults to ). + /// The optional action to perform further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddField(string field, string? model, Action>? configure = null) where T : notnull, IParsable + { + var config = new QueryFilterParseableFieldConfig(this, field, model); + configure?.Invoke(config); + _fields.Add(field, config); + return this; + } - /// - /// Sets (overrides) the action to be invoked where the query has been successfully parsed and is ready for execution. - /// - /// The action to invoke. - /// The to support fluent-style method-chaining. - /// The can be further maintained as required. - /// Additionally, this is an opportunity to further validate the query as needed. Throw a to have the validation message formatted correctly and consistently. - public QueryFilterParser OnQuery(Action? onQuery) - { - _onQuery = onQuery; - return this; - } + /// + /// Adds a to the parser using the specified as-is. + /// + /// The field name used in the query filter. + /// The optional action to perform further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddNullField(string field, Action? configure = null) => AddNullField(field, null, configure); - /// - /// Sets (override) the additional help text. - /// - /// The additional help text. - /// The to support fluent-style method-chaining. - public QueryFilterParser WithHelpText(string text) - { - _helpText = text; - return this; - } + /// + /// Adds a to the parser using the specified and (overrides the ). + /// + /// The field name used in the query filter. + /// The model name (defaults to ). + /// The optional action to perform further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddNullField(string field, string? model, Action? configure = null) + { + var config = new QueryFilterNullFieldConfig(this, field, model); + configure?.Invoke(config); + _fields.Add(field, config); + return this; + } - /// - /// Trys and gets the specified . - /// - /// The field name used in the query filter. - /// The where found. - /// where found; otherwise, . - public bool TryGetField(string field, [NotNullWhen(true)] out IQueryFilterFieldConfig? config) => _fields.TryGetValue(field, out config); - - /// - /// Gets the for the specified and automatically throws a where not found. - /// - /// The . - /// The query filter. - /// The . - public IQueryFilterFieldConfig GetFieldConfig(QueryFilterToken token, string filter) - { - if (token.Kind != QueryFilterTokenKind.Field) - throw new ArgumentException($"The token must have a Kind of {QueryFilterTokenKind.Field}.", nameof(token)); + /// + /// Adds a to the parser using the specified as-is. + /// + /// The . + /// The field name used in the query filter. + /// The optional action to perform further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddReferenceDataField(string field, Action>? configure = null) where TRef : IReferenceData, new() => AddReferenceDataField(field, null, configure); - var name = token.GetRawToken(filter).ToString(); - return _fields.TryGetValue(name, out var config) - ? config - : throw new QueryFilterParserException($"{QueryFilterTokenKind.Field} '{name}' is not supported."); - } + /// + /// Adds a to the parser using the specified and (overrides the ). + /// + /// The . + /// The field name used in the query filter. + /// The model name (defaults to ). + /// The optional action to perform further field configuration. + /// The to support fluent-style method-chaining. + public QueryFilterParser AddReferenceDataField(string field, string? model, Action>? configure = null) where TRef : IReferenceData, new() + { + var config = new QueryFilterReferenceDataFieldConfig(this, field, model); + configure?.Invoke(config); + _fields.Add(field, config); + return this; + } - /// - /// Parses and converts the to dynamic LINQ. - /// - /// The query filter. - /// The . - /// Leverages the to perform the actual parsing. - public Result Parse(string? filter) - { - if (!string.IsNullOrEmpty(filter) && filter.Equals("help", StringComparison.OrdinalIgnoreCase)) - return new QueryFilterParserException(ToString()); + /// + /// Sets (overrides) the default LINQ to be used where no field filtering is specified (including defaults). + /// + public QueryFilterParser WithDefault(QueryStatement? statement) => WithDefault(statement is null ? null : () => statement); - var result = new QueryFilterParserResult(); + /// + /// Sets (overrides) the default LINQ function to be used where no field filtering is specified (including defaults). + /// + public QueryFilterParser WithDefault(Func? statement) + { + _defaultStatement = statement; + return this; + } - try - { - // Append all the expressions to the resulting LINQ whilst parsing. - foreach (var expression in GetExpressions(filter)) - { - if (expression is IQueryFilterFieldStatementExpression fse) - { - result.Fields.Add(fse.FieldConfig.Field); - if (fse.FieldConfig.ResultWriter is not null && fse.FieldConfig.ResultWriter.Invoke(fse, result)) - continue; - } + /// + /// Sets (overrides) the action to be invoked where the query has been successfully parsed and is ready for execution. + /// + /// The action to invoke. + /// The to support fluent-style method-chaining. + /// The can be further maintained as required. + /// Additionally, this is an opportunity to further validate the query as needed. Throw a to have the validation message formatted correctly and consistently. + public QueryFilterParser OnQuery(Action? onQuery) + { + _onQuery = onQuery; + return this; + } - expression.WriteToResult(result); - } + /// + /// Sets (override) the additional help text. + /// + /// The additional help text. + /// The to support fluent-style method-chaining. + public QueryFilterParser WithHelpText(string text) + { + _helpText = text; + return this; + } + + /// + /// Tries and gets the specified . + /// + /// The field name used in the query filter. + /// The where found. + /// where found; otherwise, . + public bool TryGetField(string field, [NotNullWhen(true)] out IQueryFilterFieldConfig? config) => _fields.TryGetValue(field, out config); + + /// + /// Gets the for the specified and automatically throws a where not found. + /// + /// The . + /// The query filter. + /// The . + public IQueryFilterFieldConfig GetFieldConfig(QueryFilterToken token, string filter) + { + if (token.Kind != QueryFilterTokenKind.Field) + throw new ArgumentException($"The token must have a Kind of {QueryFilterTokenKind.Field}.", nameof(token)); + + var name = token.GetRawToken(filter).ToString(); + return _fields.TryGetValue(name, out var config) + ? config + : throw new QueryFilterParserException($"{QueryFilterTokenKind.Field} '{name}' is not supported."); + } + + /// + /// Parses and converts the to dynamic LINQ. + /// + /// The query filter. + /// The . + /// Leverages the to perform the actual parsing. + public QueryFilterParserResult Parse(string? filter) + { + var result = new QueryFilterParserResult(Owner); - // Append any default statements where no fields are in the filter. - foreach (var statement in _fields.Where(x => x.Value.DefaultStatement is not null && !result.Fields.Contains(x.Key)).Select(x => x.Value.DefaultStatement!)) + try + { + // Append all the expressions to the resulting LINQ whilst parsing. + foreach (var expression in GetExpressions(filter)) + { + if (expression is IQueryFilterFieldStatementExpression fse) { - var stmt = statement(); - if (stmt is not null) - result.AppendStatement(stmt); + result.Fields.Add(fse.FieldConfig.Field); + if (fse.FieldConfig.ResultWriter is not null && fse.FieldConfig.ResultWriter.Invoke(fse, result.Writer)) + continue; } - // Uses the default statement where no fields were specified (or defaulted). - result.UseDefault(_defaultStatement); - - // Last chance ;-) - _onQuery?.Invoke(result); + expression.WriteAsDynamicLinq(result.Writer); } - catch (QueryFilterParserException qfpex) + + // Append any default statements where no fields are in the filter. + foreach (var statement in _fields.Where(x => x.Value.DefaultStatement is not null && !result.Fields.Contains(x.Key)).Select(x => x.Value.DefaultStatement!)) { - return qfpex; + var stmt = statement(); + if (stmt is not null) + result.Writer.AppendStatement(stmt); } - return result; + // Uses the default statement where no fields were specified (or defaulted). + result.UseDefault(_defaultStatement); + + // Last chance ;-) + _onQuery?.Invoke(result); + } + catch (QueryFilterParserException qfpex) + { + qfpex.WithExtension("schema", owner.ToJsonSchema()); + result.Error = qfpex; } - /// - /// Parses and gets the expressions from the . - /// - /// The query filter. - /// The . - public IEnumerable GetExpressions(string? filter) + return result; + } + + /// + /// Parses and gets the expressions from the . + /// + /// The query filter. + /// The . + public IEnumerable GetExpressions(string? filter) + { + if (!string.IsNullOrEmpty(filter) && !string.IsNullOrWhiteSpace(filter)) { - if (!string.IsNullOrEmpty(filter)) + QueryFilterExpressionBase? current = null; + int expressionCount = 0; + bool canOpenParen = true; + bool canLogical = false; + int parenDepth = 0; + + foreach (var t in GetRawTokens(filter)) { - QueryFilterExpressionBase? current = null; - int expressionCount = 0; - bool canOpenParen = true; - bool canLogical = false; - int parenDepth = 0; + if (current is not null && !current.CanAddToken(t)) + { + yield return current; + expressionCount++; + current = null; + } - foreach (var t in GetRawTokens(filter)) + if (current is not null) + current.AddToken(t); + else { - if (current is not null && !current.CanAddToken(t)) + if (t.Kind == QueryFilterTokenKind.Not && expressionCount == 0) { - yield return current; - expressionCount++; - current = null; + current = new QueryFilterLogicalExpression(this, filter, t); + canOpenParen = true; + canLogical = false; } - - if (current is not null) - current.AddToken(t); - else + else if (t.Kind == QueryFilterTokenKind.Field) { - if (t.Kind == QueryFilterTokenKind.Not && expressionCount == 0) - { - current = new QueryFilterLogicalExpression(this, filter, t); - canOpenParen = true; - canLogical = false; - } - else if (t.Kind == QueryFilterTokenKind.Field) - { - current = new QueryFilterOperatorExpression(this, filter, t); - canOpenParen = false; - canLogical = true; - } - else if (QueryFilterTokenKind.StringFunctions.HasFlag(t.Kind)) - { - current = new QueryFilterStringFunctionExpression(this, filter, t); - canOpenParen = false; - canLogical = true; - } - else if (t.Kind == QueryFilterTokenKind.OpenParenthesis) - { - if (!canOpenParen) - throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter).ToString()}' positioning that is syntactically incorrect."); - - current = new QueryFilterOpenParenthesisExpression(this, filter, t); - parenDepth++; - canLogical = false; - } - else if (t.Kind == QueryFilterTokenKind.CloseParenthesis) - { - if (canOpenParen) - throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter).ToString()}' positioning that is syntactically incorrect."); - - if (parenDepth == 0) - throw new QueryFilterParserException($"There is a closing '{t.GetRawToken(filter).ToString()}' that has no matching opening '('."); - - current = new QueryFilterCloseParenthesisExpression(this, filter, t); - parenDepth--; - canOpenParen = false; - canLogical = true; - } - else if (QueryFilterTokenKind.Logical.HasFlag(t.Kind)) - { - if (!canLogical) - throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter).ToString()}' positioning that is syntactically incorrect."); - - current = new QueryFilterLogicalExpression(this, filter, t); - canOpenParen = true; - canLogical = false; - } - else - throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter).ToString()}' positioning that is syntactically incorrect."); + current = new QueryFilterOperatorExpression(this, filter, t); + canOpenParen = false; + canLogical = true; } - } + else if (QueryFilterTokenKind.StringFunctions.HasFlag(t.Kind)) + { + current = new QueryFilterStringFunctionExpression(this, filter, t); + canOpenParen = false; + canLogical = true; + } + else if (t.Kind == QueryFilterTokenKind.OpenParenthesis) + { + if (!canOpenParen) + throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter)}' positioning that is syntactically incorrect."); - if (current is not null) - { - if (!current.IsComplete) - throw new QueryFilterParserException("The final expression is incomplete."); + current = new QueryFilterOpenParenthesisExpression(this, filter, t); + parenDepth++; + canLogical = false; + } + else if (t.Kind == QueryFilterTokenKind.CloseParenthesis) + { + if (canOpenParen) + throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter)}' positioning that is syntactically incorrect."); - yield return current; - } + if (parenDepth == 0) + throw new QueryFilterParserException($"There is a closing '{t.GetRawToken(filter)}' that has no matching opening '('."); - if (parenDepth != 0) - throw new QueryFilterParserException("There is an opening '(' that has no matching closing ')'."); + current = new QueryFilterCloseParenthesisExpression(this, filter, t); + parenDepth--; + canOpenParen = false; + canLogical = true; + } + else if (QueryFilterTokenKind.Logical.HasFlag(t.Kind)) + { + if (!canLogical) + throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter)}' positioning that is syntactically incorrect."); - if (!canLogical) + current = new QueryFilterLogicalExpression(this, filter, t); + canOpenParen = true; + canLogical = false; + } + else + throw new QueryFilterParserException($"There is a '{t.GetRawToken(filter)}' positioning that is syntactically incorrect."); + } + } + + if (current is not null) + { + if (!current.IsComplete) throw new QueryFilterParserException("The final expression is incomplete."); + + yield return current; } + + if (parenDepth != 0) + throw new QueryFilterParserException("There is an opening '(' that has no matching closing ')'."); + + if (!canLogical) + throw new QueryFilterParserException("The final expression is incomplete."); } + } - /// - /// Parses and gets the raw tokens from the filter with limited validation. - /// - private IEnumerable GetRawTokens(string filter) + /// + /// Parses and gets the raw tokens from the filter with limited validation. + /// + private IEnumerable GetRawTokens(string filter) + { + for (int i = 0; i < filter.Length; i++) { - for (int i = 0; i < filter.Length; i++) + if (filter[i] == '(') { - if (filter[i] == '(') - { - yield return new QueryFilterToken(QueryFilterTokenKind.OpenParenthesis, i, 1); - continue; - } + yield return new QueryFilterToken(QueryFilterTokenKind.OpenParenthesis, i, 1); + continue; + } - if (filter[i] == ')') - { - yield return new QueryFilterToken(QueryFilterTokenKind.CloseParenthesis, i, 1); - continue; - } + if (filter[i] == ')') + { + yield return new QueryFilterToken(QueryFilterTokenKind.CloseParenthesis, i, 1); + continue; + } - if (filter[i] == ',') - { - yield return new QueryFilterToken(QueryFilterTokenKind.Comma, i, 1); - continue; - } + if (filter[i] == ',') + { + yield return new QueryFilterToken(QueryFilterTokenKind.Comma, i, 1); + continue; + } - if (filter[i] == '\'') - { - var span = filter.AsSpan()[(i + 1)..]; - var j = FindEndOfLiteral(ref span); - if (j == -1) - throw new QueryFilterParserException($"A {QueryFilterTokenKind.Literal} has not been terminated."); - - yield return new QueryFilterToken(QueryFilterTokenKind.Literal, i, j + 1); - i += j; - continue; - } + if (filter[i] == '\'') + { + var span = filter.AsSpan()[(i + 1)..]; + var j = FindEndOfLiteral(ref span); + if (j == -1) + throw new QueryFilterParserException($"A {QueryFilterTokenKind.Literal} has not been terminated."); + + yield return new QueryFilterToken(QueryFilterTokenKind.Literal, i, j + 1); + i += j; + continue; + } + + if (filter[i] != ' ') + { + var start = i; + var j = i + 1; + var backup = false; - if (filter[i] != ' ') + for (; j < filter.Length; j++) { - var start = i; - var j = i + 1; - var backup = false; + if (filter[j] == ' ') + break; - for (; j < filter.Length; j++) + if (filter[j] == '(' || filter[j] == ')' || filter[j] == ',') { - if (filter[j] == ' ') - break; - - if (filter[j] == '(' || filter[j] == ')' || filter[j] == ',') - { - backup = true; - break; - } + backup = true; + break; } + } - var token = filter.AsSpan()[start..j]; - var kind = QueryFilterTokenKind.Unspecified; + var token = filter.AsSpan()[start..j]; + var kind = QueryFilterTokenKind.Unspecified; - // Determine the kind of token where possible. - if (token.Length <= 10) - { - Span lower = new char[token.Length]; - token.ToLowerInvariant(lower); - - kind = lower switch - { - "eq" => QueryFilterTokenKind.Equal, - "ne" => QueryFilterTokenKind.NotEqual, - "gt" => QueryFilterTokenKind.GreaterThan, - "ge" => QueryFilterTokenKind.GreaterThanOrEqual, - "lt" => QueryFilterTokenKind.LessThan, - "le" => QueryFilterTokenKind.LessThanOrEqual, - "in" => QueryFilterTokenKind.In, - "true" => QueryFilterTokenKind.True, - "false" => QueryFilterTokenKind.False, - "null" => QueryFilterTokenKind.Null, - "and" => QueryFilterTokenKind.And, - "or" => QueryFilterTokenKind.Or, - "not" => QueryFilterTokenKind.Not, - "startswith" => QueryFilterTokenKind.StartsWith, - "endswith" => QueryFilterTokenKind.EndsWith, - "contains" => QueryFilterTokenKind.Contains, - _ => QueryFilterTokenKind.Unspecified - }; - } + // Determine the kind of token where possible. + if (token.Length <= 10) + { + Span lower = new char[token.Length]; + token.ToLowerInvariant(lower); - if (kind == QueryFilterTokenKind.Unspecified) - kind = (token.Length == 32 && Guid.TryParse(token, out _)) - ? QueryFilterTokenKind.Value - : (char.IsLetter(token[0]) || token[0] == '_') - ? QueryFilterTokenKind.Field - : _fields.ContainsKey(token.ToString()) - ? QueryFilterTokenKind.Field - : QueryFilterTokenKind.Value; - - yield return new QueryFilterToken(kind, start, token.Length); - i = backup ? j - 1 : j; - continue; + kind = lower switch + { + "eq" => QueryFilterTokenKind.Equal, + "ne" => QueryFilterTokenKind.NotEqual, + "gt" => QueryFilterTokenKind.GreaterThan, + "ge" => QueryFilterTokenKind.GreaterThanOrEqual, + "lt" => QueryFilterTokenKind.LessThan, + "le" => QueryFilterTokenKind.LessThanOrEqual, + "in" => QueryFilterTokenKind.In, + "true" => QueryFilterTokenKind.True, + "false" => QueryFilterTokenKind.False, + "null" => QueryFilterTokenKind.Null, + "and" => QueryFilterTokenKind.And, + "or" => QueryFilterTokenKind.Or, + "not" => QueryFilterTokenKind.Not, + "startswith" => QueryFilterTokenKind.StartsWith, + "endswith" => QueryFilterTokenKind.EndsWith, + "contains" => QueryFilterTokenKind.Contains, + _ => QueryFilterTokenKind.Unspecified + }; } + + if (kind == QueryFilterTokenKind.Unspecified) + kind = (token.Length == 32 && Guid.TryParse(token, out _)) + ? QueryFilterTokenKind.Value + : (char.IsLetter(token[0]) || token[0] == '_') + ? QueryFilterTokenKind.Field + : _fields.ContainsKey(token.ToString()) + ? QueryFilterTokenKind.Field + : QueryFilterTokenKind.Value; + + yield return new QueryFilterToken(kind, start, token.Length); + i = backup ? j - 1 : j; + continue; } } + } - /// - /// Finds the end of a literal. - /// - private static int FindEndOfLiteral(ref ReadOnlySpan filter) + /// + /// Finds the end of a literal. + /// + private static int FindEndOfLiteral(ref ReadOnlySpan filter) + { + var inQuote = true; + var i = 0; + for (; i < filter.Length; i++) { - var inQuote = true; - var i = 0; - for (; i < filter.Length; i++) + if (filter[i] == '\'') { - if (filter[i] == '\'') + if (i < filter.Length - 1) { - if (i < filter.Length - 1) + if (filter[i + 1] == '\'') { - if (filter[i + 1] == '\'') - { - i++; - continue; - } + i++; + continue; } - - inQuote = false; } - else if (filter[i] == ' ' || filter[i] == '(' || filter[i] == ')' || filter[i] == ',') - { - if (!inQuote) - return i; - } - } - return inQuote ? -1 : i; + inQuote = false; + } + else if (filter[i] == ' ' || filter[i] == '(' || filter[i] == ')' || filter[i] == ',') + { + if (!inQuote) + return i; + } } - /// - public override string ToString() - { - if (!HasFields) - return "Filter statement is not currently supported."; + return inQuote ? -1 : i; + } - var sb = new StringBuilder("Filter field(s) are as follows:"); + /// + public override string ToString() + { + var sb = new StringBuilder(); + + if (HasFields) + { + sb.Append("Filter field(s) are as follows:"); foreach (var field in _fields) { sb.AppendLine(); field.Value.AppendToString(sb); } + } + else + sb.Append("Filter statement is not currently supported."); + + if (!string.IsNullOrEmpty(_helpText)) + sb.AppendLine().AppendLine("---").Append("Note: ").Append(_helpText); - if (!string.IsNullOrEmpty(_helpText)) - sb.AppendLine().Append(_helpText); + return sb.ToString(); + } - return sb.ToString(); + /// + /// Produces the JSON schema for the configured fields. + /// + /// The . + public JsonElement ToJsonSchema() + { + var dict = new Dictionary(); + foreach (var field in _fields) + { + dict.Add(field.Key.ToLowerInvariant(), field.Value.ToSchemaDictionary()); } + + var root = new Dictionary + { + { "fields", HasFields ? dict : null } + }; + + if (!string.IsNullOrEmpty(_helpText)) + root["description"] = _helpText; + + return JsonSerializer.SerializeToElement(root); } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterParserException.cs b/src/CoreEx.Data/Querying/QueryFilterParserException.cs index 08eb3722..398a6c6a 100644 --- a/src/CoreEx.Data/Querying/QueryFilterParserException.cs +++ b/src/CoreEx.Data/Querying/QueryFilterParserException.cs @@ -1,22 +1,13 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.Localization; -using System; - -namespace CoreEx.Data.Querying +/// +/// Represents a . +/// +/// The error message. +public sealed class QueryFilterParserException(string message) : ValidationException(new MessageItem(MessageType.Error, message, HttpNames.QueryFilterQueryStringName), FallbackMessage) { /// - /// Represents a . + /// Gets the default/fallback /// - /// The error message. - public sealed class QueryFilterParserException(string message) - : ValidationException(MessageItem.CreateErrorMessage(HttpConsts.QueryArgsFilterQueryStringName, message), new LText(typeof(QueryFilterParserException).FullName, FallbackMessage)) - { - /// - /// Gets the - /// - internal const string FallbackMessage = "A query parsing error occurred."; - } + internal const string FallbackMessage = "A query filter parsing error occurred."; } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterParserResult.cs b/src/CoreEx.Data/Querying/QueryFilterParserResult.cs index cc616c82..8e52b950 100644 --- a/src/CoreEx.Data/Querying/QueryFilterParserResult.cs +++ b/src/CoreEx.Data/Querying/QueryFilterParserResult.cs @@ -1,122 +1,79 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using System; -using System.Collections.Generic; -using System.Text; - -namespace CoreEx.Data.Querying +/// +/// Represents the result of a . +/// +public sealed class QueryFilterParserResult : IQueryParseError { /// - /// Represents the result of a . + /// Initializes a new instance of the class. /// - public sealed class QueryFilterParserResult + internal QueryFilterParserResult(QueryArgsConfig config) { - /// - /// Initializes a new instance of the that is a success. - /// - internal QueryFilterParserResult() { } - - /// - /// Gets the field names referenced within the resulting LINQ query. - /// - public HashSet Fields { get; } = []; - - /// - /// Gets the resulting dynamic LINQ filter . - /// - internal StringBuilder FilterBuilder { get; } = new StringBuilder(); - - /// - /// Gets the resulting arguments referenced by the . - /// - public List Args { get; } = []; + Config = config.ThrowIfNull(); + Writer = new(Config); + } - /// - /// Provides the resulting dynamic LINQ filter. - /// - public string? ToLinqString() => FilterBuilder.ToString(); + /// + /// Gets the owning . + /// + public QueryArgsConfig Config { get; } - /// - /// Appends a value to the as a placeholder and adds into the . - /// - /// The value. - public void AppendValue(object? value) - { - Args.Add(value); - FilterBuilder.Append($"@{Args.Count - 1}"); - } + /// + public bool HasError => Error is not null; - /// - /// Appends a to the . - /// - /// The chararater to append. - /// Also appends a space if required. - internal void Append(char @char) - { - if (FilterBuilder.Length > 0 && FilterBuilder[^1] != ' ' && FilterBuilder[^1] != '!' && FilterBuilder[^1] != '(') - { - if (!(@char == ')' && FilterBuilder[^1] == ')')) - FilterBuilder.Append(' '); - } + /// + /// Throws the where ; otherwise, does nothing. + /// + public QueryFilterParserResult ThrowOnError() => HasError ? throw Error! : this; - FilterBuilder.Append(@char); - } + /// + ExtendedException? IQueryParseError.Error => Error; - /// - /// Appends a to the . - /// - /// The span. - /// Also appends a space if required. - internal void Append(ReadOnlySpan span) - { - if (FilterBuilder.Length > 0 && FilterBuilder[^1] != ' ' && FilterBuilder[^1] != '!' && FilterBuilder[^1] != '(') - FilterBuilder.Append(' '); + /// + /// Gets the error represented as an that occurred during parsing, if any. + /// + public QueryFilterParserException? Error { get; internal set; } - FilterBuilder.Append(span); - } + /// + /// Gets the field names referenced within the resulting LINQ query. + /// + public HashSet Fields { get; } = []; - /// - /// Appends a to the . - /// - /// The . - /// Also appends an ' && ' (and) prior to the where neccessary. - public void AppendStatement(QueryStatement statement) - { - statement.ThrowIfNull(nameof(statement)); - if (FilterBuilder.Length > 0) - FilterBuilder.Append(" && "); + /// + /// Gets the . + /// + public QueryFilterParserWriter Writer { get; } - var sb = new StringBuilder(statement.Statement); - for (int i = 0; i < statement.Args.Length; i++) - { - sb.Replace($"@{i}", $"@{Args.Count}"); - Args.Add(statement.Args[i]); - } + /// + /// Provides the resulting dynamic LINQ filter and corresponding . + /// + public string? ToLinqString(out object?[] args) + { + args = [.. Writer.Args]; + return Writer.ToString(); + } - FilterBuilder.Append(sb); - } + /// + /// Defaults the dynamic LINQ (see ) with the specified where not already set. + /// + /// The . + public void UseDefault(QueryStatement? statement) => UseDefault(statement is null ? null : () => statement); - /// - /// Defaults the with the specified where not already set. - /// - /// The . - public void UseDefault(QueryStatement? statement) => UseDefault(statement is null ? null : () => statement); + /// + /// Defaults the dynamic LINQ (see ) with the specified function where not already set. + /// + /// The function. + public void UseDefault(Func? statement) + { + if (Writer.FilterBuilder.Length > 0) + return; - /// - /// Defaults the with the specified function where not already set. - /// - /// The function. - public void UseDefault(Func? statement) + var stmt = statement?.Invoke(); + if (stmt is not null) { - if (FilterBuilder.Length > 0) - return; - - var stmt = statement?.Invoke(); - if (stmt is not null) - { - FilterBuilder.Append(stmt.Statement); - Args.AddRange(stmt.Args); - } + Writer.Append(stmt.Statement); + Writer.Args.AddRange(stmt.Args); } } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterParserWriter.cs b/src/CoreEx.Data/Querying/QueryFilterParserWriter.cs new file mode 100644 index 00000000..dff5e356 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterParserWriter.cs @@ -0,0 +1,103 @@ +namespace CoreEx.Data.Querying; + +/// +/// Represents the resulting dynamic LINQ filter writer. +/// +public class QueryFilterParserWriter +{ + /// + /// Initializes a new instance of the class. + /// + /// The owning . + internal QueryFilterParserWriter(QueryArgsConfig config) => Config = config.ThrowIfNull(); + + /// + /// Gets the owning . + /// + public QueryArgsConfig Config { get; } + + /// + /// Gets the resulting dynamic LINQ filter . + /// + internal StringBuilder FilterBuilder { get; } = new(); + + /// + /// Gets the resulting arguments referenced by the . + /// + internal List Args { get; } = []; + + /// + /// Appends a to the prepended with a space if required. + /// + /// The chararater to append. + /// Also appends a space if required. + internal void AppendWithSpacing(char @char) + { + if (FilterBuilder.Length > 0 && FilterBuilder[^1] != ' ' && FilterBuilder[^1] != '!' && FilterBuilder[^1] != '(') + { + if (!(@char == ')' && FilterBuilder[^1] == ')')) + FilterBuilder.Append(' '); + } + + FilterBuilder.Append(@char); + } + + /// + /// Appends a to the prepended with a space if required. + /// + /// The span. + /// Also appends a space if required. + internal void AppendWithSpacing(ReadOnlySpan span) + { + if (FilterBuilder.Length > 0 && FilterBuilder[^1] != ' ' && FilterBuilder[^1] != '!' && FilterBuilder[^1] != '(') + FilterBuilder.Append(' '); + + FilterBuilder.Append(span); + } + + /// + /// Appends a to the underlying dynamic LINQ statement as a placeholder, and captures the corresponding argument value. + /// + /// The value. + public void AppendValue(object? value) + { + Args.Add(value); + FilterBuilder.Append($"@{Args.Count - 1}"); + } + + /// + /// Appends the as-is to the underlying dynamic LINQ statement. + /// + /// The character. + public void Append(char @char) => FilterBuilder.Append(@char); + + /// + /// Appends the as-is to the underlying dynamic LINQ statement. + /// + /// The span. + public void Append(ReadOnlySpan span) => FilterBuilder.Append(span); + + /// + /// Appends a to the underlying dynamic LINQ statement. + /// + /// The . + /// Also appends an ' && ' (and) prior to the where neccessary. + public void AppendStatement(QueryStatement statement) + { + statement.ThrowIfNull(); + if (FilterBuilder.Length > 0) + FilterBuilder.Append(" && "); + + var sb = new StringBuilder(statement.Statement); + for (int i = 0; i < statement.Args.Length; i++) + { + sb.Replace($"@{i}", $"@{Args.Count}"); + Args.Add(statement.Args[i]); + } + + FilterBuilder.Append(sb); + } + + /// + public override string? ToString() => FilterBuilder.Length == 0 ? null : FilterBuilder.ToString(); +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs b/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs deleted file mode 100644 index 9ca6f9f0..00000000 --- a/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Data.Querying.Expressions; -using CoreEx.Entities; -using CoreEx.RefData; -using System; - -namespace CoreEx.Data.Querying -{ - /// - /// Provides the field configuration. - /// - /// The . - /// Defaults the to only. - public class QueryFilterReferenceDataFieldConfig : QueryFilterFieldConfigBase> where TRef : IReferenceData, new() - { - private bool _useIdentifier; - private bool _mustBeValid = true; - private Func? _valueFunc; - - /// - /// Initializes a new instance of the class. - /// - /// The owning . - /// The field name. - /// The model name (defaults to . - public QueryFilterReferenceDataFieldConfig(QueryFilterParser parser, string field, string? model) : base(parser, typeof(TRef), field, model) - { - Operators = QueryFilterOperator.EqualityOperators; - IsTypeString = true; - } - - /// - /// Indicates that the is to be used as the value for the query (versus the originating filter value being the ). - /// - /// The to support fluent-style method-chaining. - /// This will automatically set the to be only as other operators are nonsensical in this context. - public QueryFilterReferenceDataFieldConfig UseIdentifier() - { - _useIdentifier = true; - return this; - } - - /// - /// Indicates that the resulting converted value must be . - /// - /// indicates that an error will occur where not valid; otherwise, . - /// The to support fluent-style method-chaining. - /// Defaults to . - public QueryFilterReferenceDataFieldConfig MustBeValid(bool mustBeValid = true) - { - _mustBeValid = mustBeValid; - return this; - } - - /// - /// Sets (overrides) the function to, a) further convert the field value; and/or, b) to provide additional validation. - /// - /// The value function. - /// The final value that will be used in the LINQ query. - /// This is an opportunity to further validate the query as needed. Throw a to have the validation message formatted correctly and consistently. - public QueryFilterReferenceDataFieldConfig WithValue(Func? value) - { - _valueFunc = value; - return this; - } - - /// - protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) - { - var text = field.GetValueToken(filter); - TRef value = ReferenceDataOrchestrator.ConvertFromCode(text); - - if (_mustBeValid && !value.IsValid) - throw new FormatException($"Not a valid {typeof(TRef).Name}."); - - if (_valueFunc is not null) - value = _valueFunc.Invoke(value) ?? throw new FormatException($"Not a valid {typeof(TRef).Name}."); - - return _useIdentifier - ? (value.Id is null ? string.Empty : value.Id) - : value.Code!; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfigT.cs b/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfigT.cs new file mode 100644 index 00000000..def16850 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfigT.cs @@ -0,0 +1,48 @@ +namespace CoreEx.Data.Querying; + +/// +/// Provides the field configuration. +/// +/// The . +/// Defaults the to only. +public class QueryFilterReferenceDataFieldConfig : QueryFilterFieldConfigBase> where TRef : IReferenceData, new() +{ + private bool _mustBeActive; + + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The field name. + /// The model name (defaults to ). + public QueryFilterReferenceDataFieldConfig(QueryFilterParser parser, string field, string? model) : base(parser, typeof(TRef), field, model) + { + Operators = QueryFilterOperator.EqualityOperators; + FieldType = QueryFilterFieldType.String; + } + + /// + /// Indicates that the resulting converted value must be . + /// + /// indicates that an error will occur where not active; otherwise, . + /// The to support fluent-style method-chaining. + /// Defaults to . + public QueryFilterReferenceDataFieldConfig MustBeActive(bool mustBeActive = true) + { + _mustBeActive = mustBeActive; + return this; + } + + /// + protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) + { + var code = field.GetValueToken(filter); + if (!ReferenceDataOrchestrator.TryGetByCode(code, out var rd) || !rd.IsValid) + throw new FormatException($"Not a valid {typeof(TRef).Name}."); + + if (!rd.IsActive && _mustBeActive) + throw new FormatException($"Not an active {typeof(TRef).Name}."); + + return rd.Code!; + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterSchemaType.cs b/src/CoreEx.Data/Querying/QueryFilterSchemaType.cs new file mode 100644 index 00000000..99e8d159 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterSchemaType.cs @@ -0,0 +1,34 @@ +namespace CoreEx.Data.Querying; + +/// +/// Represents the schema type for a . +/// +/// This is used to define the schema data type of the field in the query filter similar to the OpenAPI specification. +public enum QueryFilterSchemaType +{ + /// + /// Indicates a . + /// + String, + + /// + /// Indicates a or . + /// + Number, + + /// + /// Indicates a or . + /// + Integer, + + /// + /// Indicates a . + /// + Boolean, + + /// + /// Indicates an object; as distinct from primitive types. + /// + /// This is a special case specifically for . + Object +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryOrderByDirection.cs b/src/CoreEx.Data/Querying/QueryOrderByDirection.cs index 1609091f..19ce77d6 100644 --- a/src/CoreEx.Data/Querying/QueryOrderByDirection.cs +++ b/src/CoreEx.Data/Querying/QueryOrderByDirection.cs @@ -1,28 +1,23 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using System; - -namespace CoreEx.Data.Querying +/// +/// Provides the query order-by direction. +/// +[Flags] +public enum QueryOrderByDirection { /// - /// Provides the query order-by direction. + /// Ascending order. /// - [Flags] - public enum QueryOrderByDirection - { - /// - /// Ascending order. - /// - Ascending = 1, + Ascending = 1, - /// - /// Descending order. - /// - Descending = 2, + /// + /// Descending order. + /// + Descending = 2, - /// - /// Both and order. - /// - Both = Ascending | Descending - } + /// + /// Both and order. + /// + Both = Ascending | Descending } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs b/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs index fda009fb..68679fa0 100644 --- a/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs +++ b/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs @@ -1,65 +1,124 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -namespace CoreEx.Data.Querying +/// +/// Provides the field configuration. +/// +/// The owning . +/// The field name. +/// The model name (defaults to . +public sealed class QueryOrderByFieldConfig(QueryOrderByParser parser, string field, string? model) { + private readonly string? _model = model; + + /// + /// Gets the owning . + /// + public QueryOrderByParser Parser { get; internal set; } = parser.ThrowIfNull(); + + /// + /// Gets the field name. + /// + public string Field { get; } = field.ThrowIfNullOrEmpty(nameof(field)); + + /// + /// Gets or sets model name to be used for the dynamic LINQ expression. + /// + /// Defaults to the name. + public string? Model => _model ?? Field; + + /// + /// Gets the optional prefix to be used where referencing the underlying model. + /// + /// This will default from when instantiated. + public string? ModelPrefix { get; private set; } + + /// + /// Gets the fully-qualified name (including any where specified). + /// + public string FullyQualifiedModelName => (ModelPrefix is null ? string.Empty : ModelPrefix + ".") + Model; + + /// + /// Gets the supported . + /// + /// Defaults to . + public QueryOrderByDirection Direction { get; private set; } = QueryOrderByDirection.Both; + + /// + /// Gets the default . + /// + public QueryOrderByDirection? DefaultDirection { get; private set; } + + /// + /// Indicates whether the field is to always be included in the query ordering. + /// + /// Where not explicitly specified in the order by statement, this field will be included as the last order-by field. + public bool IsAlwaysInclude => AlwaysIncludeDirection.HasValue; + + /// + /// Gets the to be always included in the query ordering. + /// + /// Where not explicitly specified in the order by statement, this field will be included as the last order-by field. + public QueryOrderByDirection? AlwaysIncludeDirection { get; private set; } + + /// + /// Gets the additional help text. + /// + public string? HelpText { get; private set; } + + /// + /// Sets (overrides) the . + /// + /// The . + /// The to support fluent-style method-chaining. + /// The default is . + public QueryOrderByFieldConfig WithDirection(QueryOrderByDirection supportedDirection) + { + Direction = supportedDirection; + return this; + } + + /// + /// Sets (overrides) the optional to be used where referencing the underlying model. + /// + /// The model prefix. + /// The to support fluent-style method-chaining. + public QueryOrderByFieldConfig WithModelPrefix(string? modelPrefix = null) + { + ModelPrefix = modelPrefix; + return this; + } + + /// + /// Sets (overrides) the default order-by and its . + /// + /// The default . + /// The to support fluent-style method-chaining. + /// This is used to define the overall default query ordering when none is specified; each field must also be defined in the order in which they are applied. By not specifying a field then this denotes + /// that it is not to be included in the default query ordering. + public QueryOrderByFieldConfig WithDefault(QueryOrderByDirection defaultDirection = QueryOrderByDirection.Ascending) + { + DefaultDirection = defaultDirection.ThrowWhen(defaultDirection => defaultDirection == QueryOrderByDirection.Both, $"Default direction cannot be '{QueryOrderByDirection.Both}'."); + return this; + } + + /// + /// Sets (overrides) the always included in the query ordering and its . + /// + /// The to support fluent-style method-chaining. + public QueryOrderByFieldConfig WithAlwaysInclude(QueryOrderByDirection alwaysDirection = QueryOrderByDirection.Ascending) + { + AlwaysIncludeDirection = alwaysDirection.ThrowWhen(alwaysDirection => alwaysDirection == QueryOrderByDirection.Both, $"Always include direction cannot be '{QueryOrderByDirection.Both}'."); + return this; + } + /// - /// Provides the field configuration. + /// Sets (overrides) the additional help text. /// - /// The owning . - /// The field name. - /// The model name (defaults to . - public sealed class QueryOrderByFieldConfig(QueryOrderByParser parser, string field, string? model) + /// The additional help text. + /// The to support fluent-style method-chaining. + public QueryOrderByFieldConfig WithHelpText(string text) { - private readonly string? _model = model; - - /// - /// Gets the owning . - /// - public QueryOrderByParser Parser { get; internal set; } = parser.ThrowIfNull(nameof(parser)); - - /// - /// Gets the field name. - /// - public string Field { get; } = field.ThrowIfNullOrEmpty(nameof(field)); - - /// - /// Gets or sets model name to be used for the dynamic LINQ expression. - /// - /// Defaults to the name. - public string? Model => _model ?? Field; - - /// - /// Gets the supported . - /// - /// Defaults to . - public QueryOrderByDirection Direction { get; private set; } = QueryOrderByDirection.Both; - - /// - /// Gets the additional help text. - /// - public string? HelpText { get; private set; } - - /// - /// Sets (overrides) the . - /// - /// The . - /// The to support fluent-style method-chaining. - /// The default is . - public QueryOrderByFieldConfig WithDirection(QueryOrderByDirection supportedDirection) - { - Direction = supportedDirection; - return this; - } - - /// - /// Sets (overrides) the additional help text. - /// - /// The additional help text. - /// The to support fluent-style method-chaining. - public QueryOrderByFieldConfig WithHelpText(string text) - { - HelpText = text.ThrowIfNullOrEmpty(nameof(text)); - return this; - } + HelpText = text.ThrowIfNullOrEmpty(nameof(text)); + return this; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryOrderByParser.cs b/src/CoreEx.Data/Querying/QueryOrderByParser.cs index c7c792ae..f163ab3f 100644 --- a/src/CoreEx.Data/Querying/QueryOrderByParser.cs +++ b/src/CoreEx.Data/Querying/QueryOrderByParser.cs @@ -1,140 +1,145 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Text; - -namespace CoreEx.Data.Querying +/// +/// Represents a basic query sort order by parser and LINQ translator with explicitly defined field support. +/// +/// This is not intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to sort an underlying query. +/// The owning . +public sealed class QueryOrderByParser(QueryArgsConfig owner) { + private readonly List _fields = []; + private Action? _validator; + private string? _helpText; + private string? _defaultOrderBy; + private string? _defaultOrderByStatement; + + /// + /// Gets the owning . + /// + public QueryArgsConfig Owner => owner.ThrowIfNull(); + + /// + /// Indicates that at least a single field has been configured. + /// + public bool HasFields => _fields.Count > 0; + + /// + /// Gets the default OData-like $orderby statement. + /// + public string? DefaultOrderBy => _defaultOrderBy ??= string.Join(", ", _fields.Where(f => f.DefaultDirection is not null).Select(f => f.Field.ToLowerInvariant() + (f.DefaultDirection == QueryOrderByDirection.Ascending ? " asc" : "desc"))); + /// - /// Represents a basic query sort order by parser and LINQ translator with explicitly defined field support. + /// Gets the default model prefix (if any). /// - /// This is not intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to sort an underlying query. - /// The owning . - public sealed class QueryOrderByParser(QueryArgsConfig owner) + /// This will be automatically applied to all subsequent field additions; for example . + public string? DefaultModelPrefix { get; private set; } + + /// + /// Sets (overrides) the (if any). + /// + /// + /// The to support fluent-style method-chaining. + public QueryOrderByParser WithDefaultModelPrefix(string? modelPrefix = null) { - private readonly Dictionary _fields = new(StringComparer.OrdinalIgnoreCase); - private Action? _validator; - private string? _helpText; - - /// - /// Gets the owning . - /// - public QueryArgsConfig Owner => owner.ThrowIfNull(nameof(owner)); - - /// - /// Gets the default order-by dynamic LINQ statement. - /// - /// To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement. - public string? DefaultOrderBy { get; private set; } - - /// - /// Indicates that at least a single field has been configured. - /// - public bool HasFields => _fields.Count > 0; - - /// - /// Adds a to the parser for the specified as-is. - /// - /// The field name used in the order by specified with the correct casing. - /// The optional action enabling further field configuration. - /// The to support fluent-style method-chaining. - /// To avoid unnecessary parsing this should be the valid dynamic LINQ statement. - public QueryOrderByParser AddField(string field, Action? configure = null) => AddField(field, null, configure); - - /// - /// Adds a to the parser using the specified and . - /// - /// The field name used in the query filter. - /// The model name (defaults to . - /// The optional action enabling further field configuration. - /// The to support fluent-style method-chaining. - public QueryOrderByParser AddField(string field, string? model, Action? configure = null) - { - var config = new QueryOrderByFieldConfig(this, field, model); - configure?.Invoke(config); - _fields.Add(field, config); - return this; - } + DefaultModelPrefix = modelPrefix; + return this; + } - /// - /// Sets (overrides) the default order-by dynamic LINQ statement. - /// - /// The default order-by statement used where not explicitly specified (see .). - /// The to support fluent-style method-chaining. - /// To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement. - public QueryOrderByParser WithDefault(string? defaultOrderBy) - { - DefaultOrderBy = defaultOrderBy.ThrowIfEmpty(nameof(defaultOrderBy)); - return this; - } + /// + /// Adds a to the parser for the specified as-is. + /// + /// The field name used in the order by specified with the correct casing. + /// The optional action enabling further field configuration. + /// The to support fluent-style method-chaining. + /// To avoid unnecessary parsing this should be the valid dynamic LINQ statement. + public QueryOrderByParser AddField(string field, Action? configure = null) => AddField(field, null, configure); - /// - /// Sets (override) the additional help text. - /// - /// The additional help text. - /// The to support fluent-style method-chaining. - public QueryOrderByParser WithHelpText(string text) - { - _helpText = text; - return this; - } + /// + /// Adds a to the parser using the specified and . + /// + /// The field name used in the query filter. + /// The model name (defaults to . + /// The optional action enabling further field configuration. + /// The to support fluent-style method-chaining. + public QueryOrderByParser AddField(string field, string? model, Action? configure = null) + { + var config = new QueryOrderByFieldConfig(this, field, model); - /// - /// Sets (overrides) a that can be used to further validate the fields specified in the order by. - /// - /// The validator action. - /// The to support fluent-style method-chaining. - /// Throw a to have the validation message formatted correctly and consistently. - /// The string[] passed into the validator will contain the parsed fields (names) in the order in which they were specified. - public QueryOrderByParser WithValidator(Action? validator) - { - _validator = validator; - return this; - } + field.ThrowWhen(field => _fields.Any(f => f.Field.Equals(field, StringComparison.OrdinalIgnoreCase)), $"The order-by field '{field}' has already been added and must be unique."); + + config.WithModelPrefix(DefaultModelPrefix); + configure?.Invoke(config); + _fields.Add(config); + + _defaultOrderBy = null; + _defaultOrderByStatement = null; + return this; + } + + /// + /// Sets (override) the additional help text. + /// + /// The additional help text. + /// The to support fluent-style method-chaining. + public QueryOrderByParser WithHelpText(string text) + { + _helpText = text; + return this; + } + + /// + /// Sets (overrides) a that can be used to further validate the fields specified in the order by. + /// + /// The validator action. + /// The to support fluent-style method-chaining. + /// Throw a to have the validation message formatted correctly and consistently. + /// The string[] passed into the validator will contain the parsed fields (names) in the order in which they were specified. + public QueryOrderByParser WithValidator(Action? validator) + { + _validator = validator; + return this; + } - /// - /// Parses and converts the to dynamic LINQ. - /// - /// The query order-by. - /// The . - public Result Parse(string? orderBy) + /// + /// Parses and converts the to dynamic LINQ. + /// + /// The query order-by. + /// The . + public QueryOrderByParserResult Parse(string? orderBy) + { + var usingDefault = false; + if (string.IsNullOrEmpty(orderBy)) { - if (!string.IsNullOrEmpty(orderBy) && orderBy.Equals("help", StringComparison.OrdinalIgnoreCase)) - return new QueryOrderByParserException(ToString()); + if (_defaultOrderByStatement is not null) + return new QueryOrderByParserResult(_defaultOrderByStatement); - if (string.IsNullOrEmpty(orderBy)) - return new QueryOrderByParserResult(DefaultOrderBy); + usingDefault = true; + orderBy = DefaultOrderBy; + if (orderBy is null) + return new QueryOrderByParserResult(orderBy); + } - var fields = new List(); - var sb = new StringBuilder(); + var fields = new List(); + var sb = new StringBuilder(); -#if NET6_0_OR_GREATER + try + { foreach (var sort in orderBy.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var parts = sort.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); -#else - foreach (var sort in orderBy.ThrowIfNullOrEmpty(nameof(orderBy)).Split(',', StringSplitOptions.RemoveEmptyEntries )) - { - var parts = sort.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); -#endif + if (parts.Length == 0) continue; else if (parts.Length > 2) throw new QueryOrderByParserException("Statement is syntactically incorrect."); -#if NET6_0_OR_GREATER var field = parts[0]; -#else - var field = parts[0].Trim(); -#endif - var config = _fields.TryGetValue(field, out var fc) ? fc : throw new QueryOrderByParserException($"Field '{field}' is not supported."); + var config = _fields.FirstOrDefault(f => f.Field.Equals(field, StringComparison.OrdinalIgnoreCase)) ?? throw new QueryOrderByParserException($"Field '{field}' is not supported."); if (sb.Length > 0) sb.Append(", "); - sb.Append(config.Model); + sb.Append(config.FullyQualifiedModelName); var dir = parts.Length == 2 ? parts[1].Trim() : null; if (dir is not null) @@ -146,46 +151,112 @@ public Result Parse(string? orderBy) direction = QueryOrderByDirection.Descending; } else if (!(dir.Length > 2 && nameof(QueryOrderByDirection.Ascending).StartsWith(dir, StringComparison.OrdinalIgnoreCase))) - return new QueryOrderByParserException($"Field '{field}' direction '{dir}' is invalid; must be either 'asc' (ascending) or 'desc' (descending)."); + throw new QueryOrderByParserException($"Field '{field}' direction '{dir}' is invalid; must be either 'asc' (ascending) or 'desc' (descending)."); if (!config.Direction.HasFlag(direction)) - return new QueryOrderByParserException($"Field '{field}' direction '{dir}' is invalid; not supported."); + throw new QueryOrderByParserException($"Field '{field}' direction '{dir}' is invalid; not supported."); } if (fields.Contains(config.Field)) - return new QueryOrderByParserException($"Field '{field}' must not be specified more than once."); + throw new QueryOrderByParserException($"Field '{field}' must not be specified more than once."); fields.Add(config.Field); } - try - { - _validator?.Invoke([.. fields]); - } - catch (QueryOrderByParserException qobpex) + foreach (var config in _fields.Where(x => x.IsAlwaysInclude)) { - return qobpex; + if (fields.Contains(config.Field)) + continue; + + if (sb.Length > 0) + sb.Append(", "); + + sb.Append(config.FullyQualifiedModelName); + if (config.AlwaysIncludeDirection == QueryOrderByDirection.Descending) + sb.Append(" desc"); } - return new QueryOrderByParserResult(sb.ToString()); + _validator?.Invoke([.. fields]); + } + catch (QueryOrderByParserException qobpex) + { + qobpex.WithExtension("schema", owner.ToJsonSchema()); + return new QueryOrderByParserResult(qobpex); } - /// - public override string ToString() + if (usingDefault) { - if (!HasFields) - return "Order-By statement is not currently supported."; + _defaultOrderByStatement = sb.ToString(); + return new QueryOrderByParserResult(_defaultOrderByStatement); + } + else + return new QueryOrderByParserResult(sb.ToString()); + } - var sb = new StringBuilder("Order-by field(s) are as follows:"); + /// + public override string ToString() + { + var sb = new StringBuilder(); + + if (HasFields) + { + sb.Append("Order-by field(s) are as follows:"); foreach (var field in _fields) { - sb.AppendLine().Append(field.Key).Append(" (Direction: ").Append(field.Value.Direction).Append(')'); + sb.AppendLine().Append(field.Field.ToLowerInvariant()).Append(" (Direction: "); + if (field.Direction == QueryOrderByDirection.Ascending) + sb.Append("asc"); + else if (field.Direction == QueryOrderByDirection.Descending) + sb.Append("desc"); + else + sb.Append("asc or desc"); + + sb.Append(')'); } + } + else + sb.Append("Order-By statement is not currently supported."); + + if (!string.IsNullOrEmpty(DefaultOrderBy)) + sb.AppendLine().AppendLine("---").Append("Default: ").Append(DefaultOrderBy); + + if (!string.IsNullOrEmpty(_helpText)) + sb.AppendLine().AppendLine("---").Append("Note: ").Append(_helpText); + + return sb.ToString(); + } + + /// + /// Produces the JSON schema for the configured fields. + /// + /// The . + public JsonElement ToJsonSchema() + { + var dict = new Dictionary(); + foreach (var field in _fields) + { + string[] directions = field.Direction switch + { - if (!string.IsNullOrEmpty(_helpText)) - sb.AppendLine().Append(_helpText); + QueryOrderByDirection.Ascending => ["asc"], + QueryOrderByDirection.Descending => ["desc"], + _ => ["asc", "desc"] + }; - return sb.ToString(); + dict.Add(field.Field.ToLowerInvariant(), new { direction = directions }); } + + var root = new Dictionary + { + { "fields", HasFields ? dict : null } + }; + + if (!string.IsNullOrEmpty(DefaultOrderBy)) + root["default"] = DefaultOrderBy; + + if (!string.IsNullOrEmpty(_helpText)) + root["description"] = _helpText; + + return JsonSerializer.SerializeToElement(root); } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryOrderByParserException.cs b/src/CoreEx.Data/Querying/QueryOrderByParserException.cs index fc3cb175..a4377d5d 100644 --- a/src/CoreEx.Data/Querying/QueryOrderByParserException.cs +++ b/src/CoreEx.Data/Querying/QueryOrderByParserException.cs @@ -1,15 +1,13 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.Localization; - -namespace CoreEx.Data.Querying +/// +/// Represents a . +/// +/// The error message. +public sealed class QueryOrderByParserException(string message) : ValidationException(new MessageItem(MessageType.Error, message, HttpNames.QueryOrderByQueryStringName), FallbackMessage) { /// - /// Represents a . + /// Gets the default/fallback /// - /// The error message. - public class QueryOrderByParserException(string message) - : ValidationException(MessageItem.CreateErrorMessage(HttpConsts.QueryArgsOrderByQueryStringName, message), new LText(typeof(QueryFilterParserException).FullName, QueryFilterParserException.FallbackMessage)) { } + internal const string FallbackMessage = "A query order-by parsing error occurred."; } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryOrderByParserResult.cs b/src/CoreEx.Data/Querying/QueryOrderByParserResult.cs index 009e7500..cdeb4701 100644 --- a/src/CoreEx.Data/Querying/QueryOrderByParserResult.cs +++ b/src/CoreEx.Data/Querying/QueryOrderByParserResult.cs @@ -1,23 +1,42 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -namespace CoreEx.Data.Querying +/// +/// Represents the result of . +/// +public sealed class QueryOrderByParserResult : IQueryParseError { + private readonly string? _orderByStatement; + + /// + /// Initializes a new instance of the class. + /// + /// The resulting dynamic LINQ order by statement. + internal QueryOrderByParserResult(string? orderByStatement) => _orderByStatement = orderByStatement; + + /// + /// Initializes a new instance of the class with an error. + /// + /// The error . + internal QueryOrderByParserResult(QueryOrderByParserException error) => Error = error.ThrowIfNull(); + + /// + public bool HasError => Error is not null; + + /// + ExtendedException? IQueryParseError.Error => Error; + + /// + /// Gets the error represented as an that occurred during parsing, if any. + /// + public QueryOrderByParserException? Error { get; internal set; } + + /// + /// Throws the where ; otherwise, does nothing. + /// + public QueryOrderByParserResult ThrowOnError() => HasError ? throw Error!: this; + /// - /// Represents the result of . + /// Provides the resulting dynamic LINQ order by. /// - public sealed class QueryOrderByParserResult - { - private readonly string? _orderByStatement; - - /// - /// Initializes a new instance of the class. - /// - /// The resulting dynamic LINQ order by statement. - internal QueryOrderByParserResult(string? orderByStatement) => _orderByStatement = orderByStatement; - - /// - /// Provides the resulting dynamic LINQ order by. - /// - public string? ToLinqString() => _orderByStatement; - } + public string? ToLinqString() => _orderByStatement; } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryStatement.cs b/src/CoreEx.Data/Querying/QueryStatement.cs index 4c27720c..01ecf7f7 100644 --- a/src/CoreEx.Data/Querying/QueryStatement.cs +++ b/src/CoreEx.Data/Querying/QueryStatement.cs @@ -1,29 +1,26 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Data.Querying; -namespace CoreEx.Data.Querying +/// +/// Represents a dynamic LINQ statement with optional arguments. +/// +/// The LINQ may contain placeholders referencing the by its zero-based index. +/// Note: no parsing or validation is performed prior to use and as such may result in an internal error. +/// An example is as follows: +/// +/// new QueryStatement("City == @0", "Brisbane"); +/// +/// The dynamic LINQ statement. +/// The placeholder arguments. +public class QueryStatement(string statement, params IEnumerable args) { /// - /// Represents a dynamic LINQ statement with optional arguments. + /// Gets the dynamic LINQ statement. /// - /// The LINQ may contain placeholders referencing the by its zero-based index. - /// Note: no parsing or validation is performed prior to use and as such may result in an internal error. - /// An example is as follows: - /// - /// new QueryStatement("City == @0", "Brisbane"); - /// - /// The dynamic LINQ statement. - /// The placeholder arguments. - public class QueryStatement(string statement, params object?[] args) - { - /// - /// Gets the dynamic LINQ statement. - /// - /// The dynamic LINQ statement may contain placeholders referencing the by its zero-based index. - public string Statement { get; } = statement; + /// The dynamic LINQ statement may contain placeholders referencing the by its zero-based index. + public string Statement { get; } = statement; - /// - /// Gets the placeholder arguments. - /// - public object?[] Args { get; } = args; - } + /// + /// Gets the placeholder arguments. + /// + public object?[] Args { get; } = [ ..args]; } \ No newline at end of file diff --git a/src/CoreEx.Data/README.md b/src/CoreEx.Data/README.md deleted file mode 100644 index 0b461db6..00000000 --- a/src/CoreEx.Data/README.md +++ /dev/null @@ -1,187 +0,0 @@ -# CoreEx.Data - -The `CoreEx.Data` namespace provides extended data-related capabilities. - -
- -## Motivation - -The motivation is to simplify and improve the data access experience. - -
- -## OData-like Querying - -It is not always possible to implement the likes of OData and/or GraphQL on an underlying data source. This could be related to the complexity of the implementation, the desire to hide the underlying data structure, and/or limit the types of operations performed to manage the underlying performance. - -However, the desire to provide a similar experience to the client remains. The `CoreEx.Data.Querying` namespace enables the client to perform OData-like queries (limited to [`$filter`](https://docs.oasis-open.org/odata/odata/v4.01/cs01/part2-url-conventions/odata-v4.01-cs01-part2-url-conventions.html#sec_SystemQueryOptionfilter) and [`$orderby`](https://docs.oasis-open.org/odata/odata/v4.01/cs01/part2-url-conventions/odata-v4.01-cs01-part2-url-conventions.html#_Toc505773299)) on an underlying data source, in a structured and controlled manner. - -_Note:_ This is **not** intended to be a replacement for [OData](https://learn.microsoft.com/en-us/odata/webapi-8/overview), [GraphQL](https://github.com/graphql-dotnet/graphql-dotnet), etc. but to provide a limited, explicitly supported, dynamic capability to filter an underlying query. - -Where this capability is different is that the separation from the API contract and the underlying data source schema is maintained. This is achieved by using configuration to explicitly define the fields that can be filtered and ordered, whilst also defining their relationship to the data source. This is in contrast to OData and GraphQL where the data source schema is largely exposed to the client. - -
- -### Features - -The following features are supported: - -- `$filter` - the ability to filter the underlying query based on a set of conditions. The following is supported: - - `eq` - equal to; expressed as `field eq 'value'` - - `ne` - not equal to; expressed as `field ne 'value'` - - `gt` - greater than; expressed as `field gt 'value'` - - `ge` - greater than or equal to; expressed as `field ge 'value'` - - `lt` - less than; expressed as `field lt 'value'` - - `le` - less than or equal to; expressed as `field le 'value'` - - `in` - in list; expressed as `field in ('value1', 'value2', ...)` - - `startswith` - starts with; expressed as `startswith(field, 'value')` - - `endswith` - ends with; expressed as `endswith(field, 'value')` - - `contains` - contains; expressed as `contains(field, 'value')` - - `and` - logical and; expressed as `field1 eq 'value1' and field2 eq 'value2'` - - `or` - logical or; expressed as `field1 eq 'value1' or field2 eq 'value2'` - - `not` - logical not; expressed as `not field eq 'value'` - - `null` - is null; expressed as `field eq null` - - `(` and `)` - grouping; expressed as `(field1 eq 'value1' and field2 eq 'value2') or field3 eq 'value3'`)` - -- `$orderby` - the ability to order the underlying query based on a set of fields. The following is supported: - - `asc` - ascending; expressed as `field asc` - - `desc` - descending; expressed as `field desc` - - `,` - multiple fields; expressed as `field1 asc, field2 desc` - -Where the `'value'` is expressed as a string it must be enclosed in single quotes. A number, boolean, date, date and time, or `null` should be expressed as a constant, as expected for the underlying field type. - -The following are examples of supported queries: - -``` -$filter=lastname eq 'Doe' and startswith(firstname, 'a') -$filter=salary gt 100000 and salary le 200000 -$filter=(lastname eq 'Doe' and firstname eq 'John') or (lastname eq 'Smith' and firstname eq 'Jane') -$filter=state in ('CA', 'NY', 'TX') -$filter=isactive eq true -$filter=terminated eq null -$filter=startdate ge 2020-01-01 -$orderby=lastname desc, firstname -``` - -
- -### Configuration - -The [`QueryArgsConfig`](./Querying/QueryArgsConfig.cs) provides the means to configure the desired support; the model is an _explicit_ opt-in, versus an opt-out, of the capabilities. - -This contains the following key capabilities: - -- [`FilterParser`](./Querying/QueryFilterParser.cs) - this is the `$filter` parser and LINQ translator. -- [`OrderByParser`](./Querying/QueryOrderByParser.cs) - this is the `$orderby` parser and LINQ translator. - -Each of these properties have the ability to _explicitly_ add fields and their corresponding configuration. An example is as follows: - -``` csharp -private static readonly QueryArgsConfig _config = QueryArgsConfig.Create() - .WithFilter(filter => filter - .AddField(nameof(Employee.LastName), c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) - .AddField(nameof(Employee.FirstName), c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) - .AddReferenceDataField(nameof(Employee.Gender), nameof(EfModel.Employee.GenderCode)) - .AddField(nameof(Employee.StartDate)) - .AddNullField(nameof(Employee.Termination), nameof(EfModel.Employee.TerminationDate), c => c.WithDefault(new QueryStatement($"{nameof(EfModel.Employee.TerminationDate)} == null")))) - .WithOrderBy(orderby => orderby - .AddField(nameof(Employee.LastName)) - .AddField(nameof(Employee.FirstName)) - .WithDefault($"{nameof(Employee.LastName)}, {nameof(Employee.FirstName)}")); -``` - -There are a number of different field configurations that can be added: - -Method | Description -|-|-| -`AddField` | Adds a field of the specified type `T`. See [`QueryFilterFieldConfig`](./Querying/QueryFilterFieldConfigT.cs). -`AddNullField` | Adds a field that only supports `null` checking operations; limits to `EQ` and `NE`. See [`QueryFilterNullFieldConfig`](./Querying/QueryFilterNullFieldConfig.cs). -`AddReferenceDataField` | Adds a reference data field of the specified type `TRef`. Automatically includes the requisite `IReferenceData.Code` validation, and limits operations to `EQ`, `NE` and `IN`. See [`QueryFilterReferenceDataFieldConfig`](./Querying/QueryFilterReferenceDataFieldConfig.cs). - -Each of the above methods support the following parameters: -- `field` - the name of the field (using the correct casing) that can be referenced within the `$filter`. -- `model` - the optional model name of the field (using the correct casing) to be used in the underlying LINQ operation (defaults to `field`). -- `configure` - an optional configuration action to further define the field configuration. - -Depending on the field type being added (as above), the following related configuration options are available: - -Method | Description -|-|-| -`AlsoCheckNotNull` | Indicates that a not-null check should also be performed when performing the operation. -`AsNullable` | Indicates that the field is nullable and therefore supports null equality operations. -`MustBeValid` | Indicates that the reference data field value must exist and be considered valid; i.e. it is `IReferenceData.IsValid`. -`UseIdentifier` | Indicates that the `IReferenceData.Id` should be used in the underlying LINQ operation instead of the `IReferenceData.Code`. -`WithConverter` | Provides the `IConverter` to convert the filer value string to the underlying field type of `T`. -`WithDefault` | Provides a default LINQ statement to be used for the field when no filtering is specified by the client. -`WithHelpText` | Provides additional help text for the field to be used where help is requested. -`WithOperators` | Overrides the supported operators for the field. See [`QueryFilterOperator`](./Querying/QueryFilterOperator.cs). -`WithResultWriter` | Provides an opportunity to override the default result writer; i.e. LINQ expression. -`WithUpperCase` | Indicates that the operation should be case-insensitive by performing an explicit `ToUpper()` on the field value. -`WithValue` | Provides an opportunity to override the converted field value when the filter is applied. - -
- -### Usage - -The configuration is then used to parse and apply the filter and/or order-by to the underlying query using the new `IQueryable.Where` and `IQueryable.OrderBy` extension methods. - -``` csharp -var query = new QueryArgs -{ - Filter = "LastName eq 'Doe' and startswith(firstname, 'a')", - OrderBy = "LastName desc, FirstName" -}; - -return _dbContext.Employees.Where(_queryConfig, query).OrderBy(_queryConfig, query).ToCollectionResultAsync(paging); -``` - -The [`QueryArgs`](../CoreEx/Entities/QueryArgs.cs), demonstrated above, is a simple class that is used to house the `Filter` and `OrderBy` properties in a consistent fashion. Additionally, the [`WebApiRequestOptions`](../CoreEx.AspNetCore/CoreEx.AspNetCore.WebApis.WebApiRequestOptions) automatically creates an instance of this class from the originating query string (i.e. `$filter` and `$orderby`). - -``` csharp -public Task GetAllAsync() - => _webApi.GetAsync(Request, p => _service.GetAllAsync(p.RequestOptions.Query, p.RequestOptions.Paging)); -``` - -
- -### Enablement - -The `CoreEx.Data.Querying` capabilities described above essentially parses the OData-like syntax and then translates it into the equivalent dynamic LINQ statements. This statement is then passed through the [Dynamic LINQ](https://dynamic-linq.net/) NuGet [library](https://dynamic-linq.net/). - -For example, the following OData-like filters would be translated into the equivalent dynamic LINQ statements: - -``` -$filter: code eq 'A' -LINQ: Where("Code == @0", ["A"]) ---- -$filter: startswith(firstName, 'abc'), -LINQ: Where("FirstName.ToUpper().StartsWith(@0)", ["ABC"]) -``` - -
- -### Help - -To aid the consumers (clients) of the OData-like endpoints a *help* request can be issued. This is performed by using either `$filter=help` or `$orderby=help` and will result in a `400-BadRequest` with help-like contents similar to the following: - -``` json -{ - "$filter": [ - "Filter field(s) are as follows: - LastName (Type: String, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) - FirstName (Type: String, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) - Gender (Type: Gender, Null: false, Operators: EQ, NE, IN) - StartDate (Type: DateTime, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) - Termination (Type: , Null: true, Operators: EQ, NE)" - ] -} - -{ - "$orderby": [ - "Order-by field(s) are as follows: - LastName (Direction: Both) - FirstName (Direction: Both)" - ] -} - -``` diff --git a/src/CoreEx.Data/strong-name-key.snk b/src/CoreEx.Data/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Database.MySql/CoreEx.Database.MySql.csproj b/src/CoreEx.Database.MySql/CoreEx.Database.MySql.csproj deleted file mode 100644 index 0d1731ed..00000000 --- a/src/CoreEx.Database.MySql/CoreEx.Database.MySql.csproj +++ /dev/null @@ -1,34 +0,0 @@ - - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx.Database.MySql - CoreEx - CoreEx .NET MySQL Database extras. - CoreEx .NET MySQL Database extras. - coreex db database sql mysql ado.net relational - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/CoreEx.Database.MySql/MySqlDatabase.cs b/src/CoreEx.Database.MySql/MySqlDatabase.cs deleted file mode 100644 index 335a94e6..00000000 --- a/src/CoreEx.Database.MySql/MySqlDatabase.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping.Converters; -using CoreEx.Results; -using Microsoft.Extensions.Logging; -using MySql.Data.MySqlClient; -using System; -using System.Linq; -using System.Data.Common; - -namespace CoreEx.Database.MySql -{ - /// - /// Provides MySQL database access functionality. - /// - /// The function to create the . - /// The optional . - /// The optional . - public class MySqlDatabase(Func create, ILogger? logger = null, DatabaseInvoker? invoker = null) : Database(create, MySqlClientFactory.Instance, logger, invoker) - { - /// - /// Gets the default . - /// - /// See . - public static int[] DefaultDuplicateErrorNumbers { get; } = [1022, 1062, 1088, 1291, 1586, 1859]; - - /// - public override IConverter RowVersionConverter => EncodedStringToDateTimeConverter.Default; - - /// - /// Indicates whether to transform the into an equivalent based on the . - /// - /// Transforms and throws the equivalent from the known list. - public bool ThrowTransformedException { get; set; } = true; - - /// - /// Indicates whether to check the when catching the . - /// - public bool CheckDuplicateErrorNumbers { get; set; } = true; - - /// - /// Gets or sets the list of known values that are considered a duplicate error. - /// - /// Overrides the . - public int[]? DuplicateErrorNumbers { get; set; } - - /// - protected override Result? OnDbException(DbException dbex) - { - if (ThrowTransformedException && dbex is MySqlException sex) - { - var msg = sex.Message?.TrimEnd(); - if (string.IsNullOrEmpty(msg)) - msg = null; - - switch (sex.Number) - { - case 56001: return Result.Fail(new ValidationException(msg, sex)); - case 56002: return Result.Fail(new BusinessException(msg, sex)); - case 56003: return Result.Fail(new AuthorizationException(msg, sex)); - case 56004: return Result.Fail(new ConcurrencyException(msg, sex)); - case 56005: return Result.Fail(new NotFoundException(msg, sex)); - case 56006: return Result.Fail(new ConflictException(msg, sex)); - case 56007: return Result.Fail(new DuplicateException(msg, sex)); - case 56010: return Result.Fail(new DataConsistencyException(msg, sex)); - - default: - if (CheckDuplicateErrorNumbers && (DuplicateErrorNumbers ?? DefaultDuplicateErrorNumbers).Contains(sex.Number)) - return Result.Fail(new DuplicateException(null, sex)); - - break; - } - } - - return base.OnDbException(dbex); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database.MySql/strong-name-key.snk b/src/CoreEx.Database.MySql/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Database.Postgres/CoreEx.Database.Postgres.csproj b/src/CoreEx.Database.Postgres/CoreEx.Database.Postgres.csproj deleted file mode 100644 index 3df57401..00000000 --- a/src/CoreEx.Database.Postgres/CoreEx.Database.Postgres.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net6.0;net8.0;net9.0 - CoreEx.Database.Postgres - CoreEx - CoreEx .NET Postgres Database extras. - CoreEx .NET Postgres Database extras. - coreex db database sql postgres ado.net relational - - - - - - - - - - - - - diff --git a/src/CoreEx.Database.Postgres/PostgresDatabase.cs b/src/CoreEx.Database.Postgres/PostgresDatabase.cs deleted file mode 100644 index 5093fc78..00000000 --- a/src/CoreEx.Database.Postgres/PostgresDatabase.cs +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping.Converters; -using CoreEx.Results; -using Microsoft.Extensions.Logging; -using Npgsql; -using System; -using System.Linq; -using System.Data.Common; -using System.Threading.Tasks; -using System.Threading; - -namespace CoreEx.Database.Postgres -{ - /// - /// Provides Npgsql (PostgreSQL) database access functionality. - /// - /// The function to create the . - /// The optional . - /// The optional . - public class PostgresDatabase(Func create, ILogger? logger = null, DatabaseInvoker? invoker = null) : Database(create, NpgsqlFactory.Instance, logger, invoker) - { - private static readonly PostgresDatabaseColumns _defaultColumns = new(); - - /// - /// Gets the default . - /// - /// See . - public static string[] DefaultDuplicateErrorNumbers { get; } = ["23505"]; - - /// - /// Gets or sets the names of the pre-configured . - /// - /// Do not update the default properties directly as a shared static instance is used (unless this is the desired behaviour); create a new instance for overridding. - public new PostgresDatabaseColumns DatabaseColumns { get; set; } = _defaultColumns; - - /// - /// Gets or sets the stored procedure name used by . - /// - /// Defaults to '"public"."sp_set_session_context"'. - public string SessionContextStoredProcedure { get; set; } = "\"public\".\"sp_set_session_context\""; - - /// - public override IConverter RowVersionConverter => EncodedStringToUInt32Converter.Default; - - /// - /// Indicates whether to transform the into an equivalent based on the . - /// - /// Transforms and throws the equivalent from the known list. - public bool ThrowTransformedException { get; set; } = true; - - /// - /// Indicates whether to check the when catching the . - /// - public bool CheckDuplicateErrorNumbers { get; set; } = true; - - /// - /// Gets or sets the list of known values that are considered a duplicate error. - /// - /// Overrides the . - public string[]? DuplicateErrorNumbers { get; set; } - - /// - /// Sets the PostgreSQL context using the specified values by invoking the using parameters named , - /// , and . - /// - /// The username (where null the value will default to ). - /// The timestamp (where null the value will default to ). - /// The tenant identifer (where null the value will not be used). - /// The unique user identifier (where null the value will not be used). - /// The . - /// See . - public Task SetPostgresSessionContextAsync(string? username, DateTime? timestamp, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default) - { - if (string.IsNullOrEmpty(SessionContextStoredProcedure)) - throw new InvalidOperationException("The SessionContextStoredProcedure property must have a value."); - - return Invoker.InvokeAsync(this, username, timestamp, tenantId, userId, async (_, username, timestamp, tenantId, userId, ct) => - { - return await StoredProcedure(SessionContextStoredProcedure) - .Param($"@{DatabaseColumns.SessionContextUsernameName}", username ?? ExecutionContext.EnvironmentUserName) - .Param($"@{DatabaseColumns.SessionContextTimestampName}", timestamp ?? SystemTime.Timestamp) - .ParamWith(tenantId, $"@{DatabaseColumns.SessionContextTenantIdName}") - .ParamWith(userId, $"@{DatabaseColumns.SessionContextUserIdName}") - .NonQueryAsync(ct).ConfigureAwait(false); - }, cancellationToken, nameof(SetPostgresSessionContextAsync)); - } - - /// - /// Sets the PostgreSQL session context using the . - /// - /// The . Defaults to . - /// The . - /// See for more information. - public Task SetPostgresSessionContextAsync(ExecutionContext? executionContext = null, CancellationToken cancellationToken = default) - { - var ec = executionContext ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current : null); - return (ec == null) - ? SetPostgresSessionContextAsync(null!, null, cancellationToken: cancellationToken) - : SetPostgresSessionContextAsync(ec.UserName, ec.Timestamp, ec.TenantId, ec.UserId, cancellationToken); - } - - /// - protected override Result? OnDbException(DbException dbex) - { - if (ThrowTransformedException && dbex is PostgresException pex) - { - var msg = pex.MessageText?.TrimEnd(); - if (string.IsNullOrEmpty(msg)) - msg = null; - - switch (pex.SqlState) - { - case "56001": return Result.Fail(new ValidationException(msg, pex)); - case "56002": return Result.Fail(new BusinessException(msg, pex)); - case "56003": return Result.Fail(new AuthorizationException(msg, pex)); - case "56004": return Result.Fail(new ConcurrencyException(msg, pex)); - case "56005": return Result.Fail(new NotFoundException(msg, pex)); - case "56006": return Result.Fail(new ConflictException(msg, pex)); - case "56007": return Result.Fail(new DuplicateException(msg, pex)); - case "56010": return Result.Fail(new DataConsistencyException(msg, pex)); - - default: - if (CheckDuplicateErrorNumbers && (DuplicateErrorNumbers ?? DefaultDuplicateErrorNumbers).Contains(pex.SqlState)) - return Result.Fail(new DuplicateException(null, pex)); - - break; - } - } - - return base.OnDbException(dbex); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database.Postgres/PostgresDatabaseColumns.cs b/src/CoreEx.Database.Postgres/PostgresDatabaseColumns.cs deleted file mode 100644 index 5732bf01..00000000 --- a/src/CoreEx.Database.Postgres/PostgresDatabaseColumns.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Database.Extended; -using CoreEx.Entities; - -namespace CoreEx.Database.Postgres -{ - /// - /// Extends the adding additional SQL Server specific. - /// - /// Overrides the to 'xmin'. This is a PostgreSQL system column (hidden); see - /// and for more information. - public class PostgresDatabaseColumns : DatabaseColumns - { - /// - /// Gets or sets the session context 'Username' column name. - /// - public string SessionContextUsernameName { get; set; } = "Username"; - - /// - /// Gets or sets the session context 'Timestamp' column name. - /// - public string SessionContextTimestampName { get; set; } = "Timestamp"; - - /// - /// Gets or sets the column name. - /// - public string SessionContextTenantIdName { get; set; } = "TenantId"; - - /// - /// Gets or sets the session context 'UserId' column name. - /// - public string SessionContextUserIdName { get; set; } = "UserId"; - - /// - /// Initializes a new instance of the class. - /// - public PostgresDatabaseColumns() => RowVersionName = "xmin"; - } -} \ No newline at end of file diff --git a/src/CoreEx.Database.Postgres/strong-name-key.snk b/src/CoreEx.Database.Postgres/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj b/src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj index 709bbc63..648ed9b2 100644 --- a/src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj +++ b/src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj @@ -1,22 +1,12 @@  - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx.Database.SqlServer - CoreEx - CoreEx .NET SQL Server Database extras. - CoreEx .NET SQL Server Database extras. - coreex db database sql sqlserver ado.net relational - - - - - + + - + + + - diff --git a/src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.ApplicationBuilder.cs b/src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.ApplicationBuilder.cs new file mode 100644 index 00000000..ea80219e --- /dev/null +++ b/src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.ApplicationBuilder.cs @@ -0,0 +1,34 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Microsoft.Extensions.Hosting; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides and related extensions. +/// +public static class CoreExSqlServerExtensions +{ + /// + /// Adds singleton keyed service(s) (as per ) that will be executed as a hosted service (in the background). + /// + /// The . + /// The number of hosted services to start to enable concurrency of processing across partitions. + /// The keyed singleton and health check key prefix; defaults to 'sqlserver-outbox-relay-'. + /// An optional action to configure each instance. + /// The for fluent-style method-chaining. + /// Where the is not specified it will, attempt to get the value from configuration using 'CoreEx:Host:Services:OutboxRelay:ServicesCount' as the key; otherwise, + /// defaults to '4'. + /// Uses the to enable. + public static IHostApplicationBuilder AddSqlServerOutboxRelayHostedService(this IHostApplicationBuilder builder, int? servicesCount = null, string serviceKeyPrefix = "sqlserver-outbox-relay-", Action? configure = null) + { + // Determine the number of services to add. + builder.ThrowIfNull(); + servicesCount ??= CoreEx.Abstractions.Internal.GetConfigurationValue($"CoreEx:Host:Services:OutboxRelay:ServicesCount", 4, builder.Configuration); + servicesCount.ThrowWhen(servicesCount => servicesCount <= 0 || servicesCount > 32); + + // Add the services as per count. + for (int i = 0; i < servicesCount; i++) + builder.Services.ThrowIfNull().AddHostedService($"{serviceKeyPrefix}{i:00}", configure); + + return builder; + } +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.DependencyInjection.cs b/src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.DependencyInjection.cs new file mode 100644 index 00000000..7ae74d49 --- /dev/null +++ b/src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.DependencyInjection.cs @@ -0,0 +1,124 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure - this is by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides and related extensions. +/// +public static partial class CoreExSqlServerExtensions +{ + /// + /// Adds a scoped service. + /// + /// The . + /// An optional action to configure the database instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddSqlServerDatabase(this IServiceCollection services, Action? configure = null) + => AddSqlServerDatabase(services, configure); + + /// + /// Adds a scoped service. + /// + /// The . + /// An optional action to configure the database instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddSqlServerDatabase(this IServiceCollection services, Action? configure = null) where TDatabase : SqlServerDatabase + { + return services.ThrowIfNull().AddScoped(sp => + { + var db = ActivatorUtilities.CreateInstance(sp); + configure?.Invoke(sp, db); + return db; + }); + } + + /// + /// Adds a scoped service for the . + /// + /// The . + /// Indicates whether to also register as the service. + /// The for fluent-style method-chaining. + public static IServiceCollection AddSqlServerUnitOfWork(this IServiceCollection services, bool addAsIUnitOfWork = true) + => AddSqlServerUnitOfWork(services, addAsIUnitOfWork); + + /// + /// Adds a scoped service for the specified . + /// + /// The . + /// The . + /// Indicates whether to also register as the service. + /// The for fluent-style method-chaining. + public static IServiceCollection AddSqlServerUnitOfWork(this IServiceCollection services, bool addAsIUnitOfWork = true) where TDatabase : SqlServerDatabase + { + services.ThrowIfNull().AddScoped(sp => + { + var sql = sp.GetRequiredService(); + return ActivatorUtilities.CreateInstance(sp, sql); + }); + + if (addAsIUnitOfWork) + services.AddScoped(sp => sp.GetRequiredService()); + + return services; + } + + /// + /// Adds a keyed scoped service. + /// + /// The . + /// An optional action to configure the instance. + /// Indicates whether to also register as the default (non-keyed) service. + /// The service key to use for the keyed registration. + /// The for fluent-style method-chaining. + /// See for more information + /// related to the underlying registration implementation. + public static IServiceCollection AddSqlServerOutboxPublisher(this IServiceCollection services, Action? configure = null, bool addAsDefaultIEventPublisher = true, string serviceKey = SqlServerOutboxPublisher.DefaultServiceKey) + => services.AddSqlServerOutboxPublisher(configure, addAsDefaultIEventPublisher, serviceKey); + + /// + /// Adds a keyed scoped service. + /// + /// The . + /// The . + /// An optional action to configure the instance. + /// Indicates whether to also register as the default (non-keyed) service. + /// The service key to use for the keyed registration. + /// The for fluent-style method-chaining. + /// See for more information + /// related to the underlying registration implementation. + public static IServiceCollection AddSqlServerOutboxPublisher(this IServiceCollection services, Action? configure = null, bool addAsDefaultIEventPublisher = true, string serviceKey = SqlServerOutboxPublisher.DefaultServiceKey) where TOutbox : SqlServerOutboxPublisher + => services.ThrowIfNull().AddEventPublisher(serviceKey, sp => + { + var outbox = ActivatorUtilities.CreateInstance(sp); + configure?.Invoke(sp, outbox); + return outbox; + }, addAsDefaultIEventPublisher); + + /// + /// Adds a scoped service for the . + /// + /// The . + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddSqlServerOutboxRelay(this IServiceCollection services, Action? configure = null) + => services.AddSqlServerOutboxRelay(configure); + + /// + /// Adds a scoped service for the . + /// + /// The . + /// The . + /// The . + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddSqlServerOutboxRelay(this IServiceCollection services, Action? configure = null) where TOutboxRelay : SqlServerOutboxRelay where TDatabase : SqlServerDatabase + { + return services.ThrowIfNull().AddScoped(sp => + { + var sql = sp.GetRequiredService(); + var relay = ActivatorUtilities.CreateInstance(sp, sql); + configure?.Invoke(sp, relay); + return relay; + }); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.OpenTelemetry.cs b/src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.OpenTelemetry.cs new file mode 100644 index 00000000..50096e10 --- /dev/null +++ b/src/CoreEx.Database.SqlServer/CoreExSqlServerExtensions.OpenTelemetry.cs @@ -0,0 +1,21 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace OpenTelemetry.Trace; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static class CoreExSqlServerExtensions +{ + /// + /// Enables CoreEx OpenTelemetry instrumentation. + /// + /// The . + /// The to support fluent-style method-chaining. + public static OpenTelemetryBuilder WithCoreExSqlServerTelemetry(this OpenTelemetryBuilder builder) => builder.ThrowIfNull() + .WithCoreExEventsSources() // Included here as they are leveraged by the Sql Server Outbox capabilities. + .WithTracing(t => t.AddInvokerAsSource() + .AddInvokerAsSource() + .AddSource("CoreEx.Database.Outbox.Relay")) + .WithMetrics(m => m.AddMeter(SqlServerMetrics.Meter.Name)); +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs b/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs deleted file mode 100644 index 15851e53..00000000 --- a/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Configuration; -using CoreEx.Database.SqlServer.Outbox; -using CoreEx.Hosting; -using CoreEx.Hosting.HealthChecks; -using Microsoft.Extensions.Logging; -using System; -using System.Text; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extension methods. - /// - public static class DatabaseServiceCollectionExtensions - { - /// - /// Adds the using the . - /// - /// The . - /// The function to create an instance of (used to set the underlying property). - /// The optional partition key. - /// The optional destination name (i.e. queue or topic). - /// Indicates whether a corresponding should be configured. - /// The . - /// To turn off the execution of the (s) at runtime set the 'EventOutboxHostedService:Enabled' configuration setting to false. - public static IServiceCollection AddSqlServerEventOutboxHostedService(this IServiceCollection services, Func eventOutboxDequeueFactory, string? partitionKey = null, string? destination = null, bool healthCheck = true) - { - var exe = services.BuildServiceProvider().GetRequiredService().GetCoreExValue("EventOutboxHostedService:Enabled"); - if (!exe.HasValue || exe.Value) - { - // Add the health check. - var hc = healthCheck ? new TimerHostedServiceHealthCheck() : null; - if (hc is not null) - { - var sb = new StringBuilder("sql-server-event-outbox"); - if (partitionKey is not null) - sb.Append($"-PartitionKey-{partitionKey}"); - - if (destination is not null) - sb.Append($"-Destination-{destination}"); - - services.AddHealthChecks().AddCheck(sb.ToString(), hc); - } - - // Add the hosted service with the health check where applicable. - services.AddHostedService(sp => new EventOutboxHostedService(sp, sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetRequiredService(), hc, partitionKey, destination) - { - EventOutboxDequeueFactory = eventOutboxDequeueFactory.ThrowIfNull(nameof(eventOutboxDequeueFactory)) - }); - } - - return services; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/Extended/SqlServerDatabaseColumns.cs b/src/CoreEx.Database.SqlServer/Extended/SqlServerDatabaseColumns.cs new file mode 100644 index 00000000..07be5dff --- /dev/null +++ b/src/CoreEx.Database.SqlServer/Extended/SqlServerDatabaseColumns.cs @@ -0,0 +1,27 @@ +namespace CoreEx.Database.SqlServer.Extended; + +/// +/// Extends the adding additional SQL Server specific. +/// +public record class SqlServerDatabaseColumns : DatabaseColumns +{ + /// + /// Gets or sets the session context 'Username' column name. + /// + public string SessionContextUsernameName { get; set; } = "Username"; + + /// + /// Gets or sets the session context 'Timestamp' column name. + /// + public string SessionContextTimestampName { get; set; } = "Timestamp"; + + /// + /// Gets or sets the 'TenantId' column name. + /// + public string SessionContextTenantIdName { get; set; } = "TenantId"; + + /// + /// Gets or sets the session context 'UserId' column name. + /// + public string SessionContextUserIdName { get; set; } = "UserId"; +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/Extended/SqlServerInvoker.cs b/src/CoreEx.Database.SqlServer/Extended/SqlServerInvoker.cs new file mode 100644 index 00000000..7b91d468 --- /dev/null +++ b/src/CoreEx.Database.SqlServer/Extended/SqlServerInvoker.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Database.SqlServer.Extended; + +/// +/// Provides the invoker functionality. +/// +[InvokerName("CoreEx.Database.SqlServer.SqlServer")] +public class SqlServerInvoker : DatabaseInvoker +{ + private static SqlServerInvoker? _default; + + /// + /// Gets the default instance. + /// + public static SqlServerInvoker Default => ExecutionContext.GetService() ?? (_default ??= new SqlServerInvoker()); +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/Extended/SqlServerUnitOfWorkInvoker.cs b/src/CoreEx.Database.SqlServer/Extended/SqlServerUnitOfWorkInvoker.cs new file mode 100644 index 00000000..db963b92 --- /dev/null +++ b/src/CoreEx.Database.SqlServer/Extended/SqlServerUnitOfWorkInvoker.cs @@ -0,0 +1,139 @@ +namespace CoreEx.Database.SqlServer.Extended; + +/// +/// Provides the underlying invoker functionality. +/// +/// Implements transaction handling including automatic save-point support for nested unit-of-work invocations. Also, where the underlying work returns an , +/// then an will trigger a rollback similar to an unhandled exception. +/// Where a transactional outbox is supported () then the will +/// automatically be included within the root (top-most) transaction. This is achieved by executing the . Nested (child) transactional rollbacks are also supported by the . +/// Note that the underlying implementation is not thread-safe. +[InvokerName("CoreEx.Database.SqlServer.SqlServerUnitOfWork")] +public class SqlServerUnitOfWorkInvoker : InvokerBase +{ + private static SqlServerUnitOfWorkInvoker? _default; + + /// + /// Gets the default instance. + /// + public static SqlServerUnitOfWorkInvoker Default => ExecutionContext.GetService() ?? (_default ??= new SqlServerUnitOfWorkInvoker()); + + /// + protected async override Task OnInvokeAsync(InvokerTracer tracer, SqlServerUnitOfWork unitOfWork, SqlServerDatabaseArgs args, Func> func, CancellationToken cancellationToken) + { + var txn = unitOfWork.Database.CurrentTransaction; + var isRootTxn = txn is null; + var savePoint = isRootTxn ? string.Empty : unitOfWork.Database.GetNextSavePointName(); + var eventStartCount = unitOfWork.Outbox?.Count ?? 0; + + tracer.Activity?.AddTag("database.id", unitOfWork.Database.DatabaseId); + + // Reusable rollback logic. + async Task RollbackAsync(Exception exception) + { + if (txn is not null) + { + if (isRootTxn) + { + await txn.RollbackAsync(cancellationToken).ConfigureAwait(false); + + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Information)) + tracer.Logger.LogInformation("Unit-of-work transaction rolled back due to error: {Error} [DatabaseId: {DatabaseId}]", exception.Message, unitOfWork.Database.DatabaseId); + } + else + { + await txn.RollbackAsync(savePoint, cancellationToken).ConfigureAwait(false); + + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Information)) + tracer.Logger.LogInformation("Unit-of-work transaction save-point '{SavePoint}' rolled back due to error: {Error} [DatabaseId: {DatabaseId}]", savePoint, exception.Message, unitOfWork.Database.DatabaseId); + } + } + + // Where outbox/events are supported then also rollback any added events. + unitOfWork.Outbox?.Rollback(Math.Max(0, unitOfWork.Outbox.Count - eventStartCount)); + } + + // Perform the unit-of-work within a transaction or save-point as appropriate. + try + { + // Where root, begin new transaction; otherwise, create save-point. + if (isRootTxn) + { + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.Logger.LogDebug("Unit-of-work transaction; creating (root) transaction. [DatabaseId: {DatabaseId}]", unitOfWork.Database.DatabaseId); + + var conn = await unitOfWork.Database.GetConnectionAsync(cancellationToken).ConfigureAwait(false); + txn = await conn.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + unitOfWork.Database.UseTransaction(txn); + } + else + { + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.Logger.LogDebug("Unit-of-work transaction; creating save-point '{SavePoint}'. [DatabaseId: {DatabaseId}]", savePoint, unitOfWork.Database.DatabaseId); + + await txn!.SaveAsync(savePoint, cancellationToken).ConfigureAwait(false); + } + + // Invoke the "work". + var result = await base.OnInvokeAsync(tracer, unitOfWork, args, func, cancellationToken).ConfigureAwait(false); + + // Where a failure, rollback transaction or save-point as appropriate, and return. + if (result is IResult ir && ir.IsFailure) + { + // Rollback transaction or save-point as appropriate; then return the failure result. + await RollbackAsync(ir.Error!).ConfigureAwait(false); + return result; + } + + // Commit transaction or complete save-point as appropriate. + if (isRootTxn) + { + // Where outbox/events are supported then publish. + var outboxEnqueued = 0; + if (unitOfWork.AreEventsSupported && !unitOfWork.Events.IsEmpty) + { + await unitOfWork.Outbox!.PublishAsync(cancellationToken).ConfigureAwait(false); + outboxEnqueued = unitOfWork.Outbox.Count; + } + + // Commit the work and outbox. + await txn!.CommitAsync(cancellationToken).ConfigureAwait(false); + + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.Logger.LogDebug("Unit-of-work transaction committed successfully. [DatabaseId: {DatabaseId}]", unitOfWork.Database.DatabaseId); + + // Record metrics for enqueued outbox messages. + if (outboxEnqueued > 0) + SqlServerMetrics.OutboxEnqueued.Add(outboxEnqueued); + } + else if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.Logger.LogDebug("Unit-of-work transaction save-point '{SavePoint}' completed successfully. [DatabaseId: {DatabaseId}]", savePoint, unitOfWork.Database.DatabaseId); + + // Sweet, we made it. Happy times! + return result; + } + catch (Exception ex) + { + // Rollback transaction or save-point as appropriate. + await RollbackAsync(ex).ConfigureAwait(false); + + // Where extended exception, and the result is an IResult then convert to a failure result. + if (ExtendedException.TryConvertExceptionToResult(ex, out var result)) + return result; + + // Keep on bubbling. + throw; + } + finally + { + // Dispose and reset transaction where root. + if (isRootTxn) + { + if (txn is not null) + await txn.DisposeAsync().ConfigureAwait(false); + + unitOfWork.Database.UseTransaction(null); + } + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/GlobalUsing.cs b/src/CoreEx.Database.SqlServer/GlobalUsing.cs new file mode 100644 index 00000000..b9db2fcc --- /dev/null +++ b/src/CoreEx.Database.SqlServer/GlobalUsing.cs @@ -0,0 +1,25 @@ +global using CoreEx; +global using CoreEx.Abstractions; +global using CoreEx.Data; +global using CoreEx.Database.Abstractions; +global using CoreEx.Database.Extended; +global using CoreEx.Database.Outbox; +global using CoreEx.Database.SqlServer; +global using CoreEx.Database.SqlServer.Extended; +global using CoreEx.Database.SqlServer.Outbox; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Hosting; +global using CoreEx.Invokers; +global using CoreEx.Mapping.Converters; +global using CoreEx.Mapping.Converters.Abstractions; +global using CoreEx.Results.Abstractions; +global using CoreEx.Security; +global using Microsoft.Data.SqlClient; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using System.Collections.Immutable; +global using System.Data; +global using System.Data.Common; +global using System.Diagnostics.Metrics; +global using System.Text.Json; \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/Outbox/EventOutboxDequeueBase.cs b/src/CoreEx.Database.SqlServer/Outbox/EventOutboxDequeueBase.cs deleted file mode 100644 index fbed039f..00000000 --- a/src/CoreEx.Database.SqlServer/Outbox/EventOutboxDequeueBase.cs +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using CoreEx.Json; -using CoreEx.Mapping; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using System.Transactions; - -namespace CoreEx.Database.SqlServer.Outbox -{ - /// - /// Provides the base database outbox dequeue. - /// - /// The (being the unique event identifier) can be leveraged by the underlying messaging platform to perform duplicate checking. There is no guarantee that a dequeued event is on published more - /// than once, the guarantee is at best at-least once semantics based on the implementation of the final . - /// - /// The . - /// The . - /// The . - public abstract class EventOutboxDequeueBase(IDatabase database, IEventSender eventSender, ILogger logger) : IDatabaseMapper - { - /// - /// Gets the . - /// - protected IDatabase Database { get; } = database.ThrowIfNull(nameof(database)); - - /// - /// Gets the . - /// - protected IEventSender EventSender { get; } = eventSender.ThrowIfNull(nameof(eventSender)); - - /// - /// Gets the . - /// - protected ILogger Logger { get; } = logger.ThrowIfNull(nameof(logger)); - - /// - /// Gets the event outbox dequeue stored procedure name. - /// - protected abstract string DequeueStoredProcedure { get; } - - /// - /// Gets the column name for the property within the event outbox table. - /// - /// Defaults to 'EventId'. - protected virtual string EventIdColumnName => "EventId"; - - /// - /// Gets or sets the default partition key. - /// - /// Defaults to '$default'. This will ensure that value is nullified when reading from the database. - public string DefaultPartitionKey { get; set; } = "$default"; - - /// - /// Gets or sets the default destination name. - /// - /// Defaults to '$default'. This will ensure that value is nullified when reading from the database. - public string DefaultDestination { get; set; } = "$default"; - - /// - /// Performs the dequeue of the events (up to ) from the database outbox and then sends (via ). - /// - /// The maximum dequeue size. Defaults to 50. - /// The partition key. - /// The destination name (i.e. queue or topic). - /// The . - /// The number of dequeued and sent events. - public async Task DequeueAndSendAsync(int maxDequeueSize = 50, string? partitionKey = null, string? destination = null, CancellationToken cancellationToken = default) - { - Stopwatch sw; - maxDequeueSize = maxDequeueSize > 0 ? maxDequeueSize : 1; - - // Where a cancel has been requested then this is a convenient time to do it. - if (cancellationToken.IsCancellationRequested) - return 0; - - // Manage a transaction to ensure that the dequeue only commits after successful publish. - var txn = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); - - try - { - // Dequeue the events; where there are none to send, then simply exit and try again later. - Logger.LogTrace("Dequeue events. [MaxDequeueSize={MaxDequeueSize}, PartitionKey={PartitionKey}, Destination={Destination}]", maxDequeueSize, partitionKey, destination); - - sw = Stopwatch.StartNew(); - var events = await DequeueAsync(maxDequeueSize, partitionKey, destination, cancellationToken).ConfigureAwait(false); - sw.Stop(); - - if (events == null || !events.Any()) - { - txn.Complete(); - return 0; - } - - Logger.LogDebug("{EventCount} event(s) were dequeued. [Elapsed={Elapsed}ms]", events.Count(), sw.Elapsed.TotalMilliseconds); - - // Send the events. - sw = Stopwatch.StartNew(); - await EventSender.SendAsync(events.ToArray(), cancellationToken).ConfigureAwait(false); - sw.Stop(); - Logger.LogDebug("{EventCount} event(s) were sent successfully. [Sender={Sender}, Elapsed={Elapsed}ms]", events.Count(), EventSender.GetType().Name, sw.Elapsed.TotalMilliseconds); - - // Commit the transaction. - txn.Complete(); - return events.Count(); - } - finally - { - txn?.Dispose(); - } - } - - /// - /// Dequeues the list. - /// - private Task> DequeueAsync(int maxDequeueSize, string? partitionKey, string? destination, CancellationToken cancellationToken) - => Database.StoredProcedure(DequeueStoredProcedure) - .Param("@MaxDequeueSize", maxDequeueSize) - .Param("@PartitionKey", partitionKey) - .Param("@Destination", destination) - .SelectQueryAsync(this, cancellationToken); - - /// - public EventSendData MapFromDb(DatabaseRecord record, OperationTypes operationType = OperationTypes.Unspecified) - { - var source = record.GetValue(nameof(EventSendData.Source)); - var attributes = record.GetValue(nameof(EventSendData.Attributes)); - var data = record.GetValue(nameof(EventSendData.Data)); - var destination = record.GetValue(nameof(EventSendData.Destination)); - var partitionKey = record.GetValue(nameof(EventSendData.PartitionKey)); - - return new() - { - Id = record.GetValue(EventIdColumnName), - Destination = destination == DefaultDestination ? null : destination, - Subject = record.GetValue(nameof(EventSendData.Subject)), - Action = record.GetValue(nameof(EventSendData.Action)), - Type = record.GetValue(nameof(EventSendData.Type)), - Source = string.IsNullOrEmpty(source) ? null : new Uri(source, UriKind.RelativeOrAbsolute), - Timestamp = record.GetValue(nameof(EventSendData.Timestamp)), - CorrelationId = record.GetValue(nameof(EventSendData.CorrelationId)), - Key = record.GetValue(nameof(EventSendData.Key)), - TenantId = record.GetValue(nameof(EventSendData.TenantId)), - PartitionKey = partitionKey == DefaultPartitionKey ? null : partitionKey, - ETag = record.GetValue(nameof(EventSendData.ETag)), - Attributes = attributes == null || attributes.Length == 0 ? null : JsonSerializer.Default.Deserialize>(new BinaryData(attributes)), - Data = data == null || data.Length == 0 ? null : new BinaryData(data) - }; - } - - /// - /// This method will result in a . - void IDatabaseMapper.MapToDb(EventSendData? value, DatabaseParameterCollection parameters, OperationTypes operationType) => throw new NotSupportedException(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/Outbox/EventOutboxEnqueueBase.cs b/src/CoreEx.Database.SqlServer/Outbox/EventOutboxEnqueueBase.cs deleted file mode 100644 index ea88c562..00000000 --- a/src/CoreEx.Database.SqlServer/Outbox/EventOutboxEnqueueBase.cs +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database.SqlServer.Outbox -{ - /// - /// Provides the base database outbox enqueue . - /// - /// By default the events are first sent/enqueued to the database outbox, then a secondary process dequeues and sends. Also, by enqueing to a single database outbox the event publishing order is preserved. - /// This can however introduce unwanted latency depending on the frequency in which the secondary process performs the dequeue and send, as this is essentially a polling-based operation. To improve (minimize) latency, the primary - /// can be specified using . This will then be used to send the events immediately, and where successful, they will be audited in the database as dequeued - /// event(s); versus on error (as a backup), where they will be enqueued for the out-of-process dequeue and send (as per default). Note: the challenge this primary sender introduces is in-order publishing; there is no means to guarantee order for the - /// events that are processed on error. - /// The . - /// The . - public abstract class EventOutboxEnqueueBase(IDatabase database, ILogger logger) : IEventSender - { - private IEventSender? _eventSender; - - /// - /// Gets the . - /// - protected IDatabase Database { get; } = database.ThrowIfNull(nameof(database)); - - /// - /// Gets the . - /// - protected ILogger Logger { get; } = logger.ThrowIfNull(nameof(logger)); - - /// - /// Gets the event outbox enqueue stored procedure name. - /// - protected abstract string EnqueueStoredProcedure { get; } - - /// - /// Gets the column name for the property within the event outbox table. - /// - /// Defaults to 'EventId'. - protected virtual string EventIdColumnName => "EventId"; - - /// - /// Gets or sets the default partition key. - /// - /// Defaults to '$default'. This will ensure that there is always a value recorded in the database. - public string DefaultPartitionKey { get; set; } = "$default"; - - /// - /// Gets or sets the default destination name. - /// - /// Defaults to '$default'. This will ensure that there is always a value recorded in the database. - public string DefaultDestination { get; set; } = "$default"; - - /// - /// Sets the to act as the primary where outbox enqueue is to provide backup/audit capabilities. - /// - public void SetPrimaryEventSender(IEventSender eventSender) - { - if (eventSender != null & eventSender is EventOutboxEnqueueBase) - throw new ArgumentException($"{nameof(SetPrimaryEventSender)} value must not be of Type {nameof(EventOutboxEnqueueBase)}.", nameof(eventSender)); - - _eventSender = eventSender; - } - - /// - /// - /// - /// - /// - /// Executes the to send / enqueue the to the underlying database outbox tables. - public async Task SendAsync(IEnumerable events, CancellationToken cancellationToken = default) - { - if (events == null || !events.Any()) - return; - - Stopwatch sw = Stopwatch.StartNew(); - var unsentEvents = new List(events); - - if (_eventSender != null) - { - try - { - await _eventSender!.SendAsync(events, cancellationToken).ConfigureAwait(false); - sw.Stop(); - unsentEvents.Clear(); - Logger.LogDebug("{EventCount} event(s) were sent successfully; will be forwarded (sent/enqueued) to the datatbase outbox as sent. [Sender={Sender}, Elapsed={Elapsed}ms]", - events.Count(), _eventSender.GetType().Name, sw.Elapsed.TotalMilliseconds); - } - catch (EventSendException esex) - { - sw.Stop(); - Logger.LogWarning(esex, "{UnsentCount} of {EventCount} event(s) were unable to be sent successfully; will be forwarded (sent/enqueued) to the datatbase outbox for an out-of-process send: {ErrorMessage} [Sender={Sender}, Elapsed={Elapsed}ms]", - esex.NotSentEvents?.Count() ?? unsentEvents.Count, events.Count(), esex.Message, _eventSender!.GetType().Name, sw.Elapsed.TotalMilliseconds); - - if (esex.NotSentEvents != null) - unsentEvents = esex.NotSentEvents.ToList(); - } - catch (Exception ex) - { - sw.Stop(); - Logger.LogWarning(ex, "{EventCount} event(s) were unable to be sent successfully; will be forwarded (sent/enqueued) to the datatbase outbox for an out-of-process send: {ErrorMessage} [Sender={Sender}, Elapsed={Elapsed}ms]", - events.Count(), ex.Message, _eventSender!.GetType().Name, sw.Elapsed.TotalMilliseconds); - } - } - - sw = Stopwatch.StartNew(); - await Database.StoredProcedure(EnqueueStoredProcedure) - .Param("@EventList", CreateEventsJsonForDatabase(events, unsentEvents)) - .NonQueryAsync(cancellationToken).ConfigureAwait(false); - - sw.Stop(); - Logger.LogDebug("{EventCount} event(s) were enqueued; {SuccessCount} as sent, {ErrorCount} to be sent. [Sender={Sender}, Elapsed={Elapsed}ms]", - events.Count(), events.Count() - unsentEvents.Count, unsentEvents.Count, GetType().Name, sw.Elapsed.TotalMilliseconds); - - AfterSend?.Invoke(this, EventArgs.Empty); - } - - /// - /// Creates the events JSON to send to the database. - /// - private string CreateEventsJsonForDatabase(IEnumerable list, IEnumerable unsentList) - { - using var stream = new MemoryStream(); - using var json = new Utf8JsonWriter(stream); - - json.WriteStartArray(); - - foreach (var item in list) - { - json.WriteStartObject(); - if (item.Id is not null) - json.WriteString(EventIdColumnName, item.Id); - - json.WriteBoolean("EventDequeued", !unsentList.Contains(item)); - json.WriteString(nameof(EventSendData.Destination), item.Destination ?? DefaultDestination ?? throw new InvalidOperationException($"The {nameof(DefaultDestination)} must have a non-null value.")); - - if (item.Subject is not null) - json.WriteString(nameof(EventSendData.Subject), item.Subject); - - if (item.Action is not null) - json.WriteString(nameof(EventSendData.Action), item.Action); - - if (item.Type is not null) - json.WriteString(nameof(EventSendData.Type), item.Type); - - if (item.Source is not null) - json.WriteString(nameof(EventSendData.Source), item.Source?.ToString()); - - if (item.Timestamp is not null) - json.WriteString(nameof(EventSendData.Timestamp), (DateTimeOffset)item.Timestamp); - - if (item.CorrelationId is not null) - json.WriteString(nameof(EventSendData.CorrelationId), item.CorrelationId); - - if (item.Key is not null) - json.WriteString(nameof(EventSendData.Key), item.Key); - - if (item.TenantId is not null) - json.WriteString(nameof(EventSendData.TenantId), item.TenantId); - - json.WriteString(nameof(EventSendData.PartitionKey), item.PartitionKey ?? DefaultPartitionKey ?? throw new InvalidOperationException($"The {nameof(DefaultPartitionKey)} must have a non-null value.")); - - if (item.ETag is not null) - json.WriteString(nameof(EventSendData.ETag), item.ETag); - - json.WriteBase64String(nameof(EventSendData.Attributes), item.Attributes == null || item.Attributes.Count == 0 ? new BinaryData([]) : Json.JsonSerializer.Default.SerializeToBinaryData(item.Attributes)); - json.WriteBase64String(nameof(EventSendData.Data), item.Data ?? new BinaryData([])); - json.WriteEndObject(); - } - - json.WriteEndArray(); - json.Flush(); - - return Encoding.UTF8.GetString(stream.ToArray()); - } - - /// - public event EventHandler? AfterSend; - } -} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/Outbox/EventOutboxHostedService.cs b/src/CoreEx.Database.SqlServer/Outbox/EventOutboxHostedService.cs deleted file mode 100644 index f78c6a72..00000000 --- a/src/CoreEx.Database.SqlServer/Outbox/EventOutboxHostedService.cs +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using CoreEx.Hosting; -using CoreEx.Hosting.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database.SqlServer.Outbox -{ - /// - /// Provides the dequeue and publish () capabilities. - /// - /// This will instantiate an using the underlying and invoke . - public class EventOutboxHostedService : SynchronizedTimerHostedServiceBase - { - private TimeSpan? _interval; - private int? _maxDequeueSize; - private string? _name; - - /// - /// Provides an opportunity to make a one-off change to the underlying timer to trigger using the specified to the registered . - /// - /// The used to get the registered . - /// The one-off interval before triggering; defaults to null which represents an immediate trigger. - /// Indicates whether to not adjust the time where the time remaining is less than the one-off interval specified. - /// Where there is more than one instance registered, or none, then no action will be taken. - /// This functionality is intended for low volume event publishing where there is need to bring forward the configured interval for a one-off execution. This is particularly useful where there is a need to publish - /// an event immediately versus waiting for the next scheduled execution. - public static void OneOffTrigger(IServiceProvider serviceProvider, TimeSpan? oneOffInterval = null, bool leaveWhereTimeRemainingIsLess = true) - { - var services = serviceProvider.ThrowIfNull(nameof(serviceProvider)).GetServices().OfType(); - if (services.Count() == 1) - services.First().OneOffTrigger(oneOffInterval, leaveWhereTimeRemainingIsLess); - } - - /// - /// Get or sets the configuration name for . Defaults to 'Interval'. - /// - public string IntervalName { get; set; } = "Interval"; - - /// - /// Gets or sets the configuration name for . Defaults to 'MaxDequeueSize'. - /// - public string MaxDequeueSizeName { get; set; } = "MaxDequeueSize"; - - /// - /// Gets or sets the default interval seconds used where the specified is not configured/specified. Defaults to thirty seconds. - /// - public static TimeSpan DefaultInterval { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// The . - /// The . - /// The optional to report health. - /// The optional partition key. - /// The optional destination name (i.e. queue or topic). - public EventOutboxHostedService(IServiceProvider serviceProvider, ILogger logger, SettingsBase settings, IServiceSynchronizer synchronizer, TimerHostedServiceHealthCheck? healthCheck = null, string? partitionKey = null, string? destination = null) - : base(serviceProvider, logger, settings, synchronizer, healthCheck) - { - PartitionKey = partitionKey; - Destination = destination; - - // Build the synchronization name. - var sb = new StringBuilder(); - if (partitionKey != null) - sb.Append($"PartitionKey-{partitionKey}"); - - if (destination != null) - { - if (sb.Length > 0) - sb.Append('-'); - - SynchronizationName = $"Destination-{destination}"; - } - - SynchronizationName = sb.Length > 0 ? sb.ToString() : null; - } - - /// - /// Gets the optional partition key. - /// - public string? PartitionKey { get; } - - /// - /// Gets the optional destination name (i.e. queue or topic). - /// - public string? Destination { get; } - - /// - /// Gets the service name (used for the likes of configuration and logging). - /// - public override string ServiceName => _name ??= $"{GetType().Name}{(PartitionKey == null ? "" : $".{PartitionKey}")}"; - - /// - /// Gets or sets the interval between each execution. - /// - /// Will default to configuration, a) : , then b) , where specified; otherwise, . - public override TimeSpan Interval - { - get => _interval ?? Settings.GetCoreExValue($"{ServiceName}:{IntervalName}".Replace(".", "_")) ?? Settings.GetCoreExValue(IntervalName.Replace(".", "_")) ?? DefaultInterval; - set => _interval = value; - } - - /// - /// Gets or sets the maximum dequeue size to limit the number of events that are dequeued within a single operation. - /// - /// Will default to configuration, a) : , then b) , where specified; otherwise, 10. - public int MaxDequeueSize - { - get => _maxDequeueSize ?? Settings.GetCoreExValue($"{ServiceName}:{MaxDequeueSizeName}".Replace(".", "_")) ?? Settings.GetCoreExValue(MaxDequeueSizeName.Replace(".", "_")) ?? 10; - set => _maxDequeueSize = value; - } - - /// - /// Get or sets the function to create an instance of . - /// - public Func? EventOutboxDequeueFactory { get; set; } - - /// - /// Executes the instance to perform the until queue is empty. - /// - /// - /// - protected override async Task SynchronizedExecuteAsync(IServiceProvider scopedServiceProvider, CancellationToken cancellationToken = default) - { - if (EventOutboxDequeueFactory == null) - throw new InvalidOperationException($"The {nameof(EventOutboxDequeueFactory)} property must be configured to create an instance of the {nameof(EventOutboxDequeueBase)}."); - - int sent; - - do - { - // As we want to tight loop the execution where there may be more in the queue, a new 'Scope' is used to ensure new instances of dependencies are used otherwise a disposed error may occur for the underlying transaction. - using var scope = scopedServiceProvider.CreateScope(); - ExecutionContext.Reset(); - var eod = EventOutboxDequeueFactory(scope.ServiceProvider) ?? throw new InvalidOperationException($"The {nameof(EventOutboxDequeueFactory)} function must return an instance of {nameof(EventOutboxDequeueBase)}."); - sent = await eod.DequeueAndSendAsync(MaxDequeueSize, PartitionKey, Destination, cancellationToken).ConfigureAwait(false); - } - while (sent > 0) ; - } - - /// - protected override HealthCheckResult OnReportHealthStatus(Dictionary data) - { - data.Add("maxDequeueSize", MaxDequeueSize); - data.Add("partitionKey", PartitionKey ?? ""); - data.Add("destination", Destination ?? ""); - data.Add("synchronizer", SynchronizationName ?? ""); - - return base.OnReportHealthStatus(data); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/Outbox/EventOutboxService.cs b/src/CoreEx.Database.SqlServer/Outbox/EventOutboxService.cs deleted file mode 100644 index 9cb9083a..00000000 --- a/src/CoreEx.Database.SqlServer/Outbox/EventOutboxService.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using CoreEx.Hosting; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database.SqlServer.Outbox -{ - /// - /// Provides the dequeue and publish self-service capabilities. - /// - /// This will instantiate an using the underlying and invoke . - /// The . - /// The . - /// The . - /// The optional partition key. - /// The optional destination name (i.e. queue or topic). - public class EventOutboxService(IServiceProvider serviceProvider, ILogger logger, SettingsBase? settings = null, string? partitionKey = null, string? destination = null) : ServiceBase(serviceProvider, logger, settings) - { - private string? _name; - private int? _maxIterations; - private int? _maxDequeueSize; - - /// - /// Gets or sets the configuration name for . Defaults to 'MaxDequeueSize'. - /// - public string MaxDequeueSizeName { get; set; } = "MaxDequeueSize"; - - /// - /// Gets the optional partition key. - /// - public string? PartitionKey { get; } = partitionKey; - - /// - /// Gets the optional destination name (i.e. queue or topic). - /// - public string? Destination { get; } = destination; - - /// - /// Gets the service name (used for the likes of configuration and logging). - /// - public override string ServiceName => _name ??= $"{GetType().Name}{(PartitionKey == null ? "" : $".{PartitionKey}")}"; - - /// - /// Will default to configuration, a) : , then b) , where specified; otherwise, . - public override int MaxIterations - { - get => _maxIterations ?? Settings.GetCoreExValue($"{ServiceName}:{MaxIterationsName}".Replace(".", "_")) ?? Settings.GetCoreExValue(MaxIterationsName.Replace(".", "_")) ?? DefaultMaxIterations; - set => _maxIterations = value; - } - - /// - /// Gets or sets the maximum dequeue size to limit the number of events that are dequeued within a single operation. - /// - /// Will default to configuration, a) : , then b) , where specified; otherwise, 10. - public int MaxDequeueSize - { - get => _maxDequeueSize ?? Settings.GetCoreExValue($"{ServiceName}:{MaxDequeueSizeName}".Replace(".", "_")) ?? Settings.GetCoreExValue(MaxDequeueSizeName.Replace(".", "_")) ?? 10; - set => _maxDequeueSize = value; - } - - /// - /// Get or sets the function to create an instance of . - /// - public Func? EventOutboxDequeueFactory { get; set; } - - /// - /// Executes the instance to perform the . - /// - /// The scoped . - /// The . - /// true indicates to execute the next iteration (i.e. continue); otherwise, false to stop. - protected override async Task ExecuteAsync(IServiceProvider scopedServiceProvider, CancellationToken cancellationToken) - { - if (EventOutboxDequeueFactory == null) - throw new InvalidOperationException($"The {nameof(EventOutboxDequeueFactory)} property must be configured to create an instance of the {nameof(EventOutboxDequeueBase)}."); - - var eod = EventOutboxDequeueFactory(scopedServiceProvider) ?? throw new InvalidOperationException($"The {nameof(EventOutboxDequeueFactory)} function must return an instance of {nameof(EventOutboxDequeueBase)}."); - var sent = await eod.DequeueAndSendAsync(MaxDequeueSize, PartitionKey, Destination, cancellationToken).ConfigureAwait(false); - return sent > 0; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxPublisher.cs b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxPublisher.cs new file mode 100644 index 00000000..759b4f6a --- /dev/null +++ b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxPublisher.cs @@ -0,0 +1,30 @@ +namespace CoreEx.Database.SqlServer.Outbox; + +/// +/// Provides the SQL Server to be used as a transactional outbox. +/// +/// As the is used, the should participate in the same transaction. It is the responsibility of the caller to manage this transaction. +public class SqlServerOutboxPublisher : DatabaseOutboxPublisherBase +{ + /// + /// Gets the default service key used when registering the service. + /// + /// See related . + public const string DefaultServiceKey = "SqlServerOutbox"; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The optional . + /// The optional . + /// The optional . + public SqlServerOutboxPublisher(SqlServerDatabase database, IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger? logger = null) + : base(database, destinationProvider, formatter, logger) + { + // Attempt to automatically set the statement by convention, if possible. + var schema = ExecutionContext.GetService()?.DomainName; + if (schema is not null) + Statement = SqlStatement.StoredProcedure($"[{schema}].[spOutboxEnqueue]"); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs new file mode 100644 index 00000000..434990a7 --- /dev/null +++ b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs @@ -0,0 +1,58 @@ +namespace CoreEx.Database.SqlServer.Outbox; + +/// +/// Provides the SQL Server transactional outbox relay using the destination . +/// +/// The . +/// The destination . +/// The optional . +public class SqlServerOutboxRelay(SqlServerDatabase database, IEventPublisher eventPublisher, ILogger? logger = null) + : DatabaseOutboxRelayBase(database, eventPublisher, logger) +{ + /// + /// + /// The is used to qualify the stored procedure names. The by-convention names used are as follows: + /// + /// = '[schema].[spOutboxBatchClaim]' + /// = '[schema].[spOutboxBatchComplete]' + /// = '[schema].[spOutboxBatchCancel]' + /// + public override void SetStatementsByConvention(string? schema = null) + { + schema ??= ExecutionContext.GetService()?.DomainName; + if (schema is not null) + { + ClaimBatchStatement = SqlStatement.StoredProcedure($"[{schema}].[spOutboxBatchClaim]"); + CompleteBatchStatement = SqlStatement.StoredProcedure($"[{schema}].[spOutboxBatchComplete]"); + CancelBatchStatement = SqlStatement.StoredProcedure($"[{schema}].[spOutboxBatchCancel]"); + } + } + + /// + protected override bool IsTransientException(Exception exception) + { + if (exception is SqlException sex && sex.Errors.Count > 0) + { + switch (sex.Errors[0].Number) + { + case 1205: return true; // Deadlock: https://learn.microsoft.com/en-us/sql/relational-databases/errors-events/mssqlserver-1205-database-engine-error + } + } + + return base.IsTransientException(exception); + } + + /// + protected async override Task CompleteBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, CancellationToken cancellationToken) + { + await base.CompleteBatchAsync(args, leaseId, cancellationToken); + + if (EventPublisher.IsEmpty) + return; + + // Capture metrics; no need to capture each as this would be diminishing returns, as the oldest and newest are the most important. + SqlServerMetrics.OutboxRelayBatchSize.Add(EventPublisher.Count); + 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/Outbox/SqlServerOutboxRelayHostedService.cs b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelayHostedService.cs new file mode 100644 index 00000000..d299df4d --- /dev/null +++ b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelayHostedService.cs @@ -0,0 +1,10 @@ +namespace CoreEx.Database.SqlServer.Outbox; + +/// +/// Provides the SQL Server execution leveraging a . +/// +/// The . +/// The . +public sealed class SqlServerOutboxRelayHostedService(IServiceProvider serviceProvider, ILogger logger) + : DatabaseOutboxRelayHostedServiceBase(serviceProvider, logger) +{ } \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/SqlServerCommand.cs b/src/CoreEx.Database.SqlServer/SqlServerCommand.cs new file mode 100644 index 00000000..ef238175 --- /dev/null +++ b/src/CoreEx.Database.SqlServer/SqlServerCommand.cs @@ -0,0 +1,9 @@ +namespace CoreEx.Database.SqlServer; + +/// +/// Provides extended SQL Server capabilities. +/// +/// The . +/// The . +/// As the underlying implements this is only created (and automatically disposed) where executing the command proper. +public sealed class SqlServerCommand(SqlServerDatabase db, SqlStatement statement) : DatabaseCommand(db, statement) { } \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/SqlServerDatabase.SessionContext.cs b/src/CoreEx.Database.SqlServer/SqlServerDatabase.SessionContext.cs new file mode 100644 index 00000000..801f7c35 --- /dev/null +++ b/src/CoreEx.Database.SqlServer/SqlServerDatabase.SessionContext.cs @@ -0,0 +1,47 @@ +namespace CoreEx.Database.SqlServer; + +public partial class SqlServerDatabase +{ + /// + /// Gets or sets the used by . + /// + /// Defaults to to invoke '[dbo].[spSetSessionContext]'. + public SqlStatement SessionContextStatement { get; set => field = value.ThrowIfNull(); } = SqlStatement.StoredProcedure("[dbo].[spSetSessionContext]"); + + /// + /// Sets the SQL session context using the specified values by invoking the using parameters named , + /// , and . + /// + /// The username (where the value will default to ). + /// The timestamp (where the value will default to ). + /// The tenant identifer (where the value will not be used). + /// The unique user identifier (where the value will not be used). + /// The . + /// See . + public Task SetSqlSessionContextAsync(string? username, DateTimeOffset? timestamp, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default) + { + return Invoker.InvokeAsync(this, DbArgs, async (_, _, cancellationToken) => + { + var r = await Statement(SessionContextStatement) + .Param(NamedColumns.SessionContextUsernameName, username ?? AuthenticationUser.EnvironmentUser.UserName) + .Param(NamedColumns.SessionContextTimestampName, timestamp ?? Runtime.UtcNow) + .ParamWith(tenantId, NamedColumns.SessionContextTenantIdName) + .ParamWith(userId, NamedColumns.SessionContextUserIdName) + .NonQueryAsync(cancellationToken).ConfigureAwait(false); + }, cancellationToken, nameof(SetSqlSessionContextAsync)); + } + + /// + /// Sets the SQL session context using the . + /// + /// The . Defaults to . + /// The . + /// See for more information. + public async Task SetSqlSessionContextAsync(ExecutionContext? executionContext = null, CancellationToken cancellationToken = default) + { + if (executionContext is not null || ExecutionContext.TryGetCurrent(out executionContext)) + await SetSqlSessionContextAsync(executionContext.User?.UserName, executionContext.Timestamp, executionContext.TenantId, executionContext.User?.Id, cancellationToken).ConfigureAwait(false); + else + await SetSqlSessionContextAsync(null, null, cancellationToken: cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs b/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs index b9c6de76..e74c7207 100644 --- a/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs +++ b/src/CoreEx.Database.SqlServer/SqlServerDatabase.cs @@ -1,135 +1,89 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping.Converters; -using CoreEx.Results; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; -using System; -using System.Linq; -using System.Data.Common; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database.SqlServer +namespace CoreEx.Database.SqlServer; + +/// +/// Provides the SQL Server functionality. +/// +/// The . +/// The optional . +/// The optional . +/// The override implements transformation of pre-determined SQL Server error codes, as follows: +/// +/// 56001 -> . +/// 56002 -> . +/// 56003 -> . +/// 56004 -> . +/// 56005 -> . +/// 56006 -> . +/// 56007 -> . +/// 56010 -> . +/// +/// This is in addition to the with the corresponding that will also result in a . +/// This class also implements the including a transactional outbox. The +/// functionality is enabled by the ; note, this is not thread-safe. +/// +public partial class SqlServerDatabase(SqlConnection connection, JsonSerializerOptions? jsonSerializerOptions = null, ILogger? logger = null) + : Database(SqlClientFactory.Instance, connection, SqlServerInvoker.Default, jsonSerializerOptions, logger) { + private static readonly SqlServerDatabaseColumns _defaultColumns = new(); + /// - /// Provides SQL Server database access functionality. + /// Gets the default . /// - /// The function to create the . - /// The optional . - /// The optional . - public class SqlServerDatabase(Func create, ILogger? logger = null, DatabaseInvoker? invoker = null) : Database(create, SqlClientFactory.Instance, logger, invoker) - { - private static readonly SqlServerDatabaseColumns _defaultColumns = new(); - - /// - /// Gets the default . - /// - /// See - /// and . - public static int[] DefaultDuplicateErrorNumbers { get; } = [2601, 2627]; - - /// - /// Gets or sets the names of the pre-configured . - /// - /// Do not update the default properties directly as a shared static instance is used (unless this is the desired behaviour); create a new instance for overridding. - public new SqlServerDatabaseColumns DatabaseColumns { get; set; } = _defaultColumns; - - /// - public override IConverter RowVersionConverter => StringToBase64Converter.Default; + /// See + /// and . + public static int[] DefaultDuplicateErrorNumbers { get; } = [2601, 2627]; - /// - /// Gets or sets the stored procedure name used by . - /// - /// Defaults to '[dbo].[spSetSessionContext]'. - public string SessionContextStoredProcedure { get; set; } = "[dbo].[spSetSessionContext]"; - - /// - /// Indicates whether to transform the into an equivalent based on the . - /// - /// Transforms and throws the equivalent from the known list. - public bool ThrowTransformedException { get; set; } = true; + /// + /// Gets or sets the names of the pre-configured . + /// + /// Do not update the default properties directly as a shared static instance is used (unless this is the desired behavior); create a new instance for overriding. + public new SqlServerDatabaseColumns NamedColumns { get; set; } = _defaultColumns; - /// - /// Indicates whether to check the when catching the . - /// - public bool CheckDuplicateErrorNumbers { get; set; } = true; + /// + public override ISourceConverter RowVersionConverter => StringBase64Converter.Default; - /// - /// Gets or sets the list of known values that are considered a duplicate error. - /// - /// Overrides the . - public int[]? DuplicateErrorNumbers { get; set; } + /// + /// Indicates whether to check the when catching the . + /// + public bool CheckDuplicateErrorNumbers { get; set; } = true; - /// - /// Sets the SQL session context using the specified values by invoking the using parameters named , - /// , and . - /// - /// The username (where null the value will default to ). - /// The timestamp (where null the value will default to ). - /// The tenant identifer (where null the value will not be used). - /// The unique user identifier (where null the value will not be used). - /// The . - /// See . - public Task SetSqlSessionContextAsync(string? username, DateTime? timestamp, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default) - { - if (string.IsNullOrEmpty(SessionContextStoredProcedure)) - throw new InvalidOperationException("The SessionContextStoredProcedure property must have a value."); + /// + /// Gets or sets the list of known values that are considered a duplicate error. + /// + /// Overrides the . + public int[]? DuplicateErrorNumbers { get; set; } - return Invoker.InvokeAsync(this, username, timestamp, tenantId, userId, async (_, username, timestamp, tenantId, userId, ct) => - { - return await StoredProcedure(SessionContextStoredProcedure) - .Param($"@{DatabaseColumns.SessionContextUsernameName}", username ?? ExecutionContext.EnvironmentUserName) - .Param($"@{DatabaseColumns.SessionContextTimestampName}", timestamp ?? SystemTime.Timestamp) - .ParamWith(tenantId, $"@{DatabaseColumns.SessionContextTenantIdName}") - .ParamWith(userId, $"@{DatabaseColumns.SessionContextUserIdName}") - .NonQueryAsync(ct).ConfigureAwait(false); - }, cancellationToken, nameof(SetSqlSessionContextAsync)); - } + /// + public override SqlServerCommand Statement(SqlStatement statement) => new(this, statement); - /// - /// Sets the SQL session context using the . - /// - /// The . Defaults to . - /// The . - /// See for more information. - public Task SetSqlSessionContextAsync(ExecutionContext? executionContext = null, CancellationToken cancellationToken = default) + /// + protected override Exception? OnDbException(DbException dbex) + { + if (dbex is SqlException sex) { - var ec = executionContext ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current : null); - return (ec == null) - ? SetSqlSessionContextAsync(null!, null, cancellationToken: cancellationToken) - : SetSqlSessionContextAsync(ec.UserName, ec.Timestamp, ec.TenantId, ec.UserId, cancellationToken); - } + var msg = sex.Message?.TrimEnd(); + if (string.IsNullOrEmpty(msg)) + msg = null; - /// - protected override Result? OnDbException(DbException dbex) - { - if (ThrowTransformedException && dbex is SqlException sex) + switch (sex.Number) { - var msg = sex.Message?.TrimEnd(); - if (string.IsNullOrEmpty(msg)) - msg = null; - - switch (sex.Number) - { - case 56001: return Result.Fail(new ValidationException(msg, sex)); - case 56002: return Result.Fail(new BusinessException(msg, sex)); - case 56003: return Result.Fail(new AuthorizationException(msg, sex)); - case 56004: return Result.Fail(new ConcurrencyException(msg, sex)); - case 56005: return Result.Fail(new NotFoundException(msg, sex)); - case 56006: return Result.Fail(new ConflictException(msg, sex)); - case 56007: return Result.Fail(new DuplicateException(msg, sex)); - case 56010: return Result.Fail(new DataConsistencyException(msg, sex)); - - default: - if (CheckDuplicateErrorNumbers && (DuplicateErrorNumbers ?? DefaultDuplicateErrorNumbers).Contains(sex.Number)) - return Result.Fail(new DuplicateException(null, sex)); - - break; - } + case 56001: return new ValidationException(msg, sex); + case 56002: return new BusinessException(msg, sex); + case 56003: return new AuthorizationException(msg, sex); + case 56004: return new ConcurrencyException(msg, sex); + case 56005: return new NotFoundException(msg, sex); + case 56006: return new ConflictException(msg, sex); + case 56007: return new DuplicateException(msg, sex); + case 56010: return new DataConsistencyException(msg, sex); + + default: + if (CheckDuplicateErrorNumbers && (DuplicateErrorNumbers ?? DefaultDuplicateErrorNumbers).Contains(sex.Number)) + return new DuplicateException(null, sex); + + break; } - - return base.OnDbException(dbex); } + + return base.OnDbException(dbex); } } \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/SqlServerDatabaseArgs.cs b/src/CoreEx.Database.SqlServer/SqlServerDatabaseArgs.cs new file mode 100644 index 00000000..9e2a1cfc --- /dev/null +++ b/src/CoreEx.Database.SqlServer/SqlServerDatabaseArgs.cs @@ -0,0 +1,6 @@ +namespace CoreEx.Database.SqlServer; + +/// +/// Provides the arguments. +/// +public record class SqlServerDatabaseArgs : DatabaseArgs { } \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/SqlServerDatabaseColumns.cs b/src/CoreEx.Database.SqlServer/SqlServerDatabaseColumns.cs deleted file mode 100644 index e2132fc8..00000000 --- a/src/CoreEx.Database.SqlServer/SqlServerDatabaseColumns.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Database.Extended; -using CoreEx.Entities; -using System.Collections.Generic; -using System.Data; - -namespace CoreEx.Database.SqlServer -{ - /// - /// Extends the adding additional SQL Server specific. - /// - public class SqlServerDatabaseColumns : DatabaseColumns - { - /// - /// Gets or sets the session context 'Username' column name. - /// - public string SessionContextUsernameName { get; set; } = "Username"; - - /// - /// Gets or sets the session context 'Timestamp' column name. - /// - public string SessionContextTimestampName { get; set; } = "Timestamp"; - - /// - /// Gets or sets the column name. - /// - public string SessionContextTenantIdName { get; set; } = "TenantId"; - - /// - /// Gets or sets the session context 'UserId' column name. - /// - public string SessionContextUserIdName { get; set; } = "UserId"; - - /// - /// Gets or sets the table-value parameter type name for an . - /// - public string TvpStringListTypeName { get; set; } = "[dbo].[udtNVarCharList]"; - - /// - /// Gets or sets the table-value parameter type name for an . - /// - public string TvpInt32ListTypeName { get; set; } = "[dbo].[udtIntList]"; - - /// - /// Gets or sets the table-value parameter type name for an . - /// - public string TvpInt64ListTypeName { get; set; } = "[dbo].[udtBigIntList]"; - - /// - /// Gets or sets the table-value parameter type name for an . - /// - public string TvpGuidListTypeName { get; set; } = "[dbo].[udtUniqueIdentifierList]"; - - /// - /// Gets or sets the table-value parameter type name for an . - /// - public string TvpDateTimeListTypeName { get; set; } = "[dbo].[udtDateTime2]"; - - /// - /// Gets or sets the table-value parameter column name for list values. - /// - public string TvpListValueColumnName { get; set; } = "Value"; - } -} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs b/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs new file mode 100644 index 00000000..727c1e9a --- /dev/null +++ b/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs @@ -0,0 +1,95 @@ +namespace CoreEx.Database.SqlServer; + +public static partial class SqlServerExtensions +{ + /// + /// Adds the named parameter and value, using the specified and , to the . + /// + /// The . + /// The parameter name. + /// The parameter value. + /// The parameter . + /// The (default to ). + /// A . + public static SqlParameter AddParameter(this DatabaseParameterCollection parameters, string name, T? value, SqlDbType? sqlDbType = null, ParameterDirection direction = ParameterDirection.Input) + { + var p = (SqlParameter)(parameters.ThrowIfNull().Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null.")); + p.ParameterName = DatabaseParameterCollection.ParameterizeName(name); + if (sqlDbType.HasValue) + p.SqlDbType = sqlDbType.Value; + + p.Value = DatabaseParameterCollection.ConvertToDbValue(value, parameters.Database); + p.Direction = direction; + + parameters.Add(p); + return p; + } + + /// + /// Adds the named parameter and value, using the specified and . + /// + /// The . + /// The parameter name. + /// The parameter value. + /// The parameter . + /// The (default to ). + /// + public static TSelf Param(this IDatabaseParameters parameters, string name, T? value, SqlDbType? sqlDbType = null, ParameterDirection direction = ParameterDirection.Input) + { + parameters.ThrowIfNull().Parameters.AddParameter(name, value, sqlDbType, direction); + return (TSelf)parameters; + } + + /// + /// Adds a named parameter and value . + /// + /// The owning . + /// The parameter . + /// The . + /// Adds the parameter when . + /// The parameter name. + /// The parameter value. + /// The parameter . + /// The (default to ). + /// The to support fluent-style method-chaining. + public static TSelf ParamWhen(this IDatabaseParameters parameters, bool? when, string name, Func value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) + { + parameters.ThrowIfNull(); + value.ThrowIfNull(); + + if (when == true) + parameters.Parameters.AddParameter(name, value(), sqlDbType, direction); + + return (TSelf)parameters; + } + + /// + /// Adds a named parameter when invoked a non-default value. + /// + /// The owning . + /// The parameter . + /// The . + /// The value with which to verify is non-default. + /// The parameter name. + /// The parameter value. + /// 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 && Comparer.Default.Compare((T)with, default!) != 0, name, value, sqlDbType, direction); + + /// + /// Adds a named parameter when invoked a non-default value. + /// + /// The owning . + /// The parameter . + /// The . + /// The value with which to verify is non-default. + /// The parameter name. + /// The parameter value; where not specified the vaue will be used. + /// The parameter . + /// The (default to ). + /// The current instance to support chaining (fluent interface). + public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, string name, Func? value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) + => ParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, value ?? (() => with!), sqlDbType, direction); +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/SqlServerExtensions.cs b/src/CoreEx.Database.SqlServer/SqlServerExtensions.cs index 7c6861e6..f785b9bf 100644 --- a/src/CoreEx.Database.SqlServer/SqlServerExtensions.cs +++ b/src/CoreEx.Database.SqlServer/SqlServerExtensions.cs @@ -1,402 +1,8 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Database.SqlServer; -using CoreEx.Database.Mapping; -using Microsoft.Data.SqlClient; -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; - -namespace CoreEx.Database.SqlServer +/// +/// Provides and related extensions. +/// +public static partial class SqlServerExtensions { - /// - /// Provides SQL Server extension methods. - /// - public static class SqlServerExtensions - { - /// - /// Adds the named parameter and value, using the specified and , to the . - /// - /// The . - /// The parameter name. - /// The parameter value. - /// The parameter . - /// The (default to ). - /// A . - public static SqlParameter AddParameter(this DatabaseParameterCollection dpc, string name, object? value, SqlDbType? sqlDbType = null, ParameterDirection direction = ParameterDirection.Input) - { - var p = (SqlParameter)(dpc.ThrowIfNull(nameof(dpc)).Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null.")); - p.ParameterName = DatabaseParameterCollection.ParameterizeName(name); - if (sqlDbType.HasValue) - p.SqlDbType = sqlDbType.Value; - - p.Value = value; - p.Direction = direction; - - dpc.Add(p); - return p; - } - - /// - /// Adds the named value to the . - /// - /// The . - /// The parameter name. - /// The value. - /// A . - /// This specifically implies that the is being used; if not then an exception will be thrown. - public static SqlParameter AddTableValuedParameter(this DatabaseParameterCollection dpc, string name, TableValuedParameter tvp) - { - var p = (SqlParameter)(dpc.ThrowIfNull(nameof(dpc)).Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null.")); - p.ParameterName = DatabaseParameterCollection.ParameterizeName(name); - p.SqlDbType = SqlDbType.Structured; - p.TypeName = tvp.ThrowIfNull(nameof(tvp)).TypeName; - p.Value = tvp.Value; - p.Direction = ParameterDirection.Input; - - dpc.Add(p); - return p; - } - - /// - /// Adds a named parameter and value true. - /// - /// The owning . - /// The parameter . - /// The . - /// Adds the parameter when true. - /// The parameter name. - /// The parameter value. - /// The parameter . - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf ParamWhen(this IDatabaseParameters parameters, bool? when, string name, Func value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) - { - parameters.ThrowIfNull(nameof(parameters)); - value.ThrowIfNull(nameof(value)); - - if (when == true) - parameters.Parameters.AddParameter(name, value(), sqlDbType, direction); - - return (TSelf)parameters; - } - - /// - /// Adds a named parameter and value true. - /// - /// The owning . - /// The parameter . - /// The . - /// Adds the parameter when true. - /// The . - /// The parameter value. - /// The parameter . - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf ParamWhen(this IDatabaseParameters parameters, bool? when, IPropertyColumnMapper mapper, Func value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, when, mapper?.ParameterName!, value, sqlDbType, direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The parameter name. - /// The parameter value. - /// 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 != null && Comparer.Default.Compare((T)with, default!) != 0, name, value, sqlDbType, direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The parameter name. - /// The parameter value; where not specified the vaue will be used. - /// The parameter . - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, string name, Func? value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with != null && Comparer.Default.Compare(with, default!) != 0, name, value ?? (() => with!), sqlDbType, direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The . - /// The parameter value. - /// The parameter . - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, object? with, IPropertyColumnMapper mapper, Func value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWith(parameters, with, mapper?.ParameterName!, value, sqlDbType, direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The . - /// The parameter value; where not specified the vaue will be used. - /// The parameter . - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, IPropertyColumnMapper mapper, Func? value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWith(parameters, with, mapper?.ParameterName!, value, sqlDbType, direction); - - /// - /// Adds the named value to the . - /// - /// The owning . - /// The . - /// The parameter name. - /// The value. - /// The to support fluent-style method-chaining. - /// This specifically implies that the is being used; if not then an exception will be thrown. - public static TSelf TableValuedParam(this IDatabaseParameters parameters, string name, TableValuedParameter tvp) - { - AddTableValuedParameter(parameters.Parameters, name, tvp); - return (TSelf)parameters; - } - - /// - /// Adds the named value to the true. - /// - /// The owning . - /// The . - /// Adds the parameter when true. - /// The parameter name. - /// The value. - /// The to support fluent-style method-chaining. - /// This specifically implies that the is being used; if not then an exception will be thrown. - public static TSelf TableValuedParamWhen(this IDatabaseParameters parameters, bool? when, string name, Func tvp) - { - if (when == true) - TableValuedParam(parameters, name, tvp()); - - return (TSelf)parameters; - } - - /// - /// Adds the named value to the a non-default value. - /// - /// The owning . - /// The . - /// The value with which to verify is non-default. - /// The parameter name. - /// The value. - /// The to support fluent-style method-chaining. - /// This specifically implies that the is being used; if not then an exception will be thrown. - public static TSelf TableValuedParamWith(this IDatabaseParameters parameters, object? with, string name, Func tvp) - => TableValuedParamWhen(parameters, with != null, name, tvp); - - /// - /// Adds the named value to the . - /// - /// The owning . - /// The . - /// The . - /// The value. - /// The to support fluent-style method-chaining. - /// This specifically implies that the is being used; if not then an exception will be thrown. - public static TSelf TableValuedParam(this IDatabaseParameters parameters, IPropertyColumnMapper mapper, TableValuedParameter tvp) - => TableValuedParam(parameters, mapper?.ParameterName!, tvp); - - /// - /// Adds the named value to the true. - /// - /// The owning . - /// The . - /// Adds the parameter when true. - /// The . - /// The value. - /// The to support fluent-style method-chaining. - /// This specifically implies that the is being used; if not then an exception will be thrown. - public static TSelf TableValuedParamWhen(this IDatabaseParameters parameters, bool? when, IPropertyColumnMapper mapper, Func tvp) - => TableValuedParamWhen(parameters, when, mapper?.ParameterName!, tvp); - - /// - /// Adds the named value to the a non-default value. - /// - /// The owning . - /// The . - /// The value with which to verify is non-default. - /// The . - /// The value. - /// The to support fluent-style method-chaining. - /// This specifically implies that the is being used; if not then an exception will be thrown. - public static TSelf TableValuedParamWith(this IDatabaseParameters parameters, object? with, IPropertyColumnMapper mapper, Func tvp) - => TableValuedParamWith(parameters, with != null, mapper?.ParameterName!, tvp); - - #region CreateTableValuedParameter - - /// - /// Creates a for the . - /// - /// The. - /// The list. - /// The . - public static TableValuedParameter CreateTableValuedParameter(this IDatabase database, IEnumerable list) => CreateTableValuedParameter(database, ((SqlServerDatabase)database).DatabaseColumns.TvpStringListTypeName, list); - - /// - /// Creates a for the . - /// - /// The. - /// The SQL type name of the table-valued parameter. - /// The list. - /// The . - public static TableValuedParameter CreateTableValuedParameter(this IDatabase database, string typeName, IEnumerable list) - { - using var dt = new DataTable(); - dt.Columns.Add(((SqlServerDatabase)database).DatabaseColumns.TvpListValueColumnName, typeof(string)); - - if (list != null) - { - foreach (var item in list) - { - dt.Rows.Add(item); - } - } - - return new TableValuedParameter(typeName, dt); - } - - /// - /// Creates a for the . - /// - /// The. - /// The list. - /// The . - public static TableValuedParameter CreateTableValuedParameter(this IDatabase database, IEnumerable list) => CreateTableValuedParameter(database, ((SqlServerDatabase)database).DatabaseColumns.TvpInt32ListTypeName, list); - - /// - /// Creates a for the . - /// - /// The. - /// The SQL type name of the table-valued parameter. - /// The list. - /// The . - public static TableValuedParameter CreateTableValuedParameter(this IDatabase database, string typeName, IEnumerable list) - { - using var dt = new DataTable(); - dt.Columns.Add(((SqlServerDatabase)database).DatabaseColumns.TvpListValueColumnName, typeof(int)); - - if (list != null) - { - foreach (var item in list) - { - dt.Rows.Add(item); - } - } - - return new TableValuedParameter(typeName, dt); - } - - /// - /// Creates a for the . - /// - /// The. - /// The list. - /// The . - public static TableValuedParameter CreateTableValuedParameter(this IDatabase database, IEnumerable list) => CreateTableValuedParameter(database, ((SqlServerDatabase)database).DatabaseColumns.TvpInt64ListTypeName, list); - - /// - /// Creates a for the . - /// - /// The. - /// The SQL type name of the table-valued parameter. - /// The list. - /// The . - public static TableValuedParameter CreateTableValuedParameter(this IDatabase database, string typeName, IEnumerable list) - { - using var dt = new DataTable(); - dt.Columns.Add(((SqlServerDatabase)database).DatabaseColumns.TvpListValueColumnName, typeof(long)); - - if (list != null) - { - foreach (var item in list) - { - dt.Rows.Add(item); - } - } - - return new TableValuedParameter(typeName, dt); - } - - /// - /// Creates a for the . - /// - /// The. - /// The list. - /// The . - public static TableValuedParameter CreateTableValuedParameter(this IDatabase database, IEnumerable list) => CreateTableValuedParameter(database, ((SqlServerDatabase)database).DatabaseColumns.TvpGuidListTypeName, list); - - /// - /// Creates a for the . - /// - /// The. - /// The SQL type name of the table-valued parameter. - /// The list. - /// The . - public static TableValuedParameter CreateTableValuedParameter(this IDatabase database, string typeName, IEnumerable list) - { - using var dt = new DataTable(); - dt.Columns.Add(((SqlServerDatabase)database).DatabaseColumns.TvpListValueColumnName, typeof(Guid)); - - if (list != null) - { - foreach (var item in list) - { - dt.Rows.Add(item); - } - } - - return new TableValuedParameter(typeName, dt); - } - - /// - /// Creates a for the . - /// - /// The. - /// The list. - /// The . - public static TableValuedParameter CreateTableValuedParameter(this IDatabase database, IEnumerable list) => CreateTableValuedParameter(database, ((SqlServerDatabase)database).DatabaseColumns.TvpGuidListTypeName, list); - - /// - /// Creates a for the . - /// - /// The. - /// The SQL type name of the table-valued parameter. - /// The list. - /// The . - public static TableValuedParameter CreateTableValuedParameter(this IDatabase database, string typeName, IEnumerable list) - { - using var dt = new DataTable(); - dt.Columns.Add(((SqlServerDatabase)database).DatabaseColumns.TvpListValueColumnName, typeof(DateTime)); - - if (list != null) - { - foreach (var item in list) - { - dt.Rows.Add(item); - } - } - - return new TableValuedParameter(typeName, dt); - } - - #endregion - } } \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/SqlServerMetrics.cs b/src/CoreEx.Database.SqlServer/SqlServerMetrics.cs new file mode 100644 index 00000000..31e31e90 --- /dev/null +++ b/src/CoreEx.Database.SqlServer/SqlServerMetrics.cs @@ -0,0 +1,32 @@ +namespace CoreEx.Database.SqlServer; + +/// +/// Provides the SQL Server metrics. +/// +public static class SqlServerMetrics +{ + /// + /// Gets the meter used for the database outbox metrics. + /// + public static Meter Meter { get; } = new("CoreEx.Database.SqlServer.Outbox"); + + /// + /// Gets the counter representing the total number of messages enqueued successfully. + /// + public static Counter OutboxEnqueued { get; } = Meter.CreateCounter("sqlserver.outbox.enqueue", unit: "{message}", description: "Number of SQL Server outbox messages enqueued successfully."); + + /// + /// Gets the counter representing the total number of messages (batch) dequeued (relayed) successfully. + /// + public static Counter OutboxRelayBatchSize { get; } = Meter.CreateCounter("sqlserver.outbox.relay.batch.size", unit: "{message}", description: "Number of SQL Server outbox messages (batch) relayed successfully."); + + /// + /// Gets the histogram that tracks the oldest lag duration (now - enqueued time of first message in batch), in milliseconds, of successful SQL Server outbox relay operations; i.e. end-to-end relay lag. + /// + public static Histogram OutboxRelayOldestLagDuration { get; } = Meter.CreateHistogram("sqlserver.outbox.batch.oldest_lag", unit: "ms", description: "Oldest lag duration (now - enqueued time of first message in batch) of SQL Server outbox relay."); + + /// + /// Gets the histogram that tracks the newest lag duration (now - enqueued time of last message in batch), in milliseconds, of successful SQL Server outbox relay operations; i.e. end-to-end relay lag. + /// + public static Histogram OutboxRelayNewestLagDuration { get; } = Meter.CreateHistogram("sqlserver.outbox.batch.newest_lag", unit: "ms", description: "Newest lag duration (now - enqueued time of last message in batch) of SQL Server outbox relay."); +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/SqlServerUnitOfWork.cs b/src/CoreEx.Database.SqlServer/SqlServerUnitOfWork.cs new file mode 100644 index 00000000..d698e222 --- /dev/null +++ b/src/CoreEx.Database.SqlServer/SqlServerUnitOfWork.cs @@ -0,0 +1,48 @@ +namespace CoreEx.Database.SqlServer; + +/// +/// Provides the transactional implementation for including support for a transactional outbox. +/// +/// The . +/// The optional . +/// The optional used to orchestrate the functionality. +public sealed class SqlServerUnitOfWork(SqlServerDatabase database, IEventPublisher? outbox = null, SqlServerUnitOfWorkInvoker? invoker = null) : IUnitOfWork +{ + /// + /// Gets the underlying . + /// + public SqlServerDatabase Database { get; } = database.ThrowIfNull(); + + /// + /// Gets the optional to be used as a transactional outbox. + /// + /// Where provided, the is invoked as part of the underlying transaction functionality. It is expected that the implementation + /// uses the same instance to ensure that the transactional outbox functionality works as expected. + public IEventPublisher? Outbox { get; } = outbox; + + /// + /// Gets the underlying used to orchestrate the functionality. + /// + public SqlServerUnitOfWorkInvoker UnitOfWorkInvoker { get; } = invoker ??= SqlServerUnitOfWorkInvoker.Default; + + /// + /// The is required to enable. + public bool AreEventsSupported => Outbox is not null; + + /// + public IEventQueue Events => Outbox ?? throw new NotSupportedException($"A Transaction {nameof(Outbox)} has not been provided to enable {nameof(Events)}."); + + /// + public Task TransactionAsync(Func work, CancellationToken cancellationToken = default) => TransactionAsync(Database.DbArgs, work, cancellationToken); + + /// + public Task TransactionAsync(Func> work, CancellationToken cancellationToken = default) => TransactionAsync(Database.DbArgs, work, cancellationToken); + + /// + public Task TransactionAsync(IDataArgs args, Func work, CancellationToken cancellationToken = default) + => UnitOfWorkInvoker.InvokeAsync(this, (SqlServerDatabaseArgs)args, async (_, _, cancellationToken) => await work(cancellationToken).ConfigureAwait(false), cancellationToken); + + /// + public Task TransactionAsync(IDataArgs args, Func> work, CancellationToken cancellationToken = default) + => UnitOfWorkInvoker.InvokeAsync(this, (SqlServerDatabaseArgs)args, async (_, _, cancellationToken) => await work(cancellationToken).ConfigureAwait(false), cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/TableValuedParameter.cs b/src/CoreEx.Database.SqlServer/TableValuedParameter.cs deleted file mode 100644 index 848cdd19..00000000 --- a/src/CoreEx.Database.SqlServer/TableValuedParameter.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Data; -using System.Linq; - -namespace CoreEx.Database.SqlServer -{ - /// - /// Represents a SQL-Server table-valued parameter (see ). - /// - /// The SQL type name of the table-valued parameter. - /// The value. - public class TableValuedParameter(string typeName, DataTable value) - { - /// - /// Gets or sets the SQL type name of the table-valued parameter. - /// - public string TypeName { get; } = typeName.ThrowIfNull(nameof(typeName)); - - /// - /// Gets or sets the value. - /// - public DataTable Value { get; } = value.ThrowIfNull(nameof(value)); - - /// - /// Adds a new to the using the specified . - /// - /// The column values. - public void AddRow(params object?[] columnValues) - { - var r = Value.NewRow(); - for (int i = 0; i < columnValues.Length; i++) - { - r[i] = columnValues[i] ?? DBNull.Value; - } - - Value.Rows.Add(r); - } - - /// - /// Adds a per each of the using the to get each of the column values. - /// - /// The item . - /// The . - /// The corresponding . - /// Zero or more items to add. - public void AddRows(IDatabase database, IDatabaseMapper mapper, IEnumerable? items) - { - database.ThrowIfNull(nameof(database)); - mapper.ThrowIfNull(nameof(mapper)); - - if (items == null) - return; - - var dpc = new DatabaseParameterCollection(database); - foreach (var item in items) - { - dpc.Clear(); - mapper.MapToDb(item, dpc); - AddRow(dpc.Select(x => x.Value).ToArray()); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/strong-name-key.snk b/src/CoreEx.Database.SqlServer/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Database/Abstractions/DatabaseArgs.cs b/src/CoreEx.Database/Abstractions/DatabaseArgs.cs new file mode 100644 index 00000000..4ea53faa --- /dev/null +++ b/src/CoreEx.Database/Abstractions/DatabaseArgs.cs @@ -0,0 +1,16 @@ +namespace CoreEx.Database.Abstractions; + +/// +/// Provides arguments. +/// +/// The is intended, and expected, to be immutable. Therefore, when implementing/extending, please ensure additional properties are enabled as such to ensure there are +/// not any unintended side-effects. +public abstract record class DatabaseArgs : DatabaseArgsBase +{ + /// + /// Gets or sets the . + /// + /// Defaults to . + /// This is used to specify the transaction isolation level for the likes of the . + public IsolationLevel IsolationLevel { get; init; } = IsolationLevel.ReadCommitted; +} \ No newline at end of file diff --git a/src/CoreEx.Database/Abstractions/DatabaseArgsBase.cs b/src/CoreEx.Database/Abstractions/DatabaseArgsBase.cs new file mode 100644 index 00000000..5e784dff --- /dev/null +++ b/src/CoreEx.Database/Abstractions/DatabaseArgsBase.cs @@ -0,0 +1,22 @@ +namespace CoreEx.Database.Abstractions; + +/// +/// Provides arguments. +/// +/// The is intended, and expected, to be immutable. Therefore, when implementing/extending, please ensure additional properties are enabled as such to ensure there are +/// not any unintended side-effects. +public abstract record class DatabaseArgsBase : IDataArgs +{ + /// + /// Indicates whether the data should be refreshed (reselected where applicable) after a save operation. + /// + /// Defaults to . + public bool Refresh { get; init; } = false; + + /// + /// Indicates whether to transform the underlying into an equivalent. + /// + /// Defaults to . + /// The will be skipped where set to . + public bool TransformException { get; init; } = true; +} \ No newline at end of file diff --git a/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs b/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs new file mode 100644 index 00000000..d6dbc5fc --- /dev/null +++ b/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs @@ -0,0 +1,35 @@ +namespace CoreEx.Database.Abstractions; + +/// +/// Provides the standard invoker functionality. +/// +/// Catches any unhandled and invokes to handle (where ) is +/// before bubbling up. +public abstract class DatabaseInvoker : InvokerBase +{ + /// + protected override async Task OnInvokeAsync(InvokerTracer tracer, IDatabase database, DatabaseArgs dbArgs, Func> func, CancellationToken cancellationToken) + { + try + { + return await base.OnInvokeAsync(tracer, database, dbArgs, func, cancellationToken).ConfigureAwait(false); + } + catch (DbException dbex) when (dbArgs.TransformException) + { + var hex = database.HandleDbException(dbex); + if (hex is not null) + { + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.Logger.LogDebug(dbex, "Database exception converted to '{ExceptionType}': {Message} [DatabaseId: {DatabaseId}]", hex.GetType().Name, hex.Message, database.DatabaseId); + + // Where the result is an IResult (ROP) and the exception is considered an error then return as an IResult _failure_. + if (ExtendedException.TryConvertExceptionToResult(hex, out var res)) + return res; + + throw hex; + } + + throw; + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Abstractions/IDatabaseParameters.cs b/src/CoreEx.Database/Abstractions/IDatabaseParameters.cs new file mode 100644 index 00000000..be07cde6 --- /dev/null +++ b/src/CoreEx.Database/Abstractions/IDatabaseParameters.cs @@ -0,0 +1,18 @@ +namespace CoreEx.Database.Abstractions; + +/// +/// Enables standardized access to the underlying . +/// +/// The owning . +public interface IDatabaseParameters +{ + /// + /// Gets the . + /// + IDatabase Database { get; } + + /// + /// Gets the . + /// + DatabaseParameterCollection Parameters { get; } +} \ No newline at end of file diff --git a/src/CoreEx.Database/CoreEx.Database.csproj b/src/CoreEx.Database/CoreEx.Database.csproj index 851cd5cc..b1e3d94c 100644 --- a/src/CoreEx.Database/CoreEx.Database.csproj +++ b/src/CoreEx.Database/CoreEx.Database.csproj @@ -1,19 +1,9 @@  - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx.Database - CoreEx - CoreEx .NET Relational Database extras. - CoreEx .NET Relational Database extras. - coreex db database sql ado.net relational - - - - - + + + + - - + \ No newline at end of file diff --git a/src/CoreEx.Database/Database.cs b/src/CoreEx.Database/Database.cs index 3a66fea0..658b24df 100644 --- a/src/CoreEx.Database/Database.cs +++ b/src/CoreEx.Database/Database.cs @@ -1,209 +1,159 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Database.Extended; -using CoreEx.Entities; -using CoreEx.Json; -using CoreEx.Mapping.Converters; -using CoreEx.Results; -using Microsoft.Extensions.Logging; -using System; -using System.Data; -using System.Data.Common; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database +namespace CoreEx.Database; + +/// +/// Provides the common/base access functionality. +/// +/// The . +/// The . +/// The . +/// The underlying . +/// The . +/// The . +/// The optional . +/// The optional . +public abstract class Database(DbProviderFactory provider, TConnection connection, DatabaseInvoker invoker, JsonSerializerOptions? jsonSerializerOptions = null, ILogger>? logger = null) : IDatabase + where TConnection : DbConnection where TCommand : DatabaseCommand where TDatabaseArgs : DatabaseArgs, new() { + private static readonly TDatabaseArgs _defaultDbArgs = new(); + private static readonly DatabaseColumns _defaultColumns = new(); + private static readonly DatabaseWildcard _defaultWildcard = new(); + + private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly TConnection _dbConn = connection.ThrowIfNull().ThrowWhen(connection => connection.State != ConnectionState.Closed && connection.State != ConnectionState.Open); + private int _savePointCounter = 0; + + /// + public DbProviderFactory Provider { get; } = provider.ThrowIfNull(); + + /// + public string DatabaseId { get; } = Guid.NewGuid().ToString(); + + /// + public ILogger? Logger { get; } = logger ?? ExecutionContext.GetService>>(); + + /// + public DatabaseInvoker Invoker { get; } = invoker.ThrowIfNull(); + + /// + DatabaseArgs IDatabase.DbArgs => DbArgs; + /// - /// Provides the common/base database access functionality. + /// Gets or sets the default used where not explicitly specified for an operation. /// - /// The . - /// The function to create the . - /// The underlying . - /// The optional . - /// The optional . - public class Database(Func create, DbProviderFactory provider, ILogger>? logger = null, DatabaseInvoker? invoker = null) : IDatabase where TConnection : DbConnection - { - private static readonly DatabaseColumns _defaultColumns = new(); - private static readonly DatabaseWildcard _defaultWildcard = new(); - private static DatabaseInvoker? _invoker; + public TDatabaseArgs DbArgs { get; set; } = _defaultDbArgs; - private readonly Func _dbConnCreate = create.ThrowIfNull(nameof(create)); - private TConnection? _dbConn; - private readonly SemaphoreSlim _semaphore = new(1, 1); + /// + public DateTimeTransform DateTimeTransform { get; set; } = DateTimeTransform.UseDefault; - /// - public DbProviderFactory Provider { get; } = provider.ThrowIfNull(nameof(provider)); + /// + public bool DateTimeOffsetTransform { get; set; } = true; - /// - public Guid DatabaseId { get; } = Guid.NewGuid(); + /// + public DatabaseColumns NamedColumns { get; set; } = _defaultColumns; + + /// + /// Gets or sets the to enable wildcard replacement. + /// + public DatabaseWildcard Wildcard { get; set; } = _defaultWildcard; - /// - public ILogger? Logger { get; } = logger ?? ExecutionContext.GetService>>(); + /// + public abstract ISourceConverter RowVersionConverter { get; } - /// - public DatabaseInvoker Invoker { get; } = invoker ?? (_invoker ??= new DatabaseInvoker()); + /// + public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions ?? JsonDefaults.SerializerOptions; - /// - public DatabaseArgs DbArgs { get; set; } = new DatabaseArgs(); + /// + public DbTransaction? CurrentTransaction { get; protected set; } - /// - public DateTimeTransform DateTimeTransform { get; set; } = DateTimeTransform.UseDefault; + /// + public bool IsInTransaction => CurrentTransaction is not null; - /// - /// Do not update the default properties directly as a shared static instance is used (unless this is the desired behaviour); create a new instance for overridding. - public DatabaseColumns DatabaseColumns { get; set; } = _defaultColumns; + /// + public void UseTransaction(DbTransaction? transaction) + { + if (CurrentTransaction != transaction) + { + CurrentTransaction = transaction; + UseTransactionChanged?.Invoke(this, EventArgs.Empty); + } + } - /// - /// Gets or sets the to enable wildcard replacement. - /// - /// Do not update the default properties directly as a shared static instance is used (unless this is the desired behaviour); create a new instance for overridding. - public DatabaseWildcard Wildcard { get; set; } = _defaultWildcard; + /// + public event EventHandler? UseTransactionChanged; - /// - public bool EnableChangeLogMapperToDb { get; } + /// + DbConnection IDatabase.Connection => _dbConn; - /// - public virtual IConverter RowVersionConverter => throw new NotImplementedException(); + /// + async Task IDatabase.GetConnectionAsync(CancellationToken cancellationToken) => await GetConnectionAsync(cancellationToken).ConfigureAwait(false); - /// - public IJsonSerializer JsonSerializer { get; set; } = ExecutionContext.GetService() ?? CoreEx.Json.JsonSerializer.Default; + /// + /// Gets the . + /// + /// The connection is opened on first use. + public async Task GetConnectionAsync(CancellationToken cancellationToken = default) + { + if (_dbConn.State == ConnectionState.Open) + return _dbConn; - /// - public DbConnection GetConnection() => _dbConn is not null ? _dbConn : Invokers.Invoker.RunSync(() => GetConnectionAsync()); + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - /// - public async Task GetConnectionAsync(CancellationToken cancellationToken = default) + try { - if (_dbConn == null) - { - await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - if (_dbConn != null) - return _dbConn; - - Logger?.LogDebug("Creating and opening the database connection. DatabaseId: {DatabaseId}", DatabaseId); - _dbConn = _dbConnCreate() ?? throw new InvalidOperationException($"The create function must create a valid {nameof(TConnection)} instance."); - await OnBeforeConnectionOpenAsync(_dbConn, cancellationToken).ConfigureAwait(false); - await _dbConn.OpenAsync(cancellationToken).ConfigureAwait(false); - await OnConnectionOpenAsync(_dbConn, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger?.LogError(ex, "Error occured whilst creating and opening the database connection. DatabaseId: {DatabaseId}", DatabaseId); - _dbConn = null; - throw; - } - finally - { - _semaphore.Release(); - } - } + if (_dbConn.State == ConnectionState.Open) + return _dbConn; + + if (_dbConn.State != ConnectionState.Closed) + throw new InvalidOperationException($"The database connection is in an invalid state: {_dbConn.State}."); + if (Logger is not null && Logger.IsEnabled(LogLevel.Debug)) + Logger.LogDebug("Opening the database connection. [DatabaseId: {DatabaseId}]", DatabaseId); + + await _dbConn.OpenAsync(cancellationToken).ConfigureAwait(false); return _dbConn; } - - /// - /// Occurs before a connection is opened. - /// - /// The . - /// The . - protected virtual Task OnBeforeConnectionOpenAsync(DbConnection connection, CancellationToken cancellationToken) => Task.CompletedTask; - - /// - /// Occurs when a connection is opened before any corresponding data access is performed. - /// - /// The . - /// The . - protected virtual Task OnConnectionOpenAsync(DbConnection connection, CancellationToken cancellationToken) => Task.CompletedTask; - - /// - /// Occurs before a connection is closed. - /// - /// The . - protected virtual Task OnConnectionCloseAsync(DbConnection connection) => Task.CompletedTask; - - /// - async Task IDatabase.GetConnectionAsync(CancellationToken cancellationToken) => await GetConnectionAsync(cancellationToken).ConfigureAwait(false); - - /// - public DatabaseCommand StoredProcedure(string storedProcedure) - => new(this, CommandType.StoredProcedure, storedProcedure.ThrowIfNull(nameof(storedProcedure))); - - /// - public DatabaseCommand SqlStatement(string sqlStatement) - => new(this, CommandType.Text, sqlStatement.ThrowIfNull(nameof(sqlStatement))); - - /// - public DatabaseCommand SqlFromResource(string resourceName, Assembly? assembly = null) - => SqlStatement(Abstractions.Resource.GetStreamReader(resourceName, assembly ?? Assembly.GetCallingAssembly()).ReadToEnd()); - - /// - public DatabaseCommand SqlFromResource(string resourceName) - => SqlFromResource(resourceName, typeof(TResource).Assembly); - - /// - public Result? HandleDbException(DbException dbex) + catch (Exception ex) { - var result = OnDbException(dbex); - return !result.HasValue || result.Value.IsSuccess ? Result.Fail(dbex) : result; - } + if (Logger is not null && Logger.IsEnabled(LogLevel.Error)) + Logger.LogError(ex, "Error occurred whilst opening the database connection. [DatabaseId: {DatabaseId}]", DatabaseId); - /// - /// Provides the handling as a result of . - /// - /// The . - /// The containing the appropriate where handled; otherwise, null indicating that the exception is unexpected and will continue to be thrown as such. - /// Provides an opportunity to inspect and handle the exception before it is returned. A resulting that is is not considered sensical; therefore, will result in the originating - /// exception being thrown. - /// Where overridding and the is not specifically handled then invoke the base to ensure any standard handling is executed. - protected virtual Result? OnDbException(DbException dbex) => null; - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); + throw; } - - /// - /// Dispose of the resources. - /// - /// Indicates whether to dispose. - protected virtual void Dispose(bool disposing) + finally { - if (disposing && _dbConn != null) - { - Logger?.LogDebug("Closing and disposing the database connection. DatabaseId: {DatabaseId}", DatabaseId); - Invokers.Invoker.RunSync(() => OnConnectionCloseAsync(_dbConn)); - _dbConn.Dispose(); - _dbConn = null; - } + _semaphore.Release(); } + } - /// - public async ValueTask DisposeAsync() - { - await DisposeAsyncCore().ConfigureAwait(false); - GC.SuppressFinalize(this); - } + /// + DatabaseCommand IDatabase.Statement(SqlStatement statement) => Statement(statement); - /// - /// Dispose of the resources asynchronously. - /// - public virtual async ValueTask DisposeAsyncCore() - { - if (_dbConn != null) - { - Logger?.LogDebug("Closing and disposing the database connection. DatabaseId: {DatabaseId}", DatabaseId); - await OnConnectionCloseAsync(_dbConn).ConfigureAwait(false); - await _dbConn.DisposeAsync().ConfigureAwait(false); - _dbConn = null; - } - - Dispose(); - } + /// + /// Creates a for the . + /// + /// The . + /// The . + public abstract TCommand Statement(SqlStatement statement); + + /// + public Exception? HandleDbException(DbException dbex) => OnDbException(dbex); + + /// + /// Provides the handling as a result of . + /// + /// The . + /// The where handled (converted); otherwise, indicating that the exception is unexpected and will continue to be thrown/bubbled as such. + /// Provides an opportunity to inspect and convert the exception before it continues to bubble. + /// Where overriding and the is not specifically handled then invoke the base to ensure any standard handling is executed. + protected virtual Exception? OnDbException(DbException dbex) => null; + + /// + /// Gets the next (monotonic counter) save-point name. + /// + /// The save-point name. + public string GetNextSavePointName() + { + var counter = Interlocked.Increment(ref _savePointCounter); + return $"SP_{counter}"; } } \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseCommand.NonQuery.cs b/src/CoreEx.Database/DatabaseCommand.NonQuery.cs new file mode 100644 index 00000000..15b341db --- /dev/null +++ b/src/CoreEx.Database/DatabaseCommand.NonQuery.cs @@ -0,0 +1,24 @@ +namespace CoreEx.Database; + +public abstract partial class DatabaseCommand +{ + /// + /// Executes a non-query command. + /// + /// The . + /// The number of rows affected. + public Task NonQueryAsync(CancellationToken cancellationToken = default) => NonQueryAsync(null, cancellationToken); + + /// + /// Executes a non-query command. + /// + /// The post-execution delegate to enable parameter access. + /// The . + /// The number of rows affected. + public async Task NonQueryAsync(Action? parameters, CancellationToken cancellationToken = default) => await Database.Invoker.InvokeAsync(Database, DbArgs, async (_, _, cancellationToken) => + { + using var cmd = await CreateCommandAsync(cancellationToken).ConfigureAwait(false); + parameters?.Invoke(cmd.Parameters); + return await LogCommand(cmd).ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseCommand.Scalar.cs b/src/CoreEx.Database/DatabaseCommand.Scalar.cs new file mode 100644 index 00000000..d168d769 --- /dev/null +++ b/src/CoreEx.Database/DatabaseCommand.Scalar.cs @@ -0,0 +1,27 @@ +namespace CoreEx.Database; + +public abstract partial class DatabaseCommand +{ + /// + /// Executes the query and returns the first column of the first row in the result set returned by the query. + /// + /// The result . + /// The . + /// The value of the first column of the first row in the result set. + public Task ScalarAsync(CancellationToken cancellationToken = default) => ScalarAsync(null, cancellationToken); + + /// + /// Executes the query and returns the first column of the first row in the result set returned by the query. + /// + /// The result . + /// The post-execution delegate to enable parameter access. + /// The . + /// The value of the first column of the first row in the result set. + public async Task ScalarAsync(Action? parameters, CancellationToken cancellationToken = default) => await Database.Invoker.InvokeAsync(Database, DbArgs, async (_, _, cancellationToken) => + { + using var cmd = await CreateCommandAsync(cancellationToken).ConfigureAwait(false); + parameters?.Invoke(cmd.Parameters); + var result = await LogCommand(cmd).ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return result is null ? default! : result is DBNull ? default! : (T)result; + }, cancellationToken).ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseCommand.Select.cs b/src/CoreEx.Database/DatabaseCommand.Select.cs new file mode 100644 index 00000000..6ba0e35d --- /dev/null +++ b/src/CoreEx.Database/DatabaseCommand.Select.cs @@ -0,0 +1,33 @@ +namespace CoreEx.Database; + +public abstract partial class DatabaseCommand +{ + /// + /// Selects from the first result set using a to handle each . + /// + /// The handling function. + /// The . + /// The returns a that controls whether the next should be read. A value of indicates that + /// reading should continue; otherwise, to stop. + public Task SelectAsync(Func func, CancellationToken cancellationToken = default) => SelectInternalAsync(func, nameof(SelectAsync), cancellationToken); + + /// + /// Select the rows from the query (interna)l. + /// + private async Task SelectInternalAsync(Func func, string memberName, CancellationToken cancellationToken) + { + func.ThrowIfNull(); + + await Database.Invoker.InvokeAsync(Database, DbArgs, async (_, _, cancellationToken) => + { + using var cmd = await CreateCommandAsync(cancellationToken).ConfigureAwait(false); + using var dr = await LogCommand(cmd).ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + + while (await dr.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (!func(new DatabaseRecord(Database, dr))) + break; + } + }, cancellationToken, memberName).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseCommand.SelectFirstSingle.cs b/src/CoreEx.Database/DatabaseCommand.SelectFirstSingle.cs new file mode 100644 index 00000000..0d0c27fc --- /dev/null +++ b/src/CoreEx.Database/DatabaseCommand.SelectFirstSingle.cs @@ -0,0 +1,97 @@ +namespace CoreEx.Database; + +public abstract partial class DatabaseCommand +{ + /// + /// Selects a single item. + /// + /// The resultant . + /// The . + /// The . + /// The single item. + public async Task SelectSingleAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) + { + var result = await SelectSingleFirstInternalAsync(mapper, true, nameof(SelectSingleAsync), cancellationToken).ConfigureAwait(false); + return result ?? throw new InvalidOperationException($"{nameof(SelectSingleAsync)} has not returned a row."); + } + + /// + /// Selects a single item or default. + /// + /// The resultant . + /// The . + /// The . + /// The single item or default. + public async Task SelectSingleOrDefaultAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) + => await SelectSingleFirstInternalAsync(mapper, true, nameof(SelectSingleOrDefaultAsync), cancellationToken).ConfigureAwait(false); + + /// + /// Selects first item. + /// + /// The resultant . + /// The . + /// The . + /// The single item. + public async Task SelectFirstAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) + { + var result = await SelectSingleFirstInternalAsync(mapper, false, nameof(SelectFirstAsync), cancellationToken).ConfigureAwait(false); + return result ?? throw new InvalidOperationException($"{nameof(SelectFirstAsync)} has not returned a row."); + } + + /// + /// Selects first item or default. + /// + /// The resultant . + /// The . + /// The . + /// The single item or default. + public async Task SelectFirstOrDefaultAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) + => await SelectSingleFirstInternalAsync(mapper, false, nameof(SelectFirstOrDefaultAsync), cancellationToken).ConfigureAwait(false); + + /// + /// Select first row result only (where exists) internal. + /// + 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); + return coll.Count == 0 ? default! : coll[0]; + } + + /// + /// Select the rows from the query internal. + /// + private async Task SelectInternalAsync(TColl coll, IDatabaseMapper mapper, bool throwWhereMulti, bool stopAfterOneRow, int maxRows, string memberName, CancellationToken cancellationToken) where TColl : ICollection + { + mapper.ThrowIfNull(); + + await Database.Invoker.InvokeAsync(Database, DbArgs, async (_, _, cancellationToken) => + { + int i = 0; + + using var cmd = await CreateCommandAsync(cancellationToken).ConfigureAwait(false); + using var dr = await LogCommand(cmd).ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + + while (await dr.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (++i == 2) + { + if (throwWhereMulti) + throw new InvalidOperationException($"{memberName} has returned more than one row."); + + if (stopAfterOneRow) + return; + } + + if (i - 1 >= maxRows) + return; + + coll.Add(mapper.MapFromDb(new DatabaseRecord(Database, dr)) ?? throw new InvalidOperationException("A null must not be returned from the mapper.")); + if (!throwWhereMulti && stopAfterOneRow) + return; + } + + return; + }, cancellationToken, memberName).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseCommand.SelectMultiSet.cs b/src/CoreEx.Database/DatabaseCommand.SelectMultiSet.cs new file mode 100644 index 00000000..f94c442b --- /dev/null +++ b/src/CoreEx.Database/DatabaseCommand.SelectMultiSet.cs @@ -0,0 +1,131 @@ +namespace CoreEx.Database; + +public abstract partial class DatabaseCommand +{ + /// + /// Executes a multi-dataset query command with one or more . + /// + /// One or more . + /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. + public Task SelectMultiSetAsync(params IMultiSetArgs[] multiSetArgs) => SelectMultiSetAsync(multiSetArgs, default); + + /// + /// Executes a multi-dataset query command with one or more . + /// + /// One or more . + /// The . + /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. + public Task SelectMultiSetAsync(IEnumerable multiSetArgs, CancellationToken cancellationToken = default) => SelectMultiSetInternalAsync(multiSetArgs, nameof(SelectMultiSetAsync), cancellationToken); + + /// + /// Executes a multi-dataset query command with one or more (internal). + /// + private async Task SelectMultiSetInternalAsync(IEnumerable multiSetArgs, string memberName, CancellationToken cancellationToken = default) + { + var multiSetList = multiSetArgs?.ToList(); + if (multiSetList is null || multiSetList.Count == 0) + throw new ArgumentException($"At least one {nameof(IMultiSetArgs)} must be supplied.", nameof(multiSetArgs)); + + await Database.Invoker.InvokeAsync(Database, DbArgs, async (_, _, cancellationToken) => + { + // Create and execute the command. + using var cmd = await CreateCommandAsync(cancellationToken).ConfigureAwait(false); + using var dr = await LogCommand(cmd).ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + + // Iterate through the dataset(s). + var index = 0; + var records = 0; + IMultiSetArgs? multiSetArg = null; + do + { + if (index >= multiSetList.Count) + throw new InvalidOperationException($"{nameof(SelectMultiSetAsync)} has returned more record sets than expected ({multiSetList.Count})."); + + if (multiSetList[index] is not null) + { + records = 0; + multiSetArg = multiSetList[index]; + while (await dr.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + records++; + if (multiSetArg.MaximumRows.HasValue && records > multiSetArg.MaximumRows.Value) + throw new InvalidOperationException($"{nameof(SelectMultiSetAsync)} (msa[{index}]) has returned more records than expected ({multiSetArg.MaximumRows.Value})."); + + multiSetArg.DatasetRecord(new DatabaseRecord(Database, dr)); + } + + if (records < multiSetArg.MinimumRows) + throw new InvalidOperationException($"{nameof(SelectMultiSetAsync)} (msa[{index}]) has returned less records ({records}) than expected ({multiSetArg.MinimumRows})."); + + if (records == 0 && multiSetArg.StopOnNull) + return; + + multiSetArg.InvokeResult(); + } + + index++; + } while (dr.NextResult()); + + if (index < multiSetList.Count && !multiSetList[index].StopOnNull) + throw new InvalidOperationException($"{nameof(SelectMultiSetAsync)} has returned less ({index}) record sets than expected ({multiSetList.Count})."); + }, cancellationToken, memberName).ConfigureAwait(false); + } + + /// + /// Executes a multi-dataset query command with one or more that supports . + /// + /// The or to add to the . + /// One or more . + /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. + public Task SelectMultiSetAsync(PagingArgs? paging, params IMultiSetArgs[] multiSetArgs) => SelectMultiSetAsync(paging, multiSetArgs, default); + + /// + /// Executes a multi-dataset query command with one or more that supports . + /// + /// The or to add to the . + /// One or more . + /// The . + /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. + public Task SelectMultiSetAsync(PagingArgs? paging, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) + => SelectMultiSetInternalAsync(paging, multiSetArgs, nameof(SelectMultiSetAsync), cancellationToken); + + /// + /// Executes a multi-dataset query command with one or more that supports (internal); + /// + private async Task SelectMultiSetInternalAsync(PagingArgs? paging, IEnumerable multiSetArgs, string memberName, CancellationToken cancellationToken) + { + Parameters.PagingParams(paging); + + var result = await SelectMultiSetWithValueInternalAsync(multiSetArgs, memberName, cancellationToken).ConfigureAwait(false); + if (paging is PagingResult pr && pr.IsCountRequested && result >= 0) + pr.WithTotalCount(result); + } + + /// + /// Executes a multi-dataset query command with one or more ; whilst also outputing the resulting . + /// + /// One or more . + /// The resultant return value. + /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. + public Task SelectMultiSetWithValueAsync(params IMultiSetArgs[] multiSetArgs) => SelectMultiSetWithValueAsync(multiSetArgs, default); + + /// + /// Executes a multi-dataset query command with one or more ; whilst also outputing the resulting . + /// + /// One or more . + /// The . + /// The resultant return value. + /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. + public Task SelectMultiSetWithValueAsync(IEnumerable multiSetArgs, CancellationToken cancellationToken = default) + => SelectMultiSetWithValueInternalAsync(multiSetArgs, nameof(SelectMultiSetWithValueAsync), cancellationToken); + + /// + /// Executes a multi-dataset query command with one or more ; whilst also outputing the resulting (internal). + /// + private async Task SelectMultiSetWithValueInternalAsync(IEnumerable multiSetArgs, string memberName, CancellationToken cancellationToken) + { + var rvp = Parameters.AddReturnValueParameter(); + await SelectMultiSetInternalAsync(multiSetArgs, memberName, cancellationToken).ConfigureAwait(false); + return rvp.Value is null ? -1 : (int)rvp.Value; + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseCommand.SelectQuery.cs b/src/CoreEx.Database/DatabaseCommand.SelectQuery.cs new file mode 100644 index 00000000..7db31a58 --- /dev/null +++ b/src/CoreEx.Database/DatabaseCommand.SelectQuery.cs @@ -0,0 +1,50 @@ +namespace CoreEx.Database; + +public abstract partial class DatabaseCommand +{ + /// + /// Selects none or more items from the first result set. + /// + /// The item . + /// The mapping function. + /// The . + /// The resulting set. + public Task> SelectQueryAsync(Func func, CancellationToken cancellationToken = default) + => SelectQueryAsync(new DatabaseMapper(func.ThrowIfNull()), cancellationToken); + + /// + /// Selects none or more items from the first result set using a . + /// + /// The item . + /// The . + /// The . + /// The item sequence. + public async Task> SelectQueryAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) + { + var coll = new List(); + await SelectQueryAsync(coll, mapper, cancellationToken).ConfigureAwait(false); + return coll; + } + + /// + /// Selects none or more items from the first result set and adds to the . + /// + /// The item . + /// The collection . + /// The collection. + /// The mapping function. + /// The . + public Task SelectQueryAsync(TColl collection, Func func, CancellationToken cancellationToken = default) where TColl : ICollection + => SelectQueryAsync(collection, new DatabaseMapper(func), cancellationToken); + + /// + /// Selects none or more items from the first result set and adds to the . + /// + /// The item . + /// The collection . + /// The collection. + /// The . + /// The . + public Task SelectQueryAsync(TColl collection, IDatabaseMapper mapper, CancellationToken cancellationToken = default) where TColl : ICollection + => SelectInternalAsync(collection, mapper, false, false, int.MaxValue, nameof(SelectQueryAsync), cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseCommand.cs b/src/CoreEx.Database/DatabaseCommand.cs index c083ac6d..9a6bc2c9 100644 --- a/src/CoreEx.Database/DatabaseCommand.cs +++ b/src/CoreEx.Database/DatabaseCommand.cs @@ -1,639 +1,57 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Database; + +/// +/// Provides extended database command capabilities. +/// +/// The . +/// The . +/// As the underlying implements this is only created (and automatically disposed) where executing the command proper. +public abstract partial class DatabaseCommand(IDatabase db, SqlStatement statement) : IDatabaseParameters +{ + /// + public IDatabase Database { get; } = db.ThrowIfNull(); -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; + /// + public DatabaseParameterCollection Parameters { get; } = new DatabaseParameterCollection(db); -namespace CoreEx.Database -{ /// - /// Provides extended database command capabilities. + /// Gets the . /// - /// As the underlying implements this is only created (and automatically disposed) where executing the command proper. - /// The . - /// The . - /// The command text. - public sealed class DatabaseCommand(IDatabase db, CommandType commandType, string commandText) : IDatabaseParameters - { - /// - /// Gets the underlying . - /// - public IDatabase Database { get; } = db.ThrowIfNull(nameof(db)); - - /// - public DatabaseParameterCollection Parameters { get; } = new DatabaseParameterCollection(db); - - /// - /// Gets the . - /// - public CommandType CommandType { get; } = commandType; - - /// - /// Gets the command text. - /// - public string CommandText { get; } = commandText.ThrowIfNull(nameof(commandText)); - - /// - /// Creates the corresponding . - /// - /// The . - /// The . - private async Task CreateDbCommandAsync(CancellationToken cancellationToken = default) - { - var cmd = (await Database.GetConnectionAsync(cancellationToken).ConfigureAwait(false)).CreateCommand(); - cmd.CommandType = CommandType; - cmd.CommandText = CommandText; - cmd.Parameters.AddRange(Parameters.ToArray()); - return cmd; - } - - /// - /// Executes a multi-dataset query command with one or more . - /// - /// One or more . - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public async Task SelectMultiSetAsync(params IMultiSetArgs[] multiSetArgs) - => (await SelectMultiSetWithResultInternalAsync(multiSetArgs, nameof(SelectMultiSetAsync), default).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// One or more . - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public Task SelectMultiSetWithResultAsync(params IMultiSetArgs[] multiSetArgs) - => SelectMultiSetWithResultInternalAsync(multiSetArgs, nameof(SelectMultiSetWithResultAsync), default); - - /// - /// Executes a multi-dataset query command with one or more . - /// - /// One or more . - /// The . - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public async Task SelectMultiSetAsync(IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - => (await SelectMultiSetWithResultInternalAsync(multiSetArgs, nameof(SelectMultiSetAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes a multi-dataset query command with one or more with a . - /// - /// One or more . - /// The . - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public Task SelectMultiSetWithResultAsync(IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - => SelectMultiSetWithResultInternalAsync(multiSetArgs, nameof(SelectMultiSetWithResultAsync), cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more with a internal. - /// - private Task SelectMultiSetWithResultInternalAsync(IEnumerable multiSetArgs, string memberName, CancellationToken cancellationToken = default) - { - var multiSetList = multiSetArgs?.ToList() ?? null; - if (multiSetList == null || multiSetList.Count == 0) - throw new ArgumentException($"At least one {nameof(IMultiSetArgs)} must be supplied.", nameof(multiSetArgs)); - - return Database.Invoker.InvokeAsync(Database, multiSetArgs, multiSetList, async (_, multiSetArgs, multiSetList, ct) => - { - // Create and execute the command. - using var cmd = await CreateDbCommandAsync(ct).ConfigureAwait(false); - using var dr = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); - - // Iterate through the dataset(s). - var index = 0; - var records = 0; - IMultiSetArgs? multiSetArg = null; - do - { - if (index >= multiSetList.Count) - return Result.Fail(new InvalidOperationException($"{nameof(SelectMultiSetAsync)} has returned more record sets than expected ({multiSetList.Count}).")); - - if (multiSetList[index] != null) - { - records = 0; - multiSetArg = multiSetList[index]; - while (await dr.ReadAsync(ct).ConfigureAwait(false)) - { - records++; - if (multiSetArg.MaxRows.HasValue && records > multiSetArg.MaxRows.Value) - return Result.Fail(new InvalidOperationException($"{nameof(SelectMultiSetAsync)} (msa[{index}]) has returned more records than expected ({multiSetArg.MaxRows.Value}).")); - - multiSetArg.DatasetRecord(new DatabaseRecord(Database, dr)); - } - - if (records < multiSetArg.MinRows) - return Result.Fail(new InvalidOperationException($"{nameof(SelectMultiSetAsync)} (msa[{index}]) has returned less records ({records}) than expected ({multiSetArg.MinRows}).")); - - if (records == 0 && multiSetArg.StopOnNull) - return Result.Success; - - multiSetArg.InvokeResult(); - } - - index++; - } while (dr.NextResult()); - - return index < multiSetList.Count && !multiSetList[index].StopOnNull - ? Result.Fail(new InvalidOperationException($"{nameof(SelectMultiSetAsync)} has returned less ({index}) record sets than expected ({multiSetList.Count}).")) - : Result.Success; - }, cancellationToken, memberName); - } - - /// - /// Executes a multi-dataset query command with one or more that supports . - /// - /// The or to add to the . - /// One or more . - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public async Task SelectMultiSetAsync(PagingArgs? paging, params IMultiSetArgs[] multiSetArgs) - => (await SelectMultiSetWithResultInternalAsync(paging, multiSetArgs, nameof(SelectMultiSetAsync), default).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes a multi-dataset query command with one or more that supports with a . - /// - /// The or to add to the . - /// One or more . - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public Task SelectMultiSetWithResultAsync(PagingArgs? paging, params IMultiSetArgs[] multiSetArgs) - => SelectMultiSetWithResultInternalAsync(paging, multiSetArgs, nameof(SelectMultiSetWithResultAsync), default); - - /// - /// Executes a multi-dataset query command with one or more that supports . - /// - /// The or to add to the . - /// One or more . - /// The . - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public async Task SelectMultiSetAsync(PagingArgs? paging, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - => (await SelectMultiSetWithResultInternalAsync(paging, multiSetArgs, nameof(SelectMultiSetAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes a multi-dataset query command with one or more that supports with a . - /// - /// The or to add to the . - /// One or more . - /// The . - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public Task SelectMultiSetWithResultAsync(PagingArgs? paging, IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - => SelectMultiSetWithResultInternalAsync(paging, multiSetArgs, nameof(SelectMultiSetWithResultAsync), cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more that supports with a internal. - /// - private async Task SelectMultiSetWithResultInternalAsync(PagingArgs? paging, IEnumerable multiSetArgs, string memberName, CancellationToken cancellationToken) - { - Parameters.PagingParams(paging); - - var result = await SelectMultiSetWithValueResultInternalAsync(multiSetArgs, memberName, cancellationToken).ConfigureAwait(false); - return result.ThenAs(rv => - { - if (paging is PagingResult pr && pr.IsGetCount && rv >= 0) - pr.TotalCount = rv; - }); - } - - /// - /// Executes a multi-dataset query command with one or more ; whilst also outputing the resulting . - /// - /// One or more . - /// The resultant return value. - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public async Task SelectMultiSetWithValueAsync(params IMultiSetArgs[] multiSetArgs) - => await SelectMultiSetWithValueResultInternalAsync(multiSetArgs, nameof(SelectMultiSetWithValueAsync), default).ConfigureAwait(false); - - /// - /// Executes a multi-dataset query command with one or more ; whilst also outputing the resulting with a . - /// - /// One or more . - /// The resultant return value. - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public Task> SelectMultiSetWithValueResultAsync(params IMultiSetArgs[] multiSetArgs) - => SelectMultiSetWithValueResultInternalAsync(multiSetArgs, nameof(SelectMultiSetWithValueResultAsync), default); - - /// - /// Executes a multi-dataset query command with one or more ; whilst also outputing the resulting . - /// - /// One or more . - /// The . - /// The resultant return value. - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public async Task SelectMultiSetWithValueAsync(IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - => await SelectMultiSetWithValueResultInternalAsync(multiSetArgs, nameof(SelectMultiSetWithValueAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Executes a multi-dataset query command with one or more ; whilst also outputing the resulting with a . - /// - /// One or more . - /// The . - /// The resultant return value. - /// The number of specified must match the number of returned datasets. A null dataset indicates to ignore (skip) a dataset. - public Task> SelectMultiSetWithValueResultAsync(IEnumerable multiSetArgs, CancellationToken cancellationToken = default) - => SelectMultiSetWithValueResultInternalAsync(multiSetArgs, nameof(SelectMultiSetWithValueResultAsync), cancellationToken); - - /// - /// Executes a multi-dataset query command with one or more ; whilst also outputing the resulting with a internal. - /// - private async Task> SelectMultiSetWithValueResultInternalAsync(IEnumerable multiSetArgs, string memberName, CancellationToken cancellationToken) - { - var rvp = Parameters.AddReturnValueParameter(); - var result = await SelectMultiSetWithResultInternalAsync(multiSetArgs, memberName, cancellationToken).ConfigureAwait(false); - return result.ThenAs(() => rvp.Value == null ? -1 : (int)rvp.Value); - } - - /// - /// Executes a non-query command. - /// - /// The . - /// The number of rows affected. - public async Task NonQueryAsync(CancellationToken cancellationToken = default) - => await NonQueryWithResultInternalAsync(null, nameof(NonQueryAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Executes a non-query command with a . - /// - /// The . - /// The number of rows affected. - public Task> NonQueryWithResultAsync(CancellationToken cancellationToken = default) - => NonQueryWithResultInternalAsync(null, nameof(NonQueryWithResultAsync), cancellationToken); - - /// - /// Executes a non-query command. - /// - /// The post-execution delegate to enable parameter access. - /// The . - /// The number of rows affected. - public async Task NonQueryAsync(Action? parameters, CancellationToken cancellationToken = default) - => await NonQueryWithResultInternalAsync(parameters, nameof(NonQueryAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Executes a non-query command with a . - /// - /// The post-execution delegate to enable parameter access. - /// The . - /// The number of rows affected. - public Task> NonQueryWithResultAsync(Action? parameters, CancellationToken cancellationToken = default) - => NonQueryWithResultInternalAsync(parameters, nameof(NonQueryWithResultAsync), cancellationToken); - - /// - /// Executes a non-query command with a internal. - /// - private Task> NonQueryWithResultInternalAsync(Action? parameters, string memberName, CancellationToken cancellationToken = default) => Database.Invoker.InvokeAsync(Database, parameters, async (_, parameters, ct) => - { - using var cmd = await CreateDbCommandAsync(ct).ConfigureAwait(false); - parameters?.Invoke(cmd.Parameters); - var result = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - return Result.Ok(result); - }, cancellationToken, memberName); - - /// - /// Executes the query and returns the first column of the first row in the result set returned by the query. - /// - /// The result . - /// The . - /// The value of the first column of the first row in the result set. - public async Task ScalarAsync(CancellationToken cancellationToken = default) - => await ScalarWithResultInternalAsync(null, nameof(ScalarAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Executes the query and returns the first column of the first row in the result set returned by the query with a . - /// - /// The result . - /// The . - /// The value of the first column of the first row in the result set. - public Task> ScalarWithResultAsync(CancellationToken cancellationToken = default) - => ScalarWithResultInternalAsync(null, nameof(ScalarWithResultAsync), cancellationToken); - - /// - /// Executes the query and returns the first column of the first row in the result set returned by the query. - /// - /// The result . - /// The post-execution delegate to enable parameter access. - /// The . - /// The value of the first column of the first row in the result set. - public async Task ScalarAsync(Action? parameters, CancellationToken cancellationToken = default) - => await ScalarWithResultInternalAsync(parameters, nameof(ScalarAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Executes the query and returns the first column of the first row in the result set returned by the query with a . - /// - /// The result . - /// The post-execution delegate to enable parameter access. - /// The . - /// The value of the first column of the first row in the result set. - public Task> ScalarWithResultAsync(Action? parameters, CancellationToken cancellationToken = default) - => ScalarWithResultInternalAsync(parameters, nameof(ScalarWithResultAsync), cancellationToken); + public SqlStatement Statement { get; } = statement.ThrowIfNull().ThrowWhen(statement => statement == SqlStatement.None, "A SQL statement of None is not considered valid for execution."); - /// - /// Executes the query and returns the first column of the first row in the result set returned by the query with a internal. - /// - private Task> ScalarWithResultInternalAsync(Action? parameters, string memberName, CancellationToken cancellationToken = default) => Database.Invoker.InvokeAsync(Database, parameters, async (_, parameters, ct) => - { - using var cmd = await CreateDbCommandAsync(ct).ConfigureAwait(false); - parameters?.Invoke(cmd.Parameters); - var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); - T value = result is null ? default! : result is DBNull ? default! : (T)result; - return Result.Ok(value); - }, cancellationToken, memberName); - - /// - /// Selects none or more items from the first result set using a . - /// - /// The item . - /// The . - /// The . - /// The item sequence. - public async Task> SelectQueryAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) - => (await SelectQueryWithResultInternalAsync(mapper, nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Selects none or more items from the first result set using a with a . - /// - /// The item . - /// The . - /// The . - /// The item sequence. - public Task>> SelectQueryWithResultAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) - => SelectQueryWithResultInternalAsync(mapper, nameof(SelectQueryWithResultAsync), cancellationToken); - - /// - /// Selects none or more items from the first result set using a with a internal. - /// - private async Task>> SelectQueryWithResultInternalAsync(IDatabaseMapper mapper, string memberName, CancellationToken cancellationToken) - { - var coll = new List(); - var result = await SelectQueryWithResultInternalAsync(coll, mapper, memberName, cancellationToken).ConfigureAwait(false); - return result.ThenAs(() => (IEnumerable)coll); - } - - /// - /// Selects none or more items from the first result set. - /// - /// The item . - /// The mapping function. - /// The . - /// The resulting set. - public async Task> SelectQueryAsync(Func func, CancellationToken cancellationToken = default) - => (await SelectQueryWithResultInternalAsync(func, nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Selects none or more items from the first result set with a . - /// - /// The item . - /// The mapping function. - /// The . - /// The resulting set. - public Task>> SelectQueryWithResultAsync(Func func, CancellationToken cancellationToken = default) - => SelectQueryWithResultInternalAsync(func, nameof(SelectQueryWithResultAsync), cancellationToken); - - /// - /// Selects none or more items from the first result set with a internal. - /// - private async Task>> SelectQueryWithResultInternalAsync(Func func, string memberName, CancellationToken cancellationToken = default) - { - var coll = new List(); - var result = await SelectInternalAsync(coll, new DatabaseRecordMapper(func), false, false, memberName, cancellationToken).ConfigureAwait(false); - return result.ThenAs(() => (IEnumerable)coll); - } - - /// - /// Selects none or more items from the first result set and adds to the . - /// - /// The item . - /// The collection . - /// The collection. - /// The . - /// The . - public async Task SelectQueryAsync(TColl collection, IDatabaseMapper mapper, CancellationToken cancellationToken = default) where TColl : ICollection - => (await SelectQueryWithResultInternalAsync(collection, mapper, nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Selects none or more items from the first result set and adds to the with a . - /// - /// The item . - /// The collection . - /// The collection. - /// The . - /// The . - public async Task SelectQueryWithResultAsync(TColl collection, IDatabaseMapper mapper, CancellationToken cancellationToken = default) where TColl : ICollection - => await SelectQueryWithResultInternalAsync(collection, mapper, nameof(SelectQueryWithResultAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Selects none or more items from the first result set and adds to the with a internal. - /// - private async Task SelectQueryWithResultInternalAsync(TColl collection, IDatabaseMapper mapper, string memberName, CancellationToken cancellationToken) where TColl : ICollection - => await SelectInternalAsync(collection, mapper, false, false, memberName, cancellationToken).ConfigureAwait(false); - - /// - /// Selects none or more items from the first result set and adds to the . - /// - /// The item . - /// The collection . - /// The collection. - /// The mapping function. - /// The . - public async Task SelectQueryAsync(TColl collection, Func func, CancellationToken cancellationToken = default) where TColl : ICollection - => (await SelectInternalAsync(collection, new DatabaseRecordMapper(func), false, false, nameof(SelectQueryAsync), cancellationToken)).ThrowOnError(); - - /// - /// Selects none or more items from the first result set and adds to the with a . - /// - /// The item . - /// The collection . - /// The collection. - /// The mapping function. - /// The . - public Task SelectQueryWithResultAsync(TColl collection, Func func, CancellationToken cancellationToken = default) where TColl : ICollection - => SelectInternalAsync(collection, new DatabaseRecordMapper(func), false, false, nameof(SelectQueryWithResultAsync), cancellationToken); - - /// - /// Selects a single item. - /// - /// The resultant . - /// The . - /// The . - /// The single item. - public async Task SelectSingleAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) - => (await SelectSingleWithResultInternalAsync(mapper, nameof(SelectSingleAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Selects a single item with a . - /// - /// The resultant . - /// The . - /// The . - /// The single item. - public Task> SelectSingleWithResultAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) - => SelectSingleWithResultInternalAsync(mapper, nameof(SelectSingleWithResultAsync), cancellationToken); - - /// - /// Selects a single item with a internal. - /// - private async Task> SelectSingleWithResultInternalAsync(IDatabaseMapper mapper, string memberName, CancellationToken cancellationToken) - { - var result = await SelectSingleFirstWithResultInternalAsync(mapper, true, memberName, cancellationToken).ConfigureAwait(false); - return result.When(item => Comparer.Default.Compare(item, default!) == 0, _ => Result.Fail(new InvalidOperationException("SelectSingle request has not returned a row."))); - } - - /// - /// Selects a single item or default. - /// - /// The resultant . - /// The . - /// The . - /// The single item or default. - public async Task SelectSingleOrDefaultAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) - => await SelectSingleFirstWithResultInternalAsync(mapper, false, nameof(SelectSingleOrDefaultAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Selects a single item or default with a . - /// - /// The resultant . - /// The . - /// The . - /// The single item or default. - public async Task> SelectSingleOrDefaultWithResultAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) - => await SelectSingleFirstWithResultInternalAsync(mapper, false, nameof(SelectSingleOrDefaultWithResultAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Selects first item. - /// - /// The resultant . - /// The . - /// The . - /// The single item. - public async Task SelectFirstAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) - => await SelectFirstWithResultInternalAsync(mapper, nameof(SelectFirstAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Selects first item with a . - /// - /// The resultant . - /// The . - /// The . - /// The single item. - public Task> SelectFirstWithResultAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) - => SelectFirstWithResultInternalAsync(mapper, nameof(SelectFirstWithResultAsync), cancellationToken); - - /// - /// Selects first item with a internal. - /// - private async Task> SelectFirstWithResultInternalAsync(IDatabaseMapper mapper, string memberName, CancellationToken cancellationToken = default) - { - var result = await SelectSingleFirstWithResultInternalAsync(mapper, false, memberName, cancellationToken).ConfigureAwait(false); - return result.When(item => Comparer.Default.Compare(item, default!) == 0, _ => new InvalidOperationException("SelectFirst request has not returned a row.")); - } - - /// - /// Selects first item or default. - /// - /// The resultant . - /// The . - /// The . - /// The single item or default. - public async Task SelectFirstOrDefaultAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) - => await SelectSingleFirstWithResultInternalAsync(mapper, false, nameof(SelectFirstOrDefaultAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Selects first item or default with a . - /// - /// The resultant . - /// The . - /// The . - /// The single item or default. - public async Task> SelectFirstOrDefaultWithResultAsync(IDatabaseMapper mapper, CancellationToken cancellationToken = default) - => await SelectSingleFirstWithResultInternalAsync(mapper, false, nameof(SelectFirstOrDefaultWithResultAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Select first row result only (where exists) internal. - /// - private async Task> SelectSingleFirstWithResultInternalAsync(IDatabaseMapper mapper, bool throwWhereMulti, string memberName, CancellationToken cancellationToken) - { - var coll = new List(); - var result = await SelectInternalAsync(coll, mapper, throwWhereMulti, true, memberName, cancellationToken).ConfigureAwait(false); - return result.ThenAs(() => coll.Count == 0 ? default! : coll[0]); - } - - /// - /// Select the rows from the query internal. - /// - private async Task SelectInternalAsync(TColl coll, IDatabaseMapper mapper, bool throwWhereMulti, bool stopAfterOneRow, string memberName, CancellationToken cancellationToken) where TColl : ICollection - { - mapper.ThrowIfNull(nameof(mapper)); - - return await Database.Invoker.InvokeAsync(Database, mapper, throwWhereMulti, stopAfterOneRow, async (_, mapper, throwWhereMulti, stopAfterOneRow, ct) => - { - int i = 0; - - using var cmd = await CreateDbCommandAsync(ct).ConfigureAwait(false); - using var dr = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); - - while (await dr.ReadAsync(ct).ConfigureAwait(false)) - { - if (++i == 2) - { - if (throwWhereMulti) - Result.Fail(new InvalidOperationException("SelectSingle request has returned more than one row.")); - - if (stopAfterOneRow) - return Result.Success; - } - - var val = mapper.MapFromDb(new DatabaseRecord(Database, dr)); - if (val == null) - return Result.Fail(new InvalidOperationException("A null must not be returned from the mapper.")); - - coll.Add(val); - if (!throwWhereMulti && stopAfterOneRow) - return Result.Success; - } - - return Result.Success; - }, cancellationToken, memberName).ConfigureAwait(false); - } - - /// - /// Selects from the first result set using a to handle each . - /// - /// The handling function. - /// The . - /// The returns a that controls whether the next should be read. A value of true indicates that - /// reading should continue; otherwise, false to stop. - public async Task SelectAsync(Func func, CancellationToken cancellationToken = default) - => (await SelectInternalAsync(func, nameof(SelectAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); + /// + /// Gets the for the command. + /// + /// Defaults to the underlying . + public DatabaseArgs DbArgs { get; protected set; } = db.DbArgs; - /// - /// Selects from the first result set using a to handle each with a . - /// - /// The handling function. - /// The . - /// The returns a that controls whether the next should be read. A value of true indicates that - /// reading should continue; otherwise, false to stop. - public Task SelectWithResultAsync(Func func, CancellationToken cancellationToken = default) - => SelectInternalAsync(func, nameof(SelectWithResultAsync), cancellationToken); + /// + /// Creates the corresponding . + /// + /// The . + /// The . + private async Task CreateCommandAsync(CancellationToken cancellationToken) + { + var conn = await Database.GetConnectionAsync(cancellationToken).ConfigureAwait(false); + var cmd = conn.CreateCommand(); - /// - /// Select the rows from the query internal. - /// - private async Task SelectInternalAsync(Func func, string memberName, CancellationToken cancellationToken) - { - func.ThrowIfNull(nameof(func)); + if (Database.CurrentTransaction is not null) + cmd.Transaction = Database.CurrentTransaction; - return await Database.Invoker.InvokeAsync(Database, func, async (_, action, ct) => - { - using var cmd = await CreateDbCommandAsync(ct).ConfigureAwait(false); - using var dr = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + cmd.CommandType = Statement.CommandType; + cmd.CommandText = Statement.CommandText; + cmd.Parameters.AddRange(Parameters.ToArray()); + return cmd; + } - while (await dr.ReadAsync(ct).ConfigureAwait(false)) - { - if (!action(new DatabaseRecord(Database, dr))) - break; - } + /// + /// Logs the command type and text at debug level. + /// + private DbCommand LogCommand(DbCommand command) + { + if (Database.Logger?.IsEnabled(LogLevel.Debug) is true) + Database.Logger.LogDebug("Executing DbCommand [CommandType='{CommandType}']:{NewLine}{CommandText}", command.CommandType, Environment.NewLine, command.CommandText); - return Result.Success; - }, cancellationToken, memberName).ConfigureAwait(false); - } + return command; } } \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseCommandT.cs b/src/CoreEx.Database/DatabaseCommandT.cs new file mode 100644 index 00000000..3b1d4ee9 --- /dev/null +++ b/src/CoreEx.Database/DatabaseCommandT.cs @@ -0,0 +1,32 @@ +namespace CoreEx.Database; + +/// +/// Provides extended database command capabilities. +/// +/// The . +/// The . +/// The . +/// The . +/// As the underlying implements this is only created (and automatically disposed) where executing the command proper. +public abstract class DatabaseCommand(IDatabase db, SqlStatement statement) : DatabaseCommand(db, statement) + where TDatabaseArgs : DatabaseArgs + where TSelf : DatabaseCommand +{ + /// + /// Gets the for the command. + /// + /// Defaults to the underlying . + /// See also . + public new TDatabaseArgs DbArgs { get; protected set; } = (TDatabaseArgs)db.DbArgs; + + /// + /// Sets (overrides) the for the command. + /// + /// The . + /// + public TSelf WithDbArgs(TDatabaseArgs dbArgs) + { + DbArgs = dbArgs; + return (TSelf)this; + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseExtensions.Parameters.cs b/src/CoreEx.Database/DatabaseExtensions.Parameters.cs new file mode 100644 index 00000000..08eb7663 --- /dev/null +++ b/src/CoreEx.Database/DatabaseExtensions.Parameters.cs @@ -0,0 +1,386 @@ +namespace CoreEx.Database; + +public static partial class DatabaseExtensions +{ + /// + /// Add one or more parameters by invoking a delegate. + /// + /// The owning . + /// The . + /// The delegate to enable parameter addition. + /// The to support fluent-style method-chaining. + public static TSelf Params(this IDatabaseParameters parameters, Action action) + { + action.ThrowIfNull()(parameters.ThrowIfNull().Parameters); + return (TSelf)parameters; + } + + /// + /// Adds the . + /// + /// The owning . + /// The . + /// The list. + /// The to support fluent-style method-chaining. + public static TSelf Params(this IDatabaseParameters parameters, params IEnumerable list) + { + if (list is not null && list != parameters.Parameters) + parameters.Parameters.AddRange(list); + + return (TSelf)parameters; + } + + #region Param + + /// + /// Adds the named parameter and value, using the specified , to the . + /// + /// The owning . + /// The . + /// The parameter name. + /// The parameter value. + /// The parameter . + /// The (default to ). + /// The to support fluent-style method-chaining. + public static TSelf Param(this IDatabaseParameters parameters, string name, object? value = null, DbType? dbType = null, ParameterDirection direction = ParameterDirection.Input) + { + parameters.ThrowIfNull().Parameters.AddParameter(name, value, dbType, direction); + return (TSelf)parameters; + } + + /// + /// Adds the named parameter and value, using the specified , to the . + /// + /// The owning . + /// The parameter . + /// The . + /// The parameter name. + /// The parameter value. + /// The parameter . + /// The (default to ). + /// The to support fluent-style method-chaining. + public static TSelf Param(this IDatabaseParameters parameters, string name, T? value, DbType? dbType = null, ParameterDirection direction = ParameterDirection.Input) + { + parameters.ThrowIfNull().Parameters.AddParameter(name, value, dbType, direction); + return (TSelf)parameters; + } + + /// + /// Adds the named parameter using the specified , and , to the . + /// + /// The owning . + /// The . + /// The parameter name. + /// The parameter . + /// The maximum size (in bytes). + /// The (default to ). + /// The to support fluent-style method-chaining. + public static TSelf Param(this IDatabaseParameters parameters, string name, DbType dbType, int size, ParameterDirection direction = ParameterDirection.Input) + { + parameters.ThrowIfNull().Parameters.AddParameter(name, dbType, size, direction); + return (TSelf)parameters; + } + + /// + /// Adds the named parameter and value serialized as a JSON to the . + /// + /// The owning . + /// The . + /// The parameter name. + /// The parameter value. + /// The to support fluent-style method-chaining. + public static TSelf JsonParam(this IDatabaseParameters parameters, string name, object? value) + { + parameters.ThrowIfNull().Parameters.AddJsonParameter(name, value); + return (TSelf)parameters; + } + + /// + /// Adds the named parameter to the . + /// + /// The . + /// The parameter name. + /// The wildcard value. + /// The to support fluent-style method-chaining. + public static TSelf WildCardParam(this IDatabaseParameters parameters, string name, string? wildcard) + { + parameters.ThrowIfNull().Parameters.AddWildcardParameter(name, wildcard); + return (TSelf)parameters; + } + + #endregion + + #region ParamWhen + + /// + /// Adds a named parameter and value . + /// + /// The owning . + /// The parameter . + /// The . + /// Adds the parameter when . + /// The parameter name. + /// The parameter value. + /// The parameter . + /// The (default to ). + /// The to support fluent-style method-chaining. + public static TSelf ParamWhen(this IDatabaseParameters parameters, bool? when, string name, Func value, DbType? dbType = null, ParameterDirection direction = ParameterDirection.Input) + { + value.ThrowIfNull(); + + if (when is true) + parameters.ThrowIfNull().Parameters.AddParameter(name, value(), dbType, direction); + + return (TSelf)parameters; + } + + /// + /// Adds a named parameter and value serialized as a JSON . + /// + /// The owning . + /// The parameter . + /// The . + /// Adds the parameter when . + /// The parameter name. + /// The parameter value. + /// The to support fluent-style method-chaining. + public static TSelf JsonParamWhen(this IDatabaseParameters parameters, bool? when, string name, Func value) + { + value.ThrowIfNull(); + + if (when is true) + parameters.ThrowIfNull().Parameters.AddJsonParameter(name, value()); + + return (TSelf)parameters; + } + + /// + /// Adds a named parameter . + /// + /// The owning . + /// The . + /// Adds the parameter when . + /// The parameter name. + /// The wildcard parameter value. + /// The to support fluent-style method-chaining. + public static TSelf WildcardParamWhen(this IDatabaseParameters parameters, bool? when, string name, Func wildcard) + { + wildcard.ThrowIfNull(); + if (when is true) + parameters.ThrowIfNull().Parameters.AddWildcardParameter(name, wildcard()); + + return (TSelf)parameters; + } + + #endregion + + #region ParamWith + + /// + /// Adds a named parameter when invoked a non-default value. + /// + /// The owning . + /// The parameter . + /// The . + /// The value with which to verify is non-default that is also used as the parameter value. + /// The parameter name. + /// The parameter . + /// The (default to ). + /// The to support fluent-style method-chaining. + public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, string name, DbType? dbType = null, ParameterDirection direction = ParameterDirection.Input) + => ParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, () => with!, dbType, direction); + + /// + /// Adds a named parameter when invoked a non-default value. + /// + /// The owning . + /// The with . + /// The parameter . + /// The . + /// The value with which to verify is non-default. + /// The parameter name. + /// The parameter value. + /// The parameter . + /// The (default to ). + /// The to support fluent-style method-chaining. + public static TSelf ParamWith(this IDatabaseParameters parameters, TWith? with, string name, Func value, DbType? dbType = null, ParameterDirection direction = ParameterDirection.Input) + => ParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, value, dbType, direction); + + /// + /// Adds a named parameter when invoked a non-default value serialized as a JSON . + /// + /// The owning . + /// The parameter value . + /// The . + /// The value with which to verify is non-default that is also used as the parameter value. + /// The parameter name. + /// The to support fluent-style method-chaining. + public static TSelf JsonParamWith(this IDatabaseParameters parameters, T? with, string name) + => JsonParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, () => with); + + /// + /// Adds a named parameter when invoked a non-default value serialized as a JSON . + /// + /// The owning . + /// The with value . + /// The parameter value . + /// The . + /// The value with which to verify is non-default. + /// The parameter name. + /// The parameter value. + /// The to support fluent-style method-chaining. + public static TSelf JsonParamWith(this IDatabaseParameters parameters, TWith? with, string name, Func value) + => JsonParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, value); + + /// + /// Adds a named parameter when invoked with a non-default (converted for the database). + /// + /// The owning . + /// The . + /// The wildcard with which to verify is non-default that is also used as the parameter value. + /// The parameter name. + /// The to support fluent-style method-chaining. + public static TSelf WildcardParamWith(this IDatabaseParameters parameters, string? wildcard, string name) + => WildcardParamWhen(parameters, !string.IsNullOrEmpty(wildcard), name, () => wildcard); + + /// + /// Adds a named parameter when invoked with a non-default (converted for the database). + /// + /// The owning . + /// The with value . + /// The . + /// The value with which to verify is non-default. + /// The wildcard parameter value. + /// The parameter name. + /// The to support fluent-style method-chaining. + public static TSelf WildcardParamWith(this IDatabaseParameters parameters, TWith? with, string name, Func wildcard) + => WildcardParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, wildcard); + + #endregion + + #region RowVersionParam + + /// + /// Adds a named () parameter using the . + /// + /// The owning . + /// The . + /// The -based representation of the row version. + /// The (default to ). + /// The to support fluent-style method-chaining. + public static TSelf RowVersionParam(this IDatabaseParameters parameters, string? value, ParameterDirection direction = ParameterDirection.Input) + { + parameters.ThrowIfNull().Parameters.AddRowVersionParam(value, direction); + return (TSelf)parameters; + } + + /// + /// Adds a named () parameter using the . + /// + /// The owning . + /// The . + /// Adds the parameter when . + /// The -based representation of the row version. + /// The (default to ). + /// The to support fluent-style method-chaining. + public static TSelf RowVersionParamWhen(this IDatabaseParameters parameters, bool? when, string? value, ParameterDirection direction = ParameterDirection.Input) + { + if (when is true) + parameters.ThrowIfNull().Parameters.AddRowVersionParam(value, direction); + + return (TSelf)parameters; + } + + /// + /// Adds a named () parameter when invoked with a non-default value using the . + /// + /// The owning . + /// The . + /// The -based representation of the row version which to verify is non-default that is also used as the parameter value. + /// The (default to ). + /// The to support fluent-style method-chaining. + public static TSelf RowVersionParamWith(this IDatabaseParameters parameters, string? value, ParameterDirection direction = ParameterDirection.Input) + { + if (!string.IsNullOrEmpty(value)) + parameters.ThrowIfNull().Parameters.AddRowVersionParam(value, direction); + + return (TSelf)parameters; + } + + #endregion + + #region ReselectRecordParam + + /// + /// Adds a named parameter () to the data. + /// + /// The owning . + /// The . + /// Indicates whether to reselect after the operation. + /// The to support fluent-style method-chaining. + public static TSelf ReselectRecordParam(this IDatabaseParameters parameters, bool reselect = true) + { + parameters.ThrowIfNull().Parameters.AddReselectRecordParam(reselect); + return (TSelf)parameters; + } + + #endregion + + #region PagingParams + + /// + /// Adds the as parameters. + /// + /// The owning . + /// The . + /// The . + /// The to support fluent-style method-chaining. + public static TSelf PagingParams(this IDatabaseParameters parameters, PagingArgs? paging) + { + if (paging is not null) + { + parameters.Param(parameters.Database.NamedColumns.PagingSkipName, paging.Skip); + parameters.Param(parameters.Database.NamedColumns.PagingTakeName, paging.Take); + parameters.ParamWhen(paging.IsCountRequested, parameters.Database.NamedColumns.PagingCountName, () => paging.IsCountRequested); + } + + return (TSelf)parameters; + } + + #endregion + + #region ReturnValue + + /// + /// Adds a parameter to the . + /// + /// The owning . + /// The . + /// The resulting . + /// The parameter ; defaults to . + /// The to support fluent-style method-chaining. + public static TSelf ReturnValue(this IDatabaseParameters parameters, out DbParameter returnValueParameter, DbType dbType = DbType.Int32) + { + returnValueParameter = parameters.ThrowIfNull().Parameters.AddParameter(parameters.Database.NamedColumns.ReturnValueName, dbType, direction: ParameterDirection.ReturnValue); + return (TSelf)parameters; + } + + #endregion + + /// + /// Sets the to the when the is as expressed by the . + /// + /// The . + /// The single value being performed to enable conditional execution where appropriate. + /// The single or multi expression. + /// The (default to ). + /// The to support fluent-style method-chaining. + /// When the is not as expressed by the then the existing will remain unchanged. + public static DbParameter SetDirectionWhenOperationType(this DbParameter parameter, OperationType operationType, OperationType when, ParameterDirection direction = ParameterDirection.Output) + { + if (when.HasFlag(operationType)) + parameter.Direction = direction; + + return parameter; + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseExtensions.cs b/src/CoreEx.Database/DatabaseExtensions.cs new file mode 100644 index 00000000..681de865 --- /dev/null +++ b/src/CoreEx.Database/DatabaseExtensions.cs @@ -0,0 +1,6 @@ +namespace CoreEx.Database; + +/// +/// Provides and related extensions. +/// +public static partial class DatabaseExtensions { } \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseInvoker.cs b/src/CoreEx.Database/DatabaseInvoker.cs deleted file mode 100644 index fe1864a5..00000000 --- a/src/CoreEx.Database/DatabaseInvoker.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; -using CoreEx.Results; -using System; -using System.Data.Common; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database -{ - /// - /// Provides the standard invoker functionality. - /// - /// Catches any unhandled and invokes to handle before bubbling up. - public class DatabaseInvoker : InvokerBase - { - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, IDatabase database, Func func) => throw new NotSupportedException(); - - /// - protected override async Task OnInvokeAsync(InvokeArgs invokeArgs, IDatabase database, Func> func, CancellationToken cancellationToken) - { - try - { - return await base.OnInvokeAsync(invokeArgs, database, func, cancellationToken).ConfigureAwait(false); - } - catch (DbException dbex) - { - var eresult = database.HandleDbException(dbex); - if (eresult.HasValue && eresult.Value.IsFailure && eresult.Value.Error is CoreEx.Abstractions.IExtendedException) - { - var dresult = default(TResult); - if (dresult is IResult dir) - return (TResult)dir.ToFailure(eresult.Value.Error); - else - eresult.Value.ThrowOnError(); - } - - throw; - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseParameterCollection.cs b/src/CoreEx.Database/DatabaseParameterCollection.cs index e9406fee..35db15fe 100644 --- a/src/CoreEx.Database/DatabaseParameterCollection.cs +++ b/src/CoreEx.Database/DatabaseParameterCollection.cs @@ -1,183 +1,208 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Mapping.Converters; -using CoreEx.Results; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Linq; +namespace CoreEx.Database; -namespace CoreEx.Database +/// +/// Provides a collection used for a . +/// +/// The . +public sealed class DatabaseParameterCollection(IDatabase database) : ICollection, IDatabaseParameters { + private readonly List _parameters = []; + /// - /// Provides a collection used for a . + /// Gets the underlying . /// - /// The . - public sealed class DatabaseParameterCollection(IDatabase database) : ICollection, IDatabaseParameters + public IDatabase Database { get; } = database.ThrowIfNull(); + + /// + DatabaseParameterCollection IDatabaseParameters.Parameters => this; + + /// + public int Count => _parameters.Count; + + /// + bool ICollection.IsReadOnly => false; + + /// + /// Indicates whether a with the specified exists in the collection. + /// + /// The parameter name. + /// indicates that the parameter exists in the collection; otherwise, . + public bool Contains(string name) => _parameters.Any(x => x.ParameterName == name); + + /// + /// Adds the named parameter and value, using the specified , to the . + /// + /// The parameter name. + /// The parameter value. + /// The parameter . + /// The (default to ). + /// The . + public DbParameter AddParameter(string name, object? value = null, DbType? dbType = null, ParameterDirection direction = ParameterDirection.Input) + { + var p = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); + p.ParameterName = ParameterizeName(name); + p.Value = ConvertToDbValue(value, Database); + p.Direction = direction; + if (dbType.HasValue) + p.DbType = dbType.Value; + + _parameters.Add(p); + return p; + } + + /// + /// Adds the named parameter and value, using the specified , to the . + /// + /// The parameter name. + /// The parameter value. + /// The parameter . + /// The (default to ). + /// The . + public DbParameter AddParameter(string name, T? value, DbType? dbType = null, ParameterDirection direction = ParameterDirection.Input) { - private readonly List _parameters = []; - - /// - /// Gets the underlying . - /// - public IDatabase Database { get; } = database.ThrowIfNull(nameof(database)); - - /// - DatabaseParameterCollection IDatabaseParameters.Parameters => this; - - /// - public int Count => _parameters.Count; - - /// - bool ICollection.IsReadOnly => false; - - /// - /// Indicates whether a with the specified exists in the collection. - /// - /// The parameter name. - /// true indicates that the parameter exists in the collection; otherwise, false. - public bool Contains(string name) => _parameters.Any(x => x.ParameterName == name); - - /// - /// Adds the named parameter and value, using the specified , to the . - /// - /// The parameter name. - /// The parameter value. - /// The (default to ). - /// A . - public DbParameter AddParameter(string name, object? value, ParameterDirection direction = ParameterDirection.Input) - { - var p = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); - p.ParameterName = ParameterizeName(name); - p.Value = value ?? DBNull.Value; - p.Direction = direction; - - _parameters.Add(p); - return p; - } - - /// - /// Adds the named parameter and value, using the specified and , to the . - /// - /// The parameter name. - /// The parameter value. - /// The parameter . - /// The (default to ). - /// A . - public DbParameter AddParameter(string name, object? value, DbType dbType, ParameterDirection direction = ParameterDirection.Input) - { - var p = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); - p.ParameterName = ParameterizeName(name); - p.DbType = dbType; - p.Value = value ?? DBNull.Value; - p.Direction = direction; - - _parameters.Add(p); - return p; - } - - /// - /// Adds the named parameter and value, using the specified , and , to the . - /// - /// The parameter name. - /// The parameter . - /// The maximum size (in bytes). - /// The (default to ). - /// A . - public DbParameter AddParameter(string name, DbType dbType, int size, ParameterDirection direction = ParameterDirection.Input) - { - var p = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); - p.ParameterName = ParameterizeName(name); - p.DbType = dbType; - p.Size = size; - p.Direction = direction; - - _parameters.Add(p); - return p; - } - - /// - /// Adds the named parameter and value serialized as a JSON to the . - /// - /// The parameter name. - /// The parameter value. - /// A . - /// Where the is then will be used. - public DbParameter AddJsonParameter(string name, object? value) - { - var p = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); - p.ParameterName = ParameterizeName(name); - if (value is null) - p.Value = DBNull.Value; - else - p.Value = Database.JsonSerializer.Serialize(value); - - _parameters.Add(p); - return p; - } - - /// - /// Adds an parameter. - /// - /// A . - public DbParameter AddReturnValueParameter() - { - var p = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); - p.ParameterName = ParameterizeName(Database.DatabaseColumns.ReturnValueName); - p.DbType = DbType.Int32; - p.Direction = ParameterDirection.ReturnValue; - - _parameters.Add(p); - return p; - } - - /// - /// Adds a named parameter () to the data. - /// - /// Indicates whether to reselect after the operation. - /// A . - public DbParameter AddReselectRecordParam(bool reselect = true) => AddParameter(Database.DatabaseColumns.ReselectRecordName, reselect); - - /// - /// Parameterizes the name by ensuring it starts with an '@' character. - /// - /// The parameter name. - /// The parameterized name. - public static string ParameterizeName(string name) => name.ThrowIfNull(nameof(name)).StartsWith('@') ? name : $"@{name}"; - - /// - /// Gets or sets the at the specified . - /// - /// The zero-based index. - /// The . - public DbParameter this[int index] => _parameters[index]; - - /// - public void Add(DbParameter item) => _parameters.Add(item); - - /// - /// Adds . - /// - /// The list to add. - public void AddRange(IEnumerable list) => _parameters.AddRange(list); - - /// - public void Clear() => _parameters.Clear(); - - /// - public bool Contains(DbParameter item) => _parameters.Contains(item); - - /// - public void CopyTo(DbParameter[] array, int arrayIndex) => _parameters.CopyTo(array, arrayIndex); - - /// - public bool Remove(DbParameter item) => _parameters.Remove(item); - - /// - public IEnumerator GetEnumerator() => _parameters.GetEnumerator(); - - /// - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + var p = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); + p.ParameterName = ParameterizeName(name); + p.Value = ConvertToDbValue(value, Database); + p.Direction = direction; + if (dbType.HasValue) + p.DbType = dbType.Value; + + _parameters.Add(p); + return p; } + + /// + /// Adds the named parameter and value, using the specified , and , to the . + /// + /// The parameter name. + /// The parameter . + /// The optional maximum size (in bytes). + /// The (default to ). + /// The . + public DbParameter AddParameter(string name, DbType dbType, int? size = null, ParameterDirection direction = ParameterDirection.Input) + { + var p = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); + p.ParameterName = ParameterizeName(name); + p.DbType = dbType; + p.Direction = direction; + if (size.HasValue) + p.Size = size.Value; + + _parameters.Add(p); + return p; + } + + /// + /// Adds the named parameter to the . + /// + /// The parameter name. + /// The wildcard value. + /// The . + public DbParameter AddWildcardParameter(string name, string? wildcard) => AddParameter(name, wildcard is null ? null : Database.Wildcard.Replace(wildcard)); + + /// + /// Adds the named parameter and value serialized as a JSON to the . + /// + /// The parameter name. + /// The parameter value. + /// The . + /// Where the is then will be used. + public DbParameter AddJsonParameter(string name, T? value) + { + var p = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); + p.ParameterName = ParameterizeName(name); + if (value is null) + p.Value = DBNull.Value; + else + p.Value = JsonSerializer.Serialize(value, Database.JsonSerializerOptions); + + _parameters.Add(p); + return p; + } + + /// + /// Adds an parameter to the + /// + /// The . + public DbParameter AddReturnValueParameter() + { + var p = Database.Provider.CreateParameter() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateParameter)} returned a null."); + p.ParameterName = ParameterizeName(Database.NamedColumns.ReturnValueName); + p.DbType = DbType.Int32; + p.Direction = ParameterDirection.ReturnValue; + + _parameters.Add(p); + return p; + } + + /// + /// Adds a named parameter () to the data to the + /// + /// Indicates whether to reselect after the operation. + /// The . + public DbParameter AddReselectRecordParam(bool reselect = true) => AddParameter(Database.NamedColumns.ReselectRecordName, reselect); + + /// + /// Adds a named parameter () using the to the . + /// + /// The -based representation of the row version. + /// The (default to ). + /// The . + public DbParameter AddRowVersionParam(string? value, ParameterDirection direction = ParameterDirection.Input) + => AddParameter(Database.NamedColumns.RowVersionName, Database.RowVersionConverter.ConvertToDestination(value), direction: direction); + + /// + /// Parameterizes the name by ensuring it starts with an '@' character. + /// + /// The parameter name. + /// The parameterized name. + public static string ParameterizeName(string name) => name.ThrowIfNull().StartsWith('@') ? name : $"@{name}"; + + /// + /// Converts the specified to a database-compatible value. + /// + /// The value . + /// The value to convert. + /// The . + /// The converted database-compatible value. + public static object? ConvertToDbValue(T value, IDatabase database) => value is null + ? DBNull.Value + : (value is DateTimeOffset dto && database.DateTimeOffsetTransform ? dto.ToUniversalTime() // Convert to UTC. https://www.tinybird.co/blog/database-timestamps-timezone + : (value is JsonElement je ? JsonElementStringConverter.Default.ConvertToDestination(je) : value)); + + /// + /// Gets or sets the at the specified . + /// + /// The zero-based index. + /// The . + public DbParameter this[int index] => _parameters[index]; + + /// + public void Add(DbParameter item) => _parameters.Add(item); + + /// + /// Adds . + /// + /// The list to add. + public void AddRange(IEnumerable list) => _parameters.AddRange(list); + + /// + public void Clear() => _parameters.Clear(); + + /// + public bool Contains(DbParameter item) => _parameters.Contains(item); + + /// + public void CopyTo(DbParameter[] array, int arrayIndex) => _parameters.CopyTo(array, arrayIndex); + + /// + public bool Remove(DbParameter item) => _parameters.Remove(item); + + /// + public IEnumerator GetEnumerator() => _parameters.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseQueryMapper.cs b/src/CoreEx.Database/DatabaseQueryMapper.cs deleted file mode 100644 index acd7c220..00000000 --- a/src/CoreEx.Database/DatabaseQueryMapper.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; -using System; - -namespace CoreEx.Database -{ - /// - /// Enables a database query only . - /// - /// The resulting . - public abstract class DatabaseQueryMapper : IDatabaseMapper - { - /// - /// This method will result in a . - public abstract T MapFromDb(DatabaseRecord record, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// This method will result in a . - void IDatabaseMapper.MapToDb(T? value, DatabaseParameterCollection parameters, OperationTypes operationType) => throw new NotSupportedException(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseRecord.cs b/src/CoreEx.Database/DatabaseRecord.cs index ccc6e718..938a340a 100644 --- a/src/CoreEx.Database/DatabaseRecord.cs +++ b/src/CoreEx.Database/DatabaseRecord.cs @@ -1,94 +1,183 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Mapping.Converters; +using System.Text.Json; -using CoreEx.Entities; -using System; -using System.Data.Common; +namespace CoreEx.Database; -namespace CoreEx.Database +/// +/// Encapsulates the to provide requisite column value capabilities. +/// +/// The owning . +/// The underlying . +public class DatabaseRecord(IDatabase database, DbDataReader dataReader) { + private Dictionary? _fields; + + /// + /// Gets the owning . + /// + public IDatabase Database { get; } = database.ThrowIfNull(); + /// - /// Encapsulates the to provide requisite column value capabilities. + /// Gets the underlying . /// - /// The owning . - /// The underlying . - public class DatabaseRecord(IDatabase database, DbDataReader dataReader) + public DbDataReader DataReader { get; } = dataReader.ThrowIfNull(); + + /// + /// Gets the named column value. + /// + /// The column name. + /// The value. + public object? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull())); + + /// + /// Gets the specified column value. + /// + /// The ordinal index. + /// The value. + public object? GetValue(int ordinal) { - /// - /// Gets the underlying . - /// - public IDatabase Database { get; } = database.ThrowIfNull(nameof(database)); - - /// - /// Gets the underlying . - /// - public DbDataReader DataReader { get; } = dataReader.ThrowIfNull(nameof(dataReader)); - - /// - /// Gets the named column value. - /// - /// The column name. - /// The value. - public object? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull(nameof(columnName)))); - - /// - /// Gets the specified column value. - /// - /// The ordinal index. - /// The value. - public object? GetValue(int ordinal) - { - if (DataReader.IsDBNull(ordinal)) - return default; + // Handle DBNull. + if (DataReader.IsDBNull(ordinal)) + return default; - var val = DataReader.GetValue(ordinal); - return val is DateTime dt ? Cleaner.Clean(dt, Database.DateTimeTransform) : val; - } + // Good to go! + var val = DataReader.GetValue(ordinal); + return val is DateTime dt ? Cleaner.Clean(dt, Database.DateTimeTransform) : val; + } - /// - /// Gets the named column value. - /// - /// The value . - /// The column name. - /// The value. - public T GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull(nameof(columnName)))); - - /// - /// Gets the specified column value. - /// - /// The value . - /// The ordinal index. - /// The value. - public T GetValue(int ordinal) - { - if (DataReader.IsDBNull(ordinal)) - return default!; + /// + /// Gets the named column value. + /// + /// The value . + /// The column name. + /// The value. + public T GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull())); - T val = DataReader.GetFieldValue(ordinal); - return val is DateTime dt ? (T)Convert.ChangeType(Cleaner.Clean(dt, Database.DateTimeTransform), typeof(DateTime), System.Globalization.CultureInfo.InvariantCulture) : val; - } + /// + /// Gets the specified column value. + /// + /// The value . + /// The ordinal index. + /// The value. + public T GetValue(int ordinal) + { + // Handle DBNull. + if (DataReader.IsDBNull(ordinal)) + return default!; + + // Handle Nullable/Nullable as it is not directly supported by ADO.NET. + if (typeof(T) == typeof(Nullable)) + return Internal.Cast(DataReader.GetFieldValue(ordinal)); + else if (typeof(T) == typeof(Nullable)) + return Internal.Cast(DataReader.GetFieldValue(ordinal)); + else if (typeof(T) == typeof(JsonElement)) + return Internal.Cast(JsonElementStringConverter.Default.ConvertToSource(DataReader.GetFieldValue(ordinal))); + + // Good to go! + T val = DataReader.GetFieldValue(ordinal); + return val is DateTime dt ? Internal.Cast(Cleaner.Clean(dt, Database.DateTimeTransform)) : val; + } - /// - /// Indicates whether the named column is . - /// - /// The column name. - /// The corresponding ordinal for the column name. - /// true indicates that the column value has a value; otherwise, false. - public bool IsDBNull(string columnName, out int ordinal) + /// + /// Tries to get the specified column value. + /// + /// The value . + /// The column name. + /// The corresponding value where found. + /// indicates that the column was found; otherwise, . + public bool TryGetValue(string columnName, out T val) + { + if (TryGetOrdinal(columnName, out var ordinal)) { - ordinal = DataReader.GetOrdinal(columnName.ThrowIfNull(nameof(columnName))); - return DataReader.IsDBNull(ordinal); + val = GetValue(ordinal); + return true; } - /// - /// Gets the named RowVersion column as a . - /// - /// The name of the column. - /// The resultant value. - /// The RowVersion column will be converted to a using the . - public string GetRowVersion(string columnName) + val = default!; + return false; + } + + /// + /// Gets the specified column value or the default value where not found. + /// + /// The value . + /// The column name. + /// The optional default value. + /// Tthe specified column value or the default value where not found. + public T GetValueOrDefault(string columnName, T defaultValue = default!) + { + if (TryGetOrdinal(columnName, out var ordinal)) + return GetValue(ordinal); + + return defaultValue; + } + + /// + /// Gets the named column value as JSON deserialized to the specified . + /// + /// The value . + /// The column name. + /// The value. + public T? GetValueFromJson(string columnName) => GetValueFromJson(DataReader.GetOrdinal(columnName.ThrowIfNull())); + + /// + /// Gets the specified column value as JSON deserialized to the specified . + /// + /// The value . + /// The ordinal index. + /// The value. + public T? GetValueFromJson(int ordinal) + { + if (DataReader.IsDBNull(ordinal)) + return default!; + + var json = DataReader.GetFieldValue(ordinal); + return json is null ? default! : JsonSerializer.Deserialize(json, Database.JsonSerializerOptions) ?? default!; + } + + /// + /// Indicates whether the named column is . + /// + /// The column name. + /// The corresponding ordinal for the column name. + /// indicates that the column value has a value; otherwise, . + public bool IsDBNull(string columnName, out int ordinal) + { + ordinal = DataReader.GetOrdinal(columnName.ThrowIfNull()); + return DataReader.IsDBNull(ordinal); + } + + /// + /// Gets the named RowVersion column as a . + /// + /// The name of the column; otherwise, uses . + /// The resultant value. + /// The RowVersion column will be converted to a using the . + public string? GetRowVersion(string? columnName = null) + { + var i = DataReader.GetOrdinal(!string.IsNullOrEmpty(columnName) ? columnName : Database.NamedColumns.RowVersionName); + return Database.RowVersionConverter.ConvertToSource(DataReader.GetFieldValue(i)) ?? null; + } + + /// + /// Tries to get the ordinal for the specified column name. + /// + /// The name of the column. + /// The corresponding ordinal index where found. + /// indicates that the column was found; otherwise, . + public bool TryGetOrdinal(string columnName, out int ordinal) + { + // Load the fields dictionary on first use. + if (_fields is null) { - var i = DataReader.GetOrdinal(columnName.ThrowIfNull(nameof(columnName))); - return (string)(Database.RowVersionConverter.ConvertToSource(DataReader.GetFieldValue(i)) ?? string.Empty); + _fields = []; + for (int i = 0; i < DataReader.FieldCount; i++) + { + _fields[DataReader.GetName(i)] = i; + } } + + // Try to get the ordinal. + return _fields.TryGetValue(columnName, out ordinal); } } \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseRecordMapper.cs b/src/CoreEx.Database/DatabaseRecordMapper.cs deleted file mode 100644 index 3d586b85..00000000 --- a/src/CoreEx.Database/DatabaseRecordMapper.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; -using System; - -namespace CoreEx.Database -{ - /// - /// Provides a per mapper that simulates a by invoking the function passed into the constructor. - /// - /// The mapping function. - internal class DatabaseRecordMapper(Func func) : IDatabaseMapper - { - private readonly Func _func = func.ThrowIfNull(nameof(func)); - - /// - T? IDatabaseMapper.MapFromDb(DatabaseRecord record, OperationTypes operationType) => _func(record); - - /// - /// This method will result in a . - void IDatabaseMapper.MapToDb(T? value, DatabaseParameterCollection parameters, OperationTypes operationType) => throw new NotSupportedException(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseServiceCollectionExtensions.cs b/src/CoreEx.Database/DatabaseServiceCollectionExtensions.cs deleted file mode 100644 index 14a26b82..00000000 --- a/src/CoreEx.Database/DatabaseServiceCollectionExtensions.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Database; -using CoreEx.Database.HealthChecks; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extension methods. - /// - public static class DatabaseServiceCollectionExtensions - { - /// - /// Adds an as a scoped service. - /// - /// The . - /// The function to create the instance. - /// Indicates whether a corresponding should be configured. - /// The to support fluent-style method-chaining. - public static IServiceCollection AddDatabase(this IServiceCollection services, Func create, bool healthCheck = true) - { - services.AddScoped(sp => create(sp) ?? throw new InvalidOperationException($"An {nameof(IDatabase)} instance must be instantiated.")); - return AddHealthCheck(services, healthCheck, null); - } - - /// - /// Adds an as a scoped service including a corresponding health check. - /// - /// The . - /// The function to create the instance. - /// The health check name; defaults to 'database'. - /// The to support fluent-style method-chaining. - public static IServiceCollection AddDatabase(this IServiceCollection services, Func create, string? healthCheckName) - { - services.AddScoped(sp => create(sp) ?? throw new InvalidOperationException($"An {nameof(IDatabase)} instance must be instantiated.")); - return AddHealthCheck(services, true, healthCheckName); - } - - /// - /// Adds an as a scoped service. - /// - /// The . - /// The . - /// Indicates whether a corresponding should be configured. - /// The to support fluent-style method-chaining. - public static IServiceCollection AddDatabase(this IServiceCollection services, bool healthCheck = true) where TDb : class, IDatabase - { - services.AddScoped(); - return AddHealthCheck(services, healthCheck, null); - } - - /// - /// Adds an as a scoped service including a corresponding health check. - /// - /// The . - /// The . - /// The health check name; defaults to 'database'. - /// The to support fluent-style method-chaining. - public static IServiceCollection AddDatabase(this IServiceCollection services, string? healthCheckName) where TDb : class, IDatabase - { - services.AddScoped(); - return AddHealthCheck(services, true, healthCheckName); - } - - /// - /// Adds the where configured to do so. - /// - private static IServiceCollection AddHealthCheck(this IServiceCollection services, bool healthCheck, string? healthCheckName) - { - if (healthCheck) - services.AddHealthChecks().AddTypeActivatedCheck>(healthCheckName ?? "database", HealthStatus.Unhealthy, tags: default!, timeout: TimeSpan.FromSeconds(30)); - - return services; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/DatabaseWildcard.cs b/src/CoreEx.Database/DatabaseWildcard.cs deleted file mode 100644 index 455d9090..00000000 --- a/src/CoreEx.Database/DatabaseWildcard.cs +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Wildcards; -using System; -using System.Collections.Generic; -using System.Text; - -namespace CoreEx.Database -{ - /// - /// Provides database capabilities. - /// - public class DatabaseWildcard - { - /// - /// Gets the default database multi (zero or more) wildcard character. - /// - public const char MultiWildcardCharacter = '%'; - - /// - /// Gets the default database single wildcard character. - /// - public const char SingleWildcardCharacter = '_'; - - /// - /// Gets the default list of characters that are to be escaped. - /// - /// See for more detail on escape characters. - public static readonly char[] DefaultCharactersToEscape = ['%', '_', '[']; - - /// - /// Gets the default escaping format string when one of the is found. - /// - public const string DefaultEscapeFormat = "[{0}]"; - - /// - /// Initializes a new instance of the class. - /// - /// The configuration. - /// The database multi (zero or more) wildcard character. - /// The database single wildcard character. - /// The list of characters that are to be escaped (defaults to ). - /// The escaping format string when one of the is found (defaults to ). -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Somehow the compiler thinks CharactersToEscape can be null; so not true. - public DatabaseWildcard(Wildcard? wildcard = null, char multiWildcard = MultiWildcardCharacter, char singleWildcard = SingleWildcardCharacter, char[]? charactersToEscape = null, string? escapeFormat = null) -#pragma warning restore CS8618 - { - Wildcard = wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; - MultiWildcard = multiWildcard; - SingleWildcard = singleWildcard; - CharactersToEscape = new List(charactersToEscape ?? DefaultCharactersToEscape); - EscapeFormat = escapeFormat ?? DefaultEscapeFormat; - - if (MultiWildcard != char.MinValue && SingleWildcard != char.MinValue && MultiWildcard == SingleWildcard) - throw new ArgumentException("A Wildcard cannot be configured with the same character for the MultiWildcard and SingleWildcard.", nameof(multiWildcard)); - - if (Wildcard.Supported.HasFlag(WildcardSelection.MultiWildcard) && multiWildcard == char.MinValue) - throw new ArgumentException("A Wildcard that supports MultiWildcard must also define the database MultiWildcard character."); - - if (Wildcard.Supported.HasFlag(WildcardSelection.SingleWildcard) && singleWildcard == char.MinValue) - throw new ArgumentException("A Wildcard that supports SingleWildcard must also define the database SingleWildcard character."); - - if (CharactersToEscape != null && CharactersToEscape.Count > 0 && string.IsNullOrEmpty(EscapeFormat)) - throw new InvalidOperationException("The EscapeFormat must be provided where CharactersToEscape have been defined."); - } - - /// - /// Gets or sets the underlying configuration. - /// - public Wildcard Wildcard { get; } - - /// - /// Gets or sets the database multi (zero or more) wildcard character. - /// - public char MultiWildcard { get; } - - /// - /// Gets or sets the database single wildcard character. - /// - public char SingleWildcard { get; } - - /// - /// Gets or sets the list of characters that are to be escaped. - /// - public List CharactersToEscape { get; } - - /// - /// Gets or sets the escaping format when one of the is found. - /// - /// See for more detail on escape characters. - public string EscapeFormat { get; } - - /// - /// Replaces the wildcard text with the appropriate database representative characters to enable the corresponding SQL LIKE wildcard. - /// - /// The wildcard text. - /// The SQL LIKE wildcard. - public string? Replace(string? text) - { - var wc = Wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; - var wr = wc.Parse(text).ThrowOnError(); - - if (wr.Selection.HasFlag(WildcardSelection.None) || (wr.Selection.HasFlag(WildcardSelection.Single) && wr.Selection.HasFlag(WildcardSelection.MultiWildcard))) - return new string(MultiWildcard, 1); - - var sb = new StringBuilder(); - foreach (var c in wr.Text!) - { - if (wr.Selection.HasFlag(WildcardSelection.MultiWildcard) && c == Wildcard.MultiWildcardCharacter) - sb.Append(MultiWildcard); - else if (wr.Selection.HasFlag(WildcardSelection.SingleWildcard) && c == Wildcard.SingleWildcardCharacter) - sb.Append(SingleWildcard); - else if (CharactersToEscape != null && CharactersToEscape.Contains(c)) - sb.Append(string.Format(System.Globalization.CultureInfo.InvariantCulture, EscapeFormat, c)); - else - sb.Append(c); - } - - return Cleaner.Clean(sb.ToString(), StringTrim.None, wc.Transform); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/DatabaseArgs.cs b/src/CoreEx.Database/Extended/DatabaseArgs.cs deleted file mode 100644 index 4491d2d2..00000000 --- a/src/CoreEx.Database/Extended/DatabaseArgs.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Database.Extended -{ - /// - /// Provides the extended arguments. - /// - public struct DatabaseArgs - { - private readonly IDatabaseMapper? _mapper = null; - - /// - /// Initializes a new instance of the struct. - /// - public DatabaseArgs() { } - - /// - /// Initializes a new instance of the struct. - /// - /// The template to copy from. - /// The . - public DatabaseArgs(DatabaseArgs template, IDatabaseMapper mapper) - { - _mapper = mapper.ThrowIfNull(nameof(mapper)); - Refresh = template.Refresh; - } - - /// - /// Initializes a new instance of the struct. - /// - /// The . - public DatabaseArgs(IDatabaseMapper mapper) => _mapper = mapper.ThrowIfNull(nameof(mapper)); - - /// - /// Gets the . - /// - public readonly IDatabaseMapper Mapper => _mapper ?? throw new InvalidOperationException("Mapper must have been specified for it to be referenced."); - - /// - /// Indicates whether the has been specified. - /// - public readonly bool HasMapper => _mapper != null; - - /// - /// Indicates whether the data should be refreshed (reselected where applicable) after a save operation (defaults to true). - /// - public bool Refresh { get; set; } = true; - - /// - /// Indicates whether the result should be cleaned up. - /// - public bool CleanUpResult { get; set; } = false; - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/DatabaseColumns.cs b/src/CoreEx.Database/Extended/DatabaseColumns.cs index be4c0ab0..1f1bba4b 100644 --- a/src/CoreEx.Database/Extended/DatabaseColumns.cs +++ b/src/CoreEx.Database/Extended/DatabaseColumns.cs @@ -1,109 +1,159 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Database.Extended; -using CoreEx.Entities; -using CoreEx.RefData; - -namespace CoreEx.Database.Extended +/// +/// Represents the standard database column and/or parameters names. +/// +/// These are used internally to map .NET properties to/from database column and/or parameter names. +public record class DatabaseColumns { /// - /// Represents the standard database column names. - /// - /// These are used internally to map .NET properties to database column names. - public class DatabaseColumns - { - /// - /// Gets or sets the column name (defaults to ). - /// - public string CreatedDateName { get; set; } = nameof(ChangeLog.CreatedDate); - - /// - /// Gets or sets the column name (defaults to ). - /// - public string CreatedByName { get; set; } = nameof(ChangeLog.CreatedBy); - - /// - /// Gets or sets the column name (defaults to ). - /// - public string UpdatedDateName { get; set; } = nameof(ChangeLog.UpdatedDate); - - /// - /// Gets or sets the column name (defaults to ). - /// - public string UpdatedByName { get; set; } = nameof(ChangeLog.UpdatedBy); - - /// - /// Gets or sets the 'ReselectRecord' column name (defaults to 'ReselectRecord'"). - /// - public string ReselectRecordName { get; set; } = "ReselectRecord"; - - /// - /// Gets or sets the column name (defaults to 'PagingSkip'"). - /// - public string PagingSkipName { get; set; } = "PagingSkip"; - - /// - /// Gets or sets the column name (defaults to 'PagingTake'"). - /// - public string PagingTakeName { get; set; } = "PagingTake"; - - /// - /// Gets or sets the column name (defaults to 'PagingCount'"). - /// - public string PagingCountName { get; set; } = "PagingCount"; - - /// - /// Gets or sets the 'RowVersion' column name (defaults to 'RowVersion'"). - /// - public string RowVersionName { get; set; } = "RowVersion"; - - /// - /// Gets or sets the database column name (defaults to 'RowVersion'"). - /// - public string ETagName { get; set; } = "RowVersion"; - - /// - /// Gets or sets the 'ReturnValue' column name. - /// - public string ReturnValueName { get; set; } = "ReturnValue"; - - /// - /// Gets or sets the database column name (defaults to ). - /// - public string RefDataIdName { get; set; } = nameof(IReferenceData.Id); - - /// - /// Gets or sets the database column name (defaults to ). - /// - public string RefDataCodeName { get; set; } = nameof(IReferenceData.Code); - - /// - /// Gets or sets the database column name (defaults to ). - /// - public string RefDataTextName { get; set; } = nameof(IReferenceData.Text); - - /// - /// Gets or sets the database column name (defaults to ). - /// - public string RefDataDescriptionName { get; set; } = nameof(IReferenceData.Description); - - /// - /// Gets or sets the database column name (defaults to ). - /// - public string RefDataSortOrderName { get; set; } = nameof(IReferenceData.SortOrder); - - /// - /// Gets or sets the database column name (defaults to ). - /// - public string RefDataIsActiveName { get; set; } = nameof(IReferenceData.IsActive); - - /// - /// Gets or sets the database column name (defaults to ). - /// - public string RefDataStartDateName { get; set; } = nameof(IReferenceData.StartDate); - - /// - /// Gets or sets the database column name (defaults to ). - /// - public string RefDataEndDateName { get; set; } = nameof(IReferenceData.EndDate); - } + /// Gets or sets the column name. + /// + public string CreatedOnName { get; init; } = nameof(ChangeLog.CreatedOn); + + /// + /// Gets or sets the column name. + /// + public string CreatedByName { get; init; } = nameof(ChangeLog.CreatedBy); + + /// + /// Gets or sets the column name. + /// + public string UpdatedOnName { get; init; } = nameof(ChangeLog.UpdatedOn); + + /// + /// Gets or sets the column name. + /// + public string UpdatedByName { get; init; } = nameof(ChangeLog.UpdatedBy); + + /// + /// Gets or sets the 'ReselectRecord' parameter name. + /// + public string ReselectRecordName { get; init; } = "ReselectRecord"; + + /// + /// Gets or sets the parameter name. + /// + public string PagingSkipName { get; init; } = "PagingSkip"; + + /// + /// Gets or sets the parameter name. + /// + public string PagingTakeName { get; init; } = "PagingTake"; + + /// + /// Gets or sets the parameter name. + /// + public string PagingCountName { get; init; } = "PagingCount"; + + /// + /// Gets or sets the 'RowVersion' (ETag) column name. + /// + /// Also see . + public string RowVersionName { get; init; } = "RowVersion"; + + /// + /// Gets or sets the database column name. + /// + public string TenantIdName { get; init; } = "TenantId"; + + /// + /// Gets or sets the database column name. + /// + public string IsDeletedName { get; init; } = "IsDeleted"; + + /// + /// Gets or sets the 'ReturnValue' parameter name. + /// + public string ReturnValueName { get; init; } = "ReturnValue"; + + /// + /// Gets or sets the database column name (defaults to ). + /// + public string RefDataIdName { get; init; } = nameof(IReferenceData.Id); + + /// + /// Gets or sets the database column name (defaults to ). + /// + public string RefDataCodeName { get; init; } = nameof(IReferenceData.Code); + + /// + /// Gets or sets the database column name (defaults to ). + /// + public string RefDataTextName { get; init; } = nameof(IReferenceData.Text); + + /// + /// Gets or sets the database column name (defaults to ). + /// + public string RefDataDescriptionName { get; init; } = nameof(IReferenceData.Description); + + /// + /// Gets or sets the database column name (defaults to ). + /// + public string RefDataSortOrderName { get; init; } = nameof(IReferenceData.SortOrder); + + /// + /// Gets or sets the database column name (defaults to ). + /// + public string RefDataIsActiveName { get; init; } = nameof(IReferenceData.IsActive); + + /// + /// Gets or sets the database column name (defaults to ). + /// + public string RefDataStartDateName { get; init; } = nameof(IReferenceData.StartsOn); + + /// + /// Gets or sets the database column name (defaults to ). + /// + public string RefDataEndDateName { get; init; } = nameof(IReferenceData.EndsOn); + + /// + /// Gets or sets the partition key 'PartitionKey' database column name. + /// + public string PartitionKeyName { get; init; } = "PartitionKey"; + + /// + /// Gets or sets the partition identifier/number 'PartitionId' database column name. + /// + public string PartitionIdName { get; init; } = "PartitionId"; + + /// + /// Gets or sets the outbox 'Destination' database column name. + /// + public string OutboxDestinationName { get; init; } = "Destination"; + + /// + /// Gets or sets the outbox 'Event' database column name. + /// + public string OutboxEventName { get; init; } = "Event"; + + /// + /// Gets or sets the outbox 'EnqueuedUtc' database column name. + /// + public string OutboxEnqueuedUtcName { get; init; } = "EnqueuedUtc"; + + /// + /// Gets or sets the outbox 'BatchSize' database column name. + /// + public string OutboxBatchSizeName { get; init; } = "BatchSize"; + + /// + /// Gets or sets the outbox 'LeaseId' database column name. + /// + public string OutboxLeaseIdName { get; init; } = "LeaseId"; + + /// + /// Gets or sets the outbox 'LeaseSeconds' database column name. + /// + public string OutboxLeaseDurationName { get; init; } = "LeaseSeconds"; + + /// + /// Gets or sets the outbox 'DequeuedUtc' database column name. + /// + public string OutboxDequeuedUtcName { get; init; } = "DequeuedUtc"; + + /// + /// Gets or sets the outbox 'BackoffSeconds' database column name. + /// + public string OutboxBackoffDurationName { get; init; } = "BackoffSeconds"; } \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs b/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs deleted file mode 100644 index d1c1933b..00000000 --- a/src/CoreEx.Database/Extended/DatabaseExtendedExtensions.cs +++ /dev/null @@ -1,413 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.RefData; -using CoreEx.Results; -using System; -using System.Data.Common; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database.Extended -{ - /// - /// Provides database extension methods. - /// - public static class DatabaseExtendedExtensions - { - /// - /// Creates a (for loading). - /// - /// The . - /// The item . - /// The . - /// The . - /// The . - public static RefDataLoader ReferenceData(this DatabaseCommand command) - where TColl : class, IReferenceDataCollection, new() - where TItem : class, IReferenceData, new() - where TId : IComparable, IEquatable - => new(command); - - /// - /// Creates a (for loading) using the specified . - /// - /// The . - /// The item . - /// The . - /// The . - /// The stored procedure name. - /// The . - public static RefDataLoader ReferenceData(this IDatabase database, string storedProcedure) - where TColl : class, IReferenceDataCollection, new() - where TItem : class, IReferenceData, new() - where TId : IComparable, IEquatable - => ReferenceData(database.ThrowIfNull(nameof(database)).StoredProcedure(storedProcedure)); - - /// - /// Creates a (for loading) using a 'SELECT * FROM [].[]' SQL statement. - /// - /// The . - /// The item . - /// The . - /// The . - /// The database schema name (optional). - /// The database table name. - /// The . - /// The and should not be escaped/quoted as this is performed internally to minimize SQL injection opportunity. - public static RefDataLoader ReferenceData(this IDatabase database, string? schemaName, string tableName) - where TColl : class, IReferenceDataCollection, new() - where TItem : class, IReferenceData, new() - where TId : IComparable, IEquatable - { - if (!database.Provider.CanCreateCommandBuilder) - throw new NotSupportedException("Database Provider can not CreateCommandBuilder which is required to quote the identifiers to minimize SQL inject possibility."); - - var cb = database.Provider.CreateCommandBuilder() ?? throw new InvalidOperationException($"The {nameof(DbProviderFactory)}.{nameof(DbProviderFactory.CreateCommandBuilder)} returned a null."); - if (string.IsNullOrEmpty(schemaName)) - return ReferenceData(database.ThrowIfNull(nameof(database)) - .SqlStatement($"SELECT * FROM {cb.QuoteIdentifier(tableName.ThrowIfNull(nameof(tableName)))}")); - else - return ReferenceData(database.ThrowIfNull(nameof(database)) - .SqlStatement($"SELECT * FROM {cb.QuoteIdentifier(schemaName)}.{cb.QuoteIdentifier(tableName.ThrowIfNull(nameof(tableName)))}")); - } - - /// - /// Creates a to enable select-like capabilities. - /// - /// The value . - /// The . - /// The . - /// The query action to enable additional filtering. - /// The - public static DatabaseQuery Query(this DatabaseCommand command, DatabaseArgs args, Action? queryParams = null) where T : class, new() => new(command, args, queryParams); - - /// - /// Creates a to enable select-like capabilities. - /// - /// The value . - /// The . - /// The . - /// The query action to enable additional filtering. - /// The - public static DatabaseQuery Query(this DatabaseCommand command, IDatabaseMapper mapper, Action? queryParams = null) where T : class, new() => new(command, new DatabaseArgs(command.Database.DbArgs, mapper), queryParams); - - /// - /// Performs the save (create or update) operation. - /// - private static async Task> SaveWithResultAsync(this DatabaseCommand command, DatabaseArgs args, T value, OperationTypes operationType, CancellationToken cancellationToken = default) where T : class, new() - { - command.ThrowIfNull(nameof(command)); - value.ThrowIfNull(nameof(value)); - - // Set ChangeLog properties where appropriate. - if (operationType == OperationTypes.Create) - Cleaner.PrepareCreate(value); - else - Cleaner.PrepareUpdate(value); - - // Map the parameters. - var map = (IDatabaseMapper)args.Mapper; - map.MapToDb(value, command.Parameters, operationType); - - if (args.Refresh) - { - var result = await command.ReselectRecordParam().SelectFirstOrDefaultWithResultAsync(map, cancellationToken).ConfigureAwait(false); - return result.ThenAs(v => v is null ? Result.NotFoundError() : Result.Ok(v)); - } - - // NOTE: without refresh, fields like IDs and RowVersion are not automatically updated. - var nqresult = await command.NonQueryWithResultAsync(cancellationToken).ConfigureAwait(false); - return nqresult.ThenAs(_ => value); - } - - #region Standard - - /// - /// Gets the value for the specified mapping to . - /// - /// The value . - /// The . - /// The . - /// The key value. - /// The . - /// The value where found; otherwise, null. - public static Task GetAsync(this DatabaseCommand command, DatabaseArgs args, object[] key, CancellationToken cancellationToken = default) where T : class, new() - => GetAsync(command, args, CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the value for the specified mapping to . - /// - /// The value . - /// The . - /// The . - /// The key value. - /// The . - /// The value where found; otherwise, null. - public static Task GetAsync(this DatabaseCommand command, IDatabaseMapper mapper, object? key, CancellationToken cancellationToken = default) where T : class, new() - => GetAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the value for the specified mapping to . - /// - /// The value . - /// The . - /// The . - /// The . - /// The . - /// The value where found; otherwise, null. - public static async Task GetAsync(this DatabaseCommand command, DatabaseArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, new() - => await GetWithResultAsync(command, args, key, cancellationToken).ConfigureAwait(false); - - /// - /// Gets the value for the specified mapping to . - /// - /// The value . - /// The . - /// The . - /// The . - /// The . - /// The value where found; otherwise, null. - public static Task GetAsync(this DatabaseCommand command, IDatabaseMapper mapper, CompositeKey key, CancellationToken cancellationToken = default) where T : class, new() - => GetAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), key, cancellationToken); - - /// - /// Performs a create using the specified stored procedure and value (reselects where specified). - /// - /// The value . - /// The . - /// The . - /// The value to insert. - /// The . - /// The value (reselected where specified). - public static async Task CreateAsync(this DatabaseCommand command, DatabaseArgs args, T value, CancellationToken cancellationToken = default) where T : class, new() - => await SaveWithResultAsync(command, args, value, OperationTypes.Create, cancellationToken); - - /// - /// Performs a create using the specified stored procedure and value (reselects where specified). - /// - /// The value . - /// The . - /// The . - /// The value to insert. - /// The . - /// The value (reselected where specified). - public async static Task CreateAsync(this DatabaseCommand command, IDatabaseMapper mapper, T value, CancellationToken cancellationToken = default) where T : class, new() - => await SaveWithResultAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), value, OperationTypes.Create, cancellationToken); - - /// - /// Performs an update using the specified stored procedure and value (reselects where specified). - /// - /// The value . - /// The . - /// The . - /// The value to insert. - /// The . - /// The value (reselected where specified). - public static async Task UpdateAsync(this DatabaseCommand command, DatabaseArgs args, T value, CancellationToken cancellationToken = default) where T : class, new() - => await SaveWithResultAsync(command, args, value, OperationTypes.Update, cancellationToken); - - /// - /// Performs an update using the specified stored procedure and value (reselects where specified). - /// - /// The value . - /// The . - /// The . - /// The value to insert. - /// The . - /// The value (reselected where specified). - public static async Task UpdateAsync(this DatabaseCommand command, IDatabaseMapper mapper, T value, CancellationToken cancellationToken = default) where T : class, new() - => await SaveWithResultAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), value, OperationTypes.Update, cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The . - /// The . - /// The key value. - /// The . - public static Task DeleteAsync(this DatabaseCommand command, DatabaseArgs args, object? key, CancellationToken cancellationToken = default) - => DeleteAsync(command, args, CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The . - /// The . - /// The key values. - /// The . - public static Task DeleteAsync(this DatabaseCommand command, IDatabaseMapper mapper, object? key, CancellationToken cancellationToken = default) - => DeleteAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The . - /// The . - /// The . - /// The . - public static async Task DeleteAsync(this DatabaseCommand command, DatabaseArgs args, CompositeKey key, CancellationToken cancellationToken = default) - => (await DeleteWithResultAsync(command, args, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Performs a delete for the specified . - /// - /// The . - /// The . - /// The . - /// The . - public static Task DeleteAsync(this DatabaseCommand command, IDatabaseMapper mapper, CompositeKey key, CancellationToken cancellationToken = default) - => DeleteAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), key, cancellationToken); - - #endregion - - #region WithResult - - /// - /// Gets the value for the specified mapping to with a . - /// - /// The value . - /// The . - /// The . - /// The key value. - /// The . - /// The value where found; otherwise, null. - public static Task> GetWithResultAsync(this DatabaseCommand command, DatabaseArgs args, object[] key, CancellationToken cancellationToken = default) where T : class, new() - => GetWithResultAsync(command, args, CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the value for the specified mapping to with a . - /// - /// The value . - /// The . - /// The . - /// The key value. - /// The . - /// The value where found; otherwise, null. - public static Task> GetWithResultAsync(this DatabaseCommand command, IDatabaseMapper mapper, object? key, CancellationToken cancellationToken = default) where T : class, new() - => GetWithResultAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the value for the specified mapping to with a . - /// - /// The value . - /// The . - /// The . - /// The . - /// The . - /// The value where found; otherwise, null. - public static Task> GetWithResultAsync(this DatabaseCommand command, DatabaseArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, new() - => command.ThrowIfNull(nameof(command)) - .Params(p => args.Mapper.MapKeyToDb(key, p)) - .SelectFirstOrDefaultWithResultAsync((IDatabaseMapper)args.Mapper, cancellationToken); - - /// - /// Gets the value for the specified mapping to with a . - /// - /// The value . - /// The . - /// The . - /// The . - /// The . - /// The value where found; otherwise, null. - public static Task> GetWithResultAsync(this DatabaseCommand command, IDatabaseMapper mapper, CompositeKey key, CancellationToken cancellationToken = default) where T : class, new() - => GetWithResultAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), key, cancellationToken); - - /// - /// Performs a create using the specified stored procedure and value (reselects where specified) with a . - /// - /// The value . - /// The . - /// The . - /// The value to insert. - /// The . - /// The value (reselected where specified). - public static Task> CreateWithResultAsync(this DatabaseCommand command, DatabaseArgs args, T value, CancellationToken cancellationToken = default) where T : class, new() - => SaveWithResultAsync(command, args, value, OperationTypes.Create, cancellationToken); - - /// - /// Performs a create using the specified stored procedure and value (reselects where specified) with a . - /// - /// The value . - /// The . - /// The . - /// The value to insert. - /// The . - /// The value (reselected where specified). - public static Task> CreateWithResultAsync(this DatabaseCommand command, IDatabaseMapper mapper, T value, CancellationToken cancellationToken = default) where T : class, new() - => SaveWithResultAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), value, OperationTypes.Create, cancellationToken); - - /// - /// Performs an update using the specified stored procedure and value (reselects where specified) with a . - /// - /// The value . - /// The . - /// The . - /// The value to insert. - /// The . - /// The value (reselected where specified). - public static Task> UpdateWithResultAsync(this DatabaseCommand command, DatabaseArgs args, T value, CancellationToken cancellationToken = default) where T : class, new() - => SaveWithResultAsync(command, args, value, OperationTypes.Update, cancellationToken); - - /// - /// Performs an update using the specified stored procedure and value (reselects where specified) with a . - /// - /// The value . - /// The . - /// The . - /// The value to insert. - /// The . - /// The value (reselected where specified). - public static Task> UpdateWithResultAsync(this DatabaseCommand command, IDatabaseMapper mapper, T value, CancellationToken cancellationToken = default) where T : class, new() - => SaveWithResultAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), value, OperationTypes.Update, cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The . - /// The . - /// The key value. - /// The . - public static Task DeleteWithResultAsync(this DatabaseCommand command, DatabaseArgs args, object? key, CancellationToken cancellationToken = default) - => DeleteWithResultAsync(command, args, CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The . - /// The . - /// The key values. - /// The . - public static Task DeleteWithResultAsync(this DatabaseCommand command, IDatabaseMapper mapper, object? key, CancellationToken cancellationToken = default) - => DeleteWithResultAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The . - /// The . - /// The . - /// The . - public static async Task DeleteWithResultAsync(this DatabaseCommand command, DatabaseArgs args, CompositeKey key, CancellationToken cancellationToken = default) - { - var rowsAffectedResult = await command.ThrowIfNull(nameof(command)) - .Params(p => args.Mapper.MapKeyToDb(key, p)) - .ScalarWithResultAsync(cancellationToken).ConfigureAwait(false); - - return rowsAffectedResult.WhenAs(rowsAffected => rowsAffected < 1, _ => Result.NotFoundError()); - } - - /// - /// Performs a delete for the specified with a . - /// - /// The . - /// The . - /// The . - /// The . - public static Task DeleteWithResultAsync(this DatabaseCommand command, IDatabaseMapper mapper, CompositeKey key, CancellationToken cancellationToken = default) - => DeleteWithResultAsync(command, new DatabaseArgs(command.Database.DbArgs, mapper), key, cancellationToken); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/DatabaseQuery.cs b/src/CoreEx.Database/Extended/DatabaseQuery.cs deleted file mode 100644 index 9a32fb85..00000000 --- a/src/CoreEx.Database/Extended/DatabaseQuery.cs +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database.Extended -{ - /// - /// Encapsulates a SQL query enabling select-like capabilities. - /// - /// The value . - public class DatabaseQuery : IDatabaseParameters> where T : class, new() - { - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// The query action to enable additional filtering. - internal DatabaseQuery(DatabaseCommand command, DatabaseArgs args, Action? queryParams) - { - Command = command.ThrowIfNull(nameof(command)); - Parameters = new DatabaseParameterCollection(Database); - QueryArgs = args; - Mapper = (IDatabaseMapper)args.Mapper; - - queryParams?.Invoke(Parameters); - } - - /// - /// Gets the . - /// - public DatabaseCommand Command { get; } - - /// - public IDatabase Database => Command.Database; - - /// - public DatabaseParameterCollection Parameters { get; } - - /// - /// Gets the . - /// - public DatabaseArgs QueryArgs { get; } - - /// - /// Gets the . - /// - public IDatabaseMapper Mapper { get; } - - /// - /// Gets the . - /// - public PagingResult? Paging { get; private set; } - - /// - /// Adds to the query. - /// - /// The . - /// The to suport fluent-style method-chaining. - public DatabaseQuery WithPaging(PagingArgs? paging) - { - Paging = paging == null ? null : (paging is PagingResult pr ? pr : new PagingResult(paging)); - return this; - } - - /// - /// Adds to the query. - /// - /// The specified number of elements in a sequence to bypass. - /// The specified number of contiguous elements from the start of a sequence. - /// The to suport fluent-style method-chaining. - public DatabaseQuery WithPaging(long skip, long? take = null) => WithPaging(PagingArgs.CreateSkipAndTake(skip, take)); - - /// - /// Selects a single item. - /// - /// The . - /// The single item. - public async Task SelectSingleAsync(CancellationToken cancellationToken = default) => await SelectSingleWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects a single item with a . - /// - /// The . - /// The single item. - public Task> SelectSingleWithResultAsync(CancellationToken cancellationToken = default) => SelectWrapperWithResultAsync((cmd, ct) => cmd.SelectSingleWithResultAsync(Mapper, ct), cancellationToken); - - /// - /// Selects a single item or default. - /// - /// The . - /// The single item or default. - public async Task SelectSingleOrDefaultAsync(CancellationToken cancellationToken = default) => await SelectSingleOrDefaultWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects a single item or default with a . - /// - /// The . - /// The single item or default. - public Task> SelectSingleOrDefaultWithResultAsync(CancellationToken cancellationToken = default) => SelectWrapperWithResultAsync((cmd, ct) => cmd.SelectSingleOrDefaultWithResultAsync(Mapper, ct), cancellationToken); - - /// - /// Selects first item. - /// - /// The . - /// The first item. - public async Task SelectFirstAsync(CancellationToken cancellationToken = default) => await SelectFirstWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects first item with a . - /// - /// The . - /// The first item. - public Task> SelectFirstWithResultAsync(CancellationToken cancellationToken = default) => SelectWrapperWithResultAsync((cmd, ct) => cmd.SelectFirstWithResultAsync(Mapper, ct), cancellationToken); - - /// - /// Selects first item or default. - /// - /// The . - /// The single item or default. - public async Task SelectFirstOrDefaultAsync(CancellationToken cancellationToken = default) => await SelectFirstOrDefaultWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Selects first item or default with a . - /// - /// The . - /// The single item or default. - public Task> SelectFirstOrDefaultWithResultAsync(CancellationToken cancellationToken = default) => SelectWrapperWithResultAsync((cmd, ct) => cmd.SelectFirstOrDefaultWithResultAsync(Mapper, ct), cancellationToken); - - /// - /// Executes the query command creating a . - /// - /// The . - /// The . - /// The . - /// The resulting . - public async Task SelectResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() - => (await SelectResultWithResultAsync(cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Executes the query command creating a with a . - /// - /// The . - /// The . - /// The . - /// The resulting . - public async Task> SelectResultWithResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() - { - var result = await SelectQueryWithResultAsync(cancellationToken).ConfigureAwait(false); - return result.ThenAs(items => new TCollResult { Items = items, Paging = Paging }); - } - - /// - /// Executes the query command creating a resultant collection. - /// - /// The collection . - /// The . - /// A resultant collection. - public async Task SelectQueryAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() - => await SelectQueryWithResultAsync(cancellationToken).ConfigureAwait(false); - - /// - /// Executes the query command creating a resultant collection with a . - /// - /// The collection . - /// The . - /// A resultant collection. - public async Task> SelectQueryWithResultAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() - { - var coll = new TColl(); - var result = await SelectQueryWithResultAsync(coll, cancellationToken).ConfigureAwait(false); - return result.ThenAs(() => coll); - } - - /// - /// Executes a query adding to the passed collection. - /// - /// The collection . - /// The collection to add items to. - /// The . - public async Task SelectQueryAsync(TColl coll, CancellationToken cancellationToken = default) where TColl : ICollection - => (await SelectQueryWithResultAsync(coll, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes a query adding to the passed collection with a . - /// - /// The collection . - /// The collection to add items to. - /// The . - public async Task SelectQueryWithResultAsync(TColl coll, CancellationToken cancellationToken = default) where TColl : ICollection - { - var result = await SelectWrapperWithResultAsync(async (cmd, ct) => - { - var result = await cmd.SelectQueryWithResultAsync(coll, Mapper, ct).ConfigureAwait(false); - return result.ThenAs(() => coll); - }, cancellationToken).ConfigureAwait(false); - - return result.Bind(); - } - - /// - /// Wraps the select query to perform standard logic. - /// - private async Task> SelectWrapperWithResultAsync(Func>> func, CancellationToken cancellationToken) - { - var rvp = Paging != null && Paging.IsGetCount ? Parameters.AddReturnValueParameter() : null; - var cmd = Command.Params(Parameters).PagingParams(Paging); - - return await Result.GoAsync(func(cmd, cancellationToken)) - .When(_ => rvp != null && rvp.Value != null, _ => { Paging!.TotalCount = (long)rvp!.Value!; }) - .Then(res => QueryArgs.CleanUpResult ? Cleaner.Clean(res) : res); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/DatabaseWildcard.cs b/src/CoreEx.Database/Extended/DatabaseWildcard.cs new file mode 100644 index 00000000..0a9f707b --- /dev/null +++ b/src/CoreEx.Database/Extended/DatabaseWildcard.cs @@ -0,0 +1,112 @@ +namespace CoreEx.Database.Extended; + +/// +/// Provides database capabilities. +/// +public class DatabaseWildcard +{ + /// + /// Gets the default database multi (zero or more) wildcard character. + /// + public const char MultiWildcardCharacter = '%'; + + /// + /// Gets the default database single wildcard character. + /// + public const char SingleWildcardCharacter = '_'; + + /// + /// Gets the default list of characters that are to be escaped. + /// + /// See for more detail on escape characters. + public static readonly char[] DefaultCharactersToEscape = ['%', '_', '[']; + + /// + /// Gets the default escaping format string when one of the is found. + /// + public const string DefaultEscapeFormat = "[{0}]"; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration. + /// The database multi (zero or more) wildcard character. + /// The database single wildcard character. + /// The list of characters that are to be escaped (defaults to ). + /// The escaping format string when one of the is found (defaults to ). + public DatabaseWildcard(Wildcard? wildcard = null, char multiWildcard = MultiWildcardCharacter, char singleWildcard = SingleWildcardCharacter, char[]? charactersToEscape = null, string? escapeFormat = null) + { + Wildcard = wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; + MultiWildcard = multiWildcard; + SingleWildcard = singleWildcard; + CharactersToEscape = [.. charactersToEscape ?? DefaultCharactersToEscape]; + EscapeFormat = escapeFormat ?? DefaultEscapeFormat; + + if (MultiWildcard != char.MinValue && SingleWildcard != char.MinValue && MultiWildcard == SingleWildcard) + throw new ArgumentException("A Wildcard cannot be configured with the same character for the MultiWildcard and SingleWildcard.", nameof(multiWildcard)); + + if (Wildcard.SupportedSelection.HasFlag(WildcardSelection.MultiWildcard) && multiWildcard == char.MinValue) + throw new ArgumentException("A Wildcard that supports MultiWildcard must also define the database MultiWildcard character."); + + if (Wildcard.SupportedSelection.HasFlag(WildcardSelection.SingleWildcard) && singleWildcard == char.MinValue) + throw new ArgumentException("A Wildcard that supports SingleWildcard must also define the database SingleWildcard character."); + + if (CharactersToEscape.Count > 0 && string.IsNullOrEmpty(EscapeFormat)) + throw new InvalidOperationException("The EscapeFormat must be provided where CharactersToEscape have been defined."); + } + + /// + /// Gets or sets the underlying configuration. + /// + public Wildcard Wildcard { get; } + + /// + /// Gets or sets the database multi (zero or more) wildcard character. + /// + public char MultiWildcard { get; } + + /// + /// Gets or sets the database single wildcard character. + /// + public char SingleWildcard { get; } + + /// + /// Gets or sets the list of characters that are to be escaped. + /// + public List CharactersToEscape { get; } + + /// + /// Gets or sets the escaping format when one of the is found. + /// + /// See for more detail on escape characters. + public string EscapeFormat { get; } + + /// + /// Replaces the wildcard text with the appropriate database representative characters to enable the corresponding SQL LIKE wildcard. + /// + /// The wildcard text. + /// The SQL LIKE wildcard. + public string? Replace(string? text) + { + var wc = Wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; + var wr = wc.Parse(text).ThrowOnError(); + + if (wr.Selection.HasFlag(WildcardSelection.None) || wr.Selection.HasFlag(WildcardSelection.Single) && wr.Selection.HasFlag(WildcardSelection.MultiWildcard)) + return new string(MultiWildcard, 1); + + var sb = new StringBuilder(); + foreach (var c in wr.Text!) + { + if (wr.Selection.HasFlag(WildcardSelection.MultiWildcard) && c == Wildcard.MultiWildcardCharacter) + sb.Append(MultiWildcard); + else if (wr.Selection.HasFlag(WildcardSelection.SingleWildcard) && c == Wildcard.SingleWildcardCharacter) + sb.Append(SingleWildcard); + else if (CharactersToEscape is not null && CharactersToEscape.Contains(c)) + sb.Append(string.Format(CultureInfo.InvariantCulture, EscapeFormat, c)); + else + sb.Append(c); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/IMultiSetArgs.cs b/src/CoreEx.Database/Extended/IMultiSetArgs.cs new file mode 100644 index 00000000..1799b303 --- /dev/null +++ b/src/CoreEx.Database/Extended/IMultiSetArgs.cs @@ -0,0 +1,33 @@ +namespace CoreEx.Database.Extended; + +/// +/// Enables the multi-set arguments +/// +public interface IMultiSetArgs +{ + /// + /// Gets the minimum number of rows allowed. + /// + int MinimumRows { get; } + + /// + /// Gets the maximum number of rows allowed. + /// + int? MaximumRows { get; } + + /// + /// Indicates whether to stop further query result set processing where the current set has resulted in a (i.e. no records). + /// + bool StopOnNull { get; } + + /// + /// The method invoked for each record for its respective dataset. + /// + /// The . + void DatasetRecord(DatabaseRecord dr); + + /// + /// Invokes the corresponding result function. + /// + void InvokeResult(); +} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/IMultiSetArgsT.cs b/src/CoreEx.Database/Extended/IMultiSetArgsT.cs new file mode 100644 index 00000000..0446ec3b --- /dev/null +++ b/src/CoreEx.Database/Extended/IMultiSetArgsT.cs @@ -0,0 +1,13 @@ +namespace CoreEx.Database.Extended; + +/// +/// Enables the multi-set arguments with a . +/// +/// The item . +public interface IMultiSetArgs : IMultiSetArgs where T : class, new() +{ + /// + /// Gets the for the . + /// + IDatabaseMapper Mapper { get; } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/MultiSetArgs.cs b/src/CoreEx.Database/Extended/MultiSetArgs.cs new file mode 100644 index 00000000..20206dfe --- /dev/null +++ b/src/CoreEx.Database/Extended/MultiSetArgs.cs @@ -0,0 +1,14 @@ +namespace CoreEx.Database.Extended; + +/// +/// Provides helpers. +/// +public static class MultiSetArgs +{ + /// + /// Creates an . + /// + /// The arguments. + /// The . + public static IEnumerable Create(params IEnumerable args) => args.AsEnumerable(); +} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/MultiSetCollArgs.cs b/src/CoreEx.Database/Extended/MultiSetCollArgs.cs new file mode 100644 index 00000000..8d25c97a --- /dev/null +++ b/src/CoreEx.Database/Extended/MultiSetCollArgs.cs @@ -0,0 +1,38 @@ +namespace CoreEx.Database.Extended; + +/// +/// Provides the base multi-set arguments when expecting a collection of items/records. +/// +public abstract class MultiSetCollArgs : IMultiSetArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The minimum number of rows allowed. + /// The maximum number of rows allowed. + /// 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)); + + MinimumRows = minimumRows; + MaximumRows = maximumRows; + StopOnNull = stopOnNull; + } + + /// + public int MinimumRows { get; } + + /// + public int? MaximumRows { get; } + + /// + public bool StopOnNull { get; set; } + + /// + public abstract void DatasetRecord(DatabaseRecord dr); + + /// + public virtual void InvokeResult() { } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/MultiSetCollArgsT.cs b/src/CoreEx.Database/Extended/MultiSetCollArgsT.cs new file mode 100644 index 00000000..14aa4094 --- /dev/null +++ b/src/CoreEx.Database/Extended/MultiSetCollArgsT.cs @@ -0,0 +1,42 @@ +namespace CoreEx.Database.Extended; + +/// +/// Provides the multi-set arguments when expecting a collection of items/records. +/// +/// The collection . +/// The item . +/// The for the . +/// The action that will be invoked with the result of the set. +/// The minimum number of rows allowed. +/// The maximum number of rows allowed. +/// Indicates whether to stop further query result set processing where the current set has resulted in a (i.e. no records). +public class MultiSetCollArgs(IDatabaseMapper mapper, Action result, int minimumRows = 0, int? maximumRows = null, bool stopOnNull = false) : MultiSetCollArgs(minimumRows, maximumRows, stopOnNull), IMultiSetArgs + where TItem : class, new() + where TColl : class, ICollection, new() +{ + private TColl? _coll; + private readonly Action _result = result.ThrowIfNull(); + + /// + /// Gets the for the . + /// + public IDatabaseMapper Mapper { get; } = mapper.ThrowIfNull(); + + /// + public override void DatasetRecord(DatabaseRecord dr) + { + dr.ThrowIfNull(); + _coll ??= new TColl(); + + var item = Mapper.MapFromDb(dr); + if (item is not null) + _coll.Add(item); + } + + /// + public override void InvokeResult() + { + if (_coll is not null) + _result(_coll); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/MultiSetSingleArgs.cs b/src/CoreEx.Database/Extended/MultiSetSingleArgs.cs new file mode 100644 index 00000000..6d8e504a --- /dev/null +++ b/src/CoreEx.Database/Extended/MultiSetSingleArgs.cs @@ -0,0 +1,29 @@ +namespace CoreEx.Database.Extended; + +/// +/// Provides the base multi-set arguments when expecting a single item/record only. +/// +/// Indicates whether the value is mandatory; defaults to . +/// Indicates whether to stop further query result set processing where the current set has resulted in a null (i.e. no records). +public abstract class MultiSetSingleArgs(bool isMandatory = true, bool stopOnNull = false) : IMultiSetArgs +{ + /// + /// Indicates whether the value is mandatory; i.e. a corresponding record must be read. + /// + public bool IsMandatory { get; set; } = isMandatory; + + /// + public int MinimumRows => IsMandatory ? 1 : 0; + + /// + public int? MaximumRows => 1; + + /// + public bool StopOnNull { get; set; } = stopOnNull; + + /// + public abstract void DatasetRecord(DatabaseRecord dr); + + /// + public virtual void InvokeResult() { } +} diff --git a/src/CoreEx.Database/Extended/MultiSetSingleArgsT.cs b/src/CoreEx.Database/Extended/MultiSetSingleArgsT.cs new file mode 100644 index 00000000..249cba65 --- /dev/null +++ b/src/CoreEx.Database/Extended/MultiSetSingleArgsT.cs @@ -0,0 +1,30 @@ +namespace CoreEx.Database.Extended; + +/// +/// Provides the multi-set arguments when expecting a single item/record only. +/// +/// The item . +/// The for the . +/// The action that will be invoked with the result of the set. +/// Indicates whether the value is mandatory; defaults to . +/// Indicates whether to stop further query result set processing where the current set has resulted in a null (i.e. no records). +public class MultiSetSingleArgs(IDatabaseMapper mapper, Action result, bool isMandatory = true, bool stopOnNull = false) : MultiSetSingleArgs(isMandatory, stopOnNull), IMultiSetArgs where T : class, new() +{ + private T? _value; + private readonly Action _result = result.ThrowIfNull(); + + /// + /// Gets the for the . + /// + public IDatabaseMapper Mapper { get; } = mapper.ThrowIfNull(); + + /// + public override void DatasetRecord(DatabaseRecord dr) => _value = Mapper.MapFromDb(dr); + + /// + public override void InvokeResult() + { + if (_value is not null) + _result(_value); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/RefDataLoader.cs b/src/CoreEx.Database/Extended/RefDataLoader.cs deleted file mode 100644 index 4a79ca15..00000000 --- a/src/CoreEx.Database/Extended/RefDataLoader.cs +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.RefData; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database.Extended -{ - /// - /// Provides dynamic loading capabilities. - /// - /// The . - /// The item . - /// The . - /// The . - public class RefDataLoader(DatabaseCommand command) - where TColl : class, IReferenceDataCollection, new() - where TItem : class, IReferenceData, new() - where TId : IComparable, IEquatable - { - - /// - /// Gets the . - /// - public DatabaseCommand Command { get; } = command.ThrowIfNull(nameof(command)); - - /// - /// Executes a dynamic query updating the . - /// - /// The . - /// The column name override; defaults to . - /// The additional properties action that enables non-standard properties to be updated from the . - /// The additional where additional datasets are returned. - /// The action to confirm whether the item is to be added (defaults to true). - /// The . - public async Task LoadAsync(TColl coll, string? idColumnName = null, Action? additionalProperties = null, IEnumerable? multiSetArgs = null, - Func? confirmItemIsToBeAdded = null, CancellationToken cancellationToken = default) - => (await LoadWithResultAsync(coll, idColumnName, additionalProperties, multiSetArgs, confirmItemIsToBeAdded, cancellationToken)).ThrowOnError(); - - /// - /// Executes a dynamic query updating the with a . - /// - /// The . - /// The column name override; defaults to . - /// The additional properties action that enables non-standard properties to be updated from the . - /// The additional where additional datasets are returned. - /// The action to confirm whether the item is to be added (defaults to true). - /// The . - public async Task LoadWithResultAsync(TColl coll, string? idColumnName = null, Action? additionalProperties = null, IEnumerable? multiSetArgs = null, - Func? confirmItemIsToBeAdded = null, CancellationToken cancellationToken = default) - { - coll.ThrowIfNull(nameof(coll)); - - var list = new List { new RefDataMultiSetCollArgs(Command.Database, coll.Add, idColumnName, additionalProperties, confirmItemIsToBeAdded) }; - if (multiSetArgs != null) - list.AddRange(multiSetArgs); - - var result = await Command.SelectMultiSetWithResultAsync(list, cancellationToken).ConfigureAwait(false); - return result.Then(() => Cleaner.Clean(coll)); - } - - /// - /// Executes a dynamic query. - /// - /// The column name override; defaults to . - /// The additional properties action that enables non-standard properties to be updated from the . - /// The additional where additional datasets are returned. - /// The action to confirm whether the item is to be added (defaults to true). - /// The . - /// The . - public async Task LoadAsync(string? idColumnName = null, Action? additionalProperties = null, IEnumerable? multiSetArgs = null, - Func? confirmItemIsToBeAdded = null, CancellationToken cancellationToken = default) - => (await LoadWithResultAsync(idColumnName, additionalProperties, multiSetArgs, confirmItemIsToBeAdded, cancellationToken)).ThrowOnError(); - - /// - /// Executes a dynamic query with a . - /// - /// The column name override; defaults to . - /// The additional properties action that enables non-standard properties to be updated from the . - /// The additional where additional datasets are returned. - /// The action to confirm whether the item is to be added (defaults to true). - /// The . - /// The . - public async Task> LoadWithResultAsync(string? idColumnName = null, Action? additionalProperties = null, IEnumerable? multiSetArgs = null, - Func? confirmItemIsToBeAdded = null, CancellationToken cancellationToken = default) - { - var coll = new TColl(); - var result = await LoadWithResultAsync(coll, idColumnName, additionalProperties, multiSetArgs, confirmItemIsToBeAdded, cancellationToken).ConfigureAwait(false); - return result.ThenAs(() => coll); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/RefDataMapper.cs b/src/CoreEx.Database/Extended/RefDataMapper.cs deleted file mode 100644 index 557e7615..00000000 --- a/src/CoreEx.Database/Extended/RefDataMapper.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.RefData; -using System; -using System.Collections.Generic; - -namespace CoreEx.Database.Extended -{ - /// - /// Represents a dynamic query-only mapper. - /// - /// The . - /// The . - internal class RefDataMapper : DatabaseQueryMapper where TItem : class, IReferenceData, new() where TId : IComparable, IEquatable - { - private readonly DatabaseColumns _cols; - private readonly string _idCol; - private readonly Action? _additionalProperties; - private Dictionary? _fields; - - /// - /// Initializes a new instanace of the . - /// - /// The . - /// The column name. - /// - public RefDataMapper(IDatabase database, string? idColumnName = null, Action? additionalProperties = null) - { - _cols = database.DatabaseColumns; - _idCol = idColumnName ?? _cols.RefDataIdName; - _additionalProperties = additionalProperties; - } - - /// - public override TItem MapFromDb(DatabaseRecord dr, OperationTypes operationType = OperationTypes.Unspecified) - { - if (_fields == null) - { - _fields = []; - for (var i = 0; i < dr.DataReader.FieldCount; i++) - { - _fields.Add(dr.DataReader.GetName(i), i); - } - - if (!_fields.ContainsKey(_idCol) || !_fields.ContainsKey(_cols.RefDataCodeName)) - throw new InvalidOperationException($"The reference data query must return as a minimum the Id and Code columns as per the configured names."); - } - - var item = new TItem() - { - Id = dr.GetValue(_idCol), - Code = dr.GetValue(_cols.RefDataCodeName), - Text = _fields.ContainsKey(_cols.RefDataTextName) ? dr.GetValue(_cols.RefDataTextName) : null, - Description = _fields.ContainsKey(_cols.RefDataDescriptionName) ? dr.GetValue(_cols.RefDataDescriptionName) : null, - SortOrder = _fields.ContainsKey(_cols.RefDataSortOrderName) ? dr.GetValue(_cols.RefDataSortOrderName) : 0, - IsActive = _fields.ContainsKey(_cols.RefDataIsActiveName) && dr.GetValue(_cols.RefDataIsActiveName), - StartDate = _fields.ContainsKey(_cols.RefDataStartDateName) ? dr.GetValue(_cols.RefDataStartDateName) : null, - EndDate = _fields.ContainsKey(_cols.RefDataEndDateName) ? dr.GetValue(_cols.RefDataEndDateName) : null, - ETag = _fields.ContainsKey(_cols.ETagName) ? dr.GetRowVersion(_cols.ETagName) : null - }; - - _additionalProperties?.Invoke(dr, item); - - return item; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/RefDataMultiSetCollArgs.cs b/src/CoreEx.Database/Extended/RefDataMultiSetCollArgs.cs deleted file mode 100644 index fbc4591a..00000000 --- a/src/CoreEx.Database/Extended/RefDataMultiSetCollArgs.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.RefData; -using System; - -namespace CoreEx.Database.Extended -{ - /// - /// Provides the . - /// - /// The . - /// The item . - /// The . - /// The . - /// The action that will be invoked with the result of each item. - /// The column name override; defaults to . - /// The additional properties action that enables non-standard properties to be updated from the . - /// The action to confirm whether the item is to be added (defaults to true). - internal class RefDataMultiSetCollArgs(IDatabase database, Action item, string? idColumnName = null, Action? additionalProperties = null, Func? confirmItemIsToBeAdded = null) : MultiSetCollArgs(stopOnNull: true) - where TColl : class, IReferenceDataCollection - where TItem : class, IReferenceData, new() - where TId : IComparable, IEquatable - { - private readonly Action _item = item; - private readonly RefDataMapper _refDataMapper = new(database, idColumnName, additionalProperties); - private readonly Func? _confirmItemIsToBeAdded = confirmItemIsToBeAdded; - - /// - public override void DatasetRecord(DatabaseRecord dr) - { - var rdi = _refDataMapper.MapFromDb(dr.ThrowIfNull(nameof(dr))); - if (_confirmItemIsToBeAdded == null || _confirmItemIsToBeAdded(dr, rdi)) - _item(rdi); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/GlobalUsing.cs b/src/CoreEx.Database/GlobalUsing.cs new file mode 100644 index 00000000..c9587964 --- /dev/null +++ b/src/CoreEx.Database/GlobalUsing.cs @@ -0,0 +1,30 @@ +global using CloudNative.CloudEvents.Extensions; +global using CoreEx.Abstractions; +global using CoreEx.Data; +global using CoreEx.Database.Abstractions; +global using CoreEx.Database.Extended; +global using CoreEx.Database.Mapping; +global using CoreEx.Entities; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Hosting; +global using CoreEx.Hosting.Synchronization; +global using CoreEx.Invokers; +global using CoreEx.Json; +global using CoreEx.Mapping.Converters.Abstractions; +global using CoreEx.RefData.Abstractions; +global using CoreEx.Results; +global using CoreEx.Wildcards; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.Diagnostics.HealthChecks; +global using Microsoft.Extensions.Logging; +global using OpenTelemetry; +global using System.Collections; +global using System.Data; +global using System.Data.Common; +global using System.Diagnostics; +global using System.Globalization; +global using System.Linq.Dynamic.Core; +global using System.Reflection; +global using System.Text; +global using System.Text.Json; \ No newline at end of file diff --git a/src/CoreEx.Database/HealthChecks/DatabaseHealthCheck.cs b/src/CoreEx.Database/HealthChecks/DatabaseHealthCheck.cs deleted file mode 100644 index ac5f7f2f..00000000 --- a/src/CoreEx.Database/HealthChecks/DatabaseHealthCheck.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database.HealthChecks -{ - /// - /// Provides a generic to verify the database is accessible by executing a simple SELECT 1 statement. - /// - /// The type. - /// The to health check. - public class DatabaseHealthCheck(TDatabase database) : IHealthCheck where TDatabase : IDatabase - { - private readonly IDatabase _database = database.ThrowIfNull(nameof(database)); - - /// - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - var conn = _database.GetConnection(); - var data = new Dictionary { { "database", conn.Database ?? "" }, { "dataSource", conn.DataSource ?? "" } }; - var result = await _database.SqlStatement("SELECT 1").NonQueryWithResultAsync(cancellationToken).ConfigureAwait(false); - return result.IsSuccess ? HealthCheckResult.Healthy(null, data) : new HealthCheckResult(context.Registration.FailureStatus, $"An unexpected database error has occurred.", result.Error, data); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/IDatabase.cs b/src/CoreEx.Database/IDatabase.cs index c2a9a4a2..73111387 100644 --- a/src/CoreEx.Database/IDatabase.cs +++ b/src/CoreEx.Database/IDatabase.cs @@ -1,130 +1,114 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Database.Extended; -using CoreEx.Entities; -using CoreEx.Json; -using CoreEx.Mapping.Converters; -using CoreEx.Results; -using Microsoft.Extensions.Logging; -using System; -using System.Data.Common; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Database +namespace CoreEx.Database; + +/// +/// Enables database (relational) access. +/// +public interface IDatabase { /// - /// Defines the database access. - /// - public interface IDatabase : IAsyncDisposable, IDisposable - { - /// - /// Gets the . - /// - DbProviderFactory Provider { get; } - - /// - /// Gets the . - /// - ILogger? Logger { get; } - - /// - /// Gets the . - /// - DatabaseInvoker Invoker { get; } - - /// - /// Gets the default used where not expliticly specified for an operation. - /// - DatabaseArgs DbArgs { get; } - - /// - /// Gets the unique database instance identifier. - /// - Guid DatabaseId { get; } - - /// - /// Gets or sets the to be used when retrieving (see ) a value from a . - /// - DateTimeTransform DateTimeTransform { get; set; } - - /// - /// Gets or sets the names of the pre-configured . - /// - DatabaseColumns DatabaseColumns { get; set; } - - /// - /// Gets or sets the to enable wildcard replacement. - /// - DatabaseWildcard Wildcard { get; set; } - - /// - /// Indicates whether the and - /// pass values via parameters. - /// - bool EnableChangeLogMapperToDb { get; } - - /// - /// Gets the converter. - /// - IConverter RowVersionConverter { get; } - - /// - /// Gets the used to automatically serialize complex objects and parameters types to JSON. - /// - /// See . - IJsonSerializer JsonSerializer => CoreEx.ExecutionContext.GetService() ?? CoreEx.Json.JsonSerializer.Default; - - /// - /// Gets the . - /// - /// The connection is created and opened on first use, and closed on or . - DbConnection GetConnection(); - - /// - /// Gets the . - /// - /// The connection is created and opened on first use, and closed on or . - Task GetConnectionAsync(CancellationToken cancellationToken = default); - - /// - /// Creates a stored procedure . - /// - /// The stored procedure name. - /// The . - DatabaseCommand StoredProcedure(string storedProcedure); - - /// - /// Creates a SQL statement . - /// - /// The SQL statement. - /// The . - DatabaseCommand SqlStatement(string sqlStatement); - - /// - /// Creates a SQL statement from the named embedded resource within the specified . - /// - /// The embedded resource name (matches to the end of the fully qualifed resource name). - /// The that contains the embedded resource; defaults to . - /// The . - DatabaseCommand SqlFromResource(string resourceName, Assembly? assembly = null); - - /// - /// Creates a SQL statement from the named embedded resource within the inferred from the . - /// - /// The to infer the that contains the embedded resource. - /// The embedded resource name (matches to the end of the fully qualifed resource name). - /// The . - DatabaseCommand SqlFromResource(string resourceName); - - /// - /// Invoked where a has been thrown. - /// - /// The . - /// The containing the appropriate where handled; otherwise, null indicating that the exception is unexpected and will continue to be thrown as such. - /// Provides an opportunity to inspect and handle the exception before it is returned. A resulting that is is not considered sensical; therefore, will result in the originating - /// exception being thrown. - Result? HandleDbException(DbException dbex); - } + /// Gets the . + /// + DbProviderFactory Provider { get; } + + /// + /// Gets the . + /// + ILogger? Logger { get; } + + /// + /// Gets the . + /// + DatabaseInvoker Invoker { get; } + + /// + /// Gets the default used where not expliticly specified for an operation. + /// + DatabaseArgs DbArgs { get; } + + /// + /// Gets the unique database instance identifier. + /// + string DatabaseId { get; } + + /// + /// Gets or sets the to be used when retrieving (see ) a value from a . + /// + DateTimeTransform DateTimeTransform { get; set; } + + /// + /// Indicates whether to transform a when adding as a parameter (see using the ). + /// + /// See for more information. As such, the default will typically be . + bool DateTimeOffsetTransform { get; set; } + + /// + /// Gets or sets the names of the pre-configured . + /// + DatabaseColumns NamedColumns { get; set; } + + /// + /// Gets or sets the to enable wildcard replacement. + /// + DatabaseWildcard Wildcard { get; set; } + + /// + /// Gets the converter. + /// + ISourceConverter RowVersionConverter { get; } + + /// + /// Gets the used to serialize database parameters to JSON. + /// + /// See . + JsonSerializerOptions JsonSerializerOptions { get; } + + /// + /// Gets the current , where one exists. + /// + DbTransaction? CurrentTransaction { get; } + + /// + /// Indicates whether a transaction is currently in progress. + /// + /// See . + bool IsInTransaction { get; } + + /// + /// Uses (overrides/resets) the . + /// + /// The . + /// Raises the event to ensure all interested parties are included (where applicable). + void UseTransaction(DbTransaction? transaction); + + /// + /// Raised when the results in an underlying change. + /// + event EventHandler? UseTransactionChanged; + + /// + /// Gets the . + /// + /// Gets the in its current state; to have it automatically opened use . + DbConnection Connection { get; } + + /// + /// Gets the . + /// + /// The connection will be automatically opened where not already open. The connection will not be closed, that is the responsibility of the caller. + Task GetConnectionAsync(CancellationToken cancellationToken = default); + + /// + /// Creates a for the . + /// + /// The . + /// The . + DatabaseCommand Statement(SqlStatement statement); + + /// + /// Invoked where a has been thrown. + /// + /// The . + /// The where handled (converted); otherwise, indicating that the exception is unexpected and will continue to be thrown/bubbled as such. + /// Provides an opportunity to inspect and convert the exception before it continues to bubble. + Exception? HandleDbException(DbException dbex); } \ No newline at end of file diff --git a/src/CoreEx.Database/IDatabaseMapper.cs b/src/CoreEx.Database/IDatabaseMapper.cs deleted file mode 100644 index f2cc5cb7..00000000 --- a/src/CoreEx.Database/IDatabaseMapper.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using System; - -namespace CoreEx.Database -{ - /// - /// Defines a database mapper. - /// - public interface IDatabaseMapper - { - /// - /// Gets the source being mapped from/to the database. - /// - Type SourceType { get; } - - /// - /// Maps from a creating a corresponding instance of the . - /// - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The corresponding instance of the . - object? MapFromDb(DatabaseRecord record, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToDb(object? value, DatabaseParameterCollection parameters, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps the and adds to the . - /// - /// The primary . - /// The . - /// This is used to map the only the key parameters; for example a Get or Delete operation. - void MapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/IDatabaseMapperT.cs b/src/CoreEx.Database/IDatabaseMapperT.cs deleted file mode 100644 index c936fe48..00000000 --- a/src/CoreEx.Database/IDatabaseMapperT.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using System; - -namespace CoreEx.Database -{ - /// - /// Defines a database mapper. - /// - /// The . - public interface IDatabaseMapper : IDatabaseMapper - { - /// - Type IDatabaseMapper.SourceType => typeof(TSource); - - /// - object? IDatabaseMapper.MapFromDb(DatabaseRecord record, OperationTypes operationType) => MapFromDb(record, operationType)!; - - /// - void IDatabaseMapper.MapToDb(object? value, DatabaseParameterCollection parameters, OperationTypes operationType) => MapToDb((TSource?)value, parameters, operationType); - - /// - /// Maps from a creating a corresponding instance of . - /// - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The corresponding instance of . - new TSource? MapFromDb(DatabaseRecord record, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToDb(TSource? value, DatabaseParameterCollection parameters, OperationTypes operationType = OperationTypes.Unspecified); - - /// - void IDatabaseMapper.MapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters) => throw new NotSupportedException(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/IDatabaseParameters.cs b/src/CoreEx.Database/IDatabaseParameters.cs deleted file mode 100644 index 5b363bfc..00000000 --- a/src/CoreEx.Database/IDatabaseParameters.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Database -{ - /// - /// Enables standardized access to the underlying . - /// - /// The owning . - public interface IDatabaseParameters - { - /// - /// Gets the . - /// - IDatabase Database { get; } - - /// - /// Gets the . - /// - DatabaseParameterCollection Parameters { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/IDatabaseParametersExtensions.cs b/src/CoreEx.Database/IDatabaseParametersExtensions.cs deleted file mode 100644 index c67cff36..00000000 --- a/src/CoreEx.Database/IDatabaseParametersExtensions.cs +++ /dev/null @@ -1,518 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Database.Mapping; -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.Mapping.Converters; -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; - -namespace CoreEx.Database -{ - /// - /// Provides extension methods. - /// - public static class IDatabaseParametersExtensions - { - /// - /// Add one or more parameters by invoking a delegate. - /// - /// The owning . - /// The . - /// The delegate to enable parameter addition. - /// The current instance to support chaining (fluent interface). - public static TSelf Params(this IDatabaseParameters parameters, Action action) - { - action.ThrowIfNull(nameof(action))(parameters.ThrowIfNull(nameof(parameters)).Parameters); - return (TSelf)parameters; - } - - /// - /// Adds the . - /// - /// The owning . - /// The . - /// The list. - /// The current instance to support chaining (fluent interface). - public static TSelf Params(this IDatabaseParameters parameters, IEnumerable list) - { - if (list != null && list != parameters.Parameters) - parameters.Parameters.AddRange(list); - - return (TSelf)parameters; - } - - #region Param - - /// - /// Adds the named parameter and value, using the specified , to the . - /// - /// The owning . - /// The . - /// The parameter name. - /// The parameter value. - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf Param(this IDatabaseParameters parameters, string name, object? value, ParameterDirection direction = ParameterDirection.Input) - { - parameters.ThrowIfNull(nameof(parameters)).Parameters.AddParameter(name, value, direction); - return (TSelf)parameters; - } - - /// - /// Adds the named parameter and value, using the specified and , to the . - /// - /// The owning . - /// The . - /// The parameter name. - /// The parameter value. - /// The parameter . - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf Param(this IDatabaseParameters parameters, string name, object? value, DbType dbType, ParameterDirection direction = ParameterDirection.Input) - { - parameters.ThrowIfNull(nameof(parameters)).Parameters.AddParameter(name, value, dbType, direction); - return (TSelf)parameters; - } - - /// - /// Adds the named parameter and value, using the specified and , to the . - /// - /// The owning . - /// The . - /// The parameter name. - /// The parameter . - /// The maximum size (in bytes). - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf Param(this IDatabaseParameters parameters, string name, DbType dbType, int size, ParameterDirection direction = ParameterDirection.Input) - { - parameters.ThrowIfNull(nameof(parameters)).Parameters.AddParameter(name, dbType, size, direction); - return (TSelf)parameters; - } - - /// - /// Adds the named parameter and value, using the specified , to the . - /// - /// The owning . - /// The . - /// The . - /// The parameter value. - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf Param(this IDatabaseParameters parameters, IPropertyColumnMapper mapper, object? value, ParameterDirection direction = ParameterDirection.Input) - => Param(parameters, mapper?.ParameterName!, value, direction); - - /// - /// Adds the named parameter and value, using the specified and , to the . - /// - /// The owning . - /// The . - /// The . - /// The parameter value. - /// The parameter . - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf Param(this IDatabaseParameters parameters, IPropertyColumnMapper mapper, object? value, DbType dbType, ParameterDirection direction = ParameterDirection.Input) - => Param(parameters, mapper?.ParameterName!, value, dbType, direction); - - /// - /// Adds the named parameter and value, using the specified and , to the . - /// - /// The owning . - /// The . - /// The . - /// The parameter . - /// The maximum size (in bytes). - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf Param(this IDatabaseParameters parameters, IPropertyColumnMapper mapper, DbType dbType, int size, ParameterDirection direction = ParameterDirection.Input) - => Param(parameters, mapper?.ParameterName!, dbType, size, direction); - - /// - /// Adds the named parameter and value serialized as a JSON to the . - /// - /// The owning . - /// The . - /// The parameter name. - /// The parameter value. - /// The to support fluent-style method-chaining. - public static TSelf JsonParam(this IDatabaseParameters parameters, string name, object? value) - { - parameters.ThrowIfNull(nameof(parameters)).Parameters.AddJsonParameter(name, value); - return (TSelf)parameters; - } - - #endregion - - #region ParamWhen - - /// - /// Adds a named parameter and value true. - /// - /// The owning . - /// The parameter . - /// The . - /// Adds the parameter when true. - /// The parameter name. - /// The parameter value. - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf ParamWhen(this IDatabaseParameters parameters, bool? when, string name, Func value, ParameterDirection direction = ParameterDirection.Input) - { - value.ThrowIfNull(nameof(value)); - - if (when == true) - parameters.ThrowIfNull(nameof(parameters)).Parameters.AddParameter(name, value(), direction); - - return (TSelf)parameters; - } - - /// - /// Adds a named parameter and value true. - /// - /// The owning . - /// The parameter . - /// The . - /// Adds the parameter when true. - /// The parameter name. - /// The parameter value. - /// The parameter . - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf ParamWhen(this IDatabaseParameters parameters, bool? when, string name, Func value, DbType dbType, ParameterDirection direction = ParameterDirection.Input) - { - value.ThrowIfNull(nameof(value)); - - if (when == true) - parameters.ThrowIfNull(nameof(parameters)).Parameters.AddParameter(name, value(), dbType, direction); - - return (TSelf)parameters; - } - - /// - /// Adds a named parameter and value true. - /// - /// The owning . - /// The parameter . - /// The . - /// Adds the parameter when true. - /// The . - /// The parameter value. - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf ParamWhen(this IDatabaseParameters parameters, bool? when, IPropertyColumnMapper mapper, Func value, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, when, mapper?.ParameterName!, value, direction); - - /// - /// Adds a named parameter and value true. - /// - /// The owning . - /// The parameter . - /// The . - /// Adds the parameter when true. - /// The . - /// The parameter value. - /// The parameter . - /// The (default to ). - /// The to support fluent-style method-chaining. - public static TSelf ParamWhen(this IDatabaseParameters parameters, bool? when, IPropertyColumnMapper mapper, Func value, DbType dbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, when, mapper?.ParameterName!, value, dbType, direction); - - /// - /// Adds a named parameter and value serialized as a JSON true. - /// - /// The owning . - /// The parameter . - /// The . - /// Adds the parameter when true. - /// The parameter name. - /// The parameter value. - /// The to support fluent-style method-chaining. - public static TSelf JsonParamWhen(this IDatabaseParameters parameters, bool? when, string name, Func value) - { - value.ThrowIfNull(nameof(value)); - - if (when == true) - parameters.ThrowIfNull(nameof(parameters)).Parameters.AddJsonParameter(name, value()); - - return (TSelf)parameters; - } - - #endregion - - #region ParamWith - - /// - /// Adds a named parameter when invoked a non- value. - /// - /// The owning . - /// The parameter . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The parameter name. - /// The parameter value. - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, TWith? with, string name, Func value, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with != null && Comparer.Default.Compare(with, default!) != 0, name, value, direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The parameter name. - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, string name, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with != null && Comparer.Default.Compare(with, default!) != 0, name, () => with!, direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The parameter name. - /// The parameter value. - /// 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, DbType dbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with != null && Comparer.Default.Compare((T)with, default!) != 0, name, value, dbType, direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The parameter name. - /// The parameter value; where not specified the value will be used. - /// The parameter . - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, string name, Func? value, DbType dbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with != null && Comparer.Default.Compare(with, default!) != 0, name, value ?? (() => with!), dbType, direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The . - /// The parameter value. - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, object? with, IPropertyColumnMapper mapper, Func value, ParameterDirection direction = ParameterDirection.Input) - => ParamWith(parameters, with, mapper, value, direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The . - /// The parameter value; where not specified the value will be used. - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, IPropertyColumnMapper mapper, Func? value = null, ParameterDirection direction = ParameterDirection.Input) - => ParamWith(parameters, with, mapper, value ?? (() => with!), direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The . - /// The parameter value. - /// The parameter . - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, object? with, IPropertyColumnMapper mapper, Func value, DbType dbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWith(parameters, with, mapper, value, dbType, direction); - - /// - /// Adds a named parameter when invoked a non-default value. - /// - /// The owning . - /// The parameter . - /// The . - /// The value with which to verify is non-default. - /// The . - /// The parameter value; where not specified the value will be used. - /// The parameter . - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, IPropertyColumnMapper mapper, Func? value, DbType dbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWith(parameters, with, mapper, value ?? (() => with!), dbType, direction); - - /// - /// Adds a named parameter when invoked a non-default value serialized as a JSON . - /// - /// The owning . - /// The with value . - /// The parameter value . - /// The . - /// The value with which to verify is non-default. - /// The parameter name. - /// The parameter value. - /// The current instance to support chaining (fluent interface). - public static TSelf JsonParamWith(this IDatabaseParameters parameters, TWith? with, string name, Func value) - => JsonParamWhen(parameters, with != null && Comparer.Default.Compare(with, default!) != 0, name, value); - - #endregion - - #region ParamWithWildcard - - /// - /// Adds a named parameter when invoked with a non-default (converted for the database). - /// - /// The owning . - /// The . - /// The wildcard with which to verify is non-default and apply. - /// The parameter name. - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWithWildcard(this IDatabaseParameters parameters, string? wildcard, string name, ParameterDirection direction = ParameterDirection.Input) - => ParamWith(parameters, wildcard, name, () => parameters.Database.Wildcard.Replace(wildcard), direction); - - /// - /// Adds a named parameter when invoked with a non-default (converted for the database). - /// - /// The owning . - /// The . - /// The wildcard with which to verify is non-default and apply. - /// The . - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf ParamWithWildcard(this IDatabaseParameters parameters, string? wildcard, IPropertyColumnMapper mapper, ParameterDirection direction = ParameterDirection.Input) - => ParamWithWildcard(parameters, wildcard, mapper?.ParameterName!, direction); - - #endregion - - #region RowVersionParam - - /// - /// Adds a named parameter with a RowVersion value. - /// - /// The owning . - /// The . - /// The parameter name. - /// The row version value. - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf RowVersionParam(this IDatabaseParameters parameters, string name, string? value, ParameterDirection direction = ParameterDirection.Input) - => Param(parameters, name ?? parameters.Database.DatabaseColumns.RowVersionName, parameters.Database.RowVersionConverter.ConvertToDestination(value), direction); - - /// - /// Adds a named parameter with a RowVersion value. - /// - /// The owning . - /// The . - /// The . - /// The row version value. - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf RowVersionParam(this IDatabaseParameters parameters, IPropertyColumnMapper mapper, string? value, ParameterDirection direction = ParameterDirection.Input) - => RowVersionParam(parameters, mapper?.ParameterName!, value, direction); - - /// - /// Adds a named () parameter with a RowVersion value. - /// - /// The owning . - /// The . - /// The row version value. - /// The (default to ). - /// The current instance to support chaining (fluent interface). - public static TSelf RowVersionParam(this IDatabaseParameters parameters, string? value, ParameterDirection direction = ParameterDirection.Input) - => RowVersionParam(parameters, parameters.Database.DatabaseColumns.RowVersionName, value, direction); - - #endregion - - #region ReselectRecordParam - - /// - /// Adds a named parameter () to the data. - /// - /// The owning . - /// The . - /// Indicates whether to reselect after the operation. - /// The current instance to support chaining (fluent interface). - public static TSelf ReselectRecordParam(this IDatabaseParameters parameters, bool reselect = true) - { - parameters.ThrowIfNull(nameof(parameters)).Parameters.AddReselectRecordParam(reselect); - return (TSelf)parameters; - } - - /// - /// Adds a named parameter () to the data true. - /// - /// The owning . - /// The . - /// Adds the parameter when true. - /// Indicates whether to reselect after the operation. - /// The current instance to support chaining (fluent interface). - public static TSelf ReselectRecordParamWhen(this IDatabaseParameters parameters, bool? when, bool reselect = true) - { - if (when == true) - ReselectRecordParam(parameters, reselect); - - return (TSelf)parameters; - } - - #endregion - - #region PagingParams - - /// - /// Adds the as parameters. - /// - /// The owning . - /// The . - /// The . - /// The current instance to support chaining (fluent interface). - public static TSelf PagingParams(this IDatabaseParameters parameters, PagingArgs? paging) - { - if (paging != null) - { - parameters.Param(parameters.Database.DatabaseColumns.PagingSkipName, paging.Skip); - parameters.Param(parameters.Database.DatabaseColumns.PagingTakeName, paging.Take); - parameters.ParamWhen(paging.IsGetCount, parameters.Database.DatabaseColumns.PagingCountName, () => paging.IsGetCount); - } - - return (TSelf)parameters; - } - - #endregion - - /// - /// Sets the to when the is . - /// - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The to support fluent-style method-chaining. - /// Where not then the will remain unchanged. - public static DbParameter SetDirectionToOutputOnCreate(this DbParameter parameter, OperationTypes operationType) - { - if (operationType == OperationTypes.Create) - parameter.Direction = ParameterDirection.Output; - - return parameter; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/IMultiSetArgs.cs b/src/CoreEx.Database/IMultiSetArgs.cs deleted file mode 100644 index 7cbc4028..00000000 --- a/src/CoreEx.Database/IMultiSetArgs.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Database -{ - /// - /// Enables the Database multi-set arguments - /// - public interface IMultiSetArgs - { - /// - /// Gets the minimum number of rows allowed. - /// - int MinRows { get; } - - /// - /// Gets the maximum number of rows allowed. - /// - int? MaxRows { get; } - - /// - /// Indicates whether to stop further query result set processing where the current set has resulted in a null (i.e. no records). - /// - bool StopOnNull { get; } - - /// - /// The method invoked for each record for its respective dataset. - /// - /// The . - void DatasetRecord(DatabaseRecord dr); - - /// - /// Invokes the corresponding result function. - /// - void InvokeResult(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/IMultiSetArgsT.cs b/src/CoreEx.Database/IMultiSetArgsT.cs deleted file mode 100644 index 785d9ff7..00000000 --- a/src/CoreEx.Database/IMultiSetArgsT.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Database -{ - /// - /// Enables the Database multi-set arguments with a . - /// - /// The item . - public interface IMultiSetArgs : IMultiSetArgs where T : class, new() - { - /// - /// Gets the for the . - /// - IDatabaseMapper Mapper { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/ChangeLogDatabaseMapper.cs b/src/CoreEx.Database/Mapping/ChangeLogDatabaseMapper.cs deleted file mode 100644 index b403ebf5..00000000 --- a/src/CoreEx.Database/Mapping/ChangeLogDatabaseMapper.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using System; - -namespace CoreEx.Database.Mapping -{ - /// - /// Represents a . - /// - public readonly struct ChangeLogDatabaseMapper : IDatabaseMapper - { - private static readonly Lazy _default = new(() => new(), true); - - /// - /// Gets the default (singleton) instance. - /// - public static ChangeLogDatabaseMapper Default => _default.Value; - - /// - public ChangeLog? MapFromDb(DatabaseRecord record, OperationTypes operationType = OperationTypes.Unspecified) - { - if (OperationTypes.AnyExceptGet.HasFlag(operationType)) - { - var changeLog = new ChangeLog - { - CreatedBy = record.GetValue(record.Database.DatabaseColumns.CreatedByName), - CreatedDate = record.GetValue(record.Database.DatabaseColumns.CreatedDateName), - UpdatedBy = record.GetValue(record.Database.DatabaseColumns.UpdatedByName), - UpdatedDate = record.GetValue(record.Database.DatabaseColumns.UpdatedDateName) - }; - - return ((IChangeLogAudit)changeLog).IsInitial ? null : changeLog; - } - - return null; - } - - /// - public void MapToDb(ChangeLog? value, DatabaseParameterCollection parameters, OperationTypes operationType = OperationTypes.Unspecified) - { - if (value == null || !parameters.Database.EnableChangeLogMapperToDb) - return; - - if (OperationTypes.AnyExceptUpdate.HasFlag(operationType)) - { - parameters.Param(parameters.Database.DatabaseColumns.CreatedByName, value.CreatedBy); - parameters.Param(parameters.Database.DatabaseColumns.CreatedDateName, value.CreatedDate); - } - - if (OperationTypes.AnyExceptCreate.HasFlag(operationType)) - { - parameters.Param(parameters.Database.DatabaseColumns.UpdatedByName, value.UpdatedBy); - parameters.Param(parameters.Database.DatabaseColumns.UpdatedDateName, value.UpdatedDate); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/ChangeLogExDatabaseMapper.cs b/src/CoreEx.Database/Mapping/ChangeLogExDatabaseMapper.cs deleted file mode 100644 index c6fa88ff..00000000 --- a/src/CoreEx.Database/Mapping/ChangeLogExDatabaseMapper.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities.Extended; -using CoreEx.Mapping; -using System; - -namespace CoreEx.Database.Mapping -{ - /// - /// Represents a . - /// - public readonly struct ChangeLogExDatabaseMapper : IDatabaseMapper - { - private static readonly Lazy _default = new(() => new(), true); - - /// - /// Gets the default (singleton) instance. - /// - public static ChangeLogExDatabaseMapper Default => _default.Value; - - /// - public ChangeLogEx? MapFromDb(DatabaseRecord record, OperationTypes operationType = OperationTypes.Unspecified) - { - if (OperationTypes.AnyExceptGet.HasFlag(operationType)) - { - var ChangeLogEx = new ChangeLogEx - { - CreatedBy = record.GetValue(record.Database.DatabaseColumns.CreatedByName), - CreatedDate = record.GetValue(record.Database.DatabaseColumns.CreatedDateName), - UpdatedBy = record.GetValue(record.Database.DatabaseColumns.UpdatedByName), - UpdatedDate = record.GetValue(record.Database.DatabaseColumns.UpdatedDateName) - }; - - return ((Entities.IChangeLogAudit)ChangeLogEx).IsInitial ? null : ChangeLogEx; - } - - return null; - } - - /// - public void MapToDb(ChangeLogEx? value, DatabaseParameterCollection parameters, OperationTypes operationType = OperationTypes.Unspecified) - { - if (value == null || !parameters.Database.EnableChangeLogMapperToDb) - return; - - if (OperationTypes.AnyExceptUpdate.HasFlag(operationType)) - { - parameters.Param(parameters.Database.DatabaseColumns.CreatedByName, value.CreatedBy); - parameters.Param(parameters.Database.DatabaseColumns.CreatedDateName, value.CreatedDate); - } - - if (OperationTypes.AnyExceptCreate.HasFlag(operationType)) - { - parameters.Param(parameters.Database.DatabaseColumns.UpdatedByName, value.UpdatedBy); - parameters.Param(parameters.Database.DatabaseColumns.UpdatedDateName, value.UpdatedDate); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/DatabaseMapper.cs b/src/CoreEx.Database/Mapping/DatabaseMapper.cs index 45f18a76..63703674 100644 --- a/src/CoreEx.Database/Mapping/DatabaseMapper.cs +++ b/src/CoreEx.Database/Mapping/DatabaseMapper.cs @@ -1,36 +1,184 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Database.Mapping; -using CoreEx.Entities; -using CoreEx.Mapping; -using System; - -namespace CoreEx.Database.Mapping +/// +/// Provides utility capabilities for database mapping. +/// +public static class DatabaseMapper { /// - /// Enables or of a . + /// Maps the database record values to the standard properties of the specified item. + /// + /// The item . + /// The item to map into. + /// The . + /// The value being performed to enable conditional execution where appropriate. + /// Indicates whether the underlying column value is strictly required or not. + /// The to support fluent-style method-chaining. + /// Standard properties are mapped based on whether the implements the following respectively: + /// + /// -> + /// -> + /// -> + /// * -> + /// * -> + /// + /// + public static TItem MapStandardFromDb(this TItem item, DatabaseRecord record, OperationType operationType = OperationType.Unspecified, bool strict = true) + { + MapStandardFromDb(record, item.ThrowIfNull(), operationType, strict); + return item; + } + + /// + /// Maps the database record values to the standard properties of the specified item. + /// + /// The item . + /// The . + /// The item to map into. + /// The value being performed to enable conditional execution where appropriate. + /// Indicates whether the underlying column value is strictly required or not. + /// Standard properties are mapped based on whether the implements the following respectively: + /// + /// -> + /// -> + /// -> + /// * -> + /// * -> + /// + /// + public static void MapStandardFromDb(DatabaseRecord record, TItem? item, OperationType operationType = OperationType.Unspecified, bool strict = true) + { + if (record is null || item is null) + return; + + if (item is IETag et) + et.ETag = strict || record.TryGetOrdinal(record.Database.NamedColumns.RowVersionName, out _) ? record.GetRowVersion() : default; + + if (item is ITenantId ti) + ti.TenantId = strict ? record.GetValue(record.Database.NamedColumns.TenantIdName) : record.GetValueOrDefault(record.Database.NamedColumns.TenantIdName); + + if (item is ILogicallyDeleted ld) + ld.IsDeleted = (strict ? record.GetValue(record.Database.NamedColumns.IsDeletedName) : record.GetValueOrDefault(record.Database.NamedColumns.IsDeletedName)) ?? false; + + MapChangeLogFromDb(record, item, operationType, strict); + } + + /// + /// Maps the database record values to the standard change log properties of the specified item. + /// + /// The item . + /// The . + /// The item to map into. + /// The value being performed to enable conditional execution where appropriate. + /// Indicates whether the underlying column value is strictly required or not. + /// The to support fluent-style method-chaining. + public static TItem MapChangeLogFromDb(this TItem item, DatabaseRecord record, OperationType operationType = OperationType.Unspecified, bool strict = true) + { + MapChangeLogFromDb(record, item.ThrowIfNull(), operationType, strict); + return item; + } + + /// + /// Maps the database record values to the standard change log properties of the specified item. /// - public static class DatabaseMapper + /// The item . + /// The . + /// The item to map into. + /// The value being performed to enable conditional execution where appropriate. + /// Indicates whether the underlying column value is strictly required or not. + public static void MapChangeLogFromDb(DatabaseRecord record, TItem? item, OperationType operationType = OperationType.Unspecified, bool strict = true) { - /// - /// Creates a where properties are added manually (leverages reflection). - /// - /// A . - /// Where performance is critical consider using . - public static DatabaseMapper Create() where TSource : class, new() => new(false); - - /// - /// Creates a where properties are added automatically using reflection (assumes the property, column and parameter names share the same name). - /// - /// An array of source property names to ignore. - /// A . - /// Where performance is critical consider using . - public static DatabaseMapper CreateAuto(params string[] ignoreSrceProperties) where TSource : class, new() => new(true, ignoreSrceProperties); - - /// - /// Creates a where the underlying implementation is added explicitly (extended, offers potential performance benefits). - /// - /// A . - public static DatabaseMapperEx CreateExtended(Action? mapFromDb = null, Action? mapKeyToDb = null, Action? mapToDb = null) where TSource : class, new() - => new(mapFromDb, mapKeyToDb, mapToDb); + if (record is null || item is null) + return; + + if (item is IChangeLog cl) + { + if (cl.ChangeLog is null) + { + // Only update where there is a legit value. + var changeLog = new ChangeLog(); + MapChangeLogFromDb(record, changeLog, operationType, strict); + if (!changeLog.IsDefault()) + cl.ChangeLog = changeLog; + } + else + MapChangeLogFromDb(record, cl.ChangeLog, operationType, strict); + } + + if (item is not IChangeLogEx cle) + return; + + cle.CreatedBy = strict ? record.GetValue(record.Database.NamedColumns.CreatedByName) : record.GetValueOrDefault(record.Database.NamedColumns.CreatedByName); + cle.CreatedOn = strict ? record.GetValue(record.Database.NamedColumns.CreatedOnName) : record.GetValueOrDefault(record.Database.NamedColumns.CreatedOnName); + cle.UpdatedBy = strict ? record.GetValue(record.Database.NamedColumns.UpdatedByName) : record.GetValueOrDefault(record.Database.NamedColumns.UpdatedByName); + cle.UpdatedOn = strict ? record.GetValue(record.Database.NamedColumns.UpdatedOnName) : record.GetValueOrDefault(record.Database.NamedColumns.UpdatedOnName); + } + + /// + /// Maps the standard properties of the specified item to the database parameters. + /// + /// The item . + /// The item value. + /// The to update from the . + /// The value being performed to enable conditional execution where appropriate. + /// Standard properties are mapped based on whether the implements the following respectively: + /// + /// -> + /// -> + /// -> + /// -> * + /// -> * + /// + /// + public static void MapStandardToDb(TItem item, DatabaseParameterCollection parameters, OperationType operationType = OperationType.Unspecified) + { + if (item is null) + return; + + if (item is IReadOnlyETag et) + parameters.AddParameter(parameters.Database.NamedColumns.RowVersionName, et.ETag); + + if (item is IReadOnlyTenantId ti) + parameters.AddParameter(parameters.Database.NamedColumns.TenantIdName, ti.TenantId); + + if (item is IReadOnlyLogicallyDeleted ld) + parameters.AddParameter(parameters.Database.NamedColumns.IsDeletedName, ld.IsDeleted); + + MapChangeLogToDb(item, parameters, operationType); + } + + /// + /// Maps the change log properties of the specified item to the database parameters. + /// + /// The item . + /// The item value. + /// The to update from the . + /// The value being performed to enable conditional execution where appropriate. + /// This maps both and using the corresponding the . + public static void MapChangeLogToDb(TItem item, DatabaseParameterCollection parameters, OperationType operationType = OperationType.Unspecified) + { + if (item is null) + return; + + if (item is IChangeLog cl && cl.ChangeLog is not null) + { + MapChangeLogToDb(cl.ChangeLog, parameters, operationType); + return; + } + + if (item is IChangeLogEx cle) + { + if (operationType == OperationType.Unspecified || operationType == OperationType.Create) + { + parameters.AddParameter(parameters.Database.NamedColumns.CreatedByName, cle.CreatedBy); + parameters.AddParameter(parameters.Database.NamedColumns.CreatedOnName, cle.CreatedOn); + } + + if (operationType == OperationType.Unspecified || operationType == OperationType.Update) + { + parameters.AddParameter(parameters.Database.NamedColumns.UpdatedByName, cle.UpdatedBy); + parameters.AddParameter(parameters.Database.NamedColumns.UpdatedOnName, cle.UpdatedOn); + } + } } } \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/DatabaseMapperExT.cs b/src/CoreEx.Database/Mapping/DatabaseMapperExT.cs deleted file mode 100644 index 97f1efd2..00000000 --- a/src/CoreEx.Database/Mapping/DatabaseMapperExT.cs +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using System; - -namespace CoreEx.Database.Mapping -{ - /// - /// Provides mapping from a and database with the extended/explicitly provided logic. - /// - /// The source . - /// The implementation. - /// The implementation. - /// The implementation. - /// This enables the most optimized performance by enabling explicit code to be specified. - public class DatabaseMapperEx(Action? mapFromDb = null, Action? mapKeyToDb = null, Action? mapToDb = null) : IDatabaseMapperEx where TSource : class, new() - { - private readonly Action? _mapFromDb = mapFromDb; - private readonly Action? _mapToDb = mapToDb; - private readonly Action? _mapKeyToDb = mapKeyToDb; - private IDatabaseMapperEx? _extendMapper; - - /// - /// Indicates that a null should be returned from where the resulting value . - /// - /// Defaults to true. - public bool NullWhenInitial { get; set; } = true; - - /// - /// Inherits (includes) the mappings from the selected . - /// - /// The source ; must inherit from . - /// The to inherit from. - /// The to support fluent-style method-chaining. - public DatabaseMapperEx InheritMapper(IDatabaseMapperEx baseMapper) where TBase : class, new() - { - if (_extendMapper is not null) - throw new InvalidOperationException($"An {nameof(InheritMapper)} may only be invoked once for a mapper."); - - if (!typeof(TSource).IsSubclassOf(typeof(TBase))) - throw new ArgumentException($"Type {typeof(TSource).Name} must inherit from {typeof(TBase).Name}.", nameof(baseMapper)); - - _extendMapper = baseMapper.ThrowIfNull(nameof(baseMapper)); - return this; - } - - /// - public void MapFromDb(DatabaseRecord record, TSource value, OperationTypes operationType) - { - record.ThrowIfNull(nameof(record)); - value.ThrowIfNull(nameof(value)); - - _extendMapper?.MapFromDb(record, value, operationType); - _mapFromDb?.Invoke(record, value, operationType); - OnMapFromDb(record, value, operationType); - } - - /// - public TSource? MapFromDb(DatabaseRecord record, OperationTypes operationType = OperationTypes.Unspecified) - { - var value = new TSource(); - MapFromDb(record, value, operationType); - return NullWhenInitial ? ((value is not null && value is IInitial ii && ii.IsInitial) ? null : value) : null; - } - - /// - /// Extension opportunity when performing a . - /// - /// The source value. - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The source value. - protected virtual void OnMapFromDb(DatabaseRecord record, TSource value, OperationTypes operationType) { } - - /// - public void MapToDb(TSource? value, DatabaseParameterCollection parameters, OperationTypes operationType = OperationTypes.Unspecified) - { - parameters.ThrowIfNull(nameof(parameters)); - if (value is not null) - { - _extendMapper?.MapToDb(value, parameters, operationType); - _mapToDb?.Invoke(value, parameters, operationType); - OnMapToDb(value, parameters, operationType); - } - } - - /// - /// Extension opportunity when performing a . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - protected virtual void OnMapToDb(TSource value, DatabaseParameterCollection parameters, OperationTypes operationType) { } - - /// - void IDatabaseMapper.MapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters) - { - parameters.ThrowIfNull(nameof(parameters)); - _extendMapper?.MapKeyToDb(key, parameters); - _mapKeyToDb?.Invoke(key, parameters); - OnMapKeyToDb(key, parameters); - } - - /// - /// Extension opportunity when performing a . - /// - /// The primary . - /// The . - /// This is used to map the only the key parameters; for example a Get or Delete operation. - protected virtual void OnMapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters) { } - - #region When* - - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenGet(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Get, operationType, action); - - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenCreate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Create, operationType, action); - - /// - /// When is an then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenUpdate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Update, operationType, action); - - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenDelete(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Delete, operationType, action); - - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenAnyExceptGet(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.AnyExceptGet, operationType, action); - - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenAnyExceptCreate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.AnyExceptCreate, operationType, action); - - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenAnyExceptUpdate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.AnyExceptUpdate, operationType, action); - - /// - /// When the matches the then the is invoked. - /// - private static void WhenOperationType(OperationTypes expectedOperationTypes, OperationTypes operationType, Action action) - { - if (expectedOperationTypes.HasFlag(operationType)) - action?.Invoke(); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/DatabaseMapperExT2.cs b/src/CoreEx.Database/Mapping/DatabaseMapperExT2.cs deleted file mode 100644 index 8e22e36c..00000000 --- a/src/CoreEx.Database/Mapping/DatabaseMapperExT2.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Database.Mapping -{ - /// - /// Provides with a singleton . - /// - /// The source . - /// The mapper . - public abstract class DatabaseMapperEx : DatabaseMapperEx where TSource : class, new() where TMapper : DatabaseMapperEx, new() - { - private static readonly TMapper _default = new(); - - /// - /// Gets the current instance of the mapper. - /// - public static TMapper Default => _default ?? throw new InvalidOperationException("An instance of this Mapper cannot be referenced as it is still being constructed; beware that you may have a circular reference within the constructor."); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/DatabaseMapperT.cs b/src/CoreEx.Database/Mapping/DatabaseMapperT.cs index 853e192c..046a5e08 100644 --- a/src/CoreEx.Database/Mapping/DatabaseMapperT.cs +++ b/src/CoreEx.Database/Mapping/DatabaseMapperT.cs @@ -1,289 +1,21 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; -using CoreEx.Mapping; -using System; -using System.Collections.Generic; -using System.Data; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; - -namespace CoreEx.Database.Mapping +namespace CoreEx.Database.Mapping; + +/// +/// Provides a using optional mapping functions. +/// +/// The resulting item . +/// The optional to mapping function. +/// The optional to mapping action. +/// The and +/// methods will throw a unless overridden or provided via the constructor. +public class DatabaseMapper(Func? mapFromDb = null, Action? mapToDb = null) : IDatabaseMapper { - /// - /// Provides mapping from a and database using reflection. - /// - /// The source . - /// Where performance is critical consider using . - public class DatabaseMapper : IDatabaseMapper, IDatabaseMapperMappings where TSource : class, new() - { - private readonly List _mappings = []; - - /// - /// Initializes a new instance of the class. - /// - /// Indicates whether the entity should automatically map all public get/set properties, where the property, column and parameter names are all assumed to share the same name. - /// An array of source property names to ignore. - public DatabaseMapper(bool autoMap = false, params string[] ignoreSrceProperties) - { - if (typeof(TSource) == typeof(string)) throw new InvalidOperationException("TSource must not be a String."); - - if (autoMap) - AutomagicallyMap(ignoreSrceProperties); - } - - /// - IEnumerable IDatabaseMapperMappings.Mappings => _mappings.AsEnumerable(); - - /// - public IPropertyColumnMapper this[string propertyName] => TryGetProperty(propertyName, out var pcm) ? pcm : throw new ArgumentException($"Property '{propertyName}' does not exist.", nameof(propertyName)); - - /// - /// Gets the for the specified source . - /// - /// The to reference the source property. - /// The where found. - /// Thrown when the property does not exist. - public IPropertyColumnMapper this[Expression> propertyExpression] - { - get - { - propertyExpression.ThrowIfNull(nameof(propertyExpression)); - - MemberExpression? me = null; - if (propertyExpression.Body.NodeType == ExpressionType.MemberAccess) - me = propertyExpression.Body as MemberExpression; - else if (propertyExpression.Body.NodeType == ExpressionType.Convert) - { - if (propertyExpression.Body is UnaryExpression ue) - me = ue.Operand as MemberExpression; - } - - if (me == null) - throw new InvalidOperationException("Only Member access expressions are supported."); - - return this[me.Member.Name]; - } - } - - /// - public bool TryGetProperty(string propertyName, [NotNullWhen(true)] out IPropertyColumnMapper? propertyColumnMapper) - { - propertyColumnMapper = _mappings.Where(x => x.PropertyName == propertyName).FirstOrDefault(); - return propertyColumnMapper != null; - } - - /// - public string GetParameterName(string propertyName) => this[propertyName].ParameterName; - - /// - public string GetColumnName(string propertyName) => this[propertyName].ColumnName; - - /// - /// Automatically add each public get/set property. - /// - private void AutomagicallyMap(string[] ignoreSrceProperties) - { - foreach (var sp in TypeReflector.GetProperties(typeof(TSource))) - { - // Do not auto-map where ignore has been specified. - if (ignoreSrceProperties.Contains(sp.Name)) - continue; - - // Create the lambda expression for the property and add to the mapper. - var spe = Expression.Parameter(typeof(TSource), "x"); - var sex = Expression.Lambda(Expression.Property(spe, sp), spe); - typeof(DatabaseMapper) - .GetMethod("Property", BindingFlags.Public | BindingFlags.Instance)! - .MakeGenericMethod([sp.PropertyType]) - .Invoke(this, [sex, null, null, OperationTypes.Any]); - } - } - - /// - /// Adds a to the mapper. - /// - /// The source property . - /// The to reference the source property. - /// The database column name. Defaults to name. - /// The database parameter name. Defaults to prefixed with '@'. - /// The selection to enable inclusion or exclusion of property. - /// The . - public PropertyColumnMapper Property(Expression> propertyExpression, string? columnName = null, string? parameterName = null, OperationTypes operationTypes = OperationTypes.Any) - { - var pcm = new PropertyColumnMapper(propertyExpression, columnName, parameterName, operationTypes); - AddMapping(pcm); - return pcm; - } - - /// - /// Validates and adds a new IPropertyColumnMapper. - /// - private void AddMapping(PropertyColumnMapper propertyColumnMapper) - { - if (_mappings.Any(x => x.PropertyName == propertyColumnMapper.PropertyName)) - throw new ArgumentException($"Source property '{propertyColumnMapper.PropertyName}' must not be specified more than once.", nameof(propertyColumnMapper)); - - if (_mappings.Any(x => x.ColumnName == propertyColumnMapper.ColumnName)) - throw new ArgumentException($"Column '{propertyColumnMapper.ColumnName}' must not be specified more than once.", nameof(propertyColumnMapper)); - - if (_mappings.Any(x => x.ParameterName == propertyColumnMapper.ParameterName)) - throw new ArgumentException($"Parameter '{propertyColumnMapper.ParameterName}' must not be specified more than once.", nameof(propertyColumnMapper)); - - _mappings.Add(propertyColumnMapper); - } - - /// - /// Adds or updates to the mapper. - /// - /// The source property . - /// The to reference the source property. - /// The database column name. Defaults to name. - /// The database parameter name. Defaults to prefixed with '@'. - /// The selection to enable inclusion or exclusion of property. - /// An enabling access to the created . - /// - /// Where updating an existing the , and where specified will override the previous values. - public DatabaseMapper HasProperty(Expression> propertyExpression, string? columnName = null, string? parameterName = null, OperationTypes? operationTypes = null, Action>? property = null) - { - var tmp = new PropertyColumnMapper(propertyExpression, columnName, parameterName, operationTypes ?? OperationTypes.Any); - var pcm = _mappings.Where(x => x.PropertyName == tmp.PropertyName).OfType>().SingleOrDefault(); - if (pcm == null) - AddMapping(pcm = tmp); - else - { - if (columnName != null && tmp.ColumnName != pcm.ColumnName) - { - if (_mappings.Any(x => x.ColumnName == pcm.ColumnName)) - throw new ArgumentException($"Column '{pcm.ColumnName}' must not be specified more than once.", nameof(columnName)); - else - pcm.ColumnName = tmp.ColumnName; - } - - if (parameterName != null && tmp.ParameterName != pcm.ParameterName) - { - if (_mappings.Any(x => x.ParameterName == tmp.ParameterName)) - throw new ArgumentException($"Parameter '{pcm.ParameterName}' must not be specified more than once.", nameof(parameterName)); - else - pcm.ParameterName = tmp.ParameterName; - } - - if (operationTypes != null) - pcm.OperationTypes = operationTypes.Value; - } - - property?.Invoke(pcm); - return this; - } - - /// - /// Inherits the property mappings from the selected . - /// - /// The source . Must inherit from . - /// The to inherit from. Must also implement . - public void InheritPropertiesFrom(IDatabaseMapper inheritMapper) where T : class, new() - { - inheritMapper.ThrowIfNull(nameof(inheritMapper)); - if (!typeof(TSource).IsSubclassOf(typeof(T))) throw new ArgumentException($"Type {typeof(TSource).Name} must inherit from {typeof(T).Name}.", nameof(inheritMapper)); - if (inheritMapper is not IDatabaseMapperMappings inheritMappings) throw new ArgumentException($"Type {typeof(T).Name} must implement {typeof(IDatabaseMapperMappings).Name} to copy the mappings.", nameof(inheritMapper)); - - var pe = Expression.Parameter(typeof(TSource), "x"); - var type = typeof(DatabaseMapper<>).MakeGenericType(typeof(TSource)); - - foreach (var p in inheritMappings.Mappings) - { - var lex = Expression.Lambda(Expression.Property(pe, p.PropertyName), pe); - var pmap = (IPropertyColumnMapper)type - .GetMethod("Property", BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)! - .MakeGenericMethod(p.PropertyType) - .Invoke(this, [lex, p.ColumnName, p.ParameterName, p.OperationTypes])!; - - if (p.IsPrimaryKey) - pmap.SetPrimaryKey(p.IsPrimaryKeyGeneratedOnCreate); - - if (p.DbType != null) - pmap.SetDbType(p.DbType.Value); - - if (p.Converter != null) - pmap.SetConverter(p.Converter); - - if (p.Mapper != null) - pmap.SetMapper(p.Mapper); - } - } - - /// - public void MapToDb(TSource? value, DatabaseParameterCollection parameters, OperationTypes operationType = OperationTypes.Unspecified) - { - parameters.ThrowIfNull(nameof(parameters)); - if (value == null) return; - - foreach (var p in _mappings) - { - p.MapToDb(value, parameters, operationType); - } - - OnMapToDb(value, parameters, operationType); - } - - /// - /// Extension opportunity when performing a . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - protected virtual void OnMapToDb(TSource value, DatabaseParameterCollection parameters, OperationTypes operationType) { } - - /// - public TSource? MapFromDb(DatabaseRecord record, OperationTypes operationType = OperationTypes.Unspecified) - { - record.ThrowIfNull(nameof(record)); - var value = new TSource(); - - foreach (var p in _mappings) - { - p.MapFromDb(record, value, operationType); - } - - value = OnMapFromDb(value, record, operationType); - return (value != null && value is IInitial ii && ii.IsInitial) ? null : value; - } - - /// - /// Extension opportunity when performing a . - /// - /// The source value. - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The source value. - protected virtual TSource? OnMapFromDb(TSource value, DatabaseRecord record, OperationTypes operationType) => value; - - /// - /// Converts the property value for the database. - /// - private static object? ConvertPropertyValueForDb(IPropertyColumnMapper pcm, object? value) => pcm.Converter == null ? value : pcm.Converter.ConvertToDestination(value); - - /// - void IDatabaseMapper.MapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters) - { - parameters.ThrowIfNull(nameof(parameters)); - var pk = _mappings.Where(x => x.IsPrimaryKey).ToArray(); - if (key.Args == null || key.Args.Length != pk.Length) - throw new ArgumentException($"The number of keys supplied must equal the number of properties identified as {nameof(IPropertyColumnMapper.IsPrimaryKey)}.", nameof(key)); + private readonly Func? _mapFromDb = mapFromDb; + private readonly Action? _mapToDb = mapToDb; - for (int i = 0; i < key.Args.Length; i++) - { - var p = pk[i]; - var pval = DatabaseMapper.ConvertPropertyValueForDb(p, key.Args[i]); + /// + public virtual TItem MapFromDb(DatabaseRecord record, OperationType operationType) => (_mapFromDb ?? throw new NotImplementedException())(record, operationType); - if (p.DbType.HasValue) - parameters.AddParameter(p.ParameterName, pval, p.DbType.Value); - else - parameters.AddParameter(p.ParameterName, pval); - } - } - } + /// + public virtual void MapToDb(TItem? value, DatabaseParameterCollection parameters, OperationType operationType) => (_mapToDb ?? throw new NotImplementedException())(value, parameters, operationType); } \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/DatabaseMapperT2.cs b/src/CoreEx.Database/Mapping/DatabaseMapperT2.cs deleted file mode 100644 index a2a01d7b..00000000 --- a/src/CoreEx.Database/Mapping/DatabaseMapperT2.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Database.Mapping -{ - /// - /// Provides with a singleton . - /// - /// The source . - /// The mapper . - /// Where performance is critical consider using . - public abstract class DatabaseMapper : DatabaseMapper where TSource : class, new() where TMapper : DatabaseMapper, new() - { - private static readonly TMapper _default = new(); - - /// - /// Gets the current instance of the mapper. - /// - public static TMapper Default => _default ?? throw new InvalidOperationException("An instance of this Mapper cannot be referenced as it is still being constructed; beware that you may have a circular reference within the constructor."); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/IDatabaseMapper.cs b/src/CoreEx.Database/Mapping/IDatabaseMapper.cs new file mode 100644 index 00000000..fc85c387 --- /dev/null +++ b/src/CoreEx.Database/Mapping/IDatabaseMapper.cs @@ -0,0 +1,36 @@ +namespace CoreEx.Database.Mapping; + +/// +/// Enables an mapper. +/// +public interface IDatabaseMapper +{ + /// + /// Gets the item being mapped from/to the database. + /// + Type ItemType { get; } + + /// + /// Maps from a creating a corresponding instance of the . + /// + /// The . + /// The value being performed to enable conditional execution where appropriate. + /// The corresponding instance of the . + object? MapFromDb(DatabaseRecord record, OperationType operationType = OperationType.Unspecified); + + /// + /// Maps from a updating the . + /// + /// The value. + /// The to update from the . + /// The value being performed to enable conditional execution where appropriate. + void MapToDb(object? value, DatabaseParameterCollection parameters, OperationType operationType = OperationType.Unspecified); + + /// + /// Maps the adding the corresponding . + /// + /// The primary . + /// The . + /// This is used to map only the key parameters; for example, used for a Get or Delete operation. + void MapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters); +} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/IDatabaseMapperEx.cs b/src/CoreEx.Database/Mapping/IDatabaseMapperEx.cs deleted file mode 100644 index 55dd8e9f..00000000 --- a/src/CoreEx.Database/Mapping/IDatabaseMapperEx.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; - -namespace CoreEx.Database.Mapping -{ - /// - /// Defines a database extended/explicit mapper. - /// - public interface IDatabaseMapperEx : IDatabaseMapper - { - /// - /// Maps from a into the - /// - /// The . - /// The value to map into. - /// The single value being performed to enable conditional execution where appropriate. - void MapFromDb(DatabaseRecord record, object value, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/IDatabaseMapperExT.cs b/src/CoreEx.Database/Mapping/IDatabaseMapperExT.cs deleted file mode 100644 index bb5ac910..00000000 --- a/src/CoreEx.Database/Mapping/IDatabaseMapperExT.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; - -namespace CoreEx.Database.Mapping -{ - /// - /// Defines a database extended/explicit mapper. - /// - /// The . - public interface IDatabaseMapperEx : IDatabaseMapperEx, IDatabaseMapper - { - /// - void IDatabaseMapperEx.MapFromDb(DatabaseRecord record, object value, OperationTypes operationType) - => MapFromDb(record, (TSource)value, operationType); - - /// - /// Maps from a into the - /// - /// The . - /// The value to extend map into. - /// The single value being performed to enable conditional execution where appropriate. - /// The updated . - void MapFromDb(DatabaseRecord record, TSource value, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/IDatabaseMapperMappings.cs b/src/CoreEx.Database/Mapping/IDatabaseMapperMappings.cs deleted file mode 100644 index 856ea6b9..00000000 --- a/src/CoreEx.Database/Mapping/IDatabaseMapperMappings.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.Database.Mapping -{ - /// - /// Gets the mappings. - /// - public interface IDatabaseMapperMappings - { - /// - /// Gets the mappings. - /// - IEnumerable Mappings { get; } - - /// - /// Gets the from the for the specified . - /// - /// The property name. - /// The where found. - /// Thrown when the property does not exist. - IPropertyColumnMapper this[string propertyName] { get; } - - /// - /// Attempts to get the for the specified . - /// - /// The source property name. - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - bool TryGetProperty(string propertyName, [NotNullWhen(true)] out IPropertyColumnMapper? propertyColumnMapper); - - /// - /// Gets the . - /// - /// The source property name. - /// The where found. - /// Thrown when the property does not exist. - string GetParameterName(string propertyName); - - /// - /// Gets the . - /// - /// The source property name. - /// The where found. - /// Thrown when the property does not exist. - string GetColumnName(string propertyName); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/IDatabaseMapperT.cs b/src/CoreEx.Database/Mapping/IDatabaseMapperT.cs new file mode 100644 index 00000000..984d1c9d --- /dev/null +++ b/src/CoreEx.Database/Mapping/IDatabaseMapperT.cs @@ -0,0 +1,36 @@ +namespace CoreEx.Database.Mapping; + +/// +/// Enables an mapper. +/// +/// The . +public interface IDatabaseMapper : IDatabaseMapper +{ + /// + Type IDatabaseMapper.ItemType => typeof(TItem); + + /// + object? IDatabaseMapper.MapFromDb(DatabaseRecord record, OperationType operationType) => MapFromDb(record, operationType)!; + + /// + void IDatabaseMapper.MapToDb(object? value, DatabaseParameterCollection parameters, OperationType operationType) => MapToDb((TItem?)value, parameters, operationType); + + /// + /// Maps from a creating a corresponding instance of . + /// + /// The . + /// The value being performed to enable conditional execution where appropriate. + /// The corresponding instance of . + new TItem? MapFromDb(DatabaseRecord record, OperationType operationType = OperationType.Unspecified); + + /// + /// Maps from a updating the . + /// + /// The value. + /// The to update from the . + /// The value being performed to enable conditional execution where appropriate. + void MapToDb(TItem? value, DatabaseParameterCollection parameters, OperationType operationType = OperationType.Unspecified); + + /// + void IDatabaseMapper.MapKeyToDb(CompositeKey key, DatabaseParameterCollection parameters) => throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/IPropertyColumnMapper.cs b/src/CoreEx.Database/Mapping/IPropertyColumnMapper.cs deleted file mode 100644 index 485ffea7..00000000 --- a/src/CoreEx.Database/Mapping/IPropertyColumnMapper.cs +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Mapping; -using CoreEx.Mapping.Converters; -using System; -using System.Data; -using System.Data.Common; - -namespace CoreEx.Database.Mapping -{ - /// - /// Enables bi-directional property and database column mapping. - /// - public interface IPropertyColumnMapper - { - /// - /// Gets the . - /// - IPropertyExpression PropertyExpression { get; } - - /// - /// Gets the source property name. - /// - string PropertyName { get; } - - /// - /// Gets the source property . - /// - Type PropertyType { get; } - - /// - /// Indicates whether the underlying source property is a complex type. - /// - bool IsSrcePropertyComplex { get; } - - /// - /// Gets the destination database column name. - /// - string ColumnName { get; } - - /// - /// Gets the destination . - /// - string ParameterName { get; } - - /// - /// Gets the selection to enable inclusion or exclusion of property (default to ). - /// - OperationTypes OperationTypes { get; } - - /// - /// Indicates whether the property forms part of the primary key. - /// - bool IsPrimaryKey { get; } - - /// - /// Indicates whether the primary key value is generated on create. - /// - bool IsPrimaryKeyGeneratedOnCreate { get; } - - /// - /// Sets the primary key ( and ). - /// - /// Indicates whether the column value is generated on create (defaults to true). - void SetPrimaryKey(bool generatedOnCreate = true); - - /// - /// Gets the . - /// - DbType? DbType { get; } - - /// - /// Sets the . - /// - /// The - void SetDbType(DbType dbType); - - /// - /// Gets the (used where a specific source and destination type conversion is required). - /// - IConverter? Converter { get; } - - /// - /// Sets the . - /// - /// The . - /// The and are mutually exclusive. - void SetConverter(IConverter converter); - - /// - /// Gets the to map complex types. - /// - IDatabaseMapper? Mapper { get; } - - /// - /// Set the to map complex types. - /// - /// The . - /// The and are mutually exclusive. - void SetMapper(IDatabaseMapper mapper); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToDb(object value, DatabaseParameterCollection parameters, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The . - /// The value. - /// The single value being performed to enable conditional execution where appropriate. - void MapFromDb(DatabaseRecord record, object value, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Mapping/PropertyColumnMapper.cs b/src/CoreEx.Database/Mapping/PropertyColumnMapper.cs deleted file mode 100644 index ed296914..00000000 --- a/src/CoreEx.Database/Mapping/PropertyColumnMapper.cs +++ /dev/null @@ -1,229 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Mapping; -using CoreEx.Mapping.Converters; -using System; -using System.Data; -using System.Linq.Expressions; - -namespace CoreEx.Database.Mapping -{ - /// - /// Provides bi-directional property and database column mapping. - /// - /// The source entity . - /// The corresponding source property . - public class PropertyColumnMapper : IPropertyColumnMapper where TSource : class, new() - { - private readonly PropertyExpression _propertyExpression; - - /// - /// Initializes a new instance of the class. - /// - /// The to reference the source property. - /// The database column name. Defaults to name. - /// The database parameter name. Defaults to prefixed with '@'. - /// The selection to enable inclusion or exclusion of property. - public PropertyColumnMapper(Expression> propertyExpression, string? columnName = null, string? parameterName = null, OperationTypes operationTypes = OperationTypes.Any) - { - _propertyExpression = Abstractions.Reflection.PropertyExpression.Create(propertyExpression); - ColumnName = columnName ?? PropertyName; - ParameterName = parameterName ?? $"@{columnName ?? PropertyName}"; - OperationTypes = operationTypes; - } - - /// - public IPropertyExpression PropertyExpression => _propertyExpression; - - /// - public string PropertyName => _propertyExpression.Name; - - /// - public Type PropertyType => typeof(TSourceProperty); - - /// - public bool IsSrcePropertyComplex => throw new NotImplementedException(); - - /// - public string ColumnName { get; internal set; } - - /// - public string ParameterName { get; internal set; } - - /// - public OperationTypes OperationTypes { get; internal set; } - - /// - public bool IsPrimaryKey { get; private set; } - - /// - public bool IsPrimaryKeyGeneratedOnCreate { get; private set; } - - /// - public DbType? DbType { get; private set; } - - /// - public IConverter? Converter { get; private set; } - - /// - public IDatabaseMapper? Mapper { get; private set; } - - /// - void IPropertyColumnMapper.SetDbType(DbType dbType) => SetDbType(dbType); - - /// - /// Sets the . - /// - /// The - /// The to support fluent-style method-chaining. - public PropertyColumnMapper SetDbType(DbType dbType) - { - DbType = dbType; - return this; - } - - /// - void IPropertyColumnMapper.SetConverter(IConverter converter) - { - converter.ThrowIfNull(nameof(converter)); - - if (Mapper != null) - throw new InvalidOperationException("The Mapper and Converter cannot be both set; only one is permissible."); - - if (converter.SourceType != typeof(TSourceProperty)) - throw new InvalidOperationException($"The PropertyType '{PropertyType.Name}' and IConverter.SourceType '{converter.SourceType.Name}' must match."); - - Converter = converter; - } - - /// - /// Sets the . - /// - /// The . - /// The to support fluent-style method-chaining. - /// The and are mutually exclusive. - public PropertyColumnMapper SetConverter(IConverter converter) - { - ((IPropertyColumnMapper)this).SetConverter(converter); - return this; - } - - /// - void IPropertyColumnMapper.SetMapper(IDatabaseMapper mapper) - { - mapper.ThrowIfNull(nameof(mapper)); - - if (Converter != null) - throw new InvalidOperationException("The Mapper and Converter cannot be both set; only one is permissible."); - - if (!_propertyExpression.IsClass) - throw new InvalidOperationException($"The PropertyType '{PropertyType.Name}' must be a class to set a Mapper."); - - if (mapper.SourceType != typeof(TSourceProperty)) - throw new ArgumentException($"The PropertyType '{PropertyType.Name}' and IDatabaseMapper.SourceType '{mapper.SourceType.Name}' must match.", nameof(mapper)); - - if (IsPrimaryKey) - throw new InvalidOperationException("A Mapper can not be set for a primary key."); - - Mapper = mapper; - } - - /// - /// Sets the . - /// - /// The . - /// The to support fluent-style method-chaining. - /// The and are mutually exclusive. - public PropertyColumnMapper SetMapper(IDatabaseMapper mapper) - { - ((IPropertyColumnMapper)this).SetMapper(mapper); - return this; - } - - /// - void IPropertyColumnMapper.SetPrimaryKey(bool generatedOnCreate) - { - if (Mapper != null) throw new InvalidOperationException("A primary key must not contain a Mapper."); - - IsPrimaryKey = true; - IsPrimaryKeyGeneratedOnCreate = generatedOnCreate; - } - - /// - /// Sets the primary key ( and ). - /// - /// Indicates whether the column value is generated on create. Defaults to true. - /// The to support fluent-style method-chaining. - public PropertyColumnMapper SetPrimaryKey(bool generatedOnCreate = true) - { - ((IPropertyColumnMapper)this).SetPrimaryKey(generatedOnCreate); - return this; - } - - /// - void IPropertyColumnMapper.MapToDb(object? value, DatabaseParameterCollection parameters, OperationTypes operationType) => MapToDb((TSource?)value, parameters, operationType); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - private void MapToDb(TSource? value, DatabaseParameterCollection parameters, OperationTypes operationType) - { - if (value == null || !OperationTypes.HasFlag(operationType)) - return; - - if (parameters.Contains(ParameterName)) - return; - - var val = _propertyExpression.GetValue(value); - if (Mapper != null) - { - if (val != null) - Mapper.MapToDb(val, parameters, operationType); - } - else - { - if (DbType.HasValue) - parameters.AddParameter(ParameterName, Converter == null ? val : Converter.ConvertToDestination(val), DbType.Value); - else - parameters.AddParameter(ParameterName, Converter == null ? val : Converter.ConvertToDestination(val)); - } - } - - /// - void IPropertyColumnMapper.MapFromDb(DatabaseRecord record, object value, OperationTypes operationType) => MapFromDb(record, (TSource)value, operationType); - - /// - /// Maps from a updating the . - /// - /// The . - /// The value. - /// The single value being performed to enable conditional execution where appropriate. - private void MapFromDb(DatabaseRecord record, TSource value, OperationTypes operationType) - { - if (!OperationTypes.HasFlag(operationType)) - return; - - TSourceProperty? pval; - if (Mapper != null) - pval = (TSourceProperty?)Mapper.MapFromDb(record, operationType); - else - { - if (!record.IsDBNull(ColumnName, out var ordinal)) - { - if (Converter == null) - pval = record.GetValue(ordinal); - else - pval = (TSourceProperty)Converter.ConvertToSource(record.DataReader.GetValue(ordinal))!; - } - else - pval = default; - } - - _propertyExpression.SetValue(value, pval); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/MultiSetArgs.cs b/src/CoreEx.Database/MultiSetArgs.cs deleted file mode 100644 index c2a901b9..00000000 --- a/src/CoreEx.Database/MultiSetArgs.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Collections.Generic; -using System.Linq; - -namespace CoreEx.Database -{ - /// - /// Provides helpers. - /// - public static class MultiSetArgs - { - /// - /// Creates an . - /// - /// The arguments. - /// The . - public static IEnumerable Create(params IMultiSetArgs[] args) => args.AsEnumerable(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/MultiSetCollArgs.cs b/src/CoreEx.Database/MultiSetCollArgs.cs deleted file mode 100644 index 3d4cd9d7..00000000 --- a/src/CoreEx.Database/MultiSetCollArgs.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Database -{ - /// - /// Provides the base Database multi-set arguments when expecting a collection of items/records. - /// - public abstract class MultiSetCollArgs : IMultiSetArgs - { - /// - /// Initializes a new instance of the class. - /// - /// The minimum number of rows allowed. - /// The maximum number of rows allowed. - /// Indicates whether to stop further query result set processing where the current set has resulted in a null (i.e. no records). - public MultiSetCollArgs(int minRows = 0, int? maxRows = null, bool stopOnNull = false) - { - if (maxRows.HasValue && minRows <= maxRows.Value) - throw new ArgumentException("Max Rows is less than Min Rows.", nameof(maxRows)); - - MinRows = minRows; - MaxRows = maxRows; - StopOnNull = stopOnNull; - } - - /// - /// Gets the minimum number of rows allowed. - /// - public int MinRows { get; } - - /// - /// Gets the maximum number of rows allowed. - /// - public int? MaxRows { get; } - - /// - /// Indicates whether to stop further query result set processing where the current set has resulted in a null (i.e. no records). - /// - public bool StopOnNull { get; set; } - - /// - /// The method invoked for each record for its respective dataset. - /// - /// The . - public abstract void DatasetRecord(DatabaseRecord dr); - - /// - /// Invokes the corresponding result function. - /// - public virtual void InvokeResult() { } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/MultiSetCollArgsT.cs b/src/CoreEx.Database/MultiSetCollArgsT.cs deleted file mode 100644 index 0d3dec8b..00000000 --- a/src/CoreEx.Database/MultiSetCollArgsT.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; - -namespace CoreEx.Database -{ - /// - /// Provides the Database multi-set arguments when expecting a collection of items/records. - /// - /// The collection . - /// The item . - /// The for the . - /// The action that will be invoked with the result of the set. - /// The minimum number of rows allowed. - /// The maximum number of rows allowed. - /// Indicates whether to stop further query result set processing where the current set has resulted in a null (i.e. no records). - public class MultiSetCollArgs(IDatabaseMapper mapper, Action result, int minRows = 0, int? maxRows = null, bool stopOnNull = false) : MultiSetCollArgs(minRows, maxRows, stopOnNull), IMultiSetArgs - where TItem : class, new() - where TColl : class, ICollection, new() - { - private TColl? _coll; - private readonly Action _result = result.ThrowIfNull(nameof(result)); - - /// - /// Gets the for the . - /// - public IDatabaseMapper Mapper { get; private set; } = mapper.ThrowIfNull(nameof(mapper)); - - /// - /// The method invoked for each record for its respective dataset. - /// - /// The . - public override void DatasetRecord(DatabaseRecord dr) - { - dr.ThrowIfNull(nameof(dr)); - _coll ??= new TColl(); - - var item = Mapper.MapFromDb(dr); - if (item != null) - _coll.Add(item); - } - - /// - /// Invokes the corresponding result function. - /// - public override void InvokeResult() - { - if (_coll != null) - _result(_coll); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/MultiSetSingleArgs.cs b/src/CoreEx.Database/MultiSetSingleArgs.cs deleted file mode 100644 index 7dabf5aa..00000000 --- a/src/CoreEx.Database/MultiSetSingleArgs.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Database -{ - /// - /// Provides the base Database multi-set arguments when expecting a single item/record only. - /// - /// Indicates whether the value is mandatory; defaults to true. - /// Indicates whether to stop further query result set processing where the current set has resulted in a null (i.e. no records). - public abstract class MultiSetSingleArgs(bool isMandatory = true, bool stopOnNull = false) : IMultiSetArgs - { - /// - /// Indicates whether the value is mandatory; i.e. a corresponding record must be read. - /// - public bool IsMandatory { get; set; } = isMandatory; - - /// - /// Gets the minimum number of rows allowed. - /// - public int MinRows => IsMandatory ? 1 : 0; - - /// - /// Gets the maximum number of rows allowed. - /// - public int? MaxRows => 1; - - /// - /// Indicates whether to stop further query result set processing where the current set has resulted in a null (i.e. no records). - /// - public bool StopOnNull { get; set; } = stopOnNull; - - /// - /// The method invoked for each record for its respective dataset. - /// - /// The . - public abstract void DatasetRecord(DatabaseRecord dr); - - /// - /// Invokes the corresponding result function. - /// - public virtual void InvokeResult() { } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/MultiSetSingleArgsT.cs b/src/CoreEx.Database/MultiSetSingleArgsT.cs deleted file mode 100644 index 609c3bfa..00000000 --- a/src/CoreEx.Database/MultiSetSingleArgsT.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Database -{ - /// - /// Provides the Database multi-set arguments when expecting a single item/record only. - /// - /// The item . - /// The for the . - /// The action that will be invoked with the result of the set. - /// Indicates whether the value is mandatory; defaults to true. - /// Indicates whether to stop further query result set processing where the current set has resulted in a null (i.e. no records). - public class MultiSetSingleArgs(IDatabaseMapper mapper, Action result, bool isMandatory = true, bool stopOnNull = false) : MultiSetSingleArgs(isMandatory, stopOnNull), IMultiSetArgs - where T : class, new() - { - private T? _value; - private readonly Action _result = result.ThrowIfNull(nameof(result)); - - /// - /// Gets the for the . - /// - public IDatabaseMapper Mapper { get; private set; } = mapper.ThrowIfNull(nameof(mapper)); - - /// - /// The method invoked for each record for its respective dataset. - /// - /// The . - public override void DatasetRecord(DatabaseRecord dr) => _value = Mapper.MapFromDb(dr); - - /// - /// Invokes the corresponding result function. - /// - public override void InvokeResult() - { - if (_value != null) - _result(_value); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxPublisherBase.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxPublisherBase.cs new file mode 100644 index 00000000..335e0360 --- /dev/null +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxPublisherBase.cs @@ -0,0 +1,112 @@ +namespace CoreEx.Database.Outbox; + +/// +/// Provides the base to be used as a transactional outbox. +/// +/// The . +/// The optional . +/// The optional . +/// The optional . +public abstract class DatabaseOutboxPublisherBase(TDatabase database, IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger>? logger = null) + : EventPublisherBase(destinationProvider, formatter, logger) where TDatabase : IDatabase +{ + /// + /// Gets the underlying . + /// + protected TDatabase Database { get; } = database.ThrowIfNull(); + + /// + /// Gets or sets the used to persist each event to the underlying outbox store. + /// + /// Defaults to . + public virtual SqlStatement Statement { get; set; } = SqlStatement.None; + + /// + /// Gets or sets the maximum number of statements to include in a single batch operation. + /// + /// Defaults to '10'. + /// Increasing the batch size may improve performance by reducing the number of round trips; however, larger batches may require more memory and could result in a timeout or other resource-related issues. + /// This is only leveraged where the is a of ; otherwise, they are executed individually. + public int StatementBatchSize { get; set => field = value.ThrowIfLessThanOrEqualToZero(); } = 10; + + /// + /// Gets or sets the partition size to use when calculating the partition id for each event. + /// + /// This is used to ensure that events with the same partition key are stored in the same partition, which guarantees that events are processed in order within a partition. + public int PartitionSize { get; set; } = PartitionKey.DefaultPartitionSize; + + /// + protected async override Task OnPublishAsync(DestinationEvent[] events, CancellationToken cancellationToken = default) + { + var utc = Runtime.UtcNow.UtcDateTime; + var ec = ExecutionContext.HasCurrent ? ExecutionContext.Current : null; + + // Where the statement is not a stored procedure, we will execute the statement for each. + if (Statement.CommandType != CommandType.StoredProcedure) + { + foreach (var de in events) + { + var ce = de.Event; + var pk = de.Event.GetPartitionKey(); + var partitionId = PartitionKey.GetPartitionId(string.IsNullOrEmpty(pk) ? Guid.NewGuid().ToString() : pk, PartitionSize); + + await Database.Statement(Statement) + .ParamWhen(!string.IsNullOrEmpty(ec?.TenantId), Database.NamedColumns.TenantIdName, () => ec!.TenantId) + .Param(Database.NamedColumns.PartitionIdName, partitionId) + .Param(Database.NamedColumns.OutboxDestinationName, de.Destination) + .Param(Database.NamedColumns.OutboxEventName, de.Event.EncodeToJsonElement()) + .Param(Database.NamedColumns.OutboxEnqueuedUtcName, utc) + .NonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + return; + }; + + // For each into batch of up to 'StatementBatchSize' events to persist, to eek out better performance by reducing the number of round trips to the database. + foreach (var chunk in events.Chunk(StatementBatchSize)) + { + var sb = new StringBuilder(); + var dpc = new DatabaseParameterCollection(Database); + + for (var i = 0; i < chunk.Length; i++) + { + // Add each event to the batch statement and parameters collection. + var ce = chunk[i].Event; + var pk = chunk[i].Event.GetPartitionKey(); + var partitionId = PartitionKey.GetPartitionId(string.IsNullOrEmpty(pk) ? Guid.NewGuid().ToString() : pk, PartitionSize); + + sb.Append($"EXEC {Statement.CommandText} "); + + if (!string.IsNullOrEmpty(ec?.TenantId)) + AddParameter(sb, dpc, Database.NamedColumns.TenantIdName, ec!.TenantId, i); + + AddParameter(sb, dpc, Database.NamedColumns.PartitionIdName, partitionId, i); + AddParameter(sb, dpc, Database.NamedColumns.OutboxDestinationName, chunk[i].Destination, i); + AddParameter(sb, dpc, Database.NamedColumns.OutboxEventName, chunk[i].Event.EncodeToJsonElement(), i); + AddParameter(sb, dpc, Database.NamedColumns.OutboxEnqueuedUtcName, utc, i, false); + } + + if (Logger?.IsEnabled(LogLevel.Debug) is true) + Logger.LogDebug("Executing batch statement to persist {Count} event(s) as a single database command.", chunk.Length); + + // Execute the batch statement with the parameters collection. + var ds = Database.Statement(new SqlStatement(CommandType.Text, sb.ToString())); + ds.Parameters.AddRange(dpc); + await ds.NonQueryAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Adds the parameter to the and . + /// + private static void AddParameter(StringBuilder sb, DatabaseParameterCollection dpc, string name, object? value, int index, bool appendComma = true) + { + sb.Append(DatabaseParameterCollection.ParameterizeName(name)).Append(" = ").Append(DatabaseParameterCollection.ParameterizeName($"{name}_{index}")); + if (appendComma) + sb.Append(", "); + else + sb.AppendLine(";"); + + dpc.AddParameter($"{name}_{index}", value); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayArgs.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayArgs.cs new file mode 100644 index 00000000..17eb7acc --- /dev/null +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayArgs.cs @@ -0,0 +1,27 @@ +namespace CoreEx.Database.Outbox; + +/// +/// Provides the arguments. +/// +public class DatabaseOutboxRelayArgs +{ + /// + /// Gets the . + /// + public required PartitionPicker PartitionPicker { get; init; } + + /// + /// Gets the batch size. + /// + public int BatchSize { get; init => field = value <= 0 ? 1 : value; } + + /// + /// Gets the lease duration used to lock when claiming a batch. + /// + public TimeSpan LeaseDuration { get; init; } + + /// + /// Gets the backoff duration used to push out availability of the underlying event within the outbox when cancelling a batch. + /// + public TimeSpan BackOffDuration { get; init; } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs new file mode 100644 index 00000000..2aedb741 --- /dev/null +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs @@ -0,0 +1,311 @@ +namespace CoreEx.Database.Outbox; + +/// +/// Provides the base transactional outbox relay using the destination . +/// +/// The . +/// The instance (self) . +/// The is used by the constructor to default where possible. +public abstract class DatabaseOutboxRelayBase : IDatabaseOutboxRelay where TDatabase : IDatabase where TSelf : DatabaseOutboxRelayBase +{ + private readonly Lazy> _invoker = new(() => new DatabaseOutboxRelayInvoker()); + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The destination . + /// The optional . + public DatabaseOutboxRelayBase(TDatabase database, IEventPublisher eventPublisher, ILogger>? logger = null) + { + Database = database.ThrowIfNull(); + EventPublisher = eventPublisher.ThrowIfNull(); + Logger = logger; + + // Default the statements by convention. + SetStatementsByConvention(); + } + + /// + /// Gets the underlying . + /// + protected TDatabase Database { get; } + + /// + /// Gets the destination . + /// + protected IEventPublisher EventPublisher { get; } + + /// + /// Gets the . + /// + protected ILogger? Logger { get; } + + /// + /// Gets the . + /// + protected DatabaseOutboxRelayInvoker Invoker => _invoker.Value; + + /// + /// Gets or sets the used to claim the next batch of events from the . + /// + /// Defaults to . + public virtual SqlStatement ClaimBatchStatement { get; set; } = SqlStatement.None; + + /// + /// Gets or sets the used to complete the batch of events from the . + /// + /// Defaults to . + public virtual SqlStatement CompleteBatchStatement { get; set; } = SqlStatement.None; + + /// + /// Gets or sets the used to cancel the batch of events from the . + /// + /// Defaults to . + public virtual SqlStatement CancelBatchStatement { get; set; } = SqlStatement.None; + + /// + /// Indicates whether instrumentation is enabled for the polling. + /// + /// Default is . + /// The underlying polling is likely to occur frequently and as such causes instrumentation noise; therefore, is disabled by default. + public bool IsInstrumentationEnabledForPolling { get; set; } = false; + + /// + /// Indicates whether instrumentation is enabled for the publishing. + /// + /// Default is . + /// The underlying publishing is considered more interesting; therefore, is enabled by default. However, may create noise and as such is configurable. + public bool IsInstrumentationEnabledForPublishing { get; set; } = true; + + /// + /// Sets the , , and by convention using the optional . + /// + /// The optional database schema name. + /// Where the schema is not specified, and the database supports schema, then the will be used by default. + public abstract void SetStatementsByConvention(string? schema = null); + + /// + public async Task RelayAsync(DatabaseOutboxRelayArgs args, CancellationToken cancellationToken = default) + { + // Guard against being in a transaction already as this is not allowed! + if (Database.CurrentTransaction is not null) + throw new InvalidOperationException($"The {typeof(TSelf).Name} cannot be executed within an existing database transaction."); + + bool relayed = false; + + // Iterate through the allocated partitions and relay accordingly. + var partitions = args.PartitionPicker.GetNextPartitions(DateTimeOffset.UtcNow); + foreach (var partitionId in partitions) + { + // To minimize the risk of the relay being inadvertently disrupted during execution the cancellation token is not passed to the RelayAsync method; + // the cancellation token is only used here to determine whether to continue with the next partition or not. + if (cancellationToken.IsCancellationRequested) + break; + + // 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); + try + { + var relay = await RelayAsync(args, partitionId, leaseCancellationTokenSource.Token).ConfigureAwait(false); + if (relay) + relayed = true; + } + catch (Exception ex) when (ex.IsCanceled()) + { + if (Logger?.IsEnabled(LogLevel.Warning) is true) + Logger.LogWarning("The relay operation for partition '{PartitionId}' was cancelled due to exceeding lease-duration timeout; cancellation exception will continue to throw.", partitionId); + + // Keep throwing as the cancellation is likely to be due to exceeding the lease duration which is a serious failure that should be surfaced and not treated as a transient exception. + throw; + } + } + + return relayed; + } + + /// + /// Performs the relay for the specified . + /// + private async Task RelayAsync(DatabaseOutboxRelayArgs args, int partitionId, CancellationToken cancellationToken) + { + // New lease identifier per relay invocation. + var leaseId = Guid.NewGuid(); + + // Grab the next batch of events. + using (SuppressInstrumentationScope.Begin(!IsInstrumentationEnabledForPolling)) + { + // Claim a batch. + var events = await ClaimNextBatchAsync(args, leaseId, partitionId, cancellationToken).ConfigureAwait(false); + if (events.Count == 0) + { + if (Logger?.IsEnabled(LogLevel.Debug) is true) + Logger.LogDebug("No events were found to relay from the Outbox."); + + return false; + } + + // Need a try/catch so we can rollback the claim where necessary. + try + { + // Add the events. + EventPublisher.Add(events); + + // Publish the events; i.e. the actual "relay". + using (SuppressInstrumentationScope.Begin(!IsInstrumentationEnabledForPublishing)) + { + await Invoker.InvokeAsync(this, async (tracer, cancellationToken) => + { + if (tracer.Activity is not null) + { + tracer.Activity.AddTag("outbox.partition", partitionId); + tracer.Activity.AddTag("outbox.events.count", events.Count); + + foreach (var e in events) + { + if (!e.Event.TryGetExtensionAttribute("traceparent", out var traceParent) || string.IsNullOrEmpty(traceParent)) + continue; + + e.Event.TryGetExtensionAttribute("tracestate", out var traceState); + if (ActivityContext.TryParse(traceParent, traceState, out var ac)) + tracer.Activity.AddLink(new ActivityLink(ac)); + + if (e.Event.TryGetExtensionAttribute("baggage", out var baggageHeader) && !string.IsNullOrEmpty(baggageHeader)) + { + // Parse W3C Baggage format: "key1=value1,key2=value2;property1;property2" + // Note: OpenTelemetry doesn't expose a public baggage parser, so we implement per W3C spec. + foreach (var member in baggageHeader.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + // Take only the key-value part (before any optional properties after semicolon). + var keyValue = member.Split(';', 2)[0].Trim(); + var parts = keyValue.Split('=', 2); + if (parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[0])) + { + // Decode URL-encoded values per W3C Baggage spec. + var key = Uri.UnescapeDataString(parts[0].Trim()); + var value = Uri.UnescapeDataString(parts[1].Trim()); + tracer.Activity.AddBaggage(key, value); + } + } + } + } + } + + await EventPublisher.PublishAsync(cancellationToken).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + } + + // Complete the batch. + await CompleteBatchAsync(args, leaseId, cancellationToken).ConfigureAwait(false); + + // Report success to encourage consideration in the next round pick where a full batch was claimed. + if (events.Count == args.BatchSize) + args.PartitionPicker.PrioritizePartition(partitionId); + + return true; + } + catch (Exception ex) + { + // Cancel the batch. + 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); + + // Keep bubbling the exception. + throw; + } + finally + { + // Reset the event publisher events to ensure no events accidently bleed out of this operation. + EventPublisher.Reset(); + } + } + } + + /// + /// Claims the next batch of events for the specified . + /// + /// The . + /// The lease . + /// The assigned partition identifier/number. + /// The . + /// Zero or more entries within a list. + protected virtual async Task> ClaimNextBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, int partitionId, CancellationToken cancellationToken) + { + var ec = ExecutionContext.HasCurrent ? ExecutionContext.Current : null; + var events = new List(); + + // 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)); + + try + { + await Database.Statement(ClaimBatchStatement) + .ParamWhen(!string.IsNullOrEmpty(ec?.TenantId), Database.NamedColumns.TenantIdName, () => ec!.TenantId) + .Param(Database.NamedColumns.PartitionIdName, partitionId) + .Param(Database.NamedColumns.OutboxBatchSizeName, args.BatchSize) + .Param(Database.NamedColumns.OutboxLeaseIdName, leaseId) + .Param(Database.NamedColumns.OutboxLeaseDurationName, leaseDurationSeconds) + .ReturnValue(out var returnValueParameter) + .SelectQueryAsync(events, (dr, _) => + { + return new DestinationEvent + ( + dr.GetValue(Database.NamedColumns.OutboxDestinationName), + dr.GetValue(Database.NamedColumns.OutboxEventName).DecodeToCloudEvent(Database.JsonSerializerOptions) + ); + }, cancellationToken); + + if (Logger?.IsEnabled(LogLevel.Debug) is true) + Logger.LogDebug("The return value from the Outbox claim batch database statement is {ReturnValue}.", returnValueParameter.Value); + } + catch (Exception ex) + { + if (!IsTransientException(ex)) + throw; + } + + return events; + } + + /// + /// Completes the batch. + /// + /// The . + /// The lease . + /// The . + protected virtual Task CompleteBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, CancellationToken cancellationToken) + => Database.Statement(CompleteBatchStatement) + .Param(Database.NamedColumns.OutboxLeaseIdName, leaseId) + .Param(Database.NamedColumns.OutboxDequeuedUtcName, DateTime.UtcNow) + .NonQueryAsync(cancellationToken); + + /// + /// Cancels the batch. + /// + /// The . + /// The lease . + /// The . + 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)) + .NonQueryAsync(cancellationToken); + + /// + /// Indicates whether the specified is an expected exception that should not be logged as an error. + /// + /// The . + /// 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 new file mode 100644 index 00000000..5ab15dec --- /dev/null +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBase.cs @@ -0,0 +1,83 @@ +namespace CoreEx.Database.Outbox; + +/// +/// Provides the base execution leveraging a . +/// +/// The . +/// The . +public abstract class DatabaseOutboxRelayHostedServiceBase(IServiceProvider serviceProvider, ILogger logger) : TimerHostedServiceBase(serviceProvider, logger) +{ + private PartitionPicker? _partitionPicker; + + /// + /// Gets or sets the batch size. + /// + /// Defaults to '25'. + public int BatchSize { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } + + /// + /// Gets or sets the lease duration used to lock when claiming a batch. + /// + /// Defaults to '5' minutes. + public TimeSpan LeaseDuration { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } + + /// + /// Gets or sets the backoff duration used to push out availability when cancelling a batch. + /// + /// Defaults to '5' seconds. + public TimeSpan BackOffDuration { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } + + /// + /// Gets or sets the partition size. + /// + /// Defaults to . + public int PartitionSize { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } + + /// + /// Gets or sets the per-worker partition count. + /// + /// Defaults to '6'. + /// Represents the number of partitions that will be relayed per . + public int PerWorkerPartitionCount { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } + + /// + /// Gets the used to determine partition selection during processing. + /// + /// This is instantiated during and leverages configuration to determine its settings. + protected PartitionPicker PartitionPicker => _partitionPicker ?? throw new InvalidOperationException("PartitionPicker has not yet been initialized; this should not be accessed before the OnInitializeAsync."); + + /// + protected async override Task OnInitializeAsync(CancellationToken cancellationToken) + { + await base.OnInitializeAsync(cancellationToken).ConfigureAwait(false); + + BatchSize = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OutboxRelay:BatchSize", "CoreEx:Host:Services:OutboxRelay:BatchSize", 25, Configuration); + LeaseDuration = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OutboxRelay:LeaseDuration", "CoreEx:Host:Services:OutboxRelay:LeaseDuration", TimeSpan.FromMinutes(5), Configuration); + BackOffDuration = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OutboxRelay:BackOffDuration", "CoreEx:Host:Services:OutboxRelay:BackOffDuration", TimeSpan.FromSeconds(5), Configuration); + 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); + + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} settings: BatchSize={BatchSize}, LeaseDuration={LeaseDuration}, BackOffDuration={BackOffDuration}, PartitionSize={PartitionSize}, PerWorkerPartitionCount={PerWorkerPartitionCount}", + ServiceName, BatchSize, LeaseDuration, BackOffDuration, PartitionSize, PerWorkerPartitionCount); + } + + /// + protected override HealthCheckResult OnReportHealthStatus(Dictionary data) + { + var rd = new Dictionary() + { + ["BatchSize"] = BatchSize, + ["LeaseDuration"] = LeaseDuration, + ["BackOffDuration"] = BackOffDuration, + ["PartitionSize"] = PartitionSize, + ["PerWorkerPartitionCount"] = PerWorkerPartitionCount + }; + + data.Add("OutboxRelay", rd); + + return base.OnReportHealthStatus(data); + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBaseT.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBaseT.cs new file mode 100644 index 00000000..c0d769ec --- /dev/null +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBaseT.cs @@ -0,0 +1,39 @@ +namespace CoreEx.Database.Outbox; + +/// +/// Provides the execution leveraging a . +/// +/// The . +/// The . +/// The . +public abstract class DatabaseOutboxRelayHostedServiceBase(IServiceProvider serviceProvider, ILogger logger) : DatabaseOutboxRelayHostedServiceBase(serviceProvider, logger) where TOutboxRelay : IDatabaseOutboxRelay +{ + /// + /// Gets or sets the factory method to create the . + /// + public Func? RelayFactory { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } + + /// + protected override async Task OnExecuteAsync(ExecutionContext executionContext, CancellationToken cancellationToken) + { + // Instantiate the relay via the factory where specified. + var relay = RelayFactory is null + ? ExecutionContext.GetRequiredService() + : RelayFactory(executionContext.ServiceProvider.ThrowIfNull()) ?? throw new InvalidOperationException($"The {typeof(TOutboxRelay).Name} was not be created using the specified {nameof(RelayFactory)}."); + + // Create the arguments. + var args = new DatabaseOutboxRelayArgs + { + PartitionPicker = PartitionPicker, + BatchSize = BatchSize, + LeaseDuration = LeaseDuration, + BackOffDuration = BackOffDuration + }; + + // Execute the relay. + var relayed = await relay.RelayAsync(args, cancellationToken).ConfigureAwait(false); + + // Immediately re-execute where work was done (doesn't matter how much); otherwise, sleep. + return relayed; + } +} \ No newline at end of file diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayInvoker.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayInvoker.cs new file mode 100644 index 00000000..d4c4d013 --- /dev/null +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayInvoker.cs @@ -0,0 +1,9 @@ +namespace CoreEx.Database.Outbox; + +/// +/// Provides the standard invoker functionality. +/// +/// The . +/// The instance (self) . +[InvokerName("CoreEx.Database.Outbox.Relay")] +public class DatabaseOutboxRelayInvoker : InvokerBase> where TDatabase : IDatabase where TSelf : DatabaseOutboxRelayBase { } \ No newline at end of file diff --git a/src/CoreEx.Database/Outbox/IDatabaseOutboxRelay.cs b/src/CoreEx.Database/Outbox/IDatabaseOutboxRelay.cs new file mode 100644 index 00000000..3e783ce7 --- /dev/null +++ b/src/CoreEx.Database/Outbox/IDatabaseOutboxRelay.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Database.Outbox; + +/// +/// Enables the operation for performing the transactional outbox relay. +/// +public interface IDatabaseOutboxRelay +{ + /// + /// Performs the relay operation. + /// + /// The . + /// The . + /// indicates that at least one event was relayed; otherwise, . + Task RelayAsync(DatabaseOutboxRelayArgs args, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Database/README.md b/src/CoreEx.Database/README.md deleted file mode 100644 index 45b200f0..00000000 --- a/src/CoreEx.Database/README.md +++ /dev/null @@ -1,186 +0,0 @@ -# CoreEx.Database - -The `CoreEx.Database` namespace provides extended [_ADO.NET_](https://learn.microsoft.com/en-Us/dotnet/framework/data/adonet/) capabilities. - -
- -## Motivation - -The motivation is to simplify and unify the approach to _ADO.NET_ (database) access. - -
- -## Railway-oriented programming - -To support [railway-oriented programming](../CoreEx/Results/README.md) whenever a method name includes `WithResult` this indicates that it will return a `Result` or `Result` including the resulting success or failure information. In these instances an `Exception` will only be thrown when considered truly exceptional. - -
- -## Database - -The [`Database`](./Database.cs) is the base (common) implementation for the [`IDatabase`](./IDatabase.cs) interface that provides the standardized access to the underlying database. - -The following additional [`IDatabase`](./IDatabase.cs) key capabilities exist. - -Capability | Description --|- -[`DatabaseColumns`](./Extended/DatabaseColumns.cs) | Enables the specification of special database columns used for extended built-in capabilities. -[`Wildcard`](./DatabaseWildcard.cs) | Provides configuration to manage _wildcard_ transformation. -[`DateTimeTransform`](../CoreEx/Entities/DateTimeTransform.cs) | Specifies the `DateTime` transformation when reading from the database. - -
- -### Provider specific - -The following specific database provider implementations further extend the capabilities. - -Database | Implementation --|- -[Microsoft SQL Server](https://learn.microsoft.com/en-us/sql/sql-server) | [`SqlServerDatabase`](../CoreEx.Database.SqlServer/SqlServerDatabase.cs) -[Oracle MySQL](https://www.oracle.com/mysql/what-is-mysql/) | [`MySqlDatabase`](../CoreEx.Database.MySql/MySqlDatabase.cs) - -
- -### Usage - -To use the `Database` a connection creation function parameter is required that is leveraged at runtime (lazy instantiation) to get (create or provide) the underlying [`DbConnection`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbconnection). The `IDatabase` implements [`IDisposable`](https://learn.microsoft.com/en-us/dotnet/api/system.idisposable); the `Dispose` is the primary mechanism to close the connection where automatically opened. - -The following demonstrates [usage](../../samples/My.Hr/My.Hr.Business/Data/HrDb.cs). - -``` csharp - public class HrDb : SqlServerDatabase - { - public HrDb(SettingsBase settings) : base(() => new SqlConnection(settings.GetRequiredValue("ConnectionStrings:Database"))) { } - } -``` - -Additionally, review the _Beef_ repo [sample](https://github.com/Avanade/Beef/blob/master/samples/My.Hr/My.Hr.Business/Data/HrDb.cs). - -
- -## Commands - -The _CoreEx_ [`IDatabase`](./IDatabase.cs) encapsulates an _ADO.NET_ [`DbCommand`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbcommand) within a [`DatabaseCommand`](./DatabaseCommand.cs); via the following methods: - -Method | Description --|- -`StoredProcedure` | Creates a command for a stored procedure; (see [`CommandType.StoredProcedure`](https://learn.microsoft.com/en-us/dotnet/api/system.data.commandtype#system-data-commandtype-storedprocedure)) -`SqlStatement` | Creates a command for a SQL statement; (see [`CommandType.Text`](https://learn.microsoft.com/en-us/dotnet/api/system.data.commandtype#system-data-commandtype-text)) -`SqlFromResource` | Creates a command for a SQL statement within the specified embedded resource. - -or `IDatabase.SqlStatement` method passing the appropriate content. - -The following [`DatabaseCommand`](./DatabaseCommand.cs) methods provide additional capabilities. The query-based methods optionally leverage the rich [Mapping](#Mapping) capabilities. - -Method | Description --|- -`NonQueryAsync`, `NonQueryWithResultAsync` | Executes a non-query command. -`ScalarAsync`, `ScalarWithResultAsync` | Executes the query and returns the first column of the first row in the result set returned by the query. -`SelectSingleAsync`, `SelectSingleWithResultAsync` | Selects a single item. -`SelectSingleOrDefaultAsync` | Selects a single item or default. -`SelectFirstAsync`, `SelectFirstWithResultAsync` | Selects first item. -`SelectFirstOrDefaultAsync`, `SelectFirstOrDefaultWithResultAsync` | Selects first item or default. -`SelectQueryAsync`, `SelectQueryWithResultAsync` | Select items into or creating a resultant collection. -`SelectMultiSetAsync`, `SelectMultiSetWithResulAsync` | Executes a multi-dataset query command with one or more [multi-set arguments](#Multi-set-arguments). - -The _DbEx_ [`DatabaseExtensions`](https://github.com/Avanade/DbEx/blob/main/src/DbEx/DatabaseExtensions.cs) class demonstrates usage of the `SelectQueryAsync` (_without_ [Mapping](#Mapping)) within the `SelectSchemaAsync` method. - -
- -### Query - -The [`Extended`](./Extended) namespace provides a `DatabaseCommand.Query` that provides a [`DatabaseQuery`](./Extended/DatabaseQuery.cs) to encapsulate the following. - -Method | Description --|- -`WithPaging` | Adds `Skip` and `Take` paging to the query. -`SelectSingleAsync`, `SelectSingleWithResultAsync` | Selects a single item. -`SelectSingleOrDefaultAsync`, `SelectSingleOrDefaultWithResultAsync` | Selects a single item or default. -`SelectFirstAsync`, `SelectFirstWithResultAsync` | Selects first item. -`SelectFirstOrDefaultAsync`, `SelectFirstOrDefaultWithResultAsync` | Selects first item or default. -`SelectQueryAsync`, `SelectQueryWithResultAsync` | Select items into or creating a resultant collection. -`SelectResultAsync`, `SelectResultWithResultAsync` | Select items creating a [`ICollectionResult`](../CoreEx/Entities/ICollectionResultT2.cs) which also contains corresponding [`PagingResult`](../CoreEx/Entities/PagingResult.cs). - -
- -### Reference data - -The [`Extended`](./Extended) namespace provides a `DatabaseCommand.ReferenceData` that provides a [`RefDataLoader`](./Extended/RefDataLoader.cs) (via the `LoadAsync` and `LoadWithResultAsync` methods) to simplify the loading of a reference data collection. - -The [`ReferenceDataService`](../../samples/My.Hr/My.Hr.Business/Services/ReferenceDataService.cs) within the `My.Hr` smaple demonstrates usage. - -``` csharp -await _db.ReferenceData("Hr", "Gender").LoadAsync("GenderId", cancellationToken: cancellationToken).ConfigureAwait(false) -``` - -
- -## Parameters - -The [`DatabaseCommand`](./DatabaseCommand.cs) provides a [`Parameters`](./DatabaseParameterCollection.cs) property that primarily enables the following core parameter capabilities. - -Method | Description --|- -`AddParameter` | Adds a [`DbParameter`](); there are a number of overloads enabled. -`AddReturnValueParameter` | Adds an `int` return value [`DbParameter`]() (see [`DatabaseColumns.ReturnValueName`](./Extended/DatabaseColumns.cs)). -`AddReselectRecordParam` | Adds a `bool` reselect record [`DbParameter`]() (see [`DatabaseColumns.ReselectRecordName`](./Extended/DatabaseColumns.cs)). - -Additionally, the `DatabaseCommand` supports a set of [extension methods](./IDatabaseParametersExtensions.cs) to further enable, and simplify, the specification of parameters that leverage the aforementioned `Parameters`. - -Method | Description --|- -`Param` | Adds a [`DbParameter`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter); there are a number of overloads enabled. -`ParamWhen` | Adds a [`DbParameter`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter) _when_ the specified condition is `true`; there are a number of overloads enabled. -`ParamWith` | Adds a [`DbParameter`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter) when invoked with a non-default value; there are a number of overloads enabled. -`ParamWithWildcard` | Adds a _wildcard_ [`DbParameter`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter) when invoked with a non-default value; there are a number of overloads enabled. -`RowVersionParam` | Adds a _row version_ [`DbParameter`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter) (see [`DatabaseColumns.RowVersionName`](./Extended/DatabaseColumns.cs)). Note that the underlying implementation is database specific. -`ReselectRecordParam` | Adds a `bool` reselect record [`DbParameter`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter) (see [`DatabaseColumns.ReselectRecordName`](./Extended/DatabaseColumns.cs)). -`ReselectRecordParamWhen` | Adds a `bool` reselect record [`DbParameter`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter) (see [`DatabaseColumns.ReselectRecordName`](./Extended/DatabaseColumns.cs)) _when_ `true`. -`PagingParams` | Adds the [`PagingArgs`](../CoreEx/Entities/PagingArgs.cs) [`DbParameter`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter)(s) being [`DatabaseColumns.PagingSkipName`](./Extended/DatabaseColumns.cs), `DatabaseColumns.PagingTakeName` and `DatabaseColumns.PagingCountName`. - -
- -## Database record - -_CoreEx_ encapsulates an _ADO.NET_ [`DbDataReader`](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbdatareader) within a [`DatabaseRecord`](./DatabaseRecord.cs); this primarily provides the `GetValue` method that provides extended capabilites to retrieve a column value from the underlying `DbDataReader`. - -
- -## Mapping - -To support the mapping _from_ and/or _to_ a .NET Type and the underlying database, the [`IDatabaseMapper`](./IDatabaseMapper.cs) and corresponding [`IDatabaseMapper`](./IDatabaseMapperT.cs) interface enable (also see [`DatabaseQueryMapper`](./DatabaseQueryMapper.cs) for query only (`MapFromDb`) support). -- `MapToDb` - maps the .NET Type to the database by adding the properties as database [parameters](#Parameters). -- `MapFromDb` - maps the database columns to the properties of a .NET Type. - -The [`Mapping` namespace](./Mapping) provides the primary mapping capabilities. - -Class | Description --|- -[`DatabaseMapper`](./Mapping/DatabaseMapper.cs) | Enables the `Create` and `CreateAuto` of a `DatabaseMapper`. -[`DatabaseMapper`](./Mapping/DatabaseMapperT.cs) | Provides the to/from mapping configuration. -[`PropertyColumnMapper`]() | Provides the property to/from mapping configuration. - -The [`ChangeLogDatabaseMapper`](./Mapping/ChangeLogDatabaseMapper.cs) is a _CoreEx_ implementation example. Additionally, see the _Beef_ `My.Hr` sample which further demonstrates usage within the [`EmployeeBaseData.DbMapper`](https://github.com/Avanade/Beef/blob/master/samples/My.Hr/My.Hr.Business/Data/Generated/EmployeeBaseData.cs) class. - -
- -## Multi-set arguments - -To simplify the support for the [retrieval of multiple result sets](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/retrieving-data-using-a-datareader#retrieving-multiple-result-sets-using-nextresult) the [`IMultiSetArgs`](./IMultiSetArgs.cs) is provided. This is useful where a single command will result in multiple result sets reducing the chattiness between application and database, improving performance, reducing execution latency. - -The following `IMultiSetArgs` implementations are provided. The `StopOnNull` property indicates whether to stop further query result set processing where the current set has resulted in a `null` (i.e. no records). - -Class | Description --|- -[`MultiSetCollArgs`](./MultiSetCollArgsT.cs) | Provides the multi-set arguments when expecting a collection of items/records. The `MinRows` and `MaxRows` properties can also be specified to ensure/validate correctness of returned rows. -[`MultiSetSingleArgs`](./MultiSetSingleArgsT.cs) | Provides the multi-set arguments when expecting a single item/record only. The `IsMandatory` property indicates whether the value is mandatory. - -The [`DatabaseCommannd.SelectMultiSetAsync`](./DatabaseCommand.cs) method supports one or more [`IMultiSetArgs`](./IMultiSetArgs.cs) when invoked; leveraging the configuration within to create the resulting output. Note also, the `IMultiSetArgs` count must not be less that the number of result sets returned from the database. - -The _Beef_ `My.Hr` sample demonstrates usage within the [`EmployeeData`](https://github.com/Avanade/Beef/blob/master/samples/My.Hr/My.Hr.Business/Data/EmployeeData.cs) class. - -``` csharp -await db.SelectMultiSetAsync( - new MultiSetSingleArgs(DbMapper.Default, r => employee = r, isMandatory: false, stopOnNull: true), - new MultiSetCollArgs(EmergencyContactData.DbMapper.Default, r => employee!.EmergencyContacts = r)).ConfigureAwait(false); -``` \ No newline at end of file diff --git a/src/CoreEx.Database/SqlStatement.cs b/src/CoreEx.Database/SqlStatement.cs new file mode 100644 index 00000000..a3fd4a2b --- /dev/null +++ b/src/CoreEx.Database/SqlStatement.cs @@ -0,0 +1,74 @@ +namespace CoreEx.Database; + +/// +/// Represents a database SQL statement, including its command type and text, for use with data access operations. +/// +public readonly record struct SqlStatement +{ + /// + /// Gets an indeterminate . + /// + public static SqlStatement None { get; } = new SqlStatement(); + + /// + /// Creates a stored procedure . + /// + /// The stored procedure name. + /// The . + public static SqlStatement StoredProcedure(string storedProcedure) => new(CommandType.StoredProcedure, storedProcedure); + + /// + /// Creates a SQL text from the . + /// + /// The SQL statement text. + /// The . + public static SqlStatement FromText(string text) => new(CommandType.Text, text); + + /// + /// Creates a SQL text from the named embedded resource within the specified . + /// + /// The embedded resource name (matches to the end of the fully qualifed resource name). + /// The that contains the embedded resource; defaults to . + /// The . + public static SqlStatement FromResource(string resourceName, Assembly? assembly = null) + { + using var s = Resource.GetStream(resourceName, assembly ?? Assembly.GetCallingAssembly()); + using var sr = new StreamReader(s); + return FromText(sr.ReadToEnd()); + } + + /// + /// Creates a SQL text from the named embedded resource within the inferred from the . + /// + /// The to infer the that contains the embedded resource. + /// The embedded resource name (matches to the end of the fully qualifed resource name). + /// The . + public static SqlStatement FromResource(string resourceName) => FromResource(resourceName, typeof(TResource).Assembly); + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The command text. + public SqlStatement(CommandType commandType, string commandText) + { + CommandType = commandType; + CommandText = commandText.ThrowIfNullOrEmpty(); + } + + /// + /// Gets the . + /// + public CommandType CommandType { get; } + + /// + /// Gets the command text. + /// + public string CommandText { get; } + + /// + /// An implicit cast from a text to a (). + /// + /// The SQL statement text. + public static implicit operator SqlStatement(string text) => FromText(text); +} \ No newline at end of file diff --git a/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs b/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs new file mode 100644 index 00000000..daebdd6a --- /dev/null +++ b/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs @@ -0,0 +1,77 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace {{Root.DotNetDataEfRepositoriesNamespace}}; + +public partial class {{Domain}}DbContext +{ + /// + /// Adds the generated models to the . + /// + /// The . + public void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder) + { +{{#each EfModels}} + {{#unless @first}} + + {{/unless}} + // Add the entity/model configuration for the {{DbTable.QualifiedName}} database table. + modelBuilder.Entity<{{Root.DotNetDataEfModelsNamespace}}.{{EfModelName}}>(e => + { + e.{{#if DbTable.IsAView}}ToView{{else}}ToTable{{/if}}("{{Name}}"{{#ifne Schema ''}}, "{{Schema}}"{{/ifne}}); + {{#if HasPrimaryKeyIdentifier}} + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("{{PrimaryKeyIdentifierColumn.Name}}").HasColumnType("{{PrimaryKeyIdentifierColumn.DbColumn.SqlType2}}"){{#if PrimaryKeyIdentifierColumn.DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/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}}){{/if}}; + {{/each}} + {{#if HasColumnCreatedBy}} + e.Property(p => p.CreatedBy).HasColumnName("{{ColumnCreatedBy.Name}}").HasColumnType("{{ColumnCreatedBy.DbColumn.SqlType2}}"); + {{/if}} + {{#if HasColumnCreatedOn}} + e.Property(p => p.CreatedOn).HasColumnName("{{ColumnCreatedOn.Name}}").HasColumnType("{{ColumnCreatedOn.DbColumn.SqlType2}}"); + {{/if}} + {{#if HasColumnUpdatedBy}} + e.Property(p => p.UpdatedBy).HasColumnName("{{ColumnUpdatedBy.Name}}").HasColumnType("{{ColumnUpdatedBy.DbColumn.SqlType2}}"); + {{/if}} + {{#if HasColumnUpdatedOn}} + e.Property(p => p.UpdatedOn).HasColumnName("{{ColumnUpdatedOn.Name}}").HasColumnType("{{ColumnUpdatedOn.DbColumn.SqlType2}}"); + {{/if}} + {{#if HasColumnRowVersion}} + e.Property(p => p.ETag).HasColumnName("{{ColumnRowVersion.Name}}").HasColumnType("{{ColumnRowVersion.DbColumn.SqlType2}}").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + {{/if}} + {{#if HasColumnTenantId}} + e.Property(p => p.TenantId).HasColumnName("{{ColumnTenantId.Name}}").HasColumnType("{{ColumnTenantId.DbColumn.SqlType2}}"); + {{/if}} + {{#if HasColumnIsDeleted}} + e.Property(p => p.IsDeleted).HasColumnName("{{ColumnIsDeleted.Name}}").HasColumnType("{{ColumnIsDeleted.DbColumn.SqlType2}}"); + {{/if}} + {{#if DbTable.IsRefData}} + {{#ifval RefData.TextProperty RefData.DescriptionProperty RefData.SortOrderProperty RefData.IsActiveProperty RefData.StartsOnProperty RefData.EndsOnProperty}} + {{else}} + e{{#ifnull RefData.TextProperty}}.Ignore(p => p.Text){{/ifnull}}{{#ifnull RefData.DescriptionProperty}}.Ignore(p => p.Description){{/ifnull}}{{#ifnull RefData.SortOrderProperty}}.Ignore(p => p.SortOrder){{/ifnull}}{{#ifnull RefData.IsActiveProperty}}.Ignore(p => p.IsActive){{/ifnull}}{{#ifnull RefData.StartsOnProperty}}.Ignore(p => p.StartsOn){{/ifnull}}{{#ifnull RefData.EndsOnProperty}}.Ignore(p => p.EndsOn){{/ifnull}}; + {{/ifval}} + {{/if}} + {{#ifval ColumnCreatedBy ColumnCreatedOn ColumnUpdatedBy ColumnUpdatedOn ColumnRowVersion}} + {{else}} + e{{#unless HasColumnCreatedBy}}.Ignore(p => p.CreatedBy){{/unless}}{{#unless HasColumnCreatedOn}}.Ignore(p => p.CreatedOn){{/unless}}{{#unless HasColumnUpdatedBy}}.Ignore(p => p.UpdatedBy){{/unless}}{{#unless HasColumnUpdatedOn}}.Ignore(p => p.UpdatedOn){{/unless}}{{#unless HasColumnRowVersion}}.Ignore(p => p.ETag){{/unless}}; + {{/ifval}} + {{else}} + {{#ifne PrimaryKeyColumns.Count 0}} + 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}}){{/if}}; + {{/each}} + {{/if}} + }); +{{/each}} + } +} + +#nullable restore \ No newline at end of file diff --git a/src/CoreEx.Database/Templates/EfModel_cs.hbs b/src/CoreEx.Database/Templates/EfModel_cs.hbs new file mode 100644 index 00000000..5bf0582a --- /dev/null +++ b/src/CoreEx.Database/Templates/EfModel_cs.hbs @@ -0,0 +1,82 @@ +// + +/* + * This file is automatically generated; any changes will be lost. + */ + +#nullable enable + +namespace {{Root.DotNetDataEfModelsNamespace}}; + +{{#if DbTable.IsRefData}} +/// +/// Persistence reference-data model representing the '{{DbTable.QualifiedName}}' database table. +/// +public partial class {{EfModelName}} : ReferenceDataModelBase<{{PrimaryKeyIdentifierColumn.Type}}>{{#ifeq RefData.AdditionalProperties.Count 0}} { }{{/ifeq}} + {{#ifne RefData.AdditionalProperties.Count 0}} +{ + {{#each RefData.AdditionalProperties}} + {{#unless @first}} + + {{/unless}} + /// + /// Gets or sets the value of the '{{DbColumn.Name}}' column (type '{{DbColumn.SqlType}}'). + /// + public {{Type}} {{Property}} { get; set; }{{#unless DbColumn.IsNullable}}{{#ifeq DbColumn.DotNetType "string"}} = default!;{{/ifeq}}{{/unless}} + {{/each}} +} + {{/ifne}} +{{else}} +/// +/// Persistence model representing the '{{DbTable.QualifiedName}}' database table. +/// + {{#if HasPrimaryKeyIdentifier}} +/// The primary key column is '{{PrimaryKeyIdentifierColumn.Name}}' (type '{{PrimaryKeyIdentifierColumn.DbColumn.SqlType}}'). + {{/if}} +public partial class {{EfModelName}}{{#if HasPrimaryKeyIdentifier}} : ModelBase<{{PrimaryKeyIdentifierColumn.Type}}>{{#if HasColumnIsDeleted}}, ILogicallyDeleted{{/if}}{{#if HasColumnTenantId}}, ITenantId{{/if}}{{/if}} +{ + {{#if HasPrimaryKeyIdentifier}} + {{#each StandardColumns}} + {{#unless @first}} + + {{/unless}} + /// + /// Gets or sets the value of the '{{DbColumn.Name}}' column (type '{{DbColumn.SqlType}}'). + /// + {{#if DbColumn.IsPrimaryKey}} + /// This is {{#ifeq Parent.DbTable.PrimaryKeyColumns.Count 1}}the primary key{{else}}part of the primary key{{/ifeq}}. + {{/if}} + public {{Type}} {{Property}} { get; set; }{{#unless DbColumn.IsNullable}}{{#ifeq DbColumn.DotNetType "string"}} = default!;{{/ifeq}}{{/unless}} + {{/each}} + {{#if HasColumnIsDeleted}} + + /// + /// Gets or sets the value of the '{{ColumnIsDeleted.Name}}' column (type '{{ColumnIsDeleted.DbColumn.SqlType}}'); see . + /// + public bool IsDeleted { get; set; } + {{/if}} + {{#if HasColumnTenantId}} + + /// + /// Gets or sets the value of the '{{ColumnTenantId.Name}}' column (type '{{ColumnTenantId.DbColumn.SqlType}}'); see . + /// + public string? TenantId { get; set; } + {{/if}} + {{else}} + {{#each Columns}} + {{#unless @first}} + + {{/unless}} + /// + /// Gets or sets the value of the '{{DbColumn.Name}}' column (type '{{DbColumn.SqlType}}'). + /// + {{#if DbColumn.IsPrimaryKey}} + /// This is {{#ifeq Parent.DbTable.PrimaryKeyColumns.Count 1}}the primary key{{else}}part of the primary key{{/ifeq}}. + {{/if}} + public {{Type}} {{Property}} { get; set; }{{#unless DbColumn.IsNullable}}{{#ifeq DbColumn.DotNetType "string"}} = default!;{{/ifeq}}{{/unless}} + {{/each}} + {{/if}} +} +{{/if}} + +#nullable restore \ No newline at end of file diff --git a/src/CoreEx.Database/strong-name-key.snk b/src/CoreEx.Database/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Dataverse/CoreEx.Dataverse.csproj b/src/CoreEx.Dataverse/CoreEx.Dataverse.csproj deleted file mode 100644 index 1c5a23c2..00000000 --- a/src/CoreEx.Dataverse/CoreEx.Dataverse.csproj +++ /dev/null @@ -1,37 +0,0 @@ - - - - net6.0;net8.0;net9.0 - CoreEx.Dataverse - CoreEx - CoreEx .NET Microsoft Dataverse extras. - CoreEx Microsoft Dataverse extras. - coreex dataverse power-platform - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/CoreEx.Dataverse/IDataverseMapper.cs b/src/CoreEx.Dataverse/IDataverseMapper.cs deleted file mode 100644 index 1d40b8f0..00000000 --- a/src/CoreEx.Dataverse/IDataverseMapper.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; -using Microsoft.Xrm.Sdk; -using System; - -namespace CoreEx.Dataverse -{ - /// - /// Defines a Dataverse mapper. - /// - public interface IDataverseMapper - { - /// - /// Gets the source being mapped from/to the Dataverse . - /// - Type SourceType { get; } - - /// - /// Maps from an creating a corresponding instance of the . - /// - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The corresponding instance of the . - object? MapFromDataverse(Entity entity, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToDataverse(object? value, Entity entity, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx.Dataverse/IDataverseMapperT.cs b/src/CoreEx.Dataverse/IDataverseMapperT.cs deleted file mode 100644 index da62db0f..00000000 --- a/src/CoreEx.Dataverse/IDataverseMapperT.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; -using Microsoft.Xrm.Sdk; -using System; - -namespace CoreEx.Dataverse -{ - /// - /// Defines a Dataverse mapper. - /// - /// The . - public interface IDataverseMapper : IDataverseMapper - { - /// - Type IDataverseMapper.SourceType => typeof(TSource); - - /// - object? IDataverseMapper.MapFromDataverse(Entity entity, OperationTypes operationType) => MapFromDataverse(entity, operationType)!; - - /// - void IDataverseMapper.MapToDataverse(object? value, Entity entity, OperationTypes operationType) => MapToDataverse((TSource?)value, entity, operationType); - - /// - /// Maps from a creating a corresponding instance of . - /// - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The corresponding instance of . - new TSource? MapFromDataverse(Entity entity, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToDataverse(TSource? value, Entity entity, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx.Dataverse/Mapping/DataverseMapper.cs b/src/CoreEx.Dataverse/Mapping/DataverseMapper.cs deleted file mode 100644 index f2d7f06d..00000000 --- a/src/CoreEx.Dataverse/Mapping/DataverseMapper.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Dataverse.Mapping -{ - /// - /// Enables or of a . - /// - public class DataverseMapper - { - /// - /// Creates a where properties are added manually. - /// - /// A . - public static DataverseMapper Create() where TSource : class, new() => new(false); - - /// - /// Creates a where properties are added automatically (assumes the property and column names share the same name). - /// - /// An array of source property names to ignore. - /// A . - public static DataverseMapper CreateAuto(params string[] ignoreSrceProperties) where TSource : class, new() => new(true, ignoreSrceProperties); - } -} \ No newline at end of file diff --git a/src/CoreEx.Dataverse/Mapping/DataverseMapperT.cs b/src/CoreEx.Dataverse/Mapping/DataverseMapperT.cs deleted file mode 100644 index 3032b8e1..00000000 --- a/src/CoreEx.Dataverse/Mapping/DataverseMapperT.cs +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; -using CoreEx.Mapping; -using Microsoft.Xrm.Sdk; -using System; -using System.Collections.Generic; -using System.Data; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; - -namespace CoreEx.Dataverse.Mapping -{ - /// - /// Provides mapping from a and Dataverse . - /// - /// The source . - public class DataverseMapper : IDataverseMapper, IDataverseMapperMappings where TSource : class, new() - { - private readonly List _mappings = []; - private readonly bool _implementsIIdentifier = typeof(IIdentifier).IsAssignableFrom(typeof(TSource)); - - /// - /// Initializes a new instance of the class. - /// - /// Indicates whether the entity should automatically map all public get/set properties, where the property and column names are all assumed to share the same name. - /// An array of source property names to ignore. - public DataverseMapper(bool autoMap = false, params string[] ignoreSrceProperties) - { - if (typeof(TSource) == typeof(string)) throw new InvalidOperationException("TSource must not be a String."); - - if (autoMap) - AutomagicallyMap(ignoreSrceProperties); - } - - /// - IEnumerable IDataverseMapperMappings.Mappings => _mappings.AsEnumerable(); - - /// - public IPropertyColumnMapper this[string propertyName] => TryGetProperty(propertyName, out var pcm) ? pcm : throw new ArgumentException($"Property '{propertyName}' does not exist.", nameof(propertyName)); - - /// - /// Gets the for the specified source . - /// - /// The to reference the source property. - /// The where found. - /// Thrown when the property does not exist. - public IPropertyColumnMapper this[Expression> propertyExpression] - { - get - { - propertyExpression.ThrowIfNull(nameof(propertyExpression)); - - MemberExpression? me = null; - if (propertyExpression.Body.NodeType == ExpressionType.MemberAccess) - me = propertyExpression.Body as MemberExpression; - else if (propertyExpression.Body.NodeType == ExpressionType.Convert) - { - if (propertyExpression.Body is UnaryExpression ue) - me = ue.Operand as MemberExpression; - } - - if (me == null) - throw new InvalidOperationException("Only Member access expressions are supported."); - - return this[me.Member.Name]; - } - } - - /// - public bool TryGetProperty(string propertyName, [NotNullWhen(true)] out IPropertyColumnMapper? propertyColumnMapper) - { - propertyColumnMapper = _mappings.Where(x => x.PropertyName == propertyName).FirstOrDefault(); - return propertyColumnMapper != null; - } - - /// - public string GetColumnName(string propertyName) => this[propertyName].ColumnName; - - /// - /// Automatically add each public get/set property. - /// - private void AutomagicallyMap(string[] ignoreSrceProperties) - { - foreach (var sp in TypeReflector.GetProperties(typeof(TSource))) - { - // Do not auto-map where ignore has been specified. - if (ignoreSrceProperties.Contains(sp.Name)) - continue; - - // Create the lambda expression for the property and add to the mapper. - var spe = Expression.Parameter(typeof(TSource), "x"); - var sex = Expression.Lambda(Expression.Property(spe, sp), spe); - typeof(DataverseMapper) - .GetMethod(nameof(AutoProperty), BindingFlags.NonPublic | BindingFlags.Instance)! - .MakeGenericMethod([sp.PropertyType]) - .Invoke(this, [sex, null, OperationTypes.Any]); - } - } - - /// - /// Adds a to the mapper with additiional auto-logic. - /// - private PropertyColumnMapper AutoProperty(Expression> propertyExpression, string? columnName = null, OperationTypes operationTypes = OperationTypes.Any) - { - var pcm = Property(propertyExpression, columnName, operationTypes); - - // Automatically set primary key where IIdentifier. - if (_implementsIIdentifier && pcm.PropertyName == nameof(IIdentifier.Id)) - pcm.SetPrimaryKey(pcm.PropertyType == typeof(Guid) || pcm.PropertyType == typeof(Guid?) || pcm.PropertyType == typeof(string)); - - return pcm; - } - - /// - /// Adds a to the mapper. - /// - /// The source property . - /// The to reference the source property. - /// The Dataverse column name. Defaults to name. - /// The selection to enable inclusion or exclusion of property. - /// The . - public PropertyColumnMapper Property(Expression> propertyExpression, string? columnName = null, OperationTypes operationTypes = OperationTypes.Any) - { - var pcm = new PropertyColumnMapper(propertyExpression, columnName, operationTypes); - AddMapping(pcm); - return pcm; - } - - /// - /// Validates and adds a new IPropertyColumnMapper. - /// - private void AddMapping(PropertyColumnMapper propertyColumnMapper) - { - if (_mappings.Any(x => x.PropertyName == propertyColumnMapper.PropertyName)) - throw new ArgumentException($"Source property '{propertyColumnMapper.PropertyName}' must not be specified more than once.", nameof(propertyColumnMapper)); - - if (_mappings.Any(x => x.ColumnName == propertyColumnMapper.ColumnName)) - throw new ArgumentException($"Column '{propertyColumnMapper.ColumnName}' must not be specified more than once.", nameof(propertyColumnMapper)); - - _mappings.Add(propertyColumnMapper); - } - - /// - /// Adds or updates to the mapper. - /// - /// The source property . - /// The to reference the source property. - /// The Dataverse column name. Defaults to name. - /// The selection to enable inclusion or exclusion of property. - /// An enabling access to the created . - /// - /// Where updating an existing the and where specified will override the previous values. - public DataverseMapper HasProperty(Expression> propertyExpression, string? columnName = null, OperationTypes? operationTypes = null, Action>? property = null) - { - var tmp = new PropertyColumnMapper(propertyExpression, columnName, operationTypes ?? OperationTypes.Any); - var pcm = _mappings.Where(x => x.PropertyName == tmp.PropertyName).OfType>().SingleOrDefault(); - if (pcm == null) - AddMapping(pcm = tmp); - else - { - if (columnName != null && tmp.ColumnName != pcm.ColumnName) - { - if (_mappings.Any(x => x.ColumnName == pcm.ColumnName)) - throw new ArgumentException($"Column '{pcm.ColumnName}' must not be specified more than once.", nameof(columnName)); - else - pcm.ColumnName = tmp.ColumnName; - } - - if (operationTypes != null) - pcm.OperationTypes = operationTypes.Value; - } - - property?.Invoke(pcm); - return this; - } - - /// - /// Inherits the property mappings from the selected . - /// - /// The source . Must inherit from . - /// The to inherit from. Must also implement . - public void InheritPropertiesFrom(IDataverseMapper inheritMapper) where T : class, new() - { - inheritMapper.ThrowIfNull(nameof(inheritMapper)); - if (!typeof(TSource).IsSubclassOf(typeof(T))) throw new ArgumentException($"Type {typeof(TSource).Name} must inherit from {typeof(T).Name}.", nameof(inheritMapper)); - if (inheritMapper is not IDataverseMapperMappings inheritMappings) throw new ArgumentException($"Type {typeof(T).Name} must implement {typeof(IDataverseMapperMappings).Name} to copy the mappings.", nameof(inheritMapper)); - - var pe = Expression.Parameter(typeof(TSource), "x"); - var type = typeof(DataverseMapper<>).MakeGenericType(typeof(TSource)); - - foreach (var p in inheritMappings.Mappings) - { - var lex = Expression.Lambda(Expression.Property(pe, p.PropertyName), pe); - var pmap = (IPropertyColumnMapper)type - .GetMethod("Property", BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)! - .MakeGenericMethod(p.PropertyType) - .Invoke(this, [lex, p.ColumnName, p.OperationTypes])!; - - if (p.IsPrimaryKey) - pmap.SetPrimaryKey(p.IsPrimaryKeyUseEntityIdentifier); - - if (p.Converter != null) - pmap.SetConverter(p.Converter); - - if (p.Mapper != null) - pmap.SetMapper(p.Mapper); - } - } - - /// - public void MapToDataverse(TSource? value, Entity entity, OperationTypes operationType = OperationTypes.Unspecified) - { - entity.ThrowIfNull(nameof(entity)); - if (value == null) return; - - foreach (var p in _mappings) - { - p.MapToDataverse(value, entity, operationType); - } - - OnMapToDataverse(value, entity, operationType); - } - - /// - /// Extension opportunity when performing a . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - protected virtual void OnMapToDataverse(TSource value, Entity entity, OperationTypes operationType) { } - - /// - public TSource? MapFromDataverse(Entity entity, OperationTypes operationType = OperationTypes.Unspecified) - { - entity.ThrowIfNull(nameof(entity)); - var value = new TSource(); - - foreach (var p in _mappings) - { - p.MapFromDataverse(entity, value, operationType); - } - - value = OnMapFromDataverse(value, entity, operationType); - return (value != null && value is IInitial ii && ii.IsInitial) ? null : value; - } - - /// - /// Extension opportunity when performing a . - /// - /// The source value. - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The source value. - protected virtual TSource? OnMapFromDataverse(TSource value, Entity entity, OperationTypes operationType) => value; - } -} \ No newline at end of file diff --git a/src/CoreEx.Dataverse/Mapping/IDataverseMapperMappings.cs b/src/CoreEx.Dataverse/Mapping/IDataverseMapperMappings.cs deleted file mode 100644 index fd0d3a34..00000000 --- a/src/CoreEx.Dataverse/Mapping/IDataverseMapperMappings.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.Dataverse.Mapping -{ - /// - /// Gets the mappings. - /// - public interface IDataverseMapperMappings - { - /// - /// Gets the mappings. - /// - IEnumerable Mappings { get; } - - /// - /// Gets the from the for the specified . - /// - /// The property name. - /// The where found. - /// Thrown when the property does not exist. - IPropertyColumnMapper this[string propertyName] { get; } - - /// - /// Attempts to get the for the specified . - /// - /// The source property name. - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - bool TryGetProperty(string propertyName, [NotNullWhen(true)] out IPropertyColumnMapper? propertyColumnMapper); - - /// - /// Gets the . - /// - /// The source property name. - /// The where found. - /// Thrown when the property does not exist. - string GetColumnName(string propertyName); - } -} \ No newline at end of file diff --git a/src/CoreEx.Dataverse/Mapping/IPropertyColumnMapper.cs b/src/CoreEx.Dataverse/Mapping/IPropertyColumnMapper.cs deleted file mode 100644 index 02ff9aa6..00000000 --- a/src/CoreEx.Dataverse/Mapping/IPropertyColumnMapper.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Mapping.Converters; -using CoreEx.Mapping; -using System; -using Microsoft.Xrm.Sdk; - -namespace CoreEx.Dataverse.Mapping -{ - /// - /// Enables bi-directional property and Dataverse column mapping. - /// - public interface IPropertyColumnMapper - { - /// - /// Gets the . - /// - IPropertyExpression PropertyExpression { get; } - - /// - /// Gets the source property name. - /// - string PropertyName { get; } - - /// - /// Gets the source property . - /// - Type PropertyType { get; } - - /// - /// Indicates whether the underlying source property is a complex type. - /// - bool IsSrcePropertyComplex { get; } - - /// - /// Gets the destination Dataverse column name. - /// - string ColumnName { get; } - - /// - /// Gets the selection to enable inclusion or exclusion of property (default to ). - /// - OperationTypes OperationTypes { get; } - - /// - /// Indicates whether the property forms part of the primary key. - /// - bool IsPrimaryKey { get; } - - /// - /// Indicates whether the primary key value maps to the underlying versus . - /// - bool IsPrimaryKeyUseEntityIdentifier { get; } - - /// - /// Sets the primary key (). - /// - /// Indicates whether the primary key value maps to the underlying versus . - void SetPrimaryKey(bool useEntityIdentifier = true); - - /// - /// Gets the (used where a specific source and destination type conversion is required). - /// - IConverter? Converter { get; } - - /// - /// Sets the . - /// - /// The . - /// The and are mutually exclusive. - void SetConverter(IConverter converter); - - /// - /// Gets the to map complex types. - /// - IDataverseMapper? Mapper { get; } - - /// - /// Set the to map complex types. - /// - /// The . - /// The and are mutually exclusive. - void SetMapper(IDataverseMapper mapper); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToDataverse(object value, Entity entity, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The . - /// The value. - /// The single value being performed to enable conditional execution where appropriate. - void MapFromDataverse(Entity entity, object value, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx.Dataverse/Mapping/PropertyColumnMapper.cs b/src/CoreEx.Dataverse/Mapping/PropertyColumnMapper.cs deleted file mode 100644 index 05d9fb47..00000000 --- a/src/CoreEx.Dataverse/Mapping/PropertyColumnMapper.cs +++ /dev/null @@ -1,229 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Mapping; -using CoreEx.Mapping.Converters; -using Microsoft.Xrm.Sdk; -using System; -using System.ComponentModel; -using System.Linq.Expressions; - -namespace CoreEx.Dataverse.Mapping -{ - /// - /// Provides bi-directional property and Dataverse column mapping. - /// - /// The source entity . - /// The corresponding source property . - public class PropertyColumnMapper : IPropertyColumnMapper where TSource : class, new() - { - private readonly PropertyExpression _propertyExpression; - - /// - /// Initializes a new instance of the class. - /// - /// The to reference the source property. - /// The Dataverse column name. Defaults to name. - /// The selection to enable inclusion or exclusion of property. - internal PropertyColumnMapper(Expression> propertyExpression, string? columnName = null, OperationTypes operationTypes = OperationTypes.Any) - { - _propertyExpression = Abstractions.Reflection.PropertyExpression.Create(propertyExpression); - ColumnName = columnName ?? PropertyName; - OperationTypes = operationTypes; - } - - /// - public IPropertyExpression PropertyExpression => _propertyExpression; - - /// - public string PropertyName => _propertyExpression.Name; - - /// - public Type PropertyType => typeof(TSourceProperty); - - /// - public bool IsSrcePropertyComplex => throw new NotImplementedException(); - - /// - public string ColumnName { get; internal set; } - - /// - public OperationTypes OperationTypes { get; internal set; } - - /// - public bool IsPrimaryKey { get; private set; } - - /// - public bool IsPrimaryKeyUseEntityIdentifier { get; private set; } - - /// - public IConverter? Converter { get; private set; } - - /// - public IDataverseMapper? Mapper { get; private set; } - - /// - void IPropertyColumnMapper.SetConverter(IConverter converter) - { - converter.ThrowIfNull(nameof(converter)); - - if (Mapper != null) - throw new InvalidOperationException("The Mapper and Converter cannot be both set; only one is permissible."); - - if (converter.SourceType != typeof(TSourceProperty)) - throw new InvalidOperationException($"The PropertyType '{PropertyType.Name}' and IConverter.SourceType '{converter.SourceType.Name}' must match."); - - Converter = converter; - } - - /// - /// Sets the . - /// - /// The . - /// The to support fluent-style method-chaining. - /// The and are mutually exclusive. - public PropertyColumnMapper SetConverter(IConverter converter) - { - ((IPropertyColumnMapper)this).SetConverter(converter); - return this; - } - - /// - void IPropertyColumnMapper.SetMapper(IDataverseMapper mapper) - { - mapper.ThrowIfNull(nameof(mapper)); - - if (Converter != null) - throw new InvalidOperationException("The Mapper and Converter cannot be both set; only one is permissible."); - - if (!_propertyExpression.IsClass) - throw new InvalidOperationException($"The PropertyType '{PropertyType.Name}' must be a class to set a Mapper."); - - if (mapper.SourceType != typeof(TSourceProperty)) - throw new ArgumentException($"The PropertyType '{PropertyType.Name}' and IDataverseMapper.SourceType '{mapper.SourceType.Name}' must match.", nameof(mapper)); - - if (IsPrimaryKey) - throw new InvalidOperationException("A Mapper can not be set for a primary key."); - - Mapper = mapper; - } - - /// - /// Sets the . - /// - /// The . - /// The to support fluent-style method-chaining. - /// The and are mutually exclusive. - public PropertyColumnMapper SetMapper(IDataverseMapper mapper) - { - ((IPropertyColumnMapper)this).SetMapper(mapper); - return this; - } - - /// - void IPropertyColumnMapper.SetPrimaryKey(bool useEntityIdentifier) - { - if (Mapper != null) throw new InvalidOperationException("A primary key must not contain a Mapper."); - - if (useEntityIdentifier) - { - if (PropertyType == typeof(Guid) || PropertyType == typeof(Guid?) || PropertyType == typeof(string)) - IsPrimaryKeyUseEntityIdentifier = true; - else - throw new InvalidOperationException($"The PropertyType '{PropertyType.Name}' must be a Guid or String to use the Entity.Id."); - } - - IsPrimaryKey = true; - } - - /// - /// Sets the primary key (). - /// - /// Indicates whether the primary key value maps to the underlying versus . - /// The to support fluent-style method-chaining. - public PropertyColumnMapper SetPrimaryKey(bool useEntityIdentifier = true) - { - ((IPropertyColumnMapper)this).SetPrimaryKey(useEntityIdentifier); - return this; - } - - /// - void IPropertyColumnMapper.MapToDataverse(object? value, Entity entity, OperationTypes operationType) => MapToDataverse((TSource?)value, entity, operationType); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - private void MapToDataverse(TSource? value, Entity entity, OperationTypes operationType) - { - if (value == null || !OperationTypes.HasFlag(operationType)) - return; - - var val = _propertyExpression.GetValue(value); - if (Mapper != null) - { - if (val != null) - Mapper.MapToDataverse(val, entity, operationType); - } - else - { - if (IsPrimaryKey && IsPrimaryKeyUseEntityIdentifier) - { - entity.Id = PropertyType == typeof(string) ? Guid.Parse(val?.ToString() ?? string.Empty) : (Guid)Convert.ChangeType(val, typeof(Guid))!; - return; - } - - var aval = Converter == null ? val : Converter.ConvertToDestination(val); - if (IsPrimaryKey) - entity.KeyAttributes.Add(ColumnName, aval); - else - entity.Attributes.Add(ColumnName, aval); - } - } - - /// - void IPropertyColumnMapper.MapFromDataverse(Entity entity, object value, OperationTypes operationType) => MapFromDataverse(entity, (TSource)value, operationType); - - /// - /// Maps from a updating the . - /// - /// The . - /// The value. - /// The single value being performed to enable conditional execution where appropriate. - private void MapFromDataverse(Entity entity, TSource value, OperationTypes operationType) - { - if (!OperationTypes.HasFlag(operationType)) - return; - - TSourceProperty? pval; - if (Mapper != null) - pval = (TSourceProperty?)Mapper.MapFromDataverse(entity, operationType); - else - { - if (IsPrimaryKey) - { - if (IsPrimaryKeyUseEntityIdentifier) - pval = (TSourceProperty?)TypeDescriptor.GetConverter(PropertyType).ConvertFromInvariantString(entity.Id.ToString()); - else - { - if (Converter is null) - pval = (TSourceProperty)entity.KeyAttributes[ColumnName]; - else - pval = (TSourceProperty)Converter.ConvertToSource(entity.KeyAttributes[ColumnName])!; - } - } - else - { - if (Converter is null) - pval = entity.GetAttributeValue(ColumnName); - else - pval = (TSourceProperty)Converter.ConvertToSource(entity.Attributes[ColumnName])!; - } - } - - _propertyExpression.SetValue(value, pval); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Dataverse/strong-name-key.snk b/src/CoreEx.Dataverse/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.DomainDriven/Aggregate.cs b/src/CoreEx.DomainDriven/Aggregate.cs new file mode 100644 index 00000000..59568caa --- /dev/null +++ b/src/CoreEx.DomainDriven/Aggregate.cs @@ -0,0 +1,43 @@ +namespace CoreEx.DomainDriven; + +/// +/// Provides the typed domain-driven aggregate functionality. +/// +/// The identifier . +/// The itself. +/// The identifier. +/// An aggregate is ostensibly an entity with additional support as enabled by the . The events are typically used for integration purposes to inform other systems of +/// changes that have occurred to/within the aggregate root. The events are not to be confused with domain events; which are not natively supported (this is by design). +/// The collection is a temporary storage for events that have been raised during the lifetime of the aggregate; these would then need to be forwarded (by the implementor) to the appropriate event handlers for processing. +/// It is expected that the implementor will adhere to the principles of domain-driven design and only expose read-only properties, and enable modification through methods, ensuring the invariant nature of the aggregate. +public class Aggregate(TId id) : Entity(id), IAggregateRoot where TSelf : Aggregate +{ + private readonly ICollection _events = []; + + /// + [JsonIgnore] + public IReadOnlyCollection Events => (IReadOnlyCollection)_events; + + /// + [JsonIgnore] + public bool HasEvents => _events.Count > 0; + + /// + /// Adds the specified to the aggregate. + /// + /// The . + protected TSelf AddEvent(EventData eventData) + { + _events.Add(eventData); + return (TSelf)this; + } + + /// + /// Clears all events from the aggregate. + /// + protected TSelf ClearEvents() + { + _events.Clear(); + return (TSelf)this; + } +} \ No newline at end of file diff --git a/src/CoreEx.DomainDriven/CoreEx.DomainDriven.csproj b/src/CoreEx.DomainDriven/CoreEx.DomainDriven.csproj new file mode 100644 index 00000000..daa4d6b7 --- /dev/null +++ b/src/CoreEx.DomainDriven/CoreEx.DomainDriven.csproj @@ -0,0 +1,11 @@ + + + net8.0;net9.0;net10.0 + true + + + + + + + diff --git a/src/CoreEx.DomainDriven/DomainDrivenExtensions.cs b/src/CoreEx.DomainDriven/DomainDrivenExtensions.cs new file mode 100644 index 00000000..3925f13b --- /dev/null +++ b/src/CoreEx.DomainDriven/DomainDrivenExtensions.cs @@ -0,0 +1,40 @@ +namespace CoreEx.DomainDriven; + +/// +/// Provides standard extension methods for domain-driven design (DDD) related functionality. +/// +public static class DomainDrivenExtensions +{ + extension(PersistenceState persistenceState) + { + /// + /// Indicates whether the is . + /// + public bool IsNew => persistenceState == PersistenceState.New; + + /// + /// Indicates whether the is . + /// + public bool IsNotModified => persistenceState == PersistenceState.NotModified; + + /// + /// Indicates whether the is . + /// + public bool IsModified => persistenceState == PersistenceState.Modified; + + /// + /// Indicates whether the is . + /// + public bool IsRemoved => persistenceState == PersistenceState.Removed; + + /// + /// Indicates whether the is not . + /// + public bool IsNotRemoved => persistenceState != PersistenceState.Removed; + + /// + /// Indicates whether the is or . + /// + public bool IsNewOrModified => persistenceState == PersistenceState.New || persistenceState == PersistenceState.Modified; + } +} \ No newline at end of file diff --git a/src/CoreEx.DomainDriven/Entity.cs b/src/CoreEx.DomainDriven/Entity.cs new file mode 100644 index 00000000..4c733194 --- /dev/null +++ b/src/CoreEx.DomainDriven/Entity.cs @@ -0,0 +1,110 @@ +namespace CoreEx.DomainDriven; + +/// +/// Provides the typed domain-driven entity functionality. +/// +/// The identifier . +/// The itself. +/// As stated by the domain-driven design literature, an entity is identified by its identity, not its attributes. Therefore, the underlying +/// only considers the value. +/// It is expected that the implementor will adhere to the principles of domain-driven design and only expose read-only properties, and enable modification through methods, ensuring the invariant nature of the entity. +/// The identifier. +public abstract class Entity(TId id) : EntityBase, IEntity, IReadOnlyIdentifier, IEquatable> where TSelf : Entity +{ + /// + public TId Id { get; } = id; + + /// + [JsonIgnore] + object? IIdentifierCore.Id => Id; + + /// + [JsonIgnore] + Type IIdentifierCore.IdType => typeof(TId); + + /// + [JsonIgnore] + public override CompositeKey EntityKey => CompositeKey.Create(Id); + + /// + /// Sets (overrides) the . + /// + /// The new . + /// This method does not check by design as this is considered independent to. + protected new TSelf SetPersistenceState(PersistenceState state) + { + base.SetPersistenceState(state); + return (TSelf)this; + } + + /// + /// Sets (overrides) the to . + /// + /// This method does not check by design as this is considered independent to. + protected TSelf AsNew() => SetPersistenceState(PersistenceState.New); + + /// + /// Sets (overrides) the to . + /// + /// This method does not check by design as this is considered independent to. + protected TSelf AsNotModified() => SetPersistenceState(PersistenceState.NotModified); + + /// + /// Makes the entity read-only. + /// + /// See . + protected new TSelf MakeReadOnly() + { + base.MakeReadOnly(); + return (TSelf)this; + } + + /// + /// Sets (overrides) the . + /// + /// The . + /// Bypasses checking and will not result in an change by design; intended to enable setting during hydration from a data source. + protected new TSelf SetChangeLog(ChangeLog? changeLog) + { + base.SetChangeLog(changeLog); + return (TSelf)this; + } + + /// + /// Sets (overrides) the . + /// + /// The entity tag. + /// Bypasses checking and will not result in an change by design; intended to enable setting during hydration from a data source. + protected new TSelf SetETag(string? eTag) + { + base.SetETag(eTag); + return (TSelf)this; + } + + /// + /// Uses for equality comparison leveraging the value (see underlying ). As stated by the + /// domain-driven design literature, an entity is identified by its identity, not its attributes. + public bool Equals(Entity? other) => ReferenceEquals(this, other) || Entities.CompositeKeyComparer.Default.Equals(this, other); + + /// + public override bool Equals(object? obj) => ReferenceEquals(this, obj) || Entities.CompositeKeyComparer.Default.Equals(this, obj); + + /// + public override int GetHashCode() => Entities.CompositeKeyComparer.Default.GetHashCode(this); + + /// + /// Determines whether two instances are equal. + /// + /// The first entity to compare. + /// The second entity to compare. + /// where ; otherwise, . + public static bool operator ==(Entity? a, Entity? b) => object.Equals(a, null) ? object.Equals(b, null) : a.Equals(b); + + /// + /// Determines whether two instances are not equal. + /// + /// The first entity to compare. + /// The second entity to compare. + /// where not ; otherwise, . + public static bool operator !=(Entity? a, Entity? b) => !(a == b); +} \ No newline at end of file diff --git a/src/CoreEx.DomainDriven/EntityBase.cs b/src/CoreEx.DomainDriven/EntityBase.cs new file mode 100644 index 00000000..8e8cd697 --- /dev/null +++ b/src/CoreEx.DomainDriven/EntityBase.cs @@ -0,0 +1,289 @@ +namespace CoreEx.DomainDriven; + +/// +/// Provides the base domain-driven entity functionality. +/// +public abstract class EntityBase : IEntity +{ + /// + /// Gets the read-only error message. + /// + public const string ReadOnlyErrorMessage = "The operation is not valid due to the current state being read-only."; + + /// + object? IIdentifierCore.Id => throw new NotImplementedException(); + + /// + [JsonIgnore] + public abstract CompositeKey EntityKey { get; } + + /// + [JsonIgnore] + Type IIdentifierCore.IdType => throw new NotImplementedException(); + + /// + [JsonIgnore] + bool IIdentifierCore.IsIdReadOnly => true; + + /// + void IIdentifierCore.SetIdentifier(object? id) => throw new InvalidOperationException("Identifier is read-only."); + + /// + [JsonIgnore] + public bool IsReadOnly { get; private set; } + + /// + [JsonIgnore] + public PersistenceState PersistenceState { get; private set; } + + /// + /// Gets the . + /// + public ChangeLog? ChangeLog { get; protected set; } + + /// + /// Gets the entity tag. + /// + public string? ETag { get; protected set; } + + /// + /// Sets (overrides) the . + /// + /// The new . + /// This method does not check by design as this is considered independent to. + protected void SetPersistenceState(PersistenceState state) + { + // Nothing doing! + if (state == PersistenceState) + return; + + // Validate state transition. + state.ThrowWhen(state => state == PersistenceState.Unknown, $"The {nameof(PersistenceState)} cannot be set to '{PersistenceState.Unknown}'."); + state.ThrowWhen(state => state == PersistenceState.NotModified && PersistenceState != PersistenceState.Unknown, $"The {nameof(PersistenceState)} can only be set to '{PersistenceState.NotModified}' from '{PersistenceState.Unknown}' state."); + + // Transition to the new state. + PersistenceState = state; + } + + /// + /// Encapsulates modification to the entity. + /// + /// The action that performs the entity modification. + /// Wraps the invocation of the by performing the following: + /// + /// . + /// . + /// Invokes the . + /// Sets to (where ). + /// . + /// Raises event. + /// + /// + protected void Modify(Action? action = null) + { + CheckCanMutate(); + CheckReadOnly(); + + action?.Invoke(); + + if (PersistenceState.IsNotModified) + SetPersistenceState(PersistenceState.Modified); + + OnMutate(); + Mutated?.Invoke(this, EventArgs.Empty); + } + + /// + /// Encapsulates modification to the entity with a corresponding result. + /// + /// The result . + /// The function that performs the entity modification. + /// The resulting value. + /// Wraps the invocation of the by performing the following: + /// + /// . + /// . + /// Invokes the . + /// Sets to (where ). + /// . + /// Raises event. + /// + /// + protected TResult Modify(Func function) + { + CheckCanMutate(); + CheckReadOnly(); + + var result = function.ThrowIfNull()(); + + if (PersistenceState.IsNotModified) + SetPersistenceState(PersistenceState.Modified); + + OnMutate(); + Mutated?.Invoke(this, EventArgs.Empty); + + return result; + } + + /// + /// Encapsulates modification to the entity, finally setting to read-only. + /// + /// The optional action that performs the entity modification. + /// Wraps the invocation of the by performing the following: + /// + /// . + /// . + /// Invokes the . + /// Sets to (where ). + /// . + /// Raises event. + /// . + /// + /// + protected void ModifyAndMakeReadOnly(Action? action = null) + { + Modify(action); + MakeReadOnly(); + } + + /// + /// Encapsulates modification to the entity with a corresponding result, finally setting to read-only. + /// + /// The result . + /// The function that performs the entity modification. + /// The resulting value. + /// Wraps the invocation of the by performing the following: + /// + /// . + /// . + /// Invokes the . + /// Sets to (where ). + /// . + /// Raises event. + /// . + /// + /// + protected TResult ModifyAndMakeReadOnly(Func function) + { + var result = Modify(function); + MakeReadOnly(); + return result; + } + + /// + /// Encapsulates marking for removal (deletion) of the entity, finally setting to read-only. + /// + /// The optional action that performs any entity modification. + /// Wraps the invocation of the by performing the following: + /// + /// . + /// . + /// Invokes the optional . + /// Sets to . + /// . + /// Raises event. + /// . + /// + /// + protected void Remove(Action? action = null) + { + CheckCanMutate(); + CheckReadOnly(); + + action?.Invoke(); + + SetPersistenceState(PersistenceState.Removed); + + OnMutate(); + MakeReadOnly(); + Mutated?.Invoke(this, EventArgs.Empty); + } + + /// + /// Encapsulates marking for removal (deletion) of the entity, finally setting to read-only. + /// + /// The result . + /// The function that performs any entity modification. + /// The resulting value. + /// Wraps the invocation of the by performing the following: + /// + /// . + /// . + /// Invokes the . + /// Sets to . + /// . + /// Raises event. + /// . + /// + /// + protected TResult Remove(Func function) + { + CheckCanMutate(); + CheckReadOnly(); + + var result = function.ThrowIfNull()(); + + SetPersistenceState(PersistenceState.Removed); + + OnMutate(); + Mutated?.Invoke(this, EventArgs.Empty); + MakeReadOnly(); + + return result; + } + + /// + /// Makes the entity read-only. + /// + /// See . + protected void MakeReadOnly() => IsReadOnly = true; + + /// + /// Checks whether the entity and if so throws an . + /// + protected void CheckReadOnly() + { + if (IsReadOnly) + throw new InvalidOperationException(ReadOnlyErrorMessage); + } + + /// + /// Checkes whether the entity can be mutated by invoking and throwing on error. + /// + protected void CheckCanMutate() => OnCheckCanMutate().ThrowOnError(); + + /// + /// Provides an opportunity to perform pre-checks prior to mutation of the entity; i.e. within the and methods. + /// + /// A indicating the outcome of the pre-checks. + /// This is invoked by . + protected virtual Result OnCheckCanMutate() => Result.Success; + + /// + /// Provides an opportunity to perform additional actions after mutation of the entity; i.e. within the and methods. + /// + /// This is invoked by the and methods. + protected virtual void OnMutate() { } + + /// + /// Occurs when the entity is mutated. + /// + public event EventHandler? Mutated; + + /// + /// Sets (overrides) the . + /// + /// The . + /// Bypasses checking and will not result in an change by design; intended to enable setting during hydration from a data source. + public void SetChangeLog(ChangeLog? changeLog) => ChangeLog = changeLog; + + /// + /// Sets (overrides) the . + /// + /// The entity tag. + /// Bypasses checking and will not result in an change by design; intended to enable setting during hydration from a data source. + public void SetETag(string? eTag) => ETag = eTag; + + /// + public override string? ToString() => ((IEntityKey)this).EntityKey.ToString(); +} \ No newline at end of file diff --git a/src/CoreEx.DomainDriven/GlobalUsing.cs b/src/CoreEx.DomainDriven/GlobalUsing.cs new file mode 100644 index 00000000..597a661b --- /dev/null +++ b/src/CoreEx.DomainDriven/GlobalUsing.cs @@ -0,0 +1,5 @@ +global using CoreEx.Entities; +global using CoreEx.Entities.Abstractions; +global using CoreEx.Events; +global using CoreEx.Results; +global using System.Text.Json.Serialization; \ No newline at end of file diff --git a/src/CoreEx.DomainDriven/IAggregateRoot.cs b/src/CoreEx.DomainDriven/IAggregateRoot.cs new file mode 100644 index 00000000..7102dcfc --- /dev/null +++ b/src/CoreEx.DomainDriven/IAggregateRoot.cs @@ -0,0 +1,22 @@ +namespace CoreEx.DomainDriven; + +/// +/// Enables the domain-driven aggregate functionality. +/// +/// An is an that acts as the root for a cluster of associated objects (entities and value objects). The aggregate supports related integration ; +/// however, the aggregate root does not support domain events (this is by design). +public interface IAggregateRoot : IEntity +{ + /// + /// Gets a read-only collection of events. + /// + /// Events are typically used for integration purposes to inform other systems of changes that have occurred to/within the aggregate root. These events are not to be confused with domain events; which are not natively supported (this is by design). + [JsonIgnore] + IReadOnlyCollection Events { get; } + + /// + /// Indicates whether the aggregate has any events. + /// + [JsonIgnore] + bool HasEvents { get; } +} \ No newline at end of file diff --git a/src/CoreEx.DomainDriven/IEntity.cs b/src/CoreEx.DomainDriven/IEntity.cs new file mode 100644 index 00000000..d912618f --- /dev/null +++ b/src/CoreEx.DomainDriven/IEntity.cs @@ -0,0 +1,19 @@ +namespace CoreEx.DomainDriven; + +/// +/// Enables the core domain-driven entity functionality. +/// +public interface IEntity : IReadOnlyIdentifier, IReadOnlyChangeLog, IReadOnlyETag +{ + /// + /// Gets the internal persistence state of the entity. + /// + [JsonIgnore] + PersistenceState PersistenceState { get; } + + /// + /// Indicates whether the entity is read-only. + /// + [JsonIgnore] + bool IsReadOnly { get; } +} \ No newline at end of file diff --git a/src/CoreEx.DomainDriven/PersistenceState.cs b/src/CoreEx.DomainDriven/PersistenceState.cs new file mode 100644 index 00000000..6239e742 --- /dev/null +++ b/src/CoreEx.DomainDriven/PersistenceState.cs @@ -0,0 +1,32 @@ +namespace CoreEx.DomainDriven; + +/// +/// Represents the internal persistence state of an entity. +/// +public enum PersistenceState +{ + /// + /// Unknown, i.e. not determined. + /// + Unknown, + + /// + /// New entity, i.e. not yet persisted. + /// + New, + + /// + /// Not modified, i.e. unchanged since last persisted. + /// + NotModified, + + /// + /// Modified, i.e. changed since last persisted. + /// + Modified, + + /// + /// Removed, i.e. marked for deletion; will be deleted upon next persistence. + /// + Removed +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/Converters/JsonElementStringConverter.cs b/src/CoreEx.EntityFrameworkCore/Converters/JsonElementStringConverter.cs new file mode 100644 index 00000000..3ff58fc2 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/Converters/JsonElementStringConverter.cs @@ -0,0 +1,12 @@ +namespace CoreEx.EntityFrameworkCore.Converters; + +/// +/// Provides a and . +/// +public sealed class JsonElementStringConverter() : ValueConverterBridge(Mapping.Converters.JsonElementStringConverter.Default) +{ + /// + /// Gets the default . + /// + public static JsonElementStringConverter Default { get; } = new(); +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/Converters/StringBase64Converter.cs b/src/CoreEx.EntityFrameworkCore/Converters/StringBase64Converter.cs new file mode 100644 index 00000000..f367f1cb --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/Converters/StringBase64Converter.cs @@ -0,0 +1,14 @@ +namespace CoreEx.EntityFrameworkCore.Converters; + +/// +/// Provides a to converter (uses and ). . +/// +public sealed class StringBase64Converter() : ValueConverter( + s => CoreEx.Mapping.Converters.StringBase64Converter.Default.ConvertToDestination(s), + a => CoreEx.Mapping.Converters.StringBase64Converter.Default.ConvertToSource(a)) +{ + /// + /// Gets the default . + /// + public static StringBase64Converter Default { get; } = new(); +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/Converters/ValueConverterBridge.cs b/src/CoreEx.EntityFrameworkCore/Converters/ValueConverterBridge.cs new file mode 100644 index 00000000..2bc8547d --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/Converters/ValueConverterBridge.cs @@ -0,0 +1,26 @@ +namespace CoreEx.EntityFrameworkCore.Converters; + +/// +/// Provides convenience methods. +/// +public static class ValueConverterBridge +{ + /// + /// Creates a new instance of the class using the specified converter. + /// + /// The model . + /// The provider . + /// The . + /// The instance. + public static ValueConverterBridge Create(Mapping.Converters.IConverter converter) => new(converter); + + /// + /// Creates a new instance of the class using the specified converter. + /// + /// The model . + /// The provider . + /// The . + /// The instance. + public static ValueConverterBridge Create(CoreEx.Mapping.Converters.IConverter converter) + => new(converter is Mapping.Converters.IConverter vc ? vc : throw new InvalidCastException("Invalid converter type.")); +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/Converters/ValueConverterBridgeT2.cs b/src/CoreEx.EntityFrameworkCore/Converters/ValueConverterBridgeT2.cs new file mode 100644 index 00000000..7a09d321 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/Converters/ValueConverterBridgeT2.cs @@ -0,0 +1,11 @@ +namespace CoreEx.EntityFrameworkCore.Converters; + +/// +/// Provides a implementation that uses the specified bridging the conversion logic. +/// +/// The model . +/// The provider . +/// The . +public class ValueConverterBridge(Mapping.Converters.IConverter converter) : ValueConverter( + model => converter.ThrowIfNull().ConvertToDestination(model), + provider => converter.ThrowIfNull().ConvertToSource(provider)) { } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj b/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj index f4385028..83d8a671 100644 --- a/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj +++ b/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj @@ -1,31 +1,12 @@  - - - net6.0;net8.0;net9.0 - CoreEx.EntityFrameworkCore - CoreEx - CoreEx .NET Entity Framework Core (EF) extras. - CoreEx .NET Entity Framework (EF) Core extras. - coreex api db database sql sqlserver ado.net entityframework entityframeworkcore ef efcore - enable - - - - - - - - - - - - - - - - + + + + + + \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/CoreExEfDbExtensions.DependencyInjection.cs b/src/CoreEx.EntityFrameworkCore/CoreExEfDbExtensions.DependencyInjection.cs new file mode 100644 index 00000000..c219ca2b --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/CoreExEfDbExtensions.DependencyInjection.cs @@ -0,0 +1,17 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure - this is by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides Entity Framework Core extensions. +/// +public static partial class CoreExEfDbExtensions +{ + /// + /// Adds a scoped service for the . + /// + /// The . + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddEfDb(this IServiceCollection services) where TEfDb : class, IEfDb => services.ThrowIfNull().AddScoped(); +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDb.cs b/src/CoreEx.EntityFrameworkCore/EfDb.cs index c7235af2..147d1da7 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDb.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDb.cs @@ -1,258 +1,76 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.EntityFrameworkCore; -using CoreEx.Database; -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.Results; -using Microsoft.EntityFrameworkCore; - -namespace CoreEx.EntityFrameworkCore +/// +/// Provides the extended -based Entity Framework Core capabilities. +/// +/// The . +public class EfDb : IEfDb, IDisposable where TDbContext : DbContext, IEfDbContext { + private readonly TDbContext _dbContext; + private bool _disposed; + /// - /// Provides the extended Entity Framework functionality. + /// Initializes a new instance of the class. /// - /// The . - /// Provides extended functionality to simply basic CRUD activities whilst also providing encapsuled mapping between an entity and the underlying model to minimise tight-coupling to the - /// underlying data source (and minimise the object-relational impedence mismatch. - /// Additionally, extended functionality is performed where the entity implements any of the following: - /// - /// - Will use the as the underlying entity key. - /// - Will automatically update the corresponding properties depending on where performing a Create or an Update. - /// - Will automatically update the from the to ensure not overridden. - /// - /// - /// Additionally, extended functionality is performed where the EF model implements any of the following: - /// - /// - Query, Get and Update will automatically - /// filter out previously deleted items. On a Delete the property will be automatically set and updated, - /// versus, performing a physical delete. Although the Query will automatically filter; it is also recommended to use the EF native filtering to - /// achieve; for example: entity.HasQueryFilter(v => v.IsDeleted != true);. - /// - /// - /// /// The . - /// The ; defaults to . - /// Enables the to be overridden; defaults to . - public class EfDb(TDbContext dbContext, IMapper? mapper = null, EfDbInvoker? invoker = null) : IEfDb where TDbContext : DbContext, IEfDbContext + /// The optional (typically a singleton service or statically declared). + /// The optional . + public EfDb(TDbContext dbContext, EfDbOptions? options = null, ExecutionContext? executionContext = null) { - /// - DbContext IEfDb.DbContext => DbContext; - - /// - public TDbContext DbContext { get; } = dbContext.ThrowIfNull(nameof(dbContext)); - - /// - public EfDbInvoker Invoker { get; } = invoker ?? new EfDbInvoker(); - - /// - public IDatabase Database => DbContext.BaseDatabase; - - /// - public IMapper Mapper { get; } = mapper.ThrowIfNull(nameof(mapper)); - - /// - public EfDbArgs DbArgs { get; set; } = new EfDbArgs(); - - /// - public EfDbQuery Query(EfDbArgs args, Func, IQueryable>? query = null) where TModel : class, new() => new(this, args, query); - - /// - public EfDbQuery Query(EfDbArgs args, Func, IQueryable>? query = null) where T : class, IEntityKey, new() where TModel : class, new() => new(this, args, query); - - /// - public async Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => (await GetWithResultInternalAsync(args, key, nameof(GetAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - public Task> GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => GetWithResultInternalAsync(args, key, nameof(GetWithResultAsync), cancellationToken); - - /// - /// Performs the get internal. - /// - private Task> GetWithResultInternalAsync(EfDbArgs args, CompositeKey key, string memberName, CancellationToken cancellationToken) where T : class, IEntityKey, new() where TModel : class, new() - => Result.GoAsync(() => GetWithResultInternalAsync(args, key, memberName, cancellationToken)) - .WhenAs(model => model is not null, model => - { - var val = Mapper.Map(model, OperationTypes.Get); - if (val is null) - return Result.Fail(new InvalidOperationException("Mapping from the EF model must not result in a null value.")); - else - return Result.Ok(val); - }); - - /// - async Task IEfDb.GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken) where TModel : class - => (await GetWithResultInternalAsync(args, key, nameof(GetAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - Task> IEfDb.GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken) where TModel : class - => GetWithResultInternalAsync(args, key, nameof(GetWithResultAsync), cancellationToken); - - /// - /// Performs the get internal (model-only). - /// - internal async Task> GetWithResultInternalAsync(EfDbArgs args, CompositeKey key, string memberName, CancellationToken cancellationToken) where TModel : class, new() => await Invoker.InvokeAsync(this, key, async (_, key, ct) => - { - var model = await DbContext.FindAsync([.. key.Args], cancellationToken).ConfigureAwait(false); - if (args.ClearChangeTrackerAfterGet) - DbContext.ChangeTracker.Clear(); - - if (!args.IsModelValid(model)) - return Result.Ok(default!); - - return Result.Ok(model); - }, cancellationToken, memberName).ConfigureAwait(false); - - /// - public async Task CreateAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => (await CreateWithResultInternalAsync(args, value, nameof(CreateAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - public Task> CreateWithResultAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => CreateWithResultInternalAsync(args, value, nameof(CreateWithResultAsync), cancellationToken); - - /// - /// Performs the create internal. - /// - private async Task> CreateWithResultInternalAsync(EfDbArgs args, T value, string memberName, CancellationToken cancellationToken) where T : class, IEntityKey, new() where TModel : class, new() - { - args.CheckSaveArgs(); - - return await Invoker.InvokeAsync(this, args, Cleaner.PrepareCreate(value.ThrowIfNull(nameof(value))), async (_, args, value, ct) => - { - TModel model = Mapper.Map(value, Mapping.OperationTypes.Create); - if (model == null) - return Result.Fail(new InvalidOperationException("Mapping to the EF model must not result in a null value.")); - - DbContext.Add(Cleaner.PrepareCreate(model)); - - if (args.SaveChanges) - await DbContext.SaveChangesAsync(true, ct).ConfigureAwait(false); - - return Result.Ok(CleanUpResult(args.Refresh ? Mapper.Map(model, OperationTypes.Get)! : value)); - }, cancellationToken, memberName).ConfigureAwait(false); - } - - /// - public async Task UpdateAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => (await UpdateWithResultInternalAsync(args, value, nameof(UpdateAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - public Task> UpdateWithResultAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => UpdateWithResultInternalAsync(args, value, nameof(UpdateWithResultAsync), cancellationToken); - - /// - /// Performs the update internal. - /// - private async Task> UpdateWithResultInternalAsync(EfDbArgs args, T value, string memberName, CancellationToken cancellationToken) where T : class, IEntityKey, new() where TModel : class, new() - { - args.CheckSaveArgs(); - - return await Invoker.InvokeAsync(this, args, Cleaner.PrepareUpdate(value.ThrowIfNull(nameof(value))), async (_, args, value, ct) => - { - // Check (find) if the entity exists. - var model = await DbContext.FindAsync(GetEfKeys(value), ct).ConfigureAwait(false); - if (!args.IsModelValid(model)) - return Result.NotFoundError(); - - // Check optimistic concurrency of etag/rowversion to ensure valid. This is needed as underlying EF uses the row version from the find above ignoring the value.ETag where overridden; this is needed to achieve. - if (value is IETag etag && Mapper.Map(model, OperationTypes.Get) is IETag etag2 && etag.ETag != etag2.ETag) - return Result.ConcurrencyError(); - - // Update (map) the model from the entity then perform a dbcontext update which will discover/track changes. - model = Mapper.Map(value, model, OperationTypes.Update); - - DbContext.Update(Cleaner.PrepareUpdate(model)); - - if (args.SaveChanges) - await DbContext.SaveChangesAsync(true, ct).ConfigureAwait(false); - - return Result.Ok(CleanUpResult(args.Refresh ? Mapper.Map(model, Mapping.OperationTypes.Get)! : value)); - }, cancellationToken, memberName).ConfigureAwait(false); - } - - /// - public Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => DeleteAsync(args, key, cancellationToken); - - /// - public Task DeleteWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => DeleteWithResultAsync(args, key, cancellationToken); - - /// - public async Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new() - => (await DeleteWithResultInternalAsync(args, key, nameof(DeleteAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - public Task DeleteWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new() - => DeleteWithResultInternalAsync(args, key, nameof(DeleteWithResultAsync), cancellationToken); - - /// - /// Performs the delete internal. - /// - private async Task DeleteWithResultInternalAsync(EfDbArgs args, CompositeKey key, string memberName, CancellationToken cancellationToken) where TModel : class, new() - { - args.CheckSaveArgs(); + _dbContext = dbContext.ThrowIfNull(); + Options = options ?? new EfDbOptions(); + ExecutionContext = executionContext ?? ExecutionContext.Current; + Database.UseTransactionChanged += Database_UseTransactionChanged; + } - return await Invoker.InvokeAsync(this, args, key, async (_, args, key, ct) => - { - // A pre-read is required to verify validity. - var model = await DbContext.FindAsync([.. key.Args], ct).ConfigureAwait(false); - if (!args.IsModelValid(model, checkIsDeleted: false)) - return Result.NotFoundError(); + /// + public DbContext DbContext => _dbContext; - // Delete; either logically or physically. - if (model is ILogicallyDeleted ld) - { - if (ld.IsDeleted.HasValue && ld.IsDeleted.Value) - return Result.NotFoundError(); + /// + public IDatabase Database => _dbContext.BaseDatabase; - ld.IsDeleted = true; - DbContext.Update(Cleaner.PrepareUpdate(model)); - } - else - DbContext.Remove(model); + /// + public EfDbOptions Options { get; } - if (args.SaveChanges) - await DbContext.SaveChangesAsync(true, ct).ConfigureAwait(false); + /// + public ExecutionContext ExecutionContext { get; } - return Result.Success; - }, cancellationToken, memberName).ConfigureAwait(false); - } + /// + public EfDbInvoker Invoker { get; set => field = value.ThrowIfNull(); } = EfDbInvoker.Default; - /// - /// Gets the Entity Framework keys from the value. - /// - /// The entity value. - /// The key values. - public virtual object?[] GetEfKeys(T value) where T : IEntityKey => [.. value.EntityKey.Args]; + /// + /// Gets the for the specified . + /// + /// The model . + /// The . + public EfDbModel Model() where TModel : class => new(this, Options.GetOrAddModelOptions()); - /// - /// Cleans up the result where specified within the args. - /// - private T CleanUpResult(T value) => DbArgs.CleanUpResult ? Cleaner.Clean(value) : value; + /// + /// Wires up the current transaction to the when the transaction changes on the . + /// + private void Database_UseTransactionChanged(object? sender, EventArgs e) => _dbContext.Database.UseTransaction(Database.CurrentTransaction); - /// - public void WithWildcard(string? with, Action action) - { - if (with != null) - { - with = Database.Wildcard.Replace(with); - if (with != null) - action?.Invoke(with); - } - } + /// + /// Dispose of resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - /// - public void With(T with, Action action) + /// + /// 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. + protected virtual void Dispose(bool disposing) + { + if (disposing && !_disposed) { - if (with is not null && Comparer.Default.Compare(with, default!) != 0) + if (!_disposed) { - if (with is not string && with is System.Collections.IEnumerable ie && !ie.GetEnumerator().MoveNext()) - return; - - action?.Invoke(); + Database.UseTransactionChanged -= Database_UseTransactionChanged; + _disposed = true; } } } diff --git a/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs b/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs index 63d5cc4e..3470e5eb 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs @@ -1,125 +1,43 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.EntityFrameworkCore; -using CoreEx.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.EntityFrameworkCore +/// +/// Provides the extended Entity Framework Core arguments. +/// +public record class EfDbArgs : DatabaseArgsBase { /// - /// Provides the extended Entity Framework arguments. + /// Indicates whether the query results will be tracked or not (see . /// - public struct EfDbArgs - { - /// - /// Initializes a new instance of the struct. - /// - public EfDbArgs() { } - - /// - /// Initializes a new instance of the struct. - /// - /// The template to copy from. - public EfDbArgs(EfDbArgs template) - { - SaveChanges = template.SaveChanges; - Refresh = template.Refresh; - QueryNoTracking = template.QueryNoTracking; - ClearChangeTrackerAfterGet = template.ClearChangeTrackerAfterGet; - CleanUpResult = template.CleanUpResult; - FilterByTenantId = template.FilterByTenantId; - FilterByIsDeleted = template.FilterByIsDeleted; - GetTenantId = template.GetTenantId; - } - - /// - /// Indicates that the underlying is to be performed automatically. Defaults to true. - /// - public bool SaveChanges { get; set; } = true; - - /// - /// Indicates whether the data should be refreshed (reselected where applicable) after a save operation (defaults to true); is dependent on being performed. - /// - public bool Refresh { get; set; } = true; - - /// - /// Indicates whether the will not track entities (see . Defaults to true in that the queried entities will not be tracked. - /// - public bool QueryNoTracking { get; set; } = true; - - /// - /// Indicates whether the performs a such that the retrieved entity is not tracked. Defaults to false. - /// - /// The implementation performs a - /// internally which automatically attaches and tracks the retrieved entity. - public bool ClearChangeTrackerAfterGet { get; set; } = false; - - /// - /// Indicates whether the result should be cleaned up. - /// - public bool CleanUpResult { get; set; } = false; - - /// - /// Indicates that when the underlying model implements it is to be filtered by the corresponding value. Defaults to true. - /// - public bool FilterByTenantId { get; set; } = true; - - /// - /// Indicates that when the underlying model implements it should filter out any models where the equals true. Defaults to true. - /// - public bool FilterByIsDeleted { get; set; } = true; - - /// - /// Gets or sets the get tenant identifier function; defaults to . - /// - public Func GetTenantId { get; set; } = () => ExecutionContext.HasCurrent ? ExecutionContext.Current.TenantId : null; + /// Defaults to ; in that there will be no tracking. + public bool QueryTracking { get; init; } = false; - /// - /// Checks the and properties to ensure that they are valid. - /// - public readonly void CheckSaveArgs() - { - if (Refresh && !SaveChanges) - throw new InvalidOperationException($"The {nameof(Refresh)} property cannot be set to true without the {nameof(SaveChanges)} also being set to true (given the save will occur after this method call)."); - } - - /// - /// Determines whether the model is considered valid; i.e. is not null, and where applicable, checks the and properties. - /// - /// The model . - /// The model value. - /// Indicates whether to perform the check. - /// Indicates whether to perform the check. - /// true indicates that the model is valid; otherwise, false. - /// This is used by the underlying operations to ensure the model is considered valid or not, and then handled accordingly. The query-based operations leverage the corresponding filter. - /// This leverages the to perform the check to ensure consistency of implementation. - public readonly bool IsModelValid([NotNullWhen(true)] TModel? model, bool checkIsDeleted = true, bool checkTenantId = true) where TModel : class - => model != default && WhereModelValid(new[] { model }.AsQueryable(), checkIsDeleted, checkTenantId).Any(); - - /// - /// Filters the to include only valid models; i.e. checks the and properties. - /// - /// The model . - /// The current query. - /// Indicates whether to perform the check. - /// Indicates whether to perform the check. - /// The updated query. - /// This is used by the underlying and to apply standardized filtering. - public readonly IQueryable WhereModelValid(IQueryable query, bool checkIsDeleted = true, bool checkTenantId = true) where TModel : class - { - query = query.ThrowIfNull(nameof(query)); + /// + /// 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. + public bool ClearChangeTrackerAfterGet { get; init; } = false; - if (checkTenantId && FilterByTenantId && typeof(ITenantId).IsAssignableFrom(typeof(TModel))) - { - var tenantId = GetTenantId(); - query = query.Where(x => ((ITenantId)x).TenantId == tenantId); - } + /// + /// Indicates that the underlying is to be performed automatically once the mutation operation is complete. + /// + /// Defaults to . + /// This is generally required for a mutation where the changes need to be realized (persisted); i.e. to get the likes of the generated row-version, etc. + public bool SaveChanges { get; set; } = true; - if (checkIsDeleted && FilterByIsDeleted && typeof(ILogicallyDeleted).IsAssignableFrom(typeof(TModel))) - query = query.Where(x => ((ILogicallyDeleted)x).IsDeleted == null || ((ILogicallyDeleted)x).IsDeleted == false); + /// + /// Indicates whether to bypass all configured filters (where allowed). + /// + /// This is an advanced feature that should only be used where specifically desired, and/or applying the filtering manually, to avoid unintended side-effects. + public bool BypassFilters { get; init; } = false; - return query; - } + /// + /// Checks the and combination to ensure that they are valid for the operation. + /// + internal void CheckRefreshAndSaveChangesCombination() + { + if (Refresh && !SaveChanges) + throw new InvalidOperationException($"The {nameof(Refresh)} property cannot be set to true without the {nameof(SaveChanges)} also being set to true (as the refresh is predicated on the save occurring)."); } } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs b/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs deleted file mode 100644 index b9ce2fbd..00000000 --- a/src/CoreEx.EntityFrameworkCore/EfDbEntity.cs +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; - -namespace CoreEx.EntityFrameworkCore -{ - /// - /// Provides a lightweight typed Entity Framework wrapper over the operations mapping from to (and vice versa). - /// - /// The resultant . - /// The entity framework model . - /// The . - public readonly struct EfDbEntity(IEfDb efDb) : IEfDbEntity where T : class, IEntityKey, new() where TModel : class, new() - { - /// - public IEfDb EfDb { get; } = efDb.ThrowIfNull(nameof(efDb)); - - /// - /// Creates an to enable select-like capabilities. - /// - /// The function to further define the query. - /// A . - public EfDbQuery Query(Func, IQueryable>? query = null) => Query(new EfDbArgs(EfDb.DbArgs), query); - - /// - /// Creates an to enable select-like capabilities. - /// - /// The . - /// The function to further define the query. - /// A . - public EfDbQuery Query(EfDbArgs queryArgs, Func, IQueryable>? query = null) => new(EfDb, queryArgs, query); - - #region Standard - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The key values. - /// The entity value where found; otherwise, null. - public Task GetAsync(params object?[] keys) => GetAsync(keys, default); - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The key values. - /// The . - /// The entity value where found; otherwise, null. - public Task GetAsync(object?[] keys, CancellationToken cancellationToken = default) => GetAsync(CompositeKey.Create(keys), cancellationToken); - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The . - /// The . - /// The entity value where found; otherwise, null. - /// Where the model implements and is true then null will be returned. - public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetAsync(new EfDbArgs(EfDb.DbArgs), key, cancellationToken); - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null. - /// Where the model implements and is true then null will be returned. - public Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => EfDb.GetAsync(args, key, cancellationToken); - - /// - /// Performs a create for the value (reselects and/or automatically saves changes where specified). - /// - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public Task CreateAsync(T value, CancellationToken cancellationToken = default) => CreateAsync(new EfDbArgs(EfDb.DbArgs), value, cancellationToken); - - /// - /// Performs a create for the value (reselects and/or automatically saves changes where specified). - /// - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public Task CreateAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) => EfDb.CreateAsync(args, value, cancellationToken); - - /// - /// Performs an update for the value (reselects and/or automatically saves changes where specified). - /// - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public Task UpdateAsync(T value, CancellationToken cancellationToken = default) => UpdateAsync(new EfDbArgs(EfDb.DbArgs), value, cancellationToken); - - /// - /// Performs an update for the value (reselects and/or automatically saves changes where specified). - /// - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public Task UpdateAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) => EfDb.UpdateAsync(args, value, cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The key values. - /// The entity value where found; otherwise, null. - /// Where the model implements and is true then a will be thrown. - public Task DeleteAsync(params object?[] keys) => DeleteAsync(keys, default); - - /// - /// Performs a delete for the specified . - /// - /// The key values. - /// The . - /// The entity value where found; otherwise, null. - /// Where the model implements and is true then a will be thrown. - public Task DeleteAsync(object?[] keys, CancellationToken cancellationToken = default) => DeleteAsync(CompositeKey.Create(keys), cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The . - /// The . - /// Where the model implements and is true then a will be thrown. - public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteAsync(new EfDbArgs(EfDb.DbArgs), key, cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The . - /// The . - /// The . - /// Where the model implements and is true then a will be thrown. - public Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => EfDb.DeleteAsync(args, key, cancellationToken); - - #endregion - - #region WithResult - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The key values. - /// The entity value where found; otherwise, null. - public Task> GetWithResultAsync(params object?[] keys) => GetWithResultAsync(keys, default); - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The key values. - /// The . - /// The entity value where found; otherwise, null. - public Task> GetWithResultAsync(object?[] keys, CancellationToken cancellationToken = default) => GetWithResultAsync(CompositeKey.Create(keys), cancellationToken); - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The . - /// The . - /// The entity value where found; otherwise, null. - /// Where the model implements and is true then null will be returned. - public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetWithResultAsync(new EfDbArgs(EfDb.DbArgs), key, cancellationToken); - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null. - /// Where the model implements and is true then null will be returned. - public Task> GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => EfDb.GetWithResultAsync(args, key, cancellationToken); - - /// - /// Performs a create for the value (reselects and/or automatically saves changes where specified) with a . - /// - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public Task> CreateWithResultAsync(T value, CancellationToken cancellationToken = default) => CreateWithResultAsync(new EfDbArgs(EfDb.DbArgs), value, cancellationToken); - - /// - /// Performs a create for the value (reselects and/or automatically saves changes where specified) with a . - /// - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public Task> CreateWithResultAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) => EfDb.CreateWithResultAsync(args, value, cancellationToken); - - /// - /// Performs an update for the value (reselects and/or automatically saves changes where specified) with a . - /// - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public Task> UpdateWithResultAsync(T value, CancellationToken cancellationToken = default) => UpdateWithResultAsync(new EfDbArgs(EfDb.DbArgs), value, cancellationToken); - - /// - /// Performs an update for the value (reselects and/or automatically saves changes where specified) with a . - /// - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public Task> UpdateWithResultAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) => EfDb.UpdateWithResultAsync(args, value, cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The key values. - /// The entity value where found; otherwise, null. - /// Where the model implements and is true then a will be thrown. - public Task DeleteWithResultAsync(params object?[] keys) => DeleteWithResultAsync(keys, default); - - /// - /// Performs a delete for the specified with a . - /// - /// The key values. - /// The . - /// The entity value where found; otherwise, null. - /// Where the model implements and is true then a will be thrown. - public Task DeleteWithResultAsync(object?[] keys, CancellationToken cancellationToken = default) => DeleteWithResultAsync(CompositeKey.Create(keys), cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The . - /// The . - /// Where the model implements and is true then a will be thrown. - public Task DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteWithResultAsync(new EfDbArgs(EfDb.DbArgs), key, cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The . - /// The . - /// The . - /// Where the model implements and is true then a will be thrown. - public Task DeleteWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => EfDb.DeleteWithResultAsync(args, key, cancellationToken); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs b/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs index ae0b42a4..ca9ab2db 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbExtensions.cs @@ -1,446 +1,147 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.EntityFrameworkCore; -using CoreEx.Entities; -using CoreEx.RefData; -using CoreEx.Results; - -namespace CoreEx.EntityFrameworkCore +/// +/// Provides Entity Framework Core extensions. +/// +public static partial class EfDbExtensions { /// - /// Provides the extended Entity Framework extension methods. + /// Creates a from a using the specified . /// - public static class EfDbExtensions + /// The source . + /// The item collection . + /// The item . + /// The . + /// The mapping . + /// The . + public static async Task ToMappedItemsAsync(this IQueryable query, Func mapper, CancellationToken cancellationToken = default) where TColl : ICollection, new() { - /// - /// Creates an to enable select-like capabilities. - /// - /// The entity framework model . - /// The . - /// The function to further define the query. - /// Optionally override the specified/default . - /// A . - public static EfDbQuery Query(this IEfDb efDb, Func, IQueryable>? query = null, bool? noTracking = null) where TModel : class, new() - { - var ea = new EfDbArgs(efDb.DbArgs); - if (noTracking.HasValue) - ea.QueryNoTracking = noTracking.Value; + mapper.ThrowIfNull(); - return efDb.Query(ea, query); - } + var q = query.ThrowIfNull(); + var items = new TColl(); - /// - /// Creates an to enable select-like capabilities. - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The function to further define the query. - /// Optionally override the specified/default . - /// A . - public static EfDbQuery Query(this IEfDb efDb, Func, IQueryable>? query = null, bool? noTracking = null) where T : class, IEntityKey, new() where TModel : class, new() + await foreach (var source in q.AsAsyncEnumerable().WithCancellation(cancellationToken).ConfigureAwait(false)) { - var ea = new EfDbArgs(efDb.DbArgs); - if (noTracking.HasValue) - ea.QueryNoTracking = noTracking.Value; - - return efDb.Query(ea, query); + items.Add(mapper(source)); } - #region Standard - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The key. - /// The . - /// The entity value where found; otherwise, null. - public static Task GetAsync(this IEfDb efDb, EfDbArgs args, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => efDb.GetAsync(args, CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The key value. - /// The . - /// The entity value where found; otherwise, null. - public static Task GetAsync(this IEfDb efDb, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => efDb.GetAsync(new EfDbArgs(efDb.DbArgs), CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null. - public static Task GetAsync(this IEfDb efDb, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => efDb.GetAsync(new EfDbArgs(efDb.DbArgs), key, cancellationToken); - - /// - /// Performs a create for the value (reselects and/or automatically saves changes). - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static Task CreateAsync(this IEfDb efDb, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => efDb.CreateAsync(new EfDbArgs(efDb.DbArgs), value, cancellationToken); - - /// - /// Performs an update for the value (reselects and/or automatically saves changes). - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static Task UpdateAsync(this IEfDb efDb, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => efDb.UpdateAsync(new EfDbArgs(efDb.DbArgs), value, cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The key value. - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteAsync(this IEfDb efDb, EfDbArgs args, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => efDb.DeleteAsync(args, CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The key value. - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteAsync(this IEfDb efDb, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => efDb.DeleteAsync(new EfDbArgs(efDb.DbArgs), CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteAsync(this IEfDb efDb, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => efDb.DeleteAsync(new EfDbArgs(efDb.DbArgs), key, cancellationToken); - - #endregion - - #region WithResult - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The key. - /// The . - /// The entity value where found; otherwise, null. - public static Task> GetWithResultAsync(this IEfDb efDb, EfDbArgs args, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => efDb.GetWithResultAsync(args, CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The key value. - /// The . - /// The entity value where found; otherwise, null. - public static Task> GetWithResultAsync(this IEfDb efDb, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => efDb.GetWithResultAsync(new EfDbArgs(efDb.DbArgs), CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null. - public static Task> GetWithResultAsync(this IEfDb efDb, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => efDb.GetWithResultAsync(new EfDbArgs(efDb.DbArgs), key, cancellationToken); - - /// - /// Performs a create for the value (reselects and/or automatically saves changes) with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static Task> CreateWithResultAsync(this IEfDb efDb, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => efDb.CreateWithResultAsync(new EfDbArgs(efDb.DbArgs), value, cancellationToken); - - /// - /// Performs an update for the value (reselects and/or automatically saves changes) with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static Task> UpdateWithResultAsync(this IEfDb efDb, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => efDb.UpdateWithResultAsync(new EfDbArgs(efDb.DbArgs), value, cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The key value. - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteWithResultAsync(this IEfDb efDb, EfDbArgs args, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => efDb.DeleteWithResultAsync(args, CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The key value. - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteWithResultAsync(this IEfDb efDb, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => efDb.DeleteWithResultAsync(new EfDbArgs(efDb.DbArgs), CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteWithResultAsync(this IEfDb efDb, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => efDb.DeleteWithResultAsync(new EfDbArgs(efDb.DbArgs), key, cancellationToken); - - #endregion - - #region With - - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, string? with, Action action) => efDb.With(with, () => action(with!)); - - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, int? with, Action action) => efDb.With(with, () => action(with!.Value)); - - /// - /// Invokes the when the is not default. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, int with, Action action) => efDb.With(with, () => action(with)); - - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, short? with, Action action) => efDb.With(with, () => action(with!.Value)); - - /// - /// Invokes the when the is not default. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, short with, Action action) => efDb.With(with, () => action(with)); - - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, long? with, Action action) => efDb.With(with, () => action(with!.Value)); - - /// - /// Invokes the when the is not default. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, long with, Action action) => efDb.With(with, () => action(with)); - - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, decimal? with, Action action) => efDb.With(with, () => action(with!.Value)); - - /// - /// Invokes the when the is not default. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, decimal with, Action action) => efDb.With(with, () => action(with)); - - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, float? with, Action action) => efDb.With(with, () => action(with!.Value)); - - /// - /// Invokes the when the is not default. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, float with, Action action) => efDb.With(with, () => action(with)); - - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, double? with, Action action) => efDb.With(with, () => action(with!.Value)); + return items; + } - /// - /// Invokes the when the is not default. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, double with, Action action) => efDb.With(with, () => action(with)); + /// + /// Creates a from a using the specified . + /// + /// The source . + /// The item collection . + /// The item . + /// The . + /// 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); - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, DateTime? with, Action action) => efDb.With(with, () => action(with!.Value)); + /// + /// Creates a from a using the specified . + /// + /// The source . + /// The item . + /// The . + /// The mapping . + /// The . + public static async Task> ToMappedItemsAsync(this IQueryable query, Func mapper, CancellationToken cancellationToken = default) + { + mapper.ThrowIfNull(); - /// - /// Invokes the when the is not default. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, DateTime with, Action action) => efDb.With(with, () => action(with)); + var q = query.ThrowIfNull(); + var items = new List(); - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, TimeSpan? with, Action action) => efDb.With(with, () => action(with!.Value)); + await foreach (var source in q.AsAsyncEnumerable().WithCancellation(cancellationToken).ConfigureAwait(false)) + { + items.Add(mapper(source)); + } - /// - /// Invokes the when the is not default. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, TimeSpan with, Action action) => efDb.With(with, () => action(with)); + return items; + } - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, bool? with, Action action) => efDb.With(with, () => action(with!.Value)); + /// + /// Creates a from a using the specified . + /// + /// The source . + /// The item . + /// The . + /// 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); - /// - /// Invokes the when the is not default. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, bool with, Action action) => efDb.With(with, () => action(with)); + /// + /// Creates a from an applying (including with where requested). + /// + /// The item . + /// The . + /// The . + /// Indicates whether to perform the query automatically. + /// The . + /// The resulting . + /// The 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> ToItemsResultAsync(this IQueryable query, PagingArgs? paging = null, bool autoCount = true, CancellationToken cancellationToken = default) + { + var q = query.ThrowIfNull(); + var ir = new ItemsResult(paging) + { + Items = await q.WithPaging(paging).ToArrayAsync(cancellationToken).ConfigureAwait(false) + }; - /// - /// Invokes the when the is not null. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, char? with, Action action) => efDb.With(with, () => action(with!.Value)); + // When auto-counting and requested to do so, then execute a count. + if (autoCount) + await ir.WithTotalCountAsync(async cancellationToken => await query.LongCountAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - /// - /// Invokes the when the is not default. - /// - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, char with, Action action) => efDb.With(with, () => action(with)); + return ir; + } - /// - /// Invokes the when the is not null. - /// - /// The . - /// - /// - /// - public static void With(this IEfDb efDb, TRef? with, Action action) where TRef : IReferenceData => efDb.With(with, () => action(with!)); + /// + /// Creates a from a applying (including with where requested). + /// + /// The source . + /// The item . + /// The . + /// The mapping . + /// The . + /// Indicates whether to perform the query automatically. + /// The . + /// The resulting . + /// The 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, Func mapper, PagingArgs? paging = null, bool autoCount = true, CancellationToken cancellationToken = default) + { + var q = query.ThrowIfNull(); + var ir = new ItemsResult(paging) + { + Items = await ToMappedItemsAsync(q.WithPaging(paging), mapper, cancellationToken).ConfigureAwait(false) + }; - /// - /// Invokes the when the is not null. - /// - /// The . - /// The . - /// The value with which to verify. - /// The to invoke when there is a valid value. - public static void With(this IEfDb efDb, ReferenceDataCodeList? with, Action> action) where TRef : class, IReferenceData, new() => efDb.With(with, () => action(with!)); + // When auto-counting and requested to do so, then execute a count. + if (autoCount) + await ir.WithTotalCountAsync(async cancellationToken => await query.LongCountAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - #endregion + return ir; } + + /// + /// Creates a from a applying (including with where requested). + /// + /// The source . + /// The item . + /// The . + /// The mapping . + /// The . + /// Indicates whether to perform the query automatically. + /// The . + /// The resulting . + /// The 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); } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbInvoker.cs b/src/CoreEx.EntityFrameworkCore/EfDbInvoker.cs index bd622d5a..ab103b11 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbInvoker.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbInvoker.cs @@ -1,53 +1,70 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using Microsoft.Extensions.Logging; -using CoreEx.Database; -using CoreEx.Invokers; -using CoreEx.Results; -using Microsoft.EntityFrameworkCore; -using System.Data.Common; -using System.Reflection; +namespace CoreEx.EntityFrameworkCore; -namespace CoreEx.EntityFrameworkCore +/// +/// Provides the standard invoker functionality. +/// +/// Catches any unhandled and invokes to handle (where ) is +/// before bubbling up. Also, catches and handles the and where applicable. +[InvokerName("CoreEx.EntityFrameworkCore.EfDb")] +public class EfDbInvoker : InvokerBase { + private static EfDbInvoker? _default; + /// - /// Provides the standard invoker functionality. + /// Gets the default instance. /// - /// Catches any unhandled or and invokes to handle before bubbling up. - public class EfDbInvoker : InvokerBase + public static EfDbInvoker Default => ExecutionContext.GetService() ?? (_default ??= new EfDbInvoker()); + + /// + public override bool IsTracingDisabled => true; + + /// + protected override async Task OnInvokeAsync(InvokerTracer tracer, IEfDb ef, EfDbArgs efArgs, Func> func, CancellationToken cancellationToken) { - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, IEfDb efDb, Func func) => throw new NotSupportedException(); + try + { + // Ensure any ambient transaction is used. + if (ef.Database.IsInTransaction && ef.DbContext.Database.CurrentTransaction is null) + await ef.DbContext.Database.UseTransactionAsync(ef.Database.CurrentTransaction, cancellationToken).ConfigureAwait(false); - /// - protected async override Task OnInvokeAsync(InvokeArgs invokeArgs, IEfDb efdb, Func> func, CancellationToken cancellationToken) + // Invoke the EF operation. + return await base.OnInvokeAsync(tracer, ef, efArgs, func, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (efArgs.TransformException) { - try - { - return await base.OnInvokeAsync(invokeArgs, efdb, func, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) + var hex = HandleDbException(ef, ex); + if (hex is not null) { - Result? eresult = null; - if (ex is DbException dbex) - eresult = efdb.Database.HandleDbException(dbex); - else if (ex is DbUpdateConcurrencyException) - eresult = Result.Fail(new ConcurrencyException()); - else if (ex is DbUpdateException deux && deux.InnerException != null && deux.InnerException is DbException dbex2) - eresult = efdb.Database.HandleDbException(dbex2); - else if (ex is TargetInvocationException tiex && tiex.InnerException is DbException dbex3) - eresult = efdb.Database.HandleDbException(dbex3); - - if (eresult.HasValue && eresult.Value.IsFailure && eresult.Value.Error is CoreEx.Abstractions.IExtendedException) - { - var dresult = default(TResult); - if (dresult is IResult dir) - return (TResult)dir.ToFailure(eresult.Value.Error); - else - eresult.Value.ThrowOnError(); - } - - throw; + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.Logger.LogDebug(ex, "Database exception converted to '{ExceptionType}': {Message} [DatabaseId: {DatabaseId}]", hex.GetType().Name, hex.Message, ef.Database.DatabaseId); + + // Where the result is an IResult (ROP) and the exception is considered an error then return as an IResult _failure_. + if (ExtendedException.TryConvertExceptionToResult(hex, out var res)) + return res; + + throw hex; } + + throw; } } + + /// + /// Handle the various exceptions scenarios. + /// + private static Exception? HandleDbException(IEfDb ef, Exception ex) + { + if (ex is DbException dbex) + return ef.Database.HandleDbException(dbex); + else if (ex is DbUpdateConcurrencyException duex) + return new ConcurrencyException(null, duex); + else if (ex is DbUpdateException deux && deux.InnerException is not null && deux.InnerException is DbException dbex2) + return ef.Database.HandleDbException(dbex2); + else if (ex is TargetInvocationException tiex && tiex.InnerException is DbException dbex3) + return ef.Database.HandleDbException(dbex3); + else + return null; + } } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Create.cs b/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Create.cs new file mode 100644 index 00000000..7e763906 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Create.cs @@ -0,0 +1,46 @@ +namespace CoreEx.EntityFrameworkCore; + +public partial class EfDbMappedModel +{ + /// + /// Creates the . + /// + /// The value. + /// The . + /// The containing the created value. + public Task> CreateAsync(TValue value, CancellationToken cancellationToken = default) => CreateAsync(Model.Args, value, cancellationToken); + + /// + /// Creates the . + /// + /// The . + /// The value. + /// The . + /// The containing the created value. + public async Task> CreateAsync(EfDbArgs args, TValue value, CancellationToken cancellationToken = default) + { + var r = await Model.CreateAsync(args, Mapper.To.Map(value), cancellationToken).ConfigureAwait(false); + return new DataResult(Mapper.From.Map(r.Value), r.WasMutated); + } + + /// + /// Creates the . + /// + /// The value. + /// The . + /// The containing the created value. + public Task>> CreateWithResultAsync(TValue value, CancellationToken cancellationToken = default) => CreateWithResultAsync(Model.Args, value, cancellationToken); + + /// + /// Creates the . + /// + /// The . + /// The value. + /// The . + /// The containing the created value. + public async Task>> CreateWithResultAsync(EfDbArgs args, TValue value, CancellationToken cancellationToken = default) + { + var r = await Model.CreateWithResultAsync(args, Mapper.To.Map(value), cancellationToken).ConfigureAwait(false); + return r.ThenAs(dr => new DataResult(Mapper.From.Map(dr.Value)!, dr.WasMutated)); + } +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Delete.cs b/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Delete.cs new file mode 100644 index 00000000..86db1176 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Delete.cs @@ -0,0 +1,42 @@ +namespace CoreEx.EntityFrameworkCore; + +public partial class EfDbMappedModel +{ + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// A + /// A delete is considered idempotent and as such no will be thrown. The returning is informational only. + public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteAsync(Model.Args, key, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// The . + /// A + /// A delete is considered idempotent and as such no will be thrown. The returning is informational only. + public Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => Model.DeleteAsync(args, key, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// A + /// A delete is considered idempotent and as such no will be thrown. The returning is informational only. + public Task> DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteWithResultAsync(Model.Args, key, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// The . + /// A + /// A delete is considered idempotent and as such no will be thrown. The returning is informational only. + public Task> DeleteWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => Model.DeleteWithResultAsync(args, key, cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Get.cs b/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Get.cs new file mode 100644 index 00000000..46a97fbc --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Get.cs @@ -0,0 +1,46 @@ +namespace CoreEx.EntityFrameworkCore; + +public partial class EfDbMappedModel +{ + /// + /// Gets the value for the specified . + /// + /// The . + /// The . + /// The value where found; otherwise, . + public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetAsync(Model.Args, key, cancellationToken); + + /// + /// Gets the value for the specified . + /// + /// The . + /// The . + /// The . + /// The value where found; otherwise, . + public async Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + { + var m = await Model.GetAsync(args, key, cancellationToken).ConfigureAwait(false); + return Mapper.From.Map(m); + } + + /// + /// Gets the value for the specified . + /// + /// The . + /// The . + /// The value. + public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetWithResultAsync(Model.Args, key, cancellationToken); + + /// + /// Gets the value for the specified . + /// + /// The . + /// The . + /// The . + /// The value. + public async Task> GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + { + var r = await Model.GetWithResultAsync(args, key, cancellationToken).ConfigureAwait(false); + return r.IsSuccess ? Mapper.From.Map(r.Value) : r.Bind(); + } +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Update.cs b/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Update.cs new file mode 100644 index 00000000..81e0ead0 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.Update.cs @@ -0,0 +1,46 @@ +namespace CoreEx.EntityFrameworkCore; + +public partial class EfDbMappedModel +{ + /// + /// Updates the . + /// + /// The value. + /// The . + /// The containing the updated value. + public Task> UpdateAsync(TValue value, CancellationToken cancellationToken = default) => UpdateAsync(Model.Args, value, cancellationToken); + + /// + /// Updates the . + /// + /// The . + /// The value. + /// The . + /// The containing the updated value. + public async Task> UpdateAsync(EfDbArgs args, TValue value, CancellationToken cancellationToken = default) + { + var r = await Model.UpdateAsync(args, Mapper.To.Map(value), cancellationToken).ConfigureAwait(false); + return new DataResult(Mapper.From.Map(r.Value), r.WasMutated); + } + + /// + /// Updates the . + /// + /// The value. + /// The . + /// The containing the updated value. + public Task>> UpdateWithResultAsync(TValue value, CancellationToken cancellationToken = default) => UpdateWithResultAsync(Model.Args, value, cancellationToken); + + /// + /// Updates the . + /// + /// The . + /// The value. + /// The . + /// The containing the updated value. + public async Task>> UpdateWithResultAsync(EfDbArgs args, TValue value, CancellationToken cancellationToken = default) + { + var r = await Model.UpdateWithResultAsync(args, Mapper.To.Map(value), cancellationToken).ConfigureAwait(false); + return r.ThenAs(dr => new DataResult(Mapper.From.Map(dr.Value)!, dr.WasMutated)); + } +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.cs b/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.cs new file mode 100644 index 00000000..6cc608c4 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbMappedModel.cs @@ -0,0 +1,33 @@ +namespace CoreEx.EntityFrameworkCore; + +/// +/// Provides the extended Entity Framework Core mapped value to/from model functionality. +/// +/// The value . +/// The model . +/// The . +/// Note: the does not provide a Query method equivalent to by design. This is because queries +/// are tightly-coupled to the model and the enables where applicable. +public partial class EfDbMappedModel where TValue : class where TModel : class where TBiDirectionMapper : IBiDirectionMapper +{ + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + internal EfDbMappedModel(EfDbModel efDbModel, TBiDirectionMapper mapper) + { + Model = efDbModel.ThrowIfNull(); + Mapper = mapper.ThrowIfNull(); + } + + /// + /// Gets the underlying . + /// + public EfDbModel Model { get; } + + /// + /// Gets the . + /// + public TBiDirectionMapper Mapper { get; } +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.Create.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.Create.cs new file mode 100644 index 00000000..3ae82b36 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.Create.cs @@ -0,0 +1,77 @@ +namespace CoreEx.EntityFrameworkCore; + +public partial class EfDbModel +{ + /// + /// Creates the . + /// + /// The model. + /// The . + /// The containing the created model. + public Task> CreateAsync(TModel model, CancellationToken cancellationToken = default) => CreateAsync(Args, model, cancellationToken); + + /// + /// Creates the . + /// + /// The . + /// The model. + /// The . + /// The containing the created model. + public async Task> CreateAsync(EfDbArgs args, TModel model, CancellationToken cancellationToken = default) => (await CreateWithResultInternalAsync(args, model, nameof(CreateAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates the . + /// + /// The model. + /// The . + /// The containing the created model. + public Task>> CreateWithResultAsync(TModel model, CancellationToken cancellationToken = default) => CreateWithResultAsync(Args, model, cancellationToken); + + /// + /// Creates the . + /// + /// The . + /// The model. + /// The . + /// The containing the created model. + public Task>> CreateWithResultAsync(EfDbArgs args, TModel model, CancellationToken cancellationToken = default) => CreateWithResultInternalAsync(args, model, nameof(CreateWithResultAsync), cancellationToken); + + /// + /// Creates the model (internal). + /// + private async Task>> CreateWithResultInternalAsync(EfDbArgs args, TModel model, string memberName, CancellationToken cancellationToken) + { + model.ThrowIfNull(); + + if (model is IReadOnlyLogicallyDeleted ld && ld.IsDeleted) + throw new InvalidOperationException($"Cannot create a model with a deleted state; {nameof(ILogicallyDeleted.IsDeleted)} must be false."); + + args.CheckRefreshAndSaveChangesCombination(); + + return await EfDb.Invoker.InvokeAsync(EfDb, args, async (_, args, cancellationToken) => + { + // Prepare the model. + var br = Options.OnBeforeCreateOrUpdate(model, OperationType.Create); + if (br.IsFailure) + return br; + + Mapper.MapChangeLogInto(ChangeLog.Empty, model); + Model.PrepareCreate(model, EfDb.ExecutionContext); + + // Check model is valid. + var r = CheckModel(args, model, OperationType.Create); + if (r.IsFailure) + return r.Bind(); + + // EF add (create). + EfDb.DbContext.Add(model); + + if (args.SaveChanges) + await EfDb.DbContext.SaveChangesAsync(true, cancellationToken).ConfigureAwait(false); + + // Refresh as required. + var pr = await RefreshPostMutationAsync(args, model, memberName, cancellationToken).ConfigureAwait(false); + return pr.ThenAs(m => new DataResult(m, true)); + }, cancellationToken, memberName).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.Delete.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.Delete.cs new file mode 100644 index 00000000..a60ddb48 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.Delete.cs @@ -0,0 +1,94 @@ +namespace CoreEx.EntityFrameworkCore; + +public partial class EfDbModel +{ + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// A + /// A delete is considered idempotent and as such no will be thrown. The returning is informational only. + public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteAsync(Args, key, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// The . + /// A + /// A delete is considered idempotent and as such no will be thrown. The returning is informational only. + public async Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => (await DeleteWithResultInternalAsync(args, key, nameof(DeleteAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// A + /// A delete is considered idempotent and as such no will be thrown. The returning is informational only. + public Task> DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteWithResultAsync(Args, key, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// The . + /// A + /// A delete is considered idempotent and as such no will be thrown. The returning is informational only. + public Task> DeleteWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => DeleteWithResultInternalAsync(args, key, nameof(DeleteWithResultAsync), cancellationToken); + + /// + /// Deletes the model (internal). + /// + private async Task> DeleteWithResultInternalAsync(EfDbArgs args, CompositeKey key, string memberName, CancellationToken cancellationToken = default) => await EfDb.Invoker.InvokeAsync(EfDb, args, async (_, args, cancellationToken) => + { + // Check (find) to determine if the model exists. + var model = await EfDb.DbContext.FindAsync([.. key.Args], cancellationToken).ConfigureAwait(false); + var cmr = CheckModel(args, model, OperationType.Delete, treatNullAsNotFound: true); + + // Where not found, return false (a delete is considered idempotent). + if (cmr.IsNotFoundError) + return Result.Ok(DataResult.False); + + if (cmr.IsFailure) + return cmr.Bind(); + + // Delete or logical delete as appropriate. + try + { + switch (Options.LogicalDeleteSupport) + { + // 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 (update). + case FeatureSupport.Mutable: + var ld = (ILogicallyDeleted)model!; + ld.IsDeleted = true; + Model.PrepareUpdate(model, EfDb.ExecutionContext); + + EfDb.DbContext.Update(model!); + break; + } + + 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); + } + }, cancellationToken, memberName).ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.Get.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.Get.cs new file mode 100644 index 00000000..404be74d --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.Get.cs @@ -0,0 +1,52 @@ +namespace CoreEx.EntityFrameworkCore; + +public partial class EfDbModel +{ + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The model where found; otherwise, . + public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetAsync(Args, key, cancellationToken); + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The . + /// The model where found; otherwise, . + public async Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + => (await GetWithResultInternalAsync(args, key, nameof(GetAsync), false, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The model. + public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetWithResultAsync(Args, key, cancellationToken); + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The . + /// The model. + public async Task> GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + => (await GetWithResultInternalAsync(args, key, nameof(GetWithResultAsync), true, cancellationToken).ConfigureAwait(false)).ThenAs(v => v!); + + /// + /// Gets the model (internal). + /// + private async Task> GetWithResultInternalAsync(EfDbArgs args, CompositeKey key, string memberName, bool treatNullAsNotFound, CancellationToken cancellationToken) => await EfDb.Invoker.InvokeAsync(EfDb, args.ThrowIfNull(), async (_, args, cancellationToken) => + { + var model = await EfDb.DbContext.FindAsync([.. key.Args], cancellationToken).ConfigureAwait(false); + if (args.ClearChangeTrackerAfterGet) + EfDb.DbContext.ChangeTracker.Clear(); + + return CheckModel(args, model, OperationType.Get, treatNullAsNotFound); + }, 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 new file mode 100644 index 00000000..96424e84 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.Query.cs @@ -0,0 +1,25 @@ +namespace CoreEx.EntityFrameworkCore; + +public partial class EfDbModel +{ + /// + /// Gets the for the model. + /// + /// The optional . + /// The for the model. + /// This automatically applies any applicable filters; see . + public IQueryable Query(EfDbArgs? args = null) + { + args ??= Args; + return Options.ApplyFilters(args, args.QueryTracking ? EfDb.DbContext.Set() : EfDb.DbContext.Set().AsNoTracking()); + } + + /// + /// Gets the for the model with tracking explicitly enabled (i.e. is ). + /// + /// The optional . + /// The for the model with tracking enabled. + /// This automatically applies any applicable filters; see . The + /// is set (overridden) to . + public IQueryable QueryTracked(EfDbArgs? args = null) => Query((args ?? Args) with { QueryTracking = true }); +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.Update.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.Update.cs new file mode 100644 index 00000000..48a78ff8 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.Update.cs @@ -0,0 +1,105 @@ +namespace CoreEx.EntityFrameworkCore; + +public partial class EfDbModel +{ + /// + /// Updates the . + /// + /// The model. + /// The . + /// The containing the updated model. + public Task> UpdateAsync(TModel model, CancellationToken cancellationToken = default) => UpdateAsync(Args, model, cancellationToken); + + /// + /// Updates the . + /// + /// The . + /// The model. + /// The . + /// The containing the updated model. + public async Task> UpdateAsync(EfDbArgs args, TModel model, CancellationToken cancellationToken = default) => (await UpdateWithResultInternalAsync(args, model, nameof(UpdateAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Updates the . + /// + /// The model. + /// The . + /// The containing the updated model. + public Task>> UpdateWithResultAsync(TModel model, CancellationToken cancellationToken = default) => UpdateWithResultAsync(Args, model, cancellationToken); + + /// + /// Updates the . + /// + /// The . + /// The model. + /// The . + /// The containing the updated model. + public Task>> UpdateWithResultAsync(EfDbArgs args, TModel model, CancellationToken cancellationToken = default) => UpdateWithResultInternalAsync(args, model, nameof(UpdateWithResultAsync), cancellationToken); + + /// + /// Updates the model (internal). + /// + private async Task>> UpdateWithResultInternalAsync(EfDbArgs args, TModel model, string memberName, CancellationToken cancellationToken) + { + model.ThrowIfNull(); + + if (model is IReadOnlyLogicallyDeleted ld && ld.IsDeleted) + throw new InvalidOperationException($"Cannot update a model and set to the deleted state ({nameof(ILogicallyDeleted.IsDeleted)} must be false); use the delete operation to perform."); + + args.CheckRefreshAndSaveChangesCombination(); + + return await EfDb.Invoker.InvokeAsync(EfDb, args, async (_, args, cancellationToken) => + { + // Determine whether the model is already being tracked by EF and handle accordingly. + switch (EfDb.DbContext.Entry(model).State) + { + // Where detached, we need to find the tracked entity and copy the values across. + case Microsoft.EntityFrameworkCore.EntityState.Detached: + // Check (find) to ensure that the model exists. + var found = await EfDb.DbContext.FindAsync([.. Options.GetKeyFromModel(model).Args], cancellationToken).ConfigureAwait(false); + var dr = CheckModel(args, found, OperationType.Get, treatNullAsNotFound: true); + if (dr.IsFailure) + return dr.Bind(); + + // Guard against null; should never be null here. + found.ThrowIfNull(); + + // Check optimistic concurrency of etag/row-version to ensure valid. + if (model is IReadOnlyETag etag && !ETag.TryCompare(etag, (IReadOnlyETag)found)) + return Result.ConcurrencyError(); + + // Apply the updates into the found (tracked) entity. + if (!Options.MapModelForUpdate(model, found)) + return Result.Ok(new DataResult(model!, false)); + + // Use the found (tracked) as the model going forward. + model = found; + break; + + // 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)); + } + + // Prepare the model. + var br = Options.OnBeforeCreateOrUpdate(model, OperationType.Update); + if (br.IsFailure) + return br; + + Model.PrepareUpdate(model, EfDb.ExecutionContext); + var r = CheckModel(args, model, OperationType.Update); + if (r.IsFailure) + return r.Bind(); + + // EF update. + EfDb.DbContext.Update(model); + + if (args.SaveChanges) + await EfDb.DbContext.SaveChangesAsync(true, cancellationToken).ConfigureAwait(false); + + // Refresh as required. + var pr = await RefreshPostMutationAsync(args, model, memberName, cancellationToken).ConfigureAwait(false); + return pr.ThenAs(m => new DataResult(m, true)); + }, cancellationToken, memberName).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModel.cs b/src/CoreEx.EntityFrameworkCore/EfDbModel.cs index b1732a6c..4c6bf059 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbModel.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbModel.cs @@ -1,110 +1,97 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; -using CoreEx.Entities; -using CoreEx.Results; +namespace CoreEx.EntityFrameworkCore; -namespace CoreEx.EntityFrameworkCore +/// +/// Provides the extended Entity Framework Core model functionality. +/// +/// The model . +public sealed partial class EfDbModel where TModel : class { /// - /// Provides a lightweight typed Entity Framework wrapper over the operations that are -specific. + /// Initializes a new instance of the class. /// - /// The entity framework model . - /// The . - public readonly struct EfDbModel(IEfDb efDb) where TModel : class, new() + /// The owning + /// The . + internal EfDbModel(IEfDb efDb, EfDbModelOptions options) { - /// - public IEfDb EfDb { get; } = efDb.ThrowIfNull(nameof(efDb)); - - /// - /// Creates an to enable select-like capabilities. - /// - /// The function to further define the query. - /// A . - public EfDbQuery Query(Func, IQueryable>? query = null) => Query(new EfDbArgs(EfDb.DbArgs), query); - - /// - /// Creates an to enable select-like capabilities. - /// - /// The . - /// The function to further define the query. - /// A . - public EfDbQuery Query(EfDbArgs queryArgs, Func, IQueryable>? query = null) => new(EfDb, queryArgs, query); - - #region Standard - - /// - /// Gets the model for the specified . - /// - /// The key values. - /// The entity value where found; otherwise, null. - public Task GetAsync(params object?[] keys) => GetAsync(keys, default); - - /// - /// Gets the model for the specified . - /// - /// The key values. - /// The . - /// The entity value where found; otherwise, null. - public Task GetAsync(object?[] keys, CancellationToken cancellationToken = default) => GetAsync(CompositeKey.Create(keys), cancellationToken); - - /// - /// Gets the model for the specified . - /// - /// The . - /// The . - /// The entity value where found; otherwise, null. - /// Where the model implements and is true then null will be returned. - public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetAsync(new EfDbArgs(EfDb.DbArgs), key, cancellationToken); - - /// - /// Gets the model for the specified . - /// - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null. - /// Where the model implements and is true then null will be returned. - public Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) - => EfDb.GetAsync(args, key, cancellationToken); - - #endregion + EfDb = efDb.ThrowIfNull(); + Options = options.ThrowIfNull(); + } - #region WithResult + /// + /// Gets the . + /// + public IEfDb EfDb { get; } - /// - /// Gets the model for the specified with a . - /// - /// The key values. - /// The model value where found; otherwise, null. - public Task> GetWithResultAsync(params object?[] keys) => GetWithResultAsync(keys, default); + /// + /// Gets the . + /// + public EfDbModelOptions Options { get; } - /// - /// Gets the model for the specified with a . - /// - /// The key values. - /// The . - /// The model value where found; otherwise, null. - public Task> GetWithResultAsync(object?[] keys, CancellationToken cancellationToken = default) => GetWithResultAsync(CompositeKey.Create(keys), cancellationToken); + /// + /// Gets the default . + /// + /// Uses the where specified; otherwise, the . + public EfDbArgs Args => Options.Args ?? EfDb.Options.Args; - /// - /// Gets the entity for the specified with a . - /// - /// The . - /// The . - /// The model value where found; otherwise, null. - /// Where the model implements and is true then null will be returned. - public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetWithResultAsync(new EfDbArgs(EfDb.DbArgs), key, cancellationToken); + /// + /// Checks (ensures) that the is valid. + /// + /// The . + /// The model. + /// The . + /// Indicates whether to treat a model as a not found error. + /// The . + [return: NotNullIfNotNull(nameof(model))] + public Result CheckModel(EfDbArgs args, TModel? model, OperationType operationType, bool treatNullAsNotFound = false) + { + if (model is null) + return treatNullAsNotFound ? Result.NotFoundError() : Result.Ok(null); + + // 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) + return treatNullAsNotFound ? Result.NotFoundError() : Result.Ok(null); + } + + // Check not logically deleted. + if (model is IReadOnlyLogicallyDeleted ld && ld.IsDeleted) + return treatNullAsNotFound ? Result.NotFoundError() : Result.Ok(null); + + // Check filters. + return Options.CheckFilters(args, model, operationType) + .OnFailure(fr => + { + if (fr.IsNotFoundError) + return treatNullAsNotFound ? fr : Result.Ok(null); + else + return fr; + }); + } - /// - /// Gets the entity for the specified with a . - /// - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null. - /// Where the model implements and is true then null will be returned. - public Task> GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => EfDb.GetWithResultAsync(args, key, cancellationToken); + /// + /// Refreshes the model post-mutation (as required). + /// + private async Task> RefreshPostMutationAsync(EfDbArgs args, TModel model, string memberName, CancellationToken cancellationToken) + { + // Refresh the model as requested. + if (args.Refresh && Options.GetKeyFromModel(model) is CompositeKey key) + return Result.Go((await GetWithResultInternalAsync(args, key, memberName, treatNullAsNotFound: true, cancellationToken).ConfigureAwait(false)).ThenAs(v => v!)); - #endregion + // Return the current model. + return Result.Ok(model); } + + /// + /// Creates a that provides mapped CRUD operations (Create, Read, Update and Delete). + /// + /// The mapped . + /// The . + /// The . + /// The . + public EfDbMappedModel ToMappedModel(TBiDirectionMapper mapper) where T : class where TBiDirectionMapper : IBiDirectionMapper => new(this, mapper); } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs b/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs new file mode 100644 index 00000000..5008deae --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs @@ -0,0 +1,249 @@ +namespace CoreEx.EntityFrameworkCore; + +/// +/// Provides options for the . +/// +public class EfDbModelOptions where TModel : class +{ + private readonly List<(Func, IQueryable> Filter, Func? NonQueryResult, bool AllowFilterBypass)> _filters = []; + 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; + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport LogicalDeleteSupport { get; } = FeatureSupport.Determine(); + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport TenantSupport { get; } = FeatureSupport.Determine(); + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport TypeDiscriminatorSupport { get; } = FeatureSupport.Determine(); + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport ETagSupport { get; } = FeatureSupport.Determine(); + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport IdentifierSupport { get; } = FeatureSupport.Determine(); + + /// + /// Gets the default . + /// + public EfDbArgs? Args { get; private set; } + + /// + /// Sets (overrides) the default . + /// + /// The . + /// The to support fluent-style method-chaining. + public EfDbModelOptions WithArgs(EfDbArgs? args) + { + Args = args; + return this; + } + + /// + /// Sets (overrides) the function to get the for the . + /// + /// The function to get the key. + /// The to support fluent-style method-chaining. + /// By default, where the implements , that is used; otherwise, an is thrown. + public EfDbModelOptions WithGetKey(Func getKey) + { + _getKey = getKey.ThrowIfNull(); + return this; + } + + /// + /// Gets the from the . + /// + /// The model. + /// The . + public CompositeKey GetKeyFromModel(TModel model) => _getKey(model.ThrowIfNull()); + + /// + /// Adds a filter to be applied to all operations (get, create, update, delete, and query). + /// + /// The filter query to apply. + /// The optional to return for non-query operations when the filter excludes. + /// Indicates whether the filter can be bypassed via the ; defaults to . + /// The to support fluent-style method-chaining. + /// The enables different results to be returned for non-query operations when the filter excludes; for example, a + /// could be returned for an authorization filter. Where a is not specified then the specified is only applied for queries. + /// This is not a replacement for EF Core's built-in filtering mechanisms (see ); but, should be considered additive. EF built-in filtering can be used + /// to enable the likes of logical delete and tenant support to ensure the broadest usage. Note that the non-query operations handle the logical delete () and + /// 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. + /// + public EfDbModelOptions WithFilter(Func, IQueryable> filter, Func? nonQueryResult = null, bool allowFilterBypass = true) + { + _filters.Add((filter.ThrowIfNull(), nonQueryResult, allowFilterBypass)); + return this; + } + + /// + /// Adds a logical delete () query-only filter (where is supported). + /// + /// Indicates whether the filter can be bypassed via the ; defaults to . + /// The to support fluent-style method-chaining. + public EfDbModelOptions WithLogicalDeleteFilter(bool allowFilterBypass = false) + { + if (LogicalDeleteSupport.IsSupported) + WithFilter(q => q.Where(m => !((IReadOnlyLogicallyDeleted)m).IsDeleted), allowFilterBypass: allowFilterBypass); + else + throw new NotSupportedException($"{nameof(WithLogicalDeleteFilter)} is not supported; model must implement {nameof(IReadOnlyLogicallyDeleted)} to enable."); + + return this; + } + + /// + /// Adds a tenant () query-only filter (where is supported). + /// + /// Indicates whether the filter can be bypassed via the ; defaults to . + /// The to support fluent-style method-chaining. + 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 + throw new NotSupportedException($"{nameof(WithTenantFilter)} is not supported; model must implement {nameof(IReadOnlyTenantId)} to enable."); + + return this; + } + + /// + /// Indicates whether any filters have been specified. + /// + /// indicates that an authorization filter has been specified; otherwise, . + /// See for more information. + public bool HasFilters => _filters.Count > 0; + + /// + /// Applies the filters to the . + /// + /// The . + /// The . + /// 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) + { + query.ThrowIfNull(); + if (!HasFilters) + return query; + + 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); + } + + return query; + } + + /// + /// Checks the non-query filters against the . + /// + /// The . + /// The model. + /// The . + /// The of the filters check. + /// This checks all specified filters against the and executes the corresponding non-query result handling. + /// See for more information. + public Result CheckFilters(EfDbArgs args, TModel? model, OperationType operationType) + { + if (model is null || !HasFilters) + return Result.Ok(model); + + var q = new[] { model.ThrowIfNull() }.AsQueryable(); + + foreach (var (filter, nonQueryResult, allowFilterBypass) in _filters) + { + // Bypass filter where selected to do so and allowed. + if (args.BypassFilters && allowFilterBypass) + continue; + + // Apply the filter to the single model query; if no match, then carry on. + if (nonQueryResult is null || filter(q).Any()) + continue; + + // Match; so, return the non-query result (should be an error). + return nonQueryResult(model, operationType); + } + + // Sweet! + return Result.Ok(model); + } + + /// + /// Sets (or overrides) the function to perform processing on the model prior to create or update. + /// + /// The function. + /// The to support fluent-style method-chaining. + public EfDbModelOptions WithOnBeforeCreateOrUpdate(Func onBeforeCreateOrUpdate) + { + _onBeforeCreateOrUpdate = onBeforeCreateOrUpdate.ThrowIfNull(); + return this; + } + + /// + /// Indicates whether has been set. + /// + public bool HasOnBeforeCreateOrUpdate => _onBeforeCreateOrUpdate != null; + + /// + /// Executes the where set. + /// + /// The model value. + /// The . + /// The . + internal Result OnBeforeCreateOrUpdate(TModel model, OperationType operationType) => _onBeforeCreateOrUpdate?.Invoke(model, operationType) ?? Result.Success; + + /// + /// Sets (or overrides) the action to map the updated model into the existing (just read) model. + /// + /// The function to map the updated model (left) into the existing model (right). + /// The to support fluent-style method-chaining. + /// + /// The function takes two parameters: the updated model (first parameter) and the existing model (second parameter) and returns a indicating whether any changes were made. + /// This enables custom mapping logic to be specified for update operations; otherwise, by default, is used internally. + public EfDbModelOptions WithUpdateModelMapper(Func updateModelMapper) + { + _updateModelMapper = updateModelMapper.ThrowIfNull(); + return this; + } + + /// + /// Maps the model into the model. + /// + /// The updated model. + /// The existing model. + /// where changes were made; otherwise, . + internal bool MapModelForUpdate(TModel update, TModel existing) + { + if (_updateModelMapper is null) + return Metadata.RuntimeMetadata.TryCopyInto(update, existing); + else + return _updateModelMapper(update, existing); + } +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModelQuery.cs b/src/CoreEx.EntityFrameworkCore/EfDbModelQuery.cs deleted file mode 100644 index 54837dae..00000000 --- a/src/CoreEx.EntityFrameworkCore/EfDbModelQuery.cs +++ /dev/null @@ -1,308 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using Microsoft.EntityFrameworkCore; - -namespace CoreEx.EntityFrameworkCore -{ - /// - /// Encapsulates an Entity Framework query enabling all select-like capabilities on a specified . - /// - /// The entity framework model . - /// Queried entities by default are not tracked; this behavior can be overridden using . - /// Reminder: leverage and then explictly include to improve performance where applicable. - /// Automatic filtering is applied using the . - public class EfDbQuery where TModel : class, new() - { - private readonly Func, IQueryable>? _query; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// A function to modify the underlying . - internal EfDbQuery(IEfDb efdb, EfDbArgs args, Func, IQueryable>? query = null) - { - EfDb = efdb.ThrowIfNull(nameof(efdb)); - Args = args; - _query = query; - Paging = null; - } - - /// - /// Gets the . - /// - public IEfDb EfDb { get; } - - /// - /// Gets the . - /// - public EfDbArgs Args { get; } - - /// - /// Gets the . - /// - public PagingResult? Paging { get; private set; } - - /// - /// Adds to the query. - /// - /// The . - /// The to suport fluent-style method-chaining. - public EfDbQuery WithPaging(PagingArgs? paging) - { - Paging = paging == null ? null : (paging is PagingResult pr ? pr : new PagingResult(paging)); - return this; - } - - /// - /// Adds to the query. - /// - /// The specified number of elements in a sequence to bypass. - /// The specified number of contiguous elements from the start of a sequence. - /// The to suport fluent-style method-chaining. - public EfDbQuery WithPaging(long skip, long? take = null) => WithPaging(PagingArgs.CreateSkipAndTake(skip, take)); - - /// - /// Manages the DbContext and underlying query construction and lifetime. - /// - private async Task> ExecuteQueryAsync(Func, CancellationToken, Task> executeAsync, string memberName, CancellationToken cancellationToken) => await EfDb.Invoker.InvokeAsync(EfDb, EfDb, _query, Args, async (_, efdb, query, args, ct) => - { - var dbSet = args.WhereModelValid(args.QueryNoTracking ? efdb.DbContext.Set().AsNoTracking() : efdb.DbContext.Set()); - return Result.Ok(await executeAsync((query == null) ? dbSet : query(dbSet), ct).ConfigureAwait(false)); - }, cancellationToken, memberName).ConfigureAwait(false); - - /// - /// Executes the query and cleans. - /// - private async Task> ExecuteQueryInternalAsync(Func, CancellationToken, Task> executeAsync, string memberName, CancellationToken cancellationToken) - { - var result = await ExecuteQueryAsync(executeAsync, memberName, cancellationToken).ConfigureAwait(false); - if (result.IsFailure) - return Result.Fail(result.Error); - - return result.Value is TModel model ? model : default; - } - - /// - /// Sets the paging from the . - /// - private static IQueryable SetPaging(IQueryable query, PagingArgs? paging) - { - if (paging == null) - return query; - - var q = query; - if (paging.Skip > 0) - q = q.Skip((int)paging.Skip); - - return q.Take((int)(paging == null ? PagingArgs.DefaultTake : paging.Take)); - } - - /// - /// Selects a single item. - /// - /// The . - /// The single item. - public async Task SelectSingleAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryInternalAsync(async (q, ct) => await q.SingleAsync(ct).ConfigureAwait(false), nameof(SelectSingleAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects a single item with a . - /// - /// The . - /// The single item. - public async Task> SelectSingleWithResultAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryInternalAsync(async (q, ct) => await q.SingleAsync(ct).ConfigureAwait(false), nameof(SelectSingleWithResultAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects a single item or default. - /// - /// The . - /// The single item or default. - public async Task SelectSingleOrDefaultAsync(CancellationToken cancellationToken = default) - => await ExecuteQueryInternalAsync((q, ct) => q.SingleOrDefaultAsync(ct), nameof(SelectSingleOrDefaultAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Selects a single item or default with a . - /// - /// The . - /// The single item or default. - public Task> SelectSingleOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - => ExecuteQueryInternalAsync((q, ct) => q.SingleOrDefaultAsync(ct), nameof(SelectSingleOrDefaultWithResultAsync), cancellationToken); - - /// - /// Selects first item. - /// - /// The . - /// The first item. - public async Task SelectFirstAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryInternalAsync(async (q, ct) => await q.FirstAsync(ct).ConfigureAwait(false), nameof(SelectFirstAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects first item with a . - /// - /// The . - /// The first item. - public async Task> SelectFirstWithResultAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryInternalAsync(async (q, ct) => await q.FirstAsync(ct).ConfigureAwait(false), nameof(SelectFirstWithResultAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects first item or default. - /// - /// The . - /// The single item or default. - public async Task SelectFirstOrDefaultAsync(CancellationToken cancellationToken = default) - => await ExecuteQueryInternalAsync((q, ct) => q.FirstOrDefaultAsync(ct), nameof(SelectFirstOrDefaultAsync), cancellationToken).ConfigureAwait(false); - - /// - /// Selects first item or default with a . - /// - /// The . - /// The single item or default. - public Task> SelectFirstOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - => ExecuteQueryInternalAsync((q, ct) => q.FirstOrDefaultAsync(ct), nameof(SelectFirstOrDefaultWithResultAsync), cancellationToken); - - /// - /// Executes the query command creating a . - /// - /// The . - /// The . - /// The . - /// The resulting . - public async Task SelectResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult - { - Paging = Paging, - Items = (await SelectQueryWithResultInternalAsync(nameof(SelectResultAsync), cancellationToken).ConfigureAwait(false)).Value - }; - - /// - /// Executes the query command creating a with a . - /// - /// The . - /// The . - /// The . - /// The resulting . - public async Task> SelectResultWithResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult - { - Paging = Paging, - Items = (await SelectQueryWithResultInternalAsync(nameof(SelectResultWithResultAsync), cancellationToken).ConfigureAwait(false)).Value - }; - - /// - /// Executes the query command creating a resultant collection. - /// - /// The collection . - /// A resultant collection. - public async Task SelectQueryAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() - => (await SelectQueryWithResultInternalAsync(nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Executes the query command creating a resultant collection with a . - /// - /// The collection . - /// A resultant collection. - public Task> SelectQueryWithResultAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() - => SelectQueryWithResultInternalAsync(nameof(SelectQueryWithResultAsync), cancellationToken); - - /// - /// Executes the query command creating a resultant collection with a internal. - /// - private async Task> SelectQueryWithResultInternalAsync(string memberName, CancellationToken cancellationToken) where TColl : ICollection, new() - { - var coll = new TColl(); - return await SelectQueryWithResultInternalAsync(coll, memberName, cancellationToken).ConfigureAwait(false); - } - - /// - /// Executes a query adding to the passed collection. - /// - /// The collection . - /// The . - /// The collection to add items to. - /// The . - public async Task SelectQueryAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection - => (await SelectQueryWithResultInternalAsync(collection, nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Executes a query adding to the passed collection with a . - /// - /// The collection . - /// The . - /// The collection to add items to. - /// The . - public Task> SelectQueryWithResultAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection - => SelectQueryWithResultInternalAsync(collection, nameof(SelectQueryWithResultAsync), cancellationToken); - - /// - /// Executes a query adding to the passed collection with a internal. - /// - private async Task> SelectQueryWithResultInternalAsync(TColl collection, string memberName, CancellationToken cancellationToken) where TColl : ICollection - { - collection.ThrowIfNull(nameof(collection)); - - var result = await SelectQueryWithResultAsync(item => - { - collection.Add(item); - return true; - }, memberName, cancellationToken).ConfigureAwait(false); - - return result.ThenAs(() => collection); - } - - /// - /// Executes a query with per processing - /// - /// The item function. - /// The . - /// The returns a which indicates whether to continue enumerating. A true indicates - /// to continue, whilst a false indicates a stop. Where then the error will be returned as-is. - public async Task SelectQueryAsync(Func item, CancellationToken cancellationToken = default) - => (await SelectQueryWithResultAsync(model => item.ThrowIfNull(nameof(item))(model), nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Executes a query with per processing with a . - /// - /// The item function. - /// The . - /// The . - /// The returns a where the corresponding indicates whether to continue enumerating. A true indicates - /// to continue, whilst a false indicates a stop. Where then the error will be returned as-is. - public Task SelectQueryWithResultAsync(Func> item, CancellationToken cancellationToken = default) - => SelectQueryWithResultAsync(item.ThrowIfNull(nameof(item)), nameof(SelectQueryWithResultAsync), cancellationToken); - - /// - /// Executes a query with per processing. - /// - /// The item function. - /// The member name. - /// The . - /// The . - /// The returns a where the corresponding indicates whether to continue enumerating. A true indicates - /// to continue, whilst a false indicates a stop. Where then the error will be returned as-is. - internal async Task SelectQueryWithResultAsync(Func> item, string memberName, CancellationToken cancellationToken) - { - var result = await ExecuteQueryAsync(async (query, ct) => - { - var q = SetPaging(query, Paging); - Result r = Result.None; - - await foreach (var model in q.AsAsyncEnumerable().WithCancellation(ct)) - { - r = item(model); - if (r.IsFailure || !r.Value) - break; - } - - if (r.IsSuccess && Paging != null && Paging.IsGetCount) - Paging.TotalCount = query.LongCount(); - - return r; - }, memberName, cancellationToken); - - return result.AsResult(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbOptions.cs b/src/CoreEx.EntityFrameworkCore/EfDbOptions.cs new file mode 100644 index 00000000..f4b6be13 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/EfDbOptions.cs @@ -0,0 +1,66 @@ +namespace CoreEx.EntityFrameworkCore; + +/// +/// Provides options for the . +/// +public class EfDbOptions +{ + private readonly Lazy> _models = new(); + + /// + /// Gets the default . + /// + public EfDbArgs Args { get; private set; } = new(); + + /// + /// Sets (overrides) the default . + /// + /// The . + /// The to support fluent-style method-chaining. + public EfDbOptions WithArgs(EfDbArgs args) + { + Args = args with { }; + return this; + } + + /// + /// Adds (or updates) the for the specified . + /// + /// The model . + /// The action to configure the . + /// The to support fluent-style method-chaining. + public EfDbOptions WithModel(Action>? configureOptions = null) where TModel : class + { + if (configureOptions is null) + return this; + + var mo = GetOrAddModelOptions(); + configureOptions?.Invoke(mo); + return this; + } + + /// + /// Gets or adds the for the specified . + /// + /// The model . + /// he . + public EfDbModelOptions GetOrAddModelOptions() where TModel : class => (EfDbModelOptions)_models.Value.GetOrAdd(typeof(TModel), _ => new EfDbModelOptions()); + + /// + /// Tries to get for the specified . + /// + /// The model . + /// The where found. + /// where the was found; otherwise, . + public bool TryGetModelOptions([NotNullWhen(true)] out EfDbModelOptions? modelOptions) where TModel : class + { + if (_models.Value.TryGetValue(typeof(TModel), out var mo)) + { + modelOptions = (EfDbModelOptions)mo; + return true; + } + + modelOptions = null; + return false; + } +} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbQuery.cs b/src/CoreEx.EntityFrameworkCore/EfDbQuery.cs deleted file mode 100644 index ec4ededc..00000000 --- a/src/CoreEx.EntityFrameworkCore/EfDbQuery.cs +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.Results; -using Microsoft.EntityFrameworkCore; - -namespace CoreEx.EntityFrameworkCore -{ - /// - /// Encapsulates an Entity Framework query enabling all select-like capabilities on a specified automatically mapping to the resultant . - /// - /// The resultant . - /// The entity framework model . - /// Queried entities by default are not tracked; this behavior can be overridden using . - /// Reminder: leverage and then explictly include to improve performance where applicable. - public class EfDbQuery where T : class, new() where TModel : class, new() - { - private readonly EfDbQuery _query; - - /// - /// Initializes a new instance of the struct. - /// - /// The . - /// The . - /// A function to modify the underlying . - internal EfDbQuery(IEfDb efdb, EfDbArgs args, Func, IQueryable>? query = null) => _query = new EfDbQuery(efdb, args, query); - - /// - /// Gets the . - /// - public IEfDb EfDb => _query.EfDb; - - /// - /// Gets the . - /// - public EfDbArgs Args => _query.Args; - - /// - /// Gets the . - /// - public IMapper Mapper => EfDb.Mapper; - - /// - /// Gets the . - /// - public PagingResult? Paging => _query.Paging; - - /// - /// Adds to the query. - /// - /// The . - /// The to suport fluent-style method-chaining. - public EfDbQuery WithPaging(PagingArgs? paging) - { - _query.WithPaging(paging); - return this; - } - - /// - /// Adds to the query. - /// - /// The specified number of elements in a sequence to bypass. - /// The specified number of contiguous elements from the start of a sequence. - /// The to suport fluent-style method-chaining. - public EfDbQuery WithPaging(long skip, long? take = null) => WithPaging(PagingArgs.CreateSkipAndTake(skip, take)); - - /// - /// Executes the query and maps. - /// - private async Task> ExecuteQueryAndMapAsync(Func>> executeAsync, CancellationToken cancellationToken) - { - var result = await executeAsync(cancellationToken).ConfigureAwait(false); - if (result.IsFailure) - return Result.Fail(result.Error); - - var val = result.Value == null ? default! : Mapper.Map(result.Value, Mapping.OperationTypes.Get); - return Args.CleanUpResult ? Cleaner.Clean(val) : val; - } - - /// - /// Selects a single item. - /// - /// The . - /// The single item. - public async Task SelectSingleAsync(CancellationToken cancellationToken = default) - => (await SelectSingleWithResultAsync(cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Selects a single item with a . - /// - /// The . - /// The single item. - public async Task> SelectSingleWithResultAsync(CancellationToken cancellationToken = default) - { - var q = _query; - return await ExecuteQueryAndMapAsync(q.SelectSingleWithResultAsync, cancellationToken).ConfigureAwait(false); - } - - /// - /// Selects a single item or default. - /// - /// The . - /// The single item or default. - public async Task SelectSingleOrDefaultAsync(CancellationToken cancellationToken = default) - => (await SelectSingleOrDefaultWithResultAsync(cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Selects a single item or default with a . - /// - /// The . - /// The single item or default. - public async Task> SelectSingleOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - { - var q = _query; - return await ExecuteQueryAndMapAsync(q.SelectSingleOrDefaultWithResultAsync, cancellationToken).ConfigureAwait(false); - } - - /// - /// Selects first item. - /// - /// The . - /// The first item. - public async Task SelectFirstAsync(CancellationToken cancellationToken = default) - => (await SelectFirstWithResultAsync(cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Selects first item with a . - /// - /// The . - /// The first item. - public async Task> SelectFirstWithResultAsync(CancellationToken cancellationToken = default) - { - var q = _query; - return await ExecuteQueryAndMapAsync(q.SelectFirstWithResultAsync, cancellationToken).ConfigureAwait(false); - } - - /// - /// Selects first item or default. - /// - /// The . - /// The single item or default. - public async Task SelectFirstOrDefaultAsync(CancellationToken cancellationToken = default) - => (await SelectFirstOrDefaultWithResultAsync(cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Selects first item or default with a . - /// - /// The . - /// The single item or default. - public async Task> SelectFirstOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - { - var q = _query; - return await ExecuteQueryAndMapAsync(q.SelectFirstOrDefaultWithResultAsync, cancellationToken).ConfigureAwait(false); - } - - /// - /// Executes the query command creating a . - /// - /// The . - /// The . - /// The . - /// The resulting . - public async Task SelectResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult - { - Paging = _query.Paging, - Items = (await SelectQueryWithResultInternalAsync(nameof(SelectResultAsync), cancellationToken).ConfigureAwait(false)).Value - }; - - /// - /// Executes the query command creating a with a . - /// - /// The . - /// The . - /// The . - /// The resulting . - public async Task> SelectResultWithResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult - { - Paging = _query.Paging, - Items = (await SelectQueryWithResultInternalAsync(nameof(SelectResultWithResultAsync), cancellationToken).ConfigureAwait(false)).Value - }; - - /// - /// Executes the query command creating a resultant collection. - /// - /// The collection . - /// A resultant collection. - public async Task SelectQueryAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() - => (await SelectQueryWithResultInternalAsync(nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Executes the query command creating a resultant collection with a . - /// - /// The collection . - /// A resultant collection. - public Task> SelectQueryWithResultAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() - => SelectQueryWithResultInternalAsync(nameof(SelectQueryWithResultAsync), cancellationToken); - - /// - /// Executes the query command creating a resultant collection with a internal. - /// - private async Task> SelectQueryWithResultInternalAsync(string memberName, CancellationToken cancellationToken) where TColl : ICollection, new() - { - var coll = new TColl(); - return await SelectQueryWithResultInternalAsync(coll, memberName, cancellationToken).ConfigureAwait(false); - } - - /// - /// Executes a query adding to the passed collection. - /// - /// The collection . - /// The . - /// The collection to add items to. - /// The . - public async Task SelectQueryAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection - => (await SelectQueryWithResultInternalAsync(collection, nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Executes a query adding to the passed collection with a . - /// - /// The collection . - /// The . - /// The collection to add items to. - /// The . - public Task> SelectQueryWithResultAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection - => SelectQueryWithResultInternalAsync(collection, nameof(SelectQueryWithResultAsync), cancellationToken); - - /// - /// Executes a query adding to the passed collection with a internal. - /// - private async Task> SelectQueryWithResultInternalAsync(TColl collection, string memberName, CancellationToken cancellationToken) where TColl : ICollection - { - collection.ThrowIfNull(nameof(collection)); - - var mapper = Mapper; - - var result = await _query.SelectQueryWithResultAsync(item => - { - var val = mapper.Map(item, OperationTypes.Get); - if (val is null) - return new InvalidOperationException("Mapping from the EF model must not result in a null value."); - - collection.Add(val); - return true; - }, memberName, cancellationToken).ConfigureAwait(false); - - return result.ThenAs(() => collection); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbQueryableExtensions.cs b/src/CoreEx.EntityFrameworkCore/EfDbQueryableExtensions.cs deleted file mode 100644 index c7abacc1..00000000 --- a/src/CoreEx.EntityFrameworkCore/EfDbQueryableExtensions.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; - -namespace Microsoft.EntityFrameworkCore -{ - /// - /// Adds additional extension methods to . - /// - public static class EfDbQueryableExtensions - { - /// - /// Creates a from a . - /// - /// The . - /// The collection . - /// The item . - /// The . - /// The . - /// The . - /// A new collection that contains the elements from the input sequence. - public static async Task ToCollectionResultAsync(this IQueryable query, PagingArgs? paging = null, CancellationToken cancellationToken = default) - where TCollResult : ICollectionResult, new() - where TColl : ICollection, new() - { - var result = new TCollResult { Paging = new PagingResult(paging) }; - - await foreach (var item in query.WithPaging(paging).AsAsyncEnumerable().WithCancellation(cancellationToken).ConfigureAwait(false)) - { - result.Items.Add(item); - } - - if (result.Paging.IsGetCount) - result.Paging.TotalCount = await query.LongCountAsync(cancellationToken).ConfigureAwait(false); - - return result; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/EfDbServiceCollectionExtensions.cs b/src/CoreEx.EntityFrameworkCore/EfDbServiceCollectionExtensions.cs deleted file mode 100644 index 598c2d5b..00000000 --- a/src/CoreEx.EntityFrameworkCore/EfDbServiceCollectionExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.EntityFrameworkCore; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extenstion methods. - /// - public static class EfDbServiceCollectionExtensions - { - /// - /// Adds the as a scoped service. - /// - /// The corresponding entity framework implementation . - /// The . - /// The to support fluent-style method-chaining. - public static IServiceCollection AddEfDb(this IServiceCollection services) where TEfDb : class, IEfDb => services.AddScoped(); - - /// - /// Adds the as a scoped service. - /// - /// The corresponding entity framework service . - /// The corresponding entity framework implementation . - /// - /// - public static IServiceCollection AddEfDb(this IServiceCollection services) where TIEfDb : class, IEfDb where TEfDb : class, TIEfDb => services.AddScoped(); - } -} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/GlobalUsing.cs b/src/CoreEx.EntityFrameworkCore/GlobalUsing.cs new file mode 100644 index 00000000..9be21ba7 --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/GlobalUsing.cs @@ -0,0 +1,20 @@ +global using CoreEx; +global using CoreEx.Abstractions; +global using CoreEx.Data; +global using CoreEx.Database; +global using CoreEx.Database.Abstractions; +global using CoreEx.Entities; +global using CoreEx.EntityFrameworkCore; +global using CoreEx.Invokers; +global using CoreEx.Mapping; +global using CoreEx.Results; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.ChangeTracking; +global using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +global using Microsoft.Extensions.DependencyInjection; +global using System.Collections.Concurrent; +global using System.Data.Common; +global using System.Diagnostics.CodeAnalysis; +global using System.Linq; +global using System.Reflection; +global using System.Text.Json; \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/IEfDb.cs b/src/CoreEx.EntityFrameworkCore/IEfDb.cs index 823ce054..91e45c77 100644 --- a/src/CoreEx.EntityFrameworkCore/IEfDb.cs +++ b/src/CoreEx.EntityFrameworkCore/IEfDb.cs @@ -1,205 +1,32 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.EntityFrameworkCore; -using CoreEx.Database; -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.Results; -using Microsoft.EntityFrameworkCore; - -namespace CoreEx.EntityFrameworkCore +/// +/// Enables the extended -based Entity Framework Core capabilities. +/// +public interface IEfDb { /// - /// Enables the extended Entity Framework functionality. + /// Gets the underlying . /// - public interface IEfDb - { - /// - /// Gets the underlying . - /// - DbContext DbContext { get; } - - /// - /// Gets the . - /// - EfDbInvoker Invoker { get; } - - /// - /// Gets the . - /// - IDatabase Database { get; } - - /// - /// Gets the . - /// - IMapper Mapper { get; } - - /// - /// Gets the default used where not expliticly specified for an operation. - /// - EfDbArgs DbArgs { get; } - - /// - /// Creates an to enable select-like capabilities on a specified . - /// - /// The entity framework model . - /// The . - /// The function to further define the query. - /// A . - EfDbQuery Query(EfDbArgs queryArgs, Func, IQueryable>? query = null) where TModel : class, new(); - - /// - /// Creates an to enable select-like capabilities on a specified automatically mapping to the resultant . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The function to further define the query. - /// A . - EfDbQuery Query(EfDbArgs queryArgs, Func, IQueryable>? query = null) where T : class, IEntityKey, new() where TModel : class, new(); - - /// - /// Gets the model for the specified . - /// - /// The entity framework model . - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null. - Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new(); - - /// - /// Gets the model for the specified with a . - /// - /// The entity framework model . - /// The . - /// The . - /// The . - /// The model value where found; otherwise, null. - Task> GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new(); - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null. - Task GetAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new(); - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null. - Task> GetWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new(); - - /// - /// Performs a create for the value (reselects and/or automatically saves changes where specified). - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - Task CreateAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new(); - - /// - /// Performs a create for the value (reselects and/or automatically saves changes where specified) with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - Task> CreateWithResultAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new(); + DbContext DbContext { get; } - /// - /// Performs an update for the value (reselects and/or automatically saves changes where specified). - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - Task UpdateAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new(); - - /// - /// Performs an update for the value (reselects and/or automatically saves changes where specified) with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - Task> UpdateWithResultAsync(EfDbArgs args, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new(); - - /// - /// Performs a delete for the specified . - /// - /// The entity framework model . - /// The . - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new(); - - /// - /// Performs a delete for the specified with a . - /// - /// The entity framework model . - /// The . - /// The . - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - Task DeleteWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new(); - - /// - /// Performs a delete for the specified . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - Task DeleteAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new(); + /// + /// Gets the . + /// + IDatabase Database { get; } - /// - /// Performs a delete for the specified with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - Task DeleteWithResultAsync(EfDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new(); + /// + /// Gets the . + /// + EfDbOptions Options { get; } - /// - /// Invokes the whilst replacing the wildcard characters when the is not null. - /// - /// The value with which to verify. - /// The to invoke when there is a valid value; passed the database specific wildcard value as the action argument. - void WithWildcard(string? with, Action action); + /// + /// Gets the . + /// + ExecutionContext ExecutionContext { get; } - /// - /// Invokes the when the is not the default value for the . - /// - /// The with value . - /// The value with which to verify. - /// The to invoke when there is a valid value. - void With(T with, Action action); - } + /// + /// Gets the . + /// + EfDbInvoker Invoker { get; } } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/IEfDbContext.cs b/src/CoreEx.EntityFrameworkCore/IEfDbContext.cs index 44285b52..ad5cdf16 100644 --- a/src/CoreEx.EntityFrameworkCore/IEfDbContext.cs +++ b/src/CoreEx.EntityFrameworkCore/IEfDbContext.cs @@ -1,17 +1,12 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.EntityFrameworkCore; -using CoreEx.Database; - -namespace CoreEx.EntityFrameworkCore +/// +/// Enables access to the underlying instance (see ). +/// +public interface IEfDbContext { /// - /// Enables access to the base instance (see ). + /// Gets the base . /// - public interface IEfDbContext - { - /// - /// Gets the base . - /// - public IDatabase BaseDatabase { get; } - } + public IDatabase BaseDatabase { get; } } \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/IEfDbEntity.cs b/src/CoreEx.EntityFrameworkCore/IEfDbEntity.cs deleted file mode 100644 index 3bda064d..00000000 --- a/src/CoreEx.EntityFrameworkCore/IEfDbEntity.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.EntityFrameworkCore -{ - /// - /// Enables the common Entity Framework entity capabilities. - /// - public interface IEfDbEntity - { - /// - /// Gets the owning . - /// - IEfDb EfDb { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/README.md b/src/CoreEx.EntityFrameworkCore/README.md deleted file mode 100644 index b4682aca..00000000 --- a/src/CoreEx.EntityFrameworkCore/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# CoreEx.EntityFrameworkCore - -The `CoreEx.EntityFrameworkCore` namespace provides extended [_Entity Framework Core (EF)_](https://learn.microsoft.com/en-us/ef/core/) capabilities. - -
- -## Motivation - -The motivation is to provide supporting EF Core capabilities for [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) related access that support standardized _CoreEx_ data access patterns. This for the most part will simplify and unify the approach to ensure consistency of implementation where needed. - -
- -## Requirements - -The requirements for usage are as follows. -- An **entity** (DTO) that represents the data that must as a minimum implement [`IEntityKey`](../CoreEx/Entities/IEntityKey.cs); generally via either the implementation of [`IIdentifier`](../CoreEx/Entities/IIdentifierT.cs) or [`IPrimaryKey`](../CoreEx/Entities/IPrimaryKey.cs). -- A **model** being the underlying configured EF Core [data source model](https://learn.microsoft.com/en-us/ef/core/modeling/). -- An [`IMapper`](../CoreEx/Mapping/IMapper.cs) that contains the mapping logic to map to and from the **entity** and **model**. - -The **entity** and **model** are different types to encourage separation between the externalized **entity** representation and the underlying **model**; which may be shaped differently, and have different property to column naming conventions, internalized columns, etc. - -
- -## Railway-oriented programming - -To support [railway-oriented programming](../CoreEx/Results/README.md) whenever a method name includes `WithResult` this indicates that it will return a `Result` or `Result` including the resulting success or failure information. In these instances an `Exception` will only be thrown when considered truly exceptional. - -
- -## CRUD capabilities - -The [`IEfDb`](./IEfDb.cs) and corresponding [`EfDb`](./EfDb.cs) provides the base CRUD capabilities as follows. - -
- -### Query (read) - -A query is actioned using the [`EfDbQuery`](./EfDbQuery.cs) which is ostensibly a lightweight wrapper over an `IQueryable` that automatically maps from the **model** to the **entity**. - -Queried entities are not tracked by default; internally uses [`AsNoTracking`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.entityframeworkqueryableextensions.asnotracking); this behaviour can be overridden using [`EfDbArgs.QueryNoTracking`](./EfDbArgs.cs). - -Note: a consumer should also consider using [`IgnoreAutoIncludes`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.entityframeworkqueryableextensions.ignoreautoincludes) to exclude related data, where not required, to improve query performance. - -The following methods provide additional capabilities: - -Method | Description --|- -`WithPaging` | Adds `Skip` and `Take` paging to the query. -`SelectSingleAsync`, `SelectSingleWithResult` | Selects a single item. -`SelectSingleOrDefaultAsync`, `SelectSingleOrDefaultWithResultAsync` | Selects a single item or default. -`SelectFirstAsync`, `SelectFirstWithResultAsync` | Selects first item. -`SelectFirstOrDefaultAsync`, `SelectFirstOrDefaultWithResultAsync` | Selects first item or default. -`SelectQueryAsync`, `SelectQueryWithResultAsync` | Select items into or creating a resultant collection. -`SelectResultAsync`, `SelectResultWithResultAsync` | Select items creating a [`ICollectionResult`](../CoreEx/Entities/ICollectionResultT2.cs) which also contains corresponding [`PagingResult`](../CoreEx/Entities/PagingResult.cs). - -
- -### Get (Read) - -Gets (`GetAsync` or `GetWithResultAsync`) the **entity** for the specified key mapping from the **model**. Uses the [`DbContext.Find`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.find) internally for the **model** and specified key. - -Where the data is not found, then a `null` will be returned. Where the **model** implements [`ILogicallyDeleted`](../CoreEx/Entities/ILogicallyDeleted.cs) and `IsDeleted` then this acts as if not found and returns a `null`. - -
- -### Create - -Creates (`CreateAsync` or `CreateWithResultAsync`) the **entity** by firstly mapping to the **model**. Uses the [`DbContext.Add`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.add) to begin tracking the **model** which will be inserted into the database when [`DbContext.SaveChanges`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.savechanges) is called. - -Where the **entity** implements [`IChangeLogAuditLog`](../CoreEx/Entities/IChangeLogAuditLog.cs) generally via [`ChangeLog`](../CoreEx/Entities/IChangeLog.cs) or [`ChangeLogEx`](../CoreEx/Entities/Extended/IChangeLogEx.cs), then the `CreatedBy` and `CreatedDate` properties will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -Where the **entity** and/or **model** implements [`ITenantId`](../CoreEx/Entities/ITenantId.cs) then the `TenantId` property will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -Generally, the [`DbContext.SaveChanges`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.savechanges) is called to perform the _insert_; unless [`EfDbArgs.SaveChanges`](./EfDbArgs.cs) is set to `false` (defaults to `true`). - -The inserted **model** is then re-mapped to the **entity** and returned where [`EfDbArgs.Refresh`](./EfDbArgs.cs) is set to `true` (default); this will ensure all properties updated as part of the _insert_ are included in the refreshed **entity**. - -
- -### Update - -Updates (`UpdateAsync` or `UpdateWithResultAsync`) the **entity** by firstly mapping to the **model**. Uses the [`DbContext.Update`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.add) to begin tracking the **model** which will be updated within the database when [`DbContext.SaveChanges`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.savechanges) is called. - -First will check existence of the **model** by performing a [`DbContext.Find`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.find). Where the data is not found, then a [`NotFoundException`](../CoreEx/NotFoundException.cs) will be thrown. Where the **model** implements [`ILogicallyDeleted`](../CoreEx/Entities/ILogicallyDeleted.cs) and `IsDeleted` then this acts as if not found and will also result in a `NotFoundException`. - -Where the entity implements [`IETag`](../CoreEx/Entities/IETag.cs) this will be checked against the just read version, and where not matched a [`ConcurrencyException`](../CoreEx/ConcurrencyException.cs) will be thrown. Also, any [`DbUpdateConcurrencyException`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbupdateconcurrencyexception) thrown will be converted to a corresponding `ConcurrencyException` for consistency. - -Where the **entity** implements [`IChangeLogAuditLog`](../CoreEx/Entities/IChangeLogAuditLog.cs) generally via [`ChangeLog`](../CoreEx/Entities/IChangeLog.cs) or [`ChangeLogEx`](../CoreEx/Entities/Extended/IChangeLogEx.cs), then the `UpdatedBy` and `UpdatedDate` properties will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -Where the **entity** and/or **model** implements [`ITenantId`](../CoreEx/Entities/ITenantId.cs) then the `TenantId` property will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -Generally, the [`DbContext.SaveChanges`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.savechanges) is called to perform the _update_; unless [`EfDbArgs.SaveChanges`](./EfDbArgs.cs) is set to `false` (defaults to `true`). - -The updated **model** is then re-mapped to the **entity** and returned where [`EfDbArgs.Refresh`](./EfDbArgs.cs) is set to `true` (default); this will ensure all properties updated as part of the _update_ are included in the refreshed **entity**. - -
- -### Delete - -Deletes (`DeleteAsync` or `DeleteWithResultAsync`) the **entity** either physically or logically. - -First will check existence of the **model** by performing a [`DbContext.Find`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.find). Where the data is not found, then a [`NotFoundException`](../CoreEx/NotFoundException.cs) will be thrown. Where the **model** implements [`ILogicallyDeleted`](../CoreEx/Entities/ILogicallyDeleted.cs) and `IsDeleted` then this acts as if not found and will also result in a `NotFoundException`. - -Where the **model** implements [`ILogicallyDeleted`](../CoreEx/Entities/ILogicallyDeleted.cs) then an update will occur after setting `IsDeleted` to `true`. Uses the [`DbContext.Update`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.add) to begin tracking the **model** which will be updated within the database when [`DbContext.SaveChanges`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.savechanges) is called. - -Otherwise, will physically delete. Uses the [`DbContext.Remove`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.remove) to begin tracking the **model** which will be deleted from the database when [`DbContext.SaveChanges`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.savechanges) is called. - -Generally, the [`DbContext.SaveChanges`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.savechanges) is called to perform the _update_; unless [`EfDbArgs.SaveChanges`](./EfDbArgs.cs) is set to `false` (defaults to `true`). - -
- -## Usage - -To use `EfDB` relationships to the EF Core [`DbContext`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext) must be established as follows. - -- [`Database`](../CoreEx.Database/Database.cs) must be defined; see [example](../../samples/My.Hr/My.Hr.Business/Data/HrDb.cs). -- `DbContext` and [`Database`](../CoreEx.Database/Database.cs) relationship must be defined; see [example](../../samples/My.Hr/My.Hr.Business/Data/HrDbContext.cs). -- [`EfDb`](./EfDb.cs) and `DbContext` relationship must be defined; see [example](../../samples/My.Hr/My.Hr.Business/Data/HrEfDb.cs). - -Alternatively, review the _Beef_ [MyEf.Hr data](https://github.com/Avanade/Beef/tree/master/samples/MyEf.Hr/MyEf.Hr.Business/Data) sample implementation. - -
- -### MySql - -Where leveraging _MySQL_ it is recommended to use the [Pomelo.EntityFrameworkCore.MySql](https://www.nuget.org/packages/Pomelo.EntityFrameworkCore.MySql) package; this has greater uptake and community supporting than the Oracle-enabled [MySql.Data.EntityFrameworkCore](https://www.nuget.org/packages/MySql.Data.EntityFrameworkCore) package. - -The `DbContextOptionsBuilder` code within the `DbContext` implementation should be as follows (review version specification) to enable. - -```csharp -protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) -{ - base.OnConfiguring(optionsBuilder); - - if (!optionsBuilder.IsConfigured) - optionsBuilder.UseMySql(BaseDatabase.GetConnection(), ServerVersion.Create(new Version(8, 0, 33), Pomelo.EntityFrameworkCore.MySql.Infrastructure.ServerType.MySql)); -} -``` \ No newline at end of file diff --git a/src/CoreEx.EntityFrameworkCore/strong-name-key.snk b/src/CoreEx.EntityFrameworkCore/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Events/CoreEx.Events.csproj b/src/CoreEx.Events/CoreEx.Events.csproj new file mode 100644 index 00000000..8b4b558b --- /dev/null +++ b/src/CoreEx.Events/CoreEx.Events.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/CoreEx.Events/CoreExEventsExtensions.DependencyInjection.cs b/src/CoreEx.Events/CoreExEventsExtensions.DependencyInjection.cs new file mode 100644 index 00000000..f37f5eba --- /dev/null +++ b/src/CoreEx.Events/CoreExEventsExtensions.DependencyInjection.cs @@ -0,0 +1,86 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure - this is by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides and related extensions. +/// +public static class CoreExEventsExtensions +{ + /// + /// Adds a singleton service for the as the . + /// + /// The . + /// The action to configure the instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddEventFormatter(this IServiceCollection services, Action? configure = null) + { + return services.AddSingleton(sp => + { + var ef = ActivatorUtilities.CreateInstance(sp); + configure?.Invoke(ef); + return ef; + }); + } + + /// + /// Adds a singleton service for the as the + /// + /// The . + /// The fixed destination name. + /// The for fluent-style method-chaining. + public static IServiceCollection AddFixedDestinationProvider(this IServiceCollection services, string destination) + => services.ThrowIfNull().AddSingleton(new FixedDestinationProvider { Destination = destination.ThrowIfNullOrEmpty() }); + + /// + /// Adds a singleton service. + /// + /// The . + /// The action to configure the instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddSubscribedManager(this IServiceCollection services, Action? configure = null) + { + return services.AddSingleton(sp => + { + var manager = new SubscribedManager(sp.GetService()); + configure?.Invoke(sp, manager); + return manager; + }); + } + + /// + /// Adds a keyed scoped service. + /// + /// The . + /// The service key to use for the keyed registration. + /// The factory function to create the instance. + /// Indicates whether to also register as the default (non-keyed) service. + /// The for fluent-style method-chaining. + /// This method firstly registers the service with a root key (derived from the provided service key) and then registers the provided service key to resolve to the root service. + /// This ensures that the root service is always registered, even if the non-root is re-registered the root can be resolved - useful for testing scenarios where the service is re-registered with a test double (the root remains and is accessible). + /// Where is , it also registers the provided service key as the default (primary / non-keyed) service, allowing it to be resolved + /// without specifying a key simplifying usage in most scenarios. Note that only a single default can be registered at a time. + public static IServiceCollection AddEventPublisher(this IServiceCollection services, string serviceKey, Func serviceFactory, bool addAsDefaultIEventPublisher = true) + { + serviceFactory.ThrowIfNull(); + var rootKey = $"{serviceKey.ThrowIfNullOrEmpty()}_Root"; + + services.ThrowIfNull().AddKeyedScoped(rootKey, (sp, _) => serviceFactory(sp) ?? throw new InvalidOperationException("The service factory must not return null.")) + .AddKeyedScoped(serviceKey, (sp, _) => sp.GetRequiredKeyedService(rootKey)); + + if (addAsDefaultIEventPublisher) + services.AddScoped(sp => sp.GetRequiredKeyedService(serviceKey)); + + return services; + } + + /// + /// Adds a keyed scoped service for the specified service key. + /// + /// The . + /// The service key to use for the keyed registration. + /// Indicates whether to also register as the default (non-keyed) service. + /// The for fluent-style method-chaining. + public static IServiceCollection AddNoOpEventPublisher(this IServiceCollection services, string serviceKey, bool addAsDefaultIEventPublisher = true) + => AddEventPublisher(services, serviceKey, sp => ActivatorUtilities.CreateInstance(sp), addAsDefaultIEventPublisher); +} \ No newline at end of file diff --git a/src/CoreEx.Events/CoreExEventsExtensions.OpenTelemetry.cs b/src/CoreEx.Events/CoreExEventsExtensions.OpenTelemetry.cs new file mode 100644 index 00000000..77c781ab --- /dev/null +++ b/src/CoreEx.Events/CoreExEventsExtensions.OpenTelemetry.cs @@ -0,0 +1,21 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace OpenTelemetry.Trace; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static class CoreExEventsExtensions +{ + /// + /// Enables CoreEx OpenTelemetry instrumentation. + /// + /// The . + /// The to support fluent-style method-chaining. + public static OpenTelemetryBuilder WithCoreExEventsSources(this OpenTelemetryBuilder builder) => builder.ThrowIfNull() + .WithTracing(t => t + .AddInvokerAsSource() + .AddInvokerAsSource()) + .WithMetrics(m => m + .AddMeter(CoreEx.Events.Subscribing.EventSubscriberMetrics.Meter.Name)); +} \ No newline at end of file diff --git a/src/CoreEx.Events/EventAction.cs b/src/CoreEx.Events/EventAction.cs new file mode 100644 index 00000000..60fcb2f3 --- /dev/null +++ b/src/CoreEx.Events/EventAction.cs @@ -0,0 +1,88 @@ +namespace CoreEx.Events; + +/// +/// Represents the action of an event; primarily: , , and . +/// +/// Other common actions are also provided. +public enum EventAction +{ + /// + /// A created event action. + /// + Created, + + /// + /// An updated event action. + /// + Updated, + + /// + /// A deleted event action. + /// + Deleted, + + /// + /// An activated event action. + /// + Activated, + + /// + /// A deactivated event action. + /// + Deactivated, + + /// + /// A cancelled event action. + /// + Cancelled, + + /// + /// A checked-out event action. + /// + CheckedOut, + + /// + /// A completed event action. + /// + Completed, + + /// + /// A submitted event action. + /// + Submitted, + + /// + /// An approved event action. + /// + Approved, + + /// + /// A rejected event action. + /// + Rejected, + + /// + /// A sent event action. + /// + Sent, + + /// + /// A received event action. + /// + Received, + + /// + /// A published event action. + /// + Published, + + /// + /// A processed event action. + /// + Processed, + + /// + /// A failed event action. + /// + Failed +} \ No newline at end of file diff --git a/src/CoreEx.Events/EventData.Infra.cs b/src/CoreEx.Events/EventData.Infra.cs new file mode 100644 index 00000000..ff02ea71 --- /dev/null +++ b/src/CoreEx.Events/EventData.Infra.cs @@ -0,0 +1,176 @@ +namespace CoreEx.Events; + +public partial class EventData +{ + #region CreateEvent + + /// + /// Creates a new event-oriented instance with the specified (subject) and (typically a verb describing an event in past tense). + /// + /// The entity (subject) name. + /// The action. + /// The new . + public static EventData CreateEvent(string entity, string? action) => new EventData { Action = action }.WithEntity(entity); + + /// + /// Creates a new event-oriented instance with the specified (subject) and the as the (typically a verb describing an event in past tense). + /// + /// The entity (subject) name. + /// The value that represents the action. + /// The new . + public static EventData CreateEvent(string entity, Enum action) => new EventData().WithEntity(entity).WithAction(action); + + /// + /// Creates a new event-oriented instance with the specified and (typically a verb describing an event in past tense). + /// + /// The value . + /// The value. + /// The action. + /// The new . + /// The (subject) automatically defaults from the value's or name. + public static EventData CreateEventWith(T? value, string? action) + { + var ed = new EventData().WithValue(value); + return action is null ? ed : ed.WithAction(action); + } + + /// + /// Creates a new event-oriented instance with the specified and the as the (typically a verb describing an event in past tense). + /// + /// The value . + /// The value. + /// The value that represents the action. + /// The new . + /// The (subject) automatically defaults from the value's or name. + public static EventData CreateEventWith(T? value, Enum action) => new EventData().WithValue(value).WithAction(action); + + /// + /// Creates new event-oriented instances with the specified and (typically a verb describing an event in past tense). + /// + /// The value . + /// The values. + /// The action. + /// An optional action to configure each instance. + /// The new . + /// The (subject) automatically defaults from the value's or name. + public static EventData[] CreateEventsWith(IEnumerable values, string? action, Action? configure = null) + { + var list = new List(); + foreach (var value in values) + { + if (value is not null) + { + var ed = new EventData().WithValue(value); + configure?.Invoke(value, ed); + list.Add(action is null ? ed : ed.WithAction(action)); + } + } + + return [.. list]; + } + + /// + /// Creates new event-oriented instances with the specified and the as the (typically a verb describing an event in past tense). + /// + /// The value . + /// The values. + /// The value that represents the action. + /// An optional action to configure each instance. + /// The new . + /// The (subject) automatically defaults from the value's or name. + public static EventData[] CreateEventsWith(IEnumerable values, Enum action, Action? configure = null) + { + var list = new List(); + foreach (var value in values) + { + if (value is not null) + { + var ed = new EventData().WithValue(value); + configure?.Invoke(value, ed); + list.Add(action is null ? ed : ed.WithAction(action)); + } + } + + return [.. list]; + } + + #endregion + + #region CreateCommand + + /// + /// Creates a new command-oriented instance with the specified (subject) and being requested. + /// + /// The target domain name. + /// The entity (subject) name. + /// The command action. + /// The new . + /// The represents the name of the domain that is the intended target of the command. + public static EventData CreateCommand(string targetDomainName, string entity, string command) => new EventData { MessageType = MessageType.Command }.WithDomain(targetDomainName).WithEntity(entity).WithAction(command); + + /// + /// Creates a new command-oriented instance with the specified (subject) and the being requested. + /// + /// The target domain name. + /// The entity (subject) name. + /// The value that represents the command action. + /// The new . + public static EventData CreateCommand(string targetDomainName, string entity, Enum command) => new EventData { MessageType = MessageType.Command }.WithDomain(targetDomainName).WithEntity(entity).WithAction(command); + + /// + /// Creates a new command-oriented instance with the specified and being requested. + /// + /// The value . + /// The target domain name. + /// The value. + /// The command action. + /// The new . + /// The (subject) automatically defaults from the value's or name. + public static EventData CreateCommandWith(string targetDomainName, T value, string command) => new EventData { MessageType = MessageType.Command }.WithDomain(targetDomainName).WithValue(value).WithAction(command); + + /// + /// Creates a new command-oriented instance with the specified and the being requested. + /// + /// The value . + /// The target domain name. + /// The value. + /// The value that represents the command action. + /// The new . + /// The (subject) automatically defaults from the value's or name. + public static EventData CreateCommandWith(string targetDomainName, T value, Enum command) => new EventData { MessageType = MessageType.Command }.WithDomain(targetDomainName).WithValue(value).WithAction(command); + + #endregion + + #region ToObjectFromJson + + /// + /// Converts (deserializes) the JSON-based to type of . + /// + /// The resulting . + /// The optional . + /// The resulting JSON deserialized value. + public T? ToObjectFromJson(JsonSerializerOptions? jsonSerializerOptions = null) + { + if (Data is null || Data.IsEmpty) + return default; + + return Data.ToObjectFromJson(jsonSerializerOptions ?? JsonDefaults.SerializerOptions); + } + + /// + /// Converts (deserializes) the JSON-based to type of . + /// + /// The resulting . + /// The optional . + /// The resulting JSON deserialized value. + public object? ToObjectFromJson(Type type, JsonSerializerOptions? jsonSerializerOptions = null) + { + type.ThrowIfNull(); + if (Data is null || Data.IsEmpty) + return default; + + return JsonSerializer.Deserialize(Data, type, jsonSerializerOptions ?? JsonDefaults.SerializerOptions); + } + + #endregion +} \ No newline at end of file diff --git a/src/CoreEx.Events/EventData.With.cs b/src/CoreEx.Events/EventData.With.cs new file mode 100644 index 00000000..e0e7a576 --- /dev/null +++ b/src/CoreEx.Events/EventData.With.cs @@ -0,0 +1,185 @@ +namespace CoreEx.Events; + +public partial class EventData +{ + /// + /// Sets the as the . + /// + /// The entity name. + /// The to support fluent-style method-chaining. + public EventData WithEntity(string entity) => this.Adjust(x => x.Entity = entity.ThrowIfNullOrEmpty()); + + /// + /// Sets the as the . + /// + /// The action. + /// The to support fluent-style method-chaining. + public EventData WithAction(string action) => this.Adjust(x => x.Action = action.ThrowIfNullOrEmpty()); + + /// + /// Sets the as the . + /// + /// The value that represents the action. + /// The to support fluent-style method-chaining. + public EventData WithAction(Enum @enum) => WithAction(@enum.ThrowIfNull().ToString()); + + /// + /// Sets the as the . + /// + /// The key. + /// The to support fluent-style method-chaining. + /// The must be a universal, deterministic, and culture-independent ; where in doubt use which will enable. + public EventData WithKey(string key) + { + Key = key.ThrowIfNullOrEmpty(); + return this; + } + + /// + /// Sets the as the . + /// + /// The key. + /// The to support fluent-style method-chaining. + public EventData WithKey(CompositeKey key) => WithKey(key.ToString().ThrowIfNull()); + + /// + /// Sets the as the . + /// + /// The partition key. + /// The to support fluent-style method-chaining. + /// A or empty will result in a being set as the . This will ensure that at least a + /// partition key is set with a somewhat randomized value to ensure a level of distributed processing. Noting that there will be no guarantees of order; where order is required then a common/deterministic + /// value should be used. + /// When using this will be set automatically from the where applicable. Otherwise, the + /// will attempt to set. + public EventData WithPartitionKey(string? partitionKey = null) => this.Adjust(x => x.PartitionKey = string.IsNullOrEmpty(partitionKey) ? Guid.NewGuid().ToString() : partitionKey); + + /// + /// Sets the as the . + /// + /// The domain (DDD) name. + /// The to support fluent-style method-chaining. + public EventData WithDomain(string domainName) => this.Adjust(x => x.DomainName = domainName.ThrowIfNullOrEmpty()); + + /// + /// Sets the schema to be included. + /// + /// The schema . + /// The to support fluent-style method-chaining. + public EventData WithVersion(Version version) => this.Adjust(x => x.DataSchemaVersion = version.ThrowIfNull()); + + /// + /// Sets the to be included. + /// + /// The . + /// The to support fluent-style method-chaining. + public EventData WithUser(AuthenticationUser user) + { + UserType = user.ThrowIfNull().Type; + UserId = user.Id; + return this; + } + + /// + /// Sets the to be included. + /// + /// The schema . + /// The to support fluent-style method-chaining. + public EventData WithSchema(Uri schema) => this.Adjust(x => x.DataSchema = schema.ThrowIfNull()); + + /// + /// Sets the as the override. + /// + /// The title. + /// The to support fluent-style method-chaining. + /// See documentation for more details on its intended usage. + public EventData WithTitle(string title) => this.Adjust(x => x.Title = title.ThrowIfNull()); + + /// + /// Sets the as the override. + /// + /// The source . + /// The to support fluent-style method-chaining. + /// See documentation for more details on its intended usage. + public EventData WithSource(Uri source) => this.Adjust(x => x.Source = source.ThrowIfNull()); + + /// + /// Sets the as serialized JSON () to the . + /// + /// The value. + /// The list of JSON paths to exclude from the serialized JSON. + /// The optional . + /// The to support fluent-style method-chaining. + /// Automatically sets the following: + /// + /// as JSON-serialized (excluding any ). + /// as (where specified); otherwise, . + /// as (where specified). + /// as (where specified); otherwise, . + /// as (where not previously set). + /// as (where not previously set). + /// as (where not previously set). + /// + /// + public EventData WithValue(T? value, IEnumerable? excludePaths, JsonSerializerOptions? jsonSerializerOptions = null) + { + // Where entity and related has not yet been specified see if it can be inferred from the value type. + Schema.TryGetMetadata(out var metadata); + Entity ??= metadata.Name; + DataSchema ??= metadata.SchemaUri is null ? null : new Uri(metadata.SchemaUri, UriKind.RelativeOrAbsolute); + DataSchemaVersion ??= metadata.Version; + + // Infer from value where possible. + if (Key is null && value is IEntityKey key) + Key = key.EntityKey.ToString(); + + if (TenantId is null && value is IReadOnlyTenantId tenantId) + TenantId = tenantId.TenantId; + + if (PartitionKey is null && value is IReadOnlyPartitionKey partitionKey) + PartitionKey = partitionKey.PartitionKey; + + // Exit quickly where the value is null. + if (value is null) + { + Data = null; + return this; + } + + // Serialize the value data. + jsonSerializerOptions ??= JsonDefaults.SerializerOptions; + if (!excludePaths?.Any() ?? false) + Data = BinaryData.FromObjectAsJson(value, jsonSerializerOptions); + else + { + JsonFilter.TryFilter(value, excludePaths, out JsonNode node, JsonFilterOption.Exclude, jsonSerializerOptions); + using var ms = new MemoryStream(); + var jw = new Utf8JsonWriter(ms); + node.WriteTo(jw, jsonSerializerOptions); + jw.Flush(); + ms.Position = 0; + Data = BinaryData.FromStream(ms, MediaTypeNames.Application.Json); + } + + return this; + } + + /// + /// Sets the as serialized JSON () to the . + /// + /// The value . + /// The value. + /// The list of JSON paths to exclude from the serialized JSON. + /// The to support fluent-style method-chaining. + /// Automatically sets the following: + /// + /// as JSON-serialized (excluding any ). + /// as (where specified); otherwise, . + /// as (where specified). + /// as (where specified); otherwise, . + /// as (where not previously set). + /// as (where not previously set). + /// + /// + public EventData WithValue(T? value = default, params IEnumerable excludePaths) => WithValue(value, excludePaths, null); +} \ No newline at end of file diff --git a/src/CoreEx.Events/EventData.cs b/src/CoreEx.Events/EventData.cs new file mode 100644 index 00000000..512e45d2 --- /dev/null +++ b/src/CoreEx.Events/EventData.cs @@ -0,0 +1,176 @@ +namespace CoreEx.Events; + +/// +/// Provides the core event (message) data in a format agnostic manner. +/// +/// Although the is extensions are supported; these should be implemented as extension properties/methods leveraging the underlying where applicable. +public sealed partial class EventData : IIdentifier, ITenantId, IPartitionKey +{ + /// + /// Initializes a new instance of the class. + /// + public EventData() + { + Id = Runtime.NewId(); + Timestamp = Runtime.UtcNow; + + if (ExecutionContext.TryGetCurrent(out var ec)) + { + TenantId = ec.TenantId; + UserType = ec.User.Type; + UserId = ec.User.Id; + } + } + + /// + /// Gets or sets the unique event identifier. + /// + /// Defaults to . + public string Id { get; set; } + + /// + /// Gets or sets the event timestamp. + /// + /// Defaults to . + public DateTimeOffset Timestamp { get; set; } + + /// + /// Gets or sets the domain (DDD bounded context) name. + /// + public string? DomainName { get; set; } + + /// + public string? TenantId { get; set; } + + /// + /// Gets or sets the entity/command (subject) name. + /// + /// This is typically a noun describing the what that is being acted upon such as 'Order' or 'Customer'. + public string? Entity { get; set; } + + /// + /// Gets or sets the action name. + /// + /// This is typically a verb describing an event in past tense such as 'Created' or 'Updated'; or, describing a command such as 'Send'. + public string? Action { get; set; } + + /// + /// Gets or sets the unique key (or identifier) for the entity. + /// + public string? Key { get; set; } + + /// + /// Gets or sets the trace parent (set to via the ). + /// + public string? TraceParent { get; set; } + + /// + /// Gets or sets the trace state (set to via the ). + /// + public string? TraceState { get; set; } + + /// + /// Gets or sets the trace baggage (set to via the ). + /// + public IEnumerable>? TraceBaggage { get; set; } + + /// + /// Gets or sets the user (defaults to ). + /// + public AuthenticationType? UserType { get; set; } + + /// + /// Gets or sets the user (auth) identifier (defaults to ). + /// + public string? UserId { get; set; } + + /// + /// Gets or sets the event data. + /// + /// This is set automatically by the . + public BinaryData? Data { get; set; } + + /// + /// Gets or sets the event data schema . + /// + /// This is set automatically by the . + public Uri? DataSchema { get; set; } + + /// + /// Gets or sets the event data schema . + /// + /// This is set automatically by the . + public Version? DataSchemaVersion { get; set; } + + /// + /// Gets or sets the partition key. + /// + /// This is set automatically by the ; either with the implemented or falls back to the (see default ). + public string? PartitionKey { get; set; } + + /// + /// Gets or sets the reply-to destination. + /// + /// This can be set to share a destination (i.e. topic) where a result is expected to be sent upon processing the event. For example, this can be used for commands to indicate where the result should be sent back to the caller. + /// Note: sending does not guarantee usage; it is up to the consumer as to whether to respond accordingly. + public string? ReplyTo { get; set; } + + /// + /// Gets or sets the title; being the fully qualified (segmented) value used for routing, observability, policy enforcement, etc. + /// + /// This is typically set/formatted when the event is being published. + public string? Title { get; set; } + + /// + /// Gets or sets the source ; typically the originating system or service that produced the event. + /// + /// This is typically set/formatted when the event is being published. + public Uri? Source { get; set; } + + /// + /// Gets or sets the . + /// + /// This is set by either the or to distinguish which was used; otherwise, defaults to . + /// This property has no equivalence and is not converted by default. + [JsonIgnore] + public MessageType MessageType { get; set; } + + /// + /// Gets the additional attributes. + /// + /// Any attribute key with an underscore ('_') prefix denotes that it is not intended to be published unless explicitly implemented. + [JsonIgnore] + public ConcurrentDictionary Attributes { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds or updates the attribute using the specified and . + /// + /// The . + /// The attribute key. + /// The attribute value. + /// The to support fluent-style method chaining. + public EventData SetAttribute(string key, T value) + { + Attributes.AddOrUpdate(key.ThrowIfNullOrEmpty(), _ => value, (_, __) => value); + return this; + } + + /// + /// Tries to get the attribute using the specified . + /// + /// The . + /// The attribute key. + /// The attribute value. + /// indicates found; otherwise, . + public bool TryGetAttribute(string key, out T? value) + { + if (Attributes.TryGetValue(key, out var av)) + { + value = (T?)av; + return true; + } + + value = default; + return false; + } +} \ No newline at end of file diff --git a/src/CoreEx.Events/EventFormatter.cs b/src/CoreEx.Events/EventFormatter.cs new file mode 100644 index 00000000..adc53e9e --- /dev/null +++ b/src/CoreEx.Events/EventFormatter.cs @@ -0,0 +1,388 @@ +namespace CoreEx.Events; + +/// +/// Provides the formatting () and parsing () of an , and its conversion to () and from () a . +/// +/// The methods are virtual to allow this class to be easily extended; this is the intended behavior. +public class EventFormatter : IEventFormatter +{ + private const string _defaultSegment = "?"; + private bool _initializeNameArray = true; + private string[] _nameArray = []; + private readonly BaggagePropagator _propagator = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The optional used to default the (), and . + public EventFormatter(IHostSettings? hostSettings = null) + { + HostSettings = hostSettings ?? ExecutionContext.GetService(); + TitlePrefix = HostSettings?.SolutionName; + SourceBaseUri = HostSettings?.Source; + DomainName = HostSettings?.DomainName; + } + + /// + /// Gets the . + /// + /// Enables the () and (). + public IHostSettings? HostSettings { get; } + + /// + /// Gets or sets the extension attribute name for the . + /// + public string TraceParentAttributeName { get; set => field = SetAndInitializeNameArray(value); } = "traceparent"; + + /// + /// Gets or sets the extension attribute name for the . + /// + public string TraceStateAttributeName { get; set => field = SetAndInitializeNameArray(value); } = "tracestate"; + + /// + /// Gets or sets the extension attribute name for the . + /// + public string TraceBaggageAttributeName { get; set => field = SetAndInitializeNameArray(value); } = "baggage"; + + /// + /// Gets or sets the extension attribute name for the . + /// + public string TenantIdAttributeName { get; set => field = SetAndInitializeNameArray(value); } = "tenantid"; + + /// + /// Gets or sets the extension attribute name for the . + /// + public string DataSchemaVersionAttributeName { get; set => field = SetAndInitializeNameArray(value); } = "dataschemaversion"; + + /// + /// Gets or sets the extension attribute name for the . + /// + public string AuthTypeAttributeName { get; set => field = SetAndInitializeNameArray(value); } = "authtype"; + + /// + /// Gets or sets the extension attribute name for the . + /// + public string AuthIdAttributeName { get; set => field = SetAndInitializeNameArray(value); } = "authid"; + + /// + /// Gets or sets the extension attribute name for the . + /// + public string ReplyToAttributeName { get; set => field = SetAndInitializeNameArray(value); } = "replyto"; + + /// + /// Gets or sets the casing to apply to the . + /// + /// Defaults to . + public StringCase TitleCase { get; set; } = StringCase.Lower; + + /// + /// Gets or sets the casing to apply to the . + /// + public StringCase SourceCase { get; set; } = StringCase.Lower; + + /// + /// Indicates whether to set the where to the . + /// + /// Defaults to . + public bool SetPartitionKeyToKey { get; set; } = true; + + /// + /// Indicates whether to throw an during when the is . + /// + public bool PartitionKeyIsRequired { get; set; } = true; + + /// + /// Gets or sets the prefix used by the default formatting. + /// + /// This should use the '.' to separate segments. + /// This defaults to the . + public string? TitlePrefix { get; set; } + + /// + /// Gets or sets the base used by the default formatting. + /// + public Uri? SourceBaseUri { get; set; } + + /// + /// Gets or sets the default Domain (DDD) name to be used where not specified on the (see ). + /// + /// This defaults to the . + public string? DomainName { get; set; } + + /// + /// The is formatted by default using the following convention: + /// [{EventFormatter.TitlePrefix}.]{EventData.DomainName}.{EventData.Entity}.{EventData.Action}[.v{EventData.DataSchemaVersion.Major}] + public virtual EventData Format(EventData @event) + { + string[] tpa = TitlePrefix is null ? [] : [TitlePrefix]; + + if (@event.Title is null) + { + @event.Title = Cleaner.Clean(string.Join('.', [.. tpa, @event.DomainName ?? DomainName ?? _defaultSegment, @event.Entity ?? _defaultSegment, @event.Action ?? _defaultSegment]), casing: TitleCase); + if (@event.DataSchemaVersion is not null) + @event.Title += $".v{@event.DataSchemaVersion.Major}"; + } + + if (@event.Source is null) + { + if (@event.TenantId is null) + { + if (SourceBaseUri is not null) + @event.Source = new Uri(SourceBaseUri.OriginalString); + } + else if (Uri.TryCreate(SourceBaseUri, Cleaner.Clean(@event.TenantId, casing: SourceCase), out var uri)) + @event.Source = uri; + + if (@event.Source is null) + { + if (SourceBaseUri is not null) + throw new InvalidOperationException($"The {nameof(EventData)}.{nameof(EventData.Source)} URI could not be successfully created using the {nameof(SourceBaseUri)} and {nameof(EventData)}.{nameof(EventData.TenantId)}"); + else + @event.Source = SourceBaseUri; + } + } + + if (SetPartitionKeyToKey) + @event.PartitionKey ??= @event.Key; + + if (@event.PartitionKey is null && PartitionKeyIsRequired) + throw new InvalidOperationException($"A {nameof(EventData)}.{nameof(EventData.PartitionKey)} PartitionKey is required."); + + return @event; + } + + /// + /// Parses with the following convention: + /// [{EventFormatter.TitlePrefix}.]{EventData.DomainName}.{EventData.Entity}.{EventData.Action}[.v{EventData.DataSchemaVersion.Major}] + /// Updates the corresponding properties where not currently set; however, where there is a mismatch with the convention during parsing no update will occur. + public virtual EventData Parse(EventData @event) + { + var title = @event.Title?.Trim(); + if (string.IsNullOrEmpty(title)) + return @event; + + if (TitlePrefix is not null) + { + if (title.Length <= TitlePrefix.Length + 1) + return @event; + + if (title.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase)) + title = title[TitlePrefix.Length..].TrimStart('.'); + else + return @event; + } + + var segments = title.Split('.'); + if (segments.Length < 3) + return @event; + + // At this point we believe we have at least 3 segments: DomainName, Entity, Action - which is enough to carry on. + @event.DomainName = segments[0]; + @event.Entity = segments[1]; + @event.Action = segments[2]; + + // The fourth segment is optional and represents the DataSchemaVersion. + if (segments.Length >= 4) + { + if (segments[3].Length > 1 && segments[3].StartsWith("v", StringComparison.OrdinalIgnoreCase) && int.TryParse(segments[3][1..], out var major)) + @event.DataSchemaVersion ??= new Version(major, 0); + } + + return @event; + } + + /// + public virtual CloudEvent ConvertToCloudEvent(EventData @event) + { + var ce = new CloudEvent + { + Id = @event.ThrowIfNull().Id, + Type = @event.Title, + Source = @event.Source, + Subject = @event.Key, + Time = @event.Timestamp + }; + + ce.SetExtensionAttribute(TenantIdAttributeName, @event.TenantId); + + var pk = @event.PartitionKey ?? @event.Key; + if (pk is not null) + ce.SetPartitionKey(pk); + + ce.SetExtensionAttribute(ReplyToAttributeName, @event.ReplyTo); + ce.SetExtensionAttribute(AuthTypeAttributeName, ConvertFromAuthenticationType(@event.UserType)); + ce.SetExtensionAttribute(AuthIdAttributeName, @event.UserId); + + AddTracing(ce, @event.TraceParent, @event.TraceState, @event.TraceBaggage); + + foreach (var kvp in @event.Attributes.Where(x => !x.Key.StartsWith('_')).OrderBy(x => x.Key)) + { + ce.SetExtensionAttribute(kvp.Key, kvp.Value); + } + + if (@event.Data is not null) + { + ce.SetExtensionAttribute(DataSchemaVersionAttributeName, @event.DataSchemaVersion?.ToString()); + ce.DataSchema = @event.DataSchema; + ce.DataContentType = @event.Data?.MediaType; + ce.Data = @event.Data; + } + + return ce; + } + + /// + /// The must be of type otherwise a will be thrown. + public virtual EventData ConvertFromCloudEvent(CloudEvent cloudEvent) + { + var @event = new EventData + { + Id = cloudEvent.ThrowIfNull().Id ?? string.Empty, + Timestamp = cloudEvent.Time ?? DateTimeOffset.MinValue, + DataSchema = cloudEvent.DataSchema, + Key = cloudEvent.Subject, + PartitionKey = cloudEvent.GetPartitionKey() + }; + + if (cloudEvent.Data is not null) + @event.Data = cloudEvent.Data is BinaryData ed ? ed : throw new NotSupportedException($"The {nameof(CloudEvent)}.{nameof(CloudEvent.Data)} type must be {nameof(BinaryData)}; not '{cloudEvent.Data.GetType().FullName}'."); + + if (cloudEvent.TryGetExtensionAttribute(DataSchemaVersionAttributeName, out string? val)) + @event.DataSchemaVersion = Version.TryParse(val, out var ver) ? ver : null; + + if (cloudEvent.TryGetExtensionAttribute(TenantIdAttributeName, out val)) + @event.TenantId = val; + + if (cloudEvent.TryGetExtensionAttribute(ReplyToAttributeName, out val)) + @event.ReplyTo = val; + + if (cloudEvent.TryGetExtensionAttribute(AuthTypeAttributeName, out val)) + @event.UserType = ConvertToAuthenticationType(val); + + if (cloudEvent.TryGetExtensionAttribute(AuthIdAttributeName, out val)) + @event.UserId = val; + + if (cloudEvent.TryGetExtensionAttribute(TraceParentAttributeName, out val)) + @event.TraceParent = val; + + if (cloudEvent.TryGetExtensionAttribute(TraceStateAttributeName, out val)) + @event.TraceState = val; + + if (cloudEvent.TryGetExtensionAttribute(TraceBaggageAttributeName, out val) && !string.IsNullOrEmpty(val)) + { + var carrier = new Dictionary { ["baggage"] = val }; + var propagationContext = _propagator.Extract(default, carrier, (msg, key) => msg.TryGetValue(key, out var value) ? [value!] : []); + + if (propagationContext.Baggage.Count > 0) + @event.TraceBaggage = Baggage.GetBaggage(propagationContext.Baggage).Select(x => new KeyValuePair(x.Key, x.Value)); + } + + if (cloudEvent.Source is not null) + @event.Source = cloudEvent.Source; + + if (!string.IsNullOrEmpty(cloudEvent.Type)) + @event.Title = cloudEvent.Type; + + InitializeNameArray(); + foreach (var kvp in cloudEvent.GetPopulatedAttributes().Where(x => !_nameArray.Contains(x.Key.Name, StringComparer.OrdinalIgnoreCase))) + { + @event.SetAttribute(kvp.Key.Name, kvp.Value); + } + + return @event; + } + + /// + /// Flags that the initialization of the name array is required. + /// + private string SetAndInitializeNameArray(string value) + { + _initializeNameArray = true; + return value.ThrowIfNullOrEmpty(); + } + + /// + /// (Re)initializes the name array used for attribute name checking. + /// + private void InitializeNameArray() + { + if (!_initializeNameArray) + return; + + _initializeNameArray = false; + _nameArray = [.. CloudEventsSpecVersion.Default.AllAttributes.Select(x => x.Name), Partitioning.PartitionKeyAttribute.Name, TraceParentAttributeName, TraceStateAttributeName, TenantIdAttributeName, DataSchemaVersionAttributeName, AuthTypeAttributeName, AuthIdAttributeName, ReplyToAttributeName]; + } + + /// + public void AddTracing(CloudEvent @event, string? traceParent = null, string? traceState = null, IEnumerable>? traceBaggage = null) + { + @event.ThrowIfNull(); + if (@event.TryGetExtensionAttribute(TraceParentAttributeName, out string _)) + return; + + if (string.IsNullOrEmpty(traceParent) && Activity.Current is not null) + { + traceParent = Activity.Current.Id; + traceState = Activity.Current.TraceStateString; + traceBaggage ??= Activity.Current.Baggage; + } + + if (string.IsNullOrEmpty(traceParent)) + return; + + string? formattedBaggage = null; + if (traceBaggage is not null) + { + var baggage = default(Baggage); + foreach (var item in traceBaggage) + baggage = baggage.SetBaggage(item.Key, item.Value); + + if (baggage.Count > 0) + { + var carrier = new Dictionary(); + var ac = Activity.Current?.Context ?? (ActivityContext.TryParse(traceParent, traceState, out var context) ? context : default); + if (ac != default) + { + _propagator.Inject(new PropagationContext(Activity.Current?.Context ?? ActivityContext.Parse(traceParent, traceState), baggage), carrier, (msg, key, value) => msg[key] = value); + carrier.TryGetValue("baggage", out formattedBaggage); + } + } + } + + @event.SetExtensionAttribute(TraceParentAttributeName, traceParent); + @event.SetExtensionAttribute(TraceStateAttributeName, traceState); + @event.SetExtensionAttribute(TraceBaggageAttributeName, formattedBaggage); + } + + /// + /// Converts the to a corresponding CloudEvent 'authtype' attribute value. + /// + /// The . + /// The corresponding CloudEvent 'authtype' attribute value. + protected string? ConvertFromAuthenticationType(AuthenticationType? type) => type switch + { + null => null, + AuthenticationType.Unknown => "unknown", + AuthenticationType.Unauthenticated => "unauthenticated", + AuthenticationType.ApplicationUser => "app_user", + AuthenticationType.AccountUser => "user", + AuthenticationType.SystemUser => "system", + _ => throw new InvalidOperationException($"{nameof(AuthenticationType)} of '{type}' is unable to be converted to a corresponding CloudEvent 'authtype' attribute.") + }; + + /// + /// Converts the CloudEvent 'authtype' attribute value to a corresponding . + /// + /// The CloudEvent 'authtype' attribute value. + /// The corresponding . + protected AuthenticationType? ConvertToAuthenticationType(string? type) => type switch + { + null => null, + "unknown" => AuthenticationType.Unknown, + "unauthenticated" => AuthenticationType.Unauthenticated, + "app_user" => AuthenticationType.ApplicationUser, + "user" => AuthenticationType.AccountUser, + "system" => AuthenticationType.SystemUser, + _ => throw new InvalidOperationException($"The CloudEvent 'authtype' attribute value of '{type}' is unable to be converted to a corresponding {nameof(AuthenticationType)}.") + }; +} \ No newline at end of file diff --git a/src/CoreEx.Events/EventsExtensions.CloudEvent.cs b/src/CoreEx.Events/EventsExtensions.CloudEvent.cs new file mode 100644 index 00000000..774726ec --- /dev/null +++ b/src/CoreEx.Events/EventsExtensions.CloudEvent.cs @@ -0,0 +1,175 @@ +namespace CoreEx.Events; + +public static partial class EventsExtensions +{ + /// + /// Infers the from the 's . + /// + /// The . + /// The inferred . + public static ContentMode InferContentMode(this BinaryData binaryData) + { + if (binaryData is null || string.IsNullOrEmpty(binaryData.MediaType)) + return ContentMode.Binary; + + var contentType = new ContentType(binaryData.MediaType); + return contentType.MediaType.StartsWith("application/cloudevents", StringComparison.OrdinalIgnoreCase) ? ContentMode.Structured : ContentMode.Binary; + } + + /// + /// Encodes the to a using the specified . + /// + /// The . + /// The ; defaults to . + /// The optional . + /// The encoded to . + /// This uses a customized internally to enable. + public static BinaryData EncodeToBinaryData(this CloudEvent cloudEvent, ContentMode contentMode = ContentMode.Structured, JsonSerializerOptions? jsonSerializerOptions = null) + { + cloudEvent.ThrowIfNull(); + var formatter = new InternalFormatter(jsonSerializerOptions ?? JsonDefaults.SerializerOptions, default); + return contentMode switch + { + ContentMode.Structured => new BinaryData(formatter.EncodeStructuredModeMessage(cloudEvent.ThrowIfNull(), out var contentType), contentType.ToString()), + ContentMode.Binary => new BinaryData(formatter.EncodeBinaryModeEventData(cloudEvent.ThrowIfNull()), cloudEvent.DataContentType), + _ => throw new ArgumentException("Invalid content mode specified.", nameof(contentMode)) + }; + } + + /// + /// Decodes the to a using the specified . + /// + /// The . + /// The ; defaults to . + /// The optional . + /// The decoded to a . + public static CloudEvent DecodeToCloudEvent(this BinaryData binaryData, ContentMode contentMode = ContentMode.Structured, JsonSerializerOptions? jsonSerializerOptions = null) + { + var formatter = new InternalFormatter(jsonSerializerOptions ?? JsonDefaults.SerializerOptions, default); + var contentType = string.IsNullOrEmpty(binaryData?.MediaType) ? null : new ContentType(binaryData.MediaType); + + if (contentMode == ContentMode.Structured) + return formatter.DecodeStructuredModeMessage(binaryData, contentType, null); + + var cex = new CloudEvent() { DataContentType = contentType?.MediaType }; + if (formatter.IsJsonContentType(contentType)) + cex.Data = binaryData; + else + formatter.DecodeBinaryModeEventData(binaryData, cex); + + return cex; + } + + /// + /// Encodes the to a as . + /// + /// The . + /// The optional . + /// The encoded to a . + public static JsonElement EncodeToJsonElement(this CloudEvent cloudEvent, JsonSerializerOptions? jsonSerializerOptions = null) + { + var formatter = new InternalFormatter(jsonSerializerOptions ?? JsonDefaults.SerializerOptions, default); + var jr = new Utf8JsonReader(formatter.EncodeStructuredModeMessage(cloudEvent.ThrowIfNull(), out var _).Span); + return JsonElement.ParseValue(ref jr); + } + + /// + /// Decodes the to a assuming . + /// + /// The . + /// The optional . + /// The decoded to a . + public static CloudEvent DecodeToCloudEvent(this JsonElement jsonElement, JsonSerializerOptions? jsonSerializerOptions = null) + { + var formatter = new InternalFormatter(jsonSerializerOptions ?? JsonDefaults.SerializerOptions, default); + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + jsonElement.WriteTo(writer); + } + + stream.Position = 0; + return formatter.DecodeStructuredModeMessage(stream, new ContentType(MediaTypeNames.Application.Json), null); + } + + /// + /// Customized internal . + /// + private sealed class InternalFormatter(JsonSerializerOptions options, JsonDocumentOptions jsonDocumentOptions) : JsonEventFormatter(options, jsonDocumentOptions) + { + /// + /// Indicates whether the is considered JSON. + /// + public bool IsJsonContentType(ContentType? contentType) => contentType is not null && IsJsonMediaType(contentType.MediaType); + + /// + protected override void EncodeStructuredModeData(CloudEvent cloudEvent, Utf8JsonWriter writer) + { + if (cloudEvent.Data is BinaryData bd && !string.IsNullOrEmpty(cloudEvent.DataContentType) && IsJsonMediaType(cloudEvent.DataContentType)) + { + writer.WritePropertyName(DataPropertyName); + writer.WriteRawValue(bd, true); + } + else + base.EncodeStructuredModeData(cloudEvent, writer); + } + + /// + public override ReadOnlyMemory EncodeBinaryModeEventData(CloudEvent cloudEvent) + { + if (cloudEvent is not null && cloudEvent.Data is BinaryData bd && !string.IsNullOrEmpty(cloudEvent.DataContentType) && IsJsonMediaType(cloudEvent.DataContentType)) + return bd.ToArray(); + else + return base.EncodeBinaryModeEventData(cloudEvent!); + } + + protected override void DecodeStructuredModeDataProperty(JsonElement dataElement, CloudEvent cloudEvent) + { + cloudEvent.Data = new BinaryData(dataElement.GetRawText()); + } + + /// + public override void DecodeBinaryModeEventData(ReadOnlyMemory body, CloudEvent cloudEvent) + { + base.DecodeBinaryModeEventData(body, cloudEvent); + + if (cloudEvent.Data is not null && cloudEvent.Data is not BinaryData) + cloudEvent.Data = new BinaryData(cloudEvent.Data); + } + } + + /// + /// Sets the extension attribute where not default value. + /// + /// The . + /// The attribute name. + /// The attribute value. + public static void SetExtensionAttribute(this CloudEvent ce, string name, T value) + { + if (Comparer.Default.Compare(value, default!) == 0) + return; + + ce[name] = value; + } + + /// + /// Tries to get the named extension attribute value. + /// + /// The . + /// The attribute name. + /// The attribute value. + /// indicates that the extension attribute exists; otherwise, . + public static bool TryGetExtensionAttribute(this CloudEvent ce, string name, [NotNullWhen(true)] out T value) + { + var val = ce[name]; + if (val is null) + { + value = default!; + return false; + } + + value = (T)val; + return true; + } +} \ No newline at end of file diff --git a/src/CoreEx.Events/EventsExtensions.cs b/src/CoreEx.Events/EventsExtensions.cs new file mode 100644 index 00000000..1734709f --- /dev/null +++ b/src/CoreEx.Events/EventsExtensions.cs @@ -0,0 +1,8 @@ +namespace CoreEx.Events; + +/// +/// Provides event related extensions. +/// +public static partial class EventsExtensions +{ +} \ No newline at end of file diff --git a/src/CoreEx.Events/GlobalUsing.cs b/src/CoreEx.Events/GlobalUsing.cs new file mode 100644 index 00000000..dbd391cc --- /dev/null +++ b/src/CoreEx.Events/GlobalUsing.cs @@ -0,0 +1,35 @@ +global using CloudNative.CloudEvents; +global using CloudNative.CloudEvents.Extensions; +global using CloudNative.CloudEvents.SystemTextJson; +global using CoreEx; +global using CoreEx.Abstractions; +global using CoreEx.Data; +global using CoreEx.Entities; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Events.Subscribing; +global using CoreEx.Events.Subscribing.Exceptions; +global using CoreEx.Hosting; +global using CoreEx.Invokers; +global using CoreEx.Json; +global using CoreEx.Results; +global using CoreEx.Schemas; +global using CoreEx.Security; +global using CoreEx.Validation; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using OpenTelemetry; +global using OpenTelemetry.Context.Propagation; +global using System.Buffers; +global using System.Collections.Concurrent; +global using System.Collections.Immutable; +global using System.Diagnostics; +global using System.Diagnostics.CodeAnalysis; +global using System.Diagnostics.Metrics; +global using System.Net.Mime; +global using System.Reflection; +global using System.Runtime.CompilerServices; +global using System.Text.Json; +global using System.Text.Json.Nodes; +global using System.Text.Json.Serialization; +global using System.Text.RegularExpressions; \ No newline at end of file diff --git a/src/CoreEx.Events/IEventFormatter.cs b/src/CoreEx.Events/IEventFormatter.cs new file mode 100644 index 00000000..f77ad16b --- /dev/null +++ b/src/CoreEx.Events/IEventFormatter.cs @@ -0,0 +1,45 @@ +namespace CoreEx.Events; + +/// +/// Enables the formatting () and parsing () of an , and its conversion to () and from () a . +/// +public interface IEventFormatter +{ + /// + /// Formats the ; this should be performed before the . + /// + /// The . + /// The to support fluent-style method-chaining. + EventData Format(EventData @event); + + /// + /// Parses the ; this should be performed after the . + /// + /// The . + /// The to support fluent-style method-chaining. + EventData Parse(EventData @event); + + /// + /// Adds tracing to the . + /// + /// The . + /// The optional trace parent; defaults to . + /// The optional trace state; defaults to . + /// The optional trace baggage; defaults to . + /// To add, as a minimum the must be specified. + void AddTracing(CloudEvent cloudEvent, string? traceParent = null, string? traceState = null, IEnumerable>? traceBaggage = null); + + /// + /// Converts an to a . + /// + /// The . + /// The resulting . + CloudEvent ConvertToCloudEvent(EventData @event); + + /// + /// Converts from a to an . + /// + /// The . + /// The resulting . + EventData ConvertFromCloudEvent(CloudEvent cloudEvent); +} \ No newline at end of file diff --git a/src/CoreEx.Events/MessageType.cs b/src/CoreEx.Events/MessageType.cs new file mode 100644 index 00000000..3b0b2b44 --- /dev/null +++ b/src/CoreEx.Events/MessageType.cs @@ -0,0 +1,22 @@ +namespace CoreEx.Events; + +/// +/// Represents the type of message. +/// +public enum MessageType +{ + /// + /// The message is an event. + /// + Event = 0, + + /// + /// The message is a command. + /// + Command = 1, + + /// + /// The message is a reply-to. + /// + ReplyTo = 2 +} \ No newline at end of file diff --git a/src/CoreEx.Events/Publishing/DestinationEvent.cs b/src/CoreEx.Events/Publishing/DestinationEvent.cs new file mode 100644 index 00000000..c7e114d4 --- /dev/null +++ b/src/CoreEx.Events/Publishing/DestinationEvent.cs @@ -0,0 +1,28 @@ +namespace CoreEx.Events.Publishing; + +/// +/// Provides the and pairing for the . +/// +public sealed record class DestinationEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The destination (i.e. topic) name. + /// The . + public DestinationEvent(string destination, CloudEvent @event) + { + Destination = destination.ThrowIfNullOrEmpty(); + Event = @event.ThrowIfNull(); + } + + /// + /// Gets the destination (i.e. topic) name. + /// + public string Destination { get; } + + /// + /// Gets the . + /// + public CloudEvent Event { get; } +} \ No newline at end of file diff --git a/src/CoreEx.Events/Publishing/EventPublisherBase.cs b/src/CoreEx.Events/Publishing/EventPublisherBase.cs new file mode 100644 index 00000000..67aa035e --- /dev/null +++ b/src/CoreEx.Events/Publishing/EventPublisherBase.cs @@ -0,0 +1,198 @@ +namespace CoreEx.Events.Publishing; + +/// +/// Provides the base standardized Event publishing and sending orchestration. +/// +/// The optional . +/// The optional . +/// The optional logger. +/// The is used to dynamically generate the default destination when adding events using . +public abstract class EventPublisherBase(IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger? logger = null) : IEventPublisher +{ + private static JsonSerializerOptions? _debugJsonSerializerOptions; + + private readonly LinkedList _queue = new(); + private readonly SemaphoreSlim _semaphore = new(1, 1); + private EventPublisherInvoker? _invoker; + + /// + /// Gets the . + /// + public IEventFormatter Formatter { get; } = formatter ?? new EventFormatter(); + + /// + /// Gets the that provides the destination (i.e. topic/queue) name for the events. + /// + /// Defaults to . + public IDestinationProvider DestinationProvider { get; } = destinationProvider ?? FixedDestinationProvider.Default; + + /// + /// Gets the optional instance. + /// + public ILogger? Logger = logger; + + /// + /// Gets the . + /// + protected EventPublisherInvoker Invoker => _invoker ??= EventPublisherInvoker.Default; + + /// + public bool IsEmpty => _queue.Count == 0; + + /// + public int Count => _queue.Count; + + /// + public bool HasBeenPublished { get; private set; } + + /// + public void Add(params IEnumerable events) + { + Synchronize(() => + { + foreach (var de in events) + { + if (de is not null) + _queue.AddLast(de); + } + }); + } + + /// + public void Add(params IEnumerable events) + { + Synchronize(() => + { + foreach (var @event in events) + { + if (@event is not null) + _queue.AddLast(new DestinationEvent(DestinationProvider.CreateFrom(@event), Formatter.ConvertToCloudEvent(Formatter.Format(@event)))); + } + }); + } + + /// + public void Add(string destination, params IEnumerable events) + { + destination.ThrowIfNullOrEmpty(); + Synchronize(() => + { + foreach (var @event in events) + { + if (@event is not null) + _queue.AddLast(new DestinationEvent(destination, Formatter.ConvertToCloudEvent(Formatter.Format(@event)))); + } + }); + } + + /// + public void Add(string destination, params IEnumerable events) + { + destination.ThrowIfNullOrEmpty(); + Synchronize(() => + { + foreach (var @event in events) + { + if (@event is not null) + _queue.AddLast(new DestinationEvent(destination, @event)); + } + }); + } + + /// + /// Synchronizes the execution of the using a semaphore to ensure thread-safety. + /// + private void Synchronize(Action action, bool check = true) + { + _semaphore.Wait(); + try + { + if (check && HasBeenPublished) + throw new InvalidOperationException("The event publisher has already been published; it cannot be reused by default. Use Reset() to continue using the publisher."); + + action(); + } + finally + { + _semaphore.Release(); + } + } + + /// + public void Clear() => Synchronize(_queue.Clear); + + /// + public void Reset() => Synchronize(() => + { + _queue.Clear(); + HasBeenPublished = false; + }, false); + + /// + public void Rollback(int count) => Synchronize(() => + { + count.ThrowWhen(count => count > _queue.Count, $"A {nameof(Rollback)} count cannot exceed the current queue length/count."); + + if (count > 0) + { + for (int i = 0; i < count; i++) + { + _queue.RemoveLast(); + } + } + }); + + /// + /// This will also prior to the underlying . + public async Task PublishAsync(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + if (HasBeenPublished) + throw new InvalidOperationException("The event publisher has already been published; it cannot be reused by default. Use Reset() to continue using the publisher."); + + if (_queue.Count == 0) + { + HasBeenPublished = true; + return; + } + + // We have something to publish, so do it. + await Invoker.InvokeAsync(this, async (tracer, cancellationToken) => + { + var events = _queue.ToArray(); + foreach (var de in events) + Formatter.AddTracing(de.Event); + + if (Logger?.IsEnabled(LogLevel.Debug) ?? false) + { + var list = _queue.Select(kvp => new { destination = kvp.Destination, @event = kvp.Event.EncodeToJsonElement() }); + Logger.LogDebug("Preparing to send {Length} event(s):{NewLine}{Json}", events.Length, Environment.NewLine, JsonSerializer.Serialize(list, _debugJsonSerializerOptions ??= new JsonSerializerOptions { WriteIndented = true })); + } + + await OnPublishAsync(events, cancellationToken).ConfigureAwait(false); + + HasBeenPublished = true; + tracer.Activity?.AddTag("event.published.count", events.Length); + }, cancellationToken).ConfigureAwait(false); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Publishes (sends) all previously added (queued) to the underlying eventing/persistence subsystem. + /// + /// One or more objects. + /// The . + /// All , and operations have been performed prior. + /// Note: the will only be called where there is at least a single event to be published; i.e. the will never be empty. + protected abstract Task OnPublishAsync(DestinationEvent[] events, CancellationToken cancellationToken = default); + + /// + public DestinationEvent[] GetEvents() => [.. _queue]; +} \ No newline at end of file diff --git a/src/CoreEx.Events/Publishing/EventPublisherInvoker.cs b/src/CoreEx.Events/Publishing/EventPublisherInvoker.cs new file mode 100644 index 00000000..9d421fe6 --- /dev/null +++ b/src/CoreEx.Events/Publishing/EventPublisherInvoker.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Events.Publishing; + +/// +/// Provides the invoker. +/// +[InvokerName("CoreEx.Events.Publishing.EventPublisher")] +public class EventPublisherInvoker : InvokerBase +{ + private static EventPublisherInvoker? _default; + + /// + /// Gets the default instance. + /// + public static EventPublisherInvoker Default => ExecutionContext.GetService() ?? (_default ??= new EventPublisherInvoker()); +} \ No newline at end of file diff --git a/src/CoreEx.Events/Publishing/FixedDestinationProvider.cs b/src/CoreEx.Events/Publishing/FixedDestinationProvider.cs new file mode 100644 index 00000000..5004605d --- /dev/null +++ b/src/CoreEx.Events/Publishing/FixedDestinationProvider.cs @@ -0,0 +1,33 @@ +namespace CoreEx.Events.Publishing; + +/// +/// Provides a where the same (fixed) is used regardless of contents; i.e. all messages are published to a single centralized destination. +/// +/// The will default to the value of the 'CoreEx.Events:Destination' configuration setting or 'default' as a fallback. +public class FixedDestinationProvider : IDestinationProvider +{ + private string? _destination = null; + + /// + /// Gets the default . + /// + public static FixedDestinationProvider Default { get; } = new FixedDestinationProvider(); + + /// + /// Gets or sets the fixed destination name. + /// + public string Destination + { + get => _destination ??= Internal.GetConfigurationValue("CoreEx:Events:Destination", "default")!; + init => _destination = value.ThrowIfNullOrEmpty(); + } + + /// + public string CreateFrom(EventData @event, bool isDeadLetter = false) => Destination; + + /// + public string CreateFrom(string destination, bool isDeadLetter = false) => Destination; + + /// + public string CreateNew(MessageType messageType = MessageType.Event, string? domainName = null, bool isDeadLetter = false) => Destination; +} \ No newline at end of file diff --git a/src/CoreEx.Events/Publishing/IDestinationProvider.cs b/src/CoreEx.Events/Publishing/IDestinationProvider.cs new file mode 100644 index 00000000..d8335b71 --- /dev/null +++ b/src/CoreEx.Events/Publishing/IDestinationProvider.cs @@ -0,0 +1,33 @@ +namespace CoreEx.Events.Publishing; + +/// +/// Enables a standardized means to create/provide the destination (i.e. topic) name. +/// +/// The dead-letter capabilities are only leveraged where this is not a native capability of the underlying messaging system (i.e. Kafka). +public interface IDestinationProvider +{ + /// + /// Creates the destination name from an . + /// + /// The . + /// Indicates whether to provide a dead-letter specific destination or not. + /// The resulting destination name. + string CreateFrom(EventData @event, bool isDeadLetter = false); + + /// + /// Creates the destination name from an existing . + /// + /// The existing destination name. + /// Indicates whether to provide a dead-letter specific destination or not. + /// The resulting destination name. + string CreateFrom(string destination, bool isDeadLetter = false); + + /// + /// Creates the destination name using the specified parameters. + /// + /// The recipient domain (DDD) name. + /// The . + /// Indicates whether to provide a dead-letter specific destination or not. + /// The resulting destination name. + string CreateNew(MessageType messageType = MessageType.Event, string? domainName = null, bool isDeadLetter = false); +} \ No newline at end of file diff --git a/src/CoreEx.Events/Publishing/IEventPublisher.cs b/src/CoreEx.Events/Publishing/IEventPublisher.cs new file mode 100644 index 00000000..84719303 --- /dev/null +++ b/src/CoreEx.Events/Publishing/IEventPublisher.cs @@ -0,0 +1,43 @@ +namespace CoreEx.Events.Publishing; + +/// +/// Defines the standardized event adding () and publishing orchestration. +/// +/// By default, the underlying implementation should support single use; i.e. can only publish once. Once, the publisher should be immutable, unless explicitly . +public interface IEventPublisher : IEventQueue +{ + /// + /// Indicates whether the event publisher has previously published events. + /// + /// Use to re-enable the publishing of the events. + /// Note: The internal queue is not automatically emptied in case there is an unexpected error and the publishing needs to be retried. + bool HasBeenPublished { get; } + + /// + /// Publishes (sends) all previously added (queued) events to the underlying eventing/persistence subsystem. + /// + /// The . + /// Note that all existing events will remain within the internal queue unless a is explicitly performed. + Task PublishAsync(CancellationToken cancellationToken = default); + + /// + /// Resets (and clears) the event publisher to re-enable adding and publishing. + /// + /// Resets the to . + /// All existing events will also be cleared; see . + void Reset(); + + /// + /// Rollback (i.e. dequeue) the specified number of previous Add operations. + /// + /// The number of Add operations to roll back. + /// The rollback will only function where is . + void Rollback(int count); + + /// + /// Gets all destination events currently available. + /// + /// A array that is a snapshot of the current state; empty where . + /// This is intended for inspection purposes only; the returned array is a snapshot of the current state. Do not modify the individual elements. + DestinationEvent[] GetEvents(); +} \ No newline at end of file diff --git a/src/CoreEx.Events/Publishing/IEventQueue.cs b/src/CoreEx.Events/Publishing/IEventQueue.cs new file mode 100644 index 00000000..aa91207c --- /dev/null +++ b/src/CoreEx.Events/Publishing/IEventQueue.cs @@ -0,0 +1,52 @@ +namespace CoreEx.Events.Publishing; + +/// +/// Defines the standardized event adding/queueing. +/// +public interface IEventQueue +{ + /// + /// Indicates whether the internal queue is empty. + /// + /// where empty; otherwise, . + bool IsEmpty { get; } + + /// + /// Gets the internal queue count. + /// + int Count { get; } + + /// + /// Adds (queues in-process) one or more to the default destination ready for . + /// + /// Zero or more objects to publish. + /// A destination is synonymous with a topic name depending on the underlying messaging system. + void Add(params IEnumerable @events); + + /// + /// Adds (queues in-process) one or more to the specified ready for . + /// + /// The destination name. + /// Zero or more objects to publish. + /// A is synonymous with a topic name depending on the underlying messaging system. + void Add(string destination, params IEnumerable @events); + + /// + /// Adds (queues in-process) one or more to the specified ready for . + /// + /// The destination name. + /// Zero or more objects to publish. + /// A is synonymous with a topic name depending on the underlying messaging system. + void Add(string destination, params IEnumerable @events); + + /// + /// Adds (queues in-process) one or more ready for . + /// + /// Zero or more objects to publish. + void Add(params IEnumerable @events); + + /// + /// Clears the internal queue of all previously added (queued) events without publishing them. + /// + void Clear(); +} \ No newline at end of file diff --git a/src/CoreEx.Events/Publishing/NoOpEventPublisher.cs b/src/CoreEx.Events/Publishing/NoOpEventPublisher.cs new file mode 100644 index 00000000..6201436e --- /dev/null +++ b/src/CoreEx.Events/Publishing/NoOpEventPublisher.cs @@ -0,0 +1,13 @@ +namespace CoreEx.Events.Publishing; + +/// +/// Provides a no-operation event publisher; whereby the events are simply swallowed/discarded during final . +/// +/// The optional . +/// The optional . +/// The optional logger. +public class NoOpEventPublisher(IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger? logger = null) : EventPublisherBase(destinationProvider, formatter, logger) +{ + /// + protected override Task OnPublishAsync(DestinationEvent[] events, CancellationToken cancellationToken = default) => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/ErrorHandler.cs b/src/CoreEx.Events/Subscribing/ErrorHandler.cs new file mode 100644 index 00000000..ab1809b2 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/ErrorHandler.cs @@ -0,0 +1,181 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Provides the standardized error handling to ensure/enable consistency of behavior. +/// +public sealed class ErrorHandler +{ + private const string _logFormat = "{Message} [Source: {Source}, Handling: {Handling}]"; + private readonly List _handlers = []; + + /// + /// Indicates whether to automatically treat any that implements that is + /// as transient and handle with a . + /// + /// Default to . + /// Note: the overrides this to to ensure this is the default desired functionality. + public bool AutoTransientHandling { get; set; } = false; + + /// + /// Gets or sets the to use where the implements that is . + /// + /// This acts as a catch-all after the individual exception checks have occurred. + /// Note: the overrides this to as it is assumes these should be treated as poison by default. + public ErrorHandling? WhereIsExtendedErrorHandling { get; set; } + + /// + /// Adds the for the specified . + /// + /// The . + /// The . + /// The current to support fluent-style method chaining. + /// Will be checked in the sequence added. + public ErrorHandler Add(ErrorHandling errorHandling) where TException : Exception + { + _handlers.Add(new HandlerConfig { HandlingFactory = ex => ex is TException ? errorHandling : null }); + return this; + } + + /// + /// Adds the for the specified . + /// + /// The . + /// The factory. + /// The current to support fluent-style method chaining. + /// Where a is returned from the factory this indicates that the exception has not been handled and the next configured handler will be checked. + /// Will be checked in the sequence added. + public ErrorHandler Add(Func errorHandlingFactory) where TException : Exception + { + _handlers.Add(new HandlerConfig { HandlingFactory = ex => ex is TException te ? errorHandlingFactory(te) : null }); + return this; + } + + /// + /// Adds the for the specified . + /// + /// The . + /// The . + /// The current to support fluent-style method chaining. + /// Will be checked in the sequence added. + public ErrorHandler AddAssignableFrom(ErrorHandling errorHandling) where TException : Exception + { + _handlers.Add(new HandlerConfig { HandlingFactory = ex => typeof(TException).IsAssignableFrom(ex.GetType()) ? errorHandling : null }); + return this; + } + + /// + /// Adds the for the specified . + /// + /// The . + /// The factory. + /// The current to support fluent-style method chaining. + /// Where a is returned from the factory this indicates that the exception has not been handled and the next configured handler will be checked. + /// Will be checked in the sequence added. + public ErrorHandler AddAssignableFrom(Func errorHandlingFactory) where TException : Exception + { + _handlers.Add(new HandlerConfig { HandlingFactory = ex => ex is TException te && typeof(TException).IsAssignableFrom(ex.GetType()) ? errorHandlingFactory(te) : null }); + return this; + } + + /// + /// Handles the error based on the provided and the configured error handling rules. + /// + /// The . + /// The default where the error is not configured. + /// The (will always be ). + internal Result Handle(ErrorHandlerArgs args, ErrorHandling? defaultErrorHandling) + { + // Determine the error handling to use. + ErrorHandling? errorHandling = args.ErrorHandlingOverride; + if (errorHandling is null) + { + errorHandling = ResolveErrorHandling(args.Exception); + if (errorHandling is null) + { + if (defaultErrorHandling.HasValue) + errorHandling = defaultErrorHandling.Value; + else + return args.Exception; + } + } + + // Action the configured error handling. + args.SubscriberArgs.ResultingException = args.Exception; + args.SubscriberArgs.ResultingErrorHandling = errorHandling; + + var logArgs = new object[] { args.Exception.Message, args.SourceType.Name, errorHandling.Value.ToString() }; + + switch (errorHandling.Value) + { + case ErrorHandling.CompleteAsSilent: + args.Logger.LogDebug(args.Exception, _logFormat, logArgs); + return new EventSubscriberHandledException(ErrorHandling.CompleteAsSilent, null, args.Exception); + + case ErrorHandling.CompleteAsInformation: + args.Logger.LogInformation(args.Exception, _logFormat, logArgs); + return new EventSubscriberHandledException(ErrorHandling.CompleteAsInformation, null, args.Exception); + + case ErrorHandling.CompleteAsWarning: + args.Logger.LogWarning(args.Exception, _logFormat, logArgs); + return new EventSubscriberHandledException(ErrorHandling.CompleteAsWarning, null, args.Exception); + + case ErrorHandling.CompleteAsError: + args.Logger.LogError(args.Exception, _logFormat, logArgs); + return new EventSubscriberHandledException(ErrorHandling.CompleteAsError, null, args.Exception); + + case ErrorHandling.Retry: + args.Logger.LogDebug(args.Exception, _logFormat, logArgs); + return new EventSubscriberRetryException(null, args.Exception); + + case ErrorHandling.DeadLetter: + args.Logger.LogDebug(args.Exception, _logFormat, logArgs); + return new EventSubscriberDeadLetterException(null, args.Exception); + + case ErrorHandling.Catastrophic: + args.Logger.LogCritical(args.Exception, _logFormat, logArgs); + return new EventSubscriberCatastrophicException(null, args.Exception); + + case ErrorHandling.None: + default: + args.Logger.LogDebug(args.Exception, _logFormat, logArgs); + return new EventSubscriberUnhandledException(null, args.Exception); + } + } + + /// + /// Attempts to resolve the for the specified using the underlying configuration. + /// + private ErrorHandling? ResolveErrorHandling(Exception exception) + { + // Loop-de-loop through the configured handlers to determine the handling to use. + foreach (var handler in _handlers) + { + var handling = handler.GetHandling(exception); + if (handling.HasValue) + return handling.Value; + } + + // Check the extended exception configuration where applicable. + if (exception is IExtendedException extendedException) + { + if (extendedException.IsTransient && AutoTransientHandling) + return ErrorHandling.Retry; + + if (extendedException.IsError && WhereIsExtendedErrorHandling.HasValue) + return WhereIsExtendedErrorHandling.Value; + } + + // No configuration. + return null; + } + + /// + /// Provides the per configuration for the . + /// + private sealed class HandlerConfig() + { + public required Func HandlingFactory { get; init; } + + public ErrorHandling? GetHandling(Exception ex) => HandlingFactory(ex); + } +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/ErrorHandlerArgs.cs b/src/CoreEx.Events/Subscribing/ErrorHandlerArgs.cs new file mode 100644 index 00000000..ca723608 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/ErrorHandlerArgs.cs @@ -0,0 +1,37 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// The arguments. +/// +public sealed class ErrorHandlerArgs +{ + /// + /// Gets the corresponding . + /// + public required EventSubscriberArgs SubscriberArgs { get; init; } + + /// + /// Gets the owning . + /// + public EventSubscriberBase Subscriber => SubscriberArgs.Owner ?? throw new InvalidOperationException($"The {nameof(SubscriberArgs)}.{nameof(SubscriberArgs.Owner)} has not been set."); + + /// + /// Gets the . + /// + public ILogger Logger => Subscriber.Logger; + + /// + /// Gets the source of the subscriber that is handing the error. + /// + public required Type SourceType { get; init; } + + /// + /// Gets the that must be handled. + /// + public required Exception Exception { get; init; } + + /// + /// Gets the optional override (bypassing configuration). + /// + public ErrorHandling? ErrorHandlingOverride { get; init; } +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/ErrorHandling.cs b/src/CoreEx.Events/Subscribing/ErrorHandling.cs new file mode 100644 index 00000000..da33c637 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/ErrorHandling.cs @@ -0,0 +1,52 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Provides the result options. +/// +public enum ErrorHandling +{ + /// + /// Indicates that when the corresponding error occurs that no specific handling should occur and will result in an which will bubble back up the stack for the invoking host to handle. + /// + /// A message will be logged, where applicable, to support debugging. + None, + + /// + /// Indicates that when the corresponding error occurs this is expected and the current event/message should be completed without further processing (i.e. silently). + /// + /// A message will be logged, where applicable, to support debugging. + CompleteAsSilent, + + /// + /// Indicates that when the corresponding error occurs this is expected and should be completed without further processing after logging as . + /// + CompleteAsInformation, + + /// + /// Indicates that when the corresponding error occurs this is expected and should be completed without further processing after logging as . + /// + CompleteAsWarning, + + /// + /// Indicates that when the corresponding error occurs this is expected and should be completed without further processing after logging as . + /// + CompleteAsError, + + /// + /// Indicates that when the corresponding error occurs that it may be transient and should be retried (where possible). + /// + /// A message will be logged, where applicable, to support debugging. + Retry, + + /// + /// Indicates that when the corresponding error occurs the current event/message should be forwarded as a dead-letter without further processing. + /// + /// A message will be logged, where applicable, to support debugging. + DeadLetter, + + /// + /// Indicates that when the corresponding error occurs that it is considered catastrophic and will result in an ) which will bubble back up the stack for the invoking host to handle. + /// + /// A message will be logged. + Catastrophic +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/EventSubscriberArgs.cs b/src/CoreEx.Events/Subscribing/EventSubscriberArgs.cs new file mode 100644 index 00000000..d981eb89 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/EventSubscriberArgs.cs @@ -0,0 +1,59 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Provides arguments. +/// +/// Where additional properties are required then leverage the dictionary. +public sealed class EventSubscriberArgs +{ + /// + /// Gets or sets the owning . + /// + public EventSubscriberBase? Owner { get; set => field = field is null ? value : throw new InvalidOperationException($"The {nameof(Owner)} is immutable."); } + + /// + /// Gets the properties . + /// + public IDictionary Properties { get; } = new Dictionary(); + + /// + /// Gets or sets the messaging system unique key. + /// + /// This is intended to provide a unique key related to the actual underlying messaging system receive/consume. For example, a log-based messaging system, such as Kafka, this may + /// be the composition of the topic, partition and offset. And for a AMPQ-based messaging system, such as Azure Service Bus, this may be the composition of the topic (or subscription) and sequence number. + /// This provides uniqueness beyond the likes of the which has no messaging system uniqueness guarantees. + public string? MessageUniqueKey { get; set; } + + /// + /// Gets or sets the resiliency attempt count (where applicable). + /// + /// A value of zero indicates first execution; i.e. no retry performed. + public int AttemptCount { get; set; } + + /// + /// Gets or sets the corresponding subscribed (where applicable). + /// + public CloudEvent? CloudEvent { get; set; } + + /// + /// Gets the resulting where there was an error processing the event. + /// + public Exception? ResultingException { get; internal set; } + + /// + /// Gets the resulting where there was a . + /// + public ErrorHandling? ResultingErrorHandling { get; internal set; } + + /// + /// Indicates whether the is being used. + /// + public bool UsesSubscribedManager { get; internal set; } + + /// + /// Gets the matched instance where the is being used. + /// + /// Where the is not being used then this will always be . Where the is , this will + /// be the matched instance; and in this case where this indicates that no matching subscriber was found. + public SubscribedBase? Subscriber { get; internal set; } +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/EventSubscriberBase.cs b/src/CoreEx.Events/Subscribing/EventSubscriberBase.cs new file mode 100644 index 00000000..4b50f450 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/EventSubscriberBase.cs @@ -0,0 +1,192 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Provides the base event subscribing capabilities. +/// +/// The optional . +/// The . +public abstract class EventSubscriberBase(IEventFormatter formatter, ILogger logger) +{ + /// + /// Gets the . + /// + public IEventFormatter Formatter { get; } = formatter.ThrowIfNull(); + + /// + /// Gets the . + /// + public ILogger Logger { get; } = logger.ThrowIfNull(); + + /// + /// Gets or sets the customizable configuration. + /// + /// The is defaulted to and the is defaulted to . + public ErrorHandler ErrorHandler { get; set => field = value.ThrowIfNull(); } = new() { AutoTransientHandling = true, WhereIsExtendedErrorHandling = ErrorHandling.CompleteAsError }; + + /// + /// Gets or sets the unhandled . + /// + /// Defaults to . + /// This is a catch all for any not explicity configured within the . + public ErrorHandling UnhandledErrorHandling { get; set; } = ErrorHandling.None; + + /// + /// Gets or sets the for when the does not equal the . + /// + /// Defaults to . + /// Set to to bypass check. + public ErrorHandling? TenantIdMismatchHandling { get; set; } = ErrorHandling.Catastrophic; + + /// + /// Gets or sets the where the is not considered valid. + /// + /// Defaults to as it is considered poison and as such is unable to be processed. + /// This is used by . + public ErrorHandling InvalidDataHandling { get; set; } = ErrorHandling.CompleteAsError; + + /// + /// Gets or sets the optional . + /// + /// This is used by . + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// Receives a . + /// + /// The . + /// The optional . + /// The . + /// The . + /// This will the into an and invoke the + /// Additionally, this method will specifically emit both the related tracing tags and the for observability. + protected async Task ReceiveAsync(CloudEvent cloudEvent, EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) + { + args ??= new EventSubscriberArgs(); + if (this != args.Owner) + args.Owner = this; + + args.CloudEvent = cloudEvent; + + // Add tracing (where applicable). + if (Activity.Current is not null) + { + Activity.Current.SetTag("cloudevents.event_id", cloudEvent.Id); + Activity.Current.SetTag("cloudevents.event_source", cloudEvent.Source); + Activity.Current.SetTag("cloudevents.event_type", cloudEvent.Type); + Activity.Current.SetTag("cloudevents.event_subject", cloudEvent.Subject); + Activity.Current.SetTag("cloudevents.event_spec_version", cloudEvent.SpecVersion.VersionId); + } + + if (Logger.IsEnabled(LogLevel.Debug)) + Logger.LogDebug("Received CloudEvent with Id='{CloudEventId}', Source='{CloudEventSource}', Type='{CloudEventType}', Subject='{CloudEventSubject}'.", cloudEvent.Id, cloudEvent.Source, cloudEvent.Type, cloudEvent.Subject); + + // Convert to an EventData and process. + return await ReceiveWrapperAsync(args, async () => await ReceiveAsync(Formatter.ConvertFromCloudEvent(cloudEvent), args, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + } + + /// + /// Receives an . + /// + /// The . + /// The optional . + /// The . + /// The . + /// This invokes the to perform the specific receive work. + /// This will also link the underlying to the originating and where applicable. + protected async Task ReceiveAsync(EventData @event, EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) + { + args ??= new EventSubscriberArgs(); + if (this != args.Owner) + args.Owner = this; + + // When the event has tracing we should link and include any baggage. We link as we don't want to override the current activity but rather link to the originating event's activity. + if (!string.IsNullOrEmpty(@event.TraceParent) && Activity.Current is not null) + { + if (ActivityContext.TryParse(@event.TraceParent, @event.TraceState, out var ac)) + { + Activity.Current?.AddLink(new ActivityLink(ac)); + if (@event.TraceBaggage is not null) + { + foreach (var kvp in @event.TraceBaggage) + Activity.Current?.AddBaggage(kvp.Key, kvp.Value); + } + } + } + + return await ReceiveWrapperAsync(args, async () => + { + // Where the tenant is specified then confirm is same. + if (TenantIdMismatchHandling.HasValue && !string.IsNullOrEmpty(@event.TenantId) && ExecutionContext.TryGetCurrent(out var ec) && ec.TenantId != @event.TenantId) + return new EventSubscriberReceiveException($"{nameof(ExecutionContext.TenantId)} mismatch: {nameof(EventData)}.{nameof(EventData.TenantId)} '{@event.TenantId}' does not equal {nameof(ExecutionContext)}.{nameof(ExecutionContext.TenantId)} '{ec.TenantId}'.", TenantIdMismatchHandling.Value); + + return await OnReceiveAsync(@event, args ??= new EventSubscriberArgs(), cancellationToken).ConfigureAwait(false); + }).ConfigureAwait(false); + } + + /// + /// Receives and processes the . + /// + /// The . + /// The . + /// The . + /// The . + protected abstract Task OnReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken); + + /// + /// Receives and processes the function with standardized execution and error/exception handling. + /// + private async Task ReceiveWrapperAsync(EventSubscriberArgs args, Func> receiveAsync) + { + // Execute the internal receive logic. + Result result; + try + { + result = await receiveAsync().ConfigureAwait(false); + if (result.IsSuccess) + return result; + } + catch (Exception ex) // Capture any unhandled exception. + { + result = Result.Fail(ex); + } + + // Apply standardized error/exception handling where applicable. + if (result.Error is EventSubscriberReceiveException rex) // Expected with self declared error handling. + return ErrorHandler.Handle(new ErrorHandlerArgs { SubscriberArgs = args, SourceType = GetType(), ErrorHandlingOverride = rex.ErrorHandling, Exception = rex }, defaultErrorHandling: UnhandledErrorHandling); + else if (result.Error is not IEventSubscriberException && !result.Error.IsCanceled()) // Ignore IEventSubscriberException's and *CanceledException as they are intended to bubble up! + return ErrorHandler.Handle(new ErrorHandlerArgs { SubscriberArgs = args, SourceType = GetType(), Exception = result.Error }, defaultErrorHandling: UnhandledErrorHandling); + + return result; + } + + /// + /// Deserializes the value to the specified type. + /// + /// The value . + /// The . + /// Indicates whether the value is required. + /// The optional override. + /// The optional override. + /// The deserialized value as a . + /// Where an invalid data, deserialization or required, error occurs an will be be returned via a . + public Result DeserializeValue(EventData @event, bool valueIsRequired = true, ErrorHandling? invalidDataHandling = null, JsonSerializerOptions? jsonSerializerOptions = null) + { + @event.ThrowIfNull(); + + TValue? value; + try + { + value = @event.ToObjectFromJson(jsonSerializerOptions ?? JsonSerializerOptions ?? JsonDefaults.SerializerOptions)!; + } + catch (Exception ex) + { + return new EventSubscriberReceiveException("An error occurred in the event subscriber during event data deserialization.", invalidDataHandling ?? InvalidDataHandling, ex); + } + + if (!valueIsRequired) + return value; + + return Result.Go(value).Required() + .OnFailure(r => new EventSubscriberReceiveException("An error occurred in the event subscriber as the deserialized value is required.", invalidDataHandling ?? InvalidDataHandling, r.Error)); + } +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/EventSubscriberMetrics.cs b/src/CoreEx.Events/Subscribing/EventSubscriberMetrics.cs new file mode 100644 index 00000000..01e52905 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/EventSubscriberMetrics.cs @@ -0,0 +1,70 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Defines the metrics. +/// +public class EventSubscriberMetrics +{ + private const string ErrorUnhandledOutcome = "error-unhandled"; + + /// + /// Gets the used for recording metrics related to event subscriber operations. + /// + public static Meter Meter { get; } = new("CoreEx.Events.Subscribing"); + + /// + /// Gets the counter that tracks the number of messages received for processing. + /// + public static Counter MessagesReceived { get; } = Meter.CreateCounter("messages.received", unit: "{message}", description: "Number of messages received for processing."); + + /// + /// Wraps a message receive operation with metrics recording. + /// + /// The . + /// The function to execute the receive operation. + /// The of the receive operation. + /// This should be used to add standardized metrics recording to a receive operation. + public static async Task ReceiveMessageAsync(EventSubscriberArgs args, Func> receiveFunc) + { + try + { + var result = await receiveFunc().ConfigureAwait(false); + + string outcome; + if (result.IsSuccess) + outcome = "success"; + else if (args.UsesSubscribedManager && args.Subscriber is null) + outcome = "not-subscribed"; + else + { + if (result.Error is EventSubscriberHandledException rex) + { + outcome = rex.ErrorHandling switch + { + ErrorHandling.None => ErrorUnhandledOutcome, + ErrorHandling.CompleteAsSilent => "error-complete-silent", + ErrorHandling.CompleteAsInformation => "error-complete-info", + ErrorHandling.CompleteAsWarning => "error-complete-warning", + ErrorHandling.CompleteAsError => "error-complete-error", + ErrorHandling.Retry => "error-retry", + ErrorHandling.DeadLetter => "error-dead-letter", + ErrorHandling.Catastrophic => "error-catastrophic", + _ => "error-completed" + }; + } + else + outcome = "error-unhandled"; + } + + EventSubscriberMetrics.MessagesReceived.Add(1, new KeyValuePair("outcome", outcome)); + Activity.Current?.AddTag("messaging.outcome", outcome); + return result; + } + catch (Exception) + { + EventSubscriberMetrics.MessagesReceived.Add(1, new KeyValuePair("outcome", ErrorUnhandledOutcome)); + Activity.Current?.AddTag("messaging.outcome", ErrorUnhandledOutcome); + throw; + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberCatastrophicException.cs b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberCatastrophicException.cs new file mode 100644 index 00000000..c6795e1f --- /dev/null +++ b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberCatastrophicException.cs @@ -0,0 +1,14 @@ +namespace CoreEx.Events.Subscribing.Exceptions; + +/// +/// Represents an exception that occurs when an encounters a error while processing a message/event. +/// +/// This exception is intended primarily for internal use only and should not be thrown directly by user receiving/subscribing code. +/// The error message. +/// The optional inner . +public sealed class EventSubscriberCatastrophicException(string? message = null, Exception? innerException = null) + : Exception(message ?? "A catastrophic failure occurred during the event subscriber processing.", innerException), IEventSubscriberException +{ + /// + public ErrorHandling ErrorHandling => ErrorHandling.Catastrophic; +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberDeadLetterException.cs b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberDeadLetterException.cs new file mode 100644 index 00000000..1f65765e --- /dev/null +++ b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberDeadLetterException.cs @@ -0,0 +1,14 @@ +namespace CoreEx.Events.Subscribing.Exceptions; + +/// +/// Represents an exception that occurs when a message/event is unable to be processed and should be either flagged as or forwarded as a . +/// +/// This exception is intended primarily for internal use only and should not be thrown directly by user receiving/subscribing code. +/// The optional message. +/// The optional inner . +public sealed class EventSubscriberDeadLetterException(string? message = null, Exception? innerException = null) + : Exception(message ?? "An error occurred that is unable to be processed and has been flagged for dead-letter processing.", innerException), IEventSubscriberException +{ + /// + public ErrorHandling ErrorHandling => ErrorHandling.DeadLetter; +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberHandledException.cs b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberHandledException.cs new file mode 100644 index 00000000..5cf40774 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberHandledException.cs @@ -0,0 +1,17 @@ +namespace CoreEx.Events.Subscribing.Exceptions; + +/// +/// Represents an exception that occurs when an encounters an error that it is handled successfully. +/// +/// This is an error which has handling that informs the receive as successful, appropriate logging will have occurred. See , , +/// and . +/// This exception is intended primarily for internal use only and should not be thrown directly by user receiving/subscribing code. +/// The . +/// The error message. +/// The optional inner . +public sealed class EventSubscriberHandledException(ErrorHandling errorHandling, string? message = null, Exception? innerException = null) + : Exception(message ?? "An exception occurred during the event subscriber processing and was successfully handled.", innerException), IEventSubscriberException +{ + /// + public ErrorHandling ErrorHandling { get; } = errorHandling; +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberReceiveException.cs b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberReceiveException.cs new file mode 100644 index 00000000..bc7224a9 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberReceiveException.cs @@ -0,0 +1,16 @@ +namespace CoreEx.Events.Subscribing.Exceptions; + +/// +/// Represents an exception that occurs when a message/event error occurs during a subscriber receive with an override. +/// +/// The message. +/// The . +/// The optional inner . +/// This is an internal exception leveraged to manage an error during subscriber receive processing; this should eventually be converted to an external facing type. +internal sealed class EventSubscriberReceiveException(string message, ErrorHandling errorHandling, Exception? innerException = null) : Exception(message.ThrowIfNullOrEmpty(), innerException) +{ + /// + /// Gets the . + /// + public ErrorHandling ErrorHandling { get; } = errorHandling; +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberRetryException.cs b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberRetryException.cs new file mode 100644 index 00000000..02a1197e --- /dev/null +++ b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberRetryException.cs @@ -0,0 +1,13 @@ +namespace CoreEx.Events.Subscribing.Exceptions; + +/// +/// Represents an exception that occurs when an needs to perform a . +/// +/// The error message. +/// The optional inner . +public sealed class EventSubscriberRetryException(string? message = null, Exception? innerException = null) + : Exception(message ?? "An error occurred in the event subscriber that is considered transient and is a candidate for a retry.", innerException), IEventSubscriberException +{ + /// + public ErrorHandling ErrorHandling => ErrorHandling.Retry; +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberUnhandledException.cs b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberUnhandledException.cs new file mode 100644 index 00000000..a7b3aa90 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/Exceptions/EventSubscriberUnhandledException.cs @@ -0,0 +1,14 @@ +namespace CoreEx.Events.Subscribing.Exceptions; + +/// +/// Represents an exception that occurs when an encounters an error that it is unable to handle bubbling it up to the host process accordingly. +/// +/// The error message. +/// The optional inner . +/// This exception is intended primarily for internal use only and should not be thrown directly by user receiving/subscribing code. +public sealed class EventSubscriberUnhandledException(string? message = null, Exception? innerException = null) + : Exception(message ?? "An unhandled exception occurred during the event subscriber processing.", innerException), IEventSubscriberException +{ + /// + public ErrorHandling ErrorHandling => ErrorHandling.None; +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/Exceptions/IEventSubscriberException.cs b/src/CoreEx.Events/Subscribing/Exceptions/IEventSubscriberException.cs new file mode 100644 index 00000000..311b7ddb --- /dev/null +++ b/src/CoreEx.Events/Subscribing/Exceptions/IEventSubscriberException.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Events.Subscribing.Exceptions; + +/// +/// Defines an -specific . +/// +public interface IEventSubscriberException +{ + /// + /// Gets the specific option that resulted in the exception. + /// + ErrorHandling ErrorHandling { get; } +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/IEventSubscriberInbox.cs b/src/CoreEx.Events/Subscribing/IEventSubscriberInbox.cs new file mode 100644 index 00000000..72126315 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/IEventSubscriberInbox.cs @@ -0,0 +1,16 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Enables an inbox pattern to determine whether an event/message should be processed; for example, to track and detect duplicates to enable idempotent event/message handling. +/// +public interface IEventSubscriberInbox +{ + /// + /// Performs an inbox check on the /message. + /// + /// The . + /// The . + /// The . + /// indicates that the event/message should be processed; otherwise, . + Task InboxCheckAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/SubscribeAttribute.cs b/src/CoreEx.Events/Subscribing/SubscribeAttribute.cs new file mode 100644 index 00000000..25617083 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/SubscribeAttribute.cs @@ -0,0 +1,65 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Defines the pattern matching used by the to uniquely identify the by matching against both the and . +/// +/// The glob-like matching pattern that will represent the underlying . +/// The glob-like matching pattern that will represent the underlying . +/// This allows multiple; in that at least one of the specified attributes needs to match to be considered a successful match. +/// The for example may be implemented using a dot-based segmented format; for example: 'segment1.segment2.segment3.segmentn'. +/// A glob-like matching pattern supports '*' (single segment), '**' (multiple segments) and '?' (a single character within a single segment). +/// The following are matching pattern examples for 'coreex.system.product.updated.v1': +/// +/// **.product.** (match) +/// core*.system.product.updated.v1 (match) +/// coreex.**.updated.v1 (match) +/// coreex.*.updated.v1 (no match) +/// +/// +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] +public sealed class SubscribeAttribute(string? title = null, string? source = null) : Attribute +{ + private readonly string? _title = title; + private readonly string? _source = source; + + /// + /// Gets or sets the default . + /// + public static char DefaultTitleSeparator { get; set; } = '.'; + + /// + /// Gets or sets the default . + /// + public static char DefaultSourceSeparator { get; set; } = '/'; + + /// + /// Gets or sets the matching pattern for the . + /// + public Regex? Title { get; set; } + + /// + /// Gets or sets the title separator character used to split the into segments (where auto-creating the underlying ). + /// + public char TitleSeparator { get; set; } = DefaultTitleSeparator; + + /// + /// Gets or sets the matching pattern for the (see ). + /// + public Regex? Source { get; set; } + + /// + /// Gets or sets the source separator character used to split the into segments (where auto-creating the underlying ). + /// + public char SourceSeparator { get; set; } = DefaultSourceSeparator; + + /// + /// Indicates whether the event matches the and . + /// + /// The event title (i.e. ). + /// The event source (i.e. . + /// indicates a successful match; otherwise, . + public bool IsMatch(string? title, Uri? source) + => SubscribedBase.IsMatch(Title ?? SubscribedBase.CreateGlobRegex(_title.ThrowIfNullOrEmpty(nameof(Title)), TitleSeparator), title) + && ((_source is null && Source is null) || SubscribedBase.IsUriMatch(Source ?? SubscribedBase.CreateGlobRegex(_source!, SourceSeparator), source)); +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/SubscribedBase.Static.cs b/src/CoreEx.Events/Subscribing/SubscribedBase.Static.cs new file mode 100644 index 00000000..3981f3d4 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/SubscribedBase.Static.cs @@ -0,0 +1,80 @@ +namespace CoreEx.Events.Subscribing; + +public abstract partial class SubscribedBase +{ + private static readonly Regex _wildcardAllRegex = AllRegex(); + private static readonly ConcurrentDictionary<(string, char, RegexOptions?), Regex> _regexCache = new(); + + /// + /// Indicates whether the -based matches the regular expression. + /// + /// The . + /// The to search for a match. + /// indicates a match; otherwise, . + public static bool IsMatch(Regex regex, string? input) => regex.ThrowIfNull().IsMatch(input ?? string.Empty); + + /// + /// Indicates whether the -based matches the regular expression. + /// + /// The . + /// The to search for a match. + /// indicates a match; otherwise, . + public static bool IsUriMatch(Regex regex, Uri? input) => IsMatch(regex, input?.OriginalString); + + /// + /// Indicates whether the matches the segmented (using the ) glob-like matching pattern. + /// + /// The dot-based glob-like matching pattern. + /// The string to search for a match. + /// The segment separator. + /// indicates a match; otherwise, . + /// See for further details on the dot-based glob-like matching pattern. + public static bool IsMatch(string? pattern, string? input, char separator = '.') => string.IsNullOrEmpty(pattern) || IsMatch(CreateGlobRegex(pattern, separator), input); + + /// + /// Indicates whether the matches the segmented (using the ) glob-like matching pattern. + /// + /// The dot-based glob-like matching pattern. + /// The to search for a match. + /// The segment separator. + /// indicates a match; otherwise, . + /// See for further details on the dot-based glob-like matching pattern. + public static bool IsUriMatch(string? pattern, Uri? input, char separator) => string.IsNullOrEmpty(pattern) || IsMatch(CreateGlobRegex(pattern, separator), input?.OriginalString); + + /// + /// Creates a from the dot-based glob-like matching pattern. + /// + /// The glob-like matching pattern. + /// The segment separator. + /// The optional . + /// The wildcard . + /// See for further details on the dot-based glob-like matching pattern. + public static Regex CreateGlobRegex(string pattern, char separator, RegexOptions? options = null) + { + options ??= RegexOptions.IgnoreCase | RegexOptions.Compiled; + + if (string.IsNullOrEmpty(pattern)) + return _wildcardAllRegex; + + return _regexCache.GetOrAdd((pattern, separator, options), _ => + { + var regex = Regex.Escape(pattern) + .Replace(@"\*\*", ".*") // Match any number of segments. + .Replace(@"\*", $@"[^{separator}]*") // Match a single segment. + .Replace(@"\?", $@"[^{separator}]"); // Match a single char in a segment. + + return new Regex($"^{regex}$", options.Value); + }); + } + + [GeneratedRegex(".*", RegexOptions.Compiled)] + private static partial Regex AllRegex(); + + /// + /// Indicates whether the expected matches the . + /// + /// The expected major version number. + /// The to compare with. + /// indicates a match; otherwise, . + public static bool IsVersionMatch(int? majorVersion, Version? version) => majorVersion is null || (version is not null && version.Major == majorVersion.Value); +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/SubscribedBase.cs b/src/CoreEx.Events/Subscribing/SubscribedBase.cs new file mode 100644 index 00000000..f92b6481 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/SubscribedBase.cs @@ -0,0 +1,73 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Provides the base capabilities for a subscribed event receiver. +/// +/// See that manages the subscribed instance lifetime and execution management. +public abstract partial class SubscribedBase +{ + /// + /// Gets or sets the optional customizable configuration that is specific to the subscribed event/message receiving. + /// + /// Where not specified the owning will handle as a fallback. + public ErrorHandler? ErrorHandler { get; set; } + + /// + /// Gets or sets the where the is not considered valid. + /// + /// Defaults to . + /// When specified overrides the parent . + public ErrorHandling? InvalidDataHandling { get; set; } + + /// + /// Indicates whether the requires an inbox check before processing. + /// + /// Defaults to . + /// When specified overrides the parent . + public bool? RequiresInboxCheck { get; set; } + + /// + /// Gets or sets the for when the fails the . + /// + /// Defaults to . + /// When specified overrides the parent . + public ErrorHandling? InboxFailureHandling { get; set; } + + /// + /// Gets or sets the optional . + /// + /// Defaults to . + /// When specified overrides the . + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// Receives and processes the . + /// + /// The . + /// The optional . + /// The . + /// The . + public Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) => OnReceiveAsync(@event, args, cancellationToken); + + /// + /// Receives and processes the . + /// + /// The . + /// The optional . + /// The . + /// The . + /// Any exception thrown will be automatically converted to a ; however, it is considered more performant if an errant is returned natively. + protected abstract Task OnReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default); + + /// + /// Deserializes the value to the specified type. + /// + /// The value . + /// The . + /// The . + /// Indicates whether the value is required. + /// The deserialized value within a . + /// Where an invalid data error (see ) occurs a will be thrown. + protected Result DeserializeValue(EventData @event, EventSubscriberArgs args, bool valueIsRequired) + => args.Owner!.DeserializeValue(@event, valueIsRequired, InvalidDataHandling, JsonSerializerOptions); +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/SubscribedBaseT.cs b/src/CoreEx.Events/Subscribing/SubscribedBaseT.cs new file mode 100644 index 00000000..6cedd805 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/SubscribedBaseT.cs @@ -0,0 +1,39 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Provides the base capabilities for a subscribed event receiver with a specified automatically handling deserialization. +/// +/// The value . +public abstract class SubscribedBase : SubscribedBase +{ + /// + /// Indicates whether the value is required. + /// + public virtual bool ValueIsRequired { get; } = true; + + /// + /// Gets or sets the optional to use when validating the deserialized value. + /// + /// This is invoked automatically prior to the . + public virtual IValidator? ValueValidator { get; } + + /// + protected override sealed Task OnReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) + => DeserializeValue(@event, args, ValueIsRequired) + .WhenAsAsync(v => ValueValidator is not null, async v => + { + var vr = await ValueValidator!.ValidateAsync(v, cancellationToken).ConfigureAwait(false); + Result r = vr.HasErrors ? vr.ToResult() : Result.Ok(v); + return r; + }) + .ThenAsAsync(async v => await OnReceiveAsync(v, @event, args, cancellationToken).ConfigureAwait(false)); + + /// + /// Receives and processes the . + /// + /// The deserialized () value. + /// The . + /// The . + /// The . + protected abstract Task OnReceiveAsync(TValue value, EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/SubscribedInvoker.cs b/src/CoreEx.Events/Subscribing/SubscribedInvoker.cs new file mode 100644 index 00000000..6eb92e38 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/SubscribedInvoker.cs @@ -0,0 +1,8 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Provides the invoker. +/// +/// This invoker is only used where a is involved; i.e has been subscribed to. +[InvokerName("CoreEx.Events.Subscribing.Subscribed")] +public class SubscribedInvoker : InvokerBase { } \ No newline at end of file diff --git a/src/CoreEx.Events/Subscribing/SubscribedManager.cs b/src/CoreEx.Events/Subscribing/SubscribedManager.cs new file mode 100644 index 00000000..901f9210 --- /dev/null +++ b/src/CoreEx.Events/Subscribing/SubscribedManager.cs @@ -0,0 +1,220 @@ +namespace CoreEx.Events.Subscribing; + +/// +/// Provides subscription and execution management. +/// +/// Note: is enabled using one of the methods to set up. +/// The optional . +public sealed class SubscribedManager(SubscribedInvoker? invoker = null) +{ + private readonly SubscribedInvoker _invoker = invoker ?? new(); + private readonly List<(Type Type, SubscribeAttribute[] Attributes)> _subscribers = []; + private IEventSubscriberInbox? _inbox; + private Type? _inboxType; + + /// + /// Gets or sets the for when an has no configured subscriber. + /// + /// Defaults to . + public ErrorHandling NotSubscribedHandling { get; set; } = ErrorHandling.CompleteAsSilent; + + /// + /// Gets or sets the for when an has multiple subscribers. + /// + /// Defaults to . + public ErrorHandling AmbiguousSubscriberHandling { get; set; } = ErrorHandling.Catastrophic; + + /// + /// Indicates whether all subscribers require an inbox check (unless explicitly overridden) before processing. + /// + /// Defaults to . This is set using . + /// An individual subscriber can override by setting its value. + public bool RequiresInboxCheck { get; private set; } = false; + + /// + /// Sets the inbox to the specified and to enable inbox checking. + /// + /// The . + /// Indicates whether all subscribers require an check (unless explicitly overridden) before processing. + /// The to support fluent-style method-chaining. + public SubscribedManager RequiresInbox(IEventSubscriberInbox inbox, bool requiresInboxCheck = true) + { + _inbox = inbox.ThrowIfNull(); + _inboxType = null; + RequiresInboxCheck = requiresInboxCheck; + return this; + } + + /// + /// Sets the inbox to the specified and to enable inbox checking. + /// + /// The . + /// Indicates whether all subscribers require an inbox check (unless explicitly overridden) before processing. + /// The to support fluent-style method-chaining. + /// The will be instantiated using dependency injection per execution (as needed). + public SubscribedManager RequiresInbox(bool requiresInboxCheck = true) where TInbox : IEventSubscriberInbox + { + _inboxType = typeof(TInbox); + _inbox = null; + RequiresInboxCheck = requiresInboxCheck; + return this; + } + + /// + /// Sets the inbox to the DI configured and to enable inbox checking. + /// + /// Indicates whether all subscribers require an inbox check (unless explicitly overridden) before processing. + /// The to support fluent-style method-chaining. + /// The will be instantiated using dependency injection per execution (as needed). + public SubscribedManager RequiresInbox(bool requiresInboxCheck = true) => RequiresInbox(requiresInboxCheck); + + /// + /// Gets or sets the for when the fails the . + /// + /// Defaults to as this is an expected byproduct of achieving at least once only idempotent processing; i.e. event/message has previously been processed + /// (either successfully or unsuccessfully) and further attempts should be ignored. + public ErrorHandling InboxFailureHandling { get; set; } = ErrorHandling.CompleteAsWarning; + + /// + /// Adds a single subscriber type. + /// + /// The subscriber type to add. + /// The to support fluent-style method-chaining. + public SubscribedManager AddSubscriber() where T : SubscribedBase => AddSubscribers(typeof(T)); + + /// + /// Adds multiple subscriber types. + /// + /// The subscriber types to add. + /// The to support fluent-style method-chaining. + /// Each type must implement both and at least be decorated with one otherwise an will be thrown. + public SubscribedManager AddSubscribers(params IEnumerable types) + { + foreach (var type in types.Distinct().Where(t => !_subscribers.Any(x => x.Type == t))) + { + if (!type.IsClass || type.IsAbstract || type.IsGenericType) + throw new ArgumentException($"Type {type.Name} is not a class, is abstract and/or generic and as such cannot be used as a subscriber.", nameof(types)); + + if (!typeof(SubscribedBase).IsAssignableFrom(type)) + throw new ArgumentException($"Type {type.Name} does not inherit from {nameof(SubscribedBase)}.", nameof(types)); + + var atts = type.GetCustomAttributes().ToArray(); + if (atts.Length == 0) + throw new ArgumentException($"Type {type.Name} is not decorated with any {nameof(SubscribeAttribute)} attributes and as such cannot be used as a subscriber.", nameof(types)); + + _subscribers.Add((type, atts)); + } + + return this; + } + + /// + /// Dynamically adds all subscribers within the specified assembly that implement and are decorated with at least one . + /// + /// The to infer the from. + /// The to support fluent-style method-chaining. + public SubscribedManager AddSubscribersUsing() + { + var assembly = typeof(TAssembly1).Assembly; + var types = assembly.GetTypes().Where(t => typeof(SubscribedBase).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract && !t.IsGenericType && t.GetCustomAttributes().Any()); + return AddSubscribers([.. types]); + } + + /// + /// Matches and creates an instance of the where found; otherwise, an error will occur. + /// + /// The current . + /// The . + /// The event title (i.e. ) to match. + /// The event source (i.e. to match. + /// The with the instantiated ; otherwise, the corresponding . + /// The property is set to and the property is set to the matched + /// instance (where matched). + public Result Match(ExecutionContext executionContext, EventSubscriberArgs args, string? title, Uri? source = null) + { + executionContext.ThrowIfNull(); + args.ThrowIfNull(); + args.Owner.ThrowIfNull(); + args.UsesSubscribedManager = true; + + var subscribers = _subscribers + .Where(x => x.Attributes.Any(a => a.IsMatch(title, source))) + .Select(x => x.Type) + .ToArray(); + + // Handle where no subscriber is found. + if (subscribers.Length == 0) + { + var eha = new ErrorHandlerArgs { SubscriberArgs = args, SourceType = GetType(), ErrorHandlingOverride = NotSubscribedHandling, Exception = new InvalidOperationException("No subscriber matched the event.") }; + return args.Owner.ErrorHandler.Handle(eha, defaultErrorHandling: null); + } + + // Handle where more than one subscriber is found. + if (subscribers.Length > 1) + { + var eha = new ErrorHandlerArgs { SubscriberArgs = args, SourceType = GetType(), ErrorHandlingOverride = AmbiguousSubscriberHandling, Exception = new InvalidOperationException($"Multiple subscribers ({subscribers.Length}) matched the event.") }; + return args.Owner.ErrorHandler.Handle(eha, defaultErrorHandling: null); + } + + // Instantiate the matched subscriber; failure is always considered 'Catastrophic'. + try + { + return args.Subscriber = (SubscribedBase)executionContext.ServiceProvider.ThrowIfNull().GetRequiredService(subscribers[0]); + } + catch (Exception ex) + { + return args.Owner.ErrorHandler.Handle(new ErrorHandlerArgs { SubscriberArgs = args, SourceType = GetType(), Exception = new InvalidOperationException($"Unable to instantiate matched Subscribed type '{subscribers[0].Name}': {ex.Message}"), ErrorHandlingOverride = ErrorHandling.Catastrophic }, null); + } + } + + /// + /// Receives and processes the using the instance. + /// + /// The current . + /// The previously matched . + /// The . + /// The optional . + /// The . + /// It is expected that a is performed prior to match and instantiate the required instance. + public async Task ReceiveAsync(ExecutionContext executionContext, SubscribedBase subscribed, EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) + { + executionContext.ThrowIfNull(); + executionContext.ServiceProvider.ThrowIfNull(); + subscribed.ThrowIfNull(); + @event.ThrowIfNull(); + args.Owner.ThrowIfNull(); + + // Where the subscriber requires an inbox check then action accordingly. + if (subscribed.RequiresInboxCheck ?? RequiresInboxCheck) + { + if (_inbox is null && _inboxType is null) + throw new InvalidOperationException($"The Inbox is not configured and the subscriber requires an inbox check; use {nameof(RequiresInbox)} to configure."); + + var inbox = _inbox ?? (IEventSubscriberInbox)executionContext.ServiceProvider.GetRequiredService(_inboxType!); + + if (!await inbox.InboxCheckAsync(@event, args, cancellationToken).ConfigureAwait(false)) + { + var eha = new ErrorHandlerArgs { SubscriberArgs = args, SourceType = GetType(), ErrorHandlingOverride = InboxFailureHandling, Exception = new InvalidOperationException("The event failed the inbox check and will not be processed.") }; + return args.Owner.ErrorHandler.Handle(eha, defaultErrorHandling: null); + } + } + + // Execute the subscribed receive. + return await _invoker.InvokeAsync(subscribed, async (_, cancellationToken) => + { + try + { + return Result.Go(await subscribed.ReceiveAsync(@event, args, cancellationToken).ConfigureAwait(false)) + .OnFailure(result => subscribed.ErrorHandler is not null ? subscribed.ErrorHandler.Handle(new ErrorHandlerArgs { SubscriberArgs = args, SourceType = subscribed.GetType(), Exception = result.Error }, null) : result); + } + catch (Exception ex) when (subscribed.ErrorHandler is not null && ex is not IEventSubscriberException && !ex.IsCanceled()) // Ignore IEventSubscriberException's and *CanceledException as they are intended to bubble up! + { + return subscribed.ErrorHandler.Handle(new ErrorHandlerArgs { SubscriberArgs = args, SourceType = subscribed.GetType(), Exception = ex }, null); + } + catch (Exception ex) + { + return Result.Fail(ex); + } + }, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.FluentValidation/CoreEx.FluentValidation.csproj b/src/CoreEx.FluentValidation/CoreEx.FluentValidation.csproj deleted file mode 100644 index 800d49aa..00000000 --- a/src/CoreEx.FluentValidation/CoreEx.FluentValidation.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx.FluentValidation - CoreEx - CoreEx .NET FluentValidation Extensions. - CoreEx .NET FluentValidation Extensions. - coreex api function aspnet validation validator validate fluentvalidation - - - - - - - - - - - - - diff --git a/src/CoreEx.FluentValidation/FluentValidationExtensions.cs b/src/CoreEx.FluentValidation/FluentValidationExtensions.cs deleted file mode 100644 index fd3b23d1..00000000 --- a/src/CoreEx.FluentValidation/FluentValidationExtensions.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.FluentValidation; -using CoreEx.RefData; -using FluentValidation.Validators; -using System; - -namespace FluentValidation -{ - /// - /// FluentValidation extension methods. - /// - public static class FluentValidationExtensions - { - /// - /// Defines an validator whereby the is required to specify corresponding . - /// - /// The owning object . - /// The to enable the extension method. - /// The . - public static ReferenceDataTypeOf RefDataCode(this IRuleBuilder ruleBuilder) => new(ruleBuilder); - - /// - /// Provides for the specification of the corresponding . - /// - /// The owning object . - public class ReferenceDataTypeOf - { - private readonly IRuleBuilder _ruleBuilder; - - /// - /// Initializes a new instance of the class. - /// - /// The . - internal ReferenceDataTypeOf(IRuleBuilder ruleBuilder) => _ruleBuilder = ruleBuilder; - - /// - /// Sets the for the specified . - /// - /// The . - /// The to support fluent-style method-chaining. - public IRuleBuilderOptions As() where TRef : IReferenceData => _ruleBuilder.SetValidator(new ReferenceDataCodeValidator()); - } - - /// - /// Defines an validator to ensure that the is true. - /// - /// The owning object . - /// The . - /// The to enable the extension method. - /// The to support fluent-style method-chaining. - public static IRuleBuilderOptions IsValid(this IRuleBuilder ruleBuilder) where TRef : IReferenceData - => ruleBuilder.SetValidator(new ReferenceDataValidator()); - - /// - /// Defines a mandatory (see ) validator to ensure that the value is not equal to its default value. - /// - /// The owning object . - /// The property . - /// The to enable the extension method. - /// The to support fluent-style method-chaining. - public static IRuleBuilderOptions Mandatory(this IRuleBuilder ruleBuilder) - => ruleBuilder.SetValidator(new NotEmptyValidator()); - - /// - /// Associates a validator provider with the current property rule where the property is nullable (and therefore optional). - /// - /// The owning object . - /// the property . - /// The to enable the extension method. - /// The property . - /// The list of rule sets. - /// The to support fluent-style method-chaining. - /// Added as advised: . - public static IRuleBuilderOptions SetOptionalValidator(this IRuleBuilder ruleBuilder, IValidator validator, params string[] ruleSets) where TProperty : class - => ruleBuilder.SetValidator(validator!, ruleSets)!; - } -} \ No newline at end of file diff --git a/src/CoreEx.FluentValidation/FluentValidator.cs b/src/CoreEx.FluentValidation/FluentValidator.cs deleted file mode 100644 index 8fba140f..00000000 --- a/src/CoreEx.FluentValidation/FluentValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.DependencyInjection; -using System; -using FV = FluentValidation; - -namespace CoreEx.FluentValidation -{ - /// - /// Provides access to the fluent-validator capabilities. - /// - public static class FluentValidator - { - /// - /// Creates (or gets) an instance of the validator. - /// - /// The validator . - /// The ; defaults to where not specified. - /// The instance. - public static TValidator Create(IServiceProvider? serviceProvider = null) where TValidator : FV.IValidator - => (serviceProvider == null ? ExecutionContext.GetService() : serviceProvider.GetService()) - ?? throw new InvalidOperationException($"Attempted to get service '{typeof(TValidator).FullName}' but null was returned; this would indicate that the service has not been configured correctly."); - } -} \ No newline at end of file diff --git a/src/CoreEx.FluentValidation/IFluentServiceCollectionExtensions.cs b/src/CoreEx.FluentValidation/IFluentServiceCollectionExtensions.cs deleted file mode 100644 index 293b433d..00000000 --- a/src/CoreEx.FluentValidation/IFluentServiceCollectionExtensions.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using System; -using System.Linq; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extensions. - /// - public static class IFluentServiceCollectionExtensions - { - /// - /// Adds the , the corresponding and as scoped services. - /// - /// The value . - /// The validator . - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddFluentValidator(this IServiceCollection services) where TValidator : class, FluentValidation.IValidator - => AddFluentValidatorWithInterfacesInternal(services); - - /// - /// Adds the , the corresponding and as scoped services. - /// - private static IServiceCollection AddFluentValidatorWithInterfacesInternal(this IServiceCollection services) where TValidator : class, FluentValidation.IValidator - => services.ThrowIfNull(nameof(services)) - .AddScoped, TValidator>() - .AddScoped>(sp => new CoreEx.FluentValidation.ValidatorWrapper(sp.GetRequiredService>())) - .AddScoped(sp => (TValidator)sp.GetRequiredService>()); - - /// - /// Adds the as a scoped service only. - /// - /// The validator . - /// The . - /// The for fluent-style method-chaining. - /// Note that this does not register the corresponding and ; use to explicitly perform. - public static IServiceCollection AddFluentValidator(this IServiceCollection services) where TValidator : class, FluentValidation.IValidator - => AddFluentValidatorInternal(services); - - /// - /// Adds the as a scoped service only. - /// - private static IServiceCollection AddFluentValidatorInternal(this IServiceCollection services) where TValidator : class, FluentValidation.IValidator - => services.ThrowIfNull(nameof(services)).AddScoped(); - - /// - /// Adds all the (s) and corresponding (s) from the specified as scoped services. - /// - /// The to infer the underlying . - /// The . - /// Indicates whether to include internally defined types. - /// Indicates whether to also register the interfaces with (default); otherwise, with (just the validator instance itself). - /// The for fluent-style method-chaining. - public static IServiceCollection AddFluentValidators(this IServiceCollection services, bool includeInternalTypes = false, bool alsoRegisterInterfaces = true) - { - var afv = alsoRegisterInterfaces - ? typeof(IFluentServiceCollectionExtensions).GetMethod(nameof(AddFluentValidatorWithInterfacesInternal), System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)! - : typeof(IFluentServiceCollectionExtensions).GetMethod(nameof(AddFluentValidatorInternal), System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!; - - foreach (var match in from type in includeInternalTypes ? typeof(TAssembly).Assembly.GetTypes() : typeof(TAssembly).Assembly.GetExportedTypes() - where !type.IsAbstract && !type.IsGenericTypeDefinition - let interfaces = type.GetInterfaces() - let genericInterfaces = interfaces.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(FluentValidation.IValidator<>)) - let @interface = genericInterfaces.FirstOrDefault() - let valueType = @interface?.GetGenericArguments().FirstOrDefault() - where @interface != null - select new { valueType, type }) - { - if (alsoRegisterInterfaces) - afv.MakeGenericMethod(match.valueType, match.type).Invoke(null, new object[] { services }); - else - afv.MakeGenericMethod(match.type).Invoke(null, new object[] { services }); - } - - return services; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.FluentValidation/ReferenceDataCodeValidator.cs b/src/CoreEx.FluentValidation/ReferenceDataCodeValidator.cs deleted file mode 100644 index b2adc313..00000000 --- a/src/CoreEx.FluentValidation/ReferenceDataCodeValidator.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using FluentValidation; -using FluentValidation.Validators; -using System; - -namespace CoreEx.FluentValidation -{ - /// - /// Represents a validator. - /// - /// The owning object . - /// The . - public class ReferenceDataCodeValidator : PropertyValidator where TRef : IReferenceData - { - /// - public override string Name => nameof(ReferenceDataCodeValidator); - - /// - public override bool IsValid(ValidationContext context, string? value) => - value == null || ReferenceDataOrchestrator.Current.GetByTypeRequired().TryGetByCode(value, out var rd) && rd!.IsValid; - - /// - protected override string GetDefaultMessageTemplate(string errorCode) => "'{PropertyName}' is invalid."; - } -} \ No newline at end of file diff --git a/src/CoreEx.FluentValidation/ReferenceDataValidator.cs b/src/CoreEx.FluentValidation/ReferenceDataValidator.cs deleted file mode 100644 index 8f771819..00000000 --- a/src/CoreEx.FluentValidation/ReferenceDataValidator.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using FluentValidation; -using FluentValidation.Validators; -using System; - -namespace CoreEx.FluentValidation -{ - /// - /// Represents a validator. - /// - /// The owning object . - /// The . - public class ReferenceDataValidator : PropertyValidator where TRef : IReferenceData - { - /// - public override string Name => nameof(ReferenceDataValidator); - - /// - public override bool IsValid(ValidationContext context, TRef? value) => value == null || value.IsValid; - - /// - protected override string GetDefaultMessageTemplate(string errorCode) => "'{PropertyName}' is invalid."; - } -} \ No newline at end of file diff --git a/src/CoreEx.FluentValidation/ValidationExtensions.cs b/src/CoreEx.FluentValidation/ValidationExtensions.cs deleted file mode 100644 index 57da729d..00000000 --- a/src/CoreEx.FluentValidation/ValidationExtensions.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using FluentValidation.Results; -using System; -using FV = FluentValidation; - -namespace CoreEx.FluentValidation -{ - /// - /// FluentValidation extension methods. - /// - public static class ValidationExtensions - { - /// - /// Throws a where the has errors (is not ). - /// - /// The . - /// The resulting where the has errors. - public static void ThrowValidationException(this ValidationResult validationResult) - { - var vex = ToValidationException(validationResult); - if (vex != null) - throw vex; - } - - /// - /// Creates a where the has errors (is not ). - /// - /// The . - /// The where the has errors; otherwise, null. - public static ValidationException? ToValidationException(this ValidationResult validationResult) - { - if (validationResult.ThrowIfNull(nameof(validationResult)).IsValid) - return null; - - var mic = new MessageItemCollection(); - foreach (var error in validationResult.Errors) - { - mic.AddPropertyError(error.PropertyName, error.ErrorMessage); - } - - return new ValidationException(mic); - } - - /// - /// Wraps the FluentValidation to a using a to enable interoperability. - /// - /// The value . - /// The FluentValidation . - /// The . - public static CoreEx.Validation.IValidator Wrap(this FV.IValidator validator) => new ValidatorWrapper(validator); - } -} \ No newline at end of file diff --git a/src/CoreEx.FluentValidation/ValidationResultWrapper.cs b/src/CoreEx.FluentValidation/ValidationResultWrapper.cs deleted file mode 100644 index 6e28dece..00000000 --- a/src/CoreEx.FluentValidation/ValidationResultWrapper.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Localization; -using CoreEx.Results; -using CoreEx.Validation; -using FluentValidation.Results; -using System; - -namespace CoreEx.FluentValidation -{ - /// - /// Represents a wrapper to enable CoreEx interoperability. - /// - public class ValidationResultWrapper : IValidationResult - { - private ValidationException? _vex; - - /// - /// Initializes a new instance of the class with the specified . - /// - /// The to wrap. - /// The originating value being validated. - /// - public ValidationResultWrapper(ValidationResult result, T? value) - { - Result = result.ThrowIfNull(nameof(result)); - Value = value; - } - - /// - /// Initializes a new instance of the class where the value was null and therefore no corresponding . - /// - public ValidationResultWrapper() => _vex = new ValidationException(new LText("CoreEx.FluentValidation.NullValueException", "Value is required.")); - - /// - /// Gets the underlying that is being wrapped (where applicable). - /// - public ValidationResult? Result { get; } - - /// - public T? Value { get; } - - /// - public bool HasErrors => Result == null || !Result.IsValid; - - /// - public MessageItemCollection? Messages => HasErrors ? (ToException() is ValidationException vex ? vex.Messages : null) : null; - - /// - public Result? FailureResult => null; - - /// - IValidationResult IValidationResult.ThrowOnError() => ThrowOnError(); - - /// - /// Throws a where . - /// - /// The to support fluent-style method-chaining. - public ValidationResultWrapper ThrowOnError() - { - if (HasErrors) - throw ToException()!; - - return this; - } - - /// - public Exception? ToException() => HasErrors ? (_vex ??= Result!.ToValidationException()) : null; - - /// - public Result ToResult() => HasErrors ? Result.ValidationError(Messages!) : Validation.Validation.ConvertValueToResult(Value!); - - /// - public Result ToResult() => HasErrors ? Results.Result.ValidationError(Messages!) : Results.Result.Success; - } -} \ No newline at end of file diff --git a/src/CoreEx.FluentValidation/ValidatorWrapper.cs b/src/CoreEx.FluentValidation/ValidatorWrapper.cs deleted file mode 100644 index ffdda4d3..00000000 --- a/src/CoreEx.FluentValidation/ValidatorWrapper.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Validation; -using System; -using System.Threading; -using System.Threading.Tasks; -using FV = FluentValidation; - -namespace CoreEx.FluentValidation -{ - /// - /// Represents an wrapper to enable CoreEx interoperability. - /// - /// The value . - /// The to wrap. - public sealed class ValidatorWrapper(FV.IValidator fluentValidator) : IValidator - { - /// - /// Gets the underlying that is being wrapped. - /// - public FV.IValidator Validator { get; } = fluentValidator.ThrowIfNull(nameof(fluentValidator)); - - /// - public async Task> ValidateAsync(T value, CancellationToken cancellationToken = default) - => value == null ? new ValidationResultWrapper() : new ValidationResultWrapper(await Validator.ValidateAsync(value, cancellationToken).ConfigureAwait(false), value); - } -} \ No newline at end of file diff --git a/src/CoreEx.FluentValidation/strong-name-key.snk b/src/CoreEx.FluentValidation/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Newtonsoft/CoreEx.Newtonsoft.csproj b/src/CoreEx.Newtonsoft/CoreEx.Newtonsoft.csproj deleted file mode 100644 index b5f0bc80..00000000 --- a/src/CoreEx.Newtonsoft/CoreEx.Newtonsoft.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx.Newtonsoft - CoreEx - CoreEx .NET Newtonsoft Extensions. - CoreEx .NET Newtonsoft Extensions. - coreex api function aspnet json newtonsoft jsonserializer - - - - - - - - - - - - - - diff --git a/src/CoreEx.Newtonsoft/Json/CloudEventSerializer.cs b/src/CoreEx.Newtonsoft/Json/CloudEventSerializer.cs deleted file mode 100644 index 08df6e45..00000000 --- a/src/CoreEx.Newtonsoft/Json/CloudEventSerializer.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CloudNative.CloudEvents; -using CloudNative.CloudEvents.NewtonsoftJson; -using CoreEx.Events; -using Newtonsoft.Json; -using System; -using System.Net.Mime; -using System.Threading; -using System.Threading.Tasks; -using Nsj = Newtonsoft.Json; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Provides the -based . - /// - public class CloudEventSerializer : CloudEventSerializerBase - { - private Nsj.JsonSerializer? _jsonSerializer; - - /// - /// Initializes a new instance of the class. - /// - /// The ; where null this will default. - /// The ; where null this will default. - public CloudEventSerializer(EventDataFormatter? eventDataFormatter = null, JsonSerializerSettings? settings = null) : base(eventDataFormatter) - { - EventDataFormatter.JsonSerializer ??= new JsonSerializer(settings); - Settings = settings ?? (JsonSerializerSettings)EventDataFormatter.JsonSerializer.Options; - } - - /// - /// Gets the . - /// - public JsonSerializerSettings Settings { get; } - - /// - /// Gets the underlying instance. - /// - protected Nsj.JsonSerializer JsonSerializer => _jsonSerializer ??= Nsj.JsonSerializer.Create(Settings); - - /// - protected override Task DecodeAsync(BinaryData eventData, CancellationToken cancellationToken = default) - => Task.FromResult(new JsonEventFormatter(JsonSerializer).DecodeStructuredModeMessage(eventData, new ContentType(MediaTypeNames.Application.Json), null)); - - /// - protected override Task DecodeAsync(BinaryData eventData, CancellationToken cancellationToken = default) - => Task.FromResult(new JsonEventFormatter(JsonSerializer).DecodeStructuredModeMessage(eventData, new ContentType(MediaTypeNames.Application.Json), null)); - - /// - protected override Task EncodeAsync(CloudEvent cloudEvent, CancellationToken cancellationToken = default) - => Task.FromResult(new BinaryData(new InternalFormatter(JsonSerializer).EncodeStructuredModeMessage(cloudEvent, out var _))); - - private class InternalFormatter(Nsj.JsonSerializer jsonSerializer) : JsonEventFormatter(jsonSerializer) - { - /// - protected override void EncodeStructuredModeData(CloudEvent cloudEvent, JsonWriter writer) - { - if (cloudEvent.Data is BinaryData bd && cloudEvent.DataContentType == MediaTypeNames.Application.Json) - { - writer.WritePropertyName(DataPropertyName); - writer.WriteRawValue(bd.ToString()); - } - else - base.EncodeStructuredModeData(cloudEvent, writer); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/CollectionResultJsonConverter.cs b/src/CoreEx.Newtonsoft/Json/CollectionResultJsonConverter.cs deleted file mode 100644 index 6be1ece0..00000000 --- a/src/CoreEx.Newtonsoft/Json/CollectionResultJsonConverter.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using Newtonsoft.Json; -using System; -using System.Collections; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Performs JSON value conversion for values. - /// - public class CollectionResultJsonConverter : JsonConverter - { - /// - public override bool CanConvert(Type objectType) => typeof(ICollectionResult).IsAssignableFrom(objectType); - - /// - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, global::Newtonsoft.Json.JsonSerializer serializer) - { - if (reader.TokenType == JsonToken.Null) - return default; - - var cr = (ICollectionResult)Activator.CreateInstance(objectType)!; - var coll = (ICollection?)serializer.Deserialize(reader, cr.CollectionType); - - if (coll != null) - cr.Items = coll; - - return cr; - } - - /// - public override void WriteJson(JsonWriter writer, object? value, global::Newtonsoft.Json.JsonSerializer serializer) - { - if (value == null || value is not ICollectionResult cr) - return; - - writer.WriteStartArray(); - - if (cr.Items != null) - { - foreach (var item in cr.Items) - { - serializer.Serialize(writer, item); - } - } - - writer.WriteEndArray(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/CompositeKeyJsonConverter.cs b/src/CoreEx.Newtonsoft/Json/CompositeKeyJsonConverter.cs deleted file mode 100644 index 0b42b9e9..00000000 --- a/src/CoreEx.Newtonsoft/Json/CompositeKeyJsonConverter.cs +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Performs JSON value conversion for values. - /// - public class CompositeKeyJsonConverter : JsonConverter - { - /// - public override bool CanConvert(Type objectType) => objectType == typeof(CompositeKey); - - /// - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, global::Newtonsoft.Json.JsonSerializer serializer) - { - if (reader.TokenType == JsonToken.Null) - return CompositeKey.Empty; - - if (reader.TokenType != JsonToken.StartArray) - { - var jtr = (JsonTextReader)reader; - throw new JsonSerializationException($"Expected {nameof(JsonToken.StartArray)} for a {nameof(CompositeKey)}; found {reader.TokenType}.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); - } - - var depth = reader.Depth; - var args = new List(); - - reader.Read(); - while (reader.Depth > depth) - { - if (reader.TokenType == JsonToken.Null) - { - args.Add(null); - reader.Read(); - continue; - } - - if (reader.TokenType != JsonToken.StartObject) - { - var jtr = (JsonTextReader)reader; - throw new JsonSerializationException($"Expected {nameof(JsonToken.StartObject)} for a {nameof(CompositeKey)}; found {reader.TokenType}.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); - } - - var objDepth = reader.Depth; - reader.Read(); - while (reader.Depth > objDepth) - { - if (reader.TokenType != JsonToken.PropertyName) - { - var jtr = (JsonTextReader)reader; - throw new JsonSerializationException($"Expected {nameof(JsonToken.PropertyName)} for a {nameof(CompositeKey)}; found {reader.TokenType}.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); - } - - var name = reader.Value; - - switch (name) - { - case "string": args.Add(reader.ReadAsString()); break; - case "char": args.Add(reader.ReadAsString()?.ToCharArray().FirstOrDefault()); break; - case "short": args.Add((short?)reader.ReadAsInt32()); break; - case "int": args.Add(reader.ReadAsInt32()); break; - case "long": args.Add((long?)reader.ReadAsDecimal()); break; - case "guid": args.Add(reader.ReadAsString() is string s && Guid.TryParse(s, out var g) ? g : null); break; - case "datetime": args.Add(reader.ReadAsDateTime()); break; - case "datetimeoffset": args.Add(reader.ReadAsDateTimeOffset()); break; - case "ushort": args.Add((ushort?)reader.ReadAsInt32()); break; - case "uint": args.Add((uint?)reader.ReadAsDecimal()); break; - case "ulong": args.Add((ulong?)reader.ReadAsDecimal()); break; - default: - var jtr = (JsonTextReader)reader; - throw new JsonSerializationException($"Unsupported {nameof(CompositeKey)} type '{name}'.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); - } - - reader.Read(); - if (reader.TokenType != JsonToken.EndObject) - { - var jtr = (JsonTextReader)reader; - throw new JsonSerializationException($"Expected {nameof(JsonToken.EndObject)} for a {nameof(CompositeKey)} argument; found {reader.TokenType}.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); - } - } - - reader.Read(); - } - - return new CompositeKey([.. args]); - } - - /// - public override void WriteJson(JsonWriter writer, object? value, global::Newtonsoft.Json.JsonSerializer serializer) - { - if (value is not CompositeKey key || key.Args.Length == 0) - { - writer.WriteNull(); - return; - } - - writer.WriteStartArray(); - - foreach (var arg in key.Args) - { - if (arg is null) - { - writer.WriteNull(); - continue; - } - - writer.WriteStartObject(); - - _ = arg switch - { - string str => JsonWrite(writer, "string", () => writer.WriteValue(str)), - char c => JsonWrite(writer, "char", () => writer.WriteValue(c.ToString())), - short s => JsonWrite(writer, "short", () => writer.WriteValue(s)), - int i => JsonWrite(writer, "int", () => writer.WriteValue(i)), - long l => JsonWrite(writer, "long", () => writer.WriteValue(l)), - Guid g => JsonWrite(writer, "guid", () => writer.WriteValue(g)), - DateTime d => JsonWrite(writer, "datetime", () => writer.WriteValue(d)), - DateTimeOffset o => JsonWrite(writer, "datetimeoffset", () => writer.WriteValue(o)), - ushort us => JsonWrite(writer, "ushort", () => writer.WriteValue(us)), - uint ui => JsonWrite(writer, "uint", () => writer.WriteValue(ui)), - ulong ul => JsonWrite(writer, "ulong", () => writer.WriteValue(ul)), - _ => throw new JsonException($"Unsupported {nameof(CompositeKey)} type '{arg.GetType().Name}'.") - }; - - writer.WriteEndObject(); - } - - writer.WriteEndArray(); - } - - /// - /// Provides a simple means to write a JSON property name and value. - /// - private static bool JsonWrite(JsonWriter writer, string name, Action action) - { - writer.WritePropertyName(name); - action(); - return true; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/ContractResolver.cs b/src/CoreEx.Newtonsoft/Json/ContractResolver.cs deleted file mode 100644 index 202dd1ca..00000000 --- a/src/CoreEx.Newtonsoft/Json/ContractResolver.cs +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Entities.Extended; -using CoreEx.Events; -using CoreEx.RefData; -using CoreEx.RefData.Extended; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Reflection; -using Stj = System.Text.Json.Serialization; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Extends the to enable runtime configurable of JSON serialization property rename and ignore. - /// - public class ContractResolver : CamelCasePropertyNamesContractResolver - { - private readonly static ContractResolver _default = new(); - - private HashSet? _typeDict; - private ConcurrentDictionary>? _renameDict; - private ConcurrentDictionary>? _ignoreDict; - - /// - /// Static constructor. - /// - static ContractResolver() - { - _default.AddType() - .AddType() - .AddType(typeof(ReferenceDataBaseEx<,>)) - .AddType(typeof(ReferenceDataBase<>)) - .AddType() - .AddType() - .AddType() - .AddType(); - } - - /// - /// Initializes a new instance of the . - /// - public ContractResolver() => NamingStrategy = SubstituteNamingStrategy.Substitute; - - /// - /// Gets the default . - /// - /// Automatically adds the serialization property renames and ignores for , , , and types. - public static ContractResolver Default => _default; - - /// - /// Adds the by reflecting all System.Text.Json.Serialization property attributes to infer rename () or ignore (). - /// - /// The . - /// The instance to support fluent-style method-chaining. - public ContractResolver AddType() => AddType(typeof(T)); - - /// - /// Adds the by reflecting all System.Text.Json.Serialization property attributes to infer rename () or ignore (). - /// - /// The . - /// The instance to support fluent-style method-chaining. - public ContractResolver AddType(Type type) - { - (_typeDict ??= []).Add(type); - return this; - } - - /// - /// Renames one or more properties for a . - /// - /// The . - /// One or more property and JSON name pairs. - /// The instance to support fluent-style method-chaining. - public ContractResolver AddRename(Type type, IDictionary pairs) - { - type.ThrowIfNull(nameof(type)); - - if (pairs != null) - { - foreach (var pair in pairs) - { - AddRename(type, pair.Key, pair.Value); - } - } - - return this; - } - - /// - /// Renames the property to the . - /// - /// The . - /// The property name. - /// The JSON name. - /// The instance to support fluent-style method-chaining. - public ContractResolver AddRename(Type type, string propertyName, string jsonName) - { - type.ThrowIfNull(nameof(type)); - propertyName.ThrowIfNullOrEmpty(nameof(propertyName)); - jsonName.ThrowIfNullOrEmpty(nameof(jsonName)); - - if (propertyName == jsonName) - return this; - - (_renameDict ??= new ConcurrentDictionary>()).AddOrUpdate(type, t => new Dictionary { { propertyName, jsonName } }, (t, d) => { d.TryAdd(propertyName, jsonName); return d; }); - - return this; - } - - /// - /// Ignores one or more properties for a . - /// - /// The . - /// One or more property names. - /// The instance to support fluent-style method-chaining. - public ContractResolver AddIgnore(Type type, params string[] propertyNames) - { - type.ThrowIfNull(nameof(type)); - - if (propertyNames == null || propertyNames.Length == 0) - return this; - - (_ignoreDict ??= new ConcurrentDictionary>()).AddOrUpdate(type, t => new HashSet(propertyNames), (t, hs) => { hs.UnionWith(propertyNames); return hs; }); - return this; - } - - /// - protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) - { - var property = base.CreateProperty(member, memberSerialization); - var type = property.DeclaringType!.IsGenericType ? property.DeclaringType!.GetGenericTypeDefinition() : property.DeclaringType!; - - if (_ignoreDict != null && _ignoreDict.TryGetValue(type, out var hs)) - { - if (hs.Contains(property.PropertyName!)) - { - property.ShouldSerialize = x => false; - property.Ignored = true; - return property; - } - } - - if (_renameDict != null && _renameDict.TryGetValue(type, out var d) && d.TryGetValue(property.PropertyName!, out var jn)) - { - property.PropertyName = jn; - return property; - } - - if (_typeDict != null && _typeDict.TryGetValue(type, out _)) - { - var jpna = member.GetCustomAttribute(true); - if (jpna != null) - { - property.PropertyName = jpna.Name; - property.Order = member.GetCustomAttribute(true)?.Order; - return property; - } - - var jpia = member.GetCustomAttribute(true); - if (jpia != null) - { - property.ShouldSerialize = x => false; - property.Ignored = true; - } - } - - return property; - } - - /// - /// Gets (creates) the from the . - /// - /// The . - /// The option. - /// The . - public JsonProperty GetProperty(MemberInfo memberInfo, MemberSerialization memberSerialization) => CreateProperty(memberInfo, memberSerialization); - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/EventDataSerializer.cs b/src/CoreEx.Newtonsoft/Json/EventDataSerializer.cs deleted file mode 100644 index 10d1872d..00000000 --- a/src/CoreEx.Newtonsoft/Json/EventDataSerializer.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using CoreEx.Json; -using System; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Provides the -based . - /// - public class EventDataSerializer : EventDataSerializerBase - { - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - public EventDataSerializer(IJsonSerializer? jsonSerializer = null, EventDataFormatter? eventDataFormatter = null) : base(jsonSerializer ?? new JsonSerializer(), eventDataFormatter) - { - if (JsonSerializer is not CoreEx.Newtonsoft.Json.JsonSerializer) - throw new ArgumentException($"The {nameof(IJsonSerializer)} instance must be of Type '{typeof(CoreEx.Newtonsoft.Json.JsonSerializer).FullName}'.", nameof(jsonSerializer)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/JsonFilterer.cs b/src/CoreEx.Newtonsoft/Json/JsonFilterer.cs deleted file mode 100644 index ba127621..00000000 --- a/src/CoreEx.Newtonsoft/Json/JsonFilterer.cs +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Linq; -using Nsj = Newtonsoft.Json; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Provides a means to apply a filter to include or exclude JSON properties (in effect removing the unwanted properties). - /// - public static class JsonFilterer - { - /// - /// Trys to apply the JSON (using JSON ) to a resulting in the corresponding . - /// - /// The value . - /// The value. - /// The list of JSON paths to . - /// The corresponding JSON with the filtering applied. - /// The ; defaults to . - /// The optional . - /// The paths ; defaults to . - /// The action. - /// true indicates that at least one JSON node was filtered (removed); otherwise, false for no changes. - public static bool TryApply(T value, IEnumerable? paths, out string json, JsonPropertyFilter filter = JsonPropertyFilter.Include, JsonSerializerSettings? settings = null, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) - { - var r = TryApply(value, paths, out JToken node, filter, settings, comparison, preFilterInspector); - json = node.ToString(Formatting.None); - return r; - } - - /// - /// Trys to apply the JSON (using JSON ) to a resulting in the corresponding . - /// - /// The value . - /// The value. - /// The list of JSON paths to . - /// The corresponding with the filtering applied. - /// The ; defaults to . - /// The optional . - /// The paths ; defaults to . - /// The action. - /// true indicates that at least one JSON node was filtered (removed); otherwise, false for no changes. - public static bool TryApply(T value, IEnumerable? paths, out JToken json, JsonPropertyFilter filter = JsonPropertyFilter.Include, JsonSerializerSettings? settings = null, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) - { - value.ThrowIfNull(nameof(value)); - json = JToken.FromObject(value, Nsj.JsonSerializer.Create(settings)); - preFilterInspector?.Invoke(new JsonPreFilterInspector(json)); - return Apply(json, paths, filter, comparison); - } - - /// - /// Applies the inclusion and exclusion of the JSON paths to a specified . - /// - /// The value. - /// The list of JSON paths to . - /// The ; defaults to . - /// The paths ; defaults to . - /// true indicates that at least one JSON node was filtered (removed); otherwise, false for no changes. - public static bool Apply(JToken json, IEnumerable? paths, JsonPropertyFilter filter = JsonPropertyFilter.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase) - { - var maxDepth = 0; - var dict = Text.Json.JsonFilterer.CreateDictionary(paths, filter, comparison, ref maxDepth, true); - - var filtered = false; - if (maxDepth > 0) - JsonFilter(json, dict, filter, 0, maxDepth, ref filtered, comparison); - - return filtered; - } - - /// - /// Filter the JSON nodes based on the includes/excludes. - /// - private static bool JsonFilter(JToken json, Dictionary paths, JsonPropertyFilter filter, int depth, int maxDepth, ref bool filtered, StringComparison comparison) - { - // Do not check beyond maximum depth as there is no further filtering required. - if (depth > maxDepth) - return false; - - // Iterate through the properties within the object and filter accordingly. - if (json is JObject jo) - { - foreach (var jn in jo.Properties().ToArray()) - { - string path = Text.Json.JsonFilterer.PrependRootPath(jn.Path); - bool found = paths.TryGetValue(path, out var isSpecifiedPath); - if (!found && Text.Json.JsonFilterer.TryRemovePathIndexes(path, out var pathWithoutIndexes)) - found = paths.TryGetValue(pathWithoutIndexes, out isSpecifiedPath); - - if ((filter == JsonPropertyFilter.Include && !found) || (filter == JsonPropertyFilter.Exclude && found)) - { - jo.Remove(jn.Name); - filtered = true; - continue; - } - - if (filter == JsonPropertyFilter.Include && found && isSpecifiedPath) - continue; - - // Where there is a child value then continue navigation. - if (jn.Value != null) - JsonFilter(jn.Value, paths, filter, depth + 1, maxDepth, ref filtered, comparison); - } - } - else if (json is JArray ja) - { - // Iterate and filter each item in the array. - for (var i = ja.Count - 1; i >= 0; i--) - { - var jn = ja[i]; - if (jn != null) - { - if (JsonFilter(jn, paths, filter, depth, maxDepth, ref filtered, comparison)) - { - ja.RemoveAt(i); - filtered = true; - } - } - } - } - else if (json is JValue) - { - var path = Text.Json.JsonFilterer.PrependRootPath(json.Path); - if (!paths.TryGetValue(path, out var isSpecifiedPath) && Text.Json.JsonFilterer.TryRemovePathIndexes(path, out var pathWithoutIndexes)) - paths.TryGetValue(pathWithoutIndexes, out isSpecifiedPath); - - return filter == JsonPropertyFilter.Include ? !isSpecifiedPath : isSpecifiedPath; - } - - return false; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/JsonPreFilterInspector.cs b/src/CoreEx.Newtonsoft/Json/JsonPreFilterInspector.cs deleted file mode 100644 index 9e6838b8..00000000 --- a/src/CoreEx.Newtonsoft/Json/JsonPreFilterInspector.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using Newtonsoft.Json.Linq; -using Nsj = Newtonsoft.Json; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Provides pre (prior) to filtering JSON inspection. - /// - /// The . - public readonly struct JsonPreFilterInspector(JToken json) : IJsonPreFilterInspector - { - /// - object IJsonPreFilterInspector.Json => Json; - - /// - /// Gets the before any filtering has been applied. - /// - public JToken Json { get; } = json; - - /// - public string? ToJsonString() => Json.ToString(Nsj.Formatting.None); - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/JsonSerializer.cs b/src/CoreEx.Newtonsoft/Json/JsonSerializer.cs deleted file mode 100644 index 89649761..00000000 --- a/src/CoreEx.Newtonsoft/Json/JsonSerializer.cs +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Reflection; -using Nsj = Newtonsoft.Json; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Provides the encapsulated implementation. - /// - /// The . Defaults to . - public class JsonSerializer(JsonSerializerSettings? settings = null) : IJsonSerializer - { - /// - /// Gets or sets the default . - /// - /// The following will default: - /// - /// = . - /// = . - /// = . - /// = . - /// = , , - /// and . - /// - /// - public static JsonSerializerSettings DefaultSettings { get; set; } = new JsonSerializerSettings - { - DefaultValueHandling = DefaultValueHandling.Ignore, - NullValueHandling = NullValueHandling.Ignore, - Formatting = Formatting.None, - ContractResolver = ContractResolver.Default, - Converters = { new Nsj.Converters.StringEnumConverter(), new ReferenceDataJsonConverter(), new CollectionResultJsonConverter(), new CompositeKeyJsonConverter() } - }; - - /// - /// Gets the underlying serializer configuration settings/options. - /// - object IJsonSerializer.Options => Settings; - - /// - /// Gets the . - /// - public JsonSerializerSettings Settings { get; } = settings ?? DefaultSettings; - - /// - public string Serialize(T value, JsonWriteFormat? format = null) => SerializeToBinaryData(value, format).ToString(); - - /// - public BinaryData SerializeToBinaryData(T value, JsonWriteFormat? format = null) - { - var ms = new MemoryStream(); - using var sw = new StreamWriter(ms); - using var jtw = new Nsj.JsonTextWriter(sw); - Nsj.JsonSerializer.Create(format == null ? Settings : CopySettings(format!.Value)).Serialize(jtw, value); - jtw.Flush(); - ms.Position = 0; - return BinaryData.FromStream(ms); - } - - /// -#if NET7_0_OR_GREATER - public object? Deserialize([StringSyntax(StringSyntaxAttribute.Json)] string json) -#else - public object? Deserialize(string json) -#endif - => Deserialize(BinaryData.FromString(json)); - - /// -#if NET7_0_OR_GREATER - public object? Deserialize([StringSyntax(StringSyntaxAttribute.Json)] string json, Type type) -#else - public object? Deserialize(string json, Type type) -#endif - => Deserialize(BinaryData.FromString(json), type); - - /// -#if NET7_0_OR_GREATER - public T? Deserialize([StringSyntax(StringSyntaxAttribute.Json)] string json) -#else - public T? Deserialize(string json) -#endif - => Deserialize(BinaryData.FromString(json))!; - - /// - public object? Deserialize(BinaryData json) => Deserialize(json); - - /// - public T? Deserialize(BinaryData json) => (T?)Deserialize(json, typeof(T)); - - /// - public object? Deserialize(BinaryData json, Type type) - { - using var s = json.ToStream(); - using var sr = new StreamReader(s); - using var jtr = new Nsj.JsonTextReader(sr); - return Nsj.JsonSerializer.Create(Settings).Deserialize(jtr, type); - } - - /// - public bool TryApplyFilter(T value, IEnumerable? names, out string json, JsonPropertyFilter filter = JsonPropertyFilter.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) - => JsonFilterer.TryApply(value, names, out json, filter, Settings, comparison, preFilterInspector); - - /// - public bool TryApplyFilter(T value, IEnumerable? names, out object json, JsonPropertyFilter filter = JsonPropertyFilter.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) - { - var r = JsonFilterer.TryApply(value, names, out JToken node, filter, Settings, comparison, preFilterInspector); - json = node; - return r; - } - - /// - bool IJsonSerializer.TryGetJsonName(MemberInfo memberInfo, [NotNullWhen(true)] out string? jsonName) - { - var sji = memberInfo.ThrowIfNull(nameof(memberInfo)).GetCustomAttribute(true); - if (sji != null) - { - jsonName = null; - return false; - } - - var ji = memberInfo.GetCustomAttribute(true); - if (ji != null) - { - jsonName = null; - return false; - } - - var jpn = memberInfo.GetCustomAttribute(true); - if (jpn?.PropertyName != null) - { - jsonName = jpn.PropertyName; - return true; - } - - if (Settings.ContractResolver is ContractResolver cr) - { - var jo = memberInfo.DeclaringType?.GetCustomAttribute(true); - var jp = cr.GetProperty(memberInfo, jo == null ? MemberSerialization.OptOut : jo.MemberSerialization); - if (jp != null) - { - jsonName = jp.Ignored ? null : jp.PropertyName; - return !jp.Ignored; - } - } - - if (Settings.ContractResolver is CamelCasePropertyNamesContractResolver ccr && ccr.NamingStrategy != null) - jsonName = ccr.NamingStrategy.GetPropertyName(memberInfo.Name, false); - else - jsonName = memberInfo.Name; - - return true; - } - - /// - /// Copies the settings. - /// - private JsonSerializerSettings CopySettings(JsonWriteFormat format) - { - var s = new JsonSerializerSettings - { - ReferenceLoopHandling = Settings.ReferenceLoopHandling, - MissingMemberHandling = Settings.MissingMemberHandling, - ObjectCreationHandling = Settings.ObjectCreationHandling, - NullValueHandling = Settings.NullValueHandling, - DefaultValueHandling = Settings.DefaultValueHandling, - PreserveReferencesHandling = Settings.PreserveReferencesHandling, - TypeNameHandling = Settings.TypeNameHandling, - MetadataPropertyHandling = Settings.MetadataPropertyHandling, - TypeNameAssemblyFormatHandling = Settings.TypeNameAssemblyFormatHandling, - ConstructorHandling = Settings.ConstructorHandling, - ContractResolver = Settings.ContractResolver, - EqualityComparer = Settings.EqualityComparer, - ReferenceResolverProvider = Settings.ReferenceResolverProvider, - TraceWriter = Settings.TraceWriter, - SerializationBinder = Settings.SerializationBinder, - Error = Settings.Error, - Context = Settings.Context, - DateFormatString = Settings.DateFormatString, - MaxDepth = Settings.MaxDepth, - Formatting = format == JsonWriteFormat.None ? Formatting.None : Formatting.Indented, - DateFormatHandling = Settings.DateFormatHandling, - DateTimeZoneHandling = Settings.DateTimeZoneHandling, - DateParseHandling = Settings.DateParseHandling, - FloatFormatHandling = Settings.FloatFormatHandling, - FloatParseHandling = Settings.FloatParseHandling, - StringEscapeHandling = Settings.StringEscapeHandling, - Culture = Settings.Culture, - CheckAdditionalContent = Settings.CheckAdditionalContent, - }; - - if (Settings.Converters != null) - s.Converters = new List(Settings.Converters); - - return s; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/NewtonsoftServiceCollectionExtensions.cs b/src/CoreEx.Newtonsoft/Json/NewtonsoftServiceCollectionExtensions.cs deleted file mode 100644 index 01dbcf6e..00000000 --- a/src/CoreEx.Newtonsoft/Json/NewtonsoftServiceCollectionExtensions.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Events; -using CoreEx.Json; -using System; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extensions. - /// - public static class NewtonsoftServiceCollectionExtensions - { - /// - /// Checks that the is not null. - /// - private static IServiceCollection CheckServices(IServiceCollection services) => services.ThrowIfNull(nameof(services)); - - /// - /// Adds the as the and as the singleton services. - /// - /// The . - /// The . - public static IServiceCollection AddNewtonsoftJsonSerializer(this IServiceCollection services) - => CheckServices(services).AddSingleton() - .AddSingleton(); - - /// - /// Adds the as the singleton service. - /// - /// The . - /// The . - public static IServiceCollection AddNewtonsoftCloudEventSerializer(this IServiceCollection services) => CheckServices(services).AddSingleton(); - - /// - /// Adds the as the singleton service. - /// - /// The action to enable the to be further configured. - /// The . - /// The . - public static IServiceCollection AddNewtonsoftEventDataSerializer(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddSingleton(sp => - { - var eds = new CoreEx.Newtonsoft.Json.EventDataSerializer(sp.GetService(), sp.GetService()); - configure?.Invoke(eds); - return eds; - }); - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/ReferenceDataContentJsonSerializer.cs b/src/CoreEx.Newtonsoft/Json/ReferenceDataContentJsonSerializer.cs deleted file mode 100644 index 876e300e..00000000 --- a/src/CoreEx.Newtonsoft/Json/ReferenceDataContentJsonSerializer.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using CoreEx.RefData; -using Newtonsoft.Json; -using Nsj = Newtonsoft.Json; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Provides the JSON Serialize and Deserialize implementation to allow types to serialize contents. - /// - /// Generally, types will serialize the as the value; this allows for full contents to be serialized. - /// The . Defaults to . - public class ReferenceDataContentJsonSerializer(JsonSerializerSettings? settings = null) : JsonSerializer(settings ?? DefaultSettings), IReferenceDataContentJsonSerializer - { - /// - /// Gets or sets the default without to allow types to serialize contents. - /// - /// The following will default: - /// - /// = . - /// = . - /// = . - /// = . - /// = and . - /// - /// - public static new JsonSerializerSettings DefaultSettings { get; set; } = new JsonSerializerSettings - { - DefaultValueHandling = DefaultValueHandling.Ignore, - NullValueHandling = NullValueHandling.Ignore, - Formatting = Formatting.None, - ContractResolver = ContractResolver.Default, - Converters = { new Nsj.Converters.StringEnumConverter(), new CollectionResultJsonConverter() } - }; - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/ReferenceDataJsonConverter.cs b/src/CoreEx.Newtonsoft/Json/ReferenceDataJsonConverter.cs deleted file mode 100644 index 3ae8e6e8..00000000 --- a/src/CoreEx.Newtonsoft/Json/ReferenceDataJsonConverter.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using Newtonsoft.Json; -using System; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Performs JSON value conversion for values. - /// - public class ReferenceDataJsonConverter : JsonConverter - { - /// - public override bool CanConvert(Type objectType) => typeof(IReferenceData).IsAssignableFrom(objectType); - - /// - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, global::Newtonsoft.Json.JsonSerializer serializer) - { - if (reader.TokenType == JsonToken.Null) - return default; - - if (reader.TokenType != JsonToken.String) - { - var jtr = (JsonTextReader)reader; - throw new JsonSerializationException("Reference data value must be a string.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); - } - - if (reader.Value is not string code) - return default; - - if (ExecutionContext.HasCurrent) - { - var coll = ReferenceDataOrchestrator.Current.GetByType(objectType); - if (coll != null && coll.TryGetByCode(code, out var rd)) - return rd; - } - - var rdx = (IReferenceData)Activator.CreateInstance(objectType)!; - rdx.Code = code; - rdx.SetInvalid(); - return rdx; - } - - /// - public override void WriteJson(JsonWriter writer, object? value, global::Newtonsoft.Json.JsonSerializer serializer) - { - if (value is not IReferenceData rd || rd.Code == null) - return; - - writer.WriteValue(rd.Code); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/SubstituteNamingStrategy.cs b/src/CoreEx.Newtonsoft/Json/SubstituteNamingStrategy.cs deleted file mode 100644 index 698b63a1..00000000 --- a/src/CoreEx.Newtonsoft/Json/SubstituteNamingStrategy.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Newtonsoft.Json.Serialization; - -namespace CoreEx.Newtonsoft.Json -{ - /// - /// Provides a substitution and camel case naming strategy. - /// - /// Converts the name by checking , then uses . - public class SubstituteNamingStrategy : CamelCaseNamingStrategy - { - /// - /// Gets the instance. - /// - public static SubstituteNamingStrategy Substitute { get; } = new SubstituteNamingStrategy(); - - /// - /// Initializes a new instance of the class. - /// - public SubstituteNamingStrategy() - { - ProcessDictionaryKeys = true; - OverrideSpecifiedNames = false; - } - - /// - public override string GetPropertyName(string name, bool hasSpecifiedName) - => (!hasSpecifiedName && CoreEx.Json.JsonSerializer.NameSubstitutions.TryGetValue(name, out var jsonName)) ? jsonName : base.GetPropertyName(name, hasSpecifiedName); - - /// - public override string GetDictionaryKey(string key) => CoreEx.Json.JsonSerializer.NameSubstitutions.TryGetValue(key, out var jsonName) ? jsonName : base.GetDictionaryKey(key); - } -} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/strong-name-key.snk b/src/CoreEx.Newtonsoft/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.OData/CoreEx.OData.csproj b/src/CoreEx.OData/CoreEx.OData.csproj deleted file mode 100644 index 88295fb8..00000000 --- a/src/CoreEx.OData/CoreEx.OData.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx.OData - CoreEx - CoreEx .NET OData extras. - CoreEx .NET OData extras. - coreex odata - - - - - - - - - - - - - diff --git a/src/CoreEx.OData/IBoundClientExtensions.cs b/src/CoreEx.OData/IBoundClientExtensions.cs deleted file mode 100644 index 76d751b1..00000000 --- a/src/CoreEx.OData/IBoundClientExtensions.cs +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Wildcards; -using Simple.OData.Client; -using System; -using System.Collections.Generic; -using System.Linq.Expressions; - -namespace CoreEx.OData -{ - /// - /// Adds additional extension methods to the . - /// - public static class IBoundClientExtensions - { - /// - /// Filters a sequence of values based on a only true. - /// - /// The element . - /// The query. - /// Indicates to perform an underlying only when true; - /// otherwise, no Where is invoked. - /// A function to test each element for a condition. - /// The resulting query. - public static IBoundClient FilterWhen(this IBoundClient query, bool when, Expression> predicate) where TElement : class - { - var q = query.ThrowIfNull(nameof(query)); - if (when) - return q.Filter(predicate.ThrowIfNull(nameof(predicate))); - else - return q; - } - - /// - /// Filters a sequence of values based on a only when the is not the default value for the . - /// - /// The element . - /// The with value . - /// The query. - /// Indicates to perform an underlying only when the with is not the default - /// value; otherwise, no Where is invoked. - /// A function to test each element for a condition. - /// The resulting query. - public static IBoundClient FilterWith(this IBoundClient query, T with, Expression> predicate) where TElement : class - { - var q = query.ThrowIfNull(nameof(query)); - if (Comparer.Default.Compare(with, default!) != 0 && Comparer.Default.Compare(with, default!) != 0) - { - if (with is not string && with is System.Collections.IEnumerable ie && !ie.GetEnumerator().MoveNext()) - return q; - - return q.Filter(predicate.ThrowIfNull(nameof(predicate))); - } - else - return q; - } - - /// - /// Filters a sequence of values using the specified and containing supported wildcards. - /// - /// The element . - /// The query. - /// The . - /// The text to query. - /// Indicates whether the comparison should ignore case (default) or not; will use when selected for comparisons. - /// Indicates whether a null check should also be performed before the comparion occurs (defaults to true). - /// The resulting (updated) query. - public static IBoundClient FilterWildcard(this IBoundClient query, Expression> property, string? text, bool ignoreCase = true, bool checkForNull = true) where TElement : class - { - var q = query.ThrowIfNull(nameof(query)); - var p = property.ThrowIfNull(nameof(property)); - - // Check the expression. - if (p.Body is not MemberExpression me) - throw new ArgumentException("Property expression must be of Type MemberExpression.", nameof(property)); - - Expression exp = me; - var wc = Wildcard.MultiBasic; - var wr = wc.Parse(text).ThrowOnError(); - - // Exit stage left where nothing to do. - if (wr.Selection.HasFlag(WildcardSelection.None) || wr.Selection.HasFlag(WildcardSelection.Single)) - return query; - - var s = wr.GetTextWithoutWildcards(); - if (ignoreCase) - { - s = s?.ToUpper(System.Globalization.CultureInfo.InvariantCulture); - exp = Expression.Call(me, typeof(string).GetMethod(nameof(string.ToUpper), Type.EmptyTypes)!); - } - - if (wr.Selection.HasFlag(WildcardSelection.Equal)) - exp = Expression.Equal(exp, Expression.Constant(s)); - else if (wr.Selection.HasFlag(WildcardSelection.EndsWith)) - exp = Expression.Call(exp, "EndsWith", null, Expression.Constant(s)); - else if (wr.Selection.HasFlag(WildcardSelection.StartsWith)) - exp = Expression.Call(exp, "StartsWith", null, Expression.Constant(s)); - else if (wr.Selection.HasFlag(WildcardSelection.Contains)) - exp = Expression.Call(exp, "Contains", null, Expression.Constant(s)); - else - throw new ArgumentException("Wildcard selection text is not supported.", nameof(text)); - - // Add check for not null. - if (checkForNull) - { - var ee = Expression.NotEqual(me, Expression.Constant(null)); - exp = Expression.AndAlso(ee, exp); - } - - var le = Expression.Lambda>(exp, p.Parameters); - return q.Filter(le); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/IOData.cs b/src/CoreEx.OData/IOData.cs deleted file mode 100644 index 1113fced..00000000 --- a/src/CoreEx.OData/IOData.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.OData.Mapping; -using CoreEx.Results; -using System; -using System.Threading; -using System.Threading.Tasks; -using Soc = Simple.OData.Client; - -namespace CoreEx.OData -{ - /// - /// Enables the OData functionality. - /// - public interface IOData - { - /// - /// Gets the underlying . - /// - Soc.ODataClient Client { get; } - - /// - /// Gets the . - /// - ODataInvoker Invoker { get; } - - /// - /// Gets the . - /// - IMapper Mapper { get; } - - /// - /// Gets the default used where not expliticly specified for an operation. - /// - ODataArgs Args { get; } - - /// - /// Creates an to enable select-like capabilities. - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The function to further define the query. - /// A . - ODataQuery Query(ODataArgs queryArgs, string? collectionName, Func, Soc.IBoundClient>? query = null) where T : class, IEntityKey, new() where TModel : class, new(); - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The . - /// The . - /// The entity value where found; otherwise, null. - Task> GetWithResultAsync(ODataArgs args, string? collectionName, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new(); - - /// - /// Creates the entity with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The value to create. - /// The . - /// The value (refreshed where specified). - Task> CreateWithResultAsync(ODataArgs args, string? collectionName, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new(); - - /// - /// Updates the entity with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The value to update. - /// The . - /// The value (refreshed where specified). - Task> UpdateWithResultAsync(ODataArgs args, string? collectionName, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new(); - - /// - /// Deletes the entity for the specified with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The . - /// The . - /// The entity value where found; otherwise, null. - Task DeleteWithResultAsync(ODataArgs args, string? collectionName, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new(); - - /// - /// Invoked where a has been thrown. - /// - /// The OData . - /// The containing the appropriate where handled; otherwise, null indicating that the exception is unexpected and will continue to be thrown as such. - /// Provides an opportunity to inspect and handle the exception before it is returned. A resulting that is is not considered sensical; therefore, will result in the originating - /// exception being thrown. - Result? HandleODataException(Soc.WebRequestException odex); - - /// - /// Creates an untyped for the specified . - /// - /// The resultant . - /// The . - /// The collection name. - /// The specific . - /// The . - ODataItemCollection CreateItemCollection(ODataArgs args, string collectionName, IODataMapper mapper) where T : class, new(); - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/Mapping/IODataMapper.cs b/src/CoreEx.OData/Mapping/IODataMapper.cs deleted file mode 100644 index e74cb6ad..00000000 --- a/src/CoreEx.OData/Mapping/IODataMapper.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.OData.Mapping -{ - /// - /// Defines an mapper. - /// - public interface IODataMapper - { - /// - /// Gets the source being mapped from/to the . - /// - Type SourceType { get; } - - /// - /// Gets the mappings. - /// - IEnumerable Mappings { get; } - - /// - /// Gets the from the for the specified . - /// - /// The property name. - /// The where found. - /// Thrown when the property does not exist. - IPropertyColumnMapper this[string propertyName] { get; } - - /// - /// Attempts to get the for the specified . - /// - /// The source property name. - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - bool TryGetProperty(string propertyName, [NotNullWhen(true)] out IPropertyColumnMapper? propertyColumnMapper); - - /// - /// Gets the . - /// - /// The source property name. - /// The where found. - /// Thrown when the property does not exist. - string GetColumnName(string propertyName); - - /// - /// Maps from an creating a corresponding instance of the . - /// - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The corresponding instance of the . - object? MapFromOData(ODataItem entity, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToOData(object? value, ODataItem entity, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/Mapping/IODataMapperT.cs b/src/CoreEx.OData/Mapping/IODataMapperT.cs deleted file mode 100644 index 99584ce6..00000000 --- a/src/CoreEx.OData/Mapping/IODataMapperT.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; -using System; - -namespace CoreEx.OData.Mapping -{ - /// - /// Defines an mapper. - /// - /// The . - public interface IODataMapper : IODataMapper - { - /// - Type IODataMapper.SourceType => typeof(TSource); - - /// - object? IODataMapper.MapFromOData(ODataItem entity, OperationTypes operationType) => MapFromOData(entity, operationType)!; - - /// - void IODataMapper.MapToOData(object? value, ODataItem entity, OperationTypes operationType) => MapToOData((TSource?)value, entity, operationType); - - /// - /// Maps from a creating a corresponding instance of . - /// - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The corresponding instance of . - new TSource? MapFromOData(ODataItem entity, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToOData(TSource? value, ODataItem entity, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Gets the OData primary key from the . - /// - /// The value. - /// The single value being performed to enable conditional execution where appropriate. - /// The primary key. - object[] GetODataKey(TSource value, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/Mapping/IPropertyColumnMapper.cs b/src/CoreEx.OData/Mapping/IPropertyColumnMapper.cs deleted file mode 100644 index cefefef3..00000000 --- a/src/CoreEx.OData/Mapping/IPropertyColumnMapper.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Mapping.Converters; -using CoreEx.Mapping; -using System; - -namespace CoreEx.OData.Mapping -{ - /// - /// Enables bi-directional property and column mapping. - /// - public interface IPropertyColumnMapper - { - /// - /// Gets the . - /// - IPropertyExpression PropertyExpression { get; } - - /// - /// Gets the source property name. - /// - string PropertyName { get; } - - /// - /// Gets the source property . - /// - Type PropertyType { get; } - - /// - /// Indicates whether the underlying source property is a complex type. - /// - bool IsSrcePropertyComplex { get; } - - /// - /// Gets the destination Dataverse column name. - /// - string ColumnName { get; } - - /// - /// Gets the selection to enable inclusion or exclusion of property (default to ). - /// - OperationTypes OperationTypes { get; } - - /// - /// Indicates whether the property forms part of the primary key. - /// - bool IsPrimaryKey { get; } - - /// - /// Sets the primary key (). - /// - void SetPrimaryKey(); - - /// - /// Gets the (used where a specific source and destination type conversion is required). - /// - IConverter? Converter { get; } - - /// - /// Sets the . - /// - /// The . - /// The and are mutually exclusive. - void SetConverter(IConverter converter); - - /// - /// Gets the to map complex types. - /// - IODataMapper? Mapper { get; } - - /// - /// Set the to map complex types. - /// - /// The . - /// The and are mutually exclusive. - void SetMapper(IODataMapper mapper); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToOData(object value, ODataItem entity, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The . - /// The value. - /// The single value being performed to enable conditional execution where appropriate. - void MapFromOData(ODataItem entity, object value, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/Mapping/ODataMapperT.cs b/src/CoreEx.OData/Mapping/ODataMapperT.cs deleted file mode 100644 index 7338f29c..00000000 --- a/src/CoreEx.OData/Mapping/ODataMapperT.cs +++ /dev/null @@ -1,345 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; -using CoreEx.Mapping; -using System; -using System.Collections.Generic; -using System.Data; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; - -namespace CoreEx.OData.Mapping -{ - /// - /// Provides bidirectional mapping from a to an . - /// - /// The source . - public class ODataMapper : IODataMapper, IBidirectionalMapper where TSource : class, new() - { - private readonly List _mappings = []; - private readonly bool _implementsIIdentifier = typeof(IIdentifier).IsAssignableFrom(typeof(TSource)); - private readonly Lazy _mapperFromTo; - private readonly Lazy _mapperToFrom; - - /// - /// Initializes a new instance of the class. - /// - /// Indicates whether the entity should automatically map all public get/set properties, where the property and column names are all assumed to share the same name. - /// An array of source property names to ignore. - public ODataMapper(bool autoMap = false, params string[] ignoreSrceProperties) - { - if (typeof(TSource) == typeof(string)) throw new InvalidOperationException("TSource must not be a String."); - - if (autoMap) - AutomagicallyMap(ignoreSrceProperties); - - _mapperFromTo = new Lazy(() => new SourceToItemMapper(this)); - _mapperToFrom = new Lazy(() => new ItemToSourceMapper(this)); - } - - /// - /// Gets the mappings. - /// - public IEnumerable Mappings => _mappings.AsEnumerable(); - - /// - public IPropertyColumnMapper this[string propertyName] => TryGetProperty(propertyName, out var pcm) ? pcm : throw new ArgumentException($"Property '{propertyName}' does not exist.", nameof(propertyName)); - - /// - /// Gets the for the specified source . - /// - /// The to reference the source property. - /// The where found. - /// Thrown when the property does not exist. - public IPropertyColumnMapper this[Expression> propertyExpression] - { - get - { - propertyExpression.ThrowIfNull(nameof(propertyExpression)); - - MemberExpression? me = null; - if (propertyExpression.Body.NodeType == ExpressionType.MemberAccess) - me = propertyExpression.Body as MemberExpression; - else if (propertyExpression.Body.NodeType == ExpressionType.Convert) - { - if (propertyExpression.Body is UnaryExpression ue) - me = ue.Operand as MemberExpression; - } - - if (me == null) - throw new InvalidOperationException("Only Member access expressions are supported."); - - return this[me.Member.Name]; - } - } - - /// - public bool TryGetProperty(string propertyName, [NotNullWhen(true)] out IPropertyColumnMapper? propertyColumnMapper) - { - propertyColumnMapper = _mappings.Where(x => x.PropertyName == propertyName).FirstOrDefault(); - return propertyColumnMapper != null; - } - - /// - public string GetColumnName(string propertyName) => this[propertyName].ColumnName; - - /// - /// Automatically add each public get/set property. - /// - private void AutomagicallyMap(string[] ignoreSrceProperties) - { - foreach (var sp in TypeReflector.GetProperties(typeof(TSource))) - { - // Do not auto-map where ignore has been specified. - if (ignoreSrceProperties.Contains(sp.Name)) - continue; - - // Create the lambda expression for the property and add to the mapper. - var spe = Expression.Parameter(typeof(TSource), "x"); - var sex = Expression.Lambda(Expression.Property(spe, sp), spe); - typeof(ODataMapper) - .GetMethod(nameof(AutoProperty), BindingFlags.NonPublic | BindingFlags.Instance)! - .MakeGenericMethod([sp.PropertyType]) - .Invoke(this, [sex, null, OperationTypes.Any]); - } - } - - /// - /// Adds a to the mapper with additiional auto-logic. - /// - private PropertyColumnMapper AutoProperty(Expression> propertyExpression, string? columnName = null, OperationTypes operationTypes = OperationTypes.Any) - => Property(propertyExpression, columnName, operationTypes); - - /// - /// Adds a to the mapper (same as ). - /// - /// The source property . - /// The to reference the source property. - /// The OData column name. Defaults to name. - /// The selection to enable inclusion or exclusion of property. - /// The . - public PropertyColumnMapper Property(Expression> propertyExpression, string? columnName = null, OperationTypes operationTypes = OperationTypes.Any) - { - var pcm = new PropertyColumnMapper(propertyExpression, columnName, operationTypes); - AddMapping(pcm); - return pcm; - } - - /// - /// Adds a to the mapper (same as ). - /// - /// The source property . - /// The to reference the source property. - /// The OData column name. Defaults to name. - /// The selection to enable inclusion or exclusion of property. - /// The . - public PropertyColumnMapper Map(Expression> propertyExpression, string? columnName = null, OperationTypes operationTypes = OperationTypes.Any) - => Property(propertyExpression, columnName, operationTypes); - - /// - /// Validates and adds a new IPropertyColumnMapper. - /// - private void AddMapping(PropertyColumnMapper propertyColumnMapper) - { - if (_mappings.Any(x => x.PropertyName == propertyColumnMapper.PropertyName)) - throw new ArgumentException($"Source property '{propertyColumnMapper.PropertyName}' must not be specified more than once.", nameof(propertyColumnMapper)); - - if (_mappings.Any(x => x.ColumnName == propertyColumnMapper.ColumnName)) - throw new ArgumentException($"Column '{propertyColumnMapper.ColumnName}' must not be specified more than once.", nameof(propertyColumnMapper)); - - _mappings.Add(propertyColumnMapper); - } - - /// - /// Adds or updates to the mapper (same as ) - /// - /// The source property . - /// The to reference the source property. - /// The OData column name. Defaults to name. - /// The selection to enable inclusion or exclusion of property. - /// An enabling access to the created . - /// - /// Where updating an existing the and where specified will override the previous values. - public ODataMapper HasProperty(Expression> propertyExpression, string? columnName = null, OperationTypes? operationTypes = null, Action>? property = null) - { - var tmp = new PropertyColumnMapper(propertyExpression, columnName, operationTypes ?? OperationTypes.Any); - var pcm = _mappings.Where(x => x.PropertyName == tmp.PropertyName).OfType>().SingleOrDefault(); - if (pcm == null) - AddMapping(pcm = tmp); - else - { - if (columnName != null && tmp.ColumnName != pcm.ColumnName) - { - if (_mappings.Any(x => x.ColumnName == pcm.ColumnName)) - throw new ArgumentException($"Column '{pcm.ColumnName}' must not be specified more than once.", nameof(columnName)); - else - pcm.ColumnName = tmp.ColumnName; - } - - if (operationTypes != null) - pcm.OperationTypes = operationTypes.Value; - } - - property?.Invoke(pcm); - return this; - } - - /// - /// Adds or updates to the mapper (same as ). - /// - /// The source property . - /// The to reference the source property. - /// The OData column name. Defaults to name. - /// The selection to enable inclusion or exclusion of property. - /// An enabling access to the created . - /// - /// Where updating an existing the and where specified will override the previous values. - public ODataMapper HasMap(Expression> propertyExpression, string? columnName = null, OperationTypes? operationTypes = null, Action>? property = null) - => HasProperty(propertyExpression, columnName, operationTypes, property); - - /// - /// Inherits the property mappings from the selected . - /// - /// The source . Must inherit from . - /// The to inherit from. - public void InheritPropertiesFrom(IODataMapper inheritMapper) where T : class, new() - { - inheritMapper.ThrowIfNull(nameof(inheritMapper)); - if (!typeof(TSource).IsSubclassOf(typeof(T))) throw new ArgumentException($"Type {typeof(TSource).Name} must inherit from {typeof(T).Name}.", nameof(inheritMapper)); - - var pe = Expression.Parameter(typeof(TSource), "x"); - var type = typeof(ODataMapper<>).MakeGenericType(typeof(TSource)); - - foreach (var p in inheritMapper.Mappings) - { - var lex = Expression.Lambda(Expression.Property(pe, p.PropertyName), pe); - var pmap = (IPropertyColumnMapper)type - .GetMethod("Property", BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)! - .MakeGenericMethod(p.PropertyType) - .Invoke(this, [lex, p.ColumnName, p.OperationTypes])!; - - if (p.IsPrimaryKey) - pmap.SetPrimaryKey(); - - if (p.Converter != null) - pmap.SetConverter(p.Converter); - - if (p.Mapper != null) - pmap.SetMapper(p.Mapper); - } - } - - /// - public void MapToOData(TSource? value, ODataItem entity, OperationTypes operationType = OperationTypes.Unspecified) - { - entity.ThrowIfNull(nameof(entity)); - if (value == null) return; - - foreach (var p in _mappings) - { - p.MapToOData(value, entity, operationType); - } - - OnMapToOData(value, entity, operationType); - } - - /// - /// Extension opportunity when performing a . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - protected virtual void OnMapToOData(TSource value, ODataItem entity, OperationTypes operationType) { } - - /// - public TSource? MapFromOData(ODataItem entity, OperationTypes operationType = OperationTypes.Unspecified) - { - entity.ThrowIfNull(nameof(entity)); - var value = new TSource(); - - foreach (var p in _mappings) - { - p.MapFromOData(entity, value, operationType); - } - - value = OnMapFromOData(value, entity, operationType); - return (value != null && value is IInitial ii && ii.IsInitial) ? null : value; - } - - /// - /// Extension opportunity when performing a . - /// - /// The source value. - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The source value. - protected virtual TSource? OnMapFromOData(TSource value, ODataItem entity, OperationTypes operationType) => value; - - /// - /// to mapper. - /// - private class SourceToItemMapper(ODataMapper parent) : Mapper - { - internal ODataMapper Parent { get; } = parent; - - protected override ODataItem? OnMap(TSource? source, ODataItem? destination, OperationTypes operationType) - { - if (source is null) - return default; - - destination ??= new ODataItem(); - Parent.MapToOData(source, destination, operationType); - return destination; - } - } - - /// - /// to mapper. - /// - private class ItemToSourceMapper(ODataMapper parent) : Mapper - { - internal ODataMapper Parent { get; } = parent; - - protected override TSource? OnMap(ODataItem? source, TSource? destination, OperationTypes operationType) - { - if (source is null) - return default; - - return Parent.MapFromOData(source, operationType); - } - } - - /// - IMapper IBidirectionalMapper.MapperFromTo => _mapperFromTo.Value; - - /// - IMapper IBidirectionalMapper.MapperToFrom => _mapperToFrom.Value; - - /// - public object[] GetODataKey(TSource value, OperationTypes operationType = OperationTypes.Unspecified) - { - value.ThrowIfNull(nameof(value)); - - var km = _mappings.Where(x => x.IsPrimaryKey).ToArray(); - if (km.Length == 0) - throw new InvalidOperationException("No primary key mappings have been defined."); - - var oi = new ODataItem(); - foreach (var p in km) - { - p.MapToOData(value, oi, operationType); - } - - var key = new object[km.Length]; - for (int i = 0; i < km.Length; i++) - { - key[i] = oi.Attributes[km[i].ColumnName]; - } - - return key; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/Mapping/ODataMapping.cs b/src/CoreEx.OData/Mapping/ODataMapping.cs deleted file mode 100644 index b5679524..00000000 --- a/src/CoreEx.OData/Mapping/ODataMapping.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.OData.Mapping -{ - /// - /// Enables or of a . - /// - public class ODataMapping - { - /// - /// Creates a where properties are added manually. - /// - /// A . - public static ODataMapper Create() where TSource : class, new() => new(false); - - /// - /// Creates a where properties are added automatically (assumes the property and column names share the same name). - /// - /// An array of source property names to ignore. - /// A . - public static ODataMapper CreateAuto(params string[] ignoreSrceProperties) where TSource : class, new() => new(true, ignoreSrceProperties); - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/Mapping/PropertyColumnMapper.cs b/src/CoreEx.OData/Mapping/PropertyColumnMapper.cs deleted file mode 100644 index 40bcd205..00000000 --- a/src/CoreEx.OData/Mapping/PropertyColumnMapper.cs +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Mapping; -using CoreEx.Mapping.Converters; -using System; -using System.Linq.Expressions; - -namespace CoreEx.OData.Mapping -{ - /// - /// Provides bi-directional property and column mapping. - /// - /// The source entity . - /// The corresponding source property . - public class PropertyColumnMapper : IPropertyColumnMapper where TSource : class, new() - { - private readonly PropertyExpression _propertyExpression; - - /// - /// Initializes a new instance of the class. - /// - /// The to reference the source property. - /// The Dictionary column name. Defaults to name. - /// The selection to enable inclusion or exclusion of property. - internal PropertyColumnMapper(Expression> propertyExpression, string? columnName = null, OperationTypes operationTypes = OperationTypes.Any) - { - _propertyExpression = Abstractions.Reflection.PropertyExpression.Create(propertyExpression); - ColumnName = columnName ?? PropertyName; - OperationTypes = operationTypes; - } - - /// - public IPropertyExpression PropertyExpression => _propertyExpression; - - /// - public string PropertyName => _propertyExpression.Name; - - /// - public Type PropertyType => typeof(TSourceProperty); - - /// - public bool IsSrcePropertyComplex => throw new NotImplementedException(); - - /// - public string ColumnName { get; internal set; } - - /// - public OperationTypes OperationTypes { get; internal set; } - - /// - public bool IsPrimaryKey { get; private set; } - - /// - public IConverter? Converter { get; private set; } - - /// - public IODataMapper? Mapper { get; private set; } - - /// - void IPropertyColumnMapper.SetPrimaryKey() - { - if (Mapper != null) throw new InvalidOperationException("A primary key must not contain a Mapper."); - IsPrimaryKey = true; - } - - /// - /// Sets the primary key (). - /// - /// The to support fluent-style method-chaining. - public PropertyColumnMapper SetPrimaryKey() - { - ((IPropertyColumnMapper)this).SetPrimaryKey(); - return this; - } - - /// - void IPropertyColumnMapper.SetConverter(IConverter converter) - { - converter.ThrowIfNull(nameof(converter)); - - if (Mapper != null) - throw new InvalidOperationException("The Mapper and Converter cannot be both set; only one is permissible."); - - if (converter.SourceType != typeof(TSourceProperty)) - throw new InvalidOperationException($"The PropertyType '{PropertyType.Name}' and IConverter.SourceType '{converter.SourceType.Name}' must match."); - - Converter = converter; - } - - /// - /// Sets the . - /// - /// The . - /// The to support fluent-style method-chaining. - /// The and are mutually exclusive. - public PropertyColumnMapper SetConverter(IConverter converter) - { - ((IPropertyColumnMapper)this).SetConverter(converter); - return this; - } - - /// - void IPropertyColumnMapper.SetMapper(IODataMapper mapper) - { - mapper.ThrowIfNull(nameof(mapper)); - - if (Converter != null) - throw new InvalidOperationException("The Mapper and Converter cannot be both set; only one is permissible."); - - if (!_propertyExpression.IsClass) - throw new InvalidOperationException($"The PropertyType '{PropertyType.Name}' must be a class to set a Mapper."); - - if (mapper.SourceType != typeof(TSourceProperty)) - throw new ArgumentException($"The PropertyType '{PropertyType.Name}' and IDictionaryMapper.SourceType '{mapper.SourceType.Name}' must match.", nameof(mapper)); - - Mapper = mapper; - } - - /// - /// Sets the . - /// - /// The . - /// The to support fluent-style method-chaining. - /// The and are mutually exclusive. - public PropertyColumnMapper SetMapper(IODataMapper mapper) - { - ((IPropertyColumnMapper)this).SetMapper(mapper); - return this; - } - - /// - void IPropertyColumnMapper.MapToOData(object? value, ODataItem entity, OperationTypes operationType) => MapToDictionary((TSource?)value, entity, operationType); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - private void MapToDictionary(TSource? value, ODataItem entity, OperationTypes operationType) - { - if (value == null || !OperationTypes.HasFlag(operationType)) - return; - - var val = _propertyExpression.GetValue(value); - if (Mapper != null) - { - if (val != null) - Mapper.MapToOData(val, entity, operationType); - } - else - { - var aval = Converter == null ? val : Converter.ConvertToDestination(val); - entity[ColumnName] = aval!; - } - } - - /// - void IPropertyColumnMapper.MapFromOData(ODataItem entity, object value, OperationTypes operationType) => MapFromDictionary(entity, (TSource)value, operationType); - - /// - /// Maps from a updating the . - /// - /// The . - /// The value. - /// The single value being performed to enable conditional execution where appropriate. - private void MapFromDictionary(ODataItem entity, TSource value, OperationTypes operationType) - { - if (!OperationTypes.HasFlag(operationType)) - return; - - TSourceProperty? pval; - if (Mapper != null) - pval = (TSourceProperty?)Mapper.MapFromOData(entity, operationType); - else - { - if (!entity.Attributes.ContainsKey(ColumnName)) - pval = _propertyExpression.GetDefault(); - else if (Converter is null) - pval = (TSourceProperty)entity[ColumnName]!; - else - pval = (TSourceProperty)Converter.ConvertToSource(entity[ColumnName])!; - } - - _propertyExpression.SetValue(value, pval); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/ODataArgs.cs b/src/CoreEx.OData/ODataArgs.cs deleted file mode 100644 index 65fd1e7d..00000000 --- a/src/CoreEx.OData/ODataArgs.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Net; -using Soc = Simple.OData.Client; - -namespace CoreEx.OData -{ - /// - /// Provides the OData arguments. - /// - public struct ODataArgs - { - /// - /// Initializes a new instance of the struct. - /// - public ODataArgs() { } - - /// - /// Initializes a new instance of the struct from a . - /// - /// The template to copy from. - public ODataArgs(ODataArgs template) - { - NullOnNotFound = template.NullOnNotFound; - CleanUpResult = template.CleanUpResult; - PreReadOnUpdate = template.PreReadOnUpdate; - PreReadOnDelete = template.PreReadOnDelete; - IsPagingGetCountSupported = template.IsPagingGetCountSupported; - } - - /// - /// Indicates that a null is to be returned where the response has a of on Get. Defaults to true. - /// - /// Consider setting to true which ensure a null is returned avoiding the cost of an unnecessary exception. - public bool NullOnNotFound { get; set; } = true; - - /// - /// Indicates whether the result should be cleaned up. - /// - public bool CleanUpResult { get; set; } = false; - - /// - /// Indicates whether a pre-read (Get) should be performed prior to an Update operation to ensure that the entity exists before attempting. Defaults to false. - /// - /// A pre-read will ensure that only an update can occur; otherwise, an upsert may occur (dependent on system capability). - public bool PreReadOnUpdate { get; set; } = true; - - /// - /// Indicates whether a pre-read (Get) should be performed prior to a Delete operation to ensure that the entity exists before attempting. Defaults to false. - /// - public bool PreReadOnDelete { get; set; } = false; - - /// - /// Indicates whether is supported; i.e. does the OData endpoint support $count=true. Defaults to true. - /// - public bool IsPagingGetCountSupported { get; set; } = true; - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/ODataClient.cs b/src/CoreEx.OData/ODataClient.cs deleted file mode 100644 index 0b18233b..00000000 --- a/src/CoreEx.OData/ODataClient.cs +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.OData.Mapping; -using CoreEx.Results; -using System; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using Soc = Simple.OData.Client; - -namespace CoreEx.OData -{ - /// - /// Provides the OData client functionality. - /// - /// The . - /// The ; defaults to . - /// Enables the to be overridden; defaults to . - /// Where is not specified then will be used. Note that an will be required where performing any operations outside of any (being untyped). - public class ODataClient(Soc.ODataClient client, IMapper? mapper = null, ODataInvoker? invoker = null) : IOData - { - /// - public Soc.ODataClient Client { get; } = client.ThrowIfNull(nameof(client)); - - /// - public ODataInvoker Invoker { get; } = invoker ?? new ODataInvoker(); - - /// - public IMapper Mapper { get; } = mapper ?? CoreEx.Mapping.Mapper.Empty; - - /// - public ODataArgs Args { get; set; } = new ODataArgs(); - - /// - public ODataQuery Query(ODataArgs queryArgs, string? collectionName, Func, Soc.IBoundClient>? query = null) where T : class, IEntityKey, new() where TModel : class, new() - => new(this, queryArgs, collectionName, query); - - /// - public async Task> GetWithResultAsync(ODataArgs args, string? collectionName, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() => await Invoker.InvokeAsync(this, async(_, ct) => - { - return (await GetModelAsync(args, collectionName, key, ct).ConfigureAwait(false)) - .WhenAs(model => model is null, _ => default!, model => MapToValue(args, model!)); - }, this, cancellationToken); - - /// - public async Task> CreateWithResultAsync(ODataArgs args, string? collectionName, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() => await Invoker.InvokeAsync(this, async (_, ct) => - { - var model = Mapper.Map(Cleaner.PrepareCreate(value.ThrowIfNull(nameof(value))), OperationTypes.Create)!; - var created = await Client.For(collectionName).Set(Cleaner.PrepareCreate(model)).InsertEntryAsync(true, ct).ConfigureAwait(false); - return MapToValue(args, created); - }, this, cancellationToken); - - /// - public async Task> UpdateWithResultAsync(ODataArgs args, string? collectionName, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() => await Invoker.InvokeAsync(this, async (_, ct) => - { - Cleaner.PrepareUpdate(value.ThrowIfNull(nameof(value))); - TModel model; - - if (args.PreReadOnUpdate) - { - var get = (await GetModelAsync(args, collectionName, value.EntityKey, ct).ConfigureAwait(false)) - .When(v => v is null, _ => Result.NotFoundError()); - - if (get.IsFailure) - return get.AsResult(); - - model = Mapper.Map(value, get.Value, OperationTypes.Update)!; - } - else - model = Mapper.Map(value, OperationTypes.Update)!; - - var updated = await Client.For(collectionName).Key(value.EntityKey.Args).Set(Cleaner.PrepareUpdate(model)).UpdateEntryAsync(true, ct).ConfigureAwait(false); - return updated is null ? Result.NotFoundError() : Result.Ok(MapToValue(args, updated)); - }, this, cancellationToken); - - /// - public async Task DeleteWithResultAsync(ODataArgs args, string? collectionName, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() => await Invoker.InvokeAsync(this, async (_, ct) => - { - if (args.PreReadOnDelete) - { - var get = await Result.GoAsync(GetModelAsync(args, collectionName, key, ct)) - .When(model => model is null, _ => Result.NotFoundError()).ConfigureAwait(false); - - if (get.IsFailure) - return get.AsResult(); - } - - await Client.For(collectionName).Key(key.Args).DeleteEntryAsync(ct).ConfigureAwait(false); - return Result.Success; - }, this, cancellationToken); - - /// - /// Gets (reads) the model. - /// - private async Task> GetModelAsync(ODataArgs args, string? collectionName, CompositeKey key, CancellationToken cancellationToken = default) where TModel : class, new() - { - try - { - return Result.Go(await Client.For(collectionName).Key(key.Args).FindEntryAsync(cancellationToken).ConfigureAwait(false)) - .WhenAs(model => model is null || (model is ILogicallyDeleted ld && ld.IsDeleted.HasValue && ld.IsDeleted.Value), _ => args.NullOnNotFound ? default! : Result.NotFoundError(), model => model); - } - catch (Soc.WebRequestException odex) when (odex.Code == HttpStatusCode.NotFound && args.NullOnNotFound) { return default!; } - } - - /// - /// Maps from the model to the value. - /// - private T MapToValue(ODataArgs args, TModel model) where T : class, IEntityKey, new() where TModel : class, new() - { - var result = Mapper.Map(model, OperationTypes.Get); - return (result is not null) ? CleanUpResult(args, result) : throw new InvalidOperationException("Mapping from the OData model must not result in a null value."); - } - - /// - /// Cleans up the result where specified within the args. - /// - internal static T CleanUpResult(ODataArgs args, T value) => args.CleanUpResult ? Cleaner.Clean(value) : value; - - /// - public Result? HandleODataException(Soc.WebRequestException odex) => OnCosmosException(odex); - - /// - /// Provides the handling as a result of . - /// - /// The . - /// The containing the appropriate . - /// Where overridding and the is not specifically handled then invoke the base to ensure any standard handling is executed. - protected virtual Result? OnCosmosException(Soc.WebRequestException odex) => odex.ThrowIfNull(nameof(odex)).Code switch - { - HttpStatusCode.NotFound => Result.Fail(new NotFoundException(null, odex)), - HttpStatusCode.Conflict => Result.Fail(new DuplicateException(null, odex)), - HttpStatusCode.PreconditionFailed => Result.Fail(new ConcurrencyException(null, odex)), - _ => Result.Fail(odex) - }; - - /// - public ODataItemCollection CreateItemCollection(ODataArgs args, string collectionName, IODataMapper mapper) where T : class, new() => new(this, args, collectionName, mapper); - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/ODataExtensions.cs b/src/CoreEx.OData/ODataExtensions.cs deleted file mode 100644 index 96f2351d..00000000 --- a/src/CoreEx.OData/ODataExtensions.cs +++ /dev/null @@ -1,387 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.OData.Mapping; -using CoreEx.Results; -using System; -using System.Threading; -using System.Threading.Tasks; -using Soc = Simple.OData.Client; - -namespace CoreEx.OData -{ - /// - /// Provides the OData extension methods. - /// - public static class ODataExtensions - { - /// - /// Creates an to enable select-like capabilities. - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The function to further define the query. - /// A . - public static ODataQuery Query(this IOData odata, Func, Soc.IBoundClient>? query = null) where T : class, IEntityKey, new() where TModel : class, new() - => odata.Query(new ODataArgs(odata.Args), null, query); - - /// - /// Creates an to enable select-like capabilities. - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The function to further define the query. - /// A . - public static ODataQuery Query(this IOData odata, string? collectionName, Func, Soc.IBoundClient>? query = null) where T : class, IEntityKey, new() where TModel : class, new() - => odata.Query(new ODataArgs(odata.Args), collectionName, query); - - #region Standard - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The key value. - /// The . - /// The entity value where found; otherwise, null. - public static Task GetAsync(this IOData odata, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.GetAsync(CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null. - public static Task GetAsync(this IOData odata, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.GetAsync(null, key, cancellationToken); - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The key value. - /// The . - /// The entity value where found; otherwise, null. - public static Task GetAsync(this IOData odata, string? collectionName, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.GetAsync(collectionName, CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the entity for the specified mapping from to . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The . - /// The . - /// The entity value where found; otherwise, null. - public static async Task GetAsync(this IOData odata, string? collectionName, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => (await odata.GetWithResultAsync(new ODataArgs(odata.Args), collectionName, key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Performs a create for the value (reselects and/or automatically saves changes). - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static Task CreateAsync(this IOData odata, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.CreateAsync(null, value, cancellationToken); - - /// - /// Performs a create for the value (reselects and/or automatically saves changes). - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static async Task CreateAsync(this IOData odata, string? collectionName, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => (await odata.CreateWithResultAsync(new ODataArgs(odata.Args), collectionName, value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Performs an update for the value (reselects and/or automatically saves changes). - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static Task UpdateAsync(this IOData odata, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.UpdateAsync(null, value, cancellationToken); - - /// - /// Performs an update for the value (reselects and/or automatically saves changes). - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static async Task UpdateAsync(this IOData odata, string? collectionName, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => (await odata.UpdateWithResultAsync(new ODataArgs(odata.Args), collectionName, value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Performs a delete for the specified . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The key value. - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteAsync(this IOData odata, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => odata.DeleteAsync(CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteAsync(this IOData odata, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => odata.DeleteAsync(null, key, cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The key value. - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteAsync(this IOData odata, string? collectionName, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => odata.DeleteAsync(collectionName, CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static async Task DeleteAsync(this IOData odata, string? collectionName, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => (await odata.DeleteWithResultAsync(new ODataArgs(odata.Args), collectionName, key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - #endregion - - #region WithResult - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The key value. - /// The . - /// The entity value where found; otherwise, null. - public static Task> GetWithResultAsync(this IOData odata, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.GetWithResultAsync(CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// The entity value where found; otherwise, null. - public static Task> GetWithResultAsync(this IOData odata, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.GetWithResultAsync(null, key, cancellationToken); - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The key value. - /// The . - /// The entity value where found; otherwise, null. - public static Task> GetWithResultAsync(this IOData odata, string? collectionName, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.GetWithResultAsync(collectionName, CompositeKey.Create(key), cancellationToken); - - /// - /// Gets the entity for the specified mapping from to with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The . - /// The . - /// The entity value where found; otherwise, null. - public static async Task> GetWithResultAsync(this IOData odata, string? collectionName, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => (await odata.GetWithResultAsync(new ODataArgs(odata.Args), collectionName, key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Performs a create for the value (reselects and/or automatically saves changes) with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static Task> CreateWithResultAsync(this IOData odata, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.CreateWithResultAsync(null, value, cancellationToken); - - /// - /// Performs a create for the value (reselects and/or automatically saves changes) with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static Task> CreateWithResultAsync(this IOData odata, string? collectionName, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.CreateWithResultAsync(new ODataArgs(odata.Args), collectionName, value, cancellationToken); - - /// - /// Performs an update for the value (reselects and/or automatically saves changes) with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static Task> UpdateWithResultAsync(this IOData odata, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.UpdateWithResultAsync(null, value, cancellationToken); - - /// - /// Performs an update for the value (reselects and/or automatically saves changes) with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The value to insert. - /// The . - /// The value (refreshed where specified). - public static Task> UpdateWithResultAsync(this IOData odata, string? collectionName, T value, CancellationToken cancellationToken = default) where T : class, IEntityKey, new() where TModel : class, new() - => odata.UpdateWithResultAsync(new ODataArgs(odata.Args), collectionName, value, cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The key value. - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteWithResultAsync(this IOData odata, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => odata.DeleteWithResultAsync(CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteWithResultAsync(this IOData odata, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => odata.DeleteWithResultAsync(null, key, cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The key value. - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteWithResultAsync(this IOData odata, string? collectionName, object? key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => odata.DeleteWithResultAsync(collectionName, CompositeKey.Create(key), cancellationToken); - - /// - /// Performs a delete for the specified with a . - /// - /// The resultant . - /// The entity framework model . - /// The . - /// The collection name. - /// The . - /// The . - /// Where the model implements then this will update the with true versus perform a physical deletion. - public static Task DeleteWithResultAsync(this IOData odata, string? collectionName, CompositeKey key, CancellationToken cancellationToken = default) where T : class, IEntityKey where TModel : class, new() - => odata.DeleteWithResultAsync(new ODataArgs(odata.Args), collectionName, key, cancellationToken); - - #endregion - - #region CreateItemCollection - - /// - /// Creates an untyped for the specified . - /// - /// The resultant . - /// The . - /// The . - /// The collection name. - /// The . - public static ODataItemCollection CreateItemCollection(this IOData odata, string collectionName) where T : class, new() where TMapper : IODataMapper, new() - => odata.CreateItemCollection(collectionName, new TMapper()); - - /// - /// Creates an untyped for the specified . - /// - /// The resultant . - /// The . - /// The . - /// The . - /// The collection name. - /// The . - public static ODataItemCollection CreateItemCollection(this IOData odata, ODataArgs args, string collectionName) where T : class, new() where TMapper : IODataMapper, new() - => odata.CreateItemCollection(args, collectionName, new TMapper()); - - /// - /// Creates an untyped for the specified . - /// - /// The resultant . - /// The . - /// The collection name. - /// The specific . - /// The . - public static ODataItemCollection CreateItemCollection(this IOData odata, string collectionName, IODataMapper mapper) where T : class, new() - => odata.CreateItemCollection(new ODataArgs(odata.Args), collectionName, mapper); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/ODataInvoker.cs b/src/CoreEx.OData/ODataInvoker.cs deleted file mode 100644 index 1457a4c8..00000000 --- a/src/CoreEx.OData/ODataInvoker.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; -using CoreEx.Results; -using System; -using System.Threading; -using System.Threading.Tasks; -using Soc = Simple.OData.Client; - -namespace CoreEx.OData -{ - /// - /// Provides the standard invoker functionality. - /// - public class ODataInvoker : InvokerBase - { - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, object invoker, Func func, IOData? args) - { - try - { - return base.OnInvoke(invokeArgs, invoker, func, args); - } - catch (Soc.WebRequestException odex) - { - var eresult = args!.HandleODataException(odex); - if (eresult.HasValue && eresult.Value.IsFailure && eresult.Value.Error is CoreEx.Abstractions.IExtendedException) - { - var dresult = default(TResult); - if (dresult is IResult dir) - return (TResult)dir.ToFailure(eresult.Value.Error); - else - eresult.Value.ThrowOnError(); - } - - throw; - } - } - - /// - protected async override Task OnInvokeAsync(InvokeArgs invokeArgs, object invoker, Func> func, IOData? args, CancellationToken cancellationToken) - { - try - { - return await base.OnInvokeAsync(invokeArgs, invoker, func, args, cancellationToken).ConfigureAwait(false); - } - catch (Soc.WebRequestException odex) - { - var eresult = args!.HandleODataException(odex); - if (eresult.HasValue && eresult.Value.IsFailure && eresult.Value.Error is CoreEx.Abstractions.IExtendedException) - { - var dresult = default(TResult); - if (dresult is IResult dir) - return (TResult)dir.ToFailure(eresult.Value.Error); - else - eresult.Value.ThrowOnError(); - } - - throw; - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/ODataItem.cs b/src/CoreEx.OData/ODataItem.cs deleted file mode 100644 index 9cbf11aa..00000000 --- a/src/CoreEx.OData/ODataItem.cs +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; -using CoreEx.OData.Mapping; -using System; -using System.Collections.Generic; - -namespace CoreEx.OData -{ - /// - /// Provides a untyped -based () OData item/entry. - /// - public class ODataItem - { - /// - /// Maps none or more into a corresponding . - /// - /// The OData items. - /// The . - public static IEnumerable MapODataItems(IEnumerable> items) - { - foreach (var item in items) - { - yield return new ODataItem(item); - } - } - - /// - /// Maps none or more into a corresponding using the specified . - /// - /// The resulting item . - /// The specific . - /// The OData items. - /// The singluar CRUD value being performed. - /// The . - public static IEnumerable MapODataItems(IODataMapper mapper, IEnumerable> items, OperationTypes operationType = OperationTypes.Unspecified) - { - foreach (var item in MapODataItems(items)) - { - yield return mapper.MapFromOData(item, operationType)!; - } - } - - /// - /// Creates an from the specified . - /// - /// The . - /// The specific . - /// The value. - /// The singluar CRUD value being performed. - /// The resultant value. - public static ODataItem MapFrom(IODataMapper mapper, T value, OperationTypes operationType = OperationTypes.Unspecified) - { - var result = new ODataItem(); - mapper.MapToOData(value, result, operationType); - return result; - } - - /// - /// Creates an from the specified . - /// - /// The . - /// The specific . - /// The value. - /// The singluar CRUD value being performed. - /// The resultant value. - public static ODataItem? MapFrom(IMapper mapper, T? value, OperationTypes operationType = OperationTypes.Unspecified) => mapper.Map(value, operationType); - - /// - /// Creates an from the specified . - /// - /// The . - /// The mapper. - /// The value. - /// The singluar CRUD value being performed. - /// The resultant value. - public static ODataItem? MapFrom(IMapper mapper, T? value, OperationTypes operationType = OperationTypes.Unspecified) => mapper.Map(value, operationType); - - /// - /// Creates an from the specified . - /// - /// The . - /// The mapper. - /// The value. - /// The singluar CRUD value being performed. - /// The resultant value. - public static ODataItem? MapFrom(ODataClient client, T? value, OperationTypes operationType = OperationTypes.Unspecified) => MapFrom(client.Mapper, value, operationType); - - /// - /// Initializes a new instance of the class. - /// - public ODataItem() => Attributes = new Dictionary(); - - /// - /// Initializes a new instance of the class with the specified . - /// - /// The . - public ODataItem(IDictionary attributes) => Attributes = attributes; - - /// - /// Gets the attributes/columns. - /// - public IDictionary Attributes { get; private set; } = new Dictionary(); - - /// - /// Gets or sets the value of the specified attribute. - /// - /// The name. - /// The value. - public object? this[string name] - { - get => Attributes[name]; - - set - { - if (Attributes.ContainsKey(name)) - Attributes[name] = value!; - else - Attributes.Add(name, value!); - } - } - - /// - /// Maps the to a . - /// - /// The resulting value . - /// The specific . - /// The singluar CRUD value being performed. - /// The resultant value. - public T MapTo(IODataMapper mapper, OperationTypes operationType = OperationTypes.Unspecified) where T : class, new() => mapper.MapFromOData(this, operationType)!; - - /// - /// Maps the to a . - /// - /// The resulting value . - /// The specific . - /// The singluar CRUD value being performed. - /// The resultant value. - public T MapTo(IMapper mapper, OperationTypes operationType = OperationTypes.Unspecified) => mapper.Map(this, operationType)!; - - /// - /// Maps the to a . - /// - /// The resulting value . - /// The . - /// The singluar CRUD value being performed. - /// The resultant value. - public T MapTo(IMapper mapper, OperationTypes operationType = OperationTypes.Unspecified) => mapper.Map(this, operationType)!; - - /// - /// Maps the to a . - /// - /// The resulting value . - /// The . - /// The singluar CRUD value being performed. - /// The resultant value. - public T MapTo(ODataClient client, OperationTypes operationType = OperationTypes.Unspecified) => MapTo(client.Mapper, operationType); - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/ODataItemCollection.cs b/src/CoreEx.OData/ODataItemCollection.cs deleted file mode 100644 index c0ef5206..00000000 --- a/src/CoreEx.OData/ODataItemCollection.cs +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.OData.Mapping; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using Soc = Simple.OData.Client; - -namespace CoreEx.OData -{ - /// - /// Enables the common -collection CRUD functionality. - /// - /// The resultant . - /// The owning . - /// The . - /// The collection name. - /// The specific . - public class ODataItemCollection(ODataClient client, ODataArgs args, string collectionName, IODataMapper mapper) where T : class, new() - { - /// - /// Initializes a new instance of the class. - /// - /// The owning . - /// The collection name. - /// The specific . - public ODataItemCollection(ODataClient client, string collectionName, IODataMapper mapper) : this(client, new ODataArgs(client.ThrowIfNull().Args), collectionName, mapper) { } - - /// - /// Gets the owning . - /// - public ODataClient Owner { get; } = client.ThrowIfNull(nameof(client)); - - /// - /// Gets the . - /// - public ODataArgs Args { get; } = args; - - /// - /// Gets the collection name. - /// - public string CollectionName { get; } = collectionName.ThrowIfNull(nameof(collectionName)); - - /// - /// Gets the . - /// - public IODataMapper Mapper { get; } = mapper.ThrowIfNull(nameof(mapper)); - - /// - /// Gets the entity for the specified mapping from the to . - /// - /// The key. - /// The entity value where found; otherwise, null. - public Task GetAsync(params object[] key) => GetAsync(key, default); - - /// - /// Gets the entity for the specified mapping from the to . - /// - /// The key. - /// The . - /// The entity value where found; otherwise, null. - public async Task GetAsync(object[] key, CancellationToken cancellationToken = default) => (await GetWithResultAsync(key, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Gets the entity for the specified mapping from the to with a . - /// - /// The key. - /// The entity value where found; otherwise, null. - public Task> GetWithResultAsync(params object[] key) => GetWithResultAsync(key, default); - - /// - /// Gets the entity for the specified mapping from the to with a . - /// - /// The key. - /// The . - /// The entity value where found; otherwise, null. - public async Task> GetWithResultAsync(object[] key, CancellationToken cancellationToken = default) => await Owner.Invoker.InvokeAsync(this, async (_, ct) => - { - return (await GetItemAsync(key, ct).ConfigureAwait(false)) - .WhenAs(entity => entity is null, _ => default!, entity => MapFromOData(entity!, OperationTypes.Get)); - }, Owner, cancellationToken); - - /// - /// Creates the entity with a mapping from/to an intermediary untyped . - /// - /// The value to update. - /// The . - /// The value (refreshed where specified). - public async Task CreateAsync(T value, CancellationToken cancellationToken = default) => (await CreateWithResultAsync(value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Creates the entity with a mapping from/to an intermediary untyped . - /// - /// The value to update. - /// The . - /// The value (refreshed where specified). - public async Task> CreateWithResultAsync(T value, CancellationToken cancellationToken = default) => await Owner.Invoker.InvokeAsync(this, async (_, ct) => - { - var item = ODataItem.MapFrom(Mapper, Cleaner.PrepareCreate(value.ThrowIfNull()), OperationTypes.Create); - Mapper.MapToOData(value, item, OperationTypes.Create); - var created = await Owner.Client.For(CollectionName).Set(item.Attributes).InsertEntryAsync(true, ct).ConfigureAwait(false); - return created is null ? Result.NotFoundError() : Result.Ok(MapFromOData(new ODataItem(created), OperationTypes.Get)); - }, Owner, cancellationToken); - - /// - /// Updates the entity with a mapping from/to an intermediary untyped . - /// - /// The value to update. - /// The . - /// The value (refreshed where specified). - public async Task UpdateAsync(T value, CancellationToken cancellationToken = default) => (await UpdateWithResultAsync(value, cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Updates the entity with a mapping from/to an intermediary untyped . - /// - /// The value to update. - /// The . - /// The value (refreshed where specified). - public async Task> UpdateWithResultAsync(T value, CancellationToken cancellationToken = default) => await Owner.Invoker.InvokeAsync(this, async (_, ct) => - { - ODataItem item; - var key = Mapper.GetODataKey(Cleaner.PrepareUpdate(value.ThrowIfNull()), OperationTypes.Update); - - if (Args.PreReadOnUpdate) - { - var get = (await GetItemAsync(key, ct).ConfigureAwait(false)) - .When(v => v is null, _ => Result.NotFoundError()); - - if (get.IsFailure) - return get.AsResult(); - - item = get.Value; - Mapper.MapToOData(value, get.Value, OperationTypes.Update); - } - else - item = ODataItem.MapFrom(Mapper, value, OperationTypes.Update); - - var updated = await Owner.Client.For(CollectionName).Key(key).Set(item.Attributes).UpdateEntryAsync(true, ct).ConfigureAwait(false); - return updated is null ? Result.NotFoundError() : Result.Ok(MapFromOData(new ODataItem(updated), OperationTypes.Get)); - }, Owner, cancellationToken); - - /// - /// Deletes the entity for the specified . - /// - /// The key. - public Task DeleteAsync(params object[] key) => DeleteAsync(key, default); - - /// - /// Deletes the entity for the specified . - /// - /// The key. - /// The . - public async Task DeleteAsync(object[] key, CancellationToken cancellationToken = default) => (await DeleteWithResultAsync(key, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - - /// - /// Deletes the entity for the specified . - /// - /// The key. - public Task DeleteWithResultAsync(params object[] key) => DeleteWithResultAsync(key, default); - - /// - /// Deletes the entity for the specified . - /// - /// The key. - /// The . - public async Task DeleteWithResultAsync(object[] key, CancellationToken cancellationToken = default) => await Owner.Invoker.InvokeAsync(this, async (_, ct) => - { - await Owner.Client.For(CollectionName).Key(key).DeleteEntryAsync(ct).ConfigureAwait(false); - return Result.Success; - }, Owner, cancellationToken); - - /// - /// Gets (reads) the entity. - /// - private async Task> GetItemAsync(object[] key, CancellationToken cancellationToken = default) - { - try - { - return Result.Go(await Owner.Client.For(CollectionName).Key(key).FindEntryAsync(cancellationToken).ConfigureAwait(false)) - .WhenAs, ODataItem?>(d => d is null, _ => Args.NullOnNotFound ? default! : Result.NotFoundError(), d => new ODataItem(d)); - } - catch (Soc.WebRequestException odex) when (odex.Code == HttpStatusCode.NotFound && Args.NullOnNotFound) { return default!; } - } - - /// - /// Maps from the to the value. - /// - /// The . - /// The singluar CRUD value being performed. - /// The value. - public T MapFromOData(ODataItem item, OperationTypes operationType = OperationTypes.Unspecified) => item.MapTo(Mapper, operationType); - - /// - /// Maps from the to the . - /// - /// The value. - /// The singluar CRUD value being performed. - /// The . - public ODataItem MapToOData(T value, OperationTypes operationType = OperationTypes.Unspecified) => ODataItem.MapFrom(Mapper, value.ThrowIfNull(), operationType); - - /// - /// Invokes a function (wrapping with the underlying ). - /// - /// The customized untyped function. - /// The . - public Task InvokeAsync(Func>, Task> clientFunc, CancellationToken cancellationToken = default) - => InvokeWithResultAsync(async client => { await clientFunc(client).ConfigureAwait(false); return Result.Success; }, cancellationToken); - - /// - /// Invokes a function (wrapping with the underlying ) returning a value with a of - /// - /// The returning value . - /// The customized untyped function. - /// The . - /// The value. - public async Task InvokeAsync(Func>, Task> clientFunc, CancellationToken cancellationToken = default) - => (await InvokeWithResultAsync(async client => { var v = await clientFunc(client).ConfigureAwait(false); return Result.Ok(v); }, cancellationToken)).Value; - - /// - /// Invokes a function (wrapping with the underlying ). - /// - /// The customized untyped function. - /// The . - public async Task InvokeWithResultAsync(Func>, Task> clientFunc, CancellationToken cancellationToken = default) => await Owner.Invoker.InvokeAsync(this, async (_, ct) => - { - var client = Owner.Client.For(CollectionName); - return await clientFunc(client).ConfigureAwait(false); - }, Owner, cancellationToken); - - /// - /// Invokes a function (wrapping with the underlying ) returning a value with a of - /// - /// The returning value . - /// The customized untyped function. - /// The . - /// The value. - public async Task> InvokeWithResultAsync(Func>, Task>> clientFunc, CancellationToken cancellationToken = default) => await Owner.Invoker.InvokeAsync(this, async (_, ct) => - { - var client = Owner.Client.For(CollectionName); - return await clientFunc(client).ConfigureAwait(false); - }, Owner, cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/ODataQuery.cs b/src/CoreEx.OData/ODataQuery.cs deleted file mode 100644 index 43669b85..00000000 --- a/src/CoreEx.OData/ODataQuery.cs +++ /dev/null @@ -1,277 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Soc = Simple.OData.Client; - -namespace CoreEx.OData -{ - /// - /// Encapsulates an OData query enabling select-like capabilities. - /// - /// The entity . - /// The OData model . - public struct ODataQuery where T : class, new() where TModel : class, new() - { - private readonly Func, Soc.IBoundClient>? _query; - - /// - /// Initializes a new instance of the struct. - /// - /// The . - /// The . - /// The collection name. - /// A function to modify the underlying query. - /// - internal ODataQuery(IOData odata, ODataArgs args, string? collectionName, Func, Soc.IBoundClient>? query) - { - ODataClient = odata.ThrowIfNull(nameof(odata)); - Args = args; - CollectionName = collectionName; - _query = query; - } - - /// - /// Gets the . - /// - public IOData ODataClient { get; } - - /// - /// Gets the . - /// - public ODataArgs Args { get; } - - /// - /// Gets the optional collection name override. - /// - public string? CollectionName { get; } - - /// - /// Gets the . - /// - public readonly IMapper Mapper => ODataClient.Mapper; - - /// - /// Gets the . - /// - public PagingResult? Paging { get; private set; } - - /// - /// Adds to the query. - /// - /// The . - /// The to suport fluent-style method-chaining. - public ODataQuery WithPaging(PagingArgs? paging) - { - Paging = paging == null ? null : (paging is PagingResult pr ? pr : new PagingResult(paging)); - return this; - } - - /// - /// Adds to the query. - /// - /// The specified number of elements in a sequence to bypass. - /// The specified number of contiguous elements from the start of a sequence. - /// The to suport fluent-style method-chaining. - public ODataQuery WithPaging(long skip, long? take = null) => WithPaging(PagingArgs.CreateSkipAndTake(skip, take)); - - /// - /// Manages the underlying query construction and lifetime. - /// - private async readonly Task> ExecuteQueryAsync(Func, CancellationToken, Task> executeAsync, string memeberName, CancellationToken cancellationToken) - => await ODataClient.Invoker.InvokeAsync(ODataClient, ODataClient, CollectionName, _query, async (args, odata, name, query, ct) => - { - var q = odata.Client.For(name); - return await executeAsync((query == null) ? q : query(q), ct).ConfigureAwait(false); - }, ODataClient, cancellationToken, memeberName); - - /// - /// Executes the query and maps. - /// - private async readonly Task> ExecuteQueryAndMapAsync(Func, CancellationToken, Task> executeAsync, string memeberName, CancellationToken cancellationToken) - { - var result = await ExecuteQueryAsync(executeAsync, memeberName, cancellationToken).ConfigureAwait(false); - if (result.IsFailure) - return result.AsResult(); - - var val = result.Value == null ? default! : Mapper.Map(result.Value, OperationTypes.Get); - return Args.CleanUpResult ? Cleaner.Clean(val) : val; - } - - /// - /// Cleans up the result where specified within the args. - /// - private readonly T CleanUpResult(T value) => Args.CleanUpResult ? Cleaner.Clean(value) : value; - - /// - /// Selects a single item. - /// - /// The single item. - public async readonly Task SelectSingleAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => (await q.Skip(0).Top(2).FindEntriesAsync(ct).ConfigureAwait(false)).Single(), nameof(SelectSingleAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects a single item with a . - /// - /// The single item. - public async readonly Task> SelectSingleWithResultAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => (await q.Skip(0).Top(2).FindEntriesAsync(ct).ConfigureAwait(false)).Single(), nameof(SelectSingleWithResultAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects a single item or default. - /// - /// The single item or default. - public async readonly Task SelectSingleOrDefaultAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => (await q.Skip(0).Top(2).FindEntriesAsync(ct).ConfigureAwait(false)).SingleOrDefault()!, nameof(SelectSingleOrDefaultAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects a single item or default with a . - /// - /// The single item or default. - public async readonly Task> SelectSingleOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => (await q.Skip(0).Top(2).FindEntriesAsync(ct).ConfigureAwait(false)).SingleOrDefault()!, nameof(SelectSingleOrDefaultWithResultAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects first item. - /// - /// The first item. - public async readonly Task SelectFirstAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => (await q.Skip(0).Top(2).FindEntriesAsync(ct).ConfigureAwait(false)).First(), nameof(SelectFirstAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects first item with a . - /// - /// The first item. - public async readonly Task> SelectFirstWithResultAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => (await q.Skip(0).Top(2).FindEntriesAsync(ct).ConfigureAwait(false)).First(), nameof(SelectFirstWithResultAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects first item or default. - /// - /// The first item or default. - public async readonly Task SelectFirstOrDefaultAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => (await q.Skip(0).Top(2).FindEntriesAsync(ct).ConfigureAwait(false)).FirstOrDefault()!, nameof(SelectFirstOrDefaultAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Selects first item or default with a . - /// - /// The first item or default. - public async readonly Task> SelectFirstOrDefaultWithResultAsync(CancellationToken cancellationToken = default) - => (await ExecuteQueryAndMapAsync(async (q, ct) => (await q.Skip(0).Top(2).FindEntriesAsync(ct).ConfigureAwait(false)).FirstOrDefault()!, nameof(SelectFirstOrDefaultWithResultAsync), cancellationToken).ConfigureAwait(false))!; - - /// - /// Executes the query command creating a . - /// - /// The . - /// The . - /// The . - /// The resulting . - public async readonly Task SelectResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult - { - Paging = Paging, - Items = (await SelectQueryWithResultInternalAsync(nameof(SelectResultAsync), cancellationToken).ConfigureAwait(false)).Value - }; - - /// - /// Executes the query command creating a with a . - /// - /// The . - /// The . - /// The . - /// The resulting . - public async readonly Task> SelectResultWithResultAsync(CancellationToken cancellationToken = default) where TCollResult : ICollectionResult, new() where TColl : ICollection, new() => new TCollResult - { - Paging = Paging, - Items = (await SelectQueryWithResultInternalAsync(nameof(SelectResultWithResultAsync), cancellationToken).ConfigureAwait(false)).Value - }; - - /// - /// Executes the query command creating a resultant collection. - /// - /// The collection . - /// A resultant collection. - public async readonly Task SelectQueryAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() - => (await SelectQueryWithResultInternalAsync(nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Executes the query command creating a resultant collection with a . - /// - /// The collection . - /// A resultant collection. - public readonly Task> SelectQueryWithResultAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() - => SelectQueryWithResultInternalAsync(nameof(SelectQueryWithResultAsync), cancellationToken); - - /// - /// Executes a query adding to the passed collection. - /// - /// The collection . - /// The . - /// The collection to add items to. - /// The . - public async readonly Task SelectQueryAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection - => (await SelectQueryWithResultInternalAsync(collection, nameof(SelectQueryAsync), cancellationToken).ConfigureAwait(false)).Value; - - /// - /// Executes a query adding to the passed collection with a . - /// - /// The collection . - /// The . - /// The collection to add items to. - /// The . - public readonly Task> SelectQueryWithResultAsync(TColl collection, CancellationToken cancellationToken = default) where TColl : ICollection - => SelectQueryWithResultInternalAsync(collection, nameof(SelectQueryWithResultAsync), cancellationToken); - - /// - /// Executes the query command creating a resultant collection with a internal. - /// - private async readonly Task> SelectQueryWithResultInternalAsync(string memberName, CancellationToken cancellationToken) where TColl : ICollection, new() - { - var coll = new TColl(); - return await SelectQueryWithResultInternalAsync(coll, memberName, cancellationToken).ConfigureAwait(false); - } - - /// - /// Executes a query adding to the passed collection with a internal. - /// - private async readonly Task> SelectQueryWithResultInternalAsync(TColl collection, string memberName, CancellationToken cancellationToken) where TColl : ICollection - { - collection.ThrowIfNull(nameof(collection)); - - var paging = Paging; - var mapper = Mapper; - var args = Args; - - return await ExecuteQueryAsync(async (q, ct) => - { - Soc.ODataFeedAnnotations ann = null!; - - if (paging is not null) - { - if (paging.Option == PagingOption.TokenAndTake) - throw new InvalidOperationException("PagingOption.TokenAndTake is not supported for OData."); - - q = q.Skip(paging.Skip!.Value).Top(paging.Take); - if (paging.IsGetCount && args.IsPagingGetCountSupported) - ann = new Soc.ODataFeedAnnotations(); - } - - foreach (var item in await (ann is null ? q.FindEntriesAsync(ct) : q.FindEntriesAsync(ann, ct)).ConfigureAwait(false)) - { - var val = mapper.Map(item, OperationTypes.Get) ?? throw new InvalidOperationException("Mapping from the ODATA model must not result in a null value."); - collection.Add(args.CleanUpResult ? Cleaner.Clean(val) : val); - } - - if (ann != null) - paging!.TotalCount = ann.Count; - - return Result.Ok(collection); - }, memberName, cancellationToken).ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.OData/README.md b/src/CoreEx.OData/README.md deleted file mode 100644 index d9c3013a..00000000 --- a/src/CoreEx.OData/README.md +++ /dev/null @@ -1,324 +0,0 @@ -# CoreEx - -The `CoreEx.OData` namespace provides extended [_OData v4_](https://en.wikipedia.org/wiki/Open_Data_Protocol) support leveraging the [`Simple.OData.Client`](https://github.com/simple-odata-client/Simple.OData.Client/wiki) open-source capabilities. - -
- -## Motivation - -The motivation is to simplify and unify the approach to _OData_ access. The [`Simple.OData.Client`](https://github.com/simple-odata-client/Simple.OData.Client/wiki) provides, as the name implies, a simple and easy to use _OData_ client. - -
- -## ODataClient - -The [`ODataClient`](./ODataClient.cs) class is a wrapper around the [`Simple.OData.Client.ODataClient`](https://github.com/simple-odata-client/Simple.OData.Client/wiki/Getting-started-with-Simple.OData.Client). Yes, they have the same name; sadly, naming stuff is [hard](https://martinfowler.com/bliki/TwoHardThings.html). - -The [`ODataClient`](./ODataClient.cs) is the base (common) implementation for the [`IOData`](./IOData.cs) interface that provides the standardized access to the underlying endpoint. For typed access an [IMapper](../CoreEx/Mapping/IMapper.cs) that contains the mapping logic to map to and from the **entity** and underlying **model** ia required. - -
- -## Requirements - -The requirements for usage are as follows. -- An **entity** (DTO) that represents the data that must as a minimum implement [`IEntityKey`](../CoreEx/Entities/IEntityKey.cs); generally via either the implementation of [`IIdentifier`](../CoreEx/Entities/IIdentifierT.cs) or [`IPrimaryKey`](../CoreEx/Entities/IPrimaryKey.cs). -- A **model** being the underlying configured JSON-serializable representation of the data source model. -- An [`IMapper`](../CoreEx/Mapping/IMapper.cs) that contains the mapping logic to map to and from the **entity** and **model**. - -The **entity** and **model** are different types to encourage separation between the externalized **entity** representation and the underlying **model**; which may be shaped differently, and have different property to column naming conventions, etc. - -Additionally, untyped **model** access is also supported via an [`ODataItem`](./ODataItem.cs) (dictionary-based representation) and the [`ODataItemCollection`](./ODataItemCollection.cs), a CRUD-enabler for untyped. - -
- -## CRUD capabilities - -The [`IOData`](./IOData.cs) and corresponding [`ODataClient`](./ODataClient.cs) provide the base CRUD capabilities as follows: - -
- -### Query (read) - -A query is actioned using the [`ODataQuery`](./ODataQuery.cs) which is ostensibly a lighweight wrapper over an `IBoundClient`(https://github.com/simple-odata-client/Simple.OData.Client/blob/master/src/Simple.OData.Client.Core/Fluent/IBoundClient.cs) that automatically maps from the **model** to the **entity**. - -The following methods provide additional capabilities: - -Method | Description --|- -`WithPaging` | Adds `Skip` and `Take` paging to the query. -`SelectSingleAsync`, `SelectSingleWithResult` | Selects a single item. -`SelectSingleOrDefaultAsync`, `SelectSingleOrDefaultWithResultAsync` | Selects a single item or default. -`SelectFirstAsync`, `SelectFirstWithResultAsync` | Selects first item. -`SelectFirstOrDefaultAsync`, `SelectFirstOrDefaultWithResultAsync` | Selects first item or default. -`SelectQueryAsync`, `SelectQueryWithResultAsync` | Select items into or creating a resultant collection. -`SelectResultAsync`, `SelectResultWithResultAsync` | Select items creating a [`ICollectionResult`](../CoreEx/Entities/ICollectionResultT2.cs) which also contains corresponding [`PagingResult`](../CoreEx/Entities/PagingResult.cs). - -
- -### Get (read) - -Gets (`GetAsync` or `GetWithResultAsync`) the **entity** for the specified key mapping from the **model**. Uses [`Simple.OData.Client`](https://github.com/simple-odata-client/Simple.OData.Client/wiki/Retrieving-a-single-row-by-key) internally to get the **model** using the specified key. - -
- -### Create - -Creates (`CreateAsync` or `CreateWithResultAsync`) the **entity** by firstly mapping to the **model**. Uses [`Simple.OData.Client`](https://github.com/simple-odata-client/Simple.OData.Client/wiki/Adding-entries) to insert. - -Where the **entity** implements [`IChangeLogAuditLog`](../CoreEx/Entities/IChangeLogAuditLog.cs) generally via [`ChangeLog`](../CoreEx/Entities/IChangeLog.cs) or [`ChangeLogEx`](../CoreEx/Entities/Extended/IChangeLogEx.cs), then the `CreatedBy` and `CreatedDate` properties will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -Where the **entity** and/or **model** implements [`ITenantId`](../CoreEx/Entities/ITenantId.cs) then the `TenantId` property will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -
- -### Update - -Updates (`UpdateAsync` or `UpdateWithResultAsync`) the **entity** by firstly mapping to the **model**. Uses [`Simple.OData.Client`](https://github.com/simple-odata-client/Simple.OData.Client/wiki/Updating-entries) to update. - -Where the **entity** implements [`IChangeLogAuditLog`](../CoreEx/Entities/IChangeLogAuditLog.cs) generally via [`ChangeLog`](../CoreEx/Entities/IChangeLog.cs) or [`ChangeLogEx`](../CoreEx/Entities/Extended/IChangeLogEx.cs), then the `UpdatedBy` and `UpdatedDate` properties will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -Where the **entity** and/or **model** implements [`ITenantId`](../CoreEx/Entities/ITenantId.cs) then the `TenantId` property will be automatically set from the [`ExecutionContext`](../CoreEx/ExecutionContext.cs). - -
- -### Delete - -Deletes (`DeleteAsync` or `DeleteWithResultAsync`) the **entity**. Uses [`Simple.OData.Client`](https://github.com/simple-odata-client/Simple.OData.Client/wiki/Deleting-entries) to delete. - -
- -## Untyped - -Untyped refers to the support of a **model** that is not defined as a type at compile time; is from a `Simple.OData.Client.ODataClient` perspective a `IDictionary`. The [`ODataItem`](./ODataItem.cs) encapsulates the dictionary-based representation with the corresponding [`ODataItemCollection`](./ODataItemCollection.cs) enabling CRUD operations. - -The [`ODataMapper`](./Mapping/ODataMapperT.cs) provides the mapping logic to map to and from the **entity** and untyped **model** dictionary. The following demonstrates: - -``` csharp -public class CustomerToDataverseAccountMapper : ODataMapper -{ - public CustomerToDataverseAccountMapper() - { - Property(c => c.AccountId, "accountid", OperationTypes.AnyExceptCreate).SetPrimaryKey(); - Property(x => x.FirstName, "firstname"); - Property(x => x.LastName, "lastname"); - } -} -``` - -
- -## Usage - -To use the [`Simple.OData.Client.ODataClient`](https://github.com/simple-odata-client/Simple.OData.Client/wiki/Getting-started-with-Simple.OData.Client) must first be instantiated, then passed to the [`ODataClient`](./ODataClient.cs) constructor including a reference to the [`IMapper`](../CoreEx/Mapping/IMapper.cs). - -The following will demonstrate the usage connecting to [_Microsoft Dataverse Web API_](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/overview) within the context of solution leveraging the _CoreEx_ library and dependency injection (DI). - -
- -### Settings - -The _Dataverse_ connection string settings are required: - -``` csharp -public class DemoSettings : SettingsBase -{ - private readonly string UrlKey = "Url"; - private readonly string ClientIdKey = "ClientId"; - private readonly string CiientSecretKey = "ClientSecret"; - private readonly string TenantIdKey = "TenantId"; - - /// - /// Initializes a new instance of the class. - /// - /// The . - public DemoSettings(IConfiguration configuration) : base(configuration, "Demo") { } - - /// - /// Gets the Dataverse connection string. - /// - public string DataverseConnectionString => GetRequiredValue("ConnectionStrings:Dataverse"); - - /// - /// Gets the from the . - /// - public DataverseSettings DataverseConnectionSettings - { - get - { - var cs = DataverseConnectionString.Split(';').Select(s => s.Split('=')).ToDictionary(s => s[0].Trim(), s => s[1].Trim(), StringComparer.OrdinalIgnoreCase); - if (!cs.TryGetValue(UrlKey, out var url)) throw new InvalidOperationException($"The connection string is missing the '{UrlKey}' key."); - if (!cs.TryGetValue(ClientIdKey, out var clientId)) throw new InvalidOperationException($"The connection string is missing the '{ClientIdKey}' key."); - if (!cs.TryGetValue(CiientSecretKey, out var clientSecret)) throw new InvalidOperationException($"The connection string is missing the '{CiientSecretKey}' key."); - if (!cs.TryGetValue(TenantIdKey, out var tenantId)) throw new InvalidOperationException($"The connection string is missing the '{TenantIdKey}' key."); - return new DataverseSettings(url, clientId, clientSecret, tenantId); - } - } - - /// - /// Gets the Dataverse OData endpoint from the . - /// - public Uri DataverseODataEndpoint => new(DataverseConnectionSettings.Address, "/api/data/v9.2/"); - - /// - /// Represents the resuluting . - /// - public class DataverseSettings - { - /// - /// Initializes a new instance of the class. - /// - internal DataverseSettings(string url, string clientId, string clientSecret, string tenantId) - { - Address = new Uri(url); - ClientId = clientId; - ClientSecret = clientSecret; - TenantId = tenantId; - } - - /// - /// Gets the address . - /// - public Uri Address { get; } - - /// - /// Gets the client identifier. - /// - public string ClientId { get; } - - /// - /// Gets the client secret. - /// - public string ClientSecret { get; } - - /// - /// Gets the tenant identifier. - /// - public string TenantId { get; } - } -} -``` - -The _Dataverse_ connection string is stored in the `appsettings.json` file: - -``` json -{ - "ConnectionStrings": { - "Dataverse": "Url=https://.crm.dynamics.com;ClientId=;ClientSecret=;TenantId=" - } -} -``` - -
- -### Authentication - -The _Dataverse_ authentication is handled by leveraging a [`DelegatingHandler`](https://learn.microsoft.com/en-us/aspnet/web-api/overview/advanced/http-message-handlers) to perform the authentication (uses [MSAL.NET](https://learn.microsoft.com/en-us/entra/msal/dotnet/getting-started/instantiate-confidential-client-config-options)), cache the token, and add the `Authorization` header to each request. - -``` csharp -public class DataverseAuthenticationHandler : DelegatingHandler -{ - private readonly SemaphoreSlim _semaphore = new(1, 1); - private readonly SyncSettings _settings; - private AuthenticationResult? _authResult; - - /// - /// Initializes a new instance of the class. - /// - /// The . - public DataverseAuthenticationHandler(SyncSettings settings) => _settings = settings.ThrowIfNull(); - - /// - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - // Verify and renew token if needed. - await VerifyAndRenewTokenAsync(cancellationToken).ConfigureAwait(false); - - // Set the authorization bearer token. - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult!.AccessToken); - - // Honor the commitment to keep calling down the chain. - return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - } - - /// - /// Verifies and renews the token if needed: first time, has expired, or will expire in less than 5 minutes - then get token and cache for improved performance. - /// - private async Task VerifyAndRenewTokenAsync(CancellationToken cancellationToken) - { - // First time, has expired, or will expire in less than 5 minutes, then get token - token cached for performance. - var expiryLimit = DateTimeOffset.UtcNow.AddMinutes(5); - if (_authResult == null || _authResult.ExpiresOn <= expiryLimit) - { - await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - // Recheck in case another thread has already renewed the token. - if (_authResult == null || _authResult.ExpiresOn <= expiryLimit) - { - var dcs = _settings.DataverseConnectionSettings; - var authority = new Uri($"https://login.microsoftonline.com/{dcs.TenantId}"); - - var app = ConfidentialClientApplicationBuilder - .Create(dcs.ClientId) - .WithClientSecret(dcs.ClientSecret) - .WithAuthority(authority) - .Build(); - - var scopes = new List { new Uri(dcs.Address, "/.default").AbsoluteUri }; - _authResult = await app.AcquireTokenForClient(scopes).ExecuteAsync(cancellationToken).ConfigureAwait(false); - } - } - finally - { - _semaphore.Release(); - } - } - } -} -``` - -
- -### Dataverse Client - -Extend to the [`ODataClient`](./ODataClient.cs) to provide the specific _Dataverse_ implementation. The [`ODataArgs`](./ODataArgs.cs) can be used to configure the `ODataClient` to derive specific behavior where applicable. - -``` csharp -using Soc = Simple.OData.Client; - -public class DataverseClient : ODataClient -{ - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - public DataverseClient(Soc.ODataClient client, IMapper mapper) : base(client, mapper) - { - Args = new ODataArgs { PreReadOnUpdate = false }; - } -} -``` - -### Registration - -At application start up the dependency injection (DI) needs to be configured; comprised of the following: -- Register the `DataverseClient` as scoped; assumes that the `IMapper` has also been configured. -- Register the `Soc.ODataClient` as scoped; instantiates a new `Soc.ODataClientSettings` with the _named_ `HttpClient`. -- Register the `DataverseAuthenticationHandler` as a singleton; to ensure the underlying token is used for all requests. -- Register the `HttpClient` with a name of `"dataverse"`; also configured with the `DataverseAuthenticationHandler`. - -``` csharp -// Configure the Dataverse required services. -Services - .AddScoped() - .AddScoped(sp => - { - var hc = sp.GetRequiredService().CreateClient("dataverse"); - var socs = new Soc.ODataClientSettings(hc); - return new Soc.ODataClient(socs); - }) - .AddSingleton() // Singleton to ensure the underlying token is reused. - .AddHttpClient("dataverse", (sp, client) => client.BaseAddress = sp.GetRequiredService().DataverseODataEndpoint) - .AddHttpMessageHandler(); -``` \ No newline at end of file diff --git a/src/CoreEx.OData/strong-name-key.snk b/src/CoreEx.OData/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs b/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs new file mode 100644 index 00000000..509b7f91 --- /dev/null +++ b/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs @@ -0,0 +1,325 @@ +namespace CoreEx.RefData.Abstractions; + +/// +/// Represents the core implementation. +/// +/// The . +/// The . +/// This class leverages dictionaries internally to manage the items and as such there is no implied order when using the likes of the ; use +/// to achieve desired ordering where applicable. +/// The and must be both unique. +public abstract class ReferenceDataCollectionCore : IReferenceDataCollection, ICollection where TRef : class, IReferenceData +{ +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private readonly ConcurrentDictionary _rdcId = new(); + private readonly ConcurrentDictionary _rdcCode; + private Dictionary<(string, object?), TRef>? _mappingsDict; + + /// + /// Initializes a new instance of the class. + /// + /// The default for the collection. Defaults to . + /// The for comparisons. Defaults to . + internal ReferenceDataCollectionCore(ReferenceDataSortOrder sortOrder = ReferenceDataSortOrder.SortOrder, StringComparer? codeComparer = null) + { + SortOrder = sortOrder; + _rdcCode = new ConcurrentDictionary(codeComparer ?? StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets the default used by . + /// + [JsonIgnore] + public ReferenceDataSortOrder SortOrder { get; } + + /// + /// Gets the item for the specified . + /// + /// The . + /// The item where found; otherwise, . + public TRef? this[string code] => _rdcCode[code]; + + /// + public void Clear() + { + lock (_lock) + { + _rdcId.Clear(); + _rdcCode.Clear(); + _mappingsDict?.Clear(); + } + } + + /// + /// The underlying are included during add; if they are maintained (see ) after these will not be included. + public void Add(TRef item) + { + item.ThrowIfNull(); + item.Id.ThrowIfNull(); + item.Code.ThrowIfNullOrEmpty(); + + lock (_lock) + { + if (_rdcId.Values.Contains(item)) + throw new ArgumentException($"Item already exists within the collection.", nameof(item)); + + if (_rdcId.ContainsKey(item.Id)) + throw new ArgumentException($"Item with Id '{item.Id}' already exists within the collection.", nameof(item)); + + if (_rdcCode.ContainsKey(item.Code)) + throw new ArgumentException($"Item with Code '{item.Code!}' already exists within the collection.", nameof(item)); + + if (item.HasMappings) + { + _mappingsDict ??= []; + + // Make sure there are no duplicates. + foreach (var map in item.Mappings!) + { + if (_mappingsDict.ContainsKey((map.Key, map.Value))) + throw new ArgumentException($"Item with Mapping Key '{map.Key}' and Value '{map.Value}' already exists within the collection."); + } + + // Now add 'em in. + foreach (var map in item.Mappings) + { + _mappingsDict.Add((map.Key, map.Value), item); + } + } + + // Add to the underlying dictionaries. + _rdcId.TryAdd(item.Id, item); + _rdcCode.TryAdd(item.Code, item); + } + } + + /// + /// Adds items from the . + /// + /// The source . + public void AddRange(IEnumerable source) + { + if (source is null) + return; + + foreach (var item in source) + { + Add(item); + } + } + + /// + /// Adds items from the asynchronously. + /// + /// The source . + /// The . + /// + public async Task AddRangeAsync(IQueryable source, CancellationToken cancellationToken = default) + { + if (source is IAsyncEnumerable ae) + await AddRangeAsync(ae, cancellationToken).ConfigureAwait(false); + + AddRange(source); + } + + /// + /// Adds items from the asynchronously. + /// + /// The source . + /// The . + public async Task AddRangeAsync(IAsyncEnumerable? source, CancellationToken cancellationToken = default) + { + if (source is not null) + { + await foreach (TRef item in source.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + Add(item); + } + } + } + + /// + public bool ContainsId(TId id) => _rdcId.ContainsKey(id.ThrowIfNull()); + + /// + public bool TryGetById(TId id, [NotNullWhen(true)] out TRef? item) + { + if (id is not null) + return _rdcId.TryGetValue(id, out item); + + item = default; + return false; + } + + /// + public TRef? GetById(TId id) => id is null ? default : _rdcId[id]; + + /// + public bool ContainsCode(string code) => _rdcCode.ContainsKey(code); + + /// + public bool TryGetByCode(string code, [NotNullWhen(true)] out TRef? item) + { + if (code is not null) + return _rdcCode.TryGetValue(code, out item); + + item = default; + return false; + } + + /// + public TRef? GetByCode(string code) => code is null ? default : _rdcCode[code]; + + /// + public bool ContainsMapping(string name, T value) where T : IComparable, IEquatable => _mappingsDict is not null && _mappingsDict.ContainsKey((name, value)); + + /// + bool IReferenceDataCollection.TryGetByMapping(string name, T value, [NotNullWhen(true)] out IReferenceData? item) + { + var r = TryGetByMapping(name, value, out TRef? itemx); + item = itemx; + return r; + } + + /// + public bool TryGetByMapping(string name, T value, [NotNullWhen(true)] out TRef? item) where T : IComparable, IEquatable + { + if (_mappingsDict is not null) + return _mappingsDict.TryGetValue((name, value), out item); + + item = default; + return false; + } + + /// + public TRef? GetByMapping(string name, T value) where T : IComparable, IEquatable => TryGetByMapping(name, value, out var item) ? item : default; + + /// + [JsonIgnore] + IEnumerable IReferenceDataCollection.AllItems => AllList; + + /// + [JsonIgnore] + IEnumerable IReferenceDataCollection.ActiveItems => ActiveList; + + /// + /// Gets a list of all items (excluding where not ) sorted by the value. + /// + /// An containing the selected items. + /// This is provided as a property to more easily support binding; it encapsulates the following method invocation: (SortOrder, null, true); + [JsonIgnore] + public IList AllList => GetItems(SortOrder, null, true); + + /// + /// Gets a list of all active ( and ) items sorted by the value. + /// + /// An containing the selected items. + /// This is provided as a property to more easily support binding; it encapsulates the following method invocation: (SortOrder, true, null); + [JsonIgnore] + public IList ActiveList => GetItems(SortOrder, true, null); + + /// + /// Gets a list of items from the collection using the specified criteria. + /// + /// Defines the ; indicates to use the defined . + /// Indicates whether the list should include values with the same value; otherwise, indicates all. + /// Indicates whether the list should include values with the same value; otherwise, indicates all. + /// This is leveraged by and . Where both the and are provided they are treated like a logical AND. + public List GetItems(ReferenceDataSortOrder? sortOrder = null, bool? isActive = null, bool? isValid = true) + { + if (_rdcId.IsEmpty) + return []; + + var list = from rd in _rdcId.Values select rd; + if (isActive is not null) + list = list.Where(item => isActive.Value ? IsItemActive(item) : !IsItemActive(item)); + + if (isValid is not null) + list = list.Where(item => isValid.Value ? IsItemValid(item) : !IsItemValid(item)); + + list = (sortOrder ?? SortOrder) switch + { + ReferenceDataSortOrder.Id => list.OrderBy(x => x.Id), + ReferenceDataSortOrder.Code => list.OrderBy(x => x.Code), + ReferenceDataSortOrder.Text => list.OrderBy(x => x.Text).ThenBy(x => x.Code), + _ => list.OrderBy(x => x.SortOrder).ThenBy(x => x.Text).ThenBy(x => x.Code) + }; + + return [.. list]; + } + + /// + /// Determines whether the is considered active and therefore accessible from within the collection. + /// + /// The item to validate. + /// indicates active; otherwise, . + /// By default checks . + protected virtual bool IsItemActive(TRef item) => !item.IsInactive; + + /// + /// Determines whether the is considered valid and therefore accessible from within the collection. + /// + /// The item to validate. + /// indicates valid; otherwise, . + /// By default checks . + protected virtual bool IsItemValid(TRef item) => item.IsValid; + + #region ICollection + + /// + [JsonIgnore] + public ICollection Keys => throw new NotSupportedException(); + + /// + [JsonIgnore] + public ICollection Values => throw new NotSupportedException(); + + /// + [JsonIgnore] + public int Count => _rdcId.Count; + + /// + [JsonIgnore] + bool ICollection.IsReadOnly => false; + + /// + [JsonIgnore] + public bool IsSynchronized => false; + + /// + [JsonIgnore] + public object SyncRoot => throw new NotImplementedException(); + + /// + public bool Contains(TRef item) => _rdcId.Values.Contains(item); + + /// + void ICollection.CopyTo(TRef[] array, int arrayIndex) => throw new NotSupportedException(); + + /// + bool ICollection.Remove(TRef item) => throw new NotSupportedException(); + + /// + /// Only items that are are enumerated. There is no implied sort order; use for sorted lists. + public IEnumerator GetEnumerator() + { + foreach (TRef item in _rdcId.Values) + { + if (IsItemValid(item)) + yield return item; + } + } + + /// + /// Only items that are are enumerated. There is no implied sort order; use for sorted lists. + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public void CopyTo(Array array, int index) => throw new NotSupportedException(); + + #endregion +} \ No newline at end of file diff --git a/src/CoreEx.RefData/Abstractions/ReferenceDataCore.cs b/src/CoreEx.RefData/Abstractions/ReferenceDataCore.cs new file mode 100644 index 00000000..eec51d4e --- /dev/null +++ b/src/CoreEx.RefData/Abstractions/ReferenceDataCore.cs @@ -0,0 +1,145 @@ +namespace CoreEx.RefData.Abstractions; + +/// +/// Represents the core implementation. +/// +/// The . +/// The and overrides only use the and properties. The other properties are considered +/// superfluous from an equality perspective. The and properties should be both unique within their owning collection; the +/// ensures this. +[DebuggerDisplay("Id = {Id}, Code = {Code}, IsActive = {IsActive}, IsValid = {IsValid}")] +public abstract class ReferenceDataCore : IReferenceData +{ + private string? _text; + private string? _description; + private bool _isActive = true; + private bool _isValid = true; + private ConcurrentDictionary? _mappings; + + /// + /// Initializes a new instance of the class. + /// + internal ReferenceDataCore() => Id = default!; + + /// + object? IReferenceData.Id { get => Id; init => Id = (TId)value!; } + + /// + [JsonPropertyOrder(-999)] + public TId Id { get; init; } + + /// + [JsonPropertyOrder(-998)] + public string? Code { get; init; } + + /// + /// The text is localized on get using an . The is automatically set to the + '.' + ; eg. 'Contoso.Products.Contracts.Brand.YETI'. + /// Use to get the original (non-localized) text. + [JsonPropertyOrder(-997)] + public string? Text { get => new LText($"{GetType().FullName}:{Code ?? "?"}", _text).ToString(); init => _text = value; } + + /// + /// The description is localized on get using an . The is automatically set to the + '.' + + 'Description'; eg. 'Contoso.Products.Contracts.Brand.YETI.Description'. + /// Use to get the original (non-localized) description. + [JsonPropertyOrder(-996)] + public string? Description { get => new LText($"{GetType().FullName}:{Code ?? "?"}.{nameof(Description)}", _description).ToString(); init => _description = value; } + + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyOrder(-995)] + public int SortOrder { get; init; } + + /// + [JsonPropertyOrder(-994)] + public virtual bool IsInactive + { + get + { + if (!_isValid || !_isActive) + return true; + + if (StartsOn is not null || EndsOn is not null) + { + var ctx = ExecutionContext.GetService(); + var date = ctx is null ? Runtime.UtcNow : ctx[GetType()]; + + if (StartsOn is not null && date < StartsOn) + return true; + + if (EndsOn is not null && date > EndsOn) + return true; + } + + return !_isActive; + } + + init => _isActive = !value; + } + + /// + [JsonIgnore] + public bool IsActive => !IsInactive; + + /// + [JsonPropertyOrder(-993)] + public DateTimeOffset? StartsOn { get; init; } + + /// + [JsonPropertyOrder(-992)] + public DateTimeOffset? EndsOn { get; init; } + + /// + public string? ETag { get; init; } + + /// + [JsonIgnore] + public bool IsValid => _isValid; + + /// + void IReferenceData.SetInvalid() => _isValid = false; + + /// + public override string ToString() => Text ?? Code ?? Id?.ToString() ?? base.ToString()!; + + /// + public string? GetText() => _text; + + /// + public string? GetDescription() => _description; + + /// + [JsonIgnore] + public bool HasMappings => _mappings is not null && !_mappings.IsEmpty; + + /// + [JsonIgnore] + public IReadOnlyDictionary? Mappings => _mappings is null ? null : new ReadOnlyDictionary(_mappings); + + /// + public void SetMapping(string name, T? value) where T : IComparable, IEquatable + => (_mappings ??= new()).AddOrUpdate(name, _ => value, (_, _) => value); + + /// + public bool TryGetMapping(string name, [NotNullWhen(true)] out T? value) where T : IComparable, IEquatable + { + value = default!; + if (!HasMappings || !_mappings!.TryGetValue(name, out var val)) + return false; + + value = (T?)val!; + return true; + } + + /// + public override int GetHashCode() => HashCode.Combine(Id, Code); + + /// + public override bool Equals(object? obj) + { + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj is not IReferenceData rd) return false; + + return Equals(Id, rd.Id) && Equals(Code, rd.Code); + } +} \ No newline at end of file diff --git a/src/CoreEx.RefData/CoreEx.RefData.csproj b/src/CoreEx.RefData/CoreEx.RefData.csproj new file mode 100644 index 00000000..78c85272 --- /dev/null +++ b/src/CoreEx.RefData/CoreEx.RefData.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/CoreEx.RefData/CoreExReferenceDataExtensions.DependencyInjection.cs b/src/CoreEx.RefData/CoreExReferenceDataExtensions.DependencyInjection.cs new file mode 100644 index 00000000..dbe79f9b --- /dev/null +++ b/src/CoreEx.RefData/CoreExReferenceDataExtensions.DependencyInjection.cs @@ -0,0 +1,51 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static partial class CoreExReferenceDataExtensions +{ + /// + /// Adds the created by the as a singleton service. + /// + /// The . + /// The function to create the . + /// Indicates whether a corresponding should be configured. + /// The health check name; defaults to 'reference-data-orchestrator'. + /// The . + /// Also, registers the as the required scoped service. + public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, Func orchestratorFactory, bool healthCheck = true, string? healthCheckName = "reference-data-orchestrator") + { + services.ThrowIfNull().AddSingleton(sp => orchestratorFactory(sp)); + if (healthCheck) + services.AddHealthChecks().AddTypeActivatedCheck(healthCheckName ?? "reference-data-orchestrator", null, tags: HealthCheckTags.StartUpAndReadyOnly); + + services.TryAddScoped(sp => new ReferenceDataHybridCache(sp.GetService() ?? ActivatorUtilities.GetServiceOrCreateInstance(sp))); + return services; + } + + /// + /// Adds the using an as a singleton service automatically registering the (see ). + /// + /// The . + /// Indicates whether a corresponding should be configured. + /// The health check name; defaults to 'reference-data-orchestrator'. + /// The . + /// Where an has not been registered then the will be used by default. + public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, bool healthCheck = true, string? healthCheckName = "reference-data-orchestrator") + => AddReferenceDataOrchestrator(services, sp => new ReferenceDataOrchestrator(sp, sp.GetRequiredService>()).Register(), healthCheck, healthCheckName); + + /// + /// Adds the using an as a singleton service automatically registering the specified (see ). + /// + /// The to register. + /// The . + /// Indicates whether a corresponding should be configured. + /// The health check name; defaults to 'reference-data-orchestrator'. + /// The . + /// Where an has not been registered then the will be used by default. + public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, bool healthCheck = true, string? healthCheckName = "reference-data-orchestrator") where TProvider : IReferenceDataProvider + => AddReferenceDataOrchestrator(services, sp => new ReferenceDataOrchestrator(sp, sp.GetRequiredService>()).Register(), healthCheck, healthCheckName); +} \ No newline at end of file diff --git a/src/CoreEx.RefData/GlobalUsing.cs b/src/CoreEx.RefData/GlobalUsing.cs new file mode 100644 index 00000000..4a73ff40 --- /dev/null +++ b/src/CoreEx.RefData/GlobalUsing.cs @@ -0,0 +1,19 @@ +global using CoreEx; +global using CoreEx.Abstractions; +global using CoreEx.Caching; +global using CoreEx.Entities; +global using CoreEx.HealthChecks; +global using CoreEx.Localization; +global using CoreEx.RefData; +global using CoreEx.RefData.Abstractions; +global using CoreEx.RefData.HealthChecks; +global using CoreEx.Results; +global using Microsoft.Extensions.DependencyInjection.Extensions; +global using Microsoft.Extensions.Diagnostics.HealthChecks; +global using Microsoft.Extensions.Logging; +global using System.Collections; +global using System.Collections.Concurrent; +global using System.Collections.ObjectModel; +global using System.Diagnostics; +global using System.Diagnostics.CodeAnalysis; +global using System.Text.Json.Serialization; \ No newline at end of file diff --git a/src/CoreEx.RefData/HealthChecks/ReferenceDataOrchestratorHealthCheck.cs b/src/CoreEx.RefData/HealthChecks/ReferenceDataOrchestratorHealthCheck.cs new file mode 100644 index 00000000..4ae86886 --- /dev/null +++ b/src/CoreEx.RefData/HealthChecks/ReferenceDataOrchestratorHealthCheck.cs @@ -0,0 +1,22 @@ +namespace CoreEx.RefData.HealthChecks; + +/// +/// Provides a . +/// +/// The . +public class ReferenceDataOrchestratorHealthCheck(ReferenceDataOrchestrator orchestrator) : IHealthCheck +{ + private readonly ReferenceDataOrchestrator _orchestrator = orchestrator.ThrowIfNull(); + + /// + /// Will always return . + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var data = new Dictionary + { + { "types", _orchestrator.GetAllTypes().Select(x => x.Name).ToArray() } + }; + + return Task.FromResult(HealthCheckResult.Healthy(null, data)); + } +} \ No newline at end of file diff --git a/src/CoreEx.RefData/ReferenceDataCodeCollection.cs b/src/CoreEx.RefData/ReferenceDataCodeCollection.cs new file mode 100644 index 00000000..a54bf222 --- /dev/null +++ b/src/CoreEx.RefData/ReferenceDataCodeCollection.cs @@ -0,0 +1,80 @@ +namespace CoreEx.RefData; + +/// +/// Provides a special purpose collection specifically for managing a referenced list of serialization identifiers, being the underlying . +/// +public class ReferenceDataCodeCollection : IReferenceDataCodeCollection, ICollection where TRef : IReferenceData, new() +{ + private readonly List _codes; + + /// + /// Initializes a new instance of the class. + /// + public ReferenceDataCodeCollection() => _codes = []; + + /// + /// Initializes a new instance of the class with a reference to an external list. + /// + /// A reference to the external list; it is this list that will be maintained by this collection. + public ReferenceDataCodeCollection(ref List? codes) => _codes = codes ?? []; + + /// + /// Initializes a new instance of the class with a list of items. + /// + /// The list of items. + public ReferenceDataCodeCollection(IEnumerable items) => _codes = [.. (items ?? []).Select(x => x.Code)]; + + /// + /// Initializes a new instance of the class with a array. + /// + /// The array. + public ReferenceDataCodeCollection(params IEnumerable codes) => _codes = [.. codes]; + + /// + public bool HasInvalidItems => this.Any(x => x is null || !x.IsValid); + + /// + public bool HasInactiveItems => this.Any(x => x is not null && x.IsValid && !x.IsActive); + + /// + public int Count => _codes.Count; + + /// + public List ToCodeList() => [.. _codes]; + + /// + public IEnumerable ToRefDataList() => [.. this]; + + /// + public bool IsReadOnly => ((IList)_codes).IsReadOnly; + + /// + public void Add(TRef item) => _codes.Add(item?.Code); + + /// + public void Clear() => _codes.Clear(); + + /// + public bool Contains(TRef item) => ((IList)_codes).Contains(item); + + /// + public void CopyTo(TRef[] array, int arrayIndex) => ((IList)_codes).CopyTo(array, arrayIndex); + + /// + public IEnumerator GetEnumerator() + { + foreach (string? code in _codes) + { + yield return ReferenceDataOrchestrator.TryGetByCode(code, out var item) ? item : item; + } + } + + /// + public int IndexOf(TRef item) => _codes.IndexOf(item?.Code); + + /// + public bool Remove(TRef item) => _codes.Remove(item?.Code); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} \ No newline at end of file diff --git a/src/CoreEx.RefData/ReferenceDataCollectionT.cs b/src/CoreEx.RefData/ReferenceDataCollectionT.cs new file mode 100644 index 00000000..dcdec83d --- /dev/null +++ b/src/CoreEx.RefData/ReferenceDataCollectionT.cs @@ -0,0 +1,22 @@ +namespace CoreEx.RefData; + +/// +/// Represents a generic implementation where the is a . +/// +/// The . +/// This class leverages dictionaries internally to manage the items and as such there is no implied order when using the likes of the ; use +/// to achieve desired ordering where applicable. +public class ReferenceDataCollection : ReferenceDataCollectionCore, ICollection where TRef : class, IReferenceData +{ + /// + /// Initializes a new instance of the class. + /// + /// The . Defaults to . + /// The for comparisons. Defaults to . + public ReferenceDataCollection(ReferenceDataSortOrder sortOrder = ReferenceDataSortOrder.SortOrder, StringComparer? codeComparer = null) : base(sortOrder, codeComparer) => OnInitialization(); + + /// + /// Provides an opportunity to extend initialization when the object is constructed. + /// + protected virtual void OnInitialization() { } +} \ No newline at end of file diff --git a/src/CoreEx.RefData/ReferenceDataCollectionT2.cs b/src/CoreEx.RefData/ReferenceDataCollectionT2.cs new file mode 100644 index 00000000..74b231f6 --- /dev/null +++ b/src/CoreEx.RefData/ReferenceDataCollectionT2.cs @@ -0,0 +1,23 @@ +namespace CoreEx.RefData; + +/// +/// Represents a generic implementation where the is specified with the . +/// +/// The . +/// The . +/// This class leverages dictionaries internally to manage the items and as such there is no implied order when using the likes of the ; use +/// to achieve desired ordering where applicable. +public class ReferenceDataCollection : ReferenceDataCollectionCore, ICollection where TRef : class, IReferenceData +{ + /// + /// Initializes a new instance of the class. + /// + /// The . Defaults to . + /// The for comparisons. Defaults to . + public ReferenceDataCollection(ReferenceDataSortOrder sortOrder = ReferenceDataSortOrder.SortOrder, StringComparer? codeComparer = null) : base(sortOrder, codeComparer) => OnInitialization(); + + /// + /// Provides an opportunity to extend initialization when the object is constructed. + /// + protected virtual void OnInitialization() { } +} \ No newline at end of file diff --git a/src/CoreEx.RefData/ReferenceDataContext.cs b/src/CoreEx.RefData/ReferenceDataContext.cs new file mode 100644 index 00000000..9366d988 --- /dev/null +++ b/src/CoreEx.RefData/ReferenceDataContext.cs @@ -0,0 +1,40 @@ +namespace CoreEx.RefData; + +/// +/// Provides the contextual validation for a and verification. +/// +public class ReferenceDataContext : IReferenceDataContext +{ + private DateTimeOffset? _date; + private readonly ConcurrentDictionary _coll = new(); + + /// + /// Gets or sets the and contextual validation date. + /// + /// Defaults to . + public DateTimeOffset? Date + { + get => _date ??= Runtime.UtcNow; + set => _date = value; + } + + /// + /// Gets or sets a contextual validation date for a specific . + /// + /// The . + /// The contextual validation date. + public DateTimeOffset? this[Type type] + { + get => (_coll.TryGetValue(type.ThrowIfNull(), out var date) ? date : Date) ?? Date; + set => _coll.AddOrUpdate(type, _ => value, (_, _) => value); + } + + /// + /// Resets all dates. + /// + public void Reset() + { + _date = null; + _coll.Clear(); + } +} diff --git a/src/CoreEx.RefData/ReferenceDataHybridCache.TypedInvoker.cs b/src/CoreEx.RefData/ReferenceDataHybridCache.TypedInvoker.cs new file mode 100644 index 00000000..a7655fe5 --- /dev/null +++ b/src/CoreEx.RefData/ReferenceDataHybridCache.TypedInvoker.cs @@ -0,0 +1,45 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace CoreEx.RefData; + +public partial class ReferenceDataHybridCache +{ + /* + * This functionality is required as the underlying cache *may* leverage serialization, and as such, we have to get it in a typed manner as IReferenceDataCollection (interface) is not valid. + */ + + private static readonly MethodInfo TryGetByKeyAsync_OpenGeneric = typeof(IHybridCache).GetMethod(nameof(IHybridCache.TryGetByKeyAsync)) ?? throw new InvalidOperationException($"{nameof(IHybridCache)}.{nameof(IHybridCache.TryGetByKeyAsync)} public instance method not found."); + private static readonly ConcurrentDictionary _invokers = new(); + + private delegate Task<(bool Exists, object? Value)> TryGetByKeyInvoker(IHybridCache cache, string key, HybridCacheEntryOptions options, CancellationToken cancellationToken); + + /// + /// Gets (or adds) the for the specified type. + /// + private static TryGetByKeyInvoker GetInvokerForType(Type type) => _invokers.GetOrAdd(type, type => + { + // Close the generic: TryGetByKeyAsync + var closed = TryGetByKeyAsync_OpenGeneric.MakeGenericMethod(type); + + // Parameters: (cache, key, options, cancellationToken) => + var cacheParam = Expression.Parameter(typeof(IHybridCache), "cache"); + var keyParam = Expression.Parameter(typeof(string), "key"); + var optParam = Expression.Parameter(typeof(HybridCacheEntryOptions), "options"); + var ctParam = Expression.Parameter(typeof(CancellationToken), "cancellationToken"); + + // Expression: cache.TryGetByKeyAsync(key, options, ct) + var call = Expression.Call(cacheParam, closed, keyParam, optParam, ctParam); + + // Build method body: ToTupleTask(call). + var method = typeof(ReferenceDataHybridCache).GetMethod(nameof(ToTupleTask), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(type); + var body = Expression.Call(method, call); + var lambda = Expression.Lambda(body, cacheParam, keyParam, optParam, ctParam); + return lambda.Compile(); + }); + + /// + /// Underlying method to invoke the typed . + /// + private static async Task<(bool Exists, object? Value)> ToTupleTask(Task<(bool Exists, T? Value)> task) => await task.ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/CoreEx.RefData/ReferenceDataHybridCache.cs b/src/CoreEx.RefData/ReferenceDataHybridCache.cs new file mode 100644 index 00000000..0f910623 --- /dev/null +++ b/src/CoreEx.RefData/ReferenceDataHybridCache.cs @@ -0,0 +1,100 @@ +namespace CoreEx.RefData; + +/// +/// Provides implementation using an . +/// +/// The underlying . +public partial class ReferenceDataHybridCache(IHybridCache cache) : IReferenceDataCache +{ + private readonly ConcurrentDictionary _semaphores = new(); + private readonly ConcurrentDictionary _entryOptions = new(); +#if NET8_0 + private readonly object _lock = new(); +#else + private readonly Lock _lock = new(); +#endif + + /// + /// Gets the underlying . + /// + public IHybridCache Cache { get; set; } = cache.ThrowIfNull(); + + /// + /// Gets or creates the for the specified . + /// + private HybridCacheEntryOptions GetOrCreateEntryOptions(Type type) => _entryOptions.GetOrAdd(type, _ => + { + var options = HybridCacheEntryOptions.CreateForName(type.Name); + OnCreateCacheEntry(type, options); + return options; + }); + + /// + public async Task GetOrCreateAsync(Type type, Func> factory, CancellationToken cancellationToken = default) + { + var key = $"RefData:{(Internal.GetNamespaceFormattedName(type))}"; + var options = GetOrCreateEntryOptions(type); + + // Use invoker to ensure properly typed (needed for the likes of deserialization, where/if used). + var invoker = GetInvokerForType(type); + + // Try and get as most likely already in the cache; where exists then exit fast. + var (Exists, Value) = await invoker(Cache, key, options, cancellationToken).ConfigureAwait(false); + if (Exists) + return (IReferenceDataCollection)Value!; + + // A lock is also needed to absolutely ensure only a single semaphore is _ever_ created per type/key. + SemaphoreSlim semaphore; + lock (_lock) + { + // Get or add a new semaphore for the cache key so we can manage single concurrency for *this* key only. + semaphore = _semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + } + + // Use the semaphore to manage a single thread to perform the "expensive" get operation. + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Does a get or create as it may have been added as we went to lock. + return (await Cache.GetOrCreateByKeyAsync(key, async cancellationToken => + { + return await factory(type, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException($"The '{type.Name}' (reference data) collection returned from the factory must not be null."); + }, options, cancellationToken).ConfigureAwait(false))!; + } + finally + { + semaphore.Release(); + } + } + + /// + /// Provides an opportunity to further configure the . + /// + /// The . + /// The . + protected virtual void OnCreateCacheEntry(Type type, HybridCacheEntryOptions entry) { } + + /// + /// Registers the for the specified . + /// + /// The . + /// The . + /// The to support fluent-style method-chaining. + public ReferenceDataHybridCache RegisterCacheEntryOptions(HybridCacheEntryOptions options) where TRefColl : IReferenceDataCollection => RegisterCacheEntryOptions(typeof(TRefColl), options); + + /// + /// Registers the for the specified (should be a ). + /// + /// The . + /// The . + /// The to support fluent-style method-chaining. + public ReferenceDataHybridCache RegisterCacheEntryOptions(Type type, HybridCacheEntryOptions options) + { + if (type.ThrowIfNull().GetInterface(nameof(IReferenceDataCollection)) == null) + throw new ArgumentException($"The specified '{type.Name}' is not a valid {nameof(IReferenceDataCollection)} type.", nameof(type)); + + options.ThrowIfNull(); + _entryOptions[type] = options; + return this; + } +} \ No newline at end of file diff --git a/src/CoreEx.RefData/ReferenceDataSortOrder.cs b/src/CoreEx.RefData/ReferenceDataSortOrder.cs new file mode 100644 index 00000000..78490164 --- /dev/null +++ b/src/CoreEx.RefData/ReferenceDataSortOrder.cs @@ -0,0 +1,27 @@ +namespace CoreEx.RefData; + +/// +/// Provides the sort order for the reference data. +/// +public enum ReferenceDataSortOrder +{ + /// + /// Ordered by , then , and finally (default). + /// + SortOrder, + + /// + /// Ordered by . + /// + Id, + + /// + /// Ordered by . + /// + Code, + + /// + /// Ordered by and then . + /// + Text +} \ No newline at end of file diff --git a/src/CoreEx.RefData/ReferenceDataT.cs b/src/CoreEx.RefData/ReferenceDataT.cs new file mode 100644 index 00000000..3e9b28f7 --- /dev/null +++ b/src/CoreEx.RefData/ReferenceDataT.cs @@ -0,0 +1,74 @@ +namespace CoreEx.RefData; + +/// +/// Represents a implementation where the is a . +/// +/// The reference data itself. +/// The underlying implicit/explicit casting only supports as there is no means to distinguish between it and the where they are of type . +[DebuggerDisplay("Id = {Id}, Code = {Code}, Text = {_text}, IsActive = {IsActive}")] +public abstract partial class ReferenceData : ReferenceDataCore, IComparable + where TSelf : ReferenceData, IReferenceData, new() +{ + /// + /// Throws an where ; otherwise, continues. + /// + /// The instance itself to support fluent-style method-chaining. + /// This does verify whether the reference data is invalid also. + public TSelf ThrowIfInactive() + { + if (IsInactive) + throw new InvalidOperationException("The reference data not be in an active state."); + + return (TSelf)this; + } + + /// + /// Throws an where not ; otherwise, continues. + /// + /// The instance itself to support fluent-style method-chaining. + /// This does not verify whether the reference data is inactive. + public TSelf ThrowIfInvalid() + { + if (!IsValid) + throw new InvalidOperationException("The reference data must not be in an invalid state."); + + return (TSelf)this; + } + + /// + /// Tries to get the item for the specified . + /// + /// The . + /// The instance. + /// where found; otherwise, . + /// Where the item () is not found it will be created and will be invoked. + /// This leverages the internally to perform. + public static bool TryGetById(string id, out TSelf item) => ReferenceDataOrchestrator.TryGetById(id, out item); + + /// + /// Tries to get the item for the specified . + /// + /// The . + /// The instance. + /// where found; otherwise, . + /// Where the item () is not found it will be created and will be invoked. + /// This leverages the internally to perform. + public static bool TryGetByCode(string? code, out TSelf item) => ReferenceDataOrchestrator.TryGetByCode(code, out item); + + /// + /// Comparison is based on the . + public int CompareTo(TSelf? other) => other is null ? 1 : Code?.CompareTo(other.Code) ?? 0; + + /// + /// An explicit cast operator that converts a to a instance. + /// + /// The . + [return: NotNullIfNotNull(nameof(code))] + public static explicit operator ReferenceData?(string? code) => code is null ? null : TryGetByCode(code, out var item) ? item : item; + + /// + /// An implicit cast operator that converts a to its as a . + /// + /// The instance. + public static implicit operator string(ReferenceData? item) => item?.Code!; +} \ No newline at end of file diff --git a/src/CoreEx.RefData/ReferenceDataT2.cs b/src/CoreEx.RefData/ReferenceDataT2.cs new file mode 100644 index 00000000..bd43b546 --- /dev/null +++ b/src/CoreEx.RefData/ReferenceDataT2.cs @@ -0,0 +1,61 @@ +namespace CoreEx.RefData; + +/// +/// Represents a implementation where the is specified with the . +/// +/// The . +/// The reference data itself. +/// The should be used where the is to be a as this is already optimized for this. +[DebuggerDisplay("Id = {Id}, Code = {Code}, Text = {_text}, IsActive = {IsActive}")] +public abstract partial class ReferenceData : ReferenceDataCore, IComparable + where TSelf : ReferenceData, IReferenceData, new() +{ + /// + /// Tries to get the item for the specified . + /// + /// The . + /// The instance. + /// where found; otherwise, . + /// Where the item () is not found it will be created and will be invoked. + /// This leverages the internally to perform. + public static bool TryGetById(TId id, out TSelf item) => ReferenceDataOrchestrator.TryGetById(id.ThrowIfNull(), out item); + + /// + /// Tries to get the item for the specified . + /// + /// The . + /// The instance. + /// where found; otherwise, . + /// Where the item () is not found it will be created and will be invoked. + /// This leverages the internally to perform. + public static bool TryGetByCode(string? code, out TSelf item) => ReferenceDataOrchestrator.TryGetByCode(code, out item); + + /// + /// Comparison is based on the . + public int CompareTo(TSelf? other) => other is null ? 1 : Code?.CompareTo(other.Code) ?? 0; + + /// + /// An explicit cast operator that converts an to a instance. + /// + /// The . + public static explicit operator ReferenceData(TId id) => TryGetById(id, out var item) ? item : item; + + /// + /// An explicit cast operator that converts a to a instance. + /// + /// The . + [return: NotNullIfNotNull(nameof(code))] + public static explicit operator ReferenceData?(string? code) => code is null ? null : TryGetByCode(code, out var item) ? item : item; + + /// + /// An implicit cast operator that converts a to its identifier type . + /// + /// The instance. + public static implicit operator TId(ReferenceData? item) => item is null ? default! : item.Id!; + + /// + /// An implicit cast operator that converts a to its as a . + /// + /// The instance. + public static implicit operator string(ReferenceData? item) => item?.Code!; +} \ No newline at end of file diff --git a/src/CoreEx.Solace/CoreEx.Solace.csproj b/src/CoreEx.Solace/CoreEx.Solace.csproj deleted file mode 100644 index c9c7539b..00000000 --- a/src/CoreEx.Solace/CoreEx.Solace.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx.Solace - CoreEx - CoreEx .NET Standard Solace PubSub+ Extensions. - CoreEx Solace PubSub+ Extensions. - coreex api function aspnet solace pubsubplus - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/CoreEx.Solace/PubSub/EventDataToPubSubMessageConverter.cs b/src/CoreEx.Solace/PubSub/EventDataToPubSubMessageConverter.cs deleted file mode 100644 index 09d5a7ff..00000000 --- a/src/CoreEx.Solace/PubSub/EventDataToPubSubMessageConverter.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; -using CoreEx.Events; -using CoreEx.Mapping.Converters; -using CoreEx.Text.Json; -using System; -using SolaceSystems.Solclient.Messaging; - -namespace CoreEx.Solace.PubSub -{ - /// - /// Converts an to a . - /// - /// Internally converts an to a corresponding using the , then converts to the using the . - /// The to serialize the into a corresponding . - /// The to convert an to a corresponding . - public class EventDataToPubSubMessageConverter(IEventSerializer? eventSerializer = null, IValueConverter? valueConverter = null) : IValueConverter - { - /// - /// Gets the to serialize the into for the . - /// - protected IEventSerializer EventSerializer { get; } = eventSerializer ?? ExecutionContext.GetService() ?? new EventDataSerializer(); - - /// - /// Gets the to convert an to a corresponding . - /// - protected IValueConverter EventSendDataConverter { get; } = valueConverter ?? ExecutionContext.GetService>() ?? new EventSendDataToPubSubConverter(); - - /// - public IMessage Convert(EventData @event) - { - EventSerializer.EventDataFormatter.Format(@event); - var esd = new EventSendData(@event) { Data = Invoker.RunSync(() => EventSerializer.SerializeAsync(@event)) }; - return EventSendDataConverter.Convert(esd); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Solace/PubSub/EventSendDataToPubSubConverter.cs b/src/CoreEx.Solace/PubSub/EventSendDataToPubSubConverter.cs deleted file mode 100644 index d94abbca..00000000 --- a/src/CoreEx.Solace/PubSub/EventSendDataToPubSubConverter.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using CoreEx.Mapping.Converters; -using SolaceSystems.Solclient.Messaging; -using System; -using System.Linq; -using System.Net.Mime; - -namespace CoreEx.Solace.PubSub -{ - /// - /// Converts an to a . - /// - public class EventSendDataToPubSubConverter : IValueConverter - { - /// - /// Gets or sets the property selection; where a property is selected it will be set as one of the properties. - /// - /// Defaults to . - public EventDataProperty PropertySelection { get; set; } = EventDataProperty.All; - - /// - /// Gets or sets the name for the . - /// - /// Defaults to '_SessionId'. - public string SessionIdAttributeName { get; set; } = $"_{nameof(IMessage.ApplicationMessageId)}"; - - /// - /// Gets or sets the name for the . - /// - /// Defaults to '_TimeToLive'. - public string TimeToLiveAttributeName { get; set; } = $"_{nameof(IMessage.TimeToLive)}"; - - /// - /// Indicates whether to use the as the . - /// - public bool UsePartitionKeyAsSessionId { get; set; } = true; - - /// - /// By default the will be used to update the from the , followed by the - /// option, until not null; otherwise, will be left as null. - /// Similarily, the will be used to update the from the . - public IMessage Convert(EventSendData @event) - { - var message = ContextFactory.Instance.CreateMessage(); - - message.BinaryAttachment = @event.Data?.ToArray() ?? []; - message.ApplicationMessageId = @event.Id; - message.HttpContentType = MediaTypeNames.Application.Json; - message.CorrelationId = @event.CorrelationId ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current.CorrelationId : null); - - message.CreateUserPropertyMap(); - message.UserPropertyMap.AddString("Subject", @event.Subject); - - if (@event.Action != null && PropertySelection.HasFlag(EventDataProperty.Action)) - message.UserPropertyMap.AddString(nameof(EventData.Action), @event.Action); - - if (@event.Source != null && PropertySelection.HasFlag(EventDataProperty.Source)) - message.UserPropertyMap.AddString(nameof(EventData.Source), @event.Source.ToString()); - - if (@event.Type != null && PropertySelection.HasFlag(EventDataProperty.Type)) - message.UserPropertyMap.AddString(nameof(EventData.Type), @event.Type); - - if (@event.TenantId != null && PropertySelection.HasFlag(EventDataProperty.TenantId)) - message.UserPropertyMap.AddString(nameof(EventData.TenantId), @event.TenantId); - - if (@event.PartitionKey != null && PropertySelection.HasFlag(EventDataProperty.PartitionKey)) - message.UserPropertyMap.AddString(nameof(EventData.PartitionKey), @event.PartitionKey); - - if (@event.ETag != null && PropertySelection.HasFlag(EventDataProperty.ETag)) - message.UserPropertyMap.AddString(nameof(EventData.ETag), @event.ETag); - - if (@event.Key != null && PropertySelection.HasFlag(EventDataProperty.Key)) - message.UserPropertyMap.AddString(nameof(EventData.Key), @event.Key); - - if (@event.Attributes != null && @event.Attributes.Count > 0 && PropertySelection.HasFlag(EventDataProperty.Attributes)) - { - // Attrtibutes that start with an underscore are considered internal and will not be sent automatically; i.e. _SessionId and _TimeToLive. - foreach (var attribute in @event.Attributes.Where(x => !string.IsNullOrEmpty(x.Key) && !x.Key.StartsWith('_'))) - { - message.UserPropertyMap.AddString(attribute.Key, attribute.Value); - } - } - - if (@event.Attributes != null && @event.Attributes.TryGetValue(SessionIdAttributeName, out var sessionId)) - message.UserPropertyMap.AddString("SessionId", sessionId); - else if (@event.PartitionKey != null) - message.UserPropertyMap.AddString("SessionId", UsePartitionKeyAsSessionId ? @event.PartitionKey : string.Empty); - - if (@event.Attributes != null && @event.Attributes.TryGetValue(TimeToLiveAttributeName, out var ttl) && TimeSpan.TryParse(ttl, out var timeToLive)) - message.TimeToLive = (long)timeToLive.TotalMilliseconds; - - return message; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Solace/PubSub/IPubSubSender.cs b/src/CoreEx.Solace/PubSub/IPubSubSender.cs deleted file mode 100644 index 10a58486..00000000 --- a/src/CoreEx.Solace/PubSub/IPubSubSender.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; - -namespace CoreEx.Solace.PubSub -{ - /// - /// Defines the standardized Event sending via Solace PubSub+. - /// - public interface IPubSubSender : IEventSender { } -} \ No newline at end of file diff --git a/src/CoreEx.Solace/PubSub/PubSubSender.cs b/src/CoreEx.Solace/PubSub/PubSubSender.cs deleted file mode 100644 index a9d78ddf..00000000 --- a/src/CoreEx.Solace/PubSub/PubSubSender.cs +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using CoreEx.Configuration; -using CoreEx.Events; -using CoreEx.Mapping.Converters; -using Microsoft.Extensions.Logging; -using SolaceSystems.Solclient.Messaging; - -namespace CoreEx.Solace.PubSub -{ - /// - /// Represents a PubSub (see also ). - /// - /// See for details - public class PubSubSender : IPubSubSender - { - private const string _unspecifiedQueueOrTopicName = "$default"; - private static PubSubSenderInvoker? _invoker; - - /// - /// Initializes a new instance of the class. - /// - /// The Solace . - /// The Solace . - /// The . - /// The . - /// The optional . - /// The optional to convert an to a corresponding . - public PubSubSender(IContext solaceContext, SessionProperties sessionProperties, SettingsBase settings, ILogger logger, PubSubSenderInvoker? invoker = null, IValueConverter? converter = null) - { - SolaceContext = solaceContext.ThrowIfNull(nameof(solaceContext)); - SessionProperties = sessionProperties.ThrowIfNull(nameof(sessionProperties)); - Settings = settings.ThrowIfNull(nameof(settings)); - Logger = logger.ThrowIfNull(nameof(logger)); - Invoker = invoker ?? (_invoker ??= new PubSubSenderInvoker()); - Converter = converter ?? new EventSendDataToPubSubConverter(); - DefaultQueueOrTopicName = Settings.GetCoreExValue($"{GetType().Name}:QueueOrTopicName", defaultValue: _unspecifiedQueueOrTopicName); - } - - /// - /// Gets the . - /// - protected IContext SolaceContext { get; set; } - - /// - /// Gets the . - /// - protected SessionProperties SessionProperties { get; } - - /// - /// Gets the . - /// - protected SettingsBase Settings { get; } - - /// - /// Gets the . - /// - protected ILogger Logger { get; } - - /// - /// Gets the . - /// - protected PubSubSenderInvoker Invoker { get; } - - /// - /// Gets the to convert an to a corresponding . - /// - protected IValueConverter Converter { get; } - - /// - /// Gets or sets the default queue or topic name used by where is null. - /// - public string? DefaultQueueOrTopicName { get; set; } - - /// - /// Gets or sets the maximum batch size for sending. - /// - /// Defaults to 50. - public int MaxBatchSize { get; set; } = 50; - - /// - public Task SendAsync(IEnumerable events, CancellationToken cancellationToken = default) - { - if (events == null || !events.Any()) - return Task.CompletedTask; - - Invoker.Invoke(this, events, (_, events) => - { - var totalCount = events.Count(); - Logger.LogDebug("{TotalCount} events in total are to be sent.", totalCount); - - if (totalCount == 0) - return; - - if (totalCount != events.Select(x => x.Id).Distinct().Count()) - throw new EventSendException(PrependStats($"All events must have a unique identifier ({nameof(EventSendData)}.{nameof(EventSendData.Id)}).", totalCount, totalCount), events); - - // Sets up the list of unsent events. - var unsentEvents = new List(events); - - var queueDict = new Dictionary>(); - var index = 0; - foreach (var @event in events) - { - //Convert message - var message = Converter.Convert(@event) ?? throw new EventSendException($"The {nameof(Converter)} must return a {nameof(IMessage)} instance."); - - var name = @event.Destination ?? DefaultQueueOrTopicName ?? throw new InvalidOperationException($"The {nameof(DefaultQueueOrTopicName)} must be specified where the {nameof(EventSendData)}.{nameof(EventSendData.Destination)} is null."); - message.Destination = ContextFactory.Instance.CreateTopic(name); - - //Enqueue event message - if (queueDict.TryGetValue(name, out var queue)) - queue.Enqueue((message, index++)); - else - { - queue = new Queue<(IMessage, int)>(); - queue.Enqueue((message, index++)); - queueDict.Add(name, queue); - } - } - - Logger.LogDebug("There are {QueueTopicCount} queues/topics specified; as such there will be that many batches sent as a minimum.", queueDict.Keys.Count); - - // Establish session and dispose when done. - using var session = EstablishSessionToPubSubBroker(); - - // Get queue name by checking configuration override. - foreach (var qitem in queueDict) - { - var n = qitem.Key == _unspecifiedQueueOrTopicName ? null : qitem.Key; - var key = $"{GetType().Name}_QueueOrTopicName{(n is null ? "" : $"_{n}")}"; - var queue = qitem.Value; - var sentIds = new List(); - - // Send in batches. - while (queue.Count > 0) - { - sentIds.Clear(); - var messageBatch = new List(); - - // Add the first message to the batch. - var firstMsg = queue.Peek(); - messageBatch.Add(firstMsg.Message); - sentIds.Add(firstMsg.Message.ApplicationMessageId); - queue.Dequeue(); - - // Keep adding until done or max size reached for batch. - while (queue.Count > 0 && messageBatch.Count < MaxBatchSize) - { - messageBatch.Add(queue.Peek().Message); - sentIds.Add(queue.Peek().Message.ApplicationMessageId); - queue.Dequeue(); - } - - try - { - Logger.LogDebug("Sending {Count} message(s) to PubSub Broker.", messageBatch.Count); - var returnCode = session.Send([.. messageBatch], 0, messageBatch.Count, out int sentCount); - - if (returnCode != ReturnCode.SOLCLIENT_OK) - { - Logger.LogDebug("{UnsentCount} of the total {TotalCount} events were not successfully sent.", unsentEvents.Count, totalCount); - throw new EventSendException(PrependStats($"PubSubMessage send failed with return code {Enum.GetName(typeof(ReturnCode), returnCode)}.", totalCount, unsentEvents.Count), unsentEvents); - } - - if (messageBatch.Count != sentCount) - { - Logger.LogDebug("{UnsentCount} of the total {TotalCount} events were not successfully sent.", unsentEvents.Count, totalCount); - throw new InvalidOperationException("Not all messages in batch were sent; only {sentCount} of {messageBatch.Count} were sent."); - } - - Logger.LogDebug("Successful send of {Count} message(s).", messageBatch.Count); - } - catch (Exception ex) - { - Logger.LogDebug("{UnsentCount} of the total {TotalCount} events were not successfully sent.", unsentEvents.Count, totalCount); - throw new EventSendException(PrependStats($"PubSubMessage cannot be sent: {ex.Message}", totalCount, unsentEvents.Count), ex, unsentEvents); - } - - // Begin next batch after confirming sent events; continue ^ where any left. - unsentEvents.RemoveAll(esd => sentIds.Contains(esd.Id ?? string.Empty)); - } - } - - // Raise the event. - AfterSend?.Invoke(this, EventArgs.Empty); - }, nameof(SendAsync)); - - return Task.CompletedTask; - } - - /// - /// Establishes the session to the PubSub broker. - /// - private ISession EstablishSessionToPubSubBroker() - { - Logger.LogDebug("Establishing Solace Session as {UserName}@{VPNName} on {Host} with SSL Trust Store directory {SSLTrustStoreDir}.", SessionProperties.UserName, SessionProperties.VPNName, SessionProperties.Host, SessionProperties.SSLTrustStoreDir); - - var session = SolaceContext.CreateSession(SessionProperties, null, null); - var returnCode = session.Connect(); - if (returnCode == ReturnCode.SOLCLIENT_OK) - return session; - - session.Dispose(); - throw new InvalidOperationException($"Cannot establish Solace PubSub broker session. Return code is {Enum.GetName(typeof(ReturnCode), returnCode)}."); - } - - /// - /// Prepend the sent stats to the message. - /// - private static string PrependStats(string message, int totalCount, int unsentCount) => $"{unsentCount} of the total {totalCount} events were not successfully sent. {message}"; - - /// - public event EventHandler? AfterSend; - } -} \ No newline at end of file diff --git a/src/CoreEx.Solace/PubSub/PubSubSenderInvoker.cs b/src/CoreEx.Solace/PubSub/PubSubSenderInvoker.cs deleted file mode 100644 index 376cda15..00000000 --- a/src/CoreEx.Solace/PubSub/PubSubSenderInvoker.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; -using System; -using System.Threading; -using System.Threading.Tasks; -using System.Transactions; - -namespace CoreEx.Solace.PubSub -{ - /// - /// Provides the standard invoker functionality. - /// - /// Suppresses the as Azure Service Bus does not support distributed transactions and this may be invoked in the context of an already enlisted transaction. - public class PubSubSenderInvoker : InvokerBase - { - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, PubSubSender invoker, Func func) - { - TransactionScope? txn = null; - try - { - txn = new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled); - return base.OnInvoke(invokeArgs, invoker, func); - } - finally - { - txn?.Dispose(); - } - } - - /// - protected async override Task OnInvokeAsync(InvokeArgs invokeArgs, PubSubSender invoker, Func> func, CancellationToken cancellationToken) - { - TransactionScope? txn = null; - try - { - txn = new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled); - return await base.OnInvokeAsync(invokeArgs, invoker, func, cancellationToken).ConfigureAwait(false); - } - finally - { - txn?.Dispose(); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Solace/README.md b/src/CoreEx.Solace/README.md deleted file mode 100644 index 9d2aa843..00000000 --- a/src/CoreEx.Solace/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# CoreEx.Solace.PubSub - -Provides the key [Solace PubSub+ Event Broker](https://solace.com/products/event-broker/) capabilities, leveraging the [`SolaceSystems.Solclient.Messaging`](https://docs.solace.com/API/Messaging-APIs/dotNet-API/net-api-home.htm) library. - -
- -## Publishing - -A _CoreEx_ [`PubSubSender`](./PubSub/PubSubSender.cs) provides the [`IEventSender.SendAsync`](../CoreEx/Events/IEventSender.cs) capabilities to batch send one or more events/mesages to PubSub+. - -
\ No newline at end of file diff --git a/src/CoreEx.Solace/strong-name-key.snk b/src/CoreEx.Solace/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.UnitTesting.Azure.Functions/Abstractions/AzureFunctionsCoreExOneOffTestSetUp.cs b/src/CoreEx.UnitTesting.Azure.Functions/Abstractions/AzureFunctionsCoreExOneOffTestSetUp.cs deleted file mode 100644 index 742baab3..00000000 --- a/src/CoreEx.UnitTesting.Azure.Functions/Abstractions/AzureFunctionsCoreExOneOffTestSetUp.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Reflection; -using UnitTestEx.Abstractions; - -[assembly: OneOffTestSetUp(typeof(AzureFunctionsCoreExOneOffTestSetUp))] - -namespace UnitTestEx.Abstractions -{ - /// - /// Provides the one-off test set-up for the -related testing. - /// - /// Adds the to support the runtime extension inclusion. Also, changes the to the . - /// This inherits the achieving the same functionality, but is delared within this to ensure executed. - public class AzureFunctionsCoreExOneOffTestSetUp : CoreExOneOffTestSetUp { } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting.Azure.Functions/CoreEx.UnitTesting.Azure.Functions.csproj b/src/CoreEx.UnitTesting.Azure.Functions/CoreEx.UnitTesting.Azure.Functions.csproj deleted file mode 100644 index 21f9de8e..00000000 --- a/src/CoreEx.UnitTesting.Azure.Functions/CoreEx.UnitTesting.Azure.Functions.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net6.0;net8.0;net9.0 - UnitTestEx - CoreEx - CoreEx Azure Functions UnitTesting extras. - CoreEx UnitTesting (UnitTestEx) extras. - coreex unittest unit-test test unittestex - - - - - - - - - - - - - - - diff --git a/src/CoreEx.UnitTesting.Azure.Functions/UnitTestExExtensions.cs b/src/CoreEx.UnitTesting.Azure.Functions/UnitTestExExtensions.cs deleted file mode 100644 index 870ac240..00000000 --- a/src/CoreEx.UnitTesting.Azure.Functions/UnitTestExExtensions.cs +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.AspNetCore.Http; -using CoreEx.Http; -using Microsoft.AspNetCore.Http; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Net.Http; -using System.Net.Mime; -using UnitTestEx.Azure.Functions; -using Ceh = CoreEx.Http; - -namespace UnitTestEx -{ - /// - /// Provides extension methods to the core . - /// - public static class UnitTestExExtensions - { - #region FunctionTesterBase - - /// - /// Creates a new with no body. - /// - /// The tester. - /// The . - /// The requuest uri. - /// The optional . - /// The . -#if NET7_0_OR_GREATER - public static HttpRequest CreateHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, Ceh.HttpRequestOptions? requestOptions = null) -#else - public static HttpRequest CreateHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, string? requestUri, Ceh.HttpRequestOptions? requestOptions = null) -#endif - where TEntryPoint : class, new() where TSelf : FunctionTesterBase - => tester.CreateHttpRequest(httpMethod, requestUri).ApplyRequestOptions(requestOptions); - - /// - /// Creates a new with no body. - /// - /// The tester. - /// The . - /// The requuest uri. - /// The optional . - /// The optional modifier. - /// The . -#if NET7_0_OR_GREATER - public static HttpRequest CreateHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, Ceh.HttpRequestOptions? requestOptions = null, Action? requestModifier = null) -#else - public static HttpRequest CreateHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, string? requestUri, Ceh.HttpRequestOptions? requestOptions = null, Action? requestModifier = null) -#endif - where TEntryPoint : class, new() where TSelf : FunctionTesterBase - => tester.CreateHttpRequest(httpMethod, requestUri, requestModifier).ApplyRequestOptions(requestOptions); - - /// - /// Creates a new with (defaults to ). - /// - /// The tester. - /// The . - /// The requuest uri. - /// The optional body content. - /// The optional . - /// The . -#if NET7_0_OR_GREATER - public static HttpRequest CreateHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? body, Ceh.HttpRequestOptions? requestOptions = null) -#else - public static HttpRequest CreateHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, string? requestUri, string? body, Ceh.HttpRequestOptions? requestOptions = null) -#endif - where TEntryPoint : class, new() where TSelf : FunctionTesterBase - => tester.CreateHttpRequest(httpMethod, requestUri, body, null, null).ApplyRequestOptions(requestOptions); - - /// - /// Creates a new with and . - /// - /// The tester. - /// The . - /// The requuest uri. - /// The optional body content. - /// The content type. Defaults to . - /// The optional . - /// The . -#if NET7_0_OR_GREATER - public static HttpRequest CreateHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, string? body, string? contentType, Ceh.HttpRequestOptions? requestOptions = null) -#else - public static HttpRequest CreateHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, string? requestUri, string? body, string? contentType, Ceh.HttpRequestOptions? requestOptions = null) -#endif - where TEntryPoint : class, new() where TSelf : FunctionTesterBase - => tester.CreateHttpRequest(httpMethod, requestUri, body, contentType, null).ApplyRequestOptions(requestOptions); - - /// - /// Creates a new with the JSON serialized as of . - /// - /// The tester. - /// The . - /// The requuest uri. - /// The value to JSON serialize. - /// The optional modifier. - /// The . -#if NET7_0_OR_GREATER - public static HttpRequest CreateJsonHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, object? value, Ceh.HttpRequestOptions? requestOptions) -#else - public static HttpRequest CreateJsonHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, string? requestUri, object? value, Ceh.HttpRequestOptions? requestOptions) -#endif - where TEntryPoint : class, new() where TSelf : FunctionTesterBase - => tester.CreateJsonHttpRequest(httpMethod, requestUri, value).ApplyRequestOptions(requestOptions); - - /// - /// Creates a new with the JSON serialized as of . - /// - /// The tester. - /// The . - /// The requuest uri. - /// The value to JSON serialize. - /// The optional modifier. - /// The optional modifier. - /// The . -#if NET7_0_OR_GREATER - public static HttpRequest CreateJsonHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, object? value, Ceh.HttpRequestOptions? requestOptions, Action? requestModifier = null) -#else - public static HttpRequest CreateJsonHttpRequest(this FunctionTesterBase tester, HttpMethod httpMethod, string? requestUri, object? value, Ceh.HttpRequestOptions? requestOptions, Action? requestModifier = null) -#endif - where TEntryPoint : class, new() where TSelf : FunctionTesterBase - => tester.CreateJsonHttpRequest(httpMethod, requestUri, value, requestModifier).ApplyRequestOptions(requestOptions); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting.Azure.Functions/strong-name-key.snk b/src/CoreEx.UnitTesting.Azure.Functions/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.UnitTesting.Azure.ServiceBus/Abstractions/AzureServiceBusCoreExOneOffTestSetUp.cs b/src/CoreEx.UnitTesting.Azure.ServiceBus/Abstractions/AzureServiceBusCoreExOneOffTestSetUp.cs deleted file mode 100644 index 4be6c738..00000000 --- a/src/CoreEx.UnitTesting.Azure.ServiceBus/Abstractions/AzureServiceBusCoreExOneOffTestSetUp.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Reflection; -using UnitTestEx.Abstractions; - -[assembly: OneOffTestSetUp(typeof(AzureServiceBusCoreExOneOffTestSetUp))] - -namespace UnitTestEx.Abstractions -{ - /// - /// Provides the one-off test set-up for the -related testing. - /// - /// Adds the to support the runtime extension inclusion. Also, changes the to the . - /// This inheits the achieving the same functionality, but is delared within this to ensure executed. - public class AzureServiceBusCoreExOneOffTestSetUp : CoreExOneOffTestSetUp { } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting.Azure.ServiceBus/CoreEx.UnitTesting.Azure.ServiceBus.csproj b/src/CoreEx.UnitTesting.Azure.ServiceBus/CoreEx.UnitTesting.Azure.ServiceBus.csproj deleted file mode 100644 index 0686c1f3..00000000 --- a/src/CoreEx.UnitTesting.Azure.ServiceBus/CoreEx.UnitTesting.Azure.ServiceBus.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - net6.0;net8.0;net9.0 - UnitTestEx - CoreEx - CoreEx Azure ServiceBus UnitTesting extras. - CoreEx UnitTesting (UnitTestEx) extras. - coreex unittest unit-test test unittestex - - - - - - - - - - - - - - diff --git a/src/CoreEx.UnitTesting.Azure.ServiceBus/UnitTestExExtensions.cs b/src/CoreEx.UnitTesting.Azure.ServiceBus/UnitTestExExtensions.cs deleted file mode 100644 index b09fbb70..00000000 --- a/src/CoreEx.UnitTesting.Azure.ServiceBus/UnitTestExExtensions.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Core.Amqp; -using Azure.Messaging.ServiceBus; -using CoreEx; -using CoreEx.Azure.ServiceBus; -using CoreEx.Events; -using CoreEx.Mapping.Converters; -using Microsoft.Extensions.DependencyInjection; -using System; -using UnitTestEx.Abstractions; - -namespace UnitTestEx -{ - /// - /// Provides extension methods to the core . - /// - public static class UnitTestExExtensions - { - /// - /// Creates a from the leveraging the registered to perform the underlying conversion. - /// - /// The tester. - /// The or value. - /// The . - /// This will result in the from the underlying host being instantiated. If a Services-related error occurs then consider performing a after creation to reset. - public static ServiceBusReceivedMessage CreateServiceBusMessage(this TesterBase tester, EventData @event) - { - @event.ThrowIfNull(nameof(@event)); - var message = (tester.Services.GetService() ?? new EventDataToServiceBusConverter(tester.Services.GetService(), tester.Services.GetService>())).Convert(@event).GetRawAmqpMessage(); - return tester.CreateServiceBusMessage(message); - } - - /// - /// Creates a from the leveraging the registered to perform the underlying conversion. - /// - /// The tester. - /// The or value. - /// Optional modifier than enables the message to be further configured. - /// The . - /// This will result in the from the underlying host being instantiated. If a Services-related error occurs then consider performing a after creation to reset. - public static ServiceBusReceivedMessage CreateServiceBusMessage(this TesterBase tester, EventData @event, Action? messageModify) - { - @event.ThrowIfNull(nameof(@event)); - var message = (tester.Services.GetService() ?? new EventDataToServiceBusConverter(tester.Services.GetService(), tester.Services.GetService>())).Convert(@event).GetRawAmqpMessage(); - return tester.CreateServiceBusMessage(message, messageModify); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting.Azure.ServiceBus/strong-name-key.snk b/src/CoreEx.UnitTesting.Azure.ServiceBus/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.UnitTesting/Abstractions/CoreExExtension.cs b/src/CoreEx.UnitTesting/Abstractions/CoreExExtension.cs deleted file mode 100644 index 6dd0e97a..00000000 --- a/src/CoreEx.UnitTesting/Abstractions/CoreExExtension.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.AspNetCore.WebApis; -using CoreEx.Entities; -using CoreEx.Http; -using Microsoft.AspNetCore.Mvc; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using UnitTestEx.Assertors; -using UnitTestEx.Expectations; - -namespace UnitTestEx.Abstractions -{ - /// - /// Provides the extension capabilities. - /// - public class CoreExExtension : TesterExtensionsConfig - { - /// - public override void OnUseSetUp(TesterBase owner) - { - // Where the TestSetUp has been configured to use ExpectedEvents then automatically use it. - if (owner.SetUp.IsExpectedEventsEnabled()) - owner.UseExpectedEvents(); - - // Override username from ExecutionContext. - if (ExecutionContext.HasCurrent) - owner.SetUp.DefaultUserName = ExecutionContext.Current.UserName; - } - - /// - /// This is a duplication of the underlying logic. - public override void UpdateValueFromHttpResponseMessage(TesterBase owner, HttpResponseMessage response, ref TValue? value) where TValue : default - { - // This is a duplication of the HttpResult logic. - if (value != null && value is IETag etag && etag.ETag == null && response.Headers.ETag != null) - etag.ETag = response.Headers.ETag.Tag; - - // Where the value is an ICollectionResult then update the Paging property from the corresponding response headers. - if (value is ICollectionResult cr && cr != null && cr.Paging is null) - { - if (response.TryGetPagingResult(out var paging)) - cr.Paging = paging; - } - } - - /// - public override void UpdateValueFromActionResult(TesterBase owner, IActionResult actionResult, ref TValue? value) where TValue : default - { - if (actionResult is ValueContentResult vcr) - { - if (value != null && value is IETag etag && etag.ETag == null && vcr.ETag != null) - etag.ETag = vcr.ETag; - - if (value is ICollectionResult cr && cr != null && cr.Paging is null) - cr.Paging = vcr.PagingResult; - } - } - - /// - public override Task ExpectationAssertAsync(ExpectationsBase expectation, AssertArgs args) - { - // Where an ErrorExpectations and ValidationException then assert/match the errors/messages. - if (expectation is ErrorExpectations ee) - { - if (!ee.ErrorsMatched && ee.Errors.Count > 0 && args.Exception is not null && args.Exception is ValidationException vex) - { - var actual = vex.Messages?.Where(x => x.Type == MessageType.Error).Select(x => new ApiError(x.Property, x.Text ?? string.Empty)).ToArray() ?? []; - if (!Assertor.TryAreErrorsMatched(ee.Errors, actual, out var errorMessage)) - args.Tester.Implementor.AssertFail(errorMessage); - - ee.ErrorsMatched = true; - } - } - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Abstractions/CoreExOneOffTestSetUp.cs b/src/CoreEx.UnitTesting/Abstractions/CoreExOneOffTestSetUp.cs deleted file mode 100644 index 7549b349..00000000 --- a/src/CoreEx.UnitTesting/Abstractions/CoreExOneOffTestSetUp.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Entities; -using System.Linq; -using UnitTestEx.Assertors; - -[assembly: UnitTestEx.Abstractions.OneOffTestSetUp(typeof(UnitTestEx.Abstractions.CoreExOneOffTestSetUp))] - -namespace UnitTestEx.Abstractions -{ - /// - /// Provides the one-off test set-up for the -related testing. - /// - /// Adds the to support the runtime extension inclusion. Also, changes the to the . - public class CoreExOneOffTestSetUp : OneOffTestSetUpBase - { - private static bool _loaded; - - /// - public override void SetUp() - { - if (_loaded) - return; - - _loaded = true; - TestSetUp.Extensions.Add(new CoreExExtension()); - TestSetUp.Default.JsonSerializer = new CoreEx.Text.Json.JsonSerializer().ToUnitTestEx(); - - // Extend the AssertErrors functionality to support ValidationException. - AssertorBase.AddAssertErrorsExtension((assertor, errors) => - { - if (assertor.Exception is ValidationException vex) - { - var actual = vex.Messages?.Where(x => x.Type == MessageType.Error).Select(x => new ApiError(x.Property, x.Text ?? string.Empty)).ToArray() ?? []; - if (!Assertor.TryAreErrorsMatched(errors, actual, out var errorMessage)) - assertor.Owner.Implementor.AssertFail(errorMessage); - - return true; - } - - return false; - }); - } - - /// - /// Forces the one-off test set-up to occur. - /// - public static void ForceSetUp() => new CoreExOneOffTestSetUp().SetUp(); - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/AspNetCore/AgentTester.cs b/src/CoreEx.UnitTesting/AspNetCore/AgentTester.cs deleted file mode 100644 index ed3b1027..00000000 --- a/src/CoreEx.UnitTesting/AspNetCore/AgentTester.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Net.Http; -using System.Threading.Tasks; -using UnitTestEx.Abstractions; -using UnitTestEx.Assertors; -using Ceh = CoreEx.Http; - -namespace UnitTestEx.AspNetCore -{ - /// - /// Provides HTTP Agent testing. - /// - /// The owning . - /// The . - public class AgentTester(TesterBase owner, TestServer testServer) : HttpTesterBase>(owner, testServer) where TAgent : Ceh.TypedHttpClientBase - { - /// - /// Runs the test by executing a method. - /// - /// The function to execution. - /// An . - public HttpResultAssertor Run(Func> func) => RunAsync(func).GetAwaiter().GetResult(); - - /// - /// Runs the test by executing a method. - /// - /// The function to execution. - /// An . - public HttpResultAssertor Run(Func>> func) => RunAsync(func).GetAwaiter().GetResult(); - - /// - /// Runs the test by executing a method. - /// - /// The function to execution. - /// An . - public HttpResponseMessageAssertor Run(Func> func) => RunAsync(func).GetAwaiter().GetResult(); - - /// - /// Runs the test by executing a method. - /// - /// The function to execution. - /// An . - public async Task RunAsync(Func> func) - { - func.ThrowIfNull(nameof(func)); - - using var scope = this.CreateClientScope(); - var agent = scope.ServiceProvider.GetRequiredService(); - var res = await func(agent).ConfigureAwait(false); - - await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); - - var result = res.ToResult(); - await ExpectationsArranger.AssertAsync(ExpectationsArranger.CreateArgs(LastLogs, result.IsFailure ? result.Error : null).AddExtra(res.Response)).ConfigureAwait(false); - - return new HttpResultAssertor(Owner, res); - } - - /// - /// Runs the test by executing a method. - /// - /// The function to execution. - /// An . - public async Task> RunAsync(Func>> func) - { - func.ThrowIfNull(nameof(func)); - - using var scope = this.CreateClientScope(); - var agent = scope.ServiceProvider.GetRequiredService(); - var res = await func(agent).ConfigureAwait(false); - - await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); - - var result = res.ToResult(); - if (res.IsSuccess) - await ExpectationsArranger.AssertAsync(ExpectationsArranger.CreateValueArgs(LastLogs, result.Value).AddExtra(res.Response)).ConfigureAwait(false); - else - await ExpectationsArranger.AssertAsync(ExpectationsArranger.CreateArgs(LastLogs, result.Error).AddExtra(res.Response)).ConfigureAwait(false); - - return res.IsSuccess ? new HttpResultAssertor(Owner, res.Value, res) : new HttpResultAssertor(Owner, res); - } - - /// - /// Runs the test by executing a method. - /// - /// The function to execution. - /// An . - public async Task RunAsync(Func> func) - { - func.ThrowIfNull(nameof(func)); - - using var scope = this.CreateClientScope(); - var agent = scope.ServiceProvider.GetRequiredService(); - var res = await func(agent).ConfigureAwait(false); - - await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); - await ExpectationsArranger.AssertAsync(ExpectationsArranger.CreateArgs(LastLogs).AddExtra(res)).ConfigureAwait(false); - - return new HttpResponseMessageAssertor(Owner, res); - } - /// - /// Perform the assertion of any expectations. - /// - /// The / - protected override Task AssertExpectationsAsync(HttpResponseMessage res) => throw new NotImplementedException("This is performed internally; and therefore should not be invoked."); - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/AspNetCore/AgentTesterT.cs b/src/CoreEx.UnitTesting/AspNetCore/AgentTesterT.cs deleted file mode 100644 index 09507cb0..00000000 --- a/src/CoreEx.UnitTesting/AspNetCore/AgentTesterT.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Net.Http; -using System.Threading.Tasks; -using UnitTestEx.Abstractions; -using UnitTestEx.Assertors; - -namespace UnitTestEx.AspNetCore -{ - /// - /// Provides HTTP Agent testing. - /// - /// The Agent (inherits from ) . - /// The response value . - /// The owning . - /// The . - public class AgentTester(TesterBase owner, TestServer testServer) : HttpTesterBase>(owner, testServer) where TAgent : TypedHttpClientBase - { - /// - /// Runs the test by executing a method. - /// - /// The function to execution. - /// An . - public HttpResultAssertor Run(Func>> func) => RunAsync(func).GetAwaiter().GetResult(); - - /// - /// Runs the test by executing a method. - /// - /// The function to execution. - /// An . - public HttpResponseMessageAssertor Run(Func> func) => RunAsync(func).GetAwaiter().GetResult(); - - /// - /// Runs the test by executing a method. - /// - /// The function to execution. - /// An . - public async Task> RunAsync(Func>> func) - { - func.ThrowIfNull(nameof(func)); - - using var scope = this.CreateClientScope(); - var agent = scope.ServiceProvider.GetRequiredService(); - var res = await func(agent).ConfigureAwait(false); - - await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); - - var result = res.ToResult(); - if (res.IsSuccess) - await ExpectationsArranger.AssertAsync(ExpectationsArranger.CreateValueArgs(LastLogs, result.Value).AddExtra(res.Response)).ConfigureAwait(false); - else - await ExpectationsArranger.AssertAsync(ExpectationsArranger.CreateArgs(LastLogs, result.Error).AddExtra(res.Response)).ConfigureAwait(false); - - return res.IsSuccess ? new HttpResultAssertor(Owner, res.Value, res) : new HttpResultAssertor(Owner, res); - } - - /// - /// Runs the test by executing a method. - /// - /// The function to execution. - /// An . - public async Task> RunAsync(Func> func) - { - func.ThrowIfNull(nameof(func)); - - using var scope = this.CreateClientScope(); - var agent = scope.ServiceProvider.GetRequiredService(); - var res = await func(agent).ConfigureAwait(false); - - await Task.Delay(TestSetUp.TaskDelayMilliseconds).ConfigureAwait(false); - await ExpectationsArranger.AssertAsync(ExpectationsArranger.CreateArgs(LastLogs).AddExtra(res)).ConfigureAwait(false); - - return new HttpResponseMessageAssertor(Owner, res); - } - - /// - /// Perform the assertion of any expectations. - /// - /// The / - protected override Task AssertExpectationsAsync(HttpResponseMessage res) => throw new NotImplementedException("This is performed internally; and therefore should not be invoked."); - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Assertors/HttpResultAssertor.cs b/src/CoreEx.UnitTesting/Assertors/HttpResultAssertor.cs deleted file mode 100644 index 1db30345..00000000 --- a/src/CoreEx.UnitTesting/Assertors/HttpResultAssertor.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Http; -using UnitTestEx.Abstractions; - -namespace UnitTestEx.Assertors -{ - /// - /// Represents the test assert helper. - /// - /// The owning . - /// The . - public class HttpResultAssertor(TesterBase owner, HttpResult result) : HttpResponseMessageAssertor(owner, result.ThrowIfNull(nameof(result)).Response) - { - /// - /// Gets the . - /// - public HttpResult Result { get; private set; } = result; - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Assertors/HttpResultAssertorT.cs b/src/CoreEx.UnitTesting/Assertors/HttpResultAssertorT.cs deleted file mode 100644 index 330f073d..00000000 --- a/src/CoreEx.UnitTesting/Assertors/HttpResultAssertorT.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Http; -using System; -using UnitTestEx.Abstractions; - -namespace UnitTestEx.Assertors -{ - /// - /// Represents the test assert helper with a specified result . - /// - /// - public class HttpResultAssertor : HttpResponseMessageAssertor - { - /// - /// Initializes a new instance of the class. - /// - /// The owning . - /// The . - public HttpResultAssertor(TesterBase owner, HttpResult result) : base(owner, result.ThrowIfNull(nameof(result)).Response) => Result = result; - - /// - /// Initializes a new instance of the class. - /// - /// The owning . - /// The value already deserialized. - /// - public HttpResultAssertor(TesterBase owner, TValue value, HttpResult result) : base(owner, value, result.ThrowIfNull(nameof(result)).Response) => Result = result; - - /// - /// Gets the . - /// - public HttpResult Result { get; private set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj b/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj index c4578e96..d4e01411 100644 --- a/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj +++ b/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj @@ -1,24 +1,18 @@  - - net6.0;net8.0;net9.0 - UnitTestEx - CoreEx - CoreEx UnitTesting extras. - CoreEx UnitTesting (UnitTestEx) extras. - coreex unittest unit-test test unittestex - - - - - - - + + + + - + + + + + - + \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Data/JsonDataReader.cs b/src/CoreEx.UnitTesting/Data/JsonDataReader.cs new file mode 100644 index 00000000..c1c87f52 --- /dev/null +++ b/src/CoreEx.UnitTesting/Data/JsonDataReader.cs @@ -0,0 +1,415 @@ +namespace CoreEx.UnitTesting.Data; + +/// +/// Provides a hierarchical mutating reader for JSON or YAML data with dynamic property substitution support using the venerable . +/// +/// This is not intended for high-volume or high-performance use; more for the likes of basic dynamic data seeding scenarios in unit tests. +public sealed partial class JsonDataReader +{ + private static readonly Regex _regex = EmbeddedDynamicParametersRegex(); + + private readonly JsonNode _rootNode; + + /// + /// Initializes a new instance of the class. + /// + private JsonDataReader(JsonNode root, JsonDataReaderOptions? options) + { + _rootNode = root.ThrowIfNull().ThrowWhen(root => root is not JsonObject, "JSON root node must be a JsonObject."); + Options = options ?? new JsonDataReaderOptions(); + } + + /// + /// Gets the . + /// + public JsonDataReaderOptions Options { get; } + + /// + /// Gets the root . + /// + public JsonNode RootNode => _rootNode; + + /// + /// Tries to get the for the specified . + /// + /// The qualified path to support child navigation. + /// The found. + /// indicates found; otherwise, false. + public bool TryGetPath(string path, out JsonNode? jsonNode) + { + jsonNode = JsonFilter.GetMatched(_rootNode.DeepClone(), path); + return jsonNode is not null; + } + + /// + /// Tries to create the replacing any dynamic parameters for the specified . + /// + /// The qualified path to support child navigation. + /// The resulting with all dynamic parameters replaced where possible. + /// indicates created; otherwise, false. + public bool TryCreateData(string path, [NotNullWhen(true)] out JsonNode? jsonNode) + { + jsonNode = null; + + if (!TryGetPath(path, out var jn)) + return false; + + jsonNode = CopyAndReplace(jn, new JsonDataReaderArgs(Options.Parameters) { Root = jn, CurrentPropertyName = null, CurrentNode = null, Properties = Options.Properties, ApplyProperties = true }); + return jsonNode is not null; + } + + /// + /// Copies the JSON and replaces any dynamic parameters. + /// + private JsonNode? CopyAndReplace(JsonNode? jn, JsonDataReaderArgs args) + { + if (jn is null) + return null; + + switch (jn) + { + case JsonArray ja: + var newArray = new JsonArray(); + for (int i = 0; i < ja.Count; i++) + { + var item = ja[i]; + var newItem = CopyAndReplace(item, new JsonDataReaderArgs(args.Parameters) { Root = args.Root, CurrentPropertyName = null, CurrentNode = null, Index = i, Properties = args.Properties, ApplyProperties = args.ApplyProperties }); + newArray.Add(newItem); + } + + return newArray; + + case JsonObject jo: + if (args.ApplyProperties) + Options.RootNodePreProcessor?.Invoke(new JsonDataReaderArgs(args.Parameters) { Root = args.Root, CurrentPropertyName = args.CurrentPropertyName, CurrentNode = jo, Properties = args.Properties, Index = args.Index }); + + var newObject = new JsonObject(); + foreach (var kvp in jo) + { + var newValue = CopyAndReplace(kvp.Value, new JsonDataReaderArgs(args.Parameters) { Root = args.Root, CurrentPropertyName = kvp.Key, CurrentNode = kvp.Value, Properties = args.Properties, Index = args.Index }); + newObject[kvp.Key] = newValue; + } + + if (args.ApplyProperties && Options.Properties.Count > 0) + ApplyPropertiesWhereNotFound(jo, newObject, new JsonDataReaderArgs(args.Parameters) { Root = args.Root, CurrentPropertyName = null, CurrentNode = null, Properties = args.Properties, Index = args.Index }); + + args.ApplyProperties = false; + return newObject; + + case JsonValue jv: + return ReplaceDynamicParameter(jv, args); + + default: + return jn.DeepClone(); + } + } + + /// + /// Apply properties where not found in the source JSON. + /// + private void ApplyPropertiesWhereNotFound(JsonObject sourceObject, JsonObject targetObject, JsonDataReaderArgs args) + { + // Apply the args properties first. + if (args.Properties is not null) + { + foreach (var ap in args.Properties) + { + if (sourceObject.ContainsKey(ap.Key)) + continue; + + object? val = ap.Value; + if (ap.Value is string str && !string.IsNullOrEmpty(str) && str.Length > 1 && str[0] == '^') + { + if (TryGetDynamicValue(str[1..], args, out var v)) + val = v; + } + + if (val is not null) + targetObject[ap.Key] = CreateJsonValue(val); + } + } + + // Apply the options properties second. + foreach (var p in Options.Properties) + { + if (sourceObject.ContainsKey(p.Key) || (args.Properties is not null && args.Properties.ContainsKey(p.Key))) + continue; + + object? val = p.Value; + if (p.Value is string str && !string.IsNullOrEmpty(str) && str.Length > 1 && str[0] == '^') + { + if (TryGetDynamicValue(str[1..], args, out var v)) + val = v; + } + + if (val is not null) + targetObject[p.Key] = CreateJsonValue(val); + } + } + + /// + /// Replace any '^xxx' dynamic placeholders. + /// + private static JsonNode? ReplaceDynamicParameter(JsonValue jv, JsonDataReaderArgs args) + { + if (jv.GetValueKind() != JsonValueKind.String) + return jv.DeepClone(); + + var str = jv.GetValue(); + if (!string.IsNullOrEmpty(str) && str.Length > 1 && str[0] == '^') + { + if (TryGetDynamicValue(str[1..], args, out var val)) + { + if (val is string str2 && str2 is not null && ReplaceEmbeddedDynamicParameters(ref str2!, args)) + val = str2; + + return CreateJsonValue(val); + } + } + + if (ReplaceEmbeddedDynamicParameters(jv, args, out var replacedNode)) + return replacedNode; + + return jv.DeepClone(); + } + + /// + /// Replace any embedded '(^xxx)' dynamic placeholders. + /// + private static bool ReplaceEmbeddedDynamicParameters(JsonValue jv, JsonDataReaderArgs args, out JsonNode? result) + { + result = null; + + if (jv.TryGetValue(out var str)) + { + if (ReplaceEmbeddedDynamicParameters(ref str, args)) + { + result = CreateJsonValue(str); + return true; + } + } + + return false; + } + + /// + /// Replace any embedded '(^xxx)' dynamic placeholders. + /// + private static bool ReplaceEmbeddedDynamicParameters(ref string? str, JsonDataReaderArgs args) + { + if (string.IsNullOrEmpty(str)) + return false; + + var sb = new StringBuilder(); + int i = 0; + foreach (var match in _regex.EnumerateMatches(str)) + { + sb.Append(str.AsSpan(i, match.Index - i)); + var key = str.Substring(match.Index, match.Length); + if (TryGetDynamicValue(key[2..^1], args, out var val)) + { + var str2 = val?.ToString(); + ReplaceEmbeddedDynamicParameters(ref str2, args); + sb.Append(str2); + } + else + sb.Append(key); + + i = match.Index + match.Length; + } + + if (sb.Length == 0) + return false; + + sb.Append(str.AsSpan(i, str.Length - i)); + str = sb.ToString(); + return true; + } + + /// + /// Tries to get the dynamic value. + /// + private static bool TryGetDynamicValue(string key, JsonDataReaderArgs args, out object? value) + { + if (args.Parameters.TryGetValue(key, out var func)) + { + value = func(args); + if (value is string str && str is not null && ReplaceEmbeddedDynamicParameters(ref str!, args)) + value = str; + + return true; + } + else if (int.TryParse(key, out var i)) + { + value = new Guid(i, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + return true; + } + + value = null; + return false; + } + + /// + /// Creates a from a .NET value. + /// + private static JsonValue? CreateJsonValue(object? val) + { + if (val is null) + return null; + + return val switch + { + string sv => JsonValue.Create(sv), + Guid gv => JsonValue.Create(gv), + DateTime dv => JsonValue.Create(dv), + DateTimeOffset ov => JsonValue.Create(ov), + DateOnly dv => JsonValue.Create(dv.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)), + TimeOnly tv => JsonValue.Create(tv.ToString("HH:mm:ss.FFFFFFF", System.Globalization.CultureInfo.InvariantCulture)), + bool bv => JsonValue.Create(bv), + short nsv => JsonValue.Create(nsv), + int niv => JsonValue.Create(niv), + long nlv => JsonValue.Create(nlv), + ushort nusv => JsonValue.Create(nusv), + uint nuiv => JsonValue.Create(nuiv), + ulong nulv => JsonValue.Create(nulv), + decimal ndv => JsonValue.Create(ndv), + double n2v => JsonValue.Create(n2v), + float nfv => JsonValue.Create(nfv), + _ => JsonValue.Create(val.ToString()) + }; + } + + /// + /// Tries to create a new replacing any dynamic parameters for the specified and deserializes to the specified . + /// + /// The to deserialize to. + /// The qualified path to support child navigation. + /// The optional . + /// The deserialized value where found; otherwise, . + public T? Deserialize(string path, JsonSerializerOptions? options = null) + { + if (!TryCreateData(path, out var jsonNode)) + return default; + + return JsonSerializer.Deserialize(jsonNode, options ?? JsonDefaults.SerializerOptions); + } + + #region ParseYaml+Json + + /// + /// Reads and parses the YAML from the named embedded resource within the inferred from the . + /// + /// The to infer the to find manifest resources (see ). + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The optional . + /// The . + public static JsonDataReader ParseYaml(string resourceName, JsonDataReaderOptions? options = null) => ParseYaml(CoreEx.Abstractions.Resource.GetStream(resourceName), options); + + /// + /// Reads and parses the YAML . + /// + /// The YAML . + /// The optional . + /// The . + public static JsonDataReader ParseYaml(string yaml, JsonDataReaderOptions? options = null) + { + using var sr = new StringReader(yaml); + return ParseYaml(sr, options); + } + + /// + /// Reads and parses the YAML . + /// + /// The YAML . + /// The optional . + /// The . + public static JsonDataReader ParseYaml(Stream s, JsonDataReaderOptions? options = null) => ParseYaml(new StreamReader(s), options); + + /// + /// Reads and parses the YAML . + /// + /// The YAML . + /// The optional . + /// The . + public static JsonDataReader ParseYaml(TextReader tr, JsonDataReaderOptions? options = null) + { + var yaml = new DeserializerBuilder().WithNodeTypeResolver(new YamlNodeTypeResolver()).Build().Deserialize(tr); + var json = new SerializerBuilder().JsonCompatible().Build().Serialize(yaml!); + return new(JsonNode.Parse(json) ?? throw new InvalidOperationException("JsonNode.Parse resulted in a null."), options); + } + + /// + /// Reads and parses the JSON from the named embedded resource within the inferred from the . + /// + /// The to infer the to find manifest resources (see ). + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The optional . + /// The . + public static JsonDataReader ParseJson(string resourceName, JsonDataReaderOptions? options = null) => ParseJson(CoreEx.Abstractions.Resource.GetStream(resourceName), options); + + /// + /// Reads and parses the JSON . + /// + /// The JSON . + /// The optional . + /// The . + public static JsonDataReader ParseJson([StringSyntax(StringSyntaxAttribute.Json)] string json, JsonDataReaderOptions? options = null) + => new(JsonNode.Parse(json) ?? throw new InvalidOperationException("JsonNode.Parse resulted in a null."), options); + + /// + /// Reads and parses the JSON . + /// + /// The JSON . + /// The optional . + /// The . + public static JsonDataReader ParseJson(Stream s, JsonDataReaderOptions? options = null) => new(JsonNode.Parse(s) ?? throw new InvalidOperationException("JsonNode.Parse resulted in a null."), options); + + /// + /// Reads and parses the . + /// + /// The . + /// The optional . + /// The . + public static JsonDataReader ParseJson(JsonNode jsonNode, JsonDataReaderOptions? options = null) => new(jsonNode, options); + + #endregion + + /// + /// A custom to support the YAML to JSON conversion of boolean and number types. + /// + private sealed class YamlNodeTypeResolver : INodeTypeResolver + { + private static readonly string[] boolValues = ["true", "false"]; + + /// + bool INodeTypeResolver.Resolve(NodeEvent? nodeEvent, ref Type currentType) + { + if (nodeEvent is Scalar scalar && scalar.Style == YamlDotNet.Core.ScalarStyle.Plain) + { + if (decimal.TryParse(scalar.Value, out _)) + { + if (scalar.Value.Length > 1 && scalar.Value.StartsWith('0')) // Valid JSON does not support a number that starts with a zero. + currentType = typeof(string); + else + currentType = typeof(decimal); + + return true; + } + + if (boolValues.Contains(scalar.Value)) + { + currentType = typeof(bool); + return true; + } + } + + return false; + } + } + + /// + /// Provides the generated for . + /// + [GeneratedRegex(@"\(\^(.*?)\)", RegexOptions.Compiled)] + private static partial Regex EmbeddedDynamicParametersRegex(); +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Data/JsonDataReaderArgs.cs b/src/CoreEx.UnitTesting/Data/JsonDataReaderArgs.cs new file mode 100644 index 00000000..e4d73077 --- /dev/null +++ b/src/CoreEx.UnitTesting/Data/JsonDataReaderArgs.cs @@ -0,0 +1,44 @@ +namespace CoreEx.UnitTesting.Data; + +/// +/// Provides the runtime arguments for the . +/// +/// The dynamic runtime parameters and their corresponding functions. +public class JsonDataReaderArgs(IDictionary> parameters) +{ + /// + /// Gets the originating that represents the root for the data. + /// + public JsonNode? Root { get; init; } + + /// + /// Gets the current source property name. + /// + /// Where the is this indicates that no source property is available; i.e. adding new . + public string? CurrentPropertyName { get; internal set; } + + /// + /// Gets the current source value. + /// + public JsonNode? CurrentNode { get; internal set; } + + /// + /// Gets the current array index where the is an element within a ; otherwise, . + /// + public int? Index { get; internal set; } + + /// + /// Gets the standard properties that are required for each resulting . + /// + public IDictionary? Properties { get; internal set; } + + /// + /// Gets the dynamic runtime parameters and their corresponding functions. + /// + public IDictionary> Parameters { get; } = parameters; + + /// + /// Indicates whether the and must be applied. + /// + internal bool ApplyProperties { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Data/JsonDataReaderOptions.cs b/src/CoreEx.UnitTesting/Data/JsonDataReaderOptions.cs new file mode 100644 index 00000000..f3a93a36 --- /dev/null +++ b/src/CoreEx.UnitTesting/Data/JsonDataReaderOptions.cs @@ -0,0 +1,122 @@ +namespace CoreEx.UnitTesting.Data; + +/// +/// Provides options for the . +/// +/// The following are configured out-of-the-box: +/// +/// 'id' - Generates a new identifier using . +/// 'guid' - Generates a new GUID using . +/// 'now' - Gets the current UTC using . +/// 'tomorrow' - Gets the UTC for tomorrow using plus 1 day. +/// 'yesterday' - Gets the UTC for yesterday using minus 1 day. +/// 'tenantId' - Gets the current . +/// 'userId' - Gets the current . +/// 'userName' - Gets the current . +/// 'index' - Gets the current array index where the is an element within a ; otherwise, zero. +/// +/// +public class JsonDataReaderOptions +{ + /// + /// Initializes a new instance of the class. + /// + public JsonDataReaderOptions() + { + Parameters = new(StringComparer.OrdinalIgnoreCase) + { + { "id", _ => Runtime.NewId() }, + { "guid", _ => Runtime.NewGuid() }, + { "now", _ => Runtime.UtcNow }, + { "tomorrow", _ => Runtime.UtcNow.AddDays(1) }, + { "yesterday", _ => Runtime.UtcNow.AddDays(-1) }, + { "tenantId", _ => ExecutionContext.TryGetCurrent(out var ec) ? ec.TenantId : TenantId }, + { "userId", _ => ExecutionContext.TryGetCurrent(out var ec) && ec.User is not null ? ec.User.Id : AuthenticationUser.EnvironmentUser.Id }, + { "userName", _ => ExecutionContext.TryGetCurrent(out var ec) && ec.User is not null ? ec.User.UserName : AuthenticationUser.EnvironmentUser.UserName }, + { "index", args => args?.Index ?? 0 } + }; + } + + /// + /// Gets the dynamic runtime parameters and their corresponding functions. + /// + public Dictionary> Parameters { get; } + + /// + /// Gets the properties that will be applied to the root-most where not already present. + /// + /// Root-most means the top-level JSON object in the hierarchy; i.e. if the root is a JSON array, then the direct child objects would be considered root-most. + public Dictionary Properties { get; } = []; + + /// + /// Gets or sets the default where it can not be obtained from the current . + /// + public string? TenantId { get; set; } + + /// + /// Gets or sets a delegate that is invoked for each root-most to allow pre-processing. + /// + /// This can be used to customize processing of the root-most node (see ) prior to further processing, such as adding or modifying properties, etc. Once complete, then the standard substitution and property application occurs. + /// Root-most means the top-level JSON object in the hierarchy; i.e. if the root is a JSON array, then the direct child objects would be considered root-most. + public Action? RootNodePreProcessor { get; set; } + + /// + /// Adds standard properties to the root where not already present. + /// + /// The to support fluent-style method-chaining. + /// The following standard properties are included: + /// + /// 'createdOn' - Set to '^now'. + /// 'createdBy' - Set to '^username'. + /// 'tenantId' - Set to '^tenantId'. + /// + /// + public JsonDataReaderOptions AddStandardProperties() + { + Properties.TryAdd("createdOn", "^now"); + Properties.TryAdd("createdBy", "^username"); + Properties.TryAdd("tenantId", "^tenantId"); + return this; + } + + /// + /// Creates a instance configured for reference data. + /// + /// An optional function to generate the . + /// The . + /// This method will configure the to convert a single key/value pair into 'code' and 'text' properties by convention. + /// The following additional are included in addition to the : + /// + /// 'id' - Uses the where provided; otherwise, '^id'. + /// 'isActive' - Set to . + /// 'sortOrder' - Uses the current array index where the is an element within a ; otherwise, zero. + /// + /// + public static JsonDataReaderOptions CreateForReferenceData(Func? idGenerator = null) + { + var o = new JsonDataReaderOptions().AddStandardProperties(); + o.Properties.TryAdd("id", idGenerator is null ? "^id" : "^__idGenerator"); + o.Properties.TryAdd("isActive", true); + o.Properties.TryAdd("sortOrder", "^index"); + + if (idGenerator is not null) + o.Parameters.TryAdd("__idGenerator", _ => idGenerator()); + + o.RootNodePreProcessor = args => + { + // Where only a single property exists, then assume it is the code & text pair by convention. + if (args.CurrentNode is JsonObject jsonObject && jsonObject.TryGetNonEnumeratedCount(out var count) && count == 1) + { + var kvp = jsonObject.First(); + if (kvp.Value is not null && kvp.Value.GetValueKind() == JsonValueKind.String) + { + jsonObject.Remove(kvp.Key); + jsonObject.Add("code", kvp.Key); + jsonObject.Add("text", kvp.Value); + } + } + }; + + return o; + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Events/EventExpecationsConfig.cs b/src/CoreEx.UnitTesting/Events/EventExpecationsConfig.cs new file mode 100644 index 00000000..7594c829 --- /dev/null +++ b/src/CoreEx.UnitTesting/Events/EventExpecationsConfig.cs @@ -0,0 +1,430 @@ +namespace CoreEx.UnitTesting.Events; + +/// +/// Provides the configuration for a specific service key, enabling the configuration of expected events and their assertion during testing. +/// +/// Where expected events have been specified they will be matched, in the sequence specified, against the actual events published during the test execution. +public sealed class EventExpectationsConfig +{ + private bool _expectNoEvents; + private readonly List _assertors = []; + private readonly List _pathsToIgnore = [.. DefaultPathsToIgnore]; + private Action? _assertAllEvents; + + /// + /// Gets or sets the default metadata paths to ignore for comparisons. + /// + public static List DefaultMetadataPathsToIgnore { get; set => field = value.ThrowIfNull(); } = ["id", "time", "subject", "partitionkey", "authtype", "authid", "traceparent", "tracestate", "baggage"]; + + /// + /// Gets or sets the default data paths to ignore for comparisons. + /// + public static List DefaultDataPathsToIgnore { get; set => field = value.ThrowIfNull(); } = ["data.id", "data.changelog", "data.etag"]; + + /// + /// Gets the default JSON paths to ignore for comparisons, which is a combination of the and . + /// + public static List DefaultPathsToIgnore => [.. DefaultMetadataPathsToIgnore, .. DefaultDataPathsToIgnore]; + + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The registered service key. + /// The request identifier. + /// The assembly to use for resource resolution. + internal EventExpectationsConfig(TesterBase tester, string serviceKey, string? requestId, Assembly assembly) + { + Tester = tester; + ServiceKey = serviceKey; + RequestId = requestId; + ResourceAssembly = assembly; + } + + /// + /// Gets the owning . + /// + internal TesterBase Tester { get; } + + /// + /// Gets the registered service key for the underlying . + /// + internal string ServiceKey { get; } + + /// + /// Gets the request identifier. + /// + internal string? RequestId { get; } + + /// + /// Gets the assembly to use for resource resolution. + /// + internal Assembly ResourceAssembly { get; } + + /// + /// Gets the current list of JSON paths to ignore for all event comparisons. + /// + /// Defaults to . + /// Note: this list is copied and concatenated with the path(s) specified for the individual event expectations. Therefore, any changes after an event expectation is made, will not be included; i.e. only + /// applies to subsequent event expectations. This is to ensure consistency for the individual event expectation configurations, and to avoid any unintended consequences of changes to the default paths + /// after preceding event expectations have been configured. + public List PathsToIgnore => _pathsToIgnore; + + /// + /// Indicates that no events should have been published. + /// + internal void ExpectNoEvents() + { + if (_assertors.Count != 0) + throw new InvalidOperationException("Cannot set to expect no events when event expectations have already been configured."); + + _expectNoEvents = true; + } + + /// + /// Indicates that events should have been published. + /// + /// + internal void ExpectEvents() + { + if (_expectNoEvents) + throw new InvalidOperationException("Cannot set to expect events when already configured to expect no events."); + + if (_assertAllEvents is not null) + throw new InvalidOperationException($"Cannot set to expect events when already configured using {nameof(AssertAllFromJsonResource)}, {nameof(AssertCount)}, or {nameof(Assert)}."); + } + + /// + /// Gets or sets the factory function used to create new instance. + /// + public Func ExecutionContextFactory { get; set; } = sp => new ExecutionContext(); + + /// + /// Gets or sets the ; where then the registered version from the will be used. + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// Gets or sets the ; where then the registered host version from the will be used. + /// + public IHostSettings? HostSettings { get; set; } + + /// + /// Gets or sets the ; where then the registered version from the will be used. + /// + public IEventFormatter? EventFormatter { get; set; } + + /// + /// Adds an expectation assertion that the consumer of the action is responsible for asserting as required. + /// + /// The actual array. + /// The to support fluent-style method-chaining. + public EventExpectationsConfig Assert(Action events) + { + ExpectEvents(); + events.ThrowIfNull(); + + _assertAllEvents = (_, actual) => + { + events(actual); + Tester.Implementor.WriteLine($" > Expected zero or more event(s) with a custom Assert; and that assertion was met."); + }; + + return this; + } + + /// + /// Adds am expectation assertion that asserts that the number of published events matches the specified .. + /// + /// The expected number of events to be published. + /// The to support fluent-style method-chaining. + public EventExpectationsConfig AssertCount(int count) + { + ExpectEvents(); + count.ThrowIfLessThanOrEqualToZero(); + + _assertAllEvents = (_, actual) => + { + if (count == actual.Length) + Tester.Implementor.WriteLine($" > Expected {count} event(s); and that number was found to be published."); + else + Tester.Implementor.AssertFail($"Expected {_assertors.Count} '{ServiceKey}' events; however, {actual.Length} were found to be published."); + }; + + return this; + } + + /// + /// Adds an expectation assertion that one or more events were published matching the array from the specified JSON resource (with any specified JSON paths to ignore). + /// + /// The to infer the for the embedded resource. + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + /// The should be from an individual perspective from a consistency perspective. + public EventExpectationsConfig AssertAllFromJsonResource(string resourceName, params IEnumerable pathsToIgnore) + => AssertAllFromJsonResource(resourceName, typeof(TAssembly).Assembly, pathsToIgnore); + + /// + /// Adds an expectation assertion that one or more events were published matching the array from the specified JSON resource (with any specified JSON paths to ignore). + /// + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + /// The should be from an individual perspective from a consistency perspective. + public EventExpectationsConfig AssertAllFromJsonResource(string resourceName, params IEnumerable pathsToIgnore) + => AssertAllFromJsonResource(resourceName, null, pathsToIgnore); + + /// + /// Adds an expectation assertion that one or more events were published matching the array from the specified JSON resource (with any specified JSON paths to ignore). + /// + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// The that contains the resource; defaults to . + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + /// The should be from an individual perspective from a consistency perspective. + public EventExpectationsConfig AssertAllFromJsonResource(string resourceName, Assembly? assembly = null, params IEnumerable pathsToIgnore) + { + ExpectEvents(); + + assembly ??= ResourceAssembly; + var capturedPaths = CombinePaths(pathsToIgnore).Select(x => $"event.{(x.StartsWith("$.") ? x[2..] : x)}").ToArray(); + _assertAllEvents = (args, events) => + { + var ej = Resource.GetJson(resourceName, assembly); + var aj = JsonSerializer.Serialize(events.Select(kvp => new { destination = kvp.Destination, @event = kvp.Event.EncodeToJsonElement() })); + + ObjectComparer.AssertJson(new UnitTestEx.Json.JsonElementComparerOptions { PreambleText = $"'{ServiceKey}' event(s) comparison." }, ej, aj, capturedPaths); + Tester.Implementor.WriteLine(" > All event expectations met successfully."); + }; + + return this; + } + + /// + /// Adds an expectation assertion that an event was published where the returned value will be used as the source of the . + /// + /// The expected destination (i.e. topic) name. + /// The expected (. + /// The expected . + /// An optional action to further update the expected prior to assertion. + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + /// Internally this constructs the and converts it to a for comparison. + public EventExpectationsConfig AssertWithValue(string destination, string title, CoreEx.Events.MessageType messageType = CoreEx.Events.MessageType.Event, Action? updater = null, params IEnumerable pathsToIgnore) + { + ExpectEvents(); + + _assertors.Add(new EventExpectationAssertor(this, destination, (assertor, args, _) => + { + var ed = new EventData() { Title = title, MessageType = messageType }.WithValue(args.Value, null, assertor.JsonSerializerOptions); + updater?.Invoke(ed); + return assertor.EventFormatter.ConvertToCloudEvent(assertor.EventFormatter.Format(ed)); + }, CombinePaths(pathsToIgnore))); + + return this; + } + + /// + /// Adds an expectation assertion that an event was published matching the specified converted as a (with any specified JSON paths to ignore). + /// + /// The expected destination (i.e. topic) name. + /// The . + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + public EventExpectationsConfig AssertEventData(string destination, EventData eventData, params IEnumerable pathsToIgnore) + { + ExpectEvents(); + eventData.ThrowIfNull(); + + _assertors.Add(new EventExpectationAssertor(this, destination, (assertor, args, _) => + { + return assertor.EventFormatter.ConvertToCloudEvent(assertor.EventFormatter.Format(eventData)); + }, CombinePaths(pathsToIgnore))); + return this; + } + + /// + /// Adds an expectation assertion that an event was published matching the primary metadata (where specified) only. + /// + /// The expected destination (i.e. topic) name. + /// The expected () glob-like matching pattern that will represent the underlying title . + /// The expected () value. + /// The expected () glob-like matching pattern that will represent the underlying source . + /// The to support fluent-style method-chaining. + /// The and both support glob-like matching patterns. + public EventExpectationsConfig AssertMetadata(string destination, string? title = null, string? key = null, string? source = null) + { + ExpectEvents(); + + _assertors.Add(new EventExpectationAssertor(this, destination, (assertor, args, actual) => + { + assertor.AssertDestination(actual.Destination); + + var sa = new SubscribeAttribute(title, source); + if (sa.IsMatch(actual.Event?.Type, actual.Event?.Source)) + { + if (key != null) + assertor.Tester.Implementor.AssertAreEqual(key, actual.Event?.Subject, $"Expected key '{key}'; but found '{actual.Event?.Subject}'."); + } + else + assertor.Tester.Implementor.AssertFail($"Expected event metadata did not match; Title expected '{title}' vs actual '{actual.Event?.Type}', Source expected '{source}' vs actual '{actual.Event?.Source}'"); + }, [])); + + return this; + } + + /// + /// Adds an expectation assertion that an event was published matching the specified (with any specified JSON paths to ignore). + /// + /// The expected destination (i.e. topic) name. + /// The expected . + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + public EventExpectationsConfig AssertCloudEvent(string destination, CloudEvent cloudEvent, params IEnumerable pathsToIgnore) + { + ExpectEvents(); + _assertors.Add(new EventExpectationAssertor(this, destination, (_, _, _) => cloudEvent, pathsToIgnore)); + return this; + } + + /// + /// Adds an expectation assertion that an event was published matching the from the specified JSON resource (with any specified JSON paths to ignore). + /// + /// The to infer the for the embedded resource. + /// The expected destination (i.e. topic) name. + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + public EventExpectationsConfig AssertCloudEventFromJsonResource(string destination, string resourceName, params IEnumerable pathsToIgnore) + => AssertCloudEventFromJsonResource(destination, resourceName, null, pathsToIgnore); + + /// + /// Adds an expectation assertion that an event was published matching the from the specified JSON resource (with any specified JSON paths to ignore). + /// + /// The to infer the for the embedded resource. + /// The expected destination (i.e. topic) name. + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// An optional action to update the before use. + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + public EventExpectationsConfig AssertCloudEventFromJsonResource(string destination, string resourceName, Action? updater = null, params IEnumerable pathsToIgnore) + { + ExpectEvents(); + + _assertors.Add(new EventExpectationAssertor(this, destination, (assertor, args, _) => + { + return assertor.Tester.CreateCloudEventFromJsonResource(resourceName, updater); + }, CombinePaths(pathsToIgnore))); + + return this; + } + + /// + /// Adds an expectation assertion that an event was published matching the from the specified JSON resource (with any specified JSON paths to ignore). + /// + /// The expected destination (i.e. topic) name. + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + public EventExpectationsConfig AssertCloudEventFromJsonResource(string destination, string resourceName, params IEnumerable pathsToIgnore) + => AssertCloudEventFromJsonResource(destination, resourceName, null, null, pathsToIgnore); + + /// + /// Adds an expectation assertion that an event was published matching the from the specified JSON resource (with any specified JSON paths to ignore). + /// + /// The expected destination (i.e. topic) name. + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// An optional action to update the before use. + /// The that contains the resource; defaults to . + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + public EventExpectationsConfig AssertCloudEventFromJsonResource(string destination, string resourceName, Action? updater = null, Assembly? assembly = null, params IEnumerable pathsToIgnore) + { + ExpectEvents(); + + _assertors.Add(new EventExpectationAssertor(this, destination, (assertor, args, _) => + { + return assertor.Tester.CreateCloudEventFromJsonResource(resourceName, updater, assembly ?? ResourceAssembly); + }, CombinePaths(pathsToIgnore))); + + return this; + } + + /// + /// Adds a custom expectation assertion that an event was published where the provided will be invoked to perform the assertion against the actual (with any specified JSON paths to ignore). + /// + /// The expected destination (i.e. topic) name. + /// The custom assert action. + /// Any additional JSON paths to ignore from the underlying comparison. + /// The to support fluent-style method-chaining. + /// The and methods can be used within the custom assert action for consistency. + public EventExpectationsConfig AssertCustom(string destination, Action customAssert, params IEnumerable pathsToIgnore) + { + ExpectEvents(); + _assertors.Add(new EventExpectationAssertor(this, destination, customAssert, pathsToIgnore)); + return this; + } + + /// + /// Combines the specified array of paths to ignore with the existing set of ignored paths. + /// + private string[] CombinePaths(IEnumerable pathsToIgnore) => _pathsToIgnore.Count == 0 ? [.. pathsToIgnore] : [.. _pathsToIgnore, .. pathsToIgnore]; + + /// + /// Performs the assertion of the expected events against those that were published, throwing an exception if any expectations were not met. + /// + /// The . + internal void Assert(AssertArgs args) + { + Tester.Implementor.WriteLine($" > '{ServiceKey}' events."); + + // High-level checks. + if (!Tester.SharedState.RequestStateData(RequestId).TryGetValue(ServiceKey, out var obj) || obj is not DestinationEvent[] events || events.Length == 0) + { + if (_expectNoEvents) + { + Tester.Implementor.WriteLine(" > Expected no events and there were none."); + return; + } + + args.Tester.Implementor.AssertFail($"Expected '{ServiceKey}' events; however, no events were found to be published."); + return; + } + + if (_expectNoEvents) + { + Tester.Implementor.AssertFail($"Expected no '{ServiceKey}'events; however, {events.Length} found to be published."); + return; + } + + if (_assertAllEvents is not null) + { + _assertAllEvents(args, events); + return; + } + + if (_assertors.Count == 0) + { + Tester.Implementor.WriteLine($" > Expected events; and {events.Length} found to be published."); + return; + } + + if (_assertors.Count != events.Length) + { + Tester.Implementor.AssertFail($"Expected {_assertors.Count} '{ServiceKey}' events; however, {events.Length} were found to be published."); + return; + } + + // Iterate and check each. + var index = 0; + foreach (var assertor in _assertors) + { + Tester.Implementor.WriteLine($" > Event {index + 1} of {_assertors.Count}."); + assertor.Assert(args, events[index]); + index++; + } + + Tester.Implementor.WriteLine(" > Event expectations met successfully."); + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Events/EventExpectationAssertor.cs b/src/CoreEx.UnitTesting/Events/EventExpectationAssertor.cs new file mode 100644 index 00000000..9ff0255e --- /dev/null +++ b/src/CoreEx.UnitTesting/Events/EventExpectationAssertor.cs @@ -0,0 +1,110 @@ +namespace CoreEx.UnitTesting.Events; + +/// +/// Provides assertion capabilities for an expected event. +/// +public sealed class EventExpectationAssertor +{ + private readonly EventExpectationsConfig _config; + private readonly string? _expectedDestination; + private readonly string[] _pathsToIgnore; + private readonly Func? _expectedEventFactory; + private readonly Action? _customAssert; + + private JsonSerializerOptions? _jsonSerializerOptions; + private IHostSettings? _hostSettings; + private IEventFormatter? _eventFormatter; + + /// + /// Initializes a new instance of the class with an . + /// + /// The owning . + /// The expected destination. + /// The function to create the expected . + /// The JSON paths to ignore. + internal EventExpectationAssertor(EventExpectationsConfig config, string? expectedDestination, Func expectedEventFactory, IEnumerable pathsToIgnore) + { + _config = config; + _expectedDestination = expectedDestination; + _expectedEventFactory = expectedEventFactory; + _pathsToIgnore = [.. pathsToIgnore]; + + // Copy out key attributes from the config for use in the assertion - need the now version. + _jsonSerializerOptions = _config.JsonSerializerOptions; + _hostSettings = _config.HostSettings; + _eventFormatter = _config.EventFormatter; + } + + /// + /// Initializes a new instance of the class with a . + /// + /// The owning . + /// The expected destination. + /// The custom assert action. + /// The JSON paths to ignore. + internal EventExpectationAssertor(EventExpectationsConfig config, string? expectedDestination, Action customAssert, IEnumerable pathsToIgnore) + { + _config = config; + _expectedDestination = expectedDestination; + _customAssert = customAssert; + _pathsToIgnore = [.. pathsToIgnore]; + + // Copy out key attributes from the config for use in the assertion - need the now version. + _jsonSerializerOptions = _config.JsonSerializerOptions; + _hostSettings = _config.HostSettings; + _eventFormatter = _config.EventFormatter; + } + + /// + /// Gets the owning . + /// + public TesterBase Tester => _config.Tester; + + /// + /// Gets or sets the ; where then the registered version from the will be used. + /// + public JsonSerializerOptions JsonSerializerOptions => _jsonSerializerOptions ??= _config.Tester.Services.GetService() ?? CoreEx.Json.JsonDefaults.SerializerOptions; + + /// + /// Gets the ; where then the registered host version from the will be used. + /// + public IHostSettings HostSettings => _hostSettings ??= _config.Tester.Services.GetService() ?? throw new InvalidOperationException($"A {nameof(IHostSettings)} instance is required to perform the event assertion; either set on the config or ensure it is registered in the services."); + + /// + /// Gets the ; where then the registered version from the will be used. + /// + public IEventFormatter EventFormatter => _eventFormatter ??= _config.Tester.Services.GetService() ?? new EventFormatter(HostSettings); + + /// + /// Asserts that the expected and actual are equal, ignoring any specified paths. + /// + /// The . + /// The actual . + internal void Assert(AssertArgs args, DestinationEvent actual) + { + AssertDestination(actual.Destination); + + if (_expectedEventFactory is not null) + AssertCloudEvent(_expectedEventFactory(this, args, actual), actual.Event); + else + _customAssert?.Invoke(this, args, actual); + } + + /// + /// Asserts that the previously configured expected destination and destination are equal. + /// + /// The actual destination. + public void AssertDestination(string actual) => Tester.Implementor.AssertAreEqual(_expectedDestination, actual, $"Expected '{_config.ServiceKey}' event destination '{_expectedDestination}'; but found '{actual}'."); + + /// + /// Asserts that the expected and actual are equal, ignoring any previously configured JSON paths. + /// + /// The expected . + /// The actual . + public void AssertCloudEvent(CloudEvent expected, CloudEvent actual) + { + var ej = expected?.EncodeToJsonElement(JsonSerializerOptions) ?? null; + var aj = actual?.EncodeToJsonElement(JsonSerializerOptions) ?? null; + ObjectComparer.Assert(new UnitTestEx.Json.JsonElementComparerOptions { PreambleText = $"'{_config.ServiceKey}' event comparison." }, ej, aj, _pathsToIgnore); + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Events/EventExpectations.cs b/src/CoreEx.UnitTesting/Events/EventExpectations.cs new file mode 100644 index 00000000..0d66b47e --- /dev/null +++ b/src/CoreEx.UnitTesting/Events/EventExpectations.cs @@ -0,0 +1,88 @@ +namespace CoreEx.UnitTesting.Events; + +/// +/// Provides -specific expectations for unit testing. +/// +/// The tester . +public class EventExpectations : ExpectationsBase +{ + private readonly Dictionary _expectations = []; + + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The initiating tester. + /// The request identifier. + /// The assembly to use for resource resolution. + internal EventExpectations(TesterBase owner, TTester tester, string? requestId, Assembly assembly) : base(owner, tester) + { + RequestId = requestId; + ResourceAssembly = assembly; + } + + /// + public override string Title => "Event expectations"; + + /// + /// Overrides to ensure it occurs after majority. + public override int Order => 5000; + + /// + /// Gets the request identifier. + /// + internal string? RequestId { get; } + + /// + /// Gets the assembly to use for resource resolution. + /// + internal Assembly ResourceAssembly { get; } + + /// + /// Expects that no events will have been published for the keyed . + /// + /// The service key used for the keyed registration. + /// The must be the same as used when registering the underlying . + public void ExpectNoEvents(string serviceKey) + { + GetOrAddConfig(serviceKey).ExpectNoEvents(); + } + + /// + /// Expects that events will have been published for the keyed . + /// + /// The service key used for the keyed registration. + /// The action to enable events expectations configuration. + /// The must be the same as used when registering the underlying . + public void ExpectEvents(string serviceKey, Action? configure = null) + { + var config = GetOrAddConfig(serviceKey); + config.ExpectEvents(); + configure?.Invoke(config); + } + + /// + /// Gets or adds the expectation configuration for the specified service key. + /// + private EventExpectationsConfig GetOrAddConfig(string serviceKey) + { + if (!_expectations.TryGetValue(serviceKey.ThrowIfNullOrEmpty(), out var config)) + { + config = new EventExpectationsConfig(Owner, serviceKey, RequestId, ResourceAssembly); + _expectations[serviceKey] = config; + } + + return config; + } + + /// + protected override Task OnAssertAsync(AssertArgs args) + { + foreach (var kvp in _expectations) + { + kvp.Value.Assert(args); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Events/EventPublisherDecorator.cs b/src/CoreEx.UnitTesting/Events/EventPublisherDecorator.cs new file mode 100644 index 00000000..0d08e3bc --- /dev/null +++ b/src/CoreEx.UnitTesting/Events/EventPublisherDecorator.cs @@ -0,0 +1,80 @@ +namespace CoreEx.UnitTesting.Events; + +/// +/// Provides a decorator for an event publisher that integrates with enabling additional test-related behaviors while delegating event publishing operations to the actual underlying publisher. +/// +/// The key used to reference the published events in the shared state. +/// The shared test state used to coordinate or track event publishing during tests. +/// The underlying event publisher to which all event publishing operations are delegated. +/// This decorator is typically used in testing scenarios to augment and observe event publishing without modifying the core event publisher implementation. All publishing operations are forwarded +/// to the specified inner event publisher. +public class EventPublisherDecorator(string key, TestSharedState testSharedState, IEventPublisher innerEventPublisher) : IEventPublisher +{ + private readonly TestSharedState _sharedState = testSharedState.ThrowIfNull(); + private readonly IEventPublisher _innerEventPublisher = innerEventPublisher.ThrowIfNull(); + + /// + /// Gets the key used to reference the published events in the shared state. + /// + /// This key is typically the same as used to register the underlying service itself. + public string Key { get; } = key.ThrowIfNullOrEmpty(); + + /// + public bool HasBeenPublished => _innerEventPublisher.HasBeenPublished; + + /// + public bool IsEmpty => _innerEventPublisher.IsEmpty; + + /// + public int Count => _innerEventPublisher.Count; + + /// + public void Add(IEnumerable events) => _innerEventPublisher.Add(events); + + /// + public void Add(string destination, IEnumerable events) => _innerEventPublisher.Add(destination, events); + + /// + public void Add(string destination, IEnumerable events) => _innerEventPublisher.Add(destination, events); + + /// + public void Add(params EventData[] events) => _innerEventPublisher.Add(events); + + /// + public void Add(string destination, params EventData[] events) => _innerEventPublisher.Add(destination, events); + + /// + public void Add(string destination, params CloudEvent[] events) => _innerEventPublisher.Add(destination, events); + + /// + public void Add(IEnumerable events) => _innerEventPublisher.Add(events); + + /// + public void Clear() => _innerEventPublisher.Clear(); + + /// + public void Reset() => _innerEventPublisher.Reset(); + + /// + public void Rollback(int count) => _innerEventPublisher.Rollback(count); + + /// + public DestinationEvent[] GetEvents() => _innerEventPublisher.GetEvents(); + + /// + public async Task PublishAsync(CancellationToken cancellationToken = default) + { + var events = GetEvents(); + var requestId = _sharedState.GetHttpRequestId(); + + // Where an action is registered in the shared state for the current request, invoke it; this allows for test-specific behaviors to be executed just prior to the actual publishing of events. + if (_sharedState.RequestStateData(requestId).TryGetValue($"_{nameof(EventPublisherDecorator)}_{key}", out var val) && val is Action publishAction) + publishAction(); + + // Publish the events using the underlying publisher. + await _innerEventPublisher.PublishAsync(cancellationToken).ConfigureAwait(false); + + // Forward the published events appending to the shared state. + _sharedState.RequestStateData(requestId).AddOrUpdate(Key, events, (_, __) => events); + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Expectations/EventExpectations.cs b/src/CoreEx.UnitTesting/Expectations/EventExpectations.cs deleted file mode 100644 index 83571e23..00000000 --- a/src/CoreEx.UnitTesting/Expectations/EventExpectations.cs +++ /dev/null @@ -1,244 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Events; -using System; -using System.Buffers; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using UnitTestEx.Abstractions; -using UnitTestEx.Json; - -namespace UnitTestEx.Expectations -{ - /// - /// Provides expectations. - /// - /// The owning . - /// The initiating tester. - public class EventExpectations(TesterBase owner, TTester tester) : ExpectationsBase(owner, tester) - { - private readonly Dictionary> _expectedEvents = []; - private bool _expectNoEvents = true; - private bool _expectEvents; - - /// - /// Overrides to ensure it occurs after majority. - public override int Order => 1000; - - /// - /// Expects that no events have been published. - /// - /// Any previously explicitly specified expected events will be removed. - public void ExpectNoEvents() - { - _expectedEvents.Clear(); - _expectNoEvents = true; - _expectEvents = false; - } - - /// - /// Expects that at least one event has been published. - /// - /// Any previously explicitly specified expected events will be removed. - public void ExpectEvents() - { - if (!Owner.IsExpectedEventPublisherConfigured()) - throw new NotSupportedException($"The {nameof(TestSetUp)}.{nameof(UnitTestExExtensions.EnableExpectedEvents)} or {nameof(TesterBase)}.{nameof(UnitTestExExtensions.UseExpectedEvents)} must be used before this functionality can be executed; note that enabling will automatically replace the {nameof(IEventPublisher)} to use the {nameof(ExpectedEventPublisher)}."); - - _expectedEvents.Clear(); - _expectNoEvents = false; - _expectEvents = true; - } - - /// - /// Adds the event into the dictionary. - /// - private void Add(string? destination, (string? Source, string? Subject, string? Action, EventData? Event, string[] PathsToIgnore) @event) - { - if (!Owner.IsExpectedEventPublisherConfigured()) - throw new NotSupportedException($"The {nameof(TestSetUp)}.{nameof(UnitTestExExtensions.EnableExpectedEvents)} or {nameof(TesterBase)}.{nameof(UnitTestExExtensions.UseExpectedEvents)} must be used before this functionality can be executed; note that enabling will automatically replace the {nameof(IEventPublisher)} to use the {nameof(ExpectedEventPublisher)}."); - - var key = destination ?? ExpectedEventPublisher.NullKeyName; - if (_expectedEvents.TryGetValue(key, out var events)) - events.Add(@event); - else - _expectedEvents.Add(key, [@event]); - - _expectNoEvents = false; - _expectEvents = false; - } - - /// - /// Expects that the corresponding event has been published (in order specified). The expected event and can use wildcards. All other properties are not matched/verified. - /// - /// The named destination (e.g. queue or topic). - /// The expected subject (may contain wildcards). - /// The expected action (may contain wildcards). - public void Expect(string? destination, string subject, string? action = "*") => Add(destination, ("*", subject.ThrowIfNull(nameof(subject)), action, null, [])); - - /// - /// Expects that the corresponding event has been published (in order specified). The expected event , and can use wildcards. All other - /// properties are not matched/verified. - /// - /// The named destination (e.g. queue or topic). - /// The expected source formatted as a (may contain wildcards). - /// The expected subject (may contain wildcards). - /// The expected action (may contain wildcards). - public void Expect(string? destination, string source, string subject, string? action = "*") => Add(destination, (source.ThrowIfNull(nameof(source)), subject.ThrowIfNull(nameof(subject)), action, null, [])); - - /// - /// Expects that the corresponding has been published (in order specified). All properties for expected event will be compared again the actual. - /// - /// The named destination (e.g. queue or topic). - /// The expected . Wildcards are supported for and . - /// The JSON paths to ignore from the comparison. Defaults to . - /// Wildcards are supported for , and . - public void Expect(string? destination, EventData @event, params string[] pathsToIgnore) => Add(destination, (null, @event?.Subject, @event?.Action, @event.ThrowIfNull(nameof(@event)), pathsToIgnore)); - - /// - /// Expects that the corresponding has been published (in order specified). All properties for expected event will be compared again the actual. - /// - /// The named destination (e.g. queue or topic). - /// The expected source formatted as a (may contain wildcards). - /// The expected . Wildcards are supported for and . - /// The JSON paths to ignore from the comparison. Defaults to . - /// Wildcards are supported for , and . - public void Expect(string? destination, string source, EventData @event, params string[] pathsToIgnore) => Add(destination, (source, @event?.Subject, @event?.Action, @event.ThrowIfNull(nameof(@event)), pathsToIgnore)); - - /// - protected override Task OnAssertAsync(AssertArgs args) - { - var expectedEventPublisher = ExpectedEventPublisher.GetFromSharedState(args.Tester.SharedState); - if (expectedEventPublisher is null) - { - if (_expectNoEvents) - return Task.CompletedTask; - else - throw new InvalidOperationException($"The {nameof(ExpectedEventPublisher)}.{nameof(ExpectedEventPublisher.GetFromSharedState)} must not return null; there is an internal issue."); - } - - if (!expectedEventPublisher.IsEmpty) - args.Tester.Implementor.AssertFail("Expected Event Publish/Send mismatch; there are one or more published events that have not been sent."); - - var names = expectedEventPublisher.PublishedEvents.Keys.ToArray(); - if (_expectNoEvents && !_expectEvents && _expectedEvents.Count == 0 && names.Length > 0) - args.Tester.Implementor.AssertFail($"Expected no Event(s); one or more were published."); - - if (names.Length == 0 && (_expectEvents || _expectedEvents.Count != 0)) - args.Tester.Implementor.AssertFail($"Expected Event(s); none were published."); - - if (_expectEvents) - return Task.CompletedTask; - - if (names.Length != _expectedEvents.Count) - args.Tester.Implementor.AssertFail($"Expected {_expectedEvents.Count} event destination(s); there were {names.Length}."); - - if (names.Length == 1 && _expectedEvents.Count == 1 && _expectedEvents.ContainsKey(ExpectedEventPublisher.NullKeyName)) - { - var key = _expectedEvents.Keys.First(); - AssertDestination(args, key, _expectedEvents[key], [.. EventExpectations.GetEvents(expectedEventPublisher, names[0])]); - return Task.CompletedTask; - } - - foreach (var name in names) - { - if (_expectedEvents.TryGetValue(name, out var exp)) - AssertDestination(args, name, exp, [.. EventExpectations.GetEvents(expectedEventPublisher, name)]); - else - args.Tester.Implementor.AssertFail($"Published event(s) to destination '{name}'; these were not expected."); - } - - var missing = string.Join(", ", _expectedEvents.Keys.Where(key => !names.Contains(key)).Select(x => $"'{x}'")); - if (!string.IsNullOrEmpty(missing)) - args.Tester.Implementor.AssertFail($"Expected event(s) to be published to destination(s): {missing}; none were found."); - - return Task.CompletedTask; - } - - /// - /// Gets the event JSON from the event storage. - /// - private static List GetEvents(ExpectedEventPublisher expectedEventPublisher, string? name) - => expectedEventPublisher!.PublishedEvents.TryGetValue(name ?? ExpectedEventPublisher.NullKeyName, out var queue) ? [.. queue.Select(x => x.Json)] : new(); - - /// - /// Asserts the events for the destination. - /// - private void AssertDestination(AssertArgs args, string? destination, List<(string? Source, string? Subject, string? Action, EventData? Event, string[] PathsToIgnore)> expectedEvents, List actualEvents) - { - if (actualEvents.ThrowIfNull(nameof(actualEvents)).Count != expectedEvents.Count) - args.Tester.Implementor.AssertFail($"Destination {destination}: Expected {_expectedEvents.Count} event(s); there were {actualEvents.Count} actual."); - - for (int i = 0; i < actualEvents.Count; i++) - { - var exp = expectedEvents[i].Event; - var wcexp = exp ?? new EventData { Subject = expectedEvents[i].Subject, Action = expectedEvents[i].Action }; - var act = (EventData)args.Tester.JsonSerializer.Deserialize(actualEvents[i]!, wcexp.GetType())!; - - // Assert source, subject, action and type using wildcards where specified. - if (expectedEvents[i].Source != null && !WildcardMatch(args, expectedEvents[i].Source!, act.Source?.ToString(), '/')) - args.Tester.Implementor.AssertFail($"Destination {destination}: Expected Event[{i}].{nameof(EventDataBase.Source)} '{expectedEvents[i].Source}' does not match actual '{act.Source}'."); - - if (wcexp.Subject != null && !WildcardMatch(args, wcexp.Subject!, act.Subject?.ToString(), args.Tester.SetUp.GetExpectedEventsFormatter().SubjectSeparatorCharacter)) - args.Tester.Implementor.AssertFail($"Destination {destination}: Expected Event[{i}].{nameof(EventDataBase.Subject)} '{wcexp.Subject}' does not match actual '{act.Subject}'."); - - if (wcexp.Action != null && !WildcardMatch(args, wcexp.Action!, act.Action?.ToString(), char.MinValue)) - args.Tester.Implementor.AssertFail($"Destination {destination}: Expected Event[{i}].{nameof(EventDataBase.Action)} '{wcexp.Action}' does not match actual '{act.Action}'."); - - if (wcexp.Type != null && !WildcardMatch(args, wcexp.Type!, act.Type?.ToString(), args.Tester.SetUp.GetExpectedEventsFormatter().TypeSeparatorCharacter)) - args.Tester.Implementor.AssertFail($"Destination {destination}: Expected Event[{i}].{nameof(EventDataBase.Type)} '{wcexp.Type}' does not match actual '{act.Type}'."); - - // Where there is *no* expected eventdata then skip comparison. - if (exp == null) - continue; - - // Compare the events. - var list = new List(args.Tester.SetUp.GetExpectedEventsPathsToIgnore()); - list.AddRange(new string[] { nameof(EventDataBase.Source), nameof(EventDataBase.Subject), nameof(EventDataBase.Action), nameof(EventDataBase.Type) }); - list.AddRange(expectedEvents[i].PathsToIgnore); - - var res = JsonElementComparer.Default.Compare(args.Tester.JsonSerializer.Serialize(exp), actualEvents[i]!, [.. list]); - if (res.HasDifferences) - args.Tester.Implementor.AssertFail($"Destination {destination}; Expected event is not equal to actual:{Environment.NewLine}{res}"); - } - } - - /// - /// Performs a wildcard match on each part of the strings using the to split. - /// - /// The . - /// The expected including wildcards. - /// The actual to compare against the expected. - /// The seperator character. - /// true where there is a wildcard match; otherwise, false. - public bool WildcardMatch(AssertArgs args, string expected, string? actual, char separatorCharacter) - { - if (expected.ThrowIfNull(nameof(expected)) == "*") - return true; - - var eparts = expected.Split(separatorCharacter); - if (actual == null) - return false; - - var aparts = actual.Split(separatorCharacter); - - // Compare each part for an exact match or wildcard. - for (int i = 0; i < eparts.Length; i++) - { - if (i >= aparts.Length) - return false; - - if (new string[] { aparts[i] }.WhereWildcard(x => x, eparts[i], ignoreCase: false, wildcard: args.Tester.SetUp.GetExpectedEventsWildcard()).FirstOrDefault() == null) - return false; - } - - if (aparts.Length == eparts.Length) - return true; - - // Where longer make sure last part is a multi wildcard. - return aparts.Length > eparts.Length && eparts[^1] == new string(new char[] { args.Tester.SetUp.GetExpectedEventsWildcard().MultiWildcard }); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Expectations/ExpectedEventPublisher.cs b/src/CoreEx.UnitTesting/Expectations/ExpectedEventPublisher.cs deleted file mode 100644 index 6c98b835..00000000 --- a/src/CoreEx.UnitTesting/Expectations/ExpectedEventPublisher.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Events; -using CoreEx.Json; -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using UnitTestEx.Abstractions; - -namespace UnitTestEx.Expectations -{ - /// - /// Provides an expected event publisher to support . - /// - /// Where an is provided then each will also be logged during Send. - /// - public sealed class ExpectedEventPublisher : EventPublisher - { - private readonly TestSharedState _sharedState; - private readonly ILogger? _logger; - private readonly IJsonSerializer _jsonSerializer; - - /// - /// Get the null key name. - /// - public const string NullKeyName = ""; - - /// - /// Gets the from the . - /// - /// The . - /// The where found; otherwise, null. - internal static ExpectedEventPublisher? GetFromSharedState(TestSharedState sharedState) - => sharedState.ThrowIfNull(nameof(sharedState)).StateData.TryGetValue(nameof(ExpectedEventPublisher), out var eep) ? eep as ExpectedEventPublisher : null; - - /// - /// Sets the into the . - /// - /// The . - /// The . - internal static void SetToSharedState(TestSharedState sharedState, ExpectedEventPublisher? expectedEventPublisher) - => sharedState.ThrowIfNull(nameof(sharedState)).StateData[nameof(ExpectedEventPublisher)] = expectedEventPublisher.ThrowIfNull(nameof(expectedEventPublisher)); - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The optional for logging the events (each ). - /// The optional for the logging. Defaults to - /// The ; defaults where not specified. - /// The ; defaults where not specified. - public ExpectedEventPublisher(TestSharedState sharedState, ILogger? logger = null, IJsonSerializer? jsonSerializer = null, EventDataFormatter? eventDataFormatter = null, IEventSerializer? eventSerializer = null) - : base(eventDataFormatter, eventSerializer ?? new CoreEx.Text.Json.EventDataSerializer(), new NullEventSender()) - { - _sharedState = sharedState.ThrowIfNull(nameof(sharedState)); - SetToSharedState(_sharedState, this); - _logger = logger; - _jsonSerializer = jsonSerializer ?? JsonSerializer.Default; - } - - /// - /// Gets the dictionary that contains the actual published and sent events by destination. - /// - /// The actual published events are queued as the JSON-serialized (indented) representation of the , the itself, and the corresponding . - public ConcurrentDictionary> PublishedEvents { get; } = new(); - - /// - /// Indicates whether any events have been published. - /// - public bool HasPublishedEvents => !PublishedEvents.IsEmpty; - - /// - /// Gets the total count of published events (across all destinations). - /// - public int PublishedEventCount => PublishedEvents.Select(x => x.Value.Count).Sum(); - - /// - protected override Task OnEventSendAsync(string? name, EventData eventData, EventSendData eventSendData, CancellationToken cancellationToken) - { - var queue = PublishedEvents.GetOrAdd(name ?? NullKeyName, _ => new ConcurrentQueue<(string Json, EventData Event, EventSendData SentEvent)>()); - var json = _jsonSerializer.Serialize(eventData, JsonWriteFormat.Indented); - queue.Enqueue((json, eventData, eventSendData)); - - if (_logger != null) - { - var sb = new StringBuilder("UnitTestEx > Event send"); - if (!string.IsNullOrEmpty(name)) - sb.Append($" (destination: '{name}')"); - - sb.AppendLine(" ->"); - sb.Append(json); - _logger.LogInformation("{Event}", sb.ToString()); - } - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Expectations/UnitTestExExtensions.cs b/src/CoreEx.UnitTesting/Expectations/UnitTestExExtensions.cs deleted file mode 100644 index f1fc75b8..00000000 --- a/src/CoreEx.UnitTesting/Expectations/UnitTestExExtensions.cs +++ /dev/null @@ -1,721 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using CoreEx.Events; -using CoreEx.Http; -using CoreEx.Wildcards; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Reflection; -using System.Threading.Tasks; -using UnitTestEx.Abstractions; -using UnitTestEx.AspNetCore; - -namespace UnitTestEx.Expectations -{ - /// - /// Provides extension methods to the core . - /// - public static class UnitTestExExtensions - { - #region Tester - - private const string TesterBaseIsExpectedEventPublisherConfiguredKey = nameof(TesterBase) + "_" + nameof(IsExpectedEventPublisherConfigured); - - /// - /// Replaces the with the to enable the . - /// - /// The . - /// This will automatically be invoked where the has been executed. - internal static void UseExpectedEvents(this TesterBase tester) - { - var usingExpectedEvents = tester.SetUp.Properties.TryGetValue(TesterBaseIsExpectedEventPublisherConfiguredKey, out var val) && (bool)val!; - - if (!usingExpectedEvents) - { - tester.ConfigureServices(sc => sc.ReplaceScoped()); - tester.SetUp.Properties[TesterBaseIsExpectedEventPublisherConfiguredKey] = true; - } - } - - /// - /// Replaces the with the to enable the . - /// - /// The to support fluent-style method-chaining. - /// The . - /// This will automatically be invoked where the has been executed. - public static TSelf UseExpectedEvents(this TesterBase tester) where TSelf : TesterBase - { - ((TesterBase)tester).UseExpectedEvents(); - return (TSelf)tester; - } - - /// - /// Gets whether the has been invoked - /// - /// The . - /// Indicates whether the is configured. - public static bool IsExpectedEventPublisherConfigured(this TesterBase tester) => tester.SetUp.Properties.TryGetValue(TesterBaseIsExpectedEventPublisherConfiguredKey, out var val) && (bool)val!; - - /// - /// Gets the from the . - /// - /// The . - /// The . - /// Thrown where the has not been configured (). - public static ExpectedEventPublisher GetExpectedEventPublisher(this TesterBase tester) - { - if (!IsExpectedEventPublisherConfigured(tester)) - throw new InvalidOperationException($"The {nameof(ExpectedEventPublisher)} has not been configured. Please ensure that the {nameof(UseExpectedEvents)} method has been invoked."); - - return (ExpectedEventPublisher)tester.Services.GetRequiredService(); - } - - #endregion - - #region TestSetUp - - private const string ExpectedEventsPathsToIgnoreKey = nameof(ExpectedEventPublisher) + "_" + "PathsToIgnore"; - private const string ExpectedEventsWildcardKey = nameof(ExpectedEventPublisher) + "_" + nameof(Wildcard); - private const string ExpectedEventsFormatterKey = nameof(ExpectedEventPublisher) + "_" + nameof(EventDataFormatter); - private const string ExpectedEventsEnabledKey = nameof(ExpectedEventPublisher) + "_" + nameof(EnableExpectedEvents); - private const string ExpectedNoEventsKey = nameof(ExpectedEventPublisher) + "_" + nameof(ExpectNoEvents); - - /// - /// Gets the JSON paths to ignore from the . - /// - /// The . - /// The JSON paths to ignore. - /// By default , , , and are ignored. - public static List GetExpectedEventsPathsToIgnore(this TestSetUp setUp) - { - if (setUp.Properties.TryGetValue(ExpectedEventsPathsToIgnoreKey, out var pathsToIgnore)) - return (List)pathsToIgnore!; - - var pti = new List() { nameof(EventDataBase.Id), nameof(EventDataBase.CorrelationId), nameof(EventDataBase.Timestamp), nameof(EventDataBase.ETag), nameof(EventDataBase.Key) }; - setUp.Properties.TryAdd(ExpectedEventsPathsToIgnoreKey, pti); - return pti; - } - - /// - /// Gets the parser from the . - /// - /// The . - /// The parser. - public static Wildcard GetExpectedEventsWildcard(this TestSetUp setUp) - => setUp.Properties.TryGetValue(ExpectedEventsWildcardKey, out var wildcard) ? (Wildcard)wildcard! : SetExpectedEventsWildcard(setUp, Wildcard.MultiAll); - - /// - /// Sets the parser into the . - /// - /// The . - /// The parser. - public static Wildcard SetExpectedEventsWildcard(this TestSetUp setUp, Wildcard wildcard) - { - setUp.Properties[ExpectedEventsWildcardKey] = wildcard; - return wildcard; - } - - /// - /// Gets the from the . - /// - /// The . - /// The parser. - public static EventDataFormatter GetExpectedEventsFormatter(this TestSetUp setUp) - => setUp.Properties.TryGetValue(ExpectedEventsFormatterKey, out var formatter) ? (EventDataFormatter)formatter! : SetExpectedEventsFormatter(setUp, new EventDataFormatter()); - - /// - /// Sets the into the . - /// - /// The . - /// The parser. - public static EventDataFormatter SetExpectedEventsFormatter(this TestSetUp setUp, EventDataFormatter formatter) - { - setUp.Properties[ExpectedEventsFormatterKey] = formatter; - return formatter; - } - - /// - /// Sets whether the functionality is enabled. - /// - /// The . - /// The enabled option. - /// Where enabled the will be automatically replaced by the that is used by the to verify that the - /// expected events were sent. Therefore, the events will not be sent to any external eventing/messaging system as a result. - public static void EnableExpectedEvents(this TestSetUp setup, bool enabled = true) => setup.Properties[ExpectedEventsEnabledKey] = enabled; - - /// - /// Indicates whether the ExpectedEvents functionality is enabled (see ). Defaults to false. - /// - /// The . - public static bool IsExpectedEventsEnabled(this TestSetUp setup) => setup.Properties.TryGetValue(ExpectedEventsEnabledKey, out var enabled) && (bool)enabled!; - - /// - /// Sets whether to verify that no events are published as the default behaviour. Defaults to true. - /// - /// The . - /// The expectation option. - public static void ExpectNoEvents(this TestSetUp setup, bool expectNoEvents = true) => setup.Properties[ExpectedNoEventsKey] = expectNoEvents; - - /// - /// Indicates whether to verify that no events are published as the default behaviour. Defaults to true. - /// - /// The . - public static bool IsExpectingNoEvents(this TestSetUp setup) => setup.Properties.TryGetValue(ExpectedNoEventsKey, out var expectNoEvents) && (bool)expectNoEvents!; - - #endregion - - #region EventExpectations - - /// - /// Invokes the set expectation logic. - /// - private static TSelf SetEventExpectation(this IExpectations tester, Action> action) where TSelf : IExpectations - { - var ee = tester.ExpectationsArranger.GetOrAdd(() => new EventExpectations(tester.ExpectationsArranger.Owner, (TSelf)tester)); - action(ee); - return (TSelf)tester; - } - - /// - /// Expects that no events have been published. - /// - /// The . - /// The instance to support fluent-style method-chaining. - /// On first invocation will automatically replace with a new scoped service (DI) to capture events for this expectation. The other services are therefore required - /// for this to function. As this is a scoped service no parallel execution of services against the same test host is supported as this capability is not considered thread-safe. - public static TSelf ExpectNoEvents(this IExpectations tester) where TSelf : IExpectations - => tester.SetEventExpectation(e => e.ExpectNoEvents()); - - /// - /// Expects that at least one event has been published. - /// - /// The . - /// The instance to support fluent-style method-chaining. - /// On first invocation will automatically replace with a new scoped service (DI) to capture events for this expectation. The other services are therefore required - /// for this to function. As this is a scoped service no parallel execution of services against the same test host is supported as this capability is not considered thread-safe. - public static TSelf ExpectEvents(this IExpectations tester) where TSelf : IExpectations - => tester.SetEventExpectation(e => e.ExpectEvents()); - - /// - /// Expects that the corresponding event has been published (in order specified). The expected event and can use wildcards. All other properties are not matched/verified. - /// - /// The . - /// The expected subject (may contain wildcards). - /// The expected action (may contain wildcards). - /// The instance to support fluent-style method-chaining. - /// On first invocation will automatically replace with a new scoped service (DI) to capture events for this expectation. The other services are therefore required - /// for this to function. As this is a scoped service no parallel execution of services against the same test host is supported as this capability is not considered thread-safe. - public static TSelf ExpectEvent(this IExpectations tester, string subject, string? action = "*") where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(null, subject, action)); - - /// - /// Expects that the corresponding event has been published (in order specified). The expected event and can use wildcards. - /// - /// The . - /// The expected . - /// The expected subject (may contain wildcards). - /// The expected action (may contain wildcards). - /// The paths to ignore from the comparison. Defaults to . - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectEventValue(this IExpectations tester, object? value, string subject, string? action, params string[] pathsToIgnore) where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(null, new EventData { Subject = subject, Action = action, Value = value }, pathsToIgnore)); - - /// - /// Expects that the corresponding event has been published (in order specified). The expected event , and can use wildcards. All other - /// properties are not matched/verified. - /// - /// The . - /// The expected source formatted as a (may contain wildcards). - /// The expected subject (may contain wildcards). - /// The expected action (may contain wildcards). - /// The instance to support fluent-style method-chaining. - /// On first invocation will automatically replace with a new scoped service (DI) to capture events for this expectation. The other services are therefore required - /// for this to function. As this is a scoped service no parallel execution of services against the same test host is supported as this capability is not considered thread-safe. - public static TSelf ExpectEvent(this IExpectations tester, string source, string subject, string? action = "*") where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(null, source, subject, action)); - - /// - /// Expects that the corresponding has been published (in order specified). All properties for expected event will be compared again the actual. - /// - /// The . - /// The expected . Wildcards are supported for and . - /// The paths to ignore from the comparison. Defaults to . - /// The instance to support fluent-style method-chaining. - /// Wildcards are supported for , and . - public static TSelf ExpectEvent(this IExpectations tester, EventData @event, params string[] pathsToIgnore) where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(null, @event, pathsToIgnore)); - - /// - /// Expects that the corresponding has been published (in order specified). All properties for expected event will be compared again the actual. - /// - /// The . - /// The expected source formatted as a (may contain wildcards). - /// The expected . Wildcards are supported for and . - /// The paths to ignore from the comparison. Defaults to . - /// The instance to support fluent-style method-chaining. - /// Wildcards are supported for , and . - public static TSelf ExpectEvent(this IExpectations tester, string source, EventData @event, params string[] pathsToIgnore) where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(null, source, @event, pathsToIgnore)); - - /// - /// Expects that the JSON serialized from the named embedded resource has been published (in order specified). All properties for expected event will be compared again the actual. - /// - /// The . - /// The embedded resource name (matches to the end of the fully qualifed resource name) that contains the expected value as serialized JSON. - /// The paths to ignore from the comparison. - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectEventFromJsonResource(this IExpectations tester, string resourceName, params string[] pathsToIgnore) where TSelf : IExpectations - => ExpectEventFromJsonResource(tester, resourceName, Assembly.GetCallingAssembly(), pathsToIgnore); - - /// - /// Expects that the JSON serialized from the named embedded resource has been published (in order specified). All properties for expected event will be compared again the actual. - /// - /// The . - /// The embedded resource name (matches to the end of the fully qualifed resource name) that contains the expected value as serialized JSON. - /// The that contains the embedded resource. - /// The paths to ignore from the comparison. - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectEventFromJsonResource(this IExpectations tester, string resourceName, Assembly assembly, params string[] pathsToIgnore) where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(null, Resource.GetJsonValue(resourceName, assembly ?? Assembly.GetCallingAssembly(), e.Owner.JsonSerializer), pathsToIgnore)); - - /// - /// Expects that the corresponding event has been published (in order specified). The expected event and can use wildcards. All other properties are not matched/verified. - /// - /// The . - /// The named destination (e.g. queue or topic). - /// The expected subject (may contain wildcards). - /// The expected action (may contain wildcards). - /// The instance to support fluent-style method-chaining. - /// On first invocation will automatically replace with a new scoped service (DI) to capture events for this expectation. The other services are therefore required - /// for this to function. As this is a scoped service no parallel execution of services against the same test host is supported as this capability is not considered thread-safe. - public static TSelf ExpectDestinationEvent(this IExpectations tester, string destination, string subject, string? action = "*") where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(destination, subject, action)); - - /// - /// Expects that the corresponding event has been published (in order specified). The expected event and can use wildcards. - /// - /// The . - /// The named destination (e.g. queue or topic). - /// The expected . - /// The expected subject (may contain wildcards). - /// The expected action (may contain wildcards). - /// The paths to ignore from the comparison. Defaults to . - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectDestinationEventValue(this IExpectations tester, object? value, string destination, string subject, string? action, params string[] pathsToIgnore) where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(destination, new EventData { Subject = subject, Action = action, Value = value }, pathsToIgnore)); - - /// - /// Expects that the corresponding event has been published (in order specified). The expected event , and can use wildcards. All other - /// properties are not matched/verified. - /// - /// The . - /// The named destination (e.g. queue or topic). - /// The expected source formatted as a (may contain wildcards). - /// The expected subject (may contain wildcards). - /// The expected action (may contain wildcards). - /// The instance to support fluent-style method-chaining. - /// On first invocation will automatically replace with a new scoped service (DI) to capture events for this expectation. The other services are therefore required - /// for this to function. As this is a scoped service no parallel execution of services against the same test host is supported as this capability is not considered thread-safe. - public static TSelf ExpectDestinationEvent(this IExpectations tester, string destination, string source, string subject, string? action = "*") where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(destination, source, subject, action)); - - /// - /// Expects that the corresponding has been published (in order specified). All properties for expected event will be compared again the actual. - /// - /// The . - /// The named destination (e.g. queue or topic). - /// The expected . Wildcards are supported for and . - /// The paths to ignore from the comparison. Defaults to . - /// The instance to support fluent-style method-chaining. - /// Wildcards are supported for , and . - public static TSelf ExpectDestinationEvent(this IExpectations tester, string destination, EventData @event, params string[] pathsToIgnore) where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(destination, @event, pathsToIgnore)); - - /// - /// Expects that the corresponding has been published (in order specified). All properties for expected event will be compared again the actual. - /// - /// The . - /// The named destination (e.g. queue or topic). - /// The expected source formatted as a (may contain wildcards). - /// The expected . Wildcards are supported for and . - /// The paths to ignore from the comparison. Defaults to . - /// The instance to support fluent-style method-chaining. - /// Wildcards are supported for , and . - public static TSelf ExpectDestinationEvent(this IExpectations tester, string destination, string source, EventData @event, params string[] pathsToIgnore) where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(destination, source, @event, pathsToIgnore)); - - /// - /// Expects that the JSON serialized from the named embedded resource has been published (in order specified). All properties for expected event will be compared again the actual. - /// - /// The . - /// The named destination (e.g. queue or topic). - /// The embedded resource name (matches to the end of the fully qualifed resource name) that contains the expected value as serialized JSON. - /// The paths to ignore from the comparison. - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectDestinationEventFromJsonResource(this IExpectations tester, string destination, string resourceName, params string[] pathsToIgnore) where TSelf : IExpectations - => ExpectDestinationEventFromJsonResource(tester, destination, resourceName, Assembly.GetCallingAssembly(), pathsToIgnore); - - /// - /// Expects that the JSON serialized from the named embedded resource has been published (in order specified). All properties for expected event will be compared again the actual. - /// - /// The . - /// The named destination (e.g. queue or topic). - /// The embedded resource name (matches to the end of the fully qualifed resource name) that contains the expected value as serialized JSON. - /// The that contains the embedded resource. - /// The paths to ignore from the comparison. - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectDestinationEventFromJsonResource(this IExpectations tester, string destination, string resourceName, Assembly assembly, params string[] pathsToIgnore) where TSelf : IExpectations - => tester.SetEventExpectation(e => e.Expect(destination, Resource.GetJsonValue(resourceName, assembly ?? Assembly.GetCallingAssembly(), e.Owner.JsonSerializer), pathsToIgnore)); - - #endregion - - #region ValueExpectations - - /// - /// Invokes the set expectation logic. - /// - private static TSelf SetValueExpectationExtension(this IValueExpectations tester, Func> extension) where TSelf : IValueExpectations - { - tester.ExpectationsArranger.GetOrAdd(() => new ValueExpectations(tester.ExpectationsArranger.Owner, (TSelf)tester)).AddExtension(extension); - return (TSelf)tester; - } - - /// - /// Verifies implements . - /// - private static void VerifyImplements() - { - if (typeof(TValue).GetInterface(typeof(TInterface).FullName ?? typeof(TInterface).Name) == null) - throw new InvalidOperationException($"{typeof(TValue).Name} must implement the interface {typeof(TInterface).Name}."); - } - - /// - /// Expects the to be implemented and have non-default . - /// - /// The expectations . - /// The value . - /// The tester. - /// The optional expected identifier to compare to. - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectIdentifier(this IValueExpectations tester, object? identifier = null) where TSelf : IValueExpectations - { - VerifyImplements(); - IgnoreIdentifier(tester); - var pn = $"{nameof(IIdentifier)}.{nameof(IIdentifier.Id)}"; - - Task extension(AssertArgs args) - { - var id = args.Value as IIdentifier; - if (id is null || id.Id is null) - args.Tester.Implementor.AssertFail($"Expected {pn} to have a non-null value."); - - if (identifier is null) - { - if (System.Collections.Comparer.Default.Compare(id!.Id, id!.GetType().IsClass ? null! : Activator.CreateInstance(id!.GetType())) == 0) - args.Tester.Implementor.AssertFail($"Expected {pn} to have a non-default value."); - } - else - args.Tester.Implementor.AssertAreEqual(identifier, id!.Id, $"Expected {pn} value of '{identifier}'; actual '{id.Id}'."); - - return Task.FromResult(false); - } - - return SetValueExpectationExtension(tester, extension); - } - - /// - /// Expects the to be implemented and have non-default . - /// - /// The expectations . - /// The value . - /// The tester. - /// The optional expected primary key to compare to. - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectPrimaryKey(this IValueExpectations tester, object? primaryKey) where TSelf : IValueExpectations - => ExpectPrimaryKey(tester, new CompositeKey(primaryKey)); - - /// - /// Expects the to be implemented and have non-default . - /// - /// The expectations . - /// The value . - /// The tester. - /// The optional expected primary key to compare to. - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectPrimaryKey(this IValueExpectations tester, CompositeKey? primaryKey = null) where TSelf : IValueExpectations - { - VerifyImplements(); - IgnorePrimaryKey(tester); - var pn = $"{nameof(IPrimaryKey)}.{nameof(IPrimaryKey.PrimaryKey)}"; - - Task extension(AssertArgs args) - { - var pk = args.Value as IPrimaryKey; - if (pk is null) - args.Tester.Implementor.AssertFail($"Expected {pn} to have a non-null value."); - - if (pk!.PrimaryKey.IsInitial) - args.Tester.Implementor.AssertFail($"Expected {pn}.{nameof(CompositeKey.Args)} to have one or more non-default values."); - - if (primaryKey.HasValue) - args.Tester.Implementor.AssertAreEqual(primaryKey.Value, pk.PrimaryKey, $"Expected {pn} value of '{primaryKey.Value}'; actual '{pk.PrimaryKey}'."); - - return Task.FromResult(false); - } - - return SetValueExpectationExtension(tester, extension); - } - - /// - /// Expects the to be implemented and have non-default . - /// - /// The expectations . - /// The value . - /// The tester. - /// The optional previous ETag to compare not equal to; i.e. it must be different. - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectETag(this IValueExpectations tester, string? previousETag = null) where TSelf : IValueExpectations - { - VerifyImplements(); - IgnoreETag(tester); - var pn = $"{nameof(IETag)}.{nameof(IETag.ETag)}"; - - Task extension(AssertArgs args) - { - var etag = args.Value as IETag; - if (etag is null || etag.ETag is null) - args.Tester.Implementor.AssertFail($"Expected {pn} to have a non-null value."); - - if (previousETag is not null && previousETag == etag!.ETag) - args.Tester.Implementor.AssertFail($"Expected {pn} value of '{previousETag}' to be different to actual."); - - return Task.FromResult(false); - } - - return SetValueExpectationExtension(tester, extension); - } - - /// - /// Expects the to be implemented and have non-default and values. - /// - /// The expectations . - /// The value . - /// The tester. - /// The specific value where specified (can include wildcards); otherwise, indicates to check for user running the test (see ). - /// The in which the should be greater than or equal to; where null it will default to . - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectChangeLogCreated(this IValueExpectations tester, string? createdBy = null, DateTime? createdDateGreaterThan = null) where TSelf : IValueExpectations - { - VerifyImplements(); - IgnoreChangeLog(tester); - var pn = $"{nameof(IChangeLog)}.{nameof(IChangeLog.ChangeLog)}"; - - createdBy ??= tester.ExpectationsArranger.Owner.UserName; - createdDateGreaterThan = Cleaner.Clean(createdDateGreaterThan ?? DateTime.UtcNow.Subtract(new TimeSpan(0, 0, 1))); - - Task extension(AssertArgs args) - { - var cl = args.Value as IChangeLogAuditLog; - if (cl is null || cl.ChangeLogAudit == null) - args.Tester.Implementor.AssertFail($"Expected Change Log ({nameof(IChangeLogAuditLog)}.{nameof(IChangeLogAuditLog.ChangeLogAudit)}) to have a non-null value."); - - if (cl!.ChangeLogAudit!.CreatedBy == null) - args.Tester.Implementor.AssertFail($"Expected Change Log ({nameof(IChangeLogAuditLog)}.{nameof(IChangeLogAuditLog.ChangeLogAudit)}).{nameof(ChangeLog.CreatedBy)} value of '{createdBy}'; actual was null."); - else - { - var wcr = Wildcard.BothAll.Parse(createdBy).ThrowOnError(); - if (cl?.ChangeLogAudit?.CreatedBy == null || !wcr.CreateRegex().IsMatch(cl!.ChangeLogAudit!.CreatedBy)) - args.Tester.Implementor.AssertFail($"Expected Change Log ({nameof(IChangeLogAuditLog)}.{nameof(IChangeLogAuditLog.ChangeLogAudit)}).{nameof(ChangeLog.CreatedBy)} value of '{createdBy}'; actual '{cl?.ChangeLogAudit?.CreatedBy}'."); - } - - if (!cl!.ChangeLogAudit!.CreatedDate.HasValue) - args.Tester.Implementor.AssertFail($"Expected Change Log ({nameof(IChangeLogAuditLog)}.{nameof(IChangeLogAuditLog.ChangeLogAudit)}).{nameof(ChangeLog.CreatedDate)} to have a non-null value."); - else if (Cleaner.Clean(cl!.ChangeLogAudit!.CreatedDate.Value) < createdDateGreaterThan.Value) - args.Tester.Implementor.AssertFail($"Expected Change Log ({nameof(IChangeLogAuditLog)}.{nameof(IChangeLogAuditLog.ChangeLogAudit)}).{nameof(ChangeLog.CreatedDate)} value of '{createdDateGreaterThan.Value}'; actual '{cl.ChangeLogAudit.CreatedDate.Value}' must be greater than or equal to expected."); - - return Task.FromResult(false); - } - - return SetValueExpectationExtension(tester, extension); - } - - /// - /// Expects the to be implemented and have non-default and values. - /// - /// The expectations . - /// The value . - /// The tester. - /// The specific value where specified (can include wildcards); otherwise, indicates to check for user running the test (see ). - /// The in which the should be greater than or equal to; where null it will default to . - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectChangeLogUpdated(this IValueExpectations tester, string? updatedBy = null, DateTime? updatedDateGreaterThan = null) where TSelf : IValueExpectations - { - VerifyImplements(); - IgnoreChangeLog(tester); - var pn = $"{nameof(IChangeLog)}.{nameof(IChangeLog.ChangeLog)}"; - - updatedBy ??= tester.ExpectationsArranger.Owner.UserName; - updatedDateGreaterThan = Cleaner.Clean(updatedDateGreaterThan ?? DateTime.UtcNow.Subtract(new TimeSpan(0, 0, 1))); - - Task extension(AssertArgs args) - { - var cl = args.Value as IChangeLogAuditLog; - if (cl is null || cl.ChangeLogAudit == null) - args.Tester.Implementor.AssertFail($"Expected Change Log ({nameof(IChangeLogAuditLog)}.{nameof(IChangeLogAuditLog.ChangeLogAudit)}) to have a non-null value."); - - if (cl!.ChangeLogAudit!.UpdatedBy == null) - args.Tester.Implementor.AssertFail($"Expected Change Log ({nameof(IChangeLogAuditLog)}.{nameof(IChangeLogAuditLog.ChangeLogAudit)}).{nameof(ChangeLog.UpdatedBy)} value of '{updatedBy}'; actual was null."); - else - { - var wcr = Wildcard.BothAll.Parse(updatedBy).ThrowOnError(); - if (cl?.ChangeLogAudit?.UpdatedBy == null || !wcr.CreateRegex().IsMatch(cl!.ChangeLogAudit!.UpdatedBy)) - args.Tester.Implementor.AssertFail($"Expected Change Log ({nameof(IChangeLogAuditLog)}.{nameof(IChangeLogAuditLog.ChangeLogAudit)}).{nameof(ChangeLog.UpdatedBy)} value of '{updatedBy}'; actual '{cl?.ChangeLogAudit?.UpdatedBy}'."); - } - - if (!cl!.ChangeLogAudit!.UpdatedDate.HasValue) - args.Tester.Implementor.AssertFail($"Expected Change Log ({nameof(IChangeLogAuditLog)}.{nameof(IChangeLogAuditLog.ChangeLogAudit)}).{nameof(ChangeLog.UpdatedDate)} to have a non-null value."); - else if (Cleaner.Clean(cl!.ChangeLogAudit!.UpdatedDate.Value) < updatedDateGreaterThan.Value) - args.Tester.Implementor.AssertFail($"Expected Change Log ({nameof(IChangeLogAuditLog)}.{nameof(IChangeLogAuditLog.ChangeLogAudit)}).{nameof(ChangeLog.UpdatedDate)} value of '{updatedDateGreaterThan.Value}'; actual '{cl.ChangeLogAudit.UpdatedDate.Value}' must be greater than or equal to expected."); - - return Task.FromResult(false); - } - - return SetValueExpectationExtension(tester, extension); - } - - #endregion - - #region IgnorePathsExpectations - - /// - /// Ignores the JSON path. - /// - /// The expectations . - /// The value . - /// The tester. - /// The instance to support fluent-style method-chaining. - public static TSelf IgnoreIdentifier(this IValueExpectations tester) where TSelf : IValueExpectations => IgnorePaths(tester, nameof(IIdentifier.Id)); - - /// - /// Ignores the JSON path. - /// - /// The expectations . - /// The value . - /// The tester. - /// The instance to support fluent-style method-chaining. - public static TSelf IgnorePrimaryKey(this IValueExpectations tester) where TSelf : IValueExpectations => IgnorePaths(tester, nameof(IPrimaryKey.PrimaryKey)); - - /// - /// Ignores the JSON path. - /// - /// The expectations . - /// The value . - /// The tester. - /// The instance to support fluent-style method-chaining. - public static TSelf IgnoreETag(this IValueExpectations tester) where TSelf : IValueExpectations => IgnorePaths(tester, nameof(IETag.ETag)); - - /// - /// Ignores the JSON path. - /// - /// The expectations . - /// The value . - /// The tester. - /// The instance to support fluent-style method-chaining. - public static TSelf IgnoreChangeLog(this IValueExpectations tester) where TSelf : IValueExpectations => IgnorePaths(tester, nameof(IChangeLog.ChangeLog)); - - /// - /// Adds to ignore from the JSON value comparison. - /// - /// The expectations . - /// The tester. - /// The JSON paths to ignore. - /// The instance to support fluent-style method-chaining. - public static TSelf IgnorePaths(IExpectations tester, params string[] paths) where TSelf : IExpectations - { - tester.ExpectationsArranger.PathsToIgnore.AddRange(paths); - return (TSelf)tester; - } - - #endregion - - #region ErrorExpectations - - /// - /// Expects the specified . - /// - /// The expectations . - /// The . - /// The expected . - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectErrorType(this IExpectations expectations, ErrorType errorType) where TSelf : IExpectations - { - var ee = expectations.ExpectationsArranger.GetOrAdd(() => new ErrorExpectations(expectations.ExpectationsArranger.Owner, (TSelf)expectations)); - - ee.AddExtension(args => - { - if (args.Exception is not null) - { - if (args.Exception is IExtendedException eex) - { - if (eex.ErrorType != errorType.ToString()) - args.Tester.Implementor.AssertFail($"Expected error type of '{errorType}' but actual was '{eex.ErrorType}'."); - else - return Task.FromResult(false); - } - } - - if (args.TryGetExtra(out var result) && result is not null) - { - if (result.Headers.TryGetValues(HttpConsts.ErrorTypeHeaderName, out var vals)) - { - if (vals.Contains(errorType.ToString())) - return Task.FromResult(false); - else - args.Tester.Implementor.AssertFail($"Expected error type of '{errorType}' but actual was {string.Join(", ", vals.Select(x => $"'{x}'"))}."); - } - } - - args.Tester.Implementor.AssertFail($"Expected error type of '{errorType}' but none was returned."); - return Task.FromResult(false); - }); - - return (TSelf)expectations; - } - - /// - /// Expects that one or more errors will be returned matching the specified . - /// - /// The expectations . - /// The . - /// The error messages. - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectErrors(this IExpectations expectations, MessageItemCollection messages) where TSelf : IExpectations - => ExpectErrors(expectations, messages is null ? [] : messages.ToArray()); - - /// - /// Expects that one or more errors will be returned matching the specified . - /// - /// The expectations . - /// The . - /// The error messages. - /// The instance to support fluent-style method-chaining. - public static TSelf ExpectErrors(this IExpectations expectations, params MessageItem[] messages) where TSelf : IExpectations - { - if (messages.Length == 0) - return (TSelf)expectations; - - var errors = new List(); - foreach (var msg in messages.Where(x => x.Type == MessageType.Error)) - errors.Add(new ApiError(msg.Property, msg.Text ?? string.Empty)); - - return expectations.ExpectErrors(errors.ToArray()); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/GlobalUsing.cs b/src/CoreEx.UnitTesting/GlobalUsing.cs new file mode 100644 index 00000000..7d1723d2 --- /dev/null +++ b/src/CoreEx.UnitTesting/GlobalUsing.cs @@ -0,0 +1,37 @@ +global using CloudNative.CloudEvents; +global using CoreEx; +global using CoreEx.Azure.Messaging.ServiceBus; +global using CoreEx.Data; +global using CoreEx.Database.SqlServer.Outbox; +global using CoreEx.Entities; +global using CoreEx.Entities.Abstractions; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Events.Subscribing; +global using CoreEx.Hosting; +global using CoreEx.Http.Abstractions; +global using CoreEx.Json; +global using CoreEx.Security; +global using CoreEx.UnitTesting.Events; +global using CoreEx.Validation; +global using DbEx; +global using DbEx.Migration; +global using DbEx.SqlServer.Migration; +global using AwesomeAssertions; +global using Microsoft.Extensions.DependencyInjection; +global using System.Diagnostics.CodeAnalysis; +global using System.Reflection; +global using System.Text; +global using System.Text.Json; +global using System.Text.Json.Nodes; +global using System.Text.RegularExpressions; +global using UnitTestEx; +global using UnitTestEx.Abstractions; +global using UnitTestEx.AspNetCore; +global using UnitTestEx.Assertors; +global using UnitTestEx.Expectations; +global using UnitTestEx.Hosting; +global using YamlDotNet.Core.Events; +global using YamlDotNet.Serialization; +global using Asb = Azure.Messaging.ServiceBus; +global using ExecutionContext = CoreEx.ExecutionContext; \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Json/ToCoreExJsonSerializerMapper.cs b/src/CoreEx.UnitTesting/Json/ToCoreExJsonSerializerMapper.cs deleted file mode 100644 index a6b77a7b..00000000 --- a/src/CoreEx.UnitTesting/Json/ToCoreExJsonSerializerMapper.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; - -namespace UnitTestEx.Json -{ - /// - /// Provides a wrapper for to . - /// - /// Only the compatible capabilities have been implemented. - /// The . - public class ToCoreExJsonSerializerMapper(IJsonSerializer testJsonSerializer) : CoreEx.Json.IJsonSerializer - { - private readonly IJsonSerializer _testJsonSerializer = testJsonSerializer.ThrowIfNull(nameof(testJsonSerializer)); - - /// - public object Options => _testJsonSerializer.Options; - - /// - public object? Deserialize(string json) => _testJsonSerializer.Deserialize(json); - - /// - public object? Deserialize(string json, Type type) => _testJsonSerializer.Deserialize(json, type); - - /// - public T? Deserialize(string json) => _testJsonSerializer.Deserialize(json); - - /// - public object? Deserialize(BinaryData json) => throw new NotImplementedException(); - - /// - public object? Deserialize(BinaryData json, Type type) => throw new NotImplementedException(); - - /// - public T? Deserialize(BinaryData json) => throw new NotImplementedException(); - - /// - public string Serialize(T value, CoreEx.Json.JsonWriteFormat? format = null) => _testJsonSerializer.Serialize(value, format == CoreEx.Json.JsonWriteFormat.Indented ? JsonWriteFormat.Indented : JsonWriteFormat.None); - - /// - public BinaryData SerializeToBinaryData(T value, CoreEx.Json.JsonWriteFormat? format = null) => throw new NotImplementedException(); - - /// - public bool TryApplyFilter(T value, IEnumerable? names, out string json, CoreEx.Json.JsonPropertyFilter filter = CoreEx.Json.JsonPropertyFilter.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) - => throw new NotImplementedException(); - - /// - public bool TryApplyFilter(T value, IEnumerable? names, out object json, CoreEx.Json.JsonPropertyFilter filter = CoreEx.Json.JsonPropertyFilter.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) - => throw new NotImplementedException(); - - /// - public bool TryGetJsonName(MemberInfo memberInfo, [NotNullWhen(true)] out string? jsonName) - => throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Json/ToUnitTestExJsonSerializerMapper.cs b/src/CoreEx.UnitTesting/Json/ToUnitTestExJsonSerializerMapper.cs deleted file mode 100644 index 75bac02f..00000000 --- a/src/CoreEx.UnitTesting/Json/ToUnitTestExJsonSerializerMapper.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; - -namespace UnitTestEx.Json -{ - /// - /// Provides a wrapper for to . - /// - /// Only the compatible capabilities have been implemented. - /// The . - public class ToUnitTestExJsonSerializerMapper(CoreEx.Json.IJsonSerializer coreJsonSerializer) : IJsonSerializer, CoreEx.Json.IJsonSerializer - { - private readonly CoreEx.Json.IJsonSerializer _coreJsonSerializer = coreJsonSerializer.ThrowIfNull(nameof(coreJsonSerializer)); - - /// - public object Options => _coreJsonSerializer.Options; - - /// - public object? Deserialize(string json) => _coreJsonSerializer.Deserialize(json); - - /// - public object? Deserialize(string json, Type type) => _coreJsonSerializer.Deserialize(json, type); - - /// - public T? Deserialize(string json) => _coreJsonSerializer.Deserialize(json); - - /// - public string Serialize(T value, JsonWriteFormat? format = null) => _coreJsonSerializer.Serialize(value, format == JsonWriteFormat.Indented ? CoreEx.Json.JsonWriteFormat.Indented : CoreEx.Json.JsonWriteFormat.None); - - #region CoreEx.Json.IJsonSerializer - - /// - object? CoreEx.Json.IJsonSerializer.Deserialize(BinaryData json) => _coreJsonSerializer.Deserialize(json); - - /// - object? CoreEx.Json.IJsonSerializer.Deserialize(BinaryData json, Type type) => _coreJsonSerializer.Deserialize(json, type); - - /// - T? CoreEx.Json.IJsonSerializer.Deserialize(BinaryData json) where T : default => _coreJsonSerializer.Deserialize(json); - - /// - string CoreEx.Json.IJsonSerializer.Serialize(T value, CoreEx.Json.JsonWriteFormat? format) - => _coreJsonSerializer.Serialize(value, format); - - /// - BinaryData CoreEx.Json.IJsonSerializer.SerializeToBinaryData(T value, CoreEx.Json.JsonWriteFormat? format) - => _coreJsonSerializer.SerializeToBinaryData(value, format); - - /// - bool CoreEx.Json.IJsonSerializer.TryApplyFilter(T value, IEnumerable? names, out string json, CoreEx.Json.JsonPropertyFilter filter, StringComparison comparison, Action? preFilterInspector) - => _coreJsonSerializer.TryApplyFilter(value, names, out json, filter, comparison, preFilterInspector); - - /// - bool CoreEx.Json.IJsonSerializer.TryApplyFilter(T value, IEnumerable? names, out object json, CoreEx.Json.JsonPropertyFilter filter, StringComparison comparison, Action? preFilterInspector) - => _coreJsonSerializer.TryApplyFilter(value, names, out json, filter, comparison, preFilterInspector); - - /// - bool CoreEx.Json.IJsonSerializer.TryGetJsonName(MemberInfo memberInfo, [NotNullWhen(true)] out string? jsonName) - => _coreJsonSerializer.TryGetJsonName(memberInfo, out jsonName); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/README.md b/src/CoreEx.UnitTesting/README.md deleted file mode 100644 index aff4d024..00000000 --- a/src/CoreEx.UnitTesting/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# CoreEx.UnitTesting - -The `CoreEx.UnitTesting` namespace extends [_UnitTestEx_](https://github.com/Avanade/UnitTestEx) to enable _CoreEx_ related unit testing capabilities. - -
- -## Motivation - -To improve and simplify the unit testing of _CoreEx_ related code. - -
- -## Agent-initiated ASP.NET testing - -To test ASP.NET Core Controllers using the _Agent_ pattern, being the usage of a [`TypedHttpClientBase`](../../CoreEx/Http/TypedHttpClientBase.cs) to invoke via an `HttpRequestMessage`, the following enable: -- [`AgentTester`](./AspNetCore/AgentTester.cs) - an _Agent_-based tester that expects no response value. -- [`AgentTester`](./AspNetCore/AgentTesterT.cs) - an _Agent_-based tester that expects the specified responce value. - -
- -## Validation testing - -To test an [`IValidator`](../CoreEx/Validation/IValidator.cs) `Validation().With()` extension methods are provided, extending the [`GenericTesterBase`](https://github.com/Avanade/UnitTestEx/blob/main/src/UnitTestEx/Generic/GenericTesterBase.cs). The following is an example: - -``` csharp -GenericTester.Create() - .ExpectErrors( - "Name is required.", - "Price must be between 0 and 100.") - .Validation().With(new Product { Price = 450.95m }) -``` \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExpectations.ChangeLog.cs b/src/CoreEx.UnitTesting/UnitTestExExpectations.ChangeLog.cs new file mode 100644 index 00000000..931d94c0 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExpectations.ChangeLog.cs @@ -0,0 +1,122 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx.Expectations; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExpectations +{ + /// + /// Expects the to be implemented and have non-default and values. + /// + /// The expectations . + /// The value . + /// The tester. + /// The specific value where specified (can include wildcards); otherwise, indicates to check for user running the test (see ). + /// The in which the should be greater than or equal to; where null it will default to . + /// The instance to support fluent-style method-chaining. + public static TSelf ExpectChangeLogCreated(this IValueExpectations tester, string? createdBy = null, DateTimeOffset? createdOn = null) where TSelf : IValueExpectations + { + string pn; + if (typeof(TValue).GetInterface(typeof(IReadOnlyChangeLogEx).FullName ?? typeof(IReadOnlyChangeLogEx).Name) is null) + { + VerifyImplements(); + pn = $"{nameof(IReadOnlyChangeLog)}.{nameof(IReadOnlyChangeLog.ChangeLog)}"; + } + else + pn = $"{nameof(IReadOnlyChangeLogEx)}"; + + IgnoreChangeLog(tester); + + createdBy ??= tester.ExpectationsArranger.Owner.UserName; + createdOn ??= DateTimeOffset.UtcNow.Subtract(new TimeSpan(0, 0, 1)); + + Task extension(AssertArgs args) + { + var cl = args.Value is IReadOnlyChangeLog icl ? icl.ChangeLog : args.Value as IReadOnlyChangeLogEx; + if (cl is null) + { + args.Tester.Implementor.AssertFail($"Expected {pn} to have a non-null value."); + return Task.FromResult(true); + } + + if (cl.CreatedOn == null) + args.Tester.Implementor.AssertFail($"Expected {pn}.{nameof(IReadOnlyChangeLogEx.CreatedBy)} value of '{createdBy}'; actual was null."); + else + { + if (!SubscribedBase.IsMatch(createdBy, cl.CreatedBy)) + args.Tester.Implementor.AssertFail($"Expected {pn}.{nameof(ChangeLog.CreatedBy)} value of '{createdBy}'; actual '{cl.CreatedBy}'."); + } + + if (!cl.CreatedOn.HasValue) + args.Tester.Implementor.AssertFail($"Expected {pn}.{nameof(IReadOnlyChangeLogEx.CreatedOn)} to have a non-null value."); + else if (cl.CreatedOn.Value < createdOn.Value) + args.Tester.Implementor.AssertFail($"Expected {pn}.{nameof(IReadOnlyChangeLogEx.CreatedOn)} value of '{createdOn.Value}'; actual '{cl.CreatedOn.Value}' must be greater than or equal to expected."); + + return Task.FromResult(true); + } + + return SetValueExpectationExtension(tester, extension); + } + + /// + /// Expects the to be implemented and have non-default and values. + /// + /// The expectations . + /// The value . + /// The tester. + /// The specific value where specified (can include wildcards); otherwise, indicates to check for user running the test (see ). + /// The in which the should be greater than or equal to; where null it will default to . + /// The instance to support fluent-style method-chaining. + public static TSelf ExpectChangeLogUpdated(this IValueExpectations tester, string? updatedBy = null, DateTimeOffset? updatedOn = null) where TSelf : IValueExpectations + { + string pn; + if (typeof(TValue).GetInterface(typeof(IReadOnlyChangeLogEx).FullName ?? typeof(IReadOnlyChangeLogEx).Name) is null) + { + VerifyImplements(); + pn = $"{nameof(IReadOnlyChangeLog)}.{nameof(IReadOnlyChangeLog.ChangeLog)}"; + } + else + pn = $"{nameof(IReadOnlyChangeLogEx)}"; + + IgnoreChangeLog(tester); + + updatedBy ??= tester.ExpectationsArranger.Owner.UserName; + updatedOn ??= DateTimeOffset.UtcNow.Subtract(new TimeSpan(0, 0, 1)); + + Task extension(AssertArgs args) + { + var cl = args.Value is IReadOnlyChangeLog icl ? icl.ChangeLog : args.Value as IReadOnlyChangeLogEx; + if (cl is null) + { + args.Tester.Implementor.AssertFail($"Expected {pn} to have a non-null value."); + return Task.FromResult(true); + } + + if (cl.UpdatedOn == null) + args.Tester.Implementor.AssertFail($"Expected {pn}.{nameof(IReadOnlyChangeLogEx.UpdatedBy)} value of '{updatedBy}'; actual was null."); + else + { + if (!SubscribedBase.IsMatch(updatedBy, cl.UpdatedBy)) + args.Tester.Implementor.AssertFail($"Expected {pn}.{nameof(ChangeLog.UpdatedBy)} value of '{updatedBy}'; actual '{cl.UpdatedBy}'."); + } + + if (!cl.UpdatedOn.HasValue) + args.Tester.Implementor.AssertFail($"Expected {pn}.{nameof(IReadOnlyChangeLogEx.UpdatedOn)} to have a non-null value."); + else if (cl.UpdatedOn.Value < updatedOn.Value) + args.Tester.Implementor.AssertFail($"Expected {pn}.{nameof(IReadOnlyChangeLogEx.UpdatedOn)} value of '{updatedOn.Value}'; actual '{cl.UpdatedOn.Value}' must be greater than or equal to expected."); + + return Task.FromResult(true); + } + + return SetValueExpectationExtension(tester, extension); + } + + /// + /// Ignores the JSON path (and , , and . + /// + /// The expectations . + /// The value . + /// The tester. + /// The instance to support fluent-style method-chaining. + public static TSelf IgnoreChangeLog(this IValueExpectations tester) where TSelf : IValueExpectations + => IgnorePaths(tester, nameof(IChangeLog.ChangeLog), nameof(IChangeLogEx.CreatedBy), nameof(IChangeLogEx.CreatedOn), nameof(IChangeLogEx.UpdatedBy), nameof(IChangeLogEx.UpdatedOn)); +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExpectations.ETag.cs b/src/CoreEx.UnitTesting/UnitTestExExpectations.ETag.cs new file mode 100644 index 00000000..bd49d94e --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExpectations.ETag.cs @@ -0,0 +1,44 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx.Expectations; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExpectations +{ + /// + /// Expects the to be implemented and have non-default . + /// + /// The expectations . + /// The value . + /// The tester. + /// The optional previous ETag to compare not equal to; i.e. it must be different. + /// The instance to support fluent-style method-chaining. + public static TSelf ExpectETag(this IValueExpectations tester, string? previousETag = null) where TSelf : IValueExpectations + { + VerifyImplements(); + IgnoreETag(tester); + var pn = $"{nameof(IReadOnlyETag)}.{nameof(IReadOnlyETag.ETag)}"; + + Task extension(AssertArgs args) + { + var etag = args.Value as IETag; + if (etag is null || etag.ETag is null) + args.Tester.Implementor.AssertFail($"Expected {pn} to have a non-null value."); + + if (previousETag is not null && previousETag == etag!.ETag) + args.Tester.Implementor.AssertFail($"Expected {pn} value of '{previousETag}' to be different to actual."); + + return Task.FromResult(true); + } + + return SetValueExpectationExtension(tester, extension); + } + + /// + /// Ignores the JSON path. + /// + /// The expectations . + /// The value . + /// The tester. + /// The instance to support fluent-style method-chaining. + public static TSelf IgnoreETag(this IValueExpectations tester) where TSelf : IValueExpectations => IgnorePaths(tester, nameof(IReadOnlyETag.ETag)); +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExpectations.Events.cs b/src/CoreEx.UnitTesting/UnitTestExExpectations.Events.cs new file mode 100644 index 00000000..92148d8e --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExpectations.Events.cs @@ -0,0 +1,100 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx.Expectations; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides -specific extension methods to . +/// +public static partial class UnitTestExExpectations +{ + /// + /// Gets the key suffix. + /// + internal const string RequestStateDataKeySuffix = "_Expectations"; + + /// + /// Expects that no events will have been published for the keyed ( or default where not specified). + /// + /// The tester. + /// The service key used for the keyed registration. + /// The instance to support fluent-style method-chaining. + /// The must be the same as used when registering the underlying . + public static TSelf ExpectNoEvents(this IExpectations tester, string serviceKey) where TSelf : IExpectations + { + var requestId = tester.ExpectationsArranger.Tester is HttpTesterBase httpTester ? httpTester.RequestId : null; + tester.ExpectationsArranger.GetOrAdd(() => new EventExpectations(tester.ExpectationsArranger.Owner, (TSelf)tester, requestId, Assembly.GetCallingAssembly())).ExpectNoEvents(serviceKey); + + // Tag that an expectation has been added for the current request (if applicable) so that the expectations will be evaluated at the end of the request. + tester.ExpectationsArranger.Owner.SharedState.RequestStateData(requestId)[$"{serviceKey}_Expectations"] = null; + return (TSelf)tester; + } + + /// + /// Expects that events will have been published for the keyed ( or default where not specified). + /// + /// The tester. + /// The service key used for the keyed registration. + /// The action to enable events expectations configuration. + /// The optional to use for default resource resolution. + /// The instance to support fluent-style method-chaining. + /// The must be the same as used when registering the underlying . + public static TSelf ExpectEvents(this IExpectations tester, string serviceKey, Action? configure = null, Assembly? resourceAssembly = null) where TSelf : IExpectations + { + var requestId = tester.ExpectationsArranger.Tester is HttpTesterBase httpTester ? httpTester.RequestId : null; + tester.ExpectationsArranger.GetOrAdd(() => new EventExpectations(tester.ExpectationsArranger.Owner, (TSelf)tester, requestId, resourceAssembly ?? Assembly.GetCallingAssembly())).ExpectEvents(serviceKey, configure); + + // Tag that an expectation has been added for the current request (if applicable) so that the expectations will be evaluated at the end of the request. + tester.ExpectationsArranger.Owner.SharedState.RequestStateData(requestId)[$"{serviceKey}{RequestStateDataKeySuffix}"] = null; + return (TSelf)tester; + } + + /// + /// Adds a post-run action to evaluate and clean up the captured events for the keyed (). + /// + /// The API startup . + /// The . + /// The service key for the previously registered . + /// Indicates whether to expect no events to be published. + /// The instance to support fluent-style method-chaining. + /// The parameter is only actioned when no explicit event expectations are defined for the underlying test; acts as a catch all. + /// Note: this should not generally be invoked directly, but rather through a parent extension method, or equivalent, where applicable. + public static AspNetCore.ApiTester AddEventExpectationsPostRun(this AspNetCore.ApiTester tester, string serviceKey, bool expectNoEvents = true) where TEntryPoint : class => tester + .AddPostRunAfterExpectationsAction(instance => + { + // Determine whether the test run itself had any expectations defined for the captured events; if not, then perform an expectation to ensure that no events were published (as the captured events would be unexpected). + var requestId = instance is HttpTesterBase httpTester ? httpTester.RequestId : null; + var data = tester.SharedState.RequestStateData(requestId); + + // If no explicit event expectations were defined for the test, then assert that no events were published (as any captured events would be unexpected). + var eventKey = $"{serviceKey}{UnitTestExExpectations.RequestStateDataKeySuffix}"; + if (expectNoEvents && !data.ContainsKey(eventKey)) + { + tester.Implementor.WriteLine(""); + tester.Implementor.WriteLine($"** '{serviceKey}' has no explicit event expectations defined; asserting that no events were published."); + if (data.TryGetValue(serviceKey, out var obj) && obj is DestinationEvent[] events && events.Length > 0) + tester.Implementor.AssertFail($"Expected no {serviceKey} events; however, {events.Length} found to be published."); + else + tester.Implementor.WriteLine("> Expected no events and there were none."); + } + }) + .AddPostRunAction(_ => + { + // Pre-delete any existing captured events to ensure a clean slate for this test run. + var eventKey = $"{serviceKey}{UnitTestExExpectations.RequestStateDataKeySuffix}"; + tester.SharedState.StateData.Remove(eventKey, out var __); + }); + + /// + /// Configures an that will be executed (during the underlying ) for the keyed (). + /// + /// The tester. + /// The service key for the previously registered . + /// The action to execute. + /// The instance to support fluent-style method-chaining. + /// This allows for test-specific behaviors to be executed just prior to the actual publishing of events; i.e. simulating the throwing on an unexpected exception. + public static TSelf OnEventPublish(this TSelf tester, string serviceKey, Action action) where TSelf : HttpTesterBase + { + tester.Owner.SharedState.RequestStateData(tester.RequestId)[$"_{nameof(EventPublisherDecorator)}_{serviceKey}"] = action; + return (TSelf)tester; + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExpectations.Identifier.cs b/src/CoreEx.UnitTesting/UnitTestExExpectations.Identifier.cs new file mode 100644 index 00000000..fad00005 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExpectations.Identifier.cs @@ -0,0 +1,49 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx.Expectations; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExpectations +{ + /// + /// Expects the to be implemented and have non-default . + /// + /// The expectations . + /// The value . + /// The tester. + /// The optional expected identifier to compare to. + /// The instance to support fluent-style method-chaining. + public static TSelf ExpectIdentifier(this IValueExpectations tester, object? identifier = null) where TSelf : IValueExpectations + { + VerifyImplements(); + IgnoreIdentifier(tester); + var pn = $"{nameof(IReadOnlyIdentifier)}.{nameof(IIdentifier.Id)}"; + + Task extension(AssertArgs args) + { + var id = args.Value as IIdentifier; + if (id is null || id.Id is null) + args.Tester.Implementor.AssertFail($"Expected {pn} to have a non-null value."); + + if (identifier is null) + { + if (System.Collections.Comparer.Default.Compare(id!.Id, id!.GetType().IsClass ? null! : Activator.CreateInstance(id!.GetType())) == 0) + args.Tester.Implementor.AssertFail($"Expected {pn} to have a non-default value."); + } + else + args.Tester.Implementor.AssertAreEqual(identifier, id!.Id, $"Expected {pn} value of '{identifier}'; actual '{id.Id}'."); + + return Task.FromResult(true); + } + + return SetValueExpectationExtension(tester, extension); + } + + /// + /// Ignores the JSON path. + /// + /// The expectations . + /// The value . + /// The tester. + /// The instance to support fluent-style method-chaining. + public static TSelf IgnoreIdentifier(this IValueExpectations tester) where TSelf : IValueExpectations => IgnorePaths(tester, nameof(IIdentifierCore.Id)); +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExpectations.ServiceBus.cs b/src/CoreEx.UnitTesting/UnitTestExExpectations.ServiceBus.cs new file mode 100644 index 00000000..881394e8 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExpectations.ServiceBus.cs @@ -0,0 +1,30 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx.Expectations; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides -specific extension methods to . +/// +public static partial class UnitTestExExpectations +{ + /// + /// Expects that no events will have been published for the keyed ( defaults to ). + /// + /// The tester. + /// The service key used for the keyed registration. + /// The instance to support fluent-style method-chaining. + /// The must be the same as used when registering the underlying . + public static TSelf ExpectNoAzureServiceBusEvents(this IExpectations tester, string serviceKey = ServiceBusPublisher.DefaultServiceKey) where TSelf : IExpectations + => ExpectNoEvents(tester, serviceKey); + + /// + /// Expects that events will have been published for the keyed ( defaults to ). + /// + /// The tester. + /// The action to enable events expectations configuration. + /// The service key used for the keyed registration. + /// The instance to support fluent-style method-chaining. + /// The must be the same as used when registering the underlying . + public static TSelf ExpectAzureServiceBusEvents(this IExpectations tester, Action? configure = null, string serviceKey = ServiceBusPublisher.DefaultServiceKey) where TSelf : IExpectations + => ExpectEvents(tester, serviceKey, configure, Assembly.GetCallingAssembly()); +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExpectations.SqlServer.cs b/src/CoreEx.UnitTesting/UnitTestExExpectations.SqlServer.cs new file mode 100644 index 00000000..710f62dd --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExpectations.SqlServer.cs @@ -0,0 +1,30 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx.Expectations; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides -specific extension methods to . +/// +public static partial class UnitTestExExpectations +{ + /// + /// Expects that no events will have been published for the keyed ( defaults to ). + /// + /// The tester. + /// The service key used for the keyed registration. + /// The instance to support fluent-style method-chaining. + /// The must be the same as used when registering the underlying . + public static TSelf ExpectNoSqlServerOutboxEvents(this IExpectations tester, string serviceKey = SqlServerOutboxPublisher.DefaultServiceKey) where TSelf : IExpectations + => ExpectNoEvents(tester, serviceKey); + + /// + /// Expects that events will have been published for the keyed ( defaults to ). + /// + /// The tester. + /// The action to enable events expectations configuration. + /// The service key used for the keyed registration. + /// The instance to support fluent-style method-chaining. + /// The must be the same as used when registering the underlying . + public static TSelf ExpectSqlServerOutboxEvents(this IExpectations tester, Action? configure = null, string serviceKey = SqlServerOutboxPublisher.DefaultServiceKey) where TSelf : IExpectations + => ExpectEvents(tester, serviceKey, configure, Assembly.GetCallingAssembly()); +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExpectations.cs b/src/CoreEx.UnitTesting/UnitTestExExpectations.cs new file mode 100644 index 00000000..b1fb9b70 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExpectations.cs @@ -0,0 +1,40 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx.Expectations; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides -specific extension methods to . +/// +public static partial class UnitTestExExpectations +{ + /// + /// Sets the configured expectation logic. + /// + private static TSelf SetValueExpectationExtension(this IValueExpectations tester, Func> extension) where TSelf : IValueExpectations + { + tester.ExpectationsArranger.GetOrAdd(() => new ValueExpectations(tester.ExpectationsArranger.Owner, (TSelf)tester)).AddExtension(extension); + return (TSelf)tester; + } + + /// + /// Verifies that the implements . + /// + private static void VerifyImplements() + { + if (typeof(TValue).GetInterface(typeof(TInterface).FullName ?? typeof(TInterface).Name) == null) + throw new InvalidOperationException($"{typeof(TValue).Name} must implement the interface {typeof(TInterface).Name}."); + } + + /// + /// Adds to ignore from the JSON value comparison. + /// + /// The expectations . + /// The tester. + /// The JSON paths to ignore. + /// The instance to support fluent-style method-chaining. + public static TSelf IgnorePaths(IExpectations tester, params string[] paths) where TSelf : IExpectations + { + tester.ExpectationsArranger.PathsToIgnore.AddRange(paths); + return (TSelf)tester; + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs new file mode 100644 index 00000000..62e598a6 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs @@ -0,0 +1,23 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExtensions +{ + /// + /// Asserts that the response is a and that the matches the expected . + /// + /// The . + /// The . + /// The expected . + /// The instance to support fluent-style method-chaining. + public static TSelf AssertProblemDetailsTitle(this TSelf assertor, string title) where TSelf : HttpResponseMessageAssertorBase + { + var problemDetails = assertor.GetValue(null); + if (problemDetails is null) + assertor.Owner.Implementor.AssertFail("Expected ProblemDetails response to be present but nothing was returned."); + + assertor.Owner.Implementor.AssertAreEqual(title, problemDetails!.Title, "ProblemDetails Title does not match expected value."); + return assertor; + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.Caching.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.Caching.cs new file mode 100644 index 00000000..d2237e31 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.Caching.cs @@ -0,0 +1,13 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExtensions +{ + /// + /// Clears the underlying L1/L2 cache by executing the . + /// + /// The . + /// The service must be registered within the underlying test host. + public static async Task ClearFusionCacheAsync(this TesterBase tester) => await tester.ThrowIfNull().Services.GetRequiredService().ClearAsync(false); +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.CloudEvent.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.CloudEvent.cs new file mode 100644 index 00000000..7ead2617 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.CloudEvent.cs @@ -0,0 +1,52 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides extensions for to support common testing scenarios. +/// +public static partial class UnitTestExExtensions +{ + /// + /// Create a from the specified . + /// + /// The . + /// The . + /// The . + /// This will use the configured service to format and convert. + public static CloudEvent CreateCloudEventFrom(this TesterBase tester, EventData @event) + { + tester.ThrowIfNull(); + var formatter = tester.Services.GetService() ?? ActivatorUtilities.CreateInstance(tester.Services); + return formatter.ConvertToCloudEvent(formatter.Format(@event)); + } + + /// + /// Create a from the specified JSON resource. + /// + /// The . + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// An optional action to update the before use. + /// The that contains the embedded resource; defaults to . + /// The . + public static CloudEvent CreateCloudEventFromJsonResource(this TesterBase tester, string resourceName, Action? updater = null, Assembly? assembly = null) + { + tester.ThrowIfNull(); + + var json = Resource.GetJson(resourceName, assembly ?? Assembly.GetCallingAssembly()); + var jr = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + var je = JsonElement.ParseValue(ref jr); + return je.DecodeToCloudEvent().Adjust(ce => updater?.Invoke(ce)); + } + + /// + /// Create a from the specified JSON resource. + /// + /// The to infer the for the embedded resource. + /// The . + /// The embedded resource name (matches to the end of the fully qualified resource name). + /// An optional action to update the before use. + /// The . + public static CloudEvent CreateCloudEventFromJsonResource(this TesterBase tester, string resourceName, Action? updater = null) + => tester.ThrowIfNull().CreateCloudEventFromJsonResource(resourceName, updater, typeof(TAssembly).Assembly); +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.DbEx.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.DbEx.cs new file mode 100644 index 00000000..8eca0b4d --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.DbEx.cs @@ -0,0 +1,51 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExtensions +{ + private static bool _initializeDatabase = true; + + /// + /// Execute the using the to include additional resource. + /// + /// The to infer the underlying . + /// The . + /// The function to further configure the . + /// The database connection string. + /// Where the migration is unsuccessful then an will be automatically isued. + /// The supports the retrieval of the value from where prefixed with 'config:' or '^', or is wrapped with '%'. + public static Task MigrateSqlServerDataAsync(this TesterBase tester, Func? configureMigrationArgs = null, string connectionString = "^Aspire:Microsoft:Data:SqlClient:ConnectionString") + => MigrateSqlServerDataAsync(tester, configureMigrationArgs, connectionString, typeof(TAssembly).Assembly); + + /// + /// Execute the . + /// + /// The . + /// The function to further configure the . + /// The database connection string. + /// Zero or more assemblies to add to the migration args (see ). + /// Where the migration is unsuccessful then an will be automatically isued. + /// The supports the retrieval of the value from where prefixed with 'config:' or '^', or is wrapped with '%'. + public static async Task MigrateSqlServerDataAsync(this TesterBase tester, Func? configureMigrationArgs = null, string connectionString = "^Aspire:Microsoft:Data:SqlClient:ConnectionString", params Assembly[] assemblies) + { + // Determine the connection string and configure the migration args. + var cs = CoreEx.Abstractions.Internal.GetValueFromConfigurationWhereApplicable(connectionString.ThrowIfNullOrEmpty(), tester.ThrowIfNull().Configuration); + var ma = new MigrationArgs(_initializeDatabase ? MigrationCommand.All | MigrationCommand.ResetAndData : MigrationCommand.ResetAndData, cs); + + if (configureMigrationArgs is not null) + ma = configureMigrationArgs(ma); + + if (assemblies.Length > 0) + ma.AddAssembly(assemblies); + + // Execute the sql-server migration. + using var m = new SqlServerMigration(ma); + var (Success, Output) = await m.MigrateAndLogAsync().ConfigureAwait(false); + + if (!Success) + tester.Implementor.AssertFail("SqlServerMigration failed:" + Environment.NewLine + Output); + + _initializeDatabase = false; + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.Events.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.Events.cs new file mode 100644 index 00000000..6bea6ab3 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.Events.cs @@ -0,0 +1,28 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExtensions +{ + /// + /// Replaces the registered with a decorator () that also captures the published events for expectation assertions. + /// + /// The . + /// The service key for the previously registered . + /// Indicates whether to bypass the pass-through to the original event publisher. + /// The to support fluent-style method-chaining. + /// The originating should have been registered with the + /// to ensure the service was registered correctly to enable this functionality. + /// The when set to will bypass the pass-through to the original event publisher and leverage the instead. + public static IServiceCollection UseExpectedEventPublisher(this IServiceCollection services, string serviceKey = "EventPublisher", bool bypassPassThrough = false) + => services.ThrowIfNull().ReplaceKeyedScoped(serviceKey.ThrowIfNullOrEmpty(), (sp, _) => + { + var rootServiceKey = $"{serviceKey}_Root"; + var root = bypassPassThrough + ? ActivatorUtilities.CreateInstance(sp) + : sp.GetKeyedService(rootServiceKey) ?? throw new InvalidOperationException($"The root '{rootServiceKey}' publisher must be registered before the expected publisher can be used."); + + var sharedState = sp.GetService() ?? throw new InvalidOperationException($"The UnitTestEx test shared state must be registered as required by the underlying {nameof(EventPublisherDecorator)}."); + return new EventPublisherDecorator(serviceKey, sharedState, root); + }); +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.ServiceBus.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.ServiceBus.cs new file mode 100644 index 00000000..c466eb27 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.ServiceBus.cs @@ -0,0 +1,160 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExtensions +{ + /// + /// Gets all messages for the Azure Service Bus queue or topic subscription completing each resulting in all messages also being cleared. + /// + /// The . + /// The . + /// A list of that were cleared. + public static async Task> GetAndClearAzureServiceBusAsync(this TesterBase tester, ServiceBusReceiverOptions sbo) + { + var sbc = tester.ThrowIfNull().Services.GetRequiredService(); + var qtn = CoreEx.Abstractions.Internal.GetValueFromConfigurationWhereApplicable(sbo.QueueOrTopicName, tester.Configuration); + var list = new List(); + + await using var receiver = sbo.IsSubscription + ? sbc.CreateReceiver(qtn, CoreEx.Abstractions.Internal.GetValueFromConfigurationWhereApplicable(sbo.SubscriptionName!, tester.Configuration)) + : sbc.CreateReceiver(qtn); + + while (true) + { + var messages = await receiver.ReceiveMessagesAsync(maxMessages: 50, maxWaitTime: TimeSpan.FromMilliseconds(1)); + if (messages.Count == 0) + break; + + foreach (var m in messages) + await receiver.CompleteMessageAsync(m); + + list.AddRange(messages); + } + + return list; + } + + /// + /// Gets all messages for the Azure Service Bus queue or topic subscription completing each resulting in all messages also being cleaed. + /// + /// The . + /// The . + /// A list of that were cleared. + /// This method is used for session-enabled queues or topic subscriptions. + public static async Task> GetAndClearAzureServiceBusAsync(this TesterBase tester, ServiceBusSessionReceiverOptions sbo) + { + var sbc = tester.ThrowIfNull().Services.GetRequiredService(); + var qtn = CoreEx.Abstractions.Internal.GetValueFromConfigurationWhereApplicable(sbo.QueueOrTopicName, tester.Configuration); + var list = new List(); + + while (true) + { + Asb.ServiceBusSessionReceiver? session; + + try + { + session = sbo.IsSubscription + ? await sbc.AcceptNextSessionAsync(qtn, CoreEx.Abstractions.Internal.GetValueFromConfigurationWhereApplicable(sbo.SubscriptionName!, tester.Configuration)) + : await sbc.AcceptNextSessionAsync(qtn); + } + catch (Asb.ServiceBusException ex) + { + if (ex.Reason == Asb.ServiceBusFailureReason.ServiceTimeout || (ex.InnerException is System.Net.Sockets.SocketException innerEx && innerEx.SocketErrorCode == System.Net.Sockets.SocketError.TimedOut)) + break; // No more sessions available + + throw; + } + + if (session is null) + break; + + await using (session) + { + while (true) + { + var messages = await session.ReceiveMessagesAsync(maxMessages: 50, maxWaitTime: TimeSpan.FromMilliseconds(1)); + if (messages.Count == 0) + break; + + foreach (var msg in messages) + await session.CompleteMessageAsync(msg); + + list.AddRange(messages); + } + } + } + + return list; + } + + /// + /// Replaces the registered with a decorator () that also captures the published events for expectation assertions. + /// + /// The . + /// The service key for the previously registered . + /// Indicates whether to bypass the pass-through to the original event publisher. + /// The to support fluent-style method-chaining. + /// This is a convenience method that defaults the to where invoking the underlying . + /// The when set to will bypass the pass-through to the original event publisher and leverage the instead. + public static IServiceCollection UseExpectedAzureServiceBusPublisher(this IServiceCollection services, string serviceKey = ServiceBusPublisher.DefaultServiceKey, bool bypassPassThrough = false) + => UseExpectedEventPublisher(services, serviceKey, bypassPassThrough); + + /// + /// Replaces the registered with a decorator () that also captures the published events for expectation assertions; whilst also adding post-run expectations for the captured events. + /// + /// The API startup . + /// The . + /// The service key for the previously registered . + /// Indicates whether to bypass the pass-through to the original event publisher. + /// Indicates whether to expect no events to be published. + /// The instance to support fluent-style method-chaining. + /// The parameter is only actioned when no explicit event expectations are defined for the underlying test; acts as a catch all. + public static AspNetCore.ApiTester UseExpectedAzureServiceBusPublisher(this AspNetCore.ApiTester tester, string serviceKey = ServiceBusPublisher.DefaultServiceKey, bool bypassPassThrough = false, bool expectNoEvents = true) where TEntryPoint : class + => tester.ConfigureServices(services => services.UseExpectedAzureServiceBusPublisher(serviceKey, bypassPassThrough)) + .AddEventExpectationsPostRun(serviceKey, expectNoEvents); + + /// + /// Converts a to a . + /// + /// The . + /// The to use; defaults to . + /// Indicates whether to include all as ; defaults to . + /// The . + /// The is set to the . This converts the to an interim before creating the . + public static Asb.ServiceBusReceivedMessage ToServiceBusReceivedMessage(this CloudEvent cloudEvent, ContentMode contentMode = ContentMode.Structured, bool includeAttributes = true) + => cloudEvent.ToServiceBusMessage(contentMode, includeAttributes).ToServiceBusReceivedMessage(); + + /// + /// Converts a to a . + /// + /// The . + /// The . + public static Asb.ServiceBusReceivedMessage ToServiceBusReceivedMessage(this Asb.ServiceBusMessage message) + { + // Copy application properties + var props = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in message.ApplicationProperties) + props[kvp.Key] = kvp.Value; + + // Create a ServiceBusReceivedMessage using the ServiceBusModelFactory with the same properties as the original message. + return Asb.ServiceBusModelFactory.ServiceBusReceivedMessage( + body: message.Body, + messageId: message.MessageId, + partitionKey: message.PartitionKey, + sessionId: message.SessionId, + replyToSessionId: message.ReplyToSessionId, + timeToLive: message.TimeToLive, + correlationId: message.CorrelationId, + subject: message.Subject, + to: message.To, + contentType: message.ContentType, + replyTo: message.ReplyTo, + scheduledEnqueueTime: message.ScheduledEnqueueTime, + properties: props, + deliveryCount: 1, + sequenceNumber: DateTimeOffset.UtcNow.Ticks, + enqueuedTime: DateTimeOffset.UtcNow + ); + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.SqlServer.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.SqlServer.cs new file mode 100644 index 00000000..24d8a460 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.SqlServer.cs @@ -0,0 +1,32 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExtensions +{ + /// + /// Replaces the registered with a decorator () that also captures the published events for expectation assertions. + /// + /// The . + /// The service key for the previously registered . + /// Indicates whether to bypass the pass-through to the original event publisher. + /// The to support fluent-style method-chaining. + /// This is a convenience method that defaults the to where invoking the underlying . + /// The when set to will bypass the pass-through to the original event publisher and leverage the instead. + public static IServiceCollection UseExpectedSqlServerOutboxPublisher(this IServiceCollection services, string serviceKey = SqlServerOutboxPublisher.DefaultServiceKey, bool bypassPassThrough = false) + => UseExpectedEventPublisher(services, serviceKey, bypassPassThrough); + + /// + /// Replaces the registered with a decorator () that also captures the published events for expectation assertions; whilst also adding post-run expectations for the captured events. + /// + /// The API startup . + /// The . + /// The service key for the previously registered . + /// Indicates whether to bypass the pass-through to the original event publisher. + /// Indicates whether to expect no events to be published. + /// The instance to support fluent-style method-chaining. + /// The parameter is only actioned when no explicit event expectations are defined for the underlying test; acts as a catch all. + public static AspNetCore.ApiTester UseExpectedSqlServerOutboxPublisher(this AspNetCore.ApiTester tester, string serviceKey = SqlServerOutboxPublisher.DefaultServiceKey, bool bypassPassThrough = false, bool expectNoEvents = true) where TEntryPoint : class + => tester.ConfigureServices(services => services.UseExpectedSqlServerOutboxPublisher(serviceKey, bypassPassThrough)) + .AddEventExpectationsPostRun(serviceKey, expectNoEvents); +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.Validation.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.Validation.cs new file mode 100644 index 00000000..d88a7c73 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.Validation.cs @@ -0,0 +1,68 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExtensions +{ + /// + /// Executes the validation and asserts that it is successful (i.e. no errors). + /// + /// The value . + /// The . + /// The value to validate. + /// The . + /// This is using to assert that the validation is successful (i.e. is ). + public static void AssertSuccess(this IValidator validator, TValue value) + => AssertSuccessAsync(validator, value).GetAwaiter().GetResult(); + + /// + /// Executes the validation and asserts that it is successful (i.e. no errors). + /// + /// The value . + /// The . + /// The value to validate. + /// The . + /// This is using to assert that the validation is successful (i.e. is ). + public static async Task> AssertSuccessAsync(this IValidator validator, TValue value) + { + var vr = await validator.ValidateAsync(value).ConfigureAwait(false); + vr.HasErrors.Should().BeFalse(); + return vr; + } + + /// + /// Executes the validation and asserts that it has errors and that the expected errors are present. + /// + /// The value . + /// The . + /// The value to validate. + /// The expected errors. + /// The . + /// This is using to assert that the validation was unsuccessful (i.e. is ), then comparing the + /// with the using . + public static IValidationResult AssertErrors(this IValidator validator, TValue value, params IEnumerable expectedErrors) + => AssertErrorsAsync(validator, value, expectedErrors).GetAwaiter().GetResult(); + + /// + /// Executes the validation and asserts that it has errors and that the expected errors are present. + /// + /// The value . + /// The . + /// The value to validate. + /// The expected errors. + /// The . + /// This is using to assert that the validation was unsuccessful (i.e. is ), then comparing the + /// with the using . + public static async Task> AssertErrorsAsync(this IValidator validator, TValue value, params IEnumerable expectedErrors) + { + var vr = await validator.ValidateAsync(value).ConfigureAwait(false); + vr.HasErrors.Should().BeTrue(); + vr.Messages.Should().NotBeNull().And.HaveCountGreaterThan(0); + + var actualErrors = vr.Messages.Where(x => x.Type == CoreEx.Entities.MessageType.Error).Select(x => new ApiError(x.Property, x.Text?.ToString() ?? "none")).ToArray(); + if (!Assertor.TryAreErrorsMatched(expectedErrors, actualErrors, out var errorMessage)) + false.Should().BeTrue(because: errorMessage); + + return vr; + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.cs index 6fc2567b..93c50bd0 100644 --- a/src/CoreEx.UnitTesting/UnitTestExExtensions.cs +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.cs @@ -1,440 +1,88 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Azure.Core.Amqp; -using Azure.Messaging.ServiceBus; -using CoreEx; -using CoreEx.AspNetCore.Http; -using CoreEx.AspNetCore.WebApis; -using CoreEx.Azure.ServiceBus; -using CoreEx.Events; -using CoreEx.Http; -using CoreEx.Mapping.Converters; -using CoreEx.Validation; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; -using System.Net.Http; -using System.Net.Mime; -using System.Threading.Tasks; -using UnitTestEx.Abstractions; -using UnitTestEx.AspNetCore; -using UnitTestEx.Assertors; -using UnitTestEx.Generic; -using UnitTestEx.Json; -using Ceh = CoreEx.Http; - -namespace UnitTestEx +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides extensions for to support common testing scenarios. +/// +public static partial class UnitTestExExtensions { /// - /// Provides extension methods to the core . + /// Enables an instance to be tested managed within a . /// - public static class UnitTestExExtensions + /// The . + /// The . + /// The testing action. + /// The optional pre-test configuration action. + /// The to support fluent-style method-chaining. + /// This is a convenience method for common testing with an instance within a scoped service lifetime. + public static TSelf Scoped(this TesterBase tester, Action> scopedTester, Action? configure = null) where TSelf : TesterBase { - #region IJsonSerializer - - /// - /// Map (convert) the to a . - /// - /// The . - /// The (see ). - public static IJsonSerializer ToUnitTestEx(this CoreEx.Json.IJsonSerializer jsonSerializer) => new ToUnitTestExJsonSerializerMapper(jsonSerializer); - - /// - /// Map (convert) the to a . - /// - /// The . - /// The (see ). - public static CoreEx.Json.IJsonSerializer ToCoreEx(this IJsonSerializer jsonSerializer) => new ToCoreExJsonSerializerMapper(jsonSerializer); - - /// - /// Updates the used by the itself, not the underlying executing host which should be configured separately. - /// - /// The to support fluent-style method-chaining. - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static TSelf UseJsonSerializer(this TesterBase tester, CoreEx.Json.IJsonSerializer jsonSerializer) where TSelf : TesterBase - => tester.UseJsonSerializer((jsonSerializer.ThrowIfNull(nameof(jsonSerializer))).ToUnitTestEx()); - - #endregion - - #region CreateClientScope - - /// - /// Creates a client-side from the enabling the . - /// - /// The Agent (inherits from ) . - /// The . - /// The . - public static IServiceScope CreateClientScope(this HttpTesterBase tester) where TAgent : TypedHttpClientBase - { - var sc = new ServiceCollection(); - sc.AddExecutionContext(sp => new CoreEx.ExecutionContext { UserName = tester.UserName ?? tester.Owner.SetUp.DefaultUserName }); - - if (tester.JsonSerializer is CoreEx.Json.IJsonSerializer cjs) - sc.AddSingleton(cjs); - else - throw new InvalidOperationException($"The {nameof(HttpTesterBase)} must use a {nameof(IJsonSerializer)} that implements CoreEx.Json.IJsonSerializer to leverage Agent {typeof(TAgent).Name}."); - - sc.AddLogging(lb => { lb.SetMinimumLevel(tester.Owner.SetUp.MinimumLogLevel); lb.ClearProviders(); lb.AddProvider(tester.Owner.LoggerProvider); }); - sc.AddSingleton(new HttpClient(new HttpTesterBase.HttpDelegatingHandler(tester, tester.TestServer.CreateHandler())) { BaseAddress = tester.TestServer.BaseAddress }); - sc.AddSingleton(tester.Owner.SharedState); - sc.AddSingleton(tester.Owner.Configuration); - sc.AddDefaultSettings(); - sc.AddScoped(); - return sc.BuildServiceProvider().CreateScope(); - } - - #endregion - - #region ActionResultAssertor - - /// - /// Asserts that the matches the . - /// - /// The assertor. - /// The expected ETag value. - /// The to support fluent-style method-chaining. - public static ActionResultAssertor AssertETagHeader(this ActionResultAssertor assertor, string expectedETag) - { - if (assertor.Result != null && assertor.Result is ValueContentResult vcr) - assertor.Owner.Implementor.AssertAreEqual(expectedETag, vcr.ETag, $"Expected and Actual {nameof(ValueContentResult.ETag)} values are not equal."); - else - assertor.Owner.Implementor.AssertFail($"The Result must be of Type {typeof(ValueContentResult).FullName} to use {nameof(AssertETagHeader)}."); - - return assertor; - } - - /// - /// Asserts that the matches the . - /// - /// The assertor. - /// The expected . - /// The to support fluent-style method-chaining. -#if NET7_0_OR_GREATER - public static ActionResultAssertor AssertLocationHeader(this ActionResultAssertor assertor, [StringSyntax(StringSyntaxAttribute.Uri)] Uri expectedUri) -#else - public static ActionResultAssertor AssertLocationHeader(this ActionResultAssertor assertor, Uri expectedUri) -#endif - { - if (assertor.Result != null && assertor.Result is ValueContentResult vcr) - assertor.Owner.Implementor.AssertAreEqual(expectedUri, vcr.Location, $"Expected and Actual {nameof(ValueContentResult.Location)} values are not equal."); - else if (assertor.Result != null && assertor.Result is ExtendedStatusCodeResult escr) - assertor.Owner.Implementor.AssertAreEqual(expectedUri, escr.Location, $"Expected and Actual {nameof(ExtendedStatusCodeResult.Location)} values are not equal."); - else - assertor.Owner.Implementor.AssertFail($"The Result must be of Type {typeof(ValueContentResult).FullName} or {typeof(ExtendedStatusCodeResult).FullName} to use {nameof(AssertLocationHeader)}."); - - return assertor; - } - - /// - /// Asserts that the matches the function. - /// - /// The value . - /// The assertor. - /// The expected function. - /// The to support fluent-style method-chaining. - public static ActionResultAssertor AssertLocationHeader(this ActionResultAssertor assertor, Func expectedUri) - => assertor.AssertLocationHeader(expectedUri.Invoke(assertor.GetValue()!)); - - /// - /// Asserts that the contains the string. - /// - /// The assertor. - /// The expected string. - /// The to support fluent-style method-chaining. - public static ActionResultAssertor AssertLocationHeaderContains(this ActionResultAssertor assertor, string expected) - { - Uri? actual = null; - if (assertor.Result != null && assertor.Result is ValueContentResult vcr) - actual = vcr.Location; - else if (assertor.Result != null && assertor.Result is ExtendedStatusCodeResult escr) - actual = escr.Location; - else - assertor.Owner.Implementor.AssertFail($"The Result must be of Type {typeof(ValueContentResult).FullName} or {typeof(ExtendedStatusCodeResult).FullName} to use {nameof(AssertLocationHeader)}."); - - if (actual == null) - assertor.Owner.Implementor.AssertFail($"The actual {nameof(ValueContentResult.Location)} must not be null."); - - if (!actual!.ToString().Contains(expected)) - assertor.Owner.Implementor.AssertFail($"The {nameof(ValueContentResult.Location)} '{actual}' must contain {expected}."); - - return assertor; - } - - /// - /// Asserts that the contains the string function. - /// - /// The value . - /// The assertor. - /// The expected string function. - /// The to support fluent-style method-chaining. - public static ActionResultAssertor AssertLocationHeader(this ActionResultAssertor assertor, Func expected) - => assertor.AssertLocationHeaderContains(expected.Invoke(assertor.GetValue()!)); - -#endregion - - #region GenericTesterBase - - /// - /// Enables the validation with a specified validation. - /// - /// The API startup . - /// The . - /// The tester. - /// The optional for the test (updates the ). - public static GenericTesterBaseWith Validation(this GenericTesterBase tester, OperationType operationType = OperationType.Unspecified) where TEntryPoint : class, new() where TSelf : GenericTesterBase - => new(tester, operationType); - - /// - /// Enables the validation. - /// - /// The API startup . - /// The . - public class GenericTesterBaseWith where TEntryPoint : class, new() where TSelf : GenericTesterBase + tester.ThrowIfNull(); + scopedTester.ThrowIfNull(); + return tester.ScopedType(test => { - private readonly GenericTesterBase _tester; - private readonly OperationType _operationType; - - /// - /// Initializes a new instance of the . - /// - internal GenericTesterBaseWith(GenericTesterBase tester, OperationType operationType) - { - _tester = tester.ThrowIfNull(nameof(tester)); - _operationType = operationType; - } - - /// - /// Creates (instantiates) the using Dependency Injection (DI) and validates the . - /// - /// The validator . - /// The value . - /// The value to validate. - /// The with the resulting . - public ValueAssertor With(TValue value) where TValue : class where TValidator : class, IValidator - => WithAsync(value).GetAwaiter().GetResult(); - - /// - /// Validates the using the . - /// - /// The validator . - /// The value . - /// The validator. - /// The value to validate. - /// The with the resulting . - public ValueAssertor With(TValidator validator, TValue value) where TValue : class where TValidator : class, IValidator - => WithAsync(validator, value).GetAwaiter().GetResult(); - - /// - /// Executes the function. - /// - /// The validation function. - /// The with the resulting . - public ValueAssertor With(Func validation) => WithAsync(() => Task.FromResult(validation())).GetAwaiter().GetResult(); - - /// - /// Executes the function. - /// - /// The validation function. - /// The with the resulting . - public ValueAssertor With(Func> validation) => WithAsync(validation).GetAwaiter().GetResult(); - - /// - /// Creates (instantiates) the using Dependency Injection (DI) and validates the . - /// - /// The validator . - /// The value . - /// The value to validate. - /// The with the resulting . - public Task> WithAsync(TValue value) where TValue : class where TValidator : class, IValidator - => WithAsync(_tester.Services.GetService() ?? throw new InvalidOperationException($"Validator '{typeof(TValidator).FullName}' not configured using Dependency Injection (DI) and therefore unable to be instantiated for testing."), value); - - /// - /// Validates the using the . - /// - /// The validator . - /// The value . - /// The validator. - /// The value to validate. - /// The with the resulting . - public Task> WithAsync(TValidator validator, TValue value) where TValue : class where TValidator : class, IValidator - => WithAsync(async () => await validator.ThrowIfNull(nameof(validator)).ValidateAsync(value).ConfigureAwait(false)); - - /// - /// Executes the function. - /// - /// The validation function. - /// The with the resulting . - public Task> WithAsync(Func> validation) - => _tester.RunAsync(async () => - { - // Build out an execution context. - OperationType? existingOperationType = null; - if (ExecutionContext.HasCurrent) - existingOperationType = ExecutionContext.Current.OperationType; - else - { - var ec = _tester.Services.GetService(); - if (ec is null) - _ = ExecutionContext.Current; - else if (!ExecutionContext.HasCurrent) - ExecutionContext.SetCurrent(ec); - - // Update service provider where null. - ExecutionContext.Current.ServiceProvider ??= _tester.Services; - } - - // Set/override the operation type. - ExecutionContext.Current.OperationType = _operationType; - - // Perform the validation. - try - { - var vr = await validation().ConfigureAwait(false); - vr.ThrowOnError(); - return vr; - } - finally - { - // Reset where finished. - if (existingOperationType.HasValue) - ExecutionContext.Current.OperationType = existingOperationType.Value; - else - ExecutionContext.Reset(); - } - }); - } - - #endregion - - #region ApiTesterBase - - /// - /// Enables a test to be sent to the underlying with a specified agent. - /// - /// The API startup . - /// The . - /// The tester. - /// The to allow Agent type specification. - public static AgentTesterWith Agent(this ApiTesterBase tester) - where TEntryPoint : class where TSelf : ApiTesterBase - => new(tester); + configure?.Invoke(test.Service); + scopedTester(test); + }); + } - /// - /// Enables the agent specification. - /// - /// The API startup . - /// The . - public class AgentTesterWith where TEntryPoint : class where TSelf : ApiTesterBase + /// + /// Enables an instance to be tested managed within a . + /// + /// The . + /// The . + /// The testing action. + /// The optional pre-test configuration action. + /// The to support fluent-style method-chaining. + /// This is a convenience method for common testing with an instance within a scoped service lifetime. + public static TSelf Scoped(this TesterBase tester, Func, Task> scopedTester, Action? configure = null) where TSelf : TesterBase + { + tester.ThrowIfNull(); + scopedTester.ThrowIfNull(); + return tester.ScopedType(async test => { - private readonly ApiTesterBase _tester; - - /// - /// Initializes a new instance of the . - /// - internal AgentTesterWith(ApiTesterBase tester) => _tester = tester.ThrowIfNull(nameof(tester)); - - /// - /// Enables a test to be sent to the underlying leveraging the specified agent. - /// - /// The . - /// The . - public AgentTester With() where TAgent : TypedHttpClientBase => new(_tester, _tester.GetTestServer()); - - /// - /// Enables a test to be sent to the underlying leveraging the specified agent. - /// - /// The . - /// The response value . - /// The . - public AgentTester With() where TAgent : TypedHttpClientBase => new(_tester, _tester.GetTestServer()); - } - - #endregion - - #region ControllerTester - - /// - /// Runs the controller using an inferring the , operation name and request from the . - /// - /// The . - /// The result value . - /// The tester. - /// The controller operation invocation expression. - /// The optional . - /// The optional modifier. - /// A . - public static HttpResponseMessageAssertor Run(this ControllerTester tester, Expression> expression, Ceh.HttpRequestOptions? requestOptions = null, Action? requestModifier = null) - where TController : ControllerBase - => RunAsync(tester, expression, requestOptions, requestModifier).GetAwaiter().GetResult(); + configure?.Invoke(test.Service); + await scopedTester(test).ConfigureAwait(false); + }); + } - /// - /// Runs the controller using an inferring the , operation name and request from the . - /// - /// The . - /// The result value . - /// The tester. - /// The controller operation invocation expression. - /// The optional . - /// The optional modifier. - /// A . - public static Task RunAsync(this ControllerTester tester, Expression> expression, Ceh.HttpRequestOptions? requestOptions = null, Action? requestModifier = null) - where TController : ControllerBase + /// + /// Enables an instance to be tested managed within a . + /// + /// The . + /// The . + /// The tenant identifier to set on the . + /// The testing action. + /// The optional pre-test configuration action. + /// The to support fluent-style method-chaining. + /// This is a convenience method for common testing with an instance within a scoped service lifetime. + public static TSelf Scoped(this TesterBase tester, string? tenantId, Action> scopedTester, Action? configure = null) where TSelf : TesterBase + => Scoped(tester, scopedTester, ec => { - void rm(HttpRequestMessage hr) - { - requestModifier?.Invoke(hr); - hr.ApplyRequestOptions(requestOptions); - } - - return tester.RunAsync(expression, rm); - } + ec.TenantId = tenantId; + configure?.Invoke(ec); + }); - /// - /// Runs the controller using an inferring the , operation name and request from the . - /// - /// The . - /// The result value . - /// The tester. - /// The controller operation invocation expression. - /// The body content. - /// The body content type. Defaults to . - /// The optional . - /// The optional modifier. - /// A . - public static HttpResponseMessageAssertor RunContent(this ControllerTester tester, Expression> expression, string? content, string? contentType = MediaTypeNames.Text.Plain, Ceh.HttpRequestOptions? requestOptions = null, Action? requestModifier = null) - where TController : ControllerBase - => RunContentAsync(tester, expression, content, contentType, requestOptions, requestModifier).GetAwaiter().GetResult(); - - /// - /// Runs the controller using an inferring the , operation name and request from the . - /// - /// The . - /// The result value . - /// The tester. - /// The controller operation invocation expression. - /// The body content. - /// The body content type. Defaults to . - /// The optional . - /// The optional modifier. - /// A . - public static Task RunContentAsync(this ControllerTester tester, Expression> expression, string? content, string? contentType = MediaTypeNames.Text.Plain, Ceh.HttpRequestOptions? requestOptions = null, Action? requestModifier = null) - where TController : ControllerBase + /// + /// Enables an instance to be tested managed within a . + /// + /// The . + /// The . + /// The tenant identifier to set on the . + /// The testing action. + /// The optional pre-test configuration action. + /// The to support fluent-style method-chaining. + /// This is a convenience method for common testing with an instance within a scoped service lifetime. + public static TSelf Scoped(this TesterBase tester, string? tenantId, Func, Task> scopedTester, Action? configure = null) where TSelf : TesterBase + { + tester.ThrowIfNull(); + scopedTester.ThrowIfNull(); + return tester.ScopedType(async test => { - void rm(HttpRequestMessage hr) - { - requestModifier?.Invoke(hr); - hr.ApplyRequestOptions(requestOptions); - } - - return tester.RunContentAsync(expression, content, contentType, rm); - } - - #endregion + test.Service.TenantId = tenantId; + configure?.Invoke(test.Service); + await scopedTester(test).ConfigureAwait(false); + }); } } \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UnitTestExOneOffTestSetUp.cs b/src/CoreEx.UnitTesting/UnitTestExOneOffTestSetUp.cs new file mode 100644 index 00000000..1856bc19 --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExOneOffTestSetUp.cs @@ -0,0 +1,31 @@ +[assembly: UnitTestEx.Abstractions.OneOffTestSetUp(typeof(CoreEx.UnitTesting.UnitTestExOneOffTestSetUp))] + +namespace CoreEx.UnitTesting; + +/// +/// One-off test set-up for UnitTestEx to initialize CoreEx-specific capabilities. +/// +internal class UnitTestExOneOffTestSetUp : UnitTestEx.Abstractions.OneOffTestSetUpBase +{ + /// + public override void SetUp() + { + TestSetUp.Default.DefaultUserName = CoreEx.Security.AuthenticationUser.EnvironmentUser.UserName; + TestSetUp.Default.JsonSerializer = new UnitTestEx.Json.JsonSerializer(CoreEx.Json.JsonDefaults.SerializerOptions); + + // Extend the AssertErrors functionality to support the ValidationException. + AssertorBase.AddAssertErrorsExtension((assertor, errors) => + { + if (assertor.Exception is ValidationException vex) + { + var actual = vex.Messages?.Where(x => x.Type == Entities.MessageType.Error).Select(x => new ApiError(x.Property, x.Text.ToString() ?? string.Empty)).ToArray() ?? []; + if (!Assertor.TryAreErrorsMatched(errors, actual, out var errorMessage)) + assertor.Owner.Implementor.AssertFail(errorMessage); + + return true; + } + + return false; + }); + } +} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/UsingApiTester.cs b/src/CoreEx.UnitTesting/UsingApiTester.cs deleted file mode 100644 index 24191932..00000000 --- a/src/CoreEx.UnitTesting/UsingApiTester.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Http; -using Microsoft.AspNetCore.TestHost; -using System; -using System.Net.Http; -using UnitTestEx.AspNetCore; - -namespace UnitTestEx -{ - /// - /// Provides a shared class to enable usage of the same underlying instance across multiple tests. - /// - /// The API startup . - /// Implements so should be automatically disposed off by the test framework host. - public abstract class UsingApiTester : ApiTester where TEntryPoint : class - { - /// - /// Gets the ; i.e. itself. - /// - /// This is provided for backwards compatibility. - public UsingApiTester ApiTester => this; - - /// - /// Enables a test to be sent to the underlying with a specified agent. - /// - /// The . - /// The . - public AgentTester Agent() where TAgent : CoreEx.Http.TypedHttpClientBase => new(this, GetTestServer()); - - /// - /// Enables a test to be sent to the underlying with a specified agent. - /// - /// The . - /// The response value . - /// The . - public AgentTester Agent() where TAgent : CoreEx.Http.TypedHttpClientBase => new(this, GetTestServer()); - } -} \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/strong-name-key.snk b/src/CoreEx.UnitTesting/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx.Validation/AbstractValidator.cs b/src/CoreEx.Validation/AbstractValidator.cs index 86cc0b94..eddb0f06 100644 --- a/src/CoreEx.Validation/AbstractValidator.cs +++ b/src/CoreEx.Validation/AbstractValidator.cs @@ -1,13 +1,8 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using System; - -namespace CoreEx.Validation -{ - /// - /// Represents the base entity validator using FluentValidation syntax. - /// - /// The entity . - /// This is a synonym for the . - public abstract class AbstractValidator : Validator where TEntity : class { } -} \ No newline at end of file +/// +/// Provides entity validation. +/// +/// The entity . +/// This is a synonym for the (and inherits from) to enable FluentValidation-like syntax. +public abstract class AbstractValidator : Validator where TEntity : class { } \ No newline at end of file diff --git a/src/CoreEx.Validation/AbstractValidatorT2.cs b/src/CoreEx.Validation/AbstractValidatorT2.cs new file mode 100644 index 00000000..f339ba38 --- /dev/null +++ b/src/CoreEx.Validation/AbstractValidatorT2.cs @@ -0,0 +1,9 @@ +namespace CoreEx.Validation; + +/// +/// Provides entity validation. +/// +/// The entity . +/// The self . +/// This is a synonym for the (and inherits from) to enable FluentValidation-like syntax. +public abstract class AbstractValidator : Validator where TEntity : class where TSelf : AbstractValidator, new() { } \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/IPropertyContext.cs b/src/CoreEx.Validation/Abstractions/IPropertyContext.cs new file mode 100644 index 00000000..cc7821fe --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/IPropertyContext.cs @@ -0,0 +1,94 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Enables a validation context for a property. +/// +public interface IPropertyContext +{ + /// + /// Gets the owning entity . + /// + IValidationContext Owner { get; } + + /// + /// Gets the property . + /// + IPropertyRuntimeMetadata Metadata { get; } + + /// + /// Gets the additional parameters (see ). + /// + IDictionary Parameters { get; } + + /// + /// Gets the property name. + /// + string Name { get; } + + /// + /// Gets the JSON property name. + /// + string JsonName { get; } + + /// + /// Gets the fully qualified property name. + /// + string FullyQualifiedPropertyName { get; } + + /// + /// Gets the fully qualified JSON property name. + /// + string FullyQualifiedJsonPropertyName { get; } + + /// + /// Gets the friendly text. + /// + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + LText Text { get; } + + /// + /// Gets the property value. + /// + object? Value { get; } + + /// + /// Indicates whether the originating property was . + /// + bool IsValueNull { get; } + + /// + /// Indicates whether the originating property type is . + /// + bool IsValueNullable { get; } + + /// + /// Indicates whether the property is in error as a result of a previous validation. + /// + bool IsInError { get; } + + /// + /// Gets the to use when localizing the property value within an error message. + /// + ValueFormatter ValueFormatter { get; } + + /// + /// Creates a new with the specified format and additional values to be included in the text and adds to the underlying . + /// + /// The composite format string. + /// The values that form part of the message text ( and are automatically passed as the first two arguments to the string formatter). + /// A . + /// The friendly and are automatically passed as the first two arguments to the string formatter. + MessageItem AddError(LText format, params object?[] values); + + /// + /// Creates a fully qualified property name appending the . + /// + /// The property name. + string CreateFullyQualifiedPropertyName(string name); + + /// + /// Creates a fully qualified JSON property name appending the . + /// + /// The JSON property name. + string CreateFullyQualifiedJsonPropertyName(string jsonName); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/IPropertyContextT.cs b/src/CoreEx.Validation/Abstractions/IPropertyContextT.cs new file mode 100644 index 00000000..02637ade --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/IPropertyContextT.cs @@ -0,0 +1,26 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Enables a validation context for a property. +/// +/// The entity . +public interface IPropertyContext : IPropertyContext where TEntity : class +{ + /// + /// Gets the . + /// + IRootPropertyRule RootPropertyRule { get; } + + /// + IValidationContext IPropertyContext.Owner => Owner; + + /// + /// Gets the owning entity . + /// + new IValidationContext Owner { get; } + + /// + /// Gets the owning entity value. + /// + TEntity? Entity { get; } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/IPropertyContextT2.cs b/src/CoreEx.Validation/Abstractions/IPropertyContextT2.cs new file mode 100644 index 00000000..395715d6 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/IPropertyContextT2.cs @@ -0,0 +1,24 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Enables a validation context for a property. +/// +/// The entity . +/// The property . +public interface IPropertyContext : IPropertyContext where TEntity : class +{ + /// + object? IPropertyContext.Value => Value; + + /// + /// Gets the property value. + /// + /// The property value. + new TProperty Value { get; } + + /// + /// Overrides (sets) the property value (where not read-only). + /// + /// The override value. + void Override(TProperty value); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/ISelfRuntimeMetadata.cs b/src/CoreEx.Validation/Abstractions/ISelfRuntimeMetadata.cs new file mode 100644 index 00000000..ee4539e9 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/ISelfRuntimeMetadata.cs @@ -0,0 +1,6 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Enables the runtime metadata definition for an entity where the property is acting as itself. +/// +internal interface ISelfRuntimeMetadata { } \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/IValidationContext.cs b/src/CoreEx.Validation/Abstractions/IValidationContext.cs new file mode 100644 index 00000000..2b317380 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/IValidationContext.cs @@ -0,0 +1,51 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Enables validation context for an entity. +/// +public interface IValidationContext : IValidationResult +{ + /// + /// Gets the entity . + /// + Type EntityType { get; } + + /// + /// Gets the entity prefix used for fully qualified entity.property naming ( represents the root). + /// + string? FullyQualifiedEntityName { get; } + + /// + /// Gets the entity prefix used for fully qualified JSON entity.property naming ( represents the root). + /// + string? FullyQualifiedJsonEntityName { get; } + + /// + /// Indicates whether JSON names were used for the . + /// + /// See and . + bool UseJsonNames { get; } + + /// + /// Gets the used for JSON property naming. + /// + JsonSerializerOptions? JsonSerializerOptions { get; } + + /// + /// Gets the to use when resolving services. + /// + /// The will be used as the default where not specified. + IServiceProvider? ServiceProvider { get; } + + /// + /// Gets the additional parameters (see ). + /// + IDictionary Parameters { get; } + + /// + /// Determines whether the specified fully qualified property has an error. + /// + /// The fully qualified property name. + /// where an error exists for the specified property; otherwise, . + bool HasError(string fullyQualifiedPropertyName); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/IValidationContextT.cs b/src/CoreEx.Validation/Abstractions/IValidationContextT.cs new file mode 100644 index 00000000..adbfb603 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/IValidationContextT.cs @@ -0,0 +1,7 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Enables validation context. +/// +/// The . +public interface IValidationContext : IValidationContext, IValidationResult { } \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/IValidatorEx.cs b/src/CoreEx.Validation/Abstractions/IValidatorEx.cs new file mode 100644 index 00000000..9e8f2785 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/IValidatorEx.cs @@ -0,0 +1,17 @@ + +namespace CoreEx.Validation.Abstractions; + +/// +/// Extends the . +/// +public interface IValidatorEx : IValidator +{ + /// + /// Validate the with optional . + /// + /// The value to validate. + /// The optional . + /// The . + /// The . + Task ValidateAsync(object? value, ValidationArgs? args, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/IValidatorExT.cs b/src/CoreEx.Validation/Abstractions/IValidatorExT.cs new file mode 100644 index 00000000..f4639d5a --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/IValidatorExT.cs @@ -0,0 +1,38 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Extends the +/// +/// The value . +public interface IValidatorEx : IValidatorEx, IValidator +{ + /// + async Task IValidatorEx.ValidateAsync(object? value, ValidationArgs? args, CancellationToken cancellationToken) + => await ValidateAsync((T)value!, args, cancellationToken).ConfigureAwait(false); + + /// + /// Validate the value with optional . + /// + /// The value. + /// An optional . + /// The . + /// The . + Task> ValidateAsync(T value, ValidationArgs? args, CancellationToken cancellationToken); + + /// + /// Validate the value with optional and automatically throw a where . + /// + /// The value. + /// An optional . + /// The . + Task ValidateAndThrowAsync(T value, ValidationArgs? args, CancellationToken cancellationToken); + + /// + /// Validate using the . + /// + /// The . + /// The . + /// The . + /// This is generally intended for internal use only. + Task ValidateAsync(IValidationContext context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/IValueValidator.cs b/src/CoreEx.Validation/Abstractions/IValueValidator.cs new file mode 100644 index 00000000..3db82308 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/IValueValidator.cs @@ -0,0 +1,23 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Enables the validation. +/// +/// The value . +public interface IValueValidator +{ + /// + /// Validate the value. + /// + /// The . + /// The . + Task>> ValidateAsync(CancellationToken cancellationToken = default); + + /// + /// Validate the value with optional . + /// + /// An optional . + /// The . + /// The . + Task>> ValidateAsync(ValidationArgs? args, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/InlineValidator.cs b/src/CoreEx.Validation/Abstractions/InlineValidator.cs new file mode 100644 index 00000000..5d0d17b8 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/InlineValidator.cs @@ -0,0 +1,82 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Provides the base inline validator functionality. +/// +/// The value . +/// See also . +public abstract class InlineValidator +{ + private readonly Validator _validator; + + /// + /// Initializes a new instance of the class. + /// + /// The action to configure the . + internal InlineValidator(Action? configure) + { + _validator = new(); + configure?.Invoke(_validator); + } + + /// + /// Gets or sets the property and JSON name override (where not ). + /// + /// This will apply to all instances in which the is used; therefore, caution is required when using. This is intended for advanced usage only. + protected string? OverrideName { get; set; } + + /// + /// Gets or sets the property text override (where not ). + /// + /// This will apply to all instances in which the is used; therefore, caution is required when using. This is intended for advanced usage only. + protected LText? OverrideText { get; set; } + + /// + /// Validates the value. + /// + /// The related entity . + /// The related . + /// The . + internal async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken) where TEntity : class + { + var root = new RootPropertyRule, TValue>( + new PropertyRuntimeMetadata, TValue>(OverrideName ?? context.Name, _ => context.Metadata.GetValue(context.Entity), text: () => OverrideText ?? context.Metadata.Text, jsonName: OverrideName ?? context.Metadata.JsonName), + context.IsValueNullable ? _ => context.GetNullableValueOrDefault() : null, context.IsValueNullable ? _ => context.IsNullableValueDefault() : null); + + ValidationExtensions.Chain(root, _validator); + + var vv = new ValidationValue(context.Value); + var vc = new ValidationContext>(vv, context.CreateValidationArgs(true)); + var pc = new PropertyContext, TValue>(root, vc); + + // Execute the primary validation. + await root.ValidateAsync(pc, cancellationToken).ConfigureAwait(false); + + // Execute the secondary validation. + await OnValidateAsync(pc, cancellationToken).ConfigureAwait(false); + + // Merge results. + context.MergeResult(vc); + } + + /// + /// Validate the common property value. + /// + /// The . + /// The . + protected virtual Task OnValidateAsync(PropertyContext, TValue> context, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Provides the underlying enabling standardized configuration and validation behavior to be added/chained. + /// + public sealed class Validator : PropertyRuleBase, TValue> + { + /// + /// Initializes a new instance of the class. + /// + internal Validator() { } + + /// + protected override Task OnValidateAsync(PropertyContext, TValue> context, CancellationToken cancellationToken) => Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/SelfRuntimeMetadata.cs b/src/CoreEx.Validation/Abstractions/SelfRuntimeMetadata.cs new file mode 100644 index 00000000..a0f6a530 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/SelfRuntimeMetadata.cs @@ -0,0 +1,59 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Provides the runtime metadata definition for an entity where the property is acting as itself. +/// +internal readonly struct SelfRuntimeMetadata() : IPropertyRuntimeMetadata, ISelfRuntimeMetadata +{ + private const string _name = "$self"; + private readonly Lazy _text = new(() => new LText($"{Internal.GetNamespaceFormattedName(typeof(TSelf))}:{_name}", Validation.ValueName.ToSentenceCase())); + + /// + public Type Owner => typeof(TSelf); + + /// + public Type Type => typeof(TSelf); + + /// + public string Name => _name; + + /// + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + public LText Text => _text.Value; + + /// + public string? JsonName => null; + + /// + public object? DefaultValue => null; + + /// + public CleanOption CleanOption => CleanOption.None; + + /// + public bool IsReadOnly => true; + + /// + public string? Format => null; + + /// + public void Clean(object entity) { } + + /// + public string GetJsonName(JsonSerializerOptions? options = null) => string.Empty; + + /// + public object? GetValue(object entity) => entity; + + /// + public T GetValue(object entity) => (T)entity; + + /// + public bool IsDefault(object entity) => throw new NotSupportedException(); + + /// + public void SetValue(object entity, object? value) => throw new NotSupportedException(); + + /// + public void SetValue(object entity, T value) => throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/ValidationMessageItem.cs b/src/CoreEx.Validation/Abstractions/ValidationMessageItem.cs new file mode 100644 index 00000000..026c4907 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/ValidationMessageItem.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Represents a validation-extended . +/// +public record class ValidationMessageItem : MessageItem +{ + /// + /// Gets the fully qualified property name that the message relates to. + /// + /// Required for internal validation purposes; specifically for tracking which property a validation message is associated with as the may contain the + /// fully qualified JSON name. + [JsonIgnore] + internal string? FullyQualifiedPropertyName { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/ValidationValue.cs b/src/CoreEx.Validation/Abstractions/ValidationValue.cs new file mode 100644 index 00000000..d91fcb13 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/ValidationValue.cs @@ -0,0 +1,19 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Provides a validation value entity wrapper. +/// +/// The value . +public sealed class ValidationValue +{ + /// + /// Initializes a new instance of the class. + /// + /// The value. + internal ValidationValue(T value) => Value = value; + + /// + /// Gets the value. + /// + public T? Value { get; } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/ValidatorBase.Fluent.cs b/src/CoreEx.Validation/Abstractions/ValidatorBase.Fluent.cs new file mode 100644 index 00000000..84c0f2dc --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/ValidatorBase.Fluent.cs @@ -0,0 +1,30 @@ +namespace CoreEx.Validation.Abstractions; + +public abstract partial class ValidatorBase +{ + /// + /// Adds a to the validator for the specified . + /// + /// The property . + /// The property expression. + /// The . + /// This is a synonym for the to enable FluentValidation-like syntax. + protected IRootPropertyRule RuleFor(Expression> propertyExpression) + => PropertyInternal(RuntimeMetadata.GetForExpression(propertyExpression.ThrowIfNull()), null, null); + + /// + /// Adds a to the validator for the specified . + /// + /// The property . + /// This is a synonym for the to enable FluentValidation-like syntax. + protected IRootPropertyRule RuleFor(Expression> propertyExpression) where TProperty : struct + { + var metadata = RuntimeMetadata.GetForExpression(propertyExpression.ThrowIfNull()); + var rule = new RootPropertyRule(metadata, + e => metadata.GetValue(e).GetValueOrDefault(), + e => Comparer.Default.Compare(metadata.GetValue(e).GetValueOrDefault(), default) == 0); + + Rules.Add(rule); + return rule; + } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/ValidatorBase.cs b/src/CoreEx.Validation/Abstractions/ValidatorBase.cs new file mode 100644 index 00000000..32141056 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/ValidatorBase.cs @@ -0,0 +1,177 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Represents the base entity validator. +/// +/// The entity . +/// The . +/// This is intended for advanced scenarios where must be explicitly overridden to implement the desired behavior; otherwise, it throws a . +/// Generally, use (inherit from) or (in-line) to leverage. +public abstract partial class ValidatorBase : IValidatorEx where TEntity : class where TSelf : ValidatorBase +{ + /// + /// Gets the underlying collection. + /// + protected List> Rules { get; } = []; + + /// + /// Adds a to the validator for the specified . + /// + /// The property . + /// The property expression. + /// The . + protected IRootPropertyRule Property(Expression> propertyExpression) + => PropertyInternal(RuntimeMetadata.GetForExpression(propertyExpression.ThrowIfNull()), null, null); + + /// + /// Adds a to the validator for the specified . + /// + /// The property . + protected IRootPropertyRule Property(Expression> propertyExpression) where TProperty : struct + { + var metadata = RuntimeMetadata.GetForExpression(propertyExpression.ThrowIfNull()); + var rule = new RootPropertyRule(metadata, + e => metadata.GetValue(e).GetValueOrDefault(), + e => Comparer.Default.Compare(metadata.GetValue(e).GetValueOrDefault(), default) == 0); + + Rules.Add(rule); + return rule; + } + + /// + /// Adds a to the validator for the specified . + /// + private RootPropertyRule PropertyInternal(IPropertyRuntimeMetadata propertyMetadata, Func? getNullableValue, Func? isNullableValueDefault) + { + var rule = new RootPropertyRule(propertyMetadata.ThrowIfNull(), getNullableValue, isNullableValueDefault); + Rules.Add(rule); + return rule; + } + + /// + /// Adds a to the validator for the specified enabling an inline opportunity. + /// + /// The property . + /// The property expression. + /// The action to configure the resulting . + /// The to support fluent-style method-chaining. + public TSelf HasProperty(Expression> propertyExpression, Action>? configure = null) + => HasPropertyInternal(RuntimeMetadata.GetForExpression(propertyExpression.ThrowIfNull()), configure, null, null); + + /// + /// Adds a to the validator for the specified enabling an inline opportunity. + /// + /// The property . + /// The property expression. + /// The action to configure the resulting . + /// The to support fluent-style method-chaining. + public TSelf HasProperty(Expression> propertyExpression, Action>? configure = null) where TProperty : struct + { + var metadata = RuntimeMetadata.GetForExpression(propertyExpression.ThrowIfNull()); + return HasPropertyInternal(metadata, configure, + e => metadata.GetValue(e).GetValueOrDefault(), + e => Comparer.Default.Compare(metadata.GetValue(e).GetValueOrDefault(), default) == 0); + } + + /// + /// Adds a to the validator for the specified enabling an inline opportunity. + /// + /// The property . + /// The . + /// The action to configure the resulting . + /// A function to get the underlying nullable value. + /// A function to determine whether the underlying nullable value is its default. + /// The to support fluent-style method-chaining. + internal TSelf HasPropertyInternal(IPropertyRuntimeMetadata propertyMetadata, Action>? configure, Func? getNullableValue, Func? isNullableValueDefault) + { + var rule = PropertyInternal(propertyMetadata, getNullableValue, isNullableValueDefault); + configure?.Invoke(rule); + return (TSelf)this; + } + + /// + /// Adds a self-validation for the to the validator . + /// + /// The action to configure the resulting . + /// The to support fluent-style method-chaining. + /// This may only be invoked once; otherwise, an will be thrown. + public TSelf Self(Action>? configure = null) + { + if (Rules.Any(r => r is RootPropertyRule rr && rr.Metadata is ISelfRuntimeMetadata)) + throw new InvalidOperationException("The 'Self' rule has already been defined for this validator; it may only be configured once."); + + // Uses a special SelfRuntimeMetadata/ISelfRuntimeMetadata to enable functionality internally. + var srm = new SelfRuntimeMetadata(); + var rule = new RootPropertyRule(srm, null, null); + Rules.Add(rule); + configure?.Invoke(rule); + return (TSelf)this; + } + + /// + /// Adds a to the validator for the specified . + /// + /// The base . + /// The to support fluent-style method-chaining. + /// must be the same or a base type of ; otherwise, an will be thrown. + /// Note: the is added internally as an rule; therefore, it will be executed in the order added in relation to other property-base rules. + protected TSelf Include(IValidatorEx baseValidator) where TInclude : class + { + baseValidator.ThrowIfNull(); + if (typeof(TInclude) == typeof(TEntity) || typeof(TInclude).IsAssignableFrom(typeof(TEntity))) + return IncludeBase(baseValidator); + + throw new ArgumentException($"The specified base validator type '{typeof(TInclude).FullName}' must be the same or a base type of the entity type '{typeof(TEntity).FullName}'.", nameof(baseValidator)); + } + + /// + /// Adds a to the validator for the specified . + /// + /// The base . + /// The to support fluent-style method-chaining. + internal TSelf IncludeBase(IValidatorEx baseValidator) where TInclude : class + { + Rules.Add(new IncludeBaseRule(baseValidator)); + return (TSelf)this; + } + + /// + /// Adds a to the validator enabling a conditional () set of rules to be configured. + /// + /// The predicate to determine whether the is to be validated. + /// The action to configure the underlying set of rules. + /// The to support fluent-style method-chaining. + public TSelf HasRuleSet(Predicate> predicate, Action> configure) + { + var ruleSet = new RuleSet(predicate); + configure?.Invoke(ruleSet); + + if (ruleSet.Rules.Count > 0) + Rules.Add(ruleSet); + + return (TSelf)this; + } + + /// + async Task> IValidator.ValidateAsync(TEntity value, CancellationToken cancellationToken) => await ValidateAsync(value, null, cancellationToken).ConfigureAwait(false); + + /// + public virtual Task> ValidateAsync(TEntity value, ValidationArgs? args, CancellationToken cancellationToken) + => throw new NotSupportedException($"{nameof(ValidateAsync)} is not supported by the {nameof(ValidatorBase<,>)} class."); + + /// + public virtual Task ValidateAndThrowAsync(TEntity value, ValidationArgs? args, CancellationToken cancellationToken) + => throw new NotSupportedException($"{nameof(ValidateAndThrowAsync)} is not supported by the {nameof(ValidatorBase<,>)} class."); + + /// + Task IValidatorEx.ValidateAsync(IValidationContext context, CancellationToken cancellationToken) + => ValidateAsync(context, cancellationToken); + + /// + /// Validate using the . + /// + /// The . + /// The . + /// The . + internal abstract Task ValidateAsync(IValidationContext context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/ValueFormatter.cs b/src/CoreEx.Validation/Abstractions/ValueFormatter.cs new file mode 100644 index 00000000..350602d4 --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/ValueFormatter.cs @@ -0,0 +1,62 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Provides value formatter configuration and corresponding . +/// +/// The format to use when localizing the property value within an error message. +/// The optional to use when localizing the property value within an error message. +/// The optional quoting character so it appears as a literal string. +public readonly struct ValueFormatter(string? format, IFormatProvider? formatProvider = null, char? quotingCharacter = '\'') +{ + private readonly bool _useStringFormat = !string.IsNullOrEmpty(format) && format.Contains("{0"); + + /// + /// Gets the default . + /// + public static ValueFormatter Default { get; } = new ValueFormatter(null, null, '\''); + + /// + /// Gets the format to use when localizing the property value within an error message. + /// + /// Also supports composite formatting. + public string? Format { get; } = format; + + /// + /// Gets the optional to use when localizing the property value within an error message. + /// + public IFormatProvider? FormatProvider { get; } = formatProvider; + + /// + /// Gets the optional quoting character so it appears as a literal string. + /// + public char? QuotingCharacter { get; } = quotingCharacter; + + /// + /// Formats the specified as a . + /// + /// The . + /// The value. + /// The formatted . + public LText ToLText(T? value) + { + var text = _useStringFormat + ? string.Format(Format!, value) + : value is IFormattable f + ? f.ToString(Format, FormatProvider) + : value?.ToString(); + + if (text is null) + return ValidatorStrings.NullText; + + if (QuotingCharacter is null) + return new LText(text); + + return new LText(string.Create(text.Length + 2, (text, QuotingCharacter.Value), + (span, state) => + { + span[0] = state.Value; + state.text.AsSpan().CopyTo(span[1..^1]); + span[^1] = state.Value; + })); + } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Abstractions/ValueValidator.cs b/src/CoreEx.Validation/Abstractions/ValueValidator.cs new file mode 100644 index 00000000..384d4b4d --- /dev/null +++ b/src/CoreEx.Validation/Abstractions/ValueValidator.cs @@ -0,0 +1,35 @@ +namespace CoreEx.Validation.Abstractions; + +/// +/// Enables validation for a value. +/// +/// The value . +public sealed class ValueValidator : Validator>, IValueValidator +{ + private readonly ValidationValue _validationValue; + private readonly IPropertyRuntimeMetadata _metadata; + + /// + /// Initializes a new instance of the class. + /// + /// The value. + /// The value name. + /// The value JSON name. + /// The friendly text name. + /// The action to configure the resulting . + /// A function to get the underlying nullable value. + /// A function to determine whether the underlying nullable value is its default. + internal ValueValidator(T value, string name, string? jsonName, LText? text, Action, T>>? configure, Func, T>? getNullableValue, Func, bool>? isNullableValueDefault) + { + _validationValue = new(value); + _metadata = new PropertyRuntimeMetadata, T?>(name, static e => e.Value, text: text is null ? null : () => text.Value, jsonName: jsonName); + HasPropertyInternal(_metadata, configure, getNullableValue, isNullableValueDefault); + } + + /// + public Task>> ValidateAsync(CancellationToken cancellationToken = default) => ValidateAsync(null, cancellationToken); + + /// + public async Task>> ValidateAsync(ValidationArgs? args, CancellationToken cancellationToken = default) + => await ValidateAsync(_validationValue, args ?? new ValidationArgs { FullyQualifiedEntityName = _metadata.Name, FullyQualifiedJsonEntityName = _metadata.JsonName }, cancellationToken).ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Clauses/DependsOnClause.cs b/src/CoreEx.Validation/Clauses/DependsOnClause.cs index e40a8c1f..4681907b 100644 --- a/src/CoreEx.Validation/Clauses/DependsOnClause.cs +++ b/src/CoreEx.Validation/Clauses/DependsOnClause.cs @@ -1,35 +1,28 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Clauses; -using CoreEx.Abstractions.Reflection; -using System; -using System.Linq.Expressions; - -namespace CoreEx.Validation.Clauses +/// +/// Represents a depends on clause; in that specified property () of the entity must have a non-default value, and not have a validation error, to continue. +/// +/// The entity . +/// The property . +/// The depends on property . +/// The to reference the depends on entity property. +public sealed class DependsOnClause(Expression> dependsOnExpression) : IPropertyClause where TEntity : class { - /// - /// Represents a depends on test clause; in that another specified property of the entity must have a non-default value (and not have a validation error) to continue. - /// - /// The entity . - /// The property . - /// The to reference the depends on entity property. - public class DependsOnClause(Expression> dependsOnExpression) : IPropertyRuleClause where TEntity : class + private readonly IPropertyRuntimeMetadata _dependsOn = RuntimeMetadata.GetForExpression(dependsOnExpression.ThrowIfNull()); + + /// + public Task CheckAsync(PropertyContext context, CancellationToken cancellationToken) { - private readonly PropertyExpression _dependsOn = PropertyExpression.Create(dependsOnExpression.ThrowIfNull(nameof(dependsOnExpression))); + // Make sure not the same property. + if (_dependsOn.Name == context.Name) + throw new InvalidOperationException($"The depends on property '{_dependsOn.Name}' cannot be the same as the property being validated."); - /// - /// Checks the clause. - /// - /// The . - /// true where validation is to continue; otherwise, false to stop. - public bool Check(IPropertyContext context) - { - // Do not continue where the depends on property is in error. - if (context.ThrowIfNull(nameof(context)).Parent.HasError(context.CreateFullyQualifiedPropertyName(_dependsOn.Name))) - return false; + // Do not continue where the depends on property is in error. + if (context.HasError(context.CreateFullyQualifiedPropertyName(_dependsOn.Name))) + return Task.FromResult(false); - // Check depends on value to continue. - object? value = _dependsOn.GetValue((TEntity)context.Parent.Value!); - return !(System.Collections.Comparer.Default.Compare(value, default(TProperty)!) == 0); - } + // Check depends on value to continue. + return Task.FromResult(!_dependsOn.IsDefault(context.Entity)); } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Clauses/IPropertyClauseT.cs b/src/CoreEx.Validation/Clauses/IPropertyClauseT.cs new file mode 100644 index 00000000..2c84bfe2 --- /dev/null +++ b/src/CoreEx.Validation/Clauses/IPropertyClauseT.cs @@ -0,0 +1,16 @@ +namespace CoreEx.Validation.Clauses; + +/// +/// Enables a property clause for an entity. +/// +/// The entity . +public interface IPropertyClause where TEntity : class +{ + /// + /// Checks the clause. + /// + /// The . + /// The . + /// where validation is to continue; otherwise, to stop. + Task CheckAsync(IPropertyContext context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Clauses/IPropertyClauseT2.cs b/src/CoreEx.Validation/Clauses/IPropertyClauseT2.cs new file mode 100644 index 00000000..31295355 --- /dev/null +++ b/src/CoreEx.Validation/Clauses/IPropertyClauseT2.cs @@ -0,0 +1,20 @@ +namespace CoreEx.Validation.Clauses; + +/// +/// Enables a typed property clause for an entity. +/// +/// The entity . +/// The property . +public interface IPropertyClause : IPropertyClause where TEntity : class +{ + Task IPropertyClause.CheckAsync(IPropertyContext context, CancellationToken cancellationToken) + => CheckAsync((PropertyContext)context, cancellationToken); + + /// + /// Checks the clause. + /// + /// The . + /// The . + /// where validation is to continue; otherwise, to stop. + Task CheckAsync(PropertyContext context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Clauses/IPropertyRuleClause.cs b/src/CoreEx.Validation/Clauses/IPropertyRuleClause.cs deleted file mode 100644 index 0c7603f1..00000000 --- a/src/CoreEx.Validation/Clauses/IPropertyRuleClause.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Validation.Clauses -{ - /// - /// Enables a clause. - /// - /// The entity . - public interface IPropertyRuleClause where TEntity : class - { - /// - /// Checks the clause. - /// - /// The . - /// true where validation is to continue; otherwise, false to stop. - bool Check(IPropertyContext context); - } - - /// - /// Enables a typed clause. - /// - /// The entity . - /// The property . - public interface IPropertyRuleClause : IPropertyRuleClause where TEntity : class { } -} diff --git a/src/CoreEx.Validation/Clauses/WhenClause.cs b/src/CoreEx.Validation/Clauses/WhenClause.cs index 5f8ebc03..d33d09f4 100644 --- a/src/CoreEx.Validation/Clauses/WhenClause.cs +++ b/src/CoreEx.Validation/Clauses/WhenClause.cs @@ -1,50 +1,15 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Validation.Clauses +namespace CoreEx.Validation.Clauses; + +/// +/// Represents a when test clause; in that the condition must be to continue. +/// +/// The entity . +/// The property . +/// The when-based . +public sealed class WhenClause(PredicateAsync whenAsync) : IPropertyClause where TEntity : class { - /// - /// Represents a when test clause; in that the condition must be true to continue. - /// - /// The entity . - /// The property . - public class WhenClause : IPropertyRuleClause where TEntity : class - { - private readonly Predicate? _entityPredicate; - private readonly Predicate? _propertyPredicate; - private readonly Func? _when; - - /// - /// Initializes a new instance of the class with a being passed the . - /// - /// The when predicate. - public WhenClause(Predicate predicate) => _entityPredicate = predicate.ThrowIfNull(nameof(predicate)); - - /// - /// Initializes a new instance of the class with a function. - /// - /// The when function. - public WhenClause(Func when) => _when = when.ThrowIfNull(nameof(when)); - - /// - /// Initializes a new instance of the class with a being passed the . - /// - /// The when predicate. - public WhenClause(Predicate predicate) => _propertyPredicate = predicate.ThrowIfNull(nameof(predicate)); - - /// - /// Checks the clause. - /// - /// The . - /// true where validation is to continue; otherwise, false to stop. - public bool Check(IPropertyContext context) - { - context.ThrowIfNull(nameof(context)); + private readonly PredicateAsync _whenAsync = whenAsync.ThrowIfNull(); - return _when != null ? _when.Invoke() - : _entityPredicate != null ? _entityPredicate.Invoke((TEntity)context.Parent.Value!) - : _propertyPredicate!.Invoke((TProperty)context.Value!); - } - } + /// + public Task CheckAsync(PropertyContext context, CancellationToken cancellationToken) => _whenAsync(context, cancellationToken); } \ No newline at end of file diff --git a/src/CoreEx.Validation/CommonValidator.cs b/src/CoreEx.Validation/CommonValidator.cs deleted file mode 100644 index 8e35c30e..00000000 --- a/src/CoreEx.Validation/CommonValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Validation -{ - /// - /// Provides access to the common value validator capabilities. - /// - public static class CommonValidator - { - /// - /// Create a new instance of the . - /// - /// An action with the to enable further configuration. - /// The . - /// This is a synonym for the . - public static CommonValidator Create(Action>? configure = null) => new CommonValidator().Configure(configure); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/CommonValidatorT.cs b/src/CoreEx.Validation/CommonValidatorT.cs index 53ded451..5cb65cd9 100644 --- a/src/CoreEx.Validation/CommonValidatorT.cs +++ b/src/CoreEx.Validation/CommonValidatorT.cs @@ -1,145 +1,39 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using CoreEx.Results; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation +namespace CoreEx.Validation; + +/// +/// Provides a common validator enabling standardized configuration and validation behavior to be shared/reused. +/// +/// The value . +/// General guidance is to not use (struct) or nullable class (i.e. string?) as this will limit reusability with no functional benefits. +/// The action to configure the . +public class CommonValidator(Action.Validator>? configure) : InlineValidator(configure) { + private List, TValue>, CancellationToken, Task>>? _additionalAsync; + /// - /// Provides a common value rule that can be used by other validators that share the same of . + /// Validate the common value (post all configured chained rules) enabling multiple additional validation functions to be added. /// - /// The value . - /// Note: the , and initially default to . - public class CommonValidator : PropertyRuleBase, T>, IValidatorEx + /// The additional validation function. + /// The . + public CommonValidator AdditionalAsync(Func, TValue>, CancellationToken, Task> additionalAsync) { - private Func, T>, CancellationToken, Task>? _additionalAsync; - private bool _textOverridden; - - /// - /// Initializes a new instance of the . - /// - public CommonValidator() : base(Validation.ValueNameDefault) => _textOverridden = false; - - /// - public override LText Text - { - get => base.Text; - set - { - base.Text = value; - _textOverridden = true; - } - } - - /// - /// Enables the validator to be further configured. - /// - /// An action with the to enable further configuration. - /// The validator to support fluent-style method-chaining. - public CommonValidator Configure(Action>? validator) - { - validator?.Invoke(this); - return this; - } - - /// - /// Validates the value. - /// - /// The related entity . - /// The related . - /// The . - internal async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken) where TEntity : class - { - var vv = new ValidationValue(context.ThrowIfNull(nameof(context)).Parent.Value, context.Value); - var vc = new ValidationContext>(vv, new ValidationArgs - { - Config = context.Parent.Config, - SelectedPropertyName = context.Parent.SelectedPropertyName, - FullyQualifiedEntityName = context.Parent.FullyQualifiedEntityName, - FullyQualifiedJsonEntityName = context.Parent.FullyQualifiedJsonEntityName, - UseJsonNames = context.UseJsonName - }); - - var ctx = new PropertyContext, T>(vc, context.Value, context.Name, context.JsonName, _textOverridden ? Text : context.Text); - await InvokeAsync(ctx, cancellationToken).ConfigureAwait(false); - - await (ctx.Parent.FailureResult ?? Result.Success) - .ThenAsync(() => OnValidateAsync(ctx, cancellationToken)) - .WhenAsync(() => _additionalAsync != null, () => _additionalAsync!(ctx, cancellationToken)) - .Match(ok: () => - { - context.HasError = ctx.HasError; - context.Parent.MergeResult(ctx.Parent); - }, fail: ex => context.Parent.SetFailureResult(Result.Fail(ex))) - .ConfigureAwait(false); - } - - /// - /// Validate the entity value (post all configured property rules) enabling additional validation logic to be added by the inheriting classes. - /// - /// The . - /// The . - /// The corresponding . - protected virtual Task OnValidateAsync(PropertyContext, T> context, CancellationToken cancellationToken) => Task.FromResult(Result.Success); - - /// - /// Validate the entity value (post all configured property rules) enabling additional validation logic to be added. - /// - /// The asynchronous function to invoke. - /// The . - public CommonValidator AdditionalAsync(Func, T>, CancellationToken, Task> additionalAsync) - { - if (_additionalAsync != null) - throw new InvalidOperationException("Additional can only be defined once for a Validator."); - - _additionalAsync = additionalAsync.ThrowIfNull(nameof(additionalAsync)); - return this; - } - - /// - async Task> IValidatorEx.ValidateAsync(T value, ValidationArgs? args, CancellationToken cancellationToken) - { - var context = new ValidationContext(value, args ?? new ValidationArgs()); - var ir = await ValidateAsync(value, context.FullyQualifiedEntityName, context.FullyQualifiedEntityName, Text, cancellationToken: cancellationToken).ConfigureAwait(false); - context.MergeResult(ir.Messages); - return context; - } + (_additionalAsync ??= []).Add(additionalAsync.ThrowIfNull()); + return this; + } - /// - /// Validates the value. - /// - /// The value to validate. - /// The value name. - /// The value JSON name. - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// Indicates to throw a where an error was found. - /// The . - /// A . - private async Task, T>> ValidateAsync(T? value, string? name, string? jsonName, LText? text = null, bool throwOnError = false, CancellationToken cancellationToken = default) + /// + /// Validate the common property value. + /// + /// The . + /// The . + protected async override Task OnValidateAsync(PropertyContext, TValue> context, CancellationToken cancellationToken) + { + if (_additionalAsync is not null) { - var vv = new ValidationValue(null, value); - var ctx = new PropertyContext, T>(new ValidationContext>(vv, - new ValidationArgs()), value, name ?? Name, jsonName ?? JsonName, text ?? name.ToSentenceCase() ?? Text); - - await InvokeAsync(ctx, cancellationToken).ConfigureAwait(false); - var res = new ValueValidatorResult, T>(ctx); - - if (ctx.Parent.FailureResult is null) + foreach (var validator in _additionalAsync) { - var result = await OnValidateAsync(ctx, cancellationToken).ConfigureAwait(false); - if (result.IsSuccess && _additionalAsync != null) - result = await _additionalAsync(ctx, cancellationToken).ConfigureAwait(false); - - ctx.Parent.SetFailureResult(result); + await validator(context, cancellationToken).ConfigureAwait(false); } - - if (throwOnError) - res.ThrowOnError(); - - return res; } } } \ No newline at end of file diff --git a/src/CoreEx.Validation/CompareOperator.cs b/src/CoreEx.Validation/CompareOperator.cs index b93aa998..7881ea94 100644 --- a/src/CoreEx.Validation/CompareOperator.cs +++ b/src/CoreEx.Validation/CompareOperator.cs @@ -1,23 +1,25 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -namespace CoreEx.Validation +/// +/// Represents a comparison operator. +/// +public enum CompareOperator { - /// - /// Represents a comparison operator. - /// - public enum CompareOperator - { - /// A comparison for equality. - Equal, - /// A comparison for inequality. - NotEqual, - /// A comparison for less than. - LessThan, - /// A comparison for less than or equal to. - LessThanEqual, - /// A comparison for greater than. - GreaterThan, - /// A comparison for greater than or equal to. - GreaterThanEqual, - } -} + /// A comparison for equality. + Equal, + + /// A comparison for inequality. + NotEqual, + + /// A comparison for less than. + LessThan, + + /// A comparison for less than or equal to. + LessThanOrEqualTo, + + /// A comparison for greater than. + GreaterThan, + + /// A comparison for greater than or equal to. + GreaterThanOrEqualTo +} \ No newline at end of file diff --git a/src/CoreEx.Validation/CoreEx.Validation.csproj b/src/CoreEx.Validation/CoreEx.Validation.csproj index 58a627bb..b87422f9 100644 --- a/src/CoreEx.Validation/CoreEx.Validation.csproj +++ b/src/CoreEx.Validation/CoreEx.Validation.csproj @@ -1,18 +1,5 @@  - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx.Validation - CoreEx - CoreEx .NET Validation (Alternative). - CoreEx .NET Validation (Alternative). - coreex api function aspnet entity microservices referencedata validation validator validate - - - - - diff --git a/src/CoreEx.Validation/GlobalUsing.cs b/src/CoreEx.Validation/GlobalUsing.cs new file mode 100644 index 00000000..36c1fb01 --- /dev/null +++ b/src/CoreEx.Validation/GlobalUsing.cs @@ -0,0 +1,23 @@ +global using CoreEx.Abstractions; +global using CoreEx.Data; +global using CoreEx.Entities; +global using CoreEx.Entities.Abstractions; +global using CoreEx.Localization; +global using CoreEx.Metadata; +global using CoreEx.RefData; +global using CoreEx.RefData.Abstractions; +global using CoreEx.Results; +global using CoreEx.Validation.Abstractions; +global using CoreEx.Validation.Clauses; +global using CoreEx.Validation.Rules; +global using CoreEx.Wildcards; +global using Microsoft.Extensions.DependencyInjection; +global using System.Collections; +global using System.Collections.Immutable; +global using System.Diagnostics; +global using System.Linq.Expressions; +global using System.Numerics; +global using System.Runtime.CompilerServices; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using System.Text.RegularExpressions; \ No newline at end of file diff --git a/src/CoreEx.Validation/IEntityRule.cs b/src/CoreEx.Validation/IEntityRule.cs deleted file mode 100644 index da107a70..00000000 --- a/src/CoreEx.Validation/IEntityRule.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Provides a validation rule for an entity. - /// - /// The entity . - public interface IEntityRule where TEntity : class - { - /// - /// Validates an entity given a . - /// - /// The - /// The . - Task ValidateAsync(ValidationContext context, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/IPropertyContext.cs b/src/CoreEx.Validation/IPropertyContext.cs deleted file mode 100644 index 158badb4..00000000 --- a/src/CoreEx.Validation/IPropertyContext.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Localization; - -namespace CoreEx.Validation -{ - /// - /// Enables a validation context for a property. - /// - public interface IPropertyContext - { - /// - /// Gets the for the parent entity. - /// - IValidationContext Parent { get; } - - /// - /// Gets the property name. - /// - string Name { get; } - - /// - /// Gets the JSON property name. - /// - string JsonName { get; } - - /// - /// Gets the property text. - /// - string Text { get; } - - /// - /// Gets the property value. - /// - object? Value { get; } - - /// - /// Gets the fully qualified property name. - /// - string FullyQualifiedPropertyName { get; } - - /// - /// Gets the fully qualified Json property name. - /// - string FullyQualifiedJsonPropertyName { get; } - - /// - /// Indicates whether there has been a validation error. - /// - bool HasError { get; } - - /// - /// Creates a new with the specified format and adds to the underlying . - /// The friendly and are automatically passed as the first two arguments to the string formatter. - /// - /// The composite format string. - /// A . - MessageItem CreateErrorMessage(LText format); - - /// - /// Creates a new with the specified format and additional values to be included in the text and adds to the underlying . - /// The friendly and are automatically passed as the first two arguments to the string formatter. - /// - /// The composite format string. - /// The values that form part of the message text ( and are automatically passed as the first two arguments to the string formatter). - /// A . - MessageItem CreateErrorMessage(LText format, params object?[] values); - - /// - /// Creates a fully qualified property name for the name. - /// - /// The property name. - string CreateFullyQualifiedPropertyName(string name); - - /// - /// Creates a fully qualified JSON property name for the name. - /// - /// The property name. - string CreateFullyQualifiedJsonPropertyName(string name); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/IPropertyContextT.cs b/src/CoreEx.Validation/IPropertyContextT.cs deleted file mode 100644 index 134d6d05..00000000 --- a/src/CoreEx.Validation/IPropertyContextT.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Validation -{ - /// - /// Enables a validation context for a property. - /// - /// The entity . - /// The property . - public interface IPropertyContext : IPropertyContext where TEntity : class { } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/IPropertyRule.cs b/src/CoreEx.Validation/IPropertyRule.cs deleted file mode 100644 index f89458a5..00000000 --- a/src/CoreEx.Validation/IPropertyRule.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; - -namespace CoreEx.Validation -{ - /// - /// Enables a validation rule for an entity property. - /// - public interface IPropertyRule - { - /// - /// Gets the property name. - /// - public string Name { get; } - - /// - /// Gets the JSON property name. - /// - public string JsonName { get; } - - /// - /// Gets or sets the friendly text name used in validation messages. - /// - public LText Text { get; set; } - - /// - /// Gets or sets the error message format text (overrides the default) used for all validation errors. - /// - public LText? ErrorText { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/IPropertyRuleT2.cs b/src/CoreEx.Validation/IPropertyRuleT2.cs deleted file mode 100644 index 9da72cb1..00000000 --- a/src/CoreEx.Validation/IPropertyRuleT2.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using CoreEx.Validation.Clauses; -using CoreEx.Validation.Rules; -using System; -using System.Linq.Expressions; - -namespace CoreEx.Validation -{ - /// - /// Enables a validation rule for an entity property. - /// - /// The entity . - /// The property . - public interface IPropertyRule : IPropertyRule where TEntity : class - { - /// - /// Adds a clause () to the rule. - /// - /// The . - void AddClause(IPropertyRuleClause clause); - - /// - /// Adds a rule () to the property. - /// - /// The . - /// The . - IPropertyRule AddRule(IValueRule rule); - - /// - /// Adds a to this in that another specified property of the entity must have a non-default value (and not have a validation error) to continue. - /// - /// A depends on expression. - /// The . - public IPropertyRule DependsOn(Expression> expression) - { - if (expression == null) - return this; - - AddClause(new DependsOnClause(expression)); - return this; - } - - /// - /// Sets the for the last rule added. - /// - /// The error message format text. - /// The . - IPropertyRule WithMessage(LText errorText); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/IValidationContext.cs b/src/CoreEx.Validation/IValidationContext.cs deleted file mode 100644 index 46381331..00000000 --- a/src/CoreEx.Validation/IValidationContext.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Collections.Generic; - -namespace CoreEx.Validation -{ - /// - /// Provides the validation context properties for an entity. - /// - public interface IValidationContext : IValidationResult - { - /// - /// Gets the entity . - /// - Type EntityType { get; } - - /// - /// Gets the entity prefix used for fully qualified entity.property naming (null represents the root). - /// - string? FullyQualifiedEntityName { get; } - - /// - /// Gets the entity prefix used for fully qualified JSON entity.property naming (null represents the root). - /// - string? FullyQualifiedJsonEntityName { get; } - - /// - /// Indicates whether JSON names were used for the ; by default (false) uses the .NET property names. - /// - bool UsedJsonNames { get; } - - /// - /// Gets the configuration parameters. - /// - /// Configuration parameters provide a means to pass values down through the validation stack. The consuming developer must instantiate the property on first use. - IDictionary? Config { get; } - - /// - /// Determines whether one of the specified fully qualified property names has an error. - /// - /// The fully qualified property name. - /// true where an error exists for at least one of the specified properties; otherwise, false. - bool HasError(string fullyQualifiedPropertyName); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/IValidatorEx.cs b/src/CoreEx.Validation/IValidatorEx.cs deleted file mode 100644 index 46c51930..00000000 --- a/src/CoreEx.Validation/IValidatorEx.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Extends the . - /// - public interface IValidatorEx : IValidator - { - /// - /// Validate the entity value with specified . - /// - /// The entity value. - /// An optional . - /// The . - /// The resulting . - Task ValidateAsync(object value, ValidationArgs? args, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/IValidatorExT.cs b/src/CoreEx.Validation/IValidatorExT.cs deleted file mode 100644 index 67df339d..00000000 --- a/src/CoreEx.Validation/IValidatorExT.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Extends the - /// - /// The value . - public interface IValidatorEx : IValidatorEx, IValidator - { - /// - async Task IValidatorEx.ValidateAsync(object value, ValidationArgs? args, CancellationToken cancellationToken) - => await ValidateAsync((T)value!, args, cancellationToken).ConfigureAwait(false); - - /// - async Task> IValidator.ValidateAsync(T value, CancellationToken cancellationToken) - => await ValidateAsync(value, null, cancellationToken).ConfigureAwait(false); - - /// - /// Validate the entity value with specified . - /// - /// The value. - /// An optional . - /// The . - /// The resulting . - Task> ValidateAsync(T value, ValidationArgs? args, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/IncludeBaseRule.cs b/src/CoreEx.Validation/IncludeBaseRule.cs deleted file mode 100644 index d8d632b5..00000000 --- a/src/CoreEx.Validation/IncludeBaseRule.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Represents a rule that enables a base validator to be included. - /// - /// The entity . - /// The entity base . - public class IncludeBaseRule : ValidatorBase, IEntityRule where TEntity : class where TInclude : class - { - private readonly IValidatorEx _include; - - /// - /// Initializes a new instance of the class. - /// - /// The base . - internal IncludeBaseRule(IValidatorEx include) => _include = include.ThrowIfNull(nameof(include)); - - /// - public async Task ValidateAsync(ValidationContext context, CancellationToken cancellationToken = default) - { - if (context.ThrowIfNull(nameof(context)).Value is not TInclude val) - throw new InvalidOperationException($"Type {typeof(TEntity).Name} must inherit from {typeof(TInclude).Name}."); - - var ctx = new ValidationContext(val, new ValidationArgs - { - Config = context.Config, - SelectedPropertyName = context.SelectedPropertyName, - ShallowValidation = context.ShallowValidation, - FullyQualifiedEntityName = context.FullyQualifiedEntityName, - UseJsonNames = context.UsedJsonNames - }); - - if (_include is ValidatorBase vb) // Victoria Bitter, for a hard-earned thirst: https://www.youtube.com/watch?v=WA1h9h7-_Z4 - { - foreach (var r in vb.Rules) - { - await r.ValidateAsync(ctx, cancellationToken).ConfigureAwait(false); - if (ctx.FailureResult is not null) - break; - } - } - - context.MergeResult(ctx); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/PredicateAsync.cs b/src/CoreEx.Validation/PredicateAsync.cs new file mode 100644 index 00000000..cc494f18 --- /dev/null +++ b/src/CoreEx.Validation/PredicateAsync.cs @@ -0,0 +1,11 @@ +namespace CoreEx.Validation; + +/// +/// Represents the method signature for an context-based asynchronous predicate. +/// +/// The entity . +/// The property . +/// The . +/// The . +/// where meets the criteria defined within the method represented by this delegate; otherwise, . +public delegate Task PredicateAsync(PropertyContext context, CancellationToken cancellationToken) where TEntity : class; \ No newline at end of file diff --git a/src/CoreEx.Validation/PropertyContext.cs b/src/CoreEx.Validation/PropertyContext.cs index 4443f3aa..e4ea7022 100644 --- a/src/CoreEx.Validation/PropertyContext.cs +++ b/src/CoreEx.Validation/PropertyContext.cs @@ -1,244 +1,268 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; + +/// +/// Provides a validation context for a property. +/// +/// The entity . +/// The property . +public struct PropertyContext : IPropertyContext where TEntity : class +{ + private const string _dictionaryKeyParameterName = "__dictionaryKey"; + private const string _collectionIndexParameterName = "__collectionIndex"; -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; -using CoreEx.Localization; -using System; -using System.Collections.Generic; -using System.Linq; + private readonly IRootPropertyRule _root; + private readonly IPropertyRuntimeMetadata _metadata; + private readonly ValidationContext _owner; -namespace CoreEx.Validation -{ /// - /// Provides a validation context for a property. + /// Initializes a new instance of the class. /// - /// The entity . - /// The property . - public class PropertyContext : IPropertyContext where TEntity : class + /// The . + /// The validation context for the parent entity. + internal PropertyContext(RootPropertyRule root, ValidationContext context) { - private readonly bool _doNotAppendName = false; - - /// - /// Initializes a new instance of the class. - /// - /// The validation context for the parent entity. - /// The property value. - /// The property name. - /// The JSON property name. - /// The property text. - public PropertyContext(ValidationContext context, TProperty? value, string name, string? jsonName = null, LText? text = null) + _root = root.ThrowIfNull(); + _metadata = root.Metadata.ThrowIfNull(); + _owner = context.ThrowIfNull(); + + JsonName = _metadata.GetJsonName(context.JsonSerializerOptions); + Value = _metadata.GetValue(context.Value); + IsValueNull = Value is null; + ValueFormatter = root.ValueFormatter; + Text = root.Text ?? _metadata.Text; + + if (_metadata is ISelfRuntimeMetadata) + { + // Self-referencing entity; so, use the owner qualified names where not null; otherwise, default. + FullyQualifiedPropertyName = _owner.FullyQualifiedEntityName ?? Name; + FullyQualifiedJsonPropertyName = _owner.FullyQualifiedJsonEntityName ?? JsonName; + } + else { - Parent = context.ThrowIfNull(nameof(context)); - Name = name.ThrowIfNullOrEmpty(nameof(name)); - JsonName = jsonName ?? Name; - UseJsonName = context.UsedJsonNames; - Text = text ?? Name.ToSentenceCase()!; - Value = value; + // Standard property; so, append to the owner qualified names. FullyQualifiedPropertyName = CreateFullyQualifiedPropertyName(Name); FullyQualifiedJsonPropertyName = CreateFullyQualifiedJsonPropertyName(JsonName); } + } - /// - /// Initializes a new instance of the class where the property is considered the value (impacts the fully-qualified names, etc.). - /// - /// The property text. - /// The validation context for the parent entity. - /// The property value. - public PropertyContext(LText? text, ValidationContext context, TProperty? value) - { - Parent = context.ThrowIfNull(nameof(context)); - FullyQualifiedPropertyName = Parent.FullyQualifiedEntityName ?? Validation.ValueNameDefault; - FullyQualifiedJsonPropertyName = Parent.FullyQualifiedJsonEntityName ?? Validation.ValueNameDefault; - Name = FullyQualifiedPropertyName.Split('.', StringSplitOptions.RemoveEmptyEntries).Last(); - JsonName = FullyQualifiedJsonPropertyName.Split('.', StringSplitOptions.RemoveEmptyEntries).Last(); - Text = text ?? Name.ToSentenceCase()!; - Value = value; - _doNotAppendName = true; - } + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The property value. + internal PropertyContext(IPropertyContext context, TProperty value) + { + _root = context.RootPropertyRule; + _metadata = context.Metadata; + _owner = (ValidationContext)context.Owner; + + JsonName = context.JsonName; + Value = value; + IsValueNull = context.IsValueNull; + ValueFormatter = context.ValueFormatter; + Text = context.Text; + FullyQualifiedPropertyName = context.FullyQualifiedPropertyName; + FullyQualifiedJsonPropertyName = context.FullyQualifiedJsonPropertyName; + } - /// - /// Gets the instance. - /// - public CoreEx.ExecutionContext ExecutionContext => ExecutionContext.Current; - - /// - /// Gets the for the parent entity. - /// - public ValidationContext Parent { get; } - - /// - /// Gets the for the parent entity. - /// - IValidationContext IPropertyContext.Parent => Parent; - - /// - /// Gets the property name. - /// - public string Name { get; } - - /// - /// Gets the JSON property name. - /// - public string JsonName { get; } - - /// - /// Gets the fully qualified property name. - /// - public string FullyQualifiedPropertyName { get; } - - /// - /// Gets the fully qualified Json property name. - /// - public string FullyQualifiedJsonPropertyName { get; } - - /// - /// Indicates whether to use the JSON property name for the ; by default (false) uses the .NET property name. - /// - public bool UseJsonName { get; } - - /// - /// Gets the property text. - /// - public string Text { get; } - - /// - /// Gets the property value. - /// - object? IPropertyContext.Value => Value; - - /// - /// Gets the property value. - /// - public TProperty? Value { get; private set; } - - /// - /// Indicates whether there has been a validation error. - /// - public bool HasError { get; internal set; } - - /// - /// Enables the underlying value to be overridden (updated). - /// - /// The override value. - public void OverrideValue(TProperty? value) - { - if (Comparer.Default.Compare(Value, value) == 0) - return; - - // Get the property info. - if (Parent.Value is ValidationValue vv) - { - if (vv.Entity != null) - { - var pr = TypeReflector.GetReflector(TypeReflectorArgs.Default, vv.Entity.GetType()).GetProperty(Name) ?? throw new InvalidOperationException($"Property '{Name}' does not exist for Type {Parent.EntityType.Name}."); - - try - { - pr.PropertyExpression.SetValue(vv.Entity, value); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Type '{Parent.EntityType.Name}' Property '{Name}' value cannot be overridden: {ex.Message}", ex); - } - } - else - throw new InvalidOperationException("A value without a parent object owning the property value cannot be overridden."); - } - else - { - var pr = TypeReflector.GetReflector(TypeReflectorArgs.Default).GetProperty(Name) ?? throw new InvalidOperationException($"Property '{Name}' does not exist for Type {typeof(TEntity).Name}."); - - try - { - pr.PropertyExpression.SetValue(Parent.Value, value); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Type '{typeof(TEntity).Name}' Property '{Name}' value cannot be overridden: {ex.Message}", ex); - } - } - - Value = value; - } + /// + readonly IRootPropertyRule IPropertyContext.RootPropertyRule => _root; - /// - /// Creates a new with the specified format and adds to the underlying . - /// The friendly and are automatically passed as the first two arguments to the string formatter. - /// - /// The composite format string. - /// A . - public MessageItem CreateErrorMessage(LText format) => CreateErrorMessage(format, []); - - /// - /// Creates a new with the specified format and additional values to be included in the text and adds to the underlying . - /// The friendly and are automatically passed as the first two arguments to the string formatter. - /// - /// The composite format string. - /// The values that form part of the message text ( and are automatically passed as the first two arguments to the string formatter). - /// A . - public MessageItem CreateErrorMessage(LText format, params object?[] values) - { - HasError = true; - var fVals = (new string[] { Text, Value?.ToString()! }).Concat(values).ToArray(); + /// + readonly IValidationContext IPropertyContext.Owner => _owner; - if (_doNotAppendName) - return Parent.AddMessage(MessageType.Error, format, fVals); - else - return Parent.AddMessage(Name, JsonName, MessageType.Error, format, fVals); - } + /// + readonly IPropertyRuntimeMetadata IPropertyContext.Metadata => _metadata; - /// - /// Creates a fully qualified property name for the name. - /// - /// The property name. - public string CreateFullyQualifiedPropertyName(string name) => (Parent.FullyQualifiedEntityName == null) ? name : (Parent.FullyQualifiedEntityName + (name.StartsWith('[') ? "" : ".") + name); - - /// - /// Creates a fully qualified JSON property name for the name. - /// - /// The property name. - public string CreateFullyQualifiedJsonPropertyName(string name) => (Parent.FullyQualifiedJsonEntityName == null) ? name : (Parent.FullyQualifiedJsonEntityName + (name.StartsWith('[') ? "" : ".") + name); - - /// - /// Creates a new from the . - /// - /// A . - public ValidationArgs CreateValidationArgs() - { - var args = new ValidationArgs - { - FullyQualifiedEntityName = FullyQualifiedPropertyName, - FullyQualifiedJsonEntityName = FullyQualifiedJsonPropertyName, - SelectedPropertyName = Parent?.SelectedPropertyName, - UseJsonNames = Parent?.UsedJsonNames - }; - - // Copy the configuration values; do not allow the higher-level dictionaries (stack) to be extended by lower-level validators. - if (Parent?.Config != null) - { - args.Config ??= new Dictionary(); - foreach (var cfg in Parent.Config) - { - args.Config.Add(cfg.Key, cfg.Value); - } - } - - return args; - } + /// + /// Gets the property . + /// + internal readonly IPropertyRuntimeMetadata Metadata => _metadata; - /// - /// Merges a validation result into this. - /// - /// The to merge. - public void MergeResult(IValidationContext context) - { - if (context == null) - return; + /// + public readonly IDictionary Parameters => _owner.Parameters; - if (context.HasErrors) - HasError = true; + /// + public readonly string Name => _metadata.Name; - Parent.MergeResult(context); - } + /// + public string JsonName { get; } + + /// + public string FullyQualifiedPropertyName { get; } + + /// + public string FullyQualifiedJsonPropertyName { get; } + + /// + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + public LText Text { get; } + + /// + public readonly TEntity Entity => _owner.Value; + + /// + public TProperty Value { get; private set; } + + /// + public bool IsValueNull { get; private set; } + + /// + public ValueFormatter ValueFormatter { get; } + + /// + public readonly bool IsInError => HasError(FullyQualifiedPropertyName); + + /// + /// Determines whether the specified fully qualified property name has an error. + /// + /// The fully qualified property name. + /// where an error exists for the specified property; otherwise, . + public readonly bool HasError(string fullyQualifiedPropertyName) => _owner.HasError(fullyQualifiedPropertyName); + + /// + /// Adds an with the specified and additional to be included in the text. + /// + /// The composite format string. + /// The values that form part of the message text. + /// The . + /// The property friendly text and value are automatically prepended to the as the first two arguments where the does not have already have . + public readonly MessageItem AddError(LText format, params object?[] values) => _owner.AddError(_metadata, Text, ValueFormatter, JsonName, format, values); + + /// + public readonly string CreateFullyQualifiedPropertyName(string name) => _owner.CreateFullyQualifiedPropertyName(name); + + /// + public readonly string CreateFullyQualifiedJsonPropertyName(string jsonName) => _owner.CreateFullyQualifiedJsonPropertyName(jsonName); + + /// + /// Creates a new from the . + /// + /// Indicates whether to alternatively use the qualified names from the parent . + /// The . + public readonly ValidationArgs CreateValidationArgs(bool useParentQualifiedNames = false) => new() + { + FullyQualifiedEntityName = useParentQualifiedNames ? _owner.FullyQualifiedEntityName : FullyQualifiedPropertyName, + FullyQualifiedJsonEntityName = useParentQualifiedNames ? _owner.FullyQualifiedJsonEntityName : FullyQualifiedJsonPropertyName, + UseJsonNames = _owner.UseJsonNames, + JsonSerializerOptions = _owner.JsonSerializerOptions, + ServiceProvider = _owner.ServiceProvider, + Parameters = _owner.Parameters + }; + + /// + /// Merges a validation result into this. + /// + /// The to merge. + internal readonly void MergeResult(IValidationResult validationResult) => _owner.MergeResult(validationResult); + + /// + /// Formats the property value as a . + /// + /// The value. + /// The representation. + /// Leverages the configuration. + public readonly LText FormatValue(TProperty? value) => ValueFormatter.ToLText(value); + + /// + /// Indicates whether the originating property type is . + /// + public readonly bool IsValueNullable => _root.IsValueNullable; + + /// + /// Gets the originating property or (where ). + /// + /// The property . + /// The originating property or . + public readonly T GetNullableValueOrDefault() => _root.GetNullableValueOrDefault(Entity); + + /// + /// Indicates whether the originating property is (where ). + /// + /// where ; otherwise, . + public readonly bool IsNullableValueDefault() => _root.IsNullableValueDefault(Entity); + + /// + /// An will be thrown where the underlying property is read-only. + public void Override(TProperty value) + { + if (_metadata.IsReadOnly) + throw new InvalidOperationException($"The property '{Name}' is read-only and cannot be overridden."); + + // Override the value on the actual entity. + _metadata.SetValue(Entity, value); + + // Update the context to reflect the new value. + Value = value; + IsValueNull = value is null; + } + + /// + /// Gets the last dictionary key from the validation stack. + /// + /// The dictionary key . + /// The dictionary key. + /// This is typically used where the property being validated is within a dictionary and the key is required as part of the validation. The dictionary key is added to the context parameters + /// by the . + /// A will be thrown where the context does not contain a dictionary key or it is not of type . + /// + public readonly TKey GetDictionaryKey() where TKey : notnull + { + if (Parameters?.TryGetValue("__dictionaryKey", out var obj) == true && obj is TKey typedKey) + return typedKey; + + throw new KeyNotFoundException("The property context does not contain a dictionary key or was not the specified type."); + } + + /// + /// Gets the last dictionary key from the validation stack where available (without exception where not found or of incorrect type). + /// + /// The dictionary key where available; otherwise, . + internal readonly object? GetDictionaryKeySafe() => Parameters.TryGetValue(_dictionaryKeyParameterName, out var obj) ? obj : null; + + /// + /// Sets the last dictionary key within the validation stack. + /// + /// The dictionary key. + internal readonly void SetDictionaryKey(object? key) + { + if (key is null) + _owner.Parameters.Remove(_dictionaryKeyParameterName); + else + _owner.Parameters[_dictionaryKeyParameterName] = key; + } + + /// + /// Gets the last collection index from the validation stack. + /// + /// The collection index. + /// This is typically used where the property being validated is within a collection and the index is required as part of the validation. The collection index is added to the context parameters + /// by the . + /// An will be thrown where the context does not contain a collection index or it is not of type . + /// + public readonly int GetCollectionIndex() + { + if (Parameters?.TryGetValue(_collectionIndexParameterName, out var obj) == true && obj is int typedIndex) + return typedIndex; + + throw new IndexOutOfRangeException("The property context does not contain a collection index or was not the specified type."); + } + + /// + /// Gets the last collection index from the validation stack where available (without exception where not found or of incorrect type). + /// + /// The collection index. + internal readonly int? GetCollectionIndexSafe() => Parameters.TryGetValue(_collectionIndexParameterName, out var obj) && obj is int typedIndex ? typedIndex : null; + + /// + /// Sets the last collection index within the validation stack. + /// + /// The collection index. + internal readonly void SetCollectionIndex(int? index) + { + if (index is null) + _owner.Parameters.Remove(_collectionIndexParameterName); + else + _owner.Parameters[_collectionIndexParameterName] = index; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/PropertyRule.cs b/src/CoreEx.Validation/PropertyRule.cs deleted file mode 100644 index 25e3f191..00000000 --- a/src/CoreEx.Validation/PropertyRule.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Localization; -using CoreEx.Validation.Clauses; -using CoreEx.Validation.Rules; -using System; -using System.Linq.Expressions; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Represents a validation rule for an entity property. - /// - /// The entity . - /// The property . - public class PropertyRule : PropertyRuleBase, IEntityRule, IValueRule where TEntity : class - { - private readonly PropertyExpression _property; - - /// - /// Initializes a new instance of the class. - /// - /// The to reference the entity property. - public PropertyRule(Expression> propertyExpression) : this(PropertyExpression.Create(propertyExpression)) { } - - /// - /// Initializes a new instance of the class. - /// - private PropertyRule(PropertyExpression propertyExpression) : base(propertyExpression.Name, propertyExpression.Text, propertyExpression.JsonName) => _property = propertyExpression; - - /// - public async Task ValidateAsync(ValidationContext context, CancellationToken cancellationToken = default) - { - if (context.ThrowIfNull(nameof(context)).Value == null) - return; - - // Where validating a specific property then make sure the names match. - if (context.SelectedPropertyName != null && context.SelectedPropertyName != Name) - return; - - // Ensure that the property does not already have an error. - if (context.HasError(_property)) - return; - - // Get the property value and create the property context. - var value = _property.GetValue(context.Value); - var ctx = new PropertyContext(context, value, this.Name, this.JsonName, this.Text); - - // Run the rules. - await InvokeAsync(ctx, cancellationToken).ConfigureAwait(false); - } - - /// - /// Adds a rule () to the property. - /// - /// The . - /// The . - public new PropertyRule AddRule(IValueRule rule) - { - base.AddRule(rule); - return this; - } - - /// - /// Gets or sets the error message format text (overrides the default). - /// - LText? IValueRule.ErrorText { get => throw new NotSupportedException("ErrorText should not bet set directly on a PropertyRule."); set => throw new NotSupportedException("ErrorText should not bet set directly on a PropertyRule."); } - - /// - void IValueRule.AddClause(IPropertyRuleClause clause) => AddClause(clause); - - /// - bool IValueRule.Check(IPropertyContext context) => throw new NotSupportedException("A property value clauses check should not occur directly on a PropertyRule."); - - /// - Task IValueRule.ValidateAsync(IPropertyContext context, CancellationToken cancellationToken) => throw new NotSupportedException("A property value validation should not occur directly on a PropertyRule."); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/PropertyRuleBase.cs b/src/CoreEx.Validation/PropertyRuleBase.cs deleted file mode 100644 index cc267f71..00000000 --- a/src/CoreEx.Validation/PropertyRuleBase.cs +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using CoreEx.Validation.Clauses; -using CoreEx.Validation.Rules; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Represents a base validation rule for an entity property. - /// - /// The entity . - /// The property . - public abstract class PropertyRuleBase : IPropertyRule where TEntity : class - { - private readonly List> _rules = []; - private readonly List> _clauses = []; - - /// - /// Initializes a new instance of the class. - /// - /// The property name. - /// The friendly text name used in validation messages (defaults to as ). - /// The JSON property name (defaults to ). - protected PropertyRuleBase(string name, LText? text = null, string? jsonName = null) - { - Name = name.ThrowIfNullOrEmpty(nameof(name)); - Text = text ?? Name.ToSentenceCase()!; - JsonName = string.IsNullOrEmpty(jsonName) ? Name : jsonName; - } - - /// - public string Name { get; internal set; } - - /// - public string JsonName { get; internal set; } - - /// - public virtual LText Text { get; set; } - - /// - public virtual LText? ErrorText { get; set; } - - /// - IPropertyRule IPropertyRule.WithMessage(LText errorText) - { - if (_rules.Count == 0) - ErrorText = errorText; - else - _rules.Last().ErrorText = errorText; - - return this; - } - - /// - IPropertyRule IPropertyRule.AddRule(IValueRule rule) => AddRule(rule); - - /// - /// Adds a rule () to the property. - /// - /// The . - /// The . - public PropertyRuleBase AddRule(IValueRule rule) - { - rule.ThrowIfNull(nameof(rule)).ErrorText ??= ErrorText; // Override the rule's error text where not already overridden. - - _rules.Add(rule); - return this; - } - - /// - /// Adds a clause () to the last rule added. - /// - /// The . - public void AddClause(IPropertyRuleClause clause) - { - if (clause == null) - return; - - if (_rules.Count == 0) - _clauses.Add(clause); - else - _rules.Last().AddClause(clause); - } - - /// - /// Runs the configured clauses and rules. - /// - /// The . - /// The . - protected async Task InvokeAsync(PropertyContext context, CancellationToken cancellationToken) - { - context.ThrowIfNull(nameof(context)); - - // Check all "this" clauses. - foreach (var clause in _clauses) - { - if (!clause.Check(context)) - return; - } - - // Check and execute all rules/clauses within the rules stack. - foreach (var rule in _rules) - { - if (rule.Check(context)) - await rule.ValidateAsync(context, cancellationToken).ConfigureAwait(false); - - // Stop validating after an error. - if (context.HasError || context.Parent.FailureResult.HasValue) - break; - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/README.md b/src/CoreEx.Validation/README.md deleted file mode 100644 index 5e1a1cdb..00000000 --- a/src/CoreEx.Validation/README.md +++ /dev/null @@ -1,445 +0,0 @@ -# CoreEx - -The `CoreEx.Validation` namespace provides an alternative data validation capability. It provides a familiar fluent-style method-chaining experience to other frameworks, taking a slightly different approach to validation configuration, and arguably supports more complex validations. - -
- -## Composition - -At its core a [`Validator`](./Validator.cs) can contain one or more **Rules** (provides a specific value validation), which can be further conditionally controlled by zero or more **Clauses** (provides a means to check whether a validation should occur). - -
- -### Rules - -All rules must inherit from [`PropertyRuleBase`](./PropertyRuleBase.cs) which enables the following key capabilities: - -Capability | Description --|- -`Name` | Gets the underlying property/value name. -`Text` | Gets/sets the friendly text name used in validation messages. -`WithMessage` | Sets (overrides) the error message for the rule. - -The following represent access to clauses/conditions (support zero or more) to determine whether the `Rule` should be invoked: - -Method | Description --|- -`DependsOn` | Adds a [`DependsOnClause`](./Clauses/DependsOnClause.cs) that ensures that another specified property of the entity must have a non-default value and not have a corresponding validation error. -`When` | Adds a [`WhenClause`](./Clauses/WhenClause.cs) where the condition must be `true`. -`WhenOperation` | Adds a `WhenClause` that ensures that the `ExecutionContext.Current.OperationType` is equal to the specified value. -`WhenNotOperation` | Adds a `WhenClause` that ensures that the `ExecutionContext.Current.OperationType` is _not_ equal to the specified value. -`WhenValue` | Adds a `WhenClause` where the condition for the corresponding value must be `true`. -`WhenHasValue` | Adds a `WhenClause` where the corresponding value must not be `default`. - -The following represent the available rules: - -Rule | Description --|- -`BetweenRule` | Provides a comparision validation between a specified from and to value. -`CollectionRule` | Provides collection (`IEnumerable`) validation including `MinCount`, `MaxCount`, per item validation `CollectionRuleItem` and duplicate checking. -`CommonRule` | Provides for integrating a common validation against a specified property. -`ComparePropertyRule` | Provides a comparision validation against another property within the same entity; also confirms other property has no errors prior to comparison. -`CompareValueRule` | Provides a comparision validation against a specified value. -`CompareValuesRule` | Provides a comparision validation against one or more specified values. -`CustomRule` | Provides a custom validation against a specified property. -`DecimalRule` | Represents a numeric rule that validates `DecimalPlaces` (fractional-part length) and `MaxDigits` (being the sum of the integer-part and fractional-part lengths). -`DictionaryRule` | Provides dictionary (`IDictionary`) validation including `MinCount`, `MaxCount` and per item validation `DictionaryRuleItem`. -`DuplicateRule` | Provides validation where the rule predicate must return `false` to not be considered a duplicate. -`EmailRule` | Provides e-mail validation. -`EntityRule` | Provides entity validation. -`EnumRule` | Provides [`Enum`](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/enum) validation to ensure that the value has been defined. -`EnumValueRule` | Provides `string` validation against an [`Enum`](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/enum) value. -`ExistsRule` | Provides validation where the rule predicate must return `true` or a value to verify it exists. -`ImmutableRule` | Provides validation where the rule predicate must return `true` to be considered valid (has not been modified). -`InteropRule` | Provides interoperability integration with other validation frameworks. -`MandatoryRule` | Provides mandatory validation; determined as mandatory when it contains its default value. -`MustRule` | Provides validation where the rule predicate must return `true` to be considered valid. -`NoneRule` | Provides a rule to ensure value is its default value (opposite of `MandatoryRule`). -`NotNullRule` | Provides validation where the value must not be `null`. -`NumericRule` | Represents a numeric rule to validate precision, scale and whether negatives are allowed. -`NullRule` | Provides validation where the value must be `null`. -`OverrideRule` | Provides the ability to override the property value. -`ReferenceDataCodeRule` | Provides validation for a `ReferenceDataBase.Code`; validates that it exists and that the corresponding `ReferenceDataBase.IsValid`. -`ReferenceDataRule` | Provides validation for a `ReferenceDataBase`; validates that the `ReferenceDataBase.IsValid`. -`ReferenceDataSidListRule` | Provides validation for a `ReferenceDataSidListBase` including `MinCount`, `MaxCount`, per item `ReferenceDataBase.IsValid` and whether to `AllowDuplicates`. -`StringRule` | Provides `string` validation including `MinLength`, `MaxLength` and `Regex`. -`WildcardRule` | Provides `string` `Wildcard` validation. - -
- -### Clauses - -The following represent the available clauses: - -Clause | Description --|- -`DependsOnClause` | Represents a depends on test clause; in that another specified property of the entity must have a non-default value to continue. -`WhenClause` | Represents a when test clause; in that the condition must be `true` to continue. - -
- -### Extension Methods - -The **rules** are generally not instantiated directly, but accessed via pre-defined extension methods to provide a more simplified, natural, experience using fluent-style (method-chaining) approach to development. - -The following represent the available extension methods: - -Extension method | Description | Underlying rule --|-|- -`AreValid()` | Adds a *reference data list* validation. | `ReferenceDataSidListRule` -`Between()` | Adds a *between comparision* validation. | `BetweenRule` -`Collection()` | Adds a *collection* validation. | `CollectionRule` -`CompareProperty()` | Adds a *property comparison* validation. | `ComparePropertyRule` -`CompareValue()` | Adds a *value comparison* validation. | `CompareValueRule` -`CompareValues()` | Adds a *values comparison* validation. | `CompareValuesRule` -`Currency()` | Adds a *currency* validation for a `decimal` using a `NumberFormatInfo`. | `DecimalRule` -`Custom()` | Adds a *custom* validation. | `CustomRule` -`Default()` | Adds a property value override where the current value is the default for the `Type`. | `OverrideRule` -`Dictionary()` | Adds a *dictionary* validation. | `DictionaryRule` -`Duplicate()` | Adds a *duplicate* validation. | `DuplicateRule` -`Empty()` | Adds a *none* validation. | `NoneRule` -`Email()` | Adds an *e-mail* validation. | `EmailRule` -`EmailAddress()` | Adds an *e-mail* validation. | `EmailRule` -`Entity()` | Adds an *entity* validation. | `EntityValidationRule` -`Entity().With()` | Adds an *entity* validation. | `EntityValidationRule` -`EntityCollection()` | Adds an *entity collection* validation. | `EntityCollectionValidationRule` -`Enum()` | Adds an *enum* validation for an `Enum` `Type`. | `EnumRule` -`Enum().As()` | Adds an *enum* validation for a `string` `Type`. | `EnumValueRule` -`Equal()` | Adds an *equal value comparison* validation. | `CompareValueRule` -`ExclusiveBetween()` | Adds an exclusive *between comparision* validation. | `BetweenRule` -`Exists()` | Adds an *exists* validation. | `ExistsRule` -`GreaterThan()` | Adds a *greater than value comparison* validation. | `CompareValueRule` -`GreaterThanOrEqualTo()` | Adds a *greater than or equal to value comparison* validation. | `CompareValueRule` -`Immutable()` | Adds an *immutable* validation. | `ImmutableRule` -`InclusiveBetween()` | Adds an inclusive *between comparision* validation. | `BetweenRule` -`IsInEnum()` | Adds an *enum* validation for an `Enum` `Type`. | `EnumRule` -`IsValid()` | Adds a *reference data* validation. | `ReferenceDataRule` -`LessThan()` | Adds a *less than value comparison* validation. | `CompareValueRule` -`LessThanOrEqualTo()` | Adds a *less than or equal to value comparison* validation. | `CompareValueRule` -`Length()` | Adds a `string` exact length validation. | `StringRule` -`Mandatory()` | Adds a *mandatory* validation. | `MandatoryRule` -`Matches()` | Adds a `Regex` validation. | `StringRule` -`MaximumLength()` | Adds a `string` maximum length validation. | `StringRule` -`MaximumCount()` | Adds an `ICollection` maximum count validation. | `CollectionRule` -`MinimumLength()` | Adds a `string` minimum length validation. | `StringRule` -`MinimumCount()` | Adds an `ICollection` minimum count validation. | `CollectionRule` -`Must()` | Adds a *must* validation. | `MustRule` -`NotEmpty()` | Adds a *mandatory* validation. | `MandatoryRule` -`NotEqual()` | Adds a *not equal value comparison* validation. | `CompareValueRule` -`NotNull()` | Adds a not *null* validation. | `NotNullRule` -`None()` | Adds a *none* validation. | `NoneRule` -`Numeric()` | Adds a *numeric* validation. | `NumericRule` or `DecimalRule` -`Null()` | Adds a *null* validation. | `NullRule` -`Override` | Adds a property value override. | `OverrideRule` -`RefData().As()` | Adds a *reference data* validation for a `string` `Type`. | `ReferenceDataCodeRule` -`RefDataCode` | Adds a *reference data code* validation. | `ReferenceDataCodeRule` -`String()` | Adds a `string` validation. | `StringRule` -`Wildcard()` | Adds a `string` *wildcard* validation. | `WildcardRule` - -Additional extension methods included are as follows: - -Extension method | Description --|- -`Text()` | Updates the rule friendly name text used in validation messages. -`Common()` | Provides for integrating a common validation against a specified property. -`Validate()` | Enables (sets up) validation for a value. - -
- -### Error messages - -All error messages are managed as an embedded resources accessible via the `ValidatorStrings` class; as follows: - -Property | Format string --|- -`AllowNegativesFormat` | {0} must not be negative. -`BetweenInclusiveFormat` | {0} must be between {2} and {3}. -`BetweenExclusiveFormat` | {0} must be between {2} and {3} (exclusive). -`CollectionNullItemFormat` | {0} contains one or more items that are not specified. -`CompareEqualFormat` | {0} must be equal to {2}. -`CompareGreaterThanEqualFormat` | {0} must be greater than or equal to {2}. -`CompareGreaterThanFormat` | {0} must be greater than {2}. -`CompareLessThanEqualFormat` | {0} must be less than or equal to {2}. -`CompareLessThanFormat` | {0} must be less than {2}. -`CompareNotEqualFormat` | {0} must not be equal to {2}. -`DecimalPlacesFormat` | {0} exceeds the maximum specified number of decimal places ({2}). -`DependsOnFormat` | {0} is required where {2} has a value. -`DictionaryNullKeyFormat` | {0} contains one or more keys that are not specified. -`DictionaryNullValueFormat` | {0} contains one or more values that are not specified. -`DuplicateFormat` | {0} already exists and would result in a duplicate. -`DuplicateValue2Format` | {0} contains duplicates; {2} value specified more than once. -`DuplicateValueFormat` | {0} contains duplicates; {2} value '{3}' specified more than once. -`ExactLengthFormat` | {0} must be exactly {2} characters in length. -`ExistsFormat` | {0} is not found; a valid value is required. -`ImmutableFormat` | {0} is not allowed to change; please reset value. -`InvalidFormat` | {0} is invalid. -`MandatoryFormat` | {0} is required. -`MaxCountFormat` | {0} must not exceed {2} item(s). -`MaxDigitsFormat` | {0} must not exceed {2} digits in total. -`MaxLengthFormat` | {0} must not exceed {2} characters in length. -`MaxValueFormat` | {0} is greater than the maximum allowed value of {2}. -`MinCountFormat` | {0} must have at least {2} item(s). -`MinLengthFormat` | {0} must be at least {2} characters in length. -`MinValueFormat` | {0} is less than the minimum allowed value of {2}. -`MustFormat` | {0} is invalid. -`RegexFormat` | {0} is invalid. -`WildcardFormat` | {0} contains invalid or non-supported wildcard selection. - -The validation framework passes the friendly text name as `{0}`, and the validating value as `{1}` for inclusion in the final message output. Higher numbered format strings are applicable to the specific validator rule consuming. - -
- -## Usage - -There are multiple means to leverage the validation framework. - -
- -### Entity-based validator class - -The primary means for an entity-based validator is to inherit from the `Validator` class (or `AbstractValidator`). The instance should be instantiated once (and cached) where possible as the underlying property expressions can be a relatively expensive (performance) operation. - -Additionally, the `OnValidate` method can be overridden to add more complex and/or cross-property validations as required. - -Each property for the entity is configured using the `Property` method (or `RuleFor`) and a corresponding property expression. The property expression is advantageous as the friendly text name can be inferred (in order specified): -- Use the `DisplayAttribute(Name="Product Code")` value; will be: "Product Code"; -- Use the property name `string CustomerNumber { get; set; }` formatted as Sentence Case; will be "Customer Number". -- The resulting text from above is then passed through the text localization (`LText`) resource string replacement. - -An example is as follows: - -``` csharp -public class PersonValidator : Validator -{ - public PersonValidator() - { - Property(x => x.Name).Mandatory().String(maxLength: 50); - Property(x => x.Birthday).CompareValue(CompareOperator.LessThanEqual, DateTime.Now, "today"); - } - - protected override Task OnValidateAsync(ValidationContext context) - { - // Check that Amount property has not had an error already; then validate and error. - context.Check(x = x.Amount, (val) => val <= 100, "{0} must be greater than 100."); - - return Result.SuccessTask; - } -} - -var person = new Person { Name = "Freddie", Birthday = new DateTime(1946, 09, 05), Amount = 150 }; - -// Validate the value. -var result = await new PersonValidator().ValidateAsync(person); -``` - -
- -### Entity-based inline validator - -The secondary means for an entity-based validator is to define and execute inline. The `HasProperty()` is used to create a property (or `HasRuleFor`), with a corresponding action to enable validation configuration. - -An example is as follows: - -``` csharp -var person = new Person { Name = "Freddie", Birthday = new DateTime(1946, 09, 05); - -// Create an entity-based validator on the fly. -var result = await Validator.Create() - .HasProperty(x => x.Name, p => p.Mandatory().String(maxLength: 50)) - .HasProperty(x => x.Birthdar, p => p.CompareValue(CompareOperator.LessThanEqual, DateTime.Now, "today")) - .ValidateAsync(person); -``` - -
- - -### Value-based validator - -Values, both entity and non-entity, can be validated directly. Examples are as follows: - -``` csharp -var person = new Person { Name = "Freddie", Birthday = new DateTime(1946, 09, 05); - -// Validate an entity value; being the Person class. -var pv = new PersonValidator(); -await person.Validate().Entity(pv).RunAsync(); - -// Validate a value (e.g. a string, int, DateTime, etc.) without an entity-based validator. -await person.Name.Validate().Mandatory().String(maxLength: 10).RunAsync(throwOnError: true); -``` - -
- -### Validation chaining - -As demonstrated in the prior examples the validation supports fluent-style (method-chaining) for the underlying **rules** and **clauses**. - -When the validation is executed the **rules** will be invoked in the order in which they are specified, and conditionally invoked where succeeding **clauses** (optional) are specified for a rule (to the right of). A validation may have zero or more clauses before the first rule, then a rule with zero or more succeeding clauses, followed by zero or more rules, etc. - -The following is a property that will only perform any succeeding rules once the `DependsOn` clause results in `true`; otherwise, no rules will be executed. Where `true` then the `CompareProperty` will be executed: - -``` csharp -Property(x => x.DateTo).DependsOn(x => x.DateFrom).CompareProperty(CompareOperator.GreaterThanEqual, x => x.DateFrom); -``` - -The following is a property that will always execute the `Mandatory` rule, and only the `CompareProperty` rule where the `DependsOn` clause results in `true`: - -``` csharp -Property(x => x.DateTo).Mandatory.CompareProperty(CompareOperator.GreaterThanEqual, x => x.DateFrom).DependsOn(x => x.DateFrom); -``` - -
- -### Common validations - -To support reusablility of property validations a `CommonValidator` is used to enable. This allows for the validation logic to be defined once, and reused (shared) across multiple validations. This validator also enables validation to be configured for non-entities (e.g. intrinisic types). - -An example is as follows: - -``` csharp -var cv = CommonValidator _cv = Validator.CreateCommon(v => v.String(5).Must(x => x.Value != "XXXXX")); - -var v = Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory().Common(cv)); -``` - -
- -### Examples - -The following represents a number of additional examples demonstrating property validation scenarios: - -``` csharp -// The integer is mandatory, must be positive, and has a max value of 999, and must have a value greater of 10. -Property(x => x.Integer).Mandatory().Numeric(allowNegatives: false, maxDigits: 3).CompareValue(CompareOperator.GreaterThan, 10); - -// The decimal should be treated as a positive currency with default decimal places (NumberFormatInfo.CurrentInfo.CurrencyDecimalDigits). -Property(x => x.Amount).Currency(allowNegatives: true); - -// The decimal must be positive, with a max value of 999.999, and max three decimal places (max digits includes decimal places). -Property(x => x.Amount2).Numeric(allowNegatives: false, maxDigits: 6, decimalPlaces: 3); - -// The Date From must be greater than Now; with specified text "today" to include in error message. -Property(x => x.DateFrom).CompareValue(CompareOperator.GreaterThan, DateTime.Now, "today"); - -// The Date To must be greater than the Date From where the Date From (DependsOn) has a value -// (also DependsOn will not validate where the dependent field has previously failed). -Property(x => x.DateTo).DependsOn(x => x.DateFrom).CompareProperty(CompareOperator.GreaterThanEqual, x => x.DateFrom); - -// When can be used to conditionalise a previous rule; so Name is mandatory only when the Integer value is 50; also, max length is 50. -Property(x => x.Name).Mandatory().When(x => x.Integer == 50).String(maxLength: 50); - -// Must can used for more complex logic, as in the condition 'must' be true otherwise the value is considered invalid. -Property(x => x.Amount).Must(x => x.Integer > 10); - -// The phone number will be validated against the defined regex. -Property(x => x.PhoneNo).String(new Regex(@"\+0\d{9}|\+0[1-9]\d{12}|0[1-9]\d{8}|00[1-9]\d{9}|00[1-9]\d{13}")); - -// The Gender (which is a Reference Data entity) is mandatory and must be considered valid. -Property(x => x.Gender).Mandatory().IsValid(); - -// Check the sub entity exists (mandatory) and is valid (using defined validator). -Property(x => x.SubTest).Mandatory().Entity().With(test2Validator); - -// Check the sub entity collection (exists), has 1-4 items in the collection, and each is valid (using defined validator). -Property(x => x.SubTesters).Mandatory().Collection(minCount: 1, maxCount: 4, item: CollectionRuleItem.Create(test2Validator)); -``` - -The following demonstrates the mixing of both entity-based options: - -``` csharp -public class PersonValidator : Validator -{ - private static readonly Validator
_addressValidator = Validator.Create
() - .HasProperty(x => x.Street, p => p.Mandatory().String(50)) - .HasProperty(x => x.City, p => p.Mandatory().String(50)); - - /// - /// Initializes a new instance of the . - /// - public PersonValidator() - { - Property(x => x.FirstName).Mandatory().String(50); - Property(x => x.LastName).Mandatory().String(50); - Property(x => x.Gender).Mandatory().IsValid(); - Property(x => x.Birthday).Mandatory().CompareValue(CompareOperator.LessThanEqual, () => DateTime.Now, "Today"); - Property(x => x.Address).Entity().With(_addressValidator); - } -} -``` - -
- -## Advanced - -There are additional features that enable more advanced / complex validation scenarios. - -
- -### Conditional rule set - -The `RuleSet` represents a conditional validation rule for an entity, in that it groups one or more **rules** together that only get invoked where a specified condition results in **true**. - -The following demonstrates `RuleSet` usage for an *Entity-based validator class*: - -``` csharp -public class TestItemValidator : Validator -{ - public TestItemValidator() - { - RuleSet(x => x.Value.Code == "A", () => - { - Property(x => x.Text).Mandatory().Must(x => x.Text == "A"); - }); - - RuleSet(x => x.Value.Code == "B", () => - { - Property(x => x.Text).Mandatory().Must(x => x.Text == "B"); - }); - } -} -``` - -The following demonstrates `HasRuleSet` usage for an `Entity-based inline validator`: - -``` csharp -var v = Validator.Create() - .HasRuleSet(x => x.Value.Code == "A", y => - { - y.Property(x => x.Text).Mandatory().Must(x => x.Text == "A"); - }) - .HasRuleSet(x => x.Value.Code == "B", (y) => - { - y.Property(x => x.Text).Mandatory().Must(x => x.Text == "B"); - }); -``` - -
- -### Include/inherit validators - -Where entities leverage inheritence, having the corresponding validators include the base (parent) classes validations rules can be advantageous (versus codifying the rules multiple times). The `IncludeBase` method enables a base validator to be included within another validator's rule set. - -The following is an example of using the `IncludeBase` method: - -``` csharp -var r = Validator.Create() - .IncludeBase(testDataBaseValidator) - .HasProperty(x => x.CountB, p => p.Mandatory().CompareValue(CompareOperator.GreaterThan, 10)) - .Validate(new TestData { CountB = 0 }); -``` - -
- -### Consolidating multiple validators - -The `MultiValidator` enables the validation of multiple values there is a need to consolidate the results into a single set of `Messages` (and/or `ValidationException`). - -An example is as follows: - -``` csharp -var result = await MultiValidator.Create() - .Add(person.Validate(nameof(value)).Mandatory().Entity(personValidator)) - .Add(other.Validate(nameof(other)).Mandatory().Entity(otherValidator)) - .RunAsync(); -``` \ No newline at end of file diff --git a/src/CoreEx.Validation/ReferenceDataValidation.cs b/src/CoreEx.Validation/ReferenceDataValidation.cs deleted file mode 100644 index 164d5387..00000000 --- a/src/CoreEx.Validation/ReferenceDataValidation.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; - -namespace CoreEx.Validation -{ - /// - /// Represents the standard validation configuration settings. - /// - public static class ReferenceDataValidation - { - /// - /// Gets or sets the maximum length for the . - /// - public static int MaxCodeLength { get; set; } = 30; - - /// - /// Gets or sets the maximum length for the . - /// - public static int MaxTextLength { get; set; } = 256; - - /// - /// Gets or sets the maximum length for the . - /// - public static int MaxDescriptionLength { get; set; } = 1000; - - /// - /// Indicates whether the is supported. - /// - public static bool SupportsDescription { get; set; } = false; - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/ReferenceDataValidator.cs b/src/CoreEx.Validation/ReferenceDataValidator.cs deleted file mode 100644 index c6dec006..00000000 --- a/src/CoreEx.Validation/ReferenceDataValidator.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using CoreEx.Results; -using System; - -namespace CoreEx.Validation -{ - /// - /// Represents the base validator. - /// - /// The . - public class ReferenceDataValidator : Validator where TEntity : class, IReferenceData - { - /// - /// Initializes a new instance of the class. - /// - public ReferenceDataValidator() - { - Property(x => x.Id).Mandatory().Custom(ValidateId); - Property(x => x.Code).Mandatory().String(ReferenceDataValidation.MaxCodeLength); - Property(x => x.Text).Mandatory().String(ReferenceDataValidation.MaxTextLength); - Property(x => x.Description).String(ReferenceDataValidation.MaxDescriptionLength).When(() => ReferenceDataValidation.SupportsDescription); - Property(x => x.Description).Empty().When(() => !ReferenceDataValidation.SupportsDescription); - Property(x => x.EndDate).When(x => x.StartDate.HasValue && x.EndDate.HasValue).CompareProperty(CompareOperator.GreaterThanEqual, x => x.StartDate); - } - - /// - /// Perform more complex mandatory check based on the ReferenceData base ID type. - /// - private Result ValidateId(PropertyContext context) - { - if (context.Value != null) - { - if (context.Value is int iid && iid != 0) - return Result.Success; - - if (context.Value is long lid && lid != 0) - return Result.Success; - - if (context.Value is Guid gid && gid != Guid.Empty) - return Result.Success; - } - - context.CreateErrorMessage(ValidatorStrings.MandatoryFormat); - return Result.Success; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/ReferenceDataValidatorBase.cs b/src/CoreEx.Validation/ReferenceDataValidatorBase.cs deleted file mode 100644 index 18a3ccb5..00000000 --- a/src/CoreEx.Validation/ReferenceDataValidatorBase.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using System; - -namespace CoreEx.Validation -{ - /// - /// Represents the base validator with a instance. - /// - /// The . - /// The . - public abstract class ReferenceDataValidatorBase : ReferenceDataValidator - where TEntity : class, IReferenceData - where TSelf : ReferenceDataValidatorBase, new() - { - private static readonly TSelf _default = new(); - -#pragma warning disable CA1000 // Do not declare static members on generic types; by-design, results in a consistent static defined default instance without the need to specify generic type to consume. - /// - /// Gets the current instance of the validator. - /// - public static TSelf Default -#pragma warning restore CA1000 - { - get - { - if (_default == null) - throw new InvalidOperationException("An instance of this Validator cannot be referenced as it is still being constructed; beware that you may have a circular reference within the constructor."); - - return _default; - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Resources.resx b/src/CoreEx.Validation/Resources.resx deleted file mode 100644 index 838f1ba1..00000000 --- a/src/CoreEx.Validation/Resources.resx +++ /dev/null @@ -1,258 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - {0} must not be negative. - - - {0} is required. - - - An authentication error occured; the credentials you provided are not valid. - - - An authorization error occurred; you are not permitted to perform this action. - - - {0} must be between {2} and {3} (exclusive). - - - {0} must be between {2} and {3}. - - - A business error occurred. - - - {0} contains one or more items that are not specified. - - - {0} must be equal to {2}. - - - {0} must be greater than or equal to {2}. - - - {0} must be greater than {2}. - - - {0} must be less than or equal to {2}. - - - {0} must be less than {2}. - - - {0} must not be equal to {2}. - - - A concurrency error occurred; please refresh the data and try again. - - - A data conflict occurred. - - - {0} exceeds the maximum specified number of decimal places ({2}). - - - {0} is required where {2} has a value. - - - {0} contains one or more keys that are not specified. - - - {0} contains one or more values that are not specified. - - - A duplicate error occurred. - - - {0} already exists and would result in a duplicate. - - - {0} contains duplicates; {2} specified more than once. - - - {0} contains duplicates; {2} '{3}' specified more than once. - - - {0} is not a valid e-mail address. - - - {0} must be exactly {2} characters in length. - - - {0} is not found; a valid value is required. - - - Identifier - - - {0} is not allowed to change; please reset value. - - - {0} is invalid. - - - {0} contains one or more invalid items. - - - {0} is required. - - - {0} must not exceed {2} item(s). - - - {0} must not exceed {2} digits in total. - - - {0} must not exceed {2} characters in length. - - - {0} is greater than the maximum allowed value of {2}. - - - {0} must have at least {2} item(s). - - - {0} must be at least {2} characters in length. - - - {0} is less than the minimum allowed value of {2}. - - - {0} is invalid. - - - {0} must not be specified. - - - Requested data was not found. - - - Primary Key - - - {0} is invalid. - - - A data validation error occurred. - - - {0} contains invalid or non-supported wildcard selection. - - \ No newline at end of file diff --git a/src/CoreEx.Validation/RuleSet.cs b/src/CoreEx.Validation/RuleSet.cs deleted file mode 100644 index 8a160dc7..00000000 --- a/src/CoreEx.Validation/RuleSet.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Represents a validation rule set for an entity, in that it groups one or more together for a specified condition. - /// - /// The entity . - public class RuleSet : ValidatorBase, IEntityRule where TEntity : class - { - /// - /// Initializes a new instance of the class to be invoked where the predicate is true. - /// - /// A function to determine whether the is to be validated. - internal RuleSet(Predicate> predicate) => Predicate = predicate.ThrowIfNull(nameof(predicate)); - - /// - /// Gets the function to determine whether the is to be validated. - /// - public Predicate> Predicate { get; private set; } - - /// - public async Task ValidateAsync(ValidationContext context, CancellationToken cancellationToken = default) - { - // Check the condition before continuing to validate the underlying rules. - if (!Predicate(context)) - return; - - // Validate each of the property rules. - foreach (var rule in Rules) - { - await rule.ValidateAsync(context, cancellationToken).ConfigureAwait(false); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/BetweenRule.cs b/src/CoreEx.Validation/Rules/BetweenRule.cs index eed7fbee..12b8c194 100644 --- a/src/CoreEx.Validation/Rules/BetweenRule.cs +++ b/src/CoreEx.Validation/Rules/BetweenRule.cs @@ -1,116 +1,46 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +namespace CoreEx.Validation.Rules; + +/// +/// Provides a comparison validation between two values. +/// +/// The entity . +/// The property . +/// The function to get the minimum value. +/// The function to get the maximum value. +/// The minimum text formatter (used in the error message); otherwise, uses the resulting value. +/// The maximum text formatter (used in the error message); otherwise, uses the resulting value. +/// Indicates whether the between comparison is exclusive or inclusive (default). +/// The optional . +public sealed class BetweenRule(Func, TProperty> min, Func, TProperty> max, Func? minText = null, Func? maxText = null, bool exclusiveBetween = false, IComparer? comparer = null) + : PropertyRuleBase where TEntity : class where TProperty : IComparable { + private readonly Func, TProperty> _min = min.ThrowIfNull(); + private readonly Func, TProperty> _max = max.ThrowIfNull(); + private readonly Func? _minText = minText; + private readonly Func? _maxText= maxText; + private readonly bool _exclusiveBetween = exclusiveBetween; + /// - /// Provides a comparision validation between two specified values. + /// Gets the . /// - /// The entity . - /// The property . - public class BetweenRule : ValueRuleBase where TEntity : class - { - private readonly TProperty _compareFromValue; - private readonly Func? _compareFromValueFunction; - private readonly Func>? _compareFromValueFunctionAsync; - private readonly LText? _compareFromText; - private readonly Func? _compareFromTextFunction; - private readonly TProperty _compareToValue; - private readonly Func? _compareToValueFunction; - private readonly Func>? _compareToValueFunctionAsync; - private readonly LText? _compareToText; - private readonly Func? _compareToTextFunction; - private readonly bool _exclusiveBetween; + public IComparer Comparer { get; } = comparer ?? Comparer.Default; - /// - /// Initializes a new instance of the class specifying the between from and to values. - /// - /// The compare from value. - /// The compare to value. - /// The compare from text to be passed for the error message (default is to use ). - /// The compare to text to be passed for the error message (default is to use ). - /// Indicates whether the between comparison is exclusive or inclusive (default). - public BetweenRule(TProperty compareFromValue, TProperty compareToValue, LText? compareFromText = null, LText? compareToText = null, bool exclusiveBetween = false) - { - _compareFromValue = compareFromValue; - _compareFromText = compareFromText; - _compareToValue = compareToValue; - _compareToText = compareToText; - _exclusiveBetween = exclusiveBetween; - } - - /// - /// Initializes a new instance of the class specifying the between from and to value functions. - /// - /// The compare from value function. - /// The compare to value function. - /// The compare from text function (default is to use the result of the ). - /// The compare to text function (default is to use the result of the ). - /// Indicates whether the between comparison is exclusive or inclusive (default). - public BetweenRule(Func compareFromValueFunction, Func compareToValueFunction, Func? compareFromTextFunction = null, Func? compareToTextFunction = null, bool exclusiveBetween = false) - { - _compareFromValueFunction = compareFromValueFunction.ThrowIfNull(nameof(compareFromValueFunction)); - _compareFromTextFunction = compareFromTextFunction; - _compareFromValue = default!; - _compareToValueFunction = compareToValueFunction.ThrowIfNull(nameof(compareToValueFunction)); - _compareToTextFunction = compareToTextFunction; - _compareToValue = default!; - _exclusiveBetween = exclusiveBetween; - } + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + // Get the min and max values. + var min = _min(context); + var max = _max(context); - /// - /// Initializes a new instance of the class specifying the between from and to value async functions. - /// - /// The compare from value function. - /// The compare to value function. - /// The compare from text function (default is to use the result of the ). - /// The compare to text function (default is to use the result of the ). - /// Indicates whether the between comparison is exclusive or inclusive (default). - public BetweenRule(Func> compareFromValueFunctionAsync, Func> compareToValueFunctionAsync, Func? compareFromTextFunction = null, Func? compareToTextFunction = null, bool exclusiveBetween = false) + // Compare the values. + if ((_exclusiveBetween && (Comparer.Compare(context.Value, min) <= 0 || Comparer.Compare(context.Value, max) >= 0)) + || (!_exclusiveBetween && (Comparer.Compare(context.Value, min) < 0 || Comparer.Compare(context.Value, max) > 0))) { - _compareFromValueFunctionAsync = compareFromValueFunctionAsync.ThrowIfNull(nameof(compareFromValueFunctionAsync)); - _compareFromTextFunction = compareFromTextFunction; - _compareFromValue = default!; - _compareToValueFunctionAsync = compareToValueFunctionAsync.ThrowIfNull(nameof(compareToValueFunctionAsync)); - _compareToTextFunction = compareToTextFunction; - _compareToValue = default!; - _exclusiveBetween = exclusiveBetween; + context.AddError(ErrorText ?? (_exclusiveBetween ? ValidatorStrings.BetweenExclusiveFormat : ValidatorStrings.BetweenInclusiveFormat), + _minText?.Invoke(min) ?? context.FormatValue(min), + _maxText?.Invoke(max) ?? context.FormatValue(max)); } - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - var compareFromValue = _compareFromValueFunction != null - ? _compareFromValueFunction(context.Parent.Value!) - : (_compareFromValueFunctionAsync != null - ? await _compareFromValueFunctionAsync(context.Parent.Value!, cancellationToken).ConfigureAwait(false) - : _compareFromValue); - - var compareToValue = _compareToValueFunction != null - ? _compareToValueFunction(context.Parent.Value!) - : (_compareToValueFunctionAsync != null - ? await _compareToValueFunctionAsync(context.Parent.Value!, cancellationToken).ConfigureAwait(false) - : _compareToValue); - - var comparer = Comparer.Default; - if ((_exclusiveBetween && (comparer.Compare(context.Value, compareFromValue) <= 0 || comparer.Compare(context.Value, compareToValue) >= 0)) - || (!_exclusiveBetween && (comparer.Compare(context.Value, compareFromValue) < 0 || comparer.Compare(context.Value, compareToValue) > 0))) - { - string? compareFromText = _compareFromText ?? compareFromValue?.ToString() ?? new LText("null"); - if (_compareFromTextFunction != null) - compareFromText = _compareFromTextFunction(context.Parent.Value!); - - string? compareToText = _compareToText ?? compareToValue?.ToString() ?? new LText("null"); - if (_compareToTextFunction != null) - compareToText = _compareToTextFunction(context.Parent.Value!); - - context.CreateErrorMessage(ErrorText ?? (_exclusiveBetween ? ValidatorStrings.BetweenExclusiveFormat : ValidatorStrings.BetweenInclusiveFormat), compareFromText, compareToText); - } - } + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/CollectionRule.cs b/src/CoreEx.Validation/Rules/CollectionRule.cs index 1695bb5a..73ddfddc 100644 --- a/src/CoreEx.Validation/Rules/CollectionRule.cs +++ b/src/CoreEx.Validation/Rules/CollectionRule.cs @@ -1,126 +1,314 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using CoreEx.Abstractions.Reflection; -using System; -using System.Collections; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides a collection () validation including item-based validation and duplicate checking. +/// +/// The entity . +/// The property (). +/// The item . +public sealed class CollectionRule : PropertyRuleBase where TEntity : class where TProperty : IEnumerable { + private readonly Func, int>? _minCount; + private readonly Func, int?>? _maxCount; + private readonly With _with; + + /// + /// Initializes a new instance of the class. + /// + /// The minimum count. + /// The maximum count. + /// Extends configuration . + public CollectionRule(Func, int>? minCount, Func, int?>? maxCount, Func? with) + { + _minCount = minCount; + _maxCount = maxCount; + + var w = new With(this); + _with = with?.Invoke(w) ?? w; + } + + /// + protected async override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + var minCount = _minCount?.Invoke(context) ?? 0; + var maxCount = _maxCount?.Invoke(context); + + if (minCount < 0) + throw new InvalidOperationException("Minimum count must not be negative."); + + if (maxCount.HasValue) + { + if (maxCount.Value < 0) + throw new InvalidOperationException("Maximum count must not be negative."); + + if (maxCount.Value < minCount) + throw new InvalidOperationException("Maximum count must not be less than minimum count."); + } + + await _with.ValidateAsync(context, minCount, maxCount, cancellationToken).ConfigureAwait(false); + } + /// - /// Provides collection validation including and . + /// Provides additional configuration options for the . /// - /// The entity . - /// The collection property . - public class CollectionRule : ValueRuleBase where TEntity : class where TProperty : IEnumerable? + public sealed class With { - private readonly Type _itemType; - private ICollectionRuleItem? _item; + private readonly CollectionRule _rule; + private Func>? _getValidator; + private bool _hasAllowNullItems; +#pragma warning disable CA1859 // Use concrete types when possible for improved performance; not applicable here as interface is needed. + private IItemDuplicateCheck? _itemDuplicateCheck; +#pragma warning restore CA1859 // Use concrete types when possible for improved performance /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public CollectionRule() => _itemType = TypeReflector.GetCollectionItemType(typeof(TProperty)).ItemType!; + internal With(CollectionRule rule) => _rule = rule; /// - /// Gets or sets the minimum count. + /// Indicates that one or more items can be . /// - public int MinCount { get; set; } + /// The to support fluent-style method-chaining. + public With AllowNullItems() + { + _hasAllowNullItems = true; + return this; + } /// - /// Gets or sets the maximum count. + /// Sets the specified Item . /// - public int? MaxCount { get; set; } + /// The to support fluent-style method-chaining. + public With WithItemValidator(Action.Validator>? configure) => WithItemValidator(new ValidatingInlineValidator(configure)); /// - /// Indicates whether the underlying collection items can be null. + /// Sets the specified Item . /// - public bool AllowNullItems { get; set; } + /// The to support fluent-style method-chaining. + public With WithItemValidator(IValidatorEx validator) + { + _getValidator = _getValidator is not null ? throw new InvalidOperationException("The collection rule can only have one validator.") : _ => validator.ThrowIfNull(); + return this; + } /// - /// Gets or sets the collection item validation configuration. + /// Sets the specified Item service (resolved at validation runtime). /// - public ICollectionRuleItem? Item + /// The property validator . + /// The to support fluent-style method-chaining. + public With WithItemValidator() where TValidator : IValidatorEx { - get => _item; - - set - { - if (value == null) - { - _item = value; - return; - } - - if (_itemType != value.ItemType) - throw new ArgumentException($"A CollectionRule TProperty ItemType '{_itemType.Name}' must be the same as the Item {value.ItemType.Name}"); - - _item = value; - } + _getValidator = _getValidator is not null ? throw new InvalidOperationException("The collection rule can only have one validator.") : args => CoreEx.Validation.Validator.Get(args.ServiceProvider); + return this; } /// - /// Overrides the Check method and will not validate where performing a shallow validation. + /// Sets the specified keyed Item service (resolved at validation runtime). /// - /// The . - /// true where validation is to continue; otherwise, false to stop. - protected override bool Check(PropertyContext context) => !context.ThrowIfNull(nameof(context)).Parent.ShallowValidation && base.Check(context); + /// The property validator . + /// The service key. + /// The to support fluent-style method-chaining. + public With WithItemKeyedValidator(object? serviceKey) where TValidator : IValidatorEx + { + _getValidator = _getValidator is not null ? throw new InvalidOperationException("The collection rule can only have one validator.") : args => CoreEx.Validation.Validator.GetKeyed(serviceKey, args.ServiceProvider); + return this; + } - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) + /// + /// Sets the generic duplicate checking logic. + /// + /// The key . + /// The key selector. + /// The equality comparer. + /// The duplicate to be used in the error message. + /// The to support fluent-style method-chaining. + internal With WithDuplicateCheckingInternal(Func keySelector, IEqualityComparer? comparer, Func duplicateText) { - if (context.Value == null) - return; + _itemDuplicateCheck = _itemDuplicateCheck is not null ? throw new InvalidOperationException("The collection rule can only have one duplicate checker.") : new ItemDuplicateCheck(keySelector, comparer, duplicateText); + return this; + } - // Where only validating count on an icollection do it quickly and exit. - if (AllowNullItems && Item is null && context.Value is ICollection coll) + /// + /// Validates each item within the collection. + /// + /// + /// The minimum count. + /// The maximum count. + /// The . + internal async Task ValidateAsync(PropertyContext context, int minCount, int? maxCount, CancellationToken cancellationToken) + { + // Fast path where only checking for count. + if (_hasAllowNullItems && _getValidator is null && _itemDuplicateCheck is null && context.Value is ICollection coll) { - if (coll.Count < MinCount) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MinCountFormat, MinCount); - else if (MaxCount.HasValue && coll.Count > MaxCount.Value) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MaxCountFormat, MaxCount); - + PostEnumerationValidation(context, false, minCount, maxCount, coll.Count); return; } - // Iterate through the collection validating each of the items. - var i = 0; + // Enumerate and validate each item. + var index = 0; var hasNullItem = false; - var hasItemErrors = false; - foreach (var item in context.Value) + var hasItemError = false; + var hasDuplicate = false; + + var duplicateChecker = _itemDuplicateCheck?.CreateDuplicateChecker(); + + foreach (var item in context.Value!) { - // Create the context args. - var args = context.CreateValidationArgs(); - var indexer = $"[{i++}]"; - args.FullyQualifiedEntityName += indexer; - args.FullyQualifiedJsonEntityName += indexer; + // Handle null item(s). + if (item is null) + { + if (!_hasAllowNullItems) + hasNullItem = true; - if (!AllowNullItems && item == null) - hasNullItem = true; + index++; + continue; + } - // Validate and merge. - if (item != null && Item?.ItemValidator != null) + // Validate the item. + var hasError = false; + if (_getValidator is not null) { - var r = await Item.ItemValidator.ValidateAsync(item, args, cancellationToken).ConfigureAwait(false); - context.MergeResult(r); - if (r.HasErrors) - hasItemErrors = true; + // Create the context args. + var args = CreateValidationArgs(context, index); + + var last = context.GetCollectionIndexSafe(); + context.SetCollectionIndex(index); + + // Validate the item and merge the result. + try + { + var r = await _getValidator.Invoke(args).ValidateAsync(item, args, cancellationToken).ConfigureAwait(false); + + context.MergeResult(r); + if (r.HasErrors) + hasItemError = hasError = true; + } + finally + { + context.SetCollectionIndex(last); + } } + + // Check for duplicates where applicable. + if (!hasError && !hasDuplicate && duplicateChecker?.IsDuplicate(item) is true) + hasDuplicate = true; + + index++; } + // Check for duplicates and error accordingly. + if (!hasItemError && hasDuplicate) + context.AddError(_rule.ErrorText ?? ValidatorStrings.DuplicateValueFormat, _itemDuplicateCheck!.DuplicateText()); + + // Perform the standard post enumeration validation. + PostEnumerationValidation(context, hasNullItem, minCount, maxCount, index); + } + + /// + /// Creates the for the specified . + /// + private static ValidationArgs CreateValidationArgs(PropertyContext context, int index) + { + var args = context.CreateValidationArgs(); + var indexer = $"[{index}]"; + args.FullyQualifiedEntityName += indexer; + args.FullyQualifiedJsonEntityName += indexer; + return args; + } + + /// + /// Performs the standatd post enumeration validation. + /// + private void PostEnumerationValidation(PropertyContext context, bool hasNullItem, int minCount, int? maxCount, int count) + { + // Emit the null item error. if (hasNullItem) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.CollectionNullItemFormat); + context.AddError(_rule.ErrorText ?? ValidatorStrings.CollectionNullItemFormat); // Check the length/count. - if (i < MinCount) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MinCountFormat, MinCount); - else if (MaxCount.HasValue && i > MaxCount.Value) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MaxCountFormat, MaxCount); - - // Check for duplicates. - if (!hasItemErrors) - Item?.DuplicateValidation(context, context.Value); + if (count < minCount) + context.AddError(_rule.ErrorText ?? ValidatorStrings.MinCountFormat, minCount); + else if (maxCount.HasValue && count > maxCount.Value) + context.AddError(_rule.ErrorText ?? ValidatorStrings.MaxCountFormat, maxCount); + } + } + + /// + /// Enables the duplicate checking configuration for items within a collection. + /// + internal interface IItemDuplicateCheck + { + /// + /// Gets the duplicate to be used in the error message. + /// + Func DuplicateText { get; } + + /// + /// Create the runtime . + /// + /// The . + IItemDuplicateChecker CreateDuplicateChecker(); + } + + /// + /// Enables the runtime duplicate checking for items within a collection. + /// + internal interface IItemDuplicateChecker + { + /// + /// Indicates whether the specified is a duplicate. + /// + /// The item. + /// indicates a duplicate; otherwise, . + bool IsDuplicate(TItem item); + } + + /// + /// Provides duplicate checking configuration for items within a collection. + /// + /// The key . + /// The key selector. + /// The equality comparer. + /// The duplicate function. + internal sealed class ItemDuplicateCheck(Func keySelector, IEqualityComparer? comparer, Func duplicateText) : IItemDuplicateCheck + { + /// + /// Gets the key selector. + /// + public Func KeySelector { get; } = keySelector.ThrowIfNull(); + + /// + /// Gets the equality comparer. + /// + public IEqualityComparer? Comparer { get; } = comparer; + + /// + public Func DuplicateText { get; } = duplicateText.ThrowIfNull(); + + /// + public IItemDuplicateChecker CreateDuplicateChecker() => new ItemDuplicateChecker(this); + } + + /// + /// Provides runtime duplicate checking for items within a collection. + /// + /// The key . + internal sealed class ItemDuplicateChecker : IItemDuplicateChecker + { + private readonly ItemDuplicateCheck _config; + private readonly HashSet _keys; + + /// + /// Initializes a new instance of the class. + /// + /// The . + public ItemDuplicateChecker(ItemDuplicateCheck config) + { + _config = config.ThrowIfNull(); + _keys = new HashSet(_config.Comparer); } + + /// + public bool IsDuplicate(TItem item) => !_keys.Add(_config.KeySelector(item)); } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/CollectionRuleItem.cs b/src/CoreEx.Validation/Rules/CollectionRuleItem.cs deleted file mode 100644 index 32703533..00000000 --- a/src/CoreEx.Validation/Rules/CollectionRuleItem.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides the means to create a instance. - /// - public static class CollectionRuleItem - { - /// - /// Create an instance of the class with no . - /// - /// The item . - /// The . - public static CollectionRuleItem Create() => new(null); - - /// - /// Create an instance of the class with a corresponding . - /// - /// The item . - /// The corresponding item . - /// The . - public static CollectionRuleItem Create(IValidatorEx validator) => new(validator.ThrowIfNull(nameof(validator))); - - /// - /// Create an instance of the class leveraging the to get the instance. - /// - /// The item entity . - /// The item validator . - /// The ; defaults to where not specified. - /// The . - public static CollectionRuleItem Create(IServiceProvider? serviceProvider = null) where TValidator : IValidatorEx => new(Validator.Create(serviceProvider)); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/CollectionRuleItemT.cs b/src/CoreEx.Validation/Rules/CollectionRuleItemT.cs deleted file mode 100644 index 22778f55..00000000 --- a/src/CoreEx.Validation/Rules/CollectionRuleItemT.cs +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Abstractions.Reflection; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using CoreEx.Localization; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides validation configuration for an item within a . - /// - /// The item . - public sealed class CollectionRuleItem : ICollectionRuleItem - { - private bool _duplicateCheck = false; - private IPropertyExpression? _propertyExpression; - private LText? _duplicateText = null; - private bool _ignoreWhereKeyIsInitial = false; - - /// - /// Initializes a new instance of the class with a corresponding . - /// - /// The corresponding item . - internal CollectionRuleItem(IValidatorEx? validator) => ItemValidator = validator; - - /// - /// Gets the corresponding item . - /// - IValidatorEx? ICollectionRuleItem.ItemValidator => ItemValidator; - - /// - /// Gets the corresponding item . - /// - public IValidatorEx? ItemValidator { get; private set; } - - /// - /// Gets the item . - /// - public Type ItemType => typeof(TItem); - - /// - /// Specifies that the collection is to be checked for duplicates using the item's value. - /// - /// The duplicate text to be passed for the error message; defaults to or depending on whether is implemented. - /// The instance to support chaining/fluent. - public CollectionRuleItem DuplicateCheck(LText? duplicateText = null) - { - if (_duplicateCheck) - throw new InvalidOperationException($"A {nameof(DuplicateCheck)} can only be specified once."); - - if (ItemType.GetInterface(typeof(IEntityKey).Name) == null) - throw new InvalidOperationException($"A CollectionRuleItem ItemType '{ItemType.Name}' must implement '{nameof(IEntityKey)}' to support default expression-less {nameof(DuplicateCheck)}."); - - _duplicateText = string.IsNullOrEmpty(duplicateText) ? (ItemType.GetInterface(typeof(IIdentifier).Name) is not null ? ValidatorStrings.Identifier : ValidatorStrings.PrimaryKey) : duplicateText; - _duplicateCheck = true; - - return this; - } - - /// - /// Specifies that the collection is to be checked for duplicates using the item's value with an option to . - /// - /// Indicates whether to ignore the when the underlying ; useful where the identifier will be generated by the underlying data source on create for example. - /// The duplicate text to be passed for the error message; defaults to . - /// The instance to support chaining/fluent. - public CollectionRuleItem DuplicateCheck(bool ignoreWhereKeyIsInitial, LText? duplicateText = null) - { - DuplicateCheck(duplicateText); - _ignoreWhereKeyIsInitial = ignoreWhereKeyIsInitial; - return this; - } - - /// - /// Specifies that the collection is to be checked for duplicates using the specified item . - /// - /// The item property . - /// The to reference the item property that is being duplicate checked. - /// The duplicate text to be passed for the error message (default is to derive the text from the property itself where possible). - /// The instance to support chaining/fluent. - public CollectionRuleItem DuplicateCheck(Expression> propertyExpression, LText? duplicateText = null) - { - if (_duplicateCheck) - throw new InvalidOperationException($"A {nameof(DuplicateCheck)} can only be specified once."); - - _propertyExpression = PropertyExpression.Create(propertyExpression); - _duplicateText = duplicateText ?? _propertyExpression.Text; - _duplicateCheck = true; - - return this; - } - - /// - /// Performs the duplicate validation check. - /// - /// The . - /// The items to duplicate check. - void ICollectionRuleItem.DuplicateValidation(IPropertyContext context, IEnumerable? items) => DuplicateValidation(context, (IEnumerable?)items); - - /// - /// Performs the duplicate validation check. - /// - /// The . - /// The items to duplicate check. - private void DuplicateValidation(IPropertyContext context, IEnumerable? items) - { - if (!_duplicateCheck || items == null) - return; - - if (_propertyExpression != null) - { - var dict = new Dictionary(); - foreach (var item in items.Where(x => x != null)) - { - var val = _propertyExpression.GetValue(item!); - if (val is not null && !dict.TryAdd(val, item)) - context.CreateErrorMessage(ValidatorStrings.DuplicateValueFormat, _duplicateText!, val!); - } - } - else - { - var dict = new Dictionary(); - foreach (var item in items.Where(x => x != null).Cast()) - { - if (_ignoreWhereKeyIsInitial && item.EntityKey.IsInitial) - continue; - - if (!dict.TryAdd(item.EntityKey, item)) - context.CreateErrorMessage(item.EntityKey.Args.Length == 1 ? ValidatorStrings.DuplicateValueFormat : ValidatorStrings.DuplicateValue2Format, _duplicateText!, item.EntityKey.ToString()); - } - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/CommonRule.cs b/src/CoreEx.Validation/Rules/CommonRule.cs index 9009b5f2..9a7592ff 100644 --- a/src/CoreEx.Validation/Rules/CommonRule.cs +++ b/src/CoreEx.Validation/Rules/CommonRule.cs @@ -1,22 +1,19 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides validation integration. +/// +/// The entity . +/// The property . +/// The . +/// Although named (primary use), the underlying base is supported to enable additional types where applicable. +public sealed class CommonRule(InlineValidator common) : PropertyRuleBase where TEntity : class { - /// - /// Provides for integrating a common validation against a specified property. - /// - /// The entity . - /// The property . - /// The . - internal class CommonRule(CommonValidator commonValidator) : ValueRuleBase where TEntity : class - { - private readonly CommonValidator _commonValidator = commonValidator.ThrowIfNull(nameof(commonValidator)); + private readonly InlineValidator _commonValidator = common.ThrowIfNull(); + + /// + protected override bool ValidateWhenNull => true; - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - => await _commonValidator.ValidateAsync(context, cancellationToken).ConfigureAwait(false); - } + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) => _commonValidator.ValidateAsync(context, cancellationToken); } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ComparePropertyRule.cs b/src/CoreEx.Validation/Rules/ComparePropertyRule.cs index 109eeed4..ddfae348 100644 --- a/src/CoreEx.Validation/Rules/ComparePropertyRule.cs +++ b/src/CoreEx.Validation/Rules/ComparePropertyRule.cs @@ -1,48 +1,57 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using CoreEx.Abstractions.Reflection; -using CoreEx.Localization; -using System; -using System.Linq.Expressions; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides a comparison validation against another property within the same entity; also confirms other property has no errors prior to comparison. +/// +/// The entity . +/// The property . +/// The comparison property . +/// The . +/// >The to reference the compare-to entity property. +/// The value text formatter (used in the error message); otherwise, uses the resulting compare-to value. +/// The optional . +public sealed class ComparePropertyRule(CompareOperator compareOperator, Expression> compareToPropertyExpression, Func? compareToText = null, IComparer? comparer = null) + : CompareRuleBase(compareOperator, compareToText, comparer) where TEntity : class where TProperty : IComparable { - /// - /// Provides a comparision validation against another property within the same entity; also confirms other property has no errors prior to comparison. - /// - /// The entity . - /// The property . - /// The compare to property . - /// The . - /// The to reference the compare to entity property. - /// The compare to text to be passed for the error message (default is to derive the text from the property itself). - public class ComparePropertyRule(CompareOperator compareOperator, Expression> compareToPropertyExpression, LText? compareToText = null) : CompareRuleBase(compareOperator) where TEntity : class + private readonly IPropertyRuntimeMetadata _compareToProperty = RuntimeMetadata.GetForExpression(compareToPropertyExpression.ThrowIfNull()); + + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) { - private readonly PropertyExpression _compareTo = PropertyExpression.Create(compareToPropertyExpression); - private readonly LText? _compareToText = compareToText; + // Make sure not the same property. + if (_compareToProperty.Name == context.Name) + throw new InvalidOperationException($"The compare-to property '{_compareToProperty.Name}' cannot be the same as the property being validated."); + + // Do not continue where the compare-to property is in error. + if (context.HasError(context.CreateFullyQualifiedPropertyName(_compareToProperty.Name))) + return Task.CompletedTask; - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) + // Get the compare to value; where is null then simply skip the comparison. + var compareTo = _compareToProperty.GetValue(context.Entity); + if (compareTo is null) + return Task.CompletedTask; + + // Where compare to is the same type then _fast-path_ the comparison. + if (compareTo is TProperty casted) { - // Do not validate where the compare to property has an error. - if (context.Parent.HasError(_compareTo)) - return Task.CompletedTask; - - // Convert type and compare values. - try - { - var compareToValue = (TProperty)(object)_compareTo.GetValue(context.Parent.Value)!; - if (!Compare(context.Value!, compareToValue)) - CreateErrorMessage(context, _compareToText ?? _compareTo.Text); - - return Task.CompletedTask; - } - catch (InvalidCastException icex) - { - throw new InvalidCastException($"Property '{_compareTo.Name}' and '{context.Name}' are incompatible: {icex.Message}", icex); - } + if (!Compare(context.Value, casted)) + CreateErrorMessage(context, casted); + + return Task.CompletedTask; } + + // Convert (slow-path) the compare-to to the property type and perform the (apples-to-apples) compare. + try + { + var changed = (TProperty)Convert.ChangeType(compareTo, typeof(TProperty)); + if (!Compare(context.Value, changed)) + CreateErrorMessage(context, changed); + } + catch (Exception ex) when (ex is InvalidCastException || ex is FormatException) + { + throw new InvalidCastException($"Property '{_compareToProperty.Name}' and '{context.Name}' are incompatible for a comparison: {ex.Message}", ex); + } + + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/CompareRuleBase.cs b/src/CoreEx.Validation/Rules/CompareRuleBase.cs index 36ffb522..c92e35bd 100644 --- a/src/CoreEx.Validation/Rules/CompareRuleBase.cs +++ b/src/CoreEx.Validation/Rules/CompareRuleBase.cs @@ -1,64 +1,65 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using CoreEx.Localization; -using System; -using System.Collections.Generic; - -namespace CoreEx.Validation.Rules +/// +/// Provides base comparison validation capability. +/// +/// The entity . +/// The property . +/// The . +/// The compare-to value text formatter (used in error message); otherwise, uses the resulting compare to value. +/// The optional . +public abstract class CompareRuleBase(CompareOperator compareOperator, Func? compareToText = null, IComparer? comparer = null) : PropertyRuleBase where TEntity : class where TProperty : IComparable { /// - /// Provides base comparision validation capability. + /// Gets the . /// - /// The entity . - /// The property . - /// The . - public abstract class CompareRuleBase(CompareOperator compareOperator) : ValueRuleBase where TEntity : class - { - /// - /// Gets the . - /// - public CompareOperator Operator { get; private set; } = compareOperator; + protected CompareOperator Operator { get; } = compareOperator; - /// - /// Gets or sets the comparer. - /// - public Comparer Comparer { get; set; } = Comparer.Default; + /// + /// Gets or sets the comparer. + /// + protected IComparer Comparer { get; } = comparer ?? Comparer.Default; - /// - /// Compare two values using the default comparer for the type. - /// - /// The left value. - /// The right value. - /// true where valid; otherwise, false. - protected bool Compare(TProperty? lValue, TProperty? rValue) => Operator switch - { - CompareOperator.Equal => Comparer.Compare(lValue, rValue) == 0, - CompareOperator.NotEqual => Comparer.Compare(lValue, rValue) != 0, - CompareOperator.LessThan => Comparer.Compare(lValue, rValue) < 0, - CompareOperator.LessThanEqual => Comparer.Compare(lValue, rValue) <= 0, - CompareOperator.GreaterThan => Comparer.Compare(lValue, rValue) > 0, - CompareOperator.GreaterThanEqual => Comparer.Compare(lValue, rValue) >= 0, - _ => throw new InvalidOperationException("An invalid Operator value was encountered.") - }; + /// + /// Gets the comparison text formatter (used in error message); otherwise, uses the resulting compare to value. + /// + protected Func? CompareToText { get; } = compareToText; - /// - /// Creates the error message passing the text as the third format parameter (i.e. String.Format("{2}")). - /// - /// The . - /// The compare text to be passed for the error message. - protected void CreateErrorMessage(PropertyContext context, LText compareToText) - { - context.ThrowIfNull(nameof(context)); + /// + /// Compare two values using the . + /// + /// The left value. + /// The right value. + /// where valid; otherwise, . + protected bool Compare(TProperty lValue, TProperty rValue) => Operator switch + { + CompareOperator.Equal => Comparer.Compare(lValue, rValue) == 0, + CompareOperator.NotEqual => Comparer.Compare(lValue, rValue) != 0, + CompareOperator.LessThan => Comparer.Compare(lValue, rValue) < 0, + CompareOperator.LessThanOrEqualTo => Comparer.Compare(lValue, rValue) <= 0, + CompareOperator.GreaterThan => Comparer.Compare(lValue, rValue) > 0, + CompareOperator.GreaterThanOrEqualTo => Comparer.Compare(lValue, rValue) >= 0, + _ => throw new InvalidOperationException("An invalid Operator value was encountered.") + }; + + /// + /// Creates the error message passing the or as the third format parameter (i.e. String.Format("{2}")). + /// + /// The . + /// The compare-to value. + protected void CreateErrorMessage(PropertyContext context, TProperty compareToValue) + { + context.ThrowIfNull(); + var compareToText = CompareToText?.Invoke(compareToValue) ?? context.FormatValue(compareToValue); - switch (Operator) - { - case CompareOperator.Equal: context.CreateErrorMessage(ErrorText ?? ValidatorStrings.CompareEqualFormat, (string)compareToText); break; - case CompareOperator.NotEqual: context.CreateErrorMessage(ErrorText ?? ValidatorStrings.CompareNotEqualFormat, (string)compareToText); break; - case CompareOperator.LessThan: context.CreateErrorMessage(ErrorText ?? ValidatorStrings.CompareLessThanFormat, (string)compareToText); break; - case CompareOperator.LessThanEqual: context.CreateErrorMessage(ErrorText ?? ValidatorStrings.CompareLessThanEqualFormat, (string)compareToText); break; - case CompareOperator.GreaterThan: context.CreateErrorMessage(ErrorText ?? ValidatorStrings.CompareGreaterThanFormat, (string)compareToText); break; - case CompareOperator.GreaterThanEqual: context.CreateErrorMessage(ErrorText ?? ValidatorStrings.CompareGreaterThanEqualFormat, (string)compareToText); break; - } + switch (Operator) + { + case CompareOperator.Equal: context.AddError(ErrorText ?? ValidatorStrings.CompareEqualFormat, compareToText); break; + case CompareOperator.NotEqual: context.AddError(ErrorText ?? ValidatorStrings.CompareNotEqualFormat, compareToText); break; + case CompareOperator.LessThan: context.AddError(ErrorText ?? ValidatorStrings.CompareLessThanFormat, compareToText); break; + case CompareOperator.LessThanOrEqualTo: context.AddError(ErrorText ?? ValidatorStrings.CompareLessThanEqualFormat, compareToText); break; + case CompareOperator.GreaterThan: context.AddError(ErrorText ?? ValidatorStrings.CompareGreaterThanFormat, compareToText); break; + case CompareOperator.GreaterThanOrEqualTo: context.AddError(ErrorText ?? ValidatorStrings.CompareGreaterThanEqualFormat, compareToText); break; } } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/CompareValueRule.cs b/src/CoreEx.Validation/Rules/CompareValueRule.cs index 63e43fa5..291b466f 100644 --- a/src/CoreEx.Validation/Rules/CompareValueRule.cs +++ b/src/CoreEx.Validation/Rules/CompareValueRule.cs @@ -1,82 +1,33 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +using CoreEx.Validation.Abstractions; + +namespace CoreEx.Validation.Rules; + +/// +/// Provides a comparison validation using the specified and . +/// +/// The entity . +/// The property . +/// The . +/// The function to get the compare-to value. +/// The value text formatter (used in the error message); otherwise, uses the resulting value. +/// The optional . +public sealed class CompareValueRule(CompareOperator compareOperator, Func, TProperty> compareToValue, Func? compareToText = null, IComparer? comparer = null) + : CompareRuleBase(compareOperator, compareToText, comparer) where TEntity : class where TProperty : IComparable { - /// - /// Provides a comparision validation against a specified value. - /// - /// The entity . - /// The property . - public class CompareValueRule : CompareRuleBase where TEntity : class - { - private readonly TProperty _compareToValue; - private readonly Func? _compareToValueFunction; - private readonly Func>? _compareToValueFunctionAsync; - private readonly LText? _compareToText; - private readonly Func? _compareToTextFunction; - - /// - /// Initializes a new instance of the class specifying the compare to value. - /// - /// The . - /// The compare to value. - /// The compare to text to be passed for the error message (default is to use ). - public CompareValueRule(CompareOperator compareOperator, TProperty compareToValue, LText? compareToText = null) : base(compareOperator) - { - _compareToValue = compareToValue; - _compareToText = compareToText; - } + private readonly Func, TProperty> _compareToValue = compareToValue.ThrowIfNull(); - /// - /// Initializes a new instance of the class specifying the compare to value function. - /// - /// The . - /// The compare to value function. - /// The compare to text function (default is to use the result of the ). - public CompareValueRule(CompareOperator compareOperator, Func compareToValueFunction, Func? compareToTextFunction = null) : base(compareOperator) - { - _compareToValueFunction = compareToValueFunction.ThrowIfNull(nameof(compareToValueFunction)); - _compareToTextFunction = compareToTextFunction; - _compareToValue = default!; - } - - /// - /// Initializes a new instance of the class specifying the compare to value async function. - /// - /// The . - /// The compare to value function. - /// The compare to text function (default is to use the result of the ). - public CompareValueRule(CompareOperator compareOperator, Func> compareToValueFunctionAsync, Func? compareToTextFunction = null) : base(compareOperator) - { - _compareToValueFunctionAsync = compareToValueFunctionAsync.ThrowIfNull(nameof(compareToValueFunctionAsync)); - _compareToTextFunction = compareToTextFunction; - _compareToValue = default!; - } - - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - context.ThrowIfNull(nameof(context)); - - var compareToValue = _compareToValueFunction != null - ? _compareToValueFunction(context.Parent.Value!) - : (_compareToValueFunctionAsync != null - ? await _compareToValueFunctionAsync(context.Parent.Value!, cancellationToken).ConfigureAwait(false) - : _compareToValue); + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + // Get the compare to value; where is null then simply skip the comparison. + var compareTo = _compareToValue(context); + if (compareTo is null) + return Task.CompletedTask; - if (!Compare(context.Value, compareToValue)) - { - string? compareToText = _compareToText ?? compareToValue?.ToString() ?? new LText("null"); - if (_compareToTextFunction != null) - compareToText = _compareToTextFunction(context.Parent.Value!); + // Perform the comparison. + if (!Compare(context.Value, compareTo)) + CreateErrorMessage(context, compareTo); - CreateErrorMessage(context, compareToText); - } - } + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/CompareValuesRule.cs b/src/CoreEx.Validation/Rules/CompareValuesRule.cs index e6fea326..251da2a4 100644 --- a/src/CoreEx.Validation/Rules/CompareValuesRule.cs +++ b/src/CoreEx.Validation/Rules/CompareValuesRule.cs @@ -1,63 +1,40 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +namespace CoreEx.Validation.Rules; + +/// +/// Provides a comparison validation against one or more values. +/// +/// The entity . +/// The property . +/// The compare-to value(s). +/// The optional . +/// Indicates whether to override the underlying property value with the corresponding matched value. +public sealed class CompareValuesRule(Func, IEnumerable> compareToValues, IEqualityComparer? comparer = null, bool overrideValueWhereMatched = false) + : PropertyRuleBase where TEntity : class where TProperty : IEquatable { - /// - /// Provides a comparision validation against one or more values. - /// - /// The entity . - /// The property . - public class CompareValuesRule : ValueRuleBase where TEntity : class - { - private readonly IEnumerable? _compareToValues; - private readonly Func>>? _compareToValuesFunctionAsync; - - /// - /// Initializes a new instance of the class. - /// - private CompareValuesRule() => ValidateWhenDefault = false; - - /// - /// Initializes a new instance of the class specifying the compare to values (as an ). - /// - /// The compare to values. - public CompareValuesRule(IEnumerable compareToValues) : this() - => _compareToValues = compareToValues.ThrowIfNull(nameof(compareToValues)); + private readonly Func, IEnumerable> _compareToValues = compareToValues.ThrowIfNull(); + private readonly IEqualityComparer _comparer = comparer ?? EqualityComparer.Default; + private readonly bool _overrideValueWhereMatched = overrideValueWhereMatched; - /// - /// Initializes a new instance of the class specifying the compare to values async function (as an ). - /// - /// The compare to values function. - public CompareValuesRule(Func>> compareToValuesFunctionAsync) : this() - => _compareToValuesFunctionAsync = compareToValuesFunctionAsync.ThrowIfNull(nameof(compareToValuesFunctionAsync)); - - /// - /// Gets or sets the . - /// - public IEqualityComparer EqualityComparer { get; set; } = EqualityComparer.Default; + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + // Get the compare to value(s). + var compareToValues = _compareToValues(context); + if (compareToValues is null) + return Task.CompletedTask; - /// - /// Indicates whether to override the underlying property value with the corresponding matched value. - /// - public bool OverrideValue { get; set; } + // Perform the comparison. + if (!compareToValues.Any(v => _comparer.Equals(context.Value, v))) + context.AddError(ErrorText ?? ValidatorStrings.InvalidFormat); - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) + // Override the value where matched, is requested, and is different. + if (_overrideValueWhereMatched) { - context.ThrowIfNull(nameof(context)); - - // Perform the comparison, and override where selected. - var values = _compareToValues != null ? _compareToValues! : await _compareToValuesFunctionAsync!(context.Parent.Value!, cancellationToken).ConfigureAwait(false); - if (!values.Where(x => EqualityComparer.Equals(x, context.Value)).Any()) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.InvalidFormat); - else if (OverrideValue) - context.OverrideValue(values.Where(x => EqualityComparer.Equals(x, context.Value)).First()); + var @override = compareToValues.First(v => _comparer.Equals(context.Value, v)); + if (!EqualityComparer.Default.Equals(@override, context.Value)) + context.Override(@override); } + + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/CustomRule.cs b/src/CoreEx.Validation/Rules/CustomRule.cs deleted file mode 100644 index bf1ebeee..00000000 --- a/src/CoreEx.Validation/Rules/CustomRule.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Results; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides a custom validation against a specified property. - /// - /// The entity . - /// The property . - public class CustomRule : ValueRuleBase where TEntity : class - { - private readonly Func, Result>? _custom; - private readonly Func, CancellationToken, Task>? _customAsync; - - /// - /// Initializes a new instance of the class specifying the corresponding . - /// - /// The function to invoke to perform the custom validation. - public CustomRule(Func, Result> custom) => _custom = custom.ThrowIfNull(nameof(custom)); - - /// - /// Initializes a new instance of the class specifying the corresponding . - /// - /// The function to invoke to perform the custom validation. - public CustomRule(Func, CancellationToken, Task> customAsync) => _customAsync = customAsync.ThrowIfNull(nameof(customAsync)); - - /// - /// Validate the property value. - /// - /// The . - /// The . - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - Result result; - if (_customAsync == null) - result = _custom!(context); - else - result = await _customAsync(context, cancellationToken).ConfigureAwait(false); - - context.Parent.SetFailureResult(result); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/DecimalRule.cs b/src/CoreEx.Validation/Rules/DecimalRule.cs index 0bb4d1cc..d8b3970e 100644 --- a/src/CoreEx.Validation/Rules/DecimalRule.cs +++ b/src/CoreEx.Validation/Rules/DecimalRule.cs @@ -1,90 +1,61 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +namespace CoreEx.Validation.Rules; + +/// +/// Provides a decimal validation to check , and whether negatives are allowed (defaults to , i.e. allowed). +/// +/// The entity . +/// The property . +/// The maximum number of significant digits (including ). +/// The maximum number of decimal places. +/// Indicates whether to allow negative values. +/// For example, to validate a number with the pattern '999.99', then would be 5 and would be 2. +/// Internally converts to the property value to a . Other floating-point types ( and ) are generally not supported as precision might be lost during conversion. +public sealed class DecimalRule(Func,int?>? precision, Func, int?>? scale = null, Func, bool>? allowNegatives = null) : PropertyRuleBase where TEntity : class where TProperty : IFloatingPoint { - /// - /// Represents a numeric rule that validates the maximum (fractional-part length aka scale) and (being the sum of the integer-part and fractional-part lengths aka precision). - /// - /// The entity . - /// The property . - /// Internally converts to the property value to a . Floating-point types ( and ) are generally not supported - /// as precision might be lost during conversion. For more information on integer- and fractional-part see . - public class DecimalRule : NumericRule where TEntity : class - { - private int? _maxDigits; - private int? _decimalPlaces; - - /// - /// Initializes a new instance of the class. - /// - public DecimalRule() => ValidateWhenDefault = false; - - /// - /// Gets or sets the maximum digits being the sum of the integer-part and fractional-part () lengths; also known as precision. - /// - /// For example, to validate a number with the pattern '999.99', then would be 5 and would be 2. Minimum specified value is 1. - public int? MaxDigits - { - get { return _maxDigits; } - - set - { - if (value.HasValue && value.Value < 1) - throw new ArgumentException("Minimum value (where specified) for MaxDigits is 1."); + private readonly Func, int?>? _precision = precision; + private readonly Func, int?>? _scale = scale; + private readonly Func, bool> _allowNegativesFunc = allowNegatives ?? (_ => true); - _maxDigits = value; - } - } + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + var precision = _precision?.Invoke(context); + var scale = _scale?.Invoke(context); + var allowNegatives = _allowNegativesFunc(context); - /// - /// Gets or sets the maximum supported number of decimal places (fractional-part length); also known as scale. - /// - /// Minimum specified value is 0. - public int? DecimalPlaces - { - get { return _decimalPlaces; } + if (precision.HasValue && precision.Value < 1) + throw new InvalidOperationException("Precision minimum value (where specified) is 1."); - set - { - if (value.HasValue && value.Value < 0) - throw new ArgumentException("Minimum value (where specified) for DecimalPlaces is 0."); + if (scale.HasValue && scale.Value < 0) + throw new InvalidOperationException("Scale minimum value (where specified) is 0."); - _decimalPlaces = value; - } - } - - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) + // Validate the scale and/or precision where specified. + if (precision is not null || scale is not null) { // Convert numeric to a decimal value. - decimal value = Convert.ToDecimal(context.Value, System.Globalization.CultureInfo.CurrentCulture); + var value = decimal.CreateChecked(context.Value); + var integralLength = precision.HasValue ? DecimalRuleHelper.CalcIntegralPartLength(value) : 0; + var fractionalLength = precision.HasValue || scale.HasValue ? DecimalRuleHelper.CalcFractionalPartLength(value) : 0; - // Check if negative. - if (!AllowNegatives && value < 0) + // Check the precision. + if (precision.HasValue && !DecimalRuleHelper.CheckPrecision(precision.Value, scale, integralLength, fractionalLength)) { - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.AllowNegativesFormat); + context.AddError(ErrorText ?? ValidatorStrings.MaxDigitsFormat, precision); return Task.CompletedTask; } - int il = MaxDigits.HasValue ? DecimalRuleHelper.CalcIntegerPartLength(value) : 0; - int dp = MaxDigits.HasValue || DecimalPlaces.HasValue ? DecimalRuleHelper.CalcFractionalPartLength(value) : 0; - - // Check max digits. - if (MaxDigits.HasValue && !DecimalRuleHelper.CheckMaxDigits(MaxDigits.Value, DecimalPlaces, il, dp)) + // Check the scale. + if (scale.HasValue && !DecimalRuleHelper.CheckScale(scale.Value, fractionalLength)) { - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MaxDigitsFormat, MaxDigits); + context.AddError(ErrorText ?? ValidatorStrings.DecimalPlacesFormat, scale); return Task.CompletedTask; } + } - // Check decimal places. - if (DecimalPlaces.HasValue && !DecimalRuleHelper.CheckDecimalPlaces(DecimalPlaces.Value, dp)) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.DecimalPlacesFormat, DecimalPlaces); + // Finally, check for negatives. + if (!allowNegatives && TProperty.IsNegative(context.Value)) + context.AddError(ErrorText ?? ValidatorStrings.AllowNegativesFormat); - return Task.CompletedTask; - } + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/DecimalRuleHelper.cs b/src/CoreEx.Validation/Rules/DecimalRuleHelper.cs deleted file mode 100644 index 657b2f2d..00000000 --- a/src/CoreEx.Validation/Rules/DecimalRuleHelper.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; - -namespace CoreEx.Validation.Rules -{ - /// - /// Represents a helper for the . - /// - public static class DecimalRuleHelper - { - /// - /// Checks the for the max digits. - /// - /// The value to check. - /// The maximum digits (including ). - /// The maximum number of decimal places. - /// true where valid; otherwise, false. - public static bool CheckMaxDigits(decimal value, int maxDigits, int? decimalPlaces = null) - { - if (maxDigits < 1) - throw new ArgumentException("MaxDigits must be 1 or greater.", nameof(maxDigits)); - - if (decimalPlaces.HasValue && decimalPlaces.Value < 0) - throw new ArgumentException("DecimalPlaces cannot be negative.", nameof(decimalPlaces)); - - if (value == 0) - return true; - - return CheckMaxDigits(maxDigits, decimalPlaces, CalcIntegerPartLength(value), CalcFractionalPartLength(value)); - } - - /// - /// Checks the max digits. - /// - internal static bool CheckMaxDigits(int maxDigits, int? decimalPlaces, int il, int dp) => (il + (decimalPlaces ?? dp)) <= maxDigits; - - /// - /// Checks the to determine whether the fractional-part length is greater than the specified maximum number of decimal places. - /// - /// The value to check. - /// The maximum number of decimal places. - /// true where valid; otherwise, false. - public static bool CheckDecimalPlaces(decimal value, int decimalPlaces) - { - if (decimalPlaces < 0) - throw new ArgumentException("DecimalPlaces cannot be negative.", nameof(decimalPlaces)); - - if (value == 0) - return true; - - return CheckDecimalPlaces(decimalPlaces, CalcFractionalPartLength(value)); - } - - /// - /// Checks the decimal places. - /// - internal static bool CheckDecimalPlaces(int decimalPlaces, int dp) => dp <= decimalPlaces; - - /// - /// Calculates the integer-part length for a value. - /// - /// The value. - /// The integer-part length. - public static int CalcIntegerPartLength(decimal value) - { - if (value == 0) - return 0; - - var floor = (double)Math.Floor(Math.Abs(value)); - if (floor == 0) - return 0; - - return (int)Math.Floor(Math.Log10(floor)) + 1; - } - - /// - /// Calculates the fractional-part length for a value. - /// - /// The value. - /// The fractional-part length. - public static int CalcFractionalPartLength(decimal value) - { - if (value == 0) - return 0; - - value %= 1; - if (value == 0) - return 0; - - int count = -1; - while (value % 10m != 0m) - { - value *= 10m; - count++; - } - - return count < 0 ? 0 : count; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/DictionaryRule.cs b/src/CoreEx.Validation/Rules/DictionaryRule.cs index 0012a65a..3aeebfc3 100644 --- a/src/CoreEx.Validation/Rules/DictionaryRule.cs +++ b/src/CoreEx.Validation/Rules/DictionaryRule.cs @@ -1,138 +1,232 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; + +/// +/// Provides a dictionary () validation including item-based validation. +/// +/// The entity . +/// The property (). +/// The key . +/// The value . +/// The dictionary validation constrains the key and value to being defined using a to limit usage challenges with this library (lack of generic covariance) limits native +/// support. However, having said that, a does not support null keys anyway. +public sealed class DictionaryRule : PropertyRuleBase where TEntity : class where TProperty : IDictionary where TKey : notnull where TValue : notnull +{ + private readonly Func, int>? _minCount; + private readonly Func, int?>? _maxCount; + private readonly With _with; -using CoreEx.Abstractions.Reflection; -using System; -using System.Collections; -using System.Threading; -using System.Threading.Tasks; + /// + /// Initializes a new instance of the class. + /// + /// The minimum count. + /// The maximum count. + /// Extends configuration . + public DictionaryRule(Func, int>? minCount, Func, int?>? maxCount, Func? with) + { + _minCount = minCount; + _maxCount = maxCount; + + var w = new With(this); + _with = with?.Invoke(w) ?? w; + } + + /// + protected async override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + var minCount = _minCount?.Invoke(context) ?? 0; + var maxCount = _maxCount?.Invoke(context); + + if (minCount < 0) + throw new InvalidOperationException("Minimum count must not be negative."); + + if (maxCount.HasValue) + { + if (maxCount.Value < 0) + throw new InvalidOperationException("Maximum count must not be negative."); + + if (maxCount.Value < minCount) + throw new InvalidOperationException("Maximum count must not be less than minimum count."); + } + + await _with.ValidateAsync(context, minCount, maxCount, cancellationToken).ConfigureAwait(false); + } -namespace CoreEx.Validation.Rules -{ /// - /// Provides dictionary validation including and . + /// Provides additional configuration options for the . /// - /// The entity . - /// The dictionary property . - public class DictionaryRule : ValueRuleBase where TEntity : class where TProperty : IDictionary? + public sealed class With { - private readonly Type _keyType; - private readonly Type _valueType; - private IDictionaryRuleItem? _item; + private readonly DictionaryRule _rule; + private Func>? _getKeyValidator; + private Func>? _getValueValidator; + private bool _hasAllowNullValues; + + /// + /// Initializes a new instance of the class. + /// + internal With(DictionaryRule rule) => _rule = rule; /// - /// Initializes a new instance of the class. + /// Indicates that entries can have a . /// - public DictionaryRule() + /// The to support fluent-style method-chaining. + public With AllowNullValues() { - var (kt, vt) = TypeReflector.GetDictionaryType(typeof(TProperty)); - _keyType = kt!; - _valueType = vt!; + _hasAllowNullValues = true; + return this; } /// - /// Indicates whether the underlying dictionary key can be null. + /// Sets the specified Key . /// - public bool AllowNullKeys { get; set; } + /// The action to configure the . + /// The to support fluent-style method-chaining. + public With WithKeyValidator(Action.Validator>? configure) => WithKeyValidator(ValidatorStrings.KeyText, configure); /// - /// Indicates whether the underlying dictionary value can be null. + /// Sets the specified Key . /// - public bool AllowNullValues { get; set; } + /// The property text. + /// The action to configure the . + /// The to support fluent-style method-chaining. + public With WithKeyValidator(LText text, Action.Validator>? configure) + { + _getKeyValidator = _getKeyValidator is null + ? _ => new ValidatingInlineValidator(configure).WithName(Validation.KeyName).WithText(text) + : throw new InvalidOperationException("The dictionary rule can only have one Key validator."); + + return this; + } /// - /// Gets or sets the minimum count; + /// Sets the specified Value . /// - public int MinCount { get; set; } + /// The to support fluent-style method-chaining. + public With WithValueValidator(Action.Validator>? configure) => WithValueValidator(new ValidatingInlineValidator(configure)); /// - /// Gets or sets the maximum count. + /// Sets the specified Value . /// - public int? MaxCount { get; set; } + /// The to support fluent-style method-chaining. + public With WithValueValidator(IValidatorEx validator) + { + _getValueValidator = _getValueValidator is not null ? throw new InvalidOperationException("The dictionary rule can only have one Value validator.") : _ => validator.ThrowIfNull(); + return this; + } /// - /// Gets or sets the dictionary item validation configuration. + /// Sets the specified Value service (resolved at validation runtime). /// - public IDictionaryRuleItem? Item + /// The property validator . + /// The to support fluent-style method-chaining. + public With WithValueValidator() where TValidator : IValidatorEx { - get => _item; - - set - { - if (value == null) - { - _item = value; - return; - } - - if (_keyType != value.KeyType) - throw new ArgumentException($"A DictionaryRule TProperty KeyType '{_keyType.Name}' must be the same as the Key {value.KeyType.Name}."); - - if (_valueType != value.ValueType) - throw new ArgumentException($"A DictionaryRule TProperty ValueType '{_valueType.Name}' must be the same as the Value {value.ValueType.Name}."); - - _item = value; - } + _getValueValidator = _getValueValidator is not null ? throw new InvalidOperationException("The dictionary rule can only have one Value validator.") : args => CoreEx.Validation.Validator.Get(args.ServiceProvider); + return this; } /// - /// Overrides the Check method and will not validate where performing a shallow validation. + /// Sets the specified keyed Value service (resolved at validation runtime). /// - /// The . - /// true where validation is to continue; otherwise, false to stop. - protected override bool Check(PropertyContext context) => !context.ThrowIfNull(nameof(context)).Parent.ShallowValidation && base.Check(context); - - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) + /// The property validator . + /// The service key. + /// The to support fluent-style method-chaining. + public With WithValueKeyedValidator(object? serviceKey) where TValidator : IValidatorEx { - if (context.Value == null) - return; + _getValueValidator = _getValueValidator is not null ? throw new InvalidOperationException("The dictionary rule can only have one Value validator.") : args => CoreEx.Validation.Validator.GetKeyed(serviceKey, args.ServiceProvider); + return this; + } - // Iterate through the dictionary validating each of the items. - var i = 0; + /// + /// Validates each item within the dictionary. + /// + /// + /// The minimum count. + /// The maximum count. + /// The . + internal async Task ValidateAsync(PropertyContext context, int minCount, int? maxCount, CancellationToken cancellationToken) + { var hasNullKey = false; var hasNullValue = false; - foreach (var item in context.Value) - { - var de = (DictionaryEntry)item; - // Create the context args. - var args = context.CreateValidationArgs(); - var indexer = $"[{de.Key}]"; - args.FullyQualifiedEntityName += indexer; - args.FullyQualifiedJsonEntityName += indexer; - i++; + // Validate each item in the dictionary. + foreach (var kvp in context.Value) + { + bool hasKeyError = false; - if (!AllowNullKeys && de.Key == null) + // Validate the key. + if (kvp.Key is null) hasNullKey = true; + else if (_getKeyValidator is not null) + { + var args = context.CreateValidationArgs(); - if (!AllowNullValues && de.Value == null) - hasNullValue = true; + // Where the key is a string, then set the name on the validator to support better error messages. + var kv = _getKeyValidator.Invoke(args); + if (kvp.Key is string s && !string.IsNullOrEmpty(s) && kv is ValidatingInlineValidator vilv) + vilv.WithName(s); - // Validate and merge. - if (de.Key != null && Item?.KeyValidator != null) - { - var r = await Item.KeyValidator.ValidateAsync(de.Key, args, cancellationToken).ConfigureAwait(false); + // Validate the key and merge the result. + var r = await kv.ValidateAsync(kvp.Key, args, cancellationToken).ConfigureAwait(false); + hasKeyError = r.HasErrors; context.MergeResult(r); } - if (de.Value != null && Item?.ValueValidator != null) + // Validate the value (only where key is considered valid; i.e. not null and passes validation where a validator is specified). + if (!hasKeyError) { - var r = await Item.ValueValidator.ValidateAsync(de.Value, args, cancellationToken).ConfigureAwait(false); - context.MergeResult(r); + if (kvp.Value is null) + hasNullValue = true; + else if (_getValueValidator is not null) + { + var args = CreateValidationArgs(context, kvp.Key?.ToString()); + + var last = context.GetDictionaryKeySafe(); + context.SetDictionaryKey(kvp.Key); + + // Validate the value and merge the result. + try + { + var r = await _getValueValidator.Invoke(args).ValidateAsync(kvp.Value, args, cancellationToken).ConfigureAwait(false); + context.MergeResult(r); + } + finally + { + context.SetDictionaryKey(last); + } + } } } + // Emit the key and/or value error(s). if (hasNullKey) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.DictionaryNullKeyFormat); + context.AddError(_rule.ErrorText ?? ValidatorStrings.DictionaryNullKeyFormat); - if (hasNullValue) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.DictionaryNullValueFormat); + if (hasNullValue && !_hasAllowNullValues) + context.AddError(_rule.ErrorText ?? ValidatorStrings.DictionaryNullValueFormat); // Check the length/count. - if (i < MinCount) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MinCountFormat, MinCount); - else if (MaxCount.HasValue && i > MaxCount.Value) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MaxCountFormat, MaxCount); + var count = context.Value.Count; + if (count < minCount) + context.AddError(_rule.ErrorText ?? ValidatorStrings.MinCountFormat, minCount); + else if (maxCount.HasValue && count > maxCount.Value) + context.AddError(_rule.ErrorText ?? ValidatorStrings.MaxCountFormat, maxCount); + } + + /// + /// Creates the for the specified . + /// + /// The . + /// The dictionary key. + /// The . + private static ValidationArgs CreateValidationArgs(PropertyContext context, string? key) + { + var args = context.CreateValidationArgs(); + args.FullyQualifiedEntityName += $"[{(key ?? "null")}]"; + + // Note: an indexer for a dictionary from a JSON perspective is simply a property name; i.e. no square brackets. + args.FullyQualifiedJsonEntityName += $".{key ?? "null"}"; + return args; } } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/DictionaryRuleItem.cs b/src/CoreEx.Validation/Rules/DictionaryRuleItem.cs deleted file mode 100644 index 0dfa6987..00000000 --- a/src/CoreEx.Validation/Rules/DictionaryRuleItem.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides the means to create a instance. - /// - public static class DictionaryRuleItem - { - /// - /// Create an instance of the class. - /// - /// The key . - /// The value . - /// The corresponding value . - /// The corresponding value . - /// The . - public static DictionaryRuleItem Create(IValidatorEx? key = null, IValidatorEx? value = null) => new(key, value); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/DictionaryRuleItemT.cs b/src/CoreEx.Validation/Rules/DictionaryRuleItemT.cs deleted file mode 100644 index 5a3de44b..00000000 --- a/src/CoreEx.Validation/Rules/DictionaryRuleItemT.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides validation configuration for an item () within a . - /// - /// The key . - /// The value . - public sealed class DictionaryRuleItem : IDictionaryRuleItem - { - /// - /// Initializes a new instance of the class with a corresponding . - /// - /// The corresponding key . - /// The corresponding value . - internal DictionaryRuleItem(IValidatorEx? keyValidator, IValidatorEx? valueValidator) - { - KeyValidator = keyValidator; - ValueValidator = valueValidator; - } - - /// - IValidatorEx? IDictionaryRuleItem.KeyValidator => KeyValidator; - - /// - /// Gets the corresponding value . - /// - public IValidatorEx? KeyValidator { get; private set; } - - /// - IValidatorEx? IDictionaryRuleItem.ValueValidator => ValueValidator; - - /// - /// Gets the corresponding value . - /// - public IValidatorEx? ValueValidator { get; private set; } - - /// - public Type ItemType => typeof(KeyValuePair); - - /// - public Type KeyType => typeof(TKey); - - /// - public Type ValueType => typeof(TValue); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/DuplicateRule.cs b/src/CoreEx.Validation/Rules/DuplicateRule.cs deleted file mode 100644 index ca787b63..00000000 --- a/src/CoreEx.Validation/Rules/DuplicateRule.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides validation where the rule predicate must return false to not be considered a duplicate. - /// - /// The entity . - /// The property . - public class DuplicateRule : ValueRuleBase where TEntity : class - { - private readonly Predicate? _predicate; - private readonly Func? _duplicate; - - /// - /// Initializes a new instance of the class with a . - /// - /// The must predicate. - public DuplicateRule(Predicate predicate) => _predicate = predicate.ThrowIfNull(nameof(predicate)); - - /// - /// Initializes a new instance of the class with a function. - /// - /// The duplicate function. - public DuplicateRule(Func duplicate) => _duplicate = duplicate.ThrowIfNull(nameof(duplicate)); - - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - if (_predicate != null) - { - if (_predicate(context.Parent.Value!)) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.DuplicateFormat); - } - else - { - if (_duplicate!()) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.DuplicateFormat); - } - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/EmailRule.cs b/src/CoreEx.Validation/Rules/EmailRule.cs index 817d7c87..64ebbbd9 100644 --- a/src/CoreEx.Validation/Rules/EmailRule.cs +++ b/src/CoreEx.Validation/Rules/EmailRule.cs @@ -1,42 +1,26 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using System.ComponentModel.DataAnnotations; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides an e-mail validation. +/// +/// The entity . +/// The optional maximum string length. +public sealed class EmailRule(Func, int?>? maxLength) : PropertyRuleBase where TEntity : class { - /// - /// Provides validation for an e-mail using the to perform the underlying validation. - /// - /// The entity . - public class EmailRule : ValueRuleBase where TEntity : class - { - private static readonly EmailAddressAttribute _emailChecker = new(); - - /// - /// Initializes a new instance of the class. - /// - public EmailRule() => ValidateWhenDefault = false; - - /// - /// Gets or sets the maximum length. - /// - public int? MaxLength { get; set; } + private readonly Func, int?>? _maxLength = maxLength; - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellation) + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + if (!System.Net.Mail.MailAddress.TryCreate(context.Value, out _)) + context.AddError(ErrorText ?? ValidatorStrings.EmailFormat); + else if (_maxLength is not null) { - if (!_emailChecker.IsValid(context.Value)) - { - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.EmailFormat); - return Task.CompletedTask; - } - - if (MaxLength.HasValue && context.Value!.Length > MaxLength.Value) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MaxLengthFormat, MaxLength); - - return Task.CompletedTask; + var maxLength = _maxLength(context); + if (maxLength.HasValue && context.Value!.Length > maxLength.Value) + context.AddError(ErrorText ?? ValidatorStrings.MaxLengthFormat, maxLength.Value); } + + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/EntityRule.cs b/src/CoreEx.Validation/Rules/EntityRule.cs index 37863181..08ef04dc 100644 --- a/src/CoreEx.Validation/Rules/EntityRule.cs +++ b/src/CoreEx.Validation/Rules/EntityRule.cs @@ -1,50 +1,83 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides entity validation. +/// +/// The entity . +/// The property . +public sealed class EntityRule : PropertyRuleBase where TEntity : class where TProperty : class? { + internal Func, ValidationArgs, CancellationToken, Task>? _validationAsync; + /// - /// Provides entity validation. + /// Initializes a new instance of the class. /// - /// The entity . - /// The property . - /// The property validator . - /// The . - public class EntityRule(TValidator validator) : ValueRuleBase where TEntity : class where TProperty : class? where TValidator : IValidatorEx + /// Extends configuration . + public EntityRule(Action with) + { + var erw = new With(this); + with?.Invoke(erw); + } + + /// + protected override async Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) { + var vr = await _validationAsync.ThrowIfNull().Invoke(context, context.CreateValidationArgs(), cancellationToken).ConfigureAwait(false); + context.MergeResult(vr); + } + + /// + /// Provides additional configuration options for the . + /// + public class With + { + private readonly EntityRule _rule; + /// - /// Gets the . + /// Initializes a new instance of the class. /// - public TValidator Validator { get; private set; } = validator.ThrowIfNull(nameof(validator)); + /// The owning . + internal With(EntityRule rule) => _rule = rule; /// - /// Overrides the Check method and will not validate where performing a shallow validation. + /// Sets the specified . /// - /// The . - /// true where validation is to continue; otherwise, false to stop. - protected override bool Check(PropertyContext context) => !context.ThrowIfNull(nameof(context)).Parent.ShallowValidation && base.Check(context); + public void WithValidator(Action.Validator>? configure) => WithValidator(new ValidatingInlineValidator(configure)); - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) + /// + /// Sets the specified . + /// + public void WithValidator(IValidatorEx validator) { - // Exit where nothing to validate. - if (context.Value == null) - return; - - if (Validator is CommonValidator vp) // Common validators need the originating context for best results. + validator.ThrowIfNull(); + _rule._validationAsync = async (context, args, cancellationToken) => { - await vp.ValidateAsync(context, cancellationToken).ConfigureAwait(false); - return; - } + var value = context.Metadata.GetValue(context.Entity); + return await validator.ValidateAsync(value, args, cancellationToken).ConfigureAwait(false); + }; + } - // Create the context args. - var args = context.CreateValidationArgs(); + /// + /// Sets the specified . + /// + /// The property validator . + public void WithValidator() where TValidator : IValidatorEx => _rule._validationAsync = async (context, args, cancellationToken) => + { + var validator = Validator.Get(args.ServiceProvider); + var value = context.Metadata.GetValue(context.Entity); + return await validator.ValidateAsync(value, args, cancellationToken).ConfigureAwait(false); + }; - // Validate and merge. - context.MergeResult(await Validator.ValidateAsync(context.Value, args, cancellationToken).ConfigureAwait(false)); - } + /// + /// Sets the specified keyed . + /// + /// The property validator . + /// The service key. + public void WithKeyedValidator(object? serviceKey) where TValidator : IValidatorEx => _rule._validationAsync = async (context, args, cancellationToken) => + { + var validator = Validator.GetKeyed(serviceKey, args.ServiceProvider); + var value = context.Metadata.GetValue(context.Entity); + return await validator.ValidateAsync(value, args, cancellationToken).ConfigureAwait(false); + }; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/EntityRuleWith.cs b/src/CoreEx.Validation/Rules/EntityRuleWith.cs deleted file mode 100644 index 02f1e36d..00000000 --- a/src/CoreEx.Validation/Rules/EntityRuleWith.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides a means to add an using a validator a specified validator . - /// - /// The entity . - /// The property . - /// The parent . - public class EntityRuleWith(IPropertyRule parent) where TEntity : class where TProperty : class? - { - private readonly IPropertyRule _parent = parent.ThrowIfNull(nameof(parent)); - - /// - /// Adds an using a validator a specified . - /// - /// The property validator . - /// The ; defaults to where not specified. - /// A . - public IPropertyRule With(IServiceProvider? serviceProvider = null) where TValidator : IValidatorEx - { - _parent.AddRule(new EntityRule(Validator.Create(serviceProvider))); - return _parent; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/EnumRule.cs b/src/CoreEx.Validation/Rules/EnumRule.cs index 44de7a81..79368f99 100644 --- a/src/CoreEx.Validation/Rules/EnumRule.cs +++ b/src/CoreEx.Validation/Rules/EnumRule.cs @@ -1,35 +1,34 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides an validation. +/// +/// The entity . +/// The property . +/// An optional list of allowed values. +public class EnumRule(Func, TProperty[]?>? allowed) : PropertyRuleBase where TEntity : class where TProperty : struct, Enum { - /// - /// Provides validation to ensure that the value has been defined. - /// - /// The entity . - /// The property . - public class EnumRule : ValueRuleBase where TEntity : class where TProperty : struct, Enum + private readonly Func, TProperty[]?>? _allowed = allowed; + + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) { - /// - /// Initializes a new instance of the class. - /// - public EnumRule() => ValidateWhenDefault = false; + if (!Enum.IsDefined(context.Value)) + context.AddError(ErrorText ?? ValidatorStrings.InvalidFormat); - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) + if (_allowed is not null) { - // Make sure the enum is defined. -#if NET6_0_OR_GREATER - if (!Enum.IsDefined(context.Value)) -#else - if (!Enum.IsDefined(typeof(TProperty), context.Value)) -#endif - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.InvalidFormat); - - return Task.CompletedTask; + var allowed = _allowed(context); + if (allowed is not null && allowed.Length > 0 && !allowed.Contains(context.Value)) + context.AddError(ErrorText ?? ValidatorStrings.InvalidFormat); } + + return Task.CompletedTask; } + + /// + /// Provides a corresponding validation.. + /// + /// An optional list of allowed values. + public sealed class NullableRule(Func, TProperty[]?>? allowed) : EnumRule(allowed) { } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/EnumStringRule.cs b/src/CoreEx.Validation/Rules/EnumStringRule.cs new file mode 100644 index 00000000..8d9a84f3 --- /dev/null +++ b/src/CoreEx.Validation/Rules/EnumStringRule.cs @@ -0,0 +1,99 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Provides an validation. +/// +/// The entity . +public class EnumStringRule : PropertyRuleBase where TEntity : class +{ + private readonly EnumWith _with; + + /// + /// Initializes a new instance of the class. + /// + /// Extended configuration. + public EnumStringRule(Func with) + { + _with = new EnumWith(this); + with.ThrowIfNull().Invoke(_with); + } + + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + if (_with.Validator is not null && !_with.Validator(context)) + context.AddError(ErrorText ?? ValidatorStrings.InvalidFormat); + + return Task.CompletedTask; + } + + /// + /// Provides additional configuration options for the . + /// + public class EnumWith + { + private readonly EnumStringRule _rule; + private bool _ignoreCasing; + private bool _overrideValue; + + /// + /// Initializes a new instance of the class. + /// + internal EnumWith(EnumStringRule rule) => _rule = rule; + + /// + /// Gets the configured validator. + /// + internal Func, bool>? Validator { get; set; } + + /// + /// Indicates whether to ignore casing when parsing the value. + /// + public EnumWith IgnoreCase() + { + _ignoreCasing = true; + return this; + } + + /// + /// Indicates whether the value should be overridden with the parsed value. + /// + /// The value must be mutable otherwise an will be thrown at runtime. + public EnumWith Override() + { + _overrideValue = true; + return this; + } + + /// + /// Sets the used to validate the value. + /// + /// The . + /// An optional list of allowed values. + public EnumWith With(params TEnum[] allowed) where TEnum : struct, Enum + { + Validator = Validator is not null ? throw new InvalidOperationException("The Enum rule can only have one validator.") : (context) => + { + if (!Enum.TryParse(context.Value, _ignoreCasing, out var @enum)) + return false; + + if (allowed is not null && allowed.Length > 0 && !allowed.Contains(@enum)) + return false; + + if (_overrideValue) + context.Override(@enum.ToString()); + + return true; + }; + + return this; + } + + /// + /// Sets the used to validate the value. + /// + /// The . + /// An optional list of allowed values. + public EnumWith With(IEnumerable allowed) where TEnum : struct, Enum => With(allowed?.ToArray() ?? []); + } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/EnumValueRule.cs b/src/CoreEx.Validation/Rules/EnumValueRule.cs deleted file mode 100644 index 19781a79..00000000 --- a/src/CoreEx.Validation/Rules/EnumValueRule.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides validation against an value. - /// - /// The entity . - /// The corresponding . - public class EnumValueRule : ValueRuleBase where TEntity : class where TEnum : struct, Enum - { - /// - /// Initializes a new class. - /// - public EnumValueRule() => ValidateWhenDefault = false; - - /// - /// Indicates whether to ignore the casing of the value when parsing the . - /// - public bool IgnoreCase { get; set; } - - /// - /// Indicates whether to override the underlying property value with the corresponding name. - /// - /// This is only applicable where is true. - public bool OverrideValue { get; set; } - - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellation) - { - if (!Enum.TryParse(context.Value!, IgnoreCase, out var val)) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.InvalidFormat); - else if (IgnoreCase && OverrideValue) - context.OverrideValue(val.ToString()); - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/EnumValueRuleAs.cs b/src/CoreEx.Validation/Rules/EnumValueRuleAs.cs deleted file mode 100644 index c85052d6..00000000 --- a/src/CoreEx.Validation/Rules/EnumValueRuleAs.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using System; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides a means to add an a specified . - /// - /// The entity . - /// The parent . - public class EnumValueRuleAs(IPropertyRule parent) where TEntity : class - { - private readonly IPropertyRule _parent = parent.ThrowIfNull(nameof(parent)); - - /// - /// Adds an using a validator a specified . - /// - /// The property . - /// Indicates whether to ignore the casing of the value when parsing the . - /// Indicates whether to override the underlying property value with the corresponding name. - /// The error message format text (overrides the default). - /// A . - public IPropertyRule As(bool ignoreCase = false, bool overrideValue = false, LText? errorText = null) where TEnum : struct, Enum - { - _parent.AddRule(new EnumValueRule { IgnoreCase = ignoreCase, OverrideValue = overrideValue, ErrorText = errorText }); - return _parent; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ErrorRule.cs b/src/CoreEx.Validation/Rules/ErrorRule.cs new file mode 100644 index 00000000..4cc41fbf --- /dev/null +++ b/src/CoreEx.Validation/Rules/ErrorRule.cs @@ -0,0 +1,19 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Provides a validation rule that will always emit an error; unless, a succeeding conditional clause prevents it. +/// +/// The entity . +/// The property . +/// The resulting default error . +public class ErrorRule(LText errorText) : PropertyRuleBase where TEntity : class +{ + private readonly LText _errorText = errorText; + + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + context.AddError(ErrorText ?? _errorText); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ExistsRule.cs b/src/CoreEx.Validation/Rules/ExistsRule.cs deleted file mode 100644 index 06643fbe..00000000 --- a/src/CoreEx.Validation/Rules/ExistsRule.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Http; -using CoreEx.Results; -using System; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides validation where the rule predicate must return true or a value to verify it exists. - /// - /// The entity . - /// The property . - public class ExistsRule : ValueRuleBase where TEntity : class - { - private readonly Predicate? _predicate; - private readonly Func>? _exists; - private readonly Func>? _existsNotNull; - private readonly Func>? _httpResult; - - /// - /// Initializes a new instance of the class with a . - /// - /// The must predicate. - public ExistsRule(Predicate predicate) => _predicate = predicate.ThrowIfNull(nameof(predicate)); - - /// - /// Initializes a new instance of the class with an function that must return true. - /// - /// The exists function. - public ExistsRule(Func> exists) => _exists = exists.ThrowIfNull(nameof(exists)); - - /// - /// Initializes a new instance of the class with an function that must return a value. - /// - /// The exists function. - /// Where the resultant value is an then existence is confirmed when and the the underlying is not null. - public ExistsRule(Func> exists) => _existsNotNull = exists.ThrowIfNull(nameof(exists)); - - /// - /// Initializes a new instance of the class with an function that must return a successful . - /// - /// The function. - /// A result of implies exists, whilst a of does not. - /// Any other status code will result in the underlying being invoked resulting in an - /// appropriate exception being thrown. - public ExistsRule(Func> httpResult) => _httpResult = httpResult.ThrowIfNull(nameof(httpResult)); - - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - if (_predicate != null) - { - if (!_predicate(context.Parent.Value!)) - CreateErrorMessage(context); - } - else if (_exists != null) - { - if (!await _exists(context.Parent.Value!, cancellationToken).ConfigureAwait(false)) - CreateErrorMessage(context); - } - else if (_httpResult != null) - { - var r = await _httpResult(context.Parent.Value!, cancellationToken).ConfigureAwait(false); - if (r == null || r.Response == null) - throw new InvalidOperationException("The HttpResult value is in an invalid state; the underlying Response property must not be null."); - - if (!r.IsSuccess) - { - if (r.StatusCode == HttpStatusCode.NotFound) - CreateErrorMessage(context); - else - r.Response.EnsureSuccessStatusCode(); - } - } - else - { - var value = await _existsNotNull!(context.Parent.Value!, cancellationToken).ConfigureAwait(false); - if (value == null) - CreateErrorMessage(context); - - if (value is IResult ir) - { - if (ir.IsFailure) - context.Parent.SetFailureResult(new Result(ir.Error)); - - if (ir.Value is null) - CreateErrorMessage(context); - } - } - } - - /// - /// Create the error message. - /// - private void CreateErrorMessage(PropertyContext context) => context.CreateErrorMessage(ErrorText ?? ValidatorStrings.ExistsFormat); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ICollectionRuleItem.cs b/src/CoreEx.Validation/Rules/ICollectionRuleItem.cs deleted file mode 100644 index 76964243..00000000 --- a/src/CoreEx.Validation/Rules/ICollectionRuleItem.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections; - -namespace CoreEx.Validation.Rules -{ - /// - /// Enables the validation configuration for an item within a . - /// - public interface ICollectionRuleItem - { - /// - /// Gets the corresponding item . - /// - IValidatorEx? ItemValidator { get; } - - /// - /// Gets the item . - /// - Type ItemType { get; } - - /// - /// Performs the duplicate validation check. - /// - /// The . - /// The items to duplicate check. - void DuplicateValidation(IPropertyContext context, IEnumerable? items); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/IDictionaryRuleItem.cs b/src/CoreEx.Validation/Rules/IDictionaryRuleItem.cs deleted file mode 100644 index 42e25b2c..00000000 --- a/src/CoreEx.Validation/Rules/IDictionaryRuleItem.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; - -namespace CoreEx.Validation.Rules -{ - /// - /// Enables the validation configuration for an item () within a . - /// - public interface IDictionaryRuleItem - { - /// - /// Gets the corresponding key . - /// - IValidatorEx? KeyValidator { get; } - - /// - /// Gets the corresponding value . - /// - IValidatorEx? ValueValidator { get; } - - /// - /// Gets the item . - /// - Type ItemType { get; } - - /// - /// Gets the key . - /// - Type KeyType { get; } - - /// - /// Gets the value . - /// - Type ValueType { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/IPropertyRuleEx.cs b/src/CoreEx.Validation/Rules/IPropertyRuleEx.cs new file mode 100644 index 00000000..4d4c6408 --- /dev/null +++ b/src/CoreEx.Validation/Rules/IPropertyRuleEx.cs @@ -0,0 +1,16 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Enables an extended property rule for an entity and property. +/// +/// The entity . +/// The property . +public interface IPropertyRuleEx : IPropertyRule where TEntity : class +{ + /// + /// Validates the value. + /// + /// The . + /// The . + Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/IPropertyRuleT.cs b/src/CoreEx.Validation/Rules/IPropertyRuleT.cs new file mode 100644 index 00000000..466d56f6 --- /dev/null +++ b/src/CoreEx.Validation/Rules/IPropertyRuleT.cs @@ -0,0 +1,34 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Enables a property rule for an entity and property. +/// +/// The entity . +public interface IPropertyRule where TEntity : class +{ + /// + /// Gets the error message format text (overrides the default) used for all property validation errors. + /// + LText? ErrorText { get; } + + /// + /// Adds an . + /// + /// The . + void AddClause(IPropertyClause clause); + + /// + /// Chains an extending the current configuration. + /// + /// The . + /// The chained . + /// Chains an additional rule that is executed where the is successful. + void Chain(IPropertyRule rule); + + /// + /// Validates the value. + /// + /// The . + /// The . + Task ValidateAsync(IPropertyContext context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/IPropertyRuleT2.cs b/src/CoreEx.Validation/Rules/IPropertyRuleT2.cs new file mode 100644 index 00000000..b10f79f3 --- /dev/null +++ b/src/CoreEx.Validation/Rules/IPropertyRuleT2.cs @@ -0,0 +1,16 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Enables a property rule for an entity and property. +/// +/// The entity . +/// The property . +public interface IPropertyRule : IPropertyRule where TEntity : class +{ + /// + /// Overrides the error message for the rule. + /// + /// The error . + /// The to support fluent-style method-chaining. + IPropertyRule WithMessage(LText errorText); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/IRootPropertyRuleT.cs b/src/CoreEx.Validation/Rules/IRootPropertyRuleT.cs new file mode 100644 index 00000000..a4d6cf8e --- /dev/null +++ b/src/CoreEx.Validation/Rules/IRootPropertyRuleT.cs @@ -0,0 +1,50 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Enables root property rule capabilities. +/// +/// The entity . +public interface IRootPropertyRule : IPropertyRule where TEntity : class +{ + /// + /// Indicates whether the originating property type is . + /// + bool IsValueNullable { get; } + + /// + /// Gets the originating property or (where ). + /// + /// The property . + /// The entity value. + /// The originating property or . + T GetNullableValueOrDefault(TEntity entity); + + /// + /// Indicates whether the originating property is (where ). + /// + /// The entity value. + /// where ; otherwise, . + bool IsNullableValueDefault(TEntity entity); + + /// + /// Sets (overrides) the property text to be used within any error message. + /// + /// The property . + void SetText(LText? text); + + /// + /// Sets (overrides) the format to use when localizing the property value within any error message. + /// + /// The format. + /// The optional . + /// The quoting character so it appears as a literal string. + /// The underlying property type must implement as this results in being used. + void SetFormat(string? format, IFormatProvider? formatProvider, char? quotingCharacter); + + /// + /// Validates the property value. + /// + /// The . + /// The . + Task ValidateAsync(ValidationContext context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/IRootPropertyRuleT2.cs b/src/CoreEx.Validation/Rules/IRootPropertyRuleT2.cs new file mode 100644 index 00000000..c57f7ab4 --- /dev/null +++ b/src/CoreEx.Validation/Rules/IRootPropertyRuleT2.cs @@ -0,0 +1,8 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Enables root property rule capabilities. +/// +/// The entity . +/// The property . +public interface IRootPropertyRule : IRootPropertyRule, IPropertyRule where TEntity : class { } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/IValueRule.cs b/src/CoreEx.Validation/Rules/IValueRule.cs deleted file mode 100644 index a3d06b9d..00000000 --- a/src/CoreEx.Validation/Rules/IValueRule.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using CoreEx.Validation.Clauses; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides the rule to validate a value. - /// - /// The entity . - /// The value . - public interface IValueRule where TEntity : class - { - /// - /// Gets or sets the error message format text (overrides the default). - /// - LText? ErrorText { get; set; } - - /// - /// Adds a . - /// - /// The . - void AddClause(IPropertyRuleClause clause); - - /// - /// Checks the clauses. - /// - /// The . - /// true where validation is to continue; otherwise, false to stop. - bool Check(IPropertyContext context); - - /// - /// Validate the value. - /// - /// The . - /// The . - Task ValidateAsync(IPropertyContext context, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ImmutableRule.cs b/src/CoreEx.Validation/Rules/ImmutableRule.cs deleted file mode 100644 index 66656e13..00000000 --- a/src/CoreEx.Validation/Rules/ImmutableRule.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides validation where the immutable rule predicate must return true to be considered valid. - /// - /// The entity . - /// The property . - public class ImmutableRule : ValueRuleBase where TEntity : class - { - private readonly Predicate? _predicate; - private readonly Func? _immutable; - private readonly Func>? _immutableAsync; - - /// - /// Initializes a new instance of the class with a . - /// - /// The must predicate. - public ImmutableRule(Predicate predicate) => _predicate = predicate.ThrowIfNull(nameof(predicate)); - - /// - /// Initializes a new instance of the class with an function. - /// - /// The immutable function. - public ImmutableRule(Func immutable) => _immutable = immutable.ThrowIfNull(nameof(immutable)); - - /// - /// Initializes a new instance of the class with an function. - /// - /// The immutable function. - public ImmutableRule(Func> immutableAsync) => _immutableAsync = immutableAsync.ThrowIfNull(nameof(immutableAsync)); - - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - if (_predicate != null) - { - if (!_predicate.Invoke(context.Parent.Value!)) - CreateErrorMessage(context); - } - else if (_immutable != null) - { - if (!_immutable.Invoke()) - CreateErrorMessage(context); - } - else - { - if (!await _immutableAsync!.Invoke(cancellationToken).ConfigureAwait(false)) - CreateErrorMessage(context); - } - } - - /// - /// Create the error message. - /// - private void CreateErrorMessage(PropertyContext context) => context.CreateErrorMessage(ErrorText ?? ValidatorStrings.ImmutableFormat); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/IncludeBaseRule.cs b/src/CoreEx.Validation/Rules/IncludeBaseRule.cs new file mode 100644 index 00000000..f896fc5c --- /dev/null +++ b/src/CoreEx.Validation/Rules/IncludeBaseRule.cs @@ -0,0 +1,60 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Represents a rule that enables a base validator to be included. +/// +/// The entity . +/// The base validator. +/// Implements to enable usage for the likes of ; however, acts as a pass-through proxy therefore will largely throw . +internal class IncludeBaseRule(IValidatorEx validator) : IRootPropertyRule where TEntity : class +{ + private readonly IValidatorEx _validator = validator.ThrowIfNull(); + + /// + bool IRootPropertyRule.IsValueNullable => throw new NotImplementedException(); + + /// + LText? IPropertyRule.ErrorText { get => throw new NotImplementedException(); } + + /// + public void SetText(LText? text) => throw new NotImplementedException(); + + /// + void IPropertyRule.AddClause(IPropertyClause clause) => throw new NotImplementedException(); + + /// + void IPropertyRule.Chain(IPropertyRule rule) => throw new NotImplementedException(); + + /// + T IRootPropertyRule.GetNullableValueOrDefault(TEntity entity) => throw new NotImplementedException(); + + /// + bool IRootPropertyRule.IsNullableValueDefault(TEntity entity) => throw new NotImplementedException(); + + /// + void IRootPropertyRule.SetFormat(string? format, IFormatProvider? formatProvider, char? quotingCharacter) => throw new NotImplementedException(); + + /// + async Task IRootPropertyRule.ValidateAsync(ValidationContext context, CancellationToken cancellationToken) + { + // Create the args to pass through. + var args = new ValidationArgs + { + FullyQualifiedEntityName = context.FullyQualifiedEntityName, + FullyQualifiedJsonEntityName = context.FullyQualifiedJsonEntityName, + UseJsonNames = context.UseJsonNames, + Parameters = context.Parameters, + JsonSerializerOptions = context.JsonSerializerOptions, + ServiceProvider = context.ServiceProvider + }; + + // Validate. + var vr = await _validator.ValidateAsync(context.Value, args, cancellationToken).ConfigureAwait(false); + + // Merge results. + context.MergeResult(vr); + } + + /// + Task IPropertyRule.ValidateAsync(IPropertyContext context, CancellationToken cancellationToken) => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/InteropRule.cs b/src/CoreEx.Validation/Rules/InteropRule.cs index 0059deee..9f3b76ee 100644 --- a/src/CoreEx.Validation/Rules/InteropRule.cs +++ b/src/CoreEx.Validation/Rules/InteropRule.cs @@ -1,73 +1,32 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +namespace CoreEx.Validation.Rules; + +/// +/// Provides an interop validation rule; intended for non-CoreEx.Validation. +/// +/// The entity . +/// The property . +/// The function to get the . +/// Indicates whether the validation will be performed where the property value is . +public class InteropRule(Func getValidator, bool validateWhenNull) : PropertyRuleBase where TEntity : class { - /// - /// Provides interop validation to a base (intended for non-CoreEx.Validation). - /// - /// The entity . - /// The property . - /// The property validator . - public class InteropRule : ValueRuleBase where TEntity : class where TProperty : class? where TValidator : IValidator - { - /// - /// Initializes a new instance of the class. - /// - /// The function to return the . - public InteropRule(Func validatorFunc) - { - ValidatorFunc = validatorFunc.ThrowIfNull(nameof(validatorFunc)); - if (validatorFunc is IValidatorEx) - throw new ArgumentException($"{ValidatorFunc.GetType().Name} implements {typeof(IValidatorEx).Name} and as such must use {typeof(EntityRule<,,>).Name}.", nameof(validatorFunc)); - } - - /// - /// Gets the function. - /// - public Func ValidatorFunc { get; private set; } - - /// - /// Overrides the Check method and will not validate where performing a shallow validation. - /// - /// The . - /// true where validation is to continue; otherwise, false to stop. - protected override bool Check(PropertyContext context) => !context.ThrowIfNull(nameof(context)).Parent.ShallowValidation && base.Check(context); + private readonly Func _getValidator = getValidator.ThrowIfNull(); - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - // Exit where nothing to validate. - if (context.Value == null) - return; + /// + protected override bool ValidateWhenNull => validateWhenNull; - var v = ValidatorFunc() ?? throw new InvalidOperationException($"The {nameof(ValidatorFunc)} must return a non-null value."); - if (v is CommonValidator cv) // Common validators need the originating context for best results. - { - await cv.ValidateAsync(context, cancellationToken).ConfigureAwait(false); - return; - } - - if (v is IValidatorEx vex) // Use the "better" validator to enable. - { - // Create the context args. - var args = context.CreateValidationArgs(); - - // Validate and merge. - context.MergeResult(await vex.ValidateAsync(context.Value, args, cancellationToken).ConfigureAwait(false)); - return; - } - - // Validate and merge using basic "interop". - var ir = await v.ValidateAsync(context.Value, cancellationToken).ConfigureAwait(false); + /// + protected async override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + var validator = _getValidator() ?? throw new InvalidOperationException("The get validator function must not return null."); + IValidationResult vr; - if (ir.HasErrors) - context.HasError = true; + if (validator is IValidatorEx vex) + vr = await vex.ValidateAsync(context.Value, context.CreateValidationArgs(), cancellationToken).ConfigureAwait(false); + else if (validator is IValidator vtx) + vr = await vtx.ValidateAsync(context.Value, cancellationToken).ConfigureAwait(false); + else + vr = await validator.ValidateAsync(context.Value, cancellationToken).ConfigureAwait(false); - context.Parent.MergeResult(ir.Messages); - } + context.MergeResult(vr); } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/MandatoryRule.cs b/src/CoreEx.Validation/Rules/MandatoryRule.cs index 31158247..02c2e171 100644 --- a/src/CoreEx.Validation/Rules/MandatoryRule.cs +++ b/src/CoreEx.Validation/Rules/MandatoryRule.cs @@ -1,56 +1,69 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides a mandatory validation rule; determined as mandatory when is or it equals its default/empty state. +/// +/// The entity . +/// The property . +/// Indicates that a validation error should occur when the value is . +/// Indicates that a validation error should occur when the value is considered empty. +/// A value will be determined as mandatory when it equals its . For example an will error when the value is zero; however, a +/// will error only when . A zero is considered non- and will succeed, unless is used. +/// For a mandatory is determined by being zero or resulting in . +/// Finally, a and mandatory is determined by whether they contain any items. +public sealed class MandatoryRule(Func, bool> mustNotBeDefault, Func, bool> mustNotBeEmpty) : PropertyRuleBase where TEntity : class { - /// - /// Provides mandatory validation; determined as mandatory when it equals its default value. - /// - /// The entity . - /// The property . - /// A value will be determined as mandatory when it equals its default value. For example an will trigger when the value is zero; however, a - /// will trigger when null only (a zero is considered a value in this instance). - /// For a mandatory is determined by being zero or resulting in true. - public class MandatoryRule : ValueRuleBase where TEntity : class + private readonly Func, bool> _mustNotBeDefault = mustNotBeDefault.ThrowIfNull(); + private readonly Func, bool> _mustNotBeEmpty = mustNotBeEmpty.ThrowIfNull(); + + /// + protected override bool ValidateWhenNull => true; + + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) { - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - // Compare the value against its default. - if (Comparer.Default.Compare(context.Value, default!) == 0) - return CreateErrorMessageAsync(context); - - // Also check for empty strings. - if (context.Value is string val && (val.Length == 0 || string.IsNullOrWhiteSpace(val))) - return CreateErrorMessageAsync(context); - - // Also check for empty collections. - if (context.Value is ICollection coll && coll.Count == 0) - return CreateErrorMessageAsync(context); - - // Also check for empty enumerables. - if (context.Value is IEnumerable enumerable) - { - var enumerator = enumerable.GetEnumerator(); - if (!enumerator.MoveNext()) - return CreateErrorMessageAsync(context); - } + // Check for null. + if (context.IsValueNull) + return AddError(context); + + var mustNotBeDefault = _mustNotBeDefault(context); + var mustNotBeEmpty = _mustNotBeEmpty(context); + + // Compare the value against its default. + if (mustNotBeDefault && context.IsValueNullable && context.IsNullableValueDefault()) + return AddError(context); + if (mustNotBeDefault && !context.IsValueNullable && Comparer.Default.Compare(context.Value, default) == 0) + return AddError(context); + + if (!mustNotBeEmpty) return Task.CompletedTask; - } - /// - /// Create the error message. - /// - private Task CreateErrorMessageAsync(PropertyContext context) + // Also check for empty strings. + if (context.Value is string val) + return val is null || val.Length == 0 || string.IsNullOrWhiteSpace(val) ? AddError(context) : Task.CompletedTask; + + // Also check for empty collections. + if (context.Value is ICollection coll && coll.Count == 0) + return coll.Count == 0 ? AddError(context) : Task.CompletedTask; + + // Also check for empty enumerables. + if (context.Value is IEnumerable enumerable) { - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MandatoryFormat); - return Task.CompletedTask; + var enumerator = enumerable.GetEnumerator(); + if (!enumerator.MoveNext()) + return AddError(context); } + + return Task.CompletedTask; + } + + /// + /// Create the error message. + /// + private Task AddError(PropertyContext context) + { + context.AddError(ErrorText ?? ValidatorStrings.MandatoryFormat); + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/MustRule.cs b/src/CoreEx.Validation/Rules/MustRule.cs deleted file mode 100644 index 7ef54559..00000000 --- a/src/CoreEx.Validation/Rules/MustRule.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides validation where the rule predicate must return true to be considered valid. - /// - /// The entity . - /// The property . - public class MustRule : ValueRuleBase where TEntity : class - { - private readonly Predicate? _predicate; - private readonly Func? _must; - private readonly Func>? _mustAsync; - - /// - /// Initializes a new instance of the class with a . - /// - /// The must predicate. - public MustRule(Predicate predicate) => _predicate = predicate.ThrowIfNull(nameof(predicate)); - - /// - /// Initializes a new instance of the class with a function. - /// - /// The must function. - public MustRule(Func must) => _must = must.ThrowIfNull(nameof(must)); - - /// - /// Initializes a new instance of the class with a function. - /// - /// The must function. - public MustRule(Func> mustAsync) => _mustAsync = mustAsync.ThrowIfNull(nameof(mustAsync)); - - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - if (_predicate != null) - { - if (!_predicate.Invoke(context.Parent.Value!)) - CreateErrorMessage(context); - } - else if (_must != null) - { - if (!_must.Invoke()) - CreateErrorMessage(context); - } - else - { - if (!await _mustAsync!.Invoke(cancellationToken).ConfigureAwait(false)) - CreateErrorMessage(context); - } - } - - /// - /// Create the error message. - /// - private void CreateErrorMessage(PropertyContext context) => context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MustFormat); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/NoneRule.cs b/src/CoreEx.Validation/Rules/NoneRule.cs deleted file mode 100644 index 02eb02c5..00000000 --- a/src/CoreEx.Validation/Rules/NoneRule.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides validation to ensure the value is not specified (is none); determined as when it does equal its default value. - /// - /// The entity . - /// The property . - /// A value will be determined as none when it equals its default value. For example an will trigger when the value is zero; however, a - /// will trigger when null only (a zero is considered a value in this instance). - public class NoneRule : ValueRuleBase where TEntity : class - { - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - // Compare the value against its default. - if (Comparer.Default.Compare(context.Value, default!) == 0) - return Task.CompletedTask; - - if (context.Value is string val && (val.Length == 0 || string.IsNullOrWhiteSpace(val))) - return Task.CompletedTask; - - // Also check for empty collections. - if (context.Value is ICollection coll && coll.Count == 0) - return Task.CompletedTask; - - // Also check for empty enumerables. - if (context.Value is IEnumerable enumerable) - { - var enumerator = enumerable.GetEnumerator(); - if (!enumerator.MoveNext()) - return Task.CompletedTask; - } - - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.NoneFormat); - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/NotNullRule.cs b/src/CoreEx.Validation/Rules/NotNullRule.cs deleted file mode 100644 index 29e719a6..00000000 --- a/src/CoreEx.Validation/Rules/NotNullRule.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides a not null validation. - /// - /// The entity . - /// The property . - public class NotNullRule : ValueRuleBase where TEntity : class - { - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - if (context.Value is null) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MandatoryFormat); - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/NullNoneEmptyRule.cs b/src/CoreEx.Validation/Rules/NullNoneEmptyRule.cs new file mode 100644 index 00000000..823b4526 --- /dev/null +++ b/src/CoreEx.Validation/Rules/NullNoneEmptyRule.cs @@ -0,0 +1,70 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Provides a , and validation. +/// +/// The entity . +/// The property . +/// Indicates that a validation error should occur when the value is not . +/// Indicates that a validation error should occur when the value is not . +/// Indicates that a validation error should occur when the value is considered not empty. +public sealed class NullNoneEmptyRule(Func, bool> mustBeNull, Func, bool> mustBeDefault, Func, bool> mustBeEmpty) : PropertyRuleBase where TEntity : class +{ + private readonly Func, bool> _mustBeNull = mustBeNull.ThrowIfNull(); + private readonly Func, bool> _mustBeDefault = mustBeDefault.ThrowIfNull(); + private readonly Func, bool> _mustBeEmpty = mustBeEmpty.ThrowIfNull(); + + /// + protected override bool ValidateWhenNull => true; + + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + var mustBeNull = _mustBeNull(context); + var mustBeDefault = _mustBeDefault(context); + var mustBeEmpty = _mustBeEmpty(context); + + // Check if must be null. + if (mustBeNull && !context.IsValueNull) + return AddError(context); + + // Compare the value against its default. + if (mustBeDefault && context.IsValueNullable && !context.IsNullableValueDefault()) + return AddError(context); + + if (mustBeDefault && !context.IsValueNullable && Comparer.Default.Compare(context.Value, default) != 0) + return AddError(context); + + if (!mustBeEmpty) + return Task.CompletedTask; + + // Also check for empty strings. + if (context.Value is string val) + return val is null || val.Length == 0 || string.IsNullOrWhiteSpace(val) ? Task.CompletedTask : AddError(context); + + if (context.Metadata.Type == typeof(string) && context.IsValueNull) + return Task.CompletedTask; + + // Also check for empty collections. + if (context.Value is ICollection coll) + return coll.Count == 0 ? Task.CompletedTask : AddError(context); + + // Also check for empty enumerables. + if (context.Value is IEnumerable enumerable) + { + var enumerator = enumerable.GetEnumerator(); + return !enumerator.MoveNext() ? Task.CompletedTask : AddError(context); + } + + return context.IsValueNull ? Task.CompletedTask : AddError(context); + } + + /// + /// Create the error message. + /// + private Task AddError(PropertyContext context) + { + context.AddError(ErrorText ?? ValidatorStrings.NoneFormat); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/NullRule.cs b/src/CoreEx.Validation/Rules/NullRule.cs deleted file mode 100644 index d83b8702..00000000 --- a/src/CoreEx.Validation/Rules/NullRule.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides a null validation. - /// - /// The entity . - /// The property . - public class NullRule : ValueRuleBase where TEntity : class - { - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - if (context.Value is not null) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.NoneFormat); - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/NullableEnumRule.cs b/src/CoreEx.Validation/Rules/NullableEnumRule.cs deleted file mode 100644 index 5e29eceb..00000000 --- a/src/CoreEx.Validation/Rules/NullableEnumRule.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides validation to ensure that the value has been defined. - /// - /// The entity . - /// The property . - public class NullableEnumRule : ValueRuleBase where TEntity : class where TProperty : struct, Enum - { - /// - /// Initializes a new instance of the class. - /// - public NullableEnumRule() => ValidateWhenDefault = false; - - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - // Make sure the enum is defined. - if (context.Value is null || !Enum.IsDefined(typeof(TProperty), context.Value)) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.InvalidFormat); - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/NumericRule.cs b/src/CoreEx.Validation/Rules/NumericRule.cs index dd4db351..06fe10d2 100644 --- a/src/CoreEx.Validation/Rules/NumericRule.cs +++ b/src/CoreEx.Validation/Rules/NumericRule.cs @@ -1,43 +1,25 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides a numeric validation to check whether negatives are allowed (defaults to , i.e. allowed). +/// +/// The entity . +/// The property . +/// Indicates whether to allow negative values. +public sealed class NumericRule(Func, bool>? allowNegatives = null) : PropertyRuleBase where TEntity : class where TProperty : INumber { - /// - /// Represents a numeric rule to validate whether negatives are allowed. - /// - /// The entity . - /// The property . - public class NumericRule : ValueRuleBase where TEntity : class - { - /// - /// Initializes a new instance of the class. - /// - public NumericRule() => ValidateWhenDefault = false; + private readonly Func, bool>? _allowNegatives = allowNegatives; - /// - /// Indicates whether to allow negative values. - /// - public bool AllowNegatives { get; set; } - - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + if (_allowNegatives is not null) { - // Where allowing negatives do nothing. - if (AllowNegatives) - return Task.CompletedTask; - - // Convert numeric to a double value. - double value = Convert.ToDouble(context.Value, System.Globalization.CultureInfo.InvariantCulture); - - // Determine if the value is negative and is/isn't allowed. - if (!AllowNegatives && value < 0) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.AllowNegativesFormat); - - return Task.CompletedTask; + var allowNegatives = _allowNegatives(context); + if (!allowNegatives && TProperty.IsNegative(context.Value)) + context.AddError(ErrorText ?? ValidatorStrings.AllowNegativesFormat); } + + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/OverrideRule.cs b/src/CoreEx.Validation/Rules/OverrideRule.cs deleted file mode 100644 index 0b2b9ebe..00000000 --- a/src/CoreEx.Validation/Rules/OverrideRule.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides a means to override a value within a validation context. - /// - /// The entity . - /// The property . - public class OverrideRule : ValueRuleBase where TEntity : class - { - private readonly Func? _func; - private readonly Func>? _funcAsync; - private readonly TProperty _value = default!; - - /// - /// Initializes a new instance of the class with a . - /// - /// The override function. - public OverrideRule(Func func) => _func = func.ThrowIfNull(nameof(func)); - - /// - /// Initializes a new instance of the class with a . - /// - /// The override function. - public OverrideRule(Func> funcAsync) => _funcAsync = funcAsync.ThrowIfNull(nameof(funcAsync)); - - /// - /// Initializes a new instance of the class with a . - /// - /// The override value. - public OverrideRule(TProperty value) => _value = value; - - /// - /// Indicates whether the value is only overridden where the current value is the default value for the type. - /// - public bool OnlyOverrideDefault { get; set; } = false; - - /// - protected override async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - // Compare the value against override to see if there is a difference. - if (OnlyOverrideDefault && Comparer.Default.Compare(context.Value, default!) != 0) - return; - - // Get the override value. - var overrideVal = _func != null - ? _func(context.Parent.Value!) - : (_funcAsync != null - ? await _funcAsync(context.Parent.Value!, cancellationToken).ConfigureAwait(false) - : _value); - - context.OverrideValue(overrideVal); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/PropertyRuleBase.cs b/src/CoreEx.Validation/Rules/PropertyRuleBase.cs new file mode 100644 index 00000000..078311a6 --- /dev/null +++ b/src/CoreEx.Validation/Rules/PropertyRuleBase.cs @@ -0,0 +1,88 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Provides the base capabilities. +/// +/// The entity . +/// The property . +public abstract class PropertyRuleBase : IPropertyRuleEx where TEntity : class +{ + private List>? _clauses; + private IPropertyRule? _chainedRule; + + /// + public LText? ErrorText { get; private set; } + + /// + /// Indicates whether the validation will be performed where the property value is . + /// + /// When this is set to and the property value is then the underyling clauses (if any) and validation will be bypassed and the next chained rule in + /// sequence will be executed. + protected virtual bool ValidateWhenNull => false; + + /// + IPropertyRule IPropertyRule.WithMessage(LText errorText) + { + ErrorText = errorText; + return this; + } + + /// + void IPropertyRule.AddClause(IPropertyClause clause) => (_clauses ??= []).Add(clause.ThrowIfNull()); + + /// + void IPropertyRule.Chain(IPropertyRule rule) + { + if (_chainedRule is not null) + throw new InvalidOperationException("Each rule can only support a single chained rule."); + + _chainedRule = rule.ThrowIfNull(); + } + + /// + async Task IPropertyRule.ValidateAsync(IPropertyContext context, CancellationToken cancellationToken) + { + if (context is PropertyContext ctx) + { + await ValidateInternalAsync(ctx, cancellationToken).ConfigureAwait(false); + return; + } + + // Value is Nullable and we need to convert it to T to continue. + ctx = new PropertyContext(context, context.Value is null ? default! : (TProperty)context.Value); + await ValidateInternalAsync(ctx, cancellationToken).ConfigureAwait(false); + } + + /// + Task IPropertyRuleEx.ValidateAsync(PropertyContext context, CancellationToken cancellationToken) => ValidateInternalAsync(context, cancellationToken); + + /// + /// Validate the property value. + /// + private async Task ValidateInternalAsync(PropertyContext context, CancellationToken cancellationToken) + { + // First, check whether rule is bypassed when null. + if (!ValidateWhenNull && context.IsValueNull) + { + if (_chainedRule is not null) + await _chainedRule!.ValidateAsync(context, cancellationToken).ConfigureAwait(false); + + return; + } + + // Next, check the clauses; if they are not satisfied then we don't execute the validation but we do execute the next chained rule (if any). + var cr = await RootPropertyRule.CheckClausesAsync(context, _clauses, cancellationToken).ConfigureAwait(false); + if (cr) + await OnValidateAsync(context, cancellationToken).ConfigureAwait(false); + + if (_chainedRule is not null && !context.IsInError) + await _chainedRule!.ValidateAsync(context, cancellationToken).ConfigureAwait(false); + } + + /// + /// Validate the property value. + /// + /// The . + /// The . + protected abstract Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ReferenceDataCodeCollectionRule.cs b/src/CoreEx.Validation/Rules/ReferenceDataCodeCollectionRule.cs new file mode 100644 index 00000000..eb5b08a0 --- /dev/null +++ b/src/CoreEx.Validation/Rules/ReferenceDataCodeCollectionRule.cs @@ -0,0 +1,19 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Provides an validation. +/// +/// The entity . +/// The property . +/// Indicates whether to allow an value where is set to . +public class ReferenceDataCodeCollectionRule(bool allowInactive) : PropertyRuleBase where TEntity : class where TProperty : IReferenceDataCodeCollection +{ + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + if (context.Value.HasInvalidItems || (!allowInactive && context.Value.HasInactiveItems)) + context.AddError(ErrorText ?? ValidatorStrings.InvalidItemsFormat); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ReferenceDataCodeRule.cs b/src/CoreEx.Validation/Rules/ReferenceDataCodeRule.cs index 25d47ffb..0ccd0144 100644 --- a/src/CoreEx.Validation/Rules/ReferenceDataCodeRule.cs +++ b/src/CoreEx.Validation/Rules/ReferenceDataCodeRule.cs @@ -1,28 +1,94 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using CoreEx.RefData; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides an validation. +/// +/// The entity . +public class ReferenceDataCodeRule : PropertyRuleBase where TEntity : class { + private readonly ReferenceDataWith _with; + /// - /// Provides validation for a ; validates that the . + /// Initializes a new instance of the class. /// - public class ReferenceDataCodeRule : ValueRuleBase where TEntity : class where TRef : IReferenceData? + /// Extended configuration. + public ReferenceDataCodeRule(Func with) + { + _with = new ReferenceDataWith(this); + with.ThrowIfNull().Invoke(_with); + } + + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) { + if (_with.Validator is not null && !_with.Validator(context)) + context.AddError(ErrorText ?? ValidatorStrings.InvalidFormat); + + return Task.CompletedTask; + } + + /// + /// Provides additional configuration options for the . + /// + public class ReferenceDataWith + { + private readonly ReferenceDataCodeRule _rule; + private bool _allowInactive; + private bool _overrideValue; + + /// + /// Initializes a new instance of the class. + /// + internal ReferenceDataWith(ReferenceDataCodeRule rule) => _rule = rule; + /// - /// Initializes a new instance of the class. + /// Gets the configured validator. /// - public ReferenceDataCodeRule() => ValidateWhenDefault = false; + internal Func, bool>? Validator { get; set; } - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) + /// + /// Indicates whether to allow an value where is set to . + /// + /// + public ReferenceDataWith AllowInactive() + { + _allowInactive = true; + return this; + } + + /// + /// Indicates whether the value should be overridden with the parsed value. + /// + /// The value must be mutable otherwise an will be thrown at runtime. + public ReferenceDataWith Override() { - if (!ReferenceDataOrchestrator.Current.GetByTypeRequired().TryGetByCode(context.Value!, out var rd) || !rd!.IsValid) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.InvalidFormat); + _overrideValue = true; + return this; + } + + /// + /// Sets the used to validate the value. + /// + /// The . + public ReferenceDataWith With() where TRef : IReferenceData + { + Validator = Validator is not null ? throw new InvalidOperationException("The ReferenceDataCode rule can only have one validator.") : context => + { + if (ReferenceDataOrchestrator.Current.GetByTypeRequired().TryGetByCode(context.Value!, out var rd) && rd.IsValid) + { + if (_allowInactive || rd.IsActive) + { + if (_overrideValue) + context.Override(rd.Code!); + + return true; + } + } + + return false; + }; - return Task.CompletedTask; + return this; } } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ReferenceDataCodeRuleAs.cs b/src/CoreEx.Validation/Rules/ReferenceDataCodeRuleAs.cs deleted file mode 100644 index 1ee5abc6..00000000 --- a/src/CoreEx.Validation/Rules/ReferenceDataCodeRuleAs.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using CoreEx.RefData; -using System; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides a means to add an using a validator a specified . - /// - /// The entity . - /// The parent . - /// The error message format text (overrides the default). - public class ReferenceDataCodeRuleAs(IPropertyRule parent, LText? errorText = null) where TEntity : class - { - private readonly IPropertyRule _parent = parent.ThrowIfNull(nameof(parent)); - private readonly LText? _errorText = errorText; - - /// - /// Adds an using a validator a specified . - /// - /// The . - /// A . - public IPropertyRule As() where TRef : IReferenceData - { - _parent.AddRule(new ReferenceDataCodeRule() { ErrorText = _errorText }); - return _parent; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ReferenceDataRule.cs b/src/CoreEx.Validation/Rules/ReferenceDataRule.cs index 3e9c55d0..9d3f6fd9 100644 --- a/src/CoreEx.Validation/Rules/ReferenceDataRule.cs +++ b/src/CoreEx.Validation/Rules/ReferenceDataRule.cs @@ -1,28 +1,19 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using CoreEx.RefData; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides an validation. +/// +/// The entity . +/// The property . +/// Indicates whether to allow an value where is set to . +public class ReferenceDataRule(bool allowInactive) : PropertyRuleBase where TEntity : class where TProperty : IReferenceData { - /// - /// Provides validation for a ; validates that the . - /// - public class ReferenceDataRule : ValueRuleBase where TEntity : class where TProperty : IReferenceData? + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) { - /// - /// Initializes a new instance of the class. - /// - public ReferenceDataRule() => ValidateWhenDefault = false; - - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - if (!context.Value!.IsValid) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.InvalidFormat); + if (!context.Value.IsValid || (!allowInactive && !context.Value.IsActive)) + context.AddError(ErrorText ?? ValidatorStrings.InvalidFormat); - return Task.CompletedTask; - } + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ReferenceDataSidListRule.cs b/src/CoreEx.Validation/Rules/ReferenceDataSidListRule.cs deleted file mode 100644 index fd2bd5b4..00000000 --- a/src/CoreEx.Validation/Rules/ReferenceDataSidListRule.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides validation for a including , , per item , and whether to . - /// - public class ReferenceDataSidListRule : ValueRuleBase where TEntity : class where TProperty : IReferenceDataCodeList? - { - /// - /// Initializes a new instance of the class. - /// - public ReferenceDataSidListRule() => ValidateWhenDefault = false; - - /// - /// Gets or sets the minimum count; - /// - public int MinCount { get; set; } - - /// - /// Gets or sets the maximum count. - /// - public int? MaxCount { get; set; } - - /// - /// Indicates whether duplicate values are allowed. - /// - public bool AllowDuplicates { get; set; } = false; - - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - if (context.Value!.HasInvalidItems) - { - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.InvalidItemsFormat); - return Task.CompletedTask; - } - - // Check Min and Max counts. - if (context.Value.Count < MinCount) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MinCountFormat, MinCount); - else if (MaxCount.HasValue && context.Value.Count > MaxCount.Value) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MaxCountFormat, MaxCount); - - // Check duplicates. - if (!AllowDuplicates) - { - var dict = new HashSet(); - foreach (var item in context.Value.ToRefDataList().Where(x => x.IsValid)) - { - if (dict.TryGetValue(item.Code, out _)) - { - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.DuplicateValueFormat, "Code", item.ToString()); - return Task.CompletedTask; - } - - dict.Add(item.Code); - } - } - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/RootPropertyRule.cs b/src/CoreEx.Validation/Rules/RootPropertyRule.cs new file mode 100644 index 00000000..84e5e1d2 --- /dev/null +++ b/src/CoreEx.Validation/Rules/RootPropertyRule.cs @@ -0,0 +1,142 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Provides the root capabilities. +/// +/// The entity . +/// The property . +public sealed class RootPropertyRule : IPropertyRuleEx, IRootPropertyRule where TEntity : class +{ + private readonly Func? _getNullableValue; + private readonly Func? _isNullableValueDefault; + private List>? _clauses; + private IPropertyRule? _chainedRule; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// A function to get the underlying nullable value. + /// A function to determine whether the underlying nullable value is its default. + internal RootPropertyRule(IPropertyRuntimeMetadata metadata, Func? getNullableValue, Func? isNullableValueDefault) + { + Metadata = metadata.ThrowIfNull(); + _getNullableValue = getNullableValue; + _isNullableValueDefault = isNullableValueDefault; + + if (metadata.Format is not null) + ValueFormatter = new(metadata.Format); + } + + /// + /// Gets the . + /// + public IPropertyRuntimeMetadata Metadata { get; } + + /// + public bool IsValueNullable => _getNullableValue is not null; + + /// + /// Gets the property text override (where set); otherwise, . + /// + public LText? Text { get; private set; } + + /// + public void SetText(LText? text) => Text = text; + + /// + /// Gets the to use when localizing the property value within an error message. + /// + public ValueFormatter ValueFormatter { get; private set; } = ValueFormatter.Default; + + /// + void IRootPropertyRule.SetFormat(string? format, IFormatProvider? formatProvider, char? quotingCharacter) => ValueFormatter = new(format, formatProvider, quotingCharacter); + + /// + /// Not supported for a ; will throw a . + public LText? ErrorText + { + get => throw new NotSupportedException($"A {GetType().Name} does not support {nameof(ErrorText)}."); + set => throw new NotSupportedException($"A {GetType().Name} does not support {nameof(ErrorText)}."); + } + + /// + /// Not supported for a ; will throw a . + IPropertyRule IPropertyRule.WithMessage(LText errorText) => throw new NotSupportedException($"A {GetType().Name} does not support {nameof(IPropertyRule<,>.WithMessage)}."); + + /// + /// Gets the originating property or (where ). + /// + /// The property . + /// The originating property or . + public T GetNullableValueOrDefault(TEntity entity) => IsValueNullable ? Internal.Cast(_getNullableValue!(entity)) : throw new InvalidOperationException("The property must be Nullable."); + + /// + /// Indicates whether the originating property is (where ). + /// + public bool IsNullableValueDefault(TEntity entity) => IsValueNullable ? _isNullableValueDefault!(entity) : throw new InvalidOperationException("The property must be Nullable."); + + /// + void IPropertyRule.AddClause(IPropertyClause clause) => (_clauses ??= []).Add(clause.ThrowIfNull()); + + /// + void IPropertyRule.Chain(IPropertyRule rule) + { + if (_chainedRule is not null) + throw new InvalidOperationException("A rule can only support a single chained rule."); + + _chainedRule = rule.ThrowIfNull(); + } + + /// + /// Checks the clauses. + /// + /// The . + /// The . + /// The . + /// where validation is to continue; otherwise, to stop. + internal static async Task CheckClausesAsync(PropertyContext context, List>? clauses, CancellationToken cancellationToken) + { + if (clauses is not null) + { + foreach (var clause in clauses) + { + var cr = await clause.CheckAsync(context, cancellationToken).ConfigureAwait(false); + if (!cr) + return cr; + } + } + + return true; + } + + /// + async Task IPropertyRule.ValidateAsync(IPropertyContext context, CancellationToken cancellationToken) + => await ValidateAsync((PropertyContext)context, cancellationToken); + + /// + public async Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + if (_chainedRule is not null) + await _chainedRule.ValidateAsync(context, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ValidateAsync(ValidationContext context, CancellationToken cancellationToken) + { + // Check that there are no pre-existing errors for the property. + if (context.HasError(Metadata)) + return; + + // Create the property context. + var pc = new PropertyContext(this, context); + + // Check the clauses. + var cr = await CheckClausesAsync(pc, _clauses, cancellationToken).ConfigureAwait(false); + if (!cr) + return; + + // Perform the validation. + await ValidateAsync(pc, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/RuleSet.cs b/src/CoreEx.Validation/Rules/RuleSet.cs new file mode 100644 index 00000000..61f0310e --- /dev/null +++ b/src/CoreEx.Validation/Rules/RuleSet.cs @@ -0,0 +1,55 @@ +namespace CoreEx.Validation.Rules; + +/// +/// Provides a validation rule set for an entity, in that it groups together that are only validated when the specified condition (predicate) is . +/// +/// the entity . +/// The predicate to determine whether the is to be validated. +public sealed class RuleSet(Predicate> predicate) : ValidatorBase>, IRootPropertyRule where TEntity : class +{ + private readonly Predicate> _predicate = predicate.ThrowIfNull(); + + /// + bool IRootPropertyRule.IsValueNullable => throw new NotImplementedException(); + + /// + LText? IPropertyRule.ErrorText { get => throw new NotImplementedException(); } + + /// + public void SetText(LText? text) => throw new NotImplementedException(); + + /// + void IPropertyRule.AddClause(IPropertyClause clause) => throw new NotImplementedException(); + + /// + void IPropertyRule.Chain(IPropertyRule rule) => throw new NotImplementedException(); + + /// + T IRootPropertyRule.GetNullableValueOrDefault(TEntity entity) => throw new NotImplementedException(); + + /// + bool IRootPropertyRule.IsNullableValueDefault(TEntity entity) => throw new NotImplementedException(); + + /// + void IRootPropertyRule.SetFormat(string? format, IFormatProvider? formatProvider, char? quotingCharacter) => throw new NotImplementedException(); + + /// + async Task IRootPropertyRule.ValidateAsync(ValidationContext context, CancellationToken cancellationToken) + { + // Check the predicate. + if (!_predicate(context)) + return; + + // Execute the rules in the set. + foreach (var rule in Rules) + { + await rule.ValidateAsync(context, cancellationToken).ConfigureAwait(false); + } + } + + /// + Task IPropertyRule.ValidateAsync(IPropertyContext context, CancellationToken cancellationToken) => throw new NotImplementedException(); + + /// + internal override Task ValidateAsync(IValidationContext context, CancellationToken cancellationToken) => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/StringRule.cs b/src/CoreEx.Validation/Rules/StringRule.cs index 676ea171..a3a35a71 100644 --- a/src/CoreEx.Validation/Rules/StringRule.cs +++ b/src/CoreEx.Validation/Rules/StringRule.cs @@ -1,64 +1,52 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +namespace CoreEx.Validation.Rules; + +/// +/// Provides validation including minimum and maximum length, and regular expression. +/// +/// The entity . +/// The minimum length. +/// The maximum length. +/// The regular expression. +public sealed class StringRule(Func, int>? minLength = null, Func, int?>? maxLength = null, Func, Regex?>? regex = null) : PropertyRuleBase where TEntity : class { - /// - /// Provides validation including , , and . - /// - /// The entity . - public class StringRule : ValueRuleBase where TEntity : class - { - private int _minLength = 0; - private int? _maxLength = null; + private readonly Func, int>? _minLength = minLength; + private readonly Func, int?>? _maxLength = maxLength; + private readonly Func, Regex?>? _regex = regex; - /// - /// Gets or sets the minimum length; - /// - public int MinLength { get => _minLength; set => _minLength = value >= 0 ? value : throw new ArgumentException($"{nameof(MinLength)} must be zero or greater.", nameof(MinLength)); } + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + var minLength = _minLength?.Invoke(context) ?? 0; + var maxLength = _maxLength?.Invoke(context); + var regex = _regex?.Invoke(context); - /// - /// Gets or sets the maximum length. - /// - public int? MaxLength { get => _maxLength; set => _maxLength = value is null || value.Value > 0 ? value : throw new ArgumentException($"{nameof(MaxLength)} must be greater that zero.", nameof(MaxLength)); } + if (minLength < 0) + throw new InvalidOperationException($"Minimum length must be zero or greater."); - /// - /// Gets or sets the regex. - /// - public Regex? Regex { get; set; } + if (maxLength is not null && maxLength.Value <= 0) + throw new InvalidOperationException($"Maximum length must be greater than zero."); - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) + if (minLength > 0 && maxLength.HasValue && minLength == maxLength!.Value && context.Value.Length != minLength) { - if (string.IsNullOrEmpty(context.Value)) - return Task.CompletedTask; - - if (MinLength > 0 && MaxLength.HasValue && MinLength == MaxLength!.Value && context.Value.Length != MinLength) - { - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.ExactLengthFormat, MinLength); - return Task.CompletedTask; - } - - if (context.Value.Length < MinLength) - { - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MinLengthFormat, MinLength); - return Task.CompletedTask; - } - - if (MaxLength.HasValue && context.Value.Length > MaxLength.Value) - { - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.MaxLengthFormat, MaxLength); - return Task.CompletedTask; - } + context.AddError(ErrorText ?? ValidatorStrings.ExactLengthFormat, minLength); + return Task.CompletedTask; + } - if (Regex != null && !Regex.IsMatch(context.Value)) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.RegexFormat); + if (context.Value.Length < minLength) + { + context.AddError(ErrorText ?? ValidatorStrings.MinLengthFormat, minLength); + return Task.CompletedTask; + } + if (maxLength.HasValue && context.Value.Length > maxLength.Value) + { + context.AddError(ErrorText ?? ValidatorStrings.MaxLengthFormat, maxLength); return Task.CompletedTask; } + + if (regex is not null && !regex.IsMatch(context.Value)) + context.AddError(ErrorText ?? ValidatorStrings.RegexFormat); + + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/ValueRuleBase.cs b/src/CoreEx.Validation/Rules/ValueRuleBase.cs deleted file mode 100644 index e2ae6bf5..00000000 --- a/src/CoreEx.Validation/Rules/ValueRuleBase.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using CoreEx.Validation.Clauses; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules -{ - /// - /// Provides the base functionality for a property value rule. - /// - /// The entity . - /// The property . - public abstract class ValueRuleBase : IValueRule where TEntity : class - { - private readonly List> _clauses = []; - - /// - public LText? ErrorText { get; set; } - - /// - /// Indicates that the is also invoked when the property value equals the default value for the . - /// - /// Defaults to true; this indicates that the property is validated where default. - protected bool ValidateWhenDefault { get; set; } = true; - - /// - /// Adds a clause () to the rule. - /// - /// The . - public void AddClause(IPropertyRuleClause clause) - { - if (clause == null) - return; - - _clauses.Add(clause); - } - - /// - /// Checks the clause. - /// - /// The . - /// true where validation is to continue; otherwise, false to stop. - protected virtual bool Check(PropertyContext context) - { - foreach (var clause in _clauses) - { - if (!clause.Check(context)) - return false; - } - - return true; - } - - /// - /// Checks the clause. - /// - /// The . - /// true where validation is to continue; otherwise, false to stop. - bool IValueRule.Check(IPropertyContext context) => Check((PropertyContext)context); - - /// - /// Validate the property value. - /// - /// The . - /// The . - /// The corresponding . - protected abstract Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default); - - /// - Task IValueRule.ValidateAsync(IPropertyContext context, CancellationToken cancellationToken) - { - var pc = (PropertyContext)context; - if (ValidateWhenDefault || Comparer.Default.Compare(pc.Value, default!) != 0) - return ValidateAsync(pc, cancellationToken); - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Rules/WildcardRule.cs b/src/CoreEx.Validation/Rules/WildcardRule.cs index 2198c7a0..57f1a562 100644 --- a/src/CoreEx.Validation/Rules/WildcardRule.cs +++ b/src/CoreEx.Validation/Rules/WildcardRule.cs @@ -1,35 +1,22 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation.Rules; -using CoreEx.Wildcards; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation.Rules +/// +/// Provides a validation. +/// +/// The entity . +/// The . +public class WildcardRule(Func, Wildcard?> wildcard) : PropertyRuleBase where TEntity : class { - /// - /// Provides validation. - /// - /// The entity . - public class WildcardRule : ValueRuleBase where TEntity : class - { - /// - /// Initializes a new instance of the class. - /// - public WildcardRule() => ValidateWhenDefault = false; + private readonly Func, Wildcard?> _wildcard = wildcard.ThrowIfNull(); - /// - /// Gets or sets the configuration (uses where null). - /// - public Wildcard? Wildcard { get; set; } + /// + protected override Task OnValidateAsync(PropertyContext context, CancellationToken cancellationToken) + { + var wildcard = _wildcard(context) ?? Wildcard.Default ?? Wildcard.MultiBasic; - /// - protected override Task ValidateAsync(PropertyContext context, CancellationToken cancellationToken = default) - { - var wildcard = Wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; - if (wildcard != null && !wildcard.Validate(context.Value)) - context.CreateErrorMessage(ErrorText ?? ValidatorStrings.WildcardFormat); + if (wildcard.Parse(context.Value).HasError) + context.AddError(ErrorText ?? ValidatorStrings.WildcardFormat); - return Task.CompletedTask; - } + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidatingInlineValidator.cs b/src/CoreEx.Validation/ValidatingInlineValidator.cs new file mode 100644 index 00000000..317512a9 --- /dev/null +++ b/src/CoreEx.Validation/ValidatingInlineValidator.cs @@ -0,0 +1,112 @@ +namespace CoreEx.Validation; + +/// +/// Provides an implementation of the that can be used directly for inline-style validation that also supports . +/// +/// The value . +/// The action to configure the . +internal sealed class ValidatingInlineValidator(Action.Validator>? configure) : InlineValidator(configure), IValidatorEx +{ + /// + /// Overrides the property and JSON names. + /// + /// The property name. + /// The to support fluent-style method-chaining. + /// This will apply to all instances in which the is used; therefore, caution is required when using. This is intended for advanced usage only. + public ValidatingInlineValidator WithName(string name) + { + OverrideName = name.ThrowIfNullOrEmpty(); + return this; + } + + /// + /// Overrides the property text. + /// + /// The property text. + /// The to support fluent-style method-chaining. + /// This will apply to all instances in which the is used; therefore, caution is required when using. This is intended for advanced usage only. + public ValidatingInlineValidator WithText(LText text) + { + OverrideText = text; + return this; + } + + /// + Task> IValidatorEx.ValidateAsync(TValue value, ValidationArgs? args, CancellationToken cancellationToken) => ValidateInternalAsync(value, args, cancellationToken); + + /// + async Task IValidatorEx.ValidateAndThrowAsync(TValue value, ValidationArgs? args, CancellationToken cancellationToken) + => (await ValidateInternalAsync(value, args, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + async Task> IValidator.ValidateAsync(TValue value, CancellationToken cancellationToken) + => await ValidateInternalAsync(value, null, cancellationToken).ConfigureAwait(false); + + /// + /// Performs the validation from above methods. + /// + private async Task> ValidateInternalAsync(TValue value, ValidationArgs? args, CancellationToken cancellationToken) + { + // Validate the value. + args ??= new ValidationArgs(); + var r = await new ValueValidator(value, Validation.ValueName, null, Validation.ValueText, c => c.Common(this), null, null).ValidateAsync(new ValidationValue(value), args, cancellationToken); + + // Transform the context to expose TValue. + var vc = new ValueValidationContext(r); + return vc; + } + + /// + Task IValidatorEx.ValidateAsync(IValidationContext context, CancellationToken cancellationToken) + => throw new NotSupportedException($"{nameof(ValidateAsync)} is not supported by the {nameof(ValidatingInlineValidator<>)} class."); + + /// + /// Custom that transforms the underlying and related-context. + /// + internal readonly struct ValueValidationContext(IValidationContext> parent) : IValidationContext + { + private readonly IValidationContext> _parent = parent; + + /// + public Type EntityType => _parent.EntityType; + + /// + public string? FullyQualifiedEntityName => _parent.FullyQualifiedEntityName; + + /// + public string? FullyQualifiedJsonEntityName => _parent.FullyQualifiedJsonEntityName; + + /// + public bool UseJsonNames => _parent.UseJsonNames; + + /// + public JsonSerializerOptions? JsonSerializerOptions => _parent.JsonSerializerOptions; + + /// + public IServiceProvider? ServiceProvider => _parent.ServiceProvider; + + /// + public IDictionary Parameters => _parent.Parameters; + + /// + public TValue? Value => _parent.Value is null ? default : _parent.Value.Value; + + /// + public bool HasErrors => _parent.HasErrors; + + /// + public MessageItemCollection? Messages => _parent.Messages; + + /// + public bool HasError(string fullyQualifiedPropertyName) => _parent.HasError(fullyQualifiedPropertyName); + + /// + public IValidationResult ThrowOnError() => _parent.ThrowOnError(); + + /// + public Exception? ToException() => _parent.ToException(); + + /// + public Result ToResult() => HasErrors ? Result.ValidationError(Messages!) : Result.Success; + } +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationArgs.cs b/src/CoreEx.Validation/ValidationArgs.cs index e92ceabb..9e14dbed 100644 --- a/src/CoreEx.Validation/ValidationArgs.cs +++ b/src/CoreEx.Validation/ValidationArgs.cs @@ -1,71 +1,58 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using CoreEx.Configuration; -using CoreEx.Entities; -using System.Collections.Generic; - -namespace CoreEx.Validation +/// +/// Represents optional arguments for a validation. +/// +public sealed record class ValidationArgs { + private static bool? _defaultUseJsonNames; + private bool? _useJsonNames; + /// - /// Represents the optional extended arguments for an entity validation. + /// Gets or sets the default for all validations unless explicitly overridden. Defaults to . /// - public class ValidationArgs + public static bool DefaultUseJsonNames { - private static bool? _defaultUseJsonNames; - - /// - /// Initializes a new instance of the class. - /// - public ValidationArgs() { } - - /// - /// Indicates whether to use the JSON name for the ; by default (false) uses the .NET name. - /// - /// Will attempt to use as a default where possible. - public static bool DefaultUseJsonNames - { - get => _defaultUseJsonNames ?? ExecutionContext.GetService()?.ValidationUseJsonNames ?? false; - set => _defaultUseJsonNames = value; - } - - /// - /// Gets or sets the optional name of a selected (specific) property to validate for the entity (null indicates to validate all). - /// - /// Nested or fully quailified entity names are not supported for this type of validation; only a property of the primary entity can be selected. - public string? SelectedPropertyName { get; set; } + get => _defaultUseJsonNames ??= Internal.GetConfigurationValue("CoreEx:Validation:DefaultUseJsonNames", true); + set => _defaultUseJsonNames = value; + } - /// - /// Gets or sets the entity prefix used for fully qualified entity.property naming (null represents the root). - /// - public string? FullyQualifiedEntityName { get; set; } + /// + /// Indicates whether to use the JSON name for the for all validation messages. + /// + /// Defaults to . + public bool UseJsonNames + { + get => _useJsonNames ??= DefaultUseJsonNames; + set => _useJsonNames = value; + } - /// - /// Gets or sets the entity prefix used for fully qualified entity.property naming (null represents the root). - /// - public string? FullyQualifiedJsonEntityName { get; set; } + /// + /// Gets or sets the entity prefix used for fully qualified entity.property naming. + /// + /// This is only used for root-level validations. + public string? FullyQualifiedEntityName { get; set; } - /// - /// Indicates (overrides ) whether to use the JSON name for the ; - /// defaults to null (uses the value). - /// - public bool? UseJsonNames { get; set; } + /// + /// Gets or sets the entity prefix used for fully qualified entity.property JSON naming. + /// + /// This is only used for root-level validations. + public string? FullyQualifiedJsonEntityName { get; set; } - /// - /// Gets selection. - /// - internal bool UseJsonNamesSelection => UseJsonNames ?? DefaultUseJsonNames; + /// + /// Gets or sets the used for JSON property naming. + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } - /// - /// Indicates that a shallow validation is required; i.e. will only validate the top level properties. - /// - /// The default deep validation will not only validate the top level properties, but also those children down the object graph; - /// i.e. sub-objects and collections. - public bool ShallowValidation { get; set; } + /// + /// Gets or sets the to use when resolving services. + /// + /// The will be used as the default where not specified. + public IServiceProvider? ServiceProvider { get; set; } - /// - /// Gets the configuration parameters. - /// - /// Configuration parameters provide a means to pass values down through the validation stack. The consuming developer must instantiate the property on first use. - public IDictionary? Config { get; set; } - } + /// + /// Gets or sets the additional parameters. + /// + /// Additional parameters provide a means to pass values down through the validation stack. + public IDictionary Parameters { get; set => field = value.ThrowIfNull(); } = new Dictionary(); } \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationContext.Utility.cs b/src/CoreEx.Validation/ValidationContext.Utility.cs new file mode 100644 index 00000000..09dea281 --- /dev/null +++ b/src/CoreEx.Validation/ValidationContext.Utility.cs @@ -0,0 +1,163 @@ +namespace CoreEx.Validation; + +public sealed partial class ValidationContext +{ + /// + /// Determines whether the specified property has an error. + /// + /// The property . + /// The property expression. + /// where an error exists for the specified property; otherwise, . + public bool HasError(Expression> propertyExpression) => HasError(RuntimeMetadata.GetForExpression(propertyExpression)); + + /// + /// Determines whether the specified property has an error. + /// + /// The property . + /// The property metadata. + /// where an error exists for the specified property; otherwise, . + internal bool HasError(IPropertyRuntimeMetadata propertyMetadata) => HasError(CreateFullyQualifiedPropertyName(propertyMetadata.Name)); + + /// + /// Checks whether a specified property has not had an error, then executes a predicate to determine whether an error has occurred and where adds an error . + /// + /// The property . + /// The property expression. + /// The error checking predicate; a result indicates an error. + /// The composite format string. + /// The values that form part of the message text. + /// indicates that the specified property has had an error, or is now considered in error; otherwise, for no error. + /// The property friendly text and value are automatically passed as the first two arguments to the underlying string formatter. + public bool Check(Expression> propertyExpression, Predicate predicate, LText format, params object?[] values) + { + var pm = RuntimeMetadata.GetForExpression(propertyExpression); + predicate.ThrowIfNull(); + + if (HasError(pm)) + return true; + + if (!predicate(pm.GetValue(Value))) + return false; + + AddError(pm, null, null, null, format, values); + return true; + } + + /// + /// Checks whether a specified property has not had an error, then adds an error . + /// + /// The property . + /// The property expression. + /// indicates an error; otherwise, . + /// The composite format string. + /// The values that form part of the message text. + /// indicates that the specified property has had an error, or is now considered in error; otherwise, for no error. + /// The property friendly text and value are automatically passed as the first two arguments to the underlying string formatter. + public bool Check(Expression> propertyExpression, bool when, LText format, params object?[] values) + { + var pm = RuntimeMetadata.GetForExpression(propertyExpression); + + if (HasError(pm)) + return true; + + if (!when) + return false; + + AddError(pm, null, null, null, format, values); + return true; + } + + /// + /// Adds an to the for the specified property, explicit text format and and additional values included in the text. + /// + /// The property . + /// The property expression. + /// The composite format string. + /// The values that form part of the message text. + /// The . + /// The property friendly text and value are automatically prepended to the as the first two arguments where the does not have already have . + public MessageItem AddError(Expression> propertyExpression, LText format, params object?[] values) + => AddError(RuntimeMetadata.GetForExpression(propertyExpression), null, null, null, format, values); + + /// + /// Adds an to the for the specified property, explicit text format and and additional values included in the text. + /// + /// The property . + /// The . + /// The property override. + /// The . + /// The JSON property name override. + /// The composite format string. + /// The values that form part of the message text. + /// The . + /// The property friendly text and value are automatically prepended to the as the first two arguments where the does not have already have . + internal MessageItem AddError(IPropertyRuntimeMetadata propertyMetadata, LText? text, ValueFormatter? valueFormatter, string? jsonNameOverride, LText format, params object?[] values) + { + format = format.EnsureNoArgsWhen(values); + + text ??= propertyMetadata.Text; + jsonNameOverride ??= propertyMetadata.GetJsonName(JsonSerializerOptions); + object?[] std = [text, valueFormatter is null ? propertyMetadata.GetValue(Value) : valueFormatter.Value.ToLText(propertyMetadata.GetValue(Value))]; + + if (propertyMetadata is ISelfRuntimeMetadata) + return AddError(format, [.. std, .. values]); + else + return AddError(propertyMetadata.Name, jsonNameOverride ?? propertyMetadata.GetJsonName(JsonSerializerOptions), format, [.. std, .. values]); + } + + /// + /// Adds an to the for the specified property, explicit text format and and additional values included in the text. + /// + /// The property name. + /// The JSON property name. + /// The composite format string. + /// The values that form part of the message text. + /// The . + internal MessageItem AddError(string propertyName, string jsonPropertyName, LText format, params object?[] values) + { + propertyName.ThrowIfNullOrEmpty(); + var fqpn = CreateFullyQualifiedPropertyName(propertyName); + var mi = new ValidationMessageItem() + { + Property = UseJsonNames ? CreateFullyQualifiedJsonPropertyName(jsonPropertyName) : fqpn, + Type = MessageType.Error, + Text = format.EnsureNoArgs().WithArgs(values), + FullyQualifiedPropertyName = fqpn + }; + + GetMessages().Add(mi); + return mi; + } + + /// + /// Adds an to the for the explicit text format and and additional values included in the text. + /// + /// The composite format string. + /// The values that form part of the message text. + /// The . + internal MessageItem AddError(LText format, params object?[] values) + { + var mi = new ValidationMessageItem() + { + Property = UseJsonNames ? (FullyQualifiedJsonEntityName ?? string.Empty) : FullyQualifiedEntityName, + Type = MessageType.Error, + Text = format.EnsureNoArgs().WithArgs(values), + FullyQualifiedPropertyName = FullyQualifiedEntityName + }; + + GetMessages().Add(mi); + return mi; + } + + /// + /// Creates a fully qualified property name appending the . + /// + /// The property name. + public string CreateFullyQualifiedPropertyName(string name) => FullyQualifiedEntityName is null ? name : name.StartsWith('[') ? FullyQualifiedEntityName + name : FullyQualifiedEntityName + "." + name; + + /// + /// Creates a fully qualified JSON property name appending the . + /// + /// The JSON property name. + public string CreateFullyQualifiedJsonPropertyName(string jsonName) => FullyQualifiedJsonEntityName is null ? jsonName : (jsonName.StartsWith('[') ? FullyQualifiedJsonEntityName + jsonName : FullyQualifiedJsonEntityName + "." + jsonName); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationContext.cs b/src/CoreEx.Validation/ValidationContext.cs index 319374e8..eae08c19 100644 --- a/src/CoreEx.Validation/ValidationContext.cs +++ b/src/CoreEx.Validation/ValidationContext.cs @@ -1,552 +1,161 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Abstractions.Reflection; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using CoreEx.Localization; -using CoreEx.Results; - -namespace CoreEx.Validation +namespace CoreEx.Validation; + +/// +/// Provides the . +/// +/// The entity . +/// The entity value. +/// The . +/// Optional override. +/// Optional override. +public sealed partial class ValidationContext(TEntity value, ValidationArgs args, string? fullyQualifiedEntityNameOverride = null, string? fullyQualifiedJsonEntityNameOverride = null) : IValidationContext where TEntity : class { + private HashSet? _errorProperties; + /// - /// Provides a validation context for an entity. + /// Gets the . /// - /// The entity . - public class ValidationContext : IValidationContext, IValidationResult - { - private readonly Dictionary _propertyMessages = []; - - /// - /// Initializes a new instance of the class. - /// - /// The entity value. - /// The . - /// Optional override. - /// Optional override. - public ValidationContext(TEntity value, ValidationArgs args, string? fullyQualifiedEntityNameOverride = null, string? fullyQualifiedJsonEntityNameOverride = null) - { - args.ThrowIfNull(nameof(args)); - - Value = value; - FullyQualifiedEntityName = fullyQualifiedEntityNameOverride ?? args.FullyQualifiedEntityName; - FullyQualifiedJsonEntityName = fullyQualifiedJsonEntityNameOverride ?? args.FullyQualifiedJsonEntityName ?? FullyQualifiedEntityName; - UsedJsonNames = args.UseJsonNamesSelection; - Config = args.Config; - SelectedPropertyName = args.SelectedPropertyName; - ShallowValidation = args.ShallowValidation; - } + public ValidationArgs Args { get; } = args.ThrowIfNull(); - /// - /// Gets the instance. - /// - public CoreEx.ExecutionContext ExecutionContext => ExecutionContext.Current; + /// + public Type EntityType => typeof(TEntity); - /// - public Type EntityType => typeof(TEntity); + /// + object? IValidationResult.Value => Value; - /// - object? IValidationResult.Value => Value; + /// + public TEntity Value { get; } = value; - /// - /// Gets the entity value. - /// - public TEntity Value { get; } + /// + public string? FullyQualifiedEntityName { get; } = fullyQualifiedEntityNameOverride ?? args.FullyQualifiedEntityName; - /// - /// Gets the entity prefix used for fully qualified entity.property naming (null represents the root). - /// - public string? FullyQualifiedEntityName { get; } + /// + public string? FullyQualifiedJsonEntityName { get; } = fullyQualifiedJsonEntityNameOverride ?? args.FullyQualifiedJsonEntityName; - /// - /// Gets the entity prefix used for fully qualified JSON entity.property naming (null represents the root). - /// - public string? FullyQualifiedJsonEntityName { get; } + /// + public bool UseJsonNames { get; } = args.UseJsonNames; - /// - /// Gets the optional name of a selected (specific) property to validate for the entity (null indicates to validate all). - /// - public string? SelectedPropertyName { get; } + /// + public JsonSerializerOptions? JsonSerializerOptions { get; } = args.JsonSerializerOptions; - /// - /// Indicates whether to use the JSON name for the ; by default (false) uses the .NET name. - /// - public bool UsedJsonNames { get; } - - /// - /// Gets the . - /// - public MessageItemCollection? Messages { get; private set; } - - /// - /// Indicates whether there has been a validation error. - /// - public bool HasErrors { get; private set; } - - /// - /// Indicates that a shallow validation is required; i.e. will only validate the top level properties. - /// - public bool ShallowValidation { get; } - - /// - public IDictionary? Config { get; set; } - - /// - /// Gets (creates) the messages collection. - /// - /// - private MessageItemCollection GetMessages() - { - if (Messages == null) - { - Messages = []; - Messages.CollectionChanged += Messages_CollectionChanged; - } - - return Messages; - } + /// + public IServiceProvider? ServiceProvider { get; } = args.ServiceProvider; - /// - /// Handle the add of a message. - /// - private void Messages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) - { - switch (e.Action) - { - case System.Collections.Specialized.NotifyCollectionChangedAction.Add: - foreach (var m in e.NewItems!) - { - MessageItem mi = (MessageItem)m; - if (mi.Type == MessageType.Error) - HasErrors = true; - } - - break; - - default: - throw new InvalidOperationException("Operation invalid for Messages; only add supported."); - } - } - - /// - public Result? FailureResult { get; private set; } - - /// - /// Sets the . - /// - /// The . - internal void SetFailureResult(Result? result) - { - if (result is null) - return; + /// + /// Gets the . + /// + public MessageItemCollection? Messages { get; private set; } - if (FailureResult.HasValue) - throw new InvalidOperationException("The ValidationContext is already in a Failure state."); + /// + public IDictionary Parameters { get; } = args.Parameters; - if (result.Value.IsFailure) - { - FailureResult = result; - HasErrors = true; - } - } - - /// - public Exception? ToException() => FailureResult.HasValue ? FailureResult.Value.Error : (HasErrors ? new ValidationException(Messages!) : null); + /// + /// Indicates whether there has been a validation error. + /// + public bool HasErrors { get; private set; } - /// - IValidationResult IValidationResult.ThrowOnError() => ThrowOnError(false); + /// + /// Determines whether the specified fully qualified property name has an error. + /// + /// The fully qualified property name. + /// where an error exists for the specified property; otherwise, . + public bool HasError(string fullyQualifiedPropertyName) => _errorProperties is not null && _errorProperties.Contains(fullyQualifiedPropertyName); - /// - /// Throws a (typically a ) where an error was found (and optionally if warnings). - /// - /// Indicates whether to throw where only warnings exist. - /// The to support fluent-style method-chaining. - public ValidationContext ThrowOnError(bool includeWarnings = false) + /// + /// Gets (creates) the messages collection. + /// + private MessageItemCollection GetMessages() + { + if (Messages is null) { - var ex = ToException(); - if (ex is not null) - throw ex; - - if (includeWarnings && Messages != null && Messages.Any(x => x.Type == MessageType.Warning)) - throw new ValidationException(Messages); - - return this; + Messages = []; + Messages.CollectionChanged += Messages_CollectionChanged; } - /// - public Result ToResult() => FailureResult.HasValue ? FailureResult.Value.Bind() : (HasErrors ? Result.ValidationError(Messages!) : Validation.ConvertValueToResult(Value!)); - - /// - public Result ToResult() => FailureResult ?? (HasErrors ? Result.ValidationError(Messages!) : Result.Success); - - /// - /// Merges a validation result into this. - /// - /// The to merge. - public void MergeResult(IValidationContext context) - { - if (context.FailureResult.HasValue) - SetFailureResult(context.FailureResult.Value); - else - MergeResult(context?.Messages); - } + return Messages; + } - /// - /// Merges a into this. - /// - /// The to merge. - public void MergeResult(MessageItemCollection? messages) + /// + /// Handle the add of a message. + /// + private void Messages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + switch (e.Action) { - if (messages == null || messages.Count == 0) - return; - - MessageItemCollection? errs = null; - foreach (var mi in messages) - { - if (!string.IsNullOrEmpty(mi.Property) && mi.Type == MessageType.Error) + case System.Collections.Specialized.NotifyCollectionChangedAction.Add: + foreach (var m in e.NewItems!) { - if (!HasError(mi.Property)) - _propertyMessages.Add(mi.Property, mi); + MessageItem mi = (MessageItem)m; + if (mi.Type == MessageType.Error) + { + HasErrors = true; + if (mi is ValidationMessageItem vmi && vmi.FullyQualifiedPropertyName is not null) + (_errorProperties ??= []).Add(vmi.FullyQualifiedPropertyName); + } } - (errs ??= GetMessages()).Add(mi); - } - } - - /// - /// Determines whether the specified property has an error. - /// - /// The property . - /// The property expression. - /// true where an error exists for the specified property; otherwise, false. - public bool HasError(Expression> propertyExpression) => HasError(CreateFullyQualifiedName(propertyExpression)); + break; - /// - /// Determines whether the specified property has an error. - /// - /// The property . - /// The property expression. - /// true where an error exists for the specified property; otherwise, false. - internal bool HasError(PropertyExpression propertyExpression) => HasError(CreateFullyQualifiedName(propertyExpression)); - - /// - /// Determines whether one of the specified fully qualified property name has an error. - /// - /// The fully qualified property name. - /// true where an error exists for at least one of the specified properties; otherwise, false. - public bool HasError(string fullyQualifiedPropertyName) => _propertyMessages.ContainsKey(fullyQualifiedPropertyName); - - /// - /// Gets the error for the specified property. - /// - /// The property . - /// The property expression. - /// The corresponding ; otherwise, null. - public MessageItem? GetError(Expression> propertyExpression) => _propertyMessages.TryGetValue(CreateFullyQualifiedName(propertyExpression), out MessageItem? mi) ? null : mi; - - /// - /// Adds an to the for the specified property and explicit text. - /// - /// The property . - /// The property expression. - /// The text . - /// The . - public MessageItem AddError(Expression> propertyExpression, LText text) - { - var pe = CreatePropertyExpression(propertyExpression); - return AddMessage(pe.Name, pe.JsonName, MessageType.Error, text, GetTextAndValue(pe)); + default: + throw new InvalidOperationException("Operation invalid for Messages; only add supported."); } + } - /// - /// Adds an to the for the specified property, explicit text format and and additional values included in the text. The property - /// friendly text and value are automatically passed as the first two arguments to the string formatter. - /// - /// The property . - /// The property expression. - /// The composite format string. - /// The values that form part of the message text. - /// The . - public MessageItem AddError(Expression> propertyExpression, LText format, params object[] values) - { - var pe = CreatePropertyExpression(propertyExpression); - return AddMessage(pe.Name, pe.JsonName, MessageType.Error, format, [.. GetTextAndValue(pe), .. values]); - } - - /// - /// Adds an to the for the specified property and explicit text. The property friendly text and value are automatically passed as the - /// first two arguments to the string formatter. - /// - /// The property . - /// The property expression. - /// The text . - /// The . - public MessageItem AddWarning(Expression> propertyExpression, LText text) - { - var pe = CreatePropertyExpression(propertyExpression); - return AddMessage(pe.Name, pe.JsonName, MessageType.Warning, text, GetTextAndValue(pe)); - } - - /// - /// Adds an to the for the specified property, explicit text format and and additional values included in the text. The property - /// friendly text and value are automatically passed as the first two arguments to the string formatter. - /// - /// The property . - /// The property expression. - /// The composite format string. - /// The values that form part of the message text. - /// The . - public MessageItem AddWarning(Expression> propertyExpression, LText format, params object[] values) - { - var pe = CreatePropertyExpression(propertyExpression); - return AddMessage(pe.Name, pe.JsonName, MessageType.Warning, format, [.. GetTextAndValue(pe), .. values]); - } - - /// - /// Adds an to the for the specified property and explicit text. The property friendly text and value are automatically passed as the - /// first two arguments to the string formatter. - /// - /// The property . - /// The property expression. - /// The text . - /// The . - public MessageItem AddInfo(Expression> propertyExpression, LText text) - { - var pe = CreatePropertyExpression(propertyExpression); - return AddMessage(pe.Name, pe.JsonName, MessageType.Info, text, GetTextAndValue(pe)); - } - - /// - /// Adds an to the for the specified property, explicit text format and and additional values included in the text. The property - /// friendly text and value are automatically passed as the first two arguments to the string formatter. - /// - /// The property . - /// The property expression. - /// The composite format string. - /// The values that form part of the message text. - /// The . - public MessageItem AddInfo(Expression> propertyExpression, LText format, params object[] values) - { - var pe = CreatePropertyExpression(propertyExpression); - return AddMessage(pe.Name, pe.JsonName, MessageType.Info, format, [.. GetTextAndValue(pe), .. values]); - } - - /// - /// Adds a to the for the specified property and explicit text. - /// - /// The property name. - /// The JSON property name. - /// The . - /// The text . - /// The . - internal MessageItem AddMessage(string propertyName, string? jsonPropertyName, MessageType type, LText text) - { - var mi = GetMessages().Add(CreateFullyQualifiedName(propertyName, jsonPropertyName), type, text); - if (type == MessageType.Error && !HasError(mi.Property!)) - _propertyMessages.Add(mi.Property!, mi); - - return mi; - } - - /// - /// Adds a to the for the specified property, explicit text format and additional values included in the text. - /// - /// The property name. - /// The JSON property name. - /// The . - /// The composite format string. - /// The values that form part of the message text. - /// The . - internal MessageItem AddMessage(string propertyName, string? jsonPropertyName, MessageType type, LText format, params object?[] values) - { - var mi = GetMessages().Add(CreateFullyQualifiedName(propertyName, jsonPropertyName), type, format, values); - if (type == MessageType.Error && !HasError(mi.Property!)) - _propertyMessages.Add(mi.Property!, mi); - - return mi; - } - - /// - /// Adds a to the for the specified text. - /// - /// The . - /// The text . - /// The . - public MessageItem AddMessage(MessageType type, LText text) - { - var mi = GetMessages().Add(UsedJsonNames ? FullyQualifiedJsonEntityName : FullyQualifiedEntityName, type, text); - if (type == MessageType.Error && !HasError(mi.Property!)) - _propertyMessages.Add(mi.Property!, mi); - - return mi; - } - - /// - /// Adds a to the for the specified text format and additional values included in the text. - /// - /// The . - /// The composite format string. - /// The values that form part of the message text. - /// The . - public MessageItem AddMessage(MessageType type, LText format, params object?[] values) - { - var mi = GetMessages().Add(UsedJsonNames ? FullyQualifiedJsonEntityName : FullyQualifiedEntityName, type, format, values); - if (type == MessageType.Error && !HasError(mi.Property!)) - _propertyMessages.Add(mi.Property!, mi); - - return mi; - } - - /// - /// Checks whether a specified property has not had an error, then executes a predicate to determine whether an error has occurred (returns true) adding an error for the - /// specified property and text. The property friendly text and value are automatically passed as the first two arguments to the string formatter. - /// - /// The property . - /// The property expression. - /// The error checking predicate; a true result indicates an error. - /// The error text . - /// true indicates that the specified property has had an error, or is now considered in error; otherwise, false for no error. - public bool Check(Expression> propertyExpression, Func predicate, LText text) - { - var pe = CreatePropertyExpression(propertyExpression); - predicate.ThrowIfNull(nameof(predicate)); - - if (HasError(pe)) - return true; - - var tv = GetTextAndValue(pe); - if (!predicate((TProperty)tv[1])) - return false; - - AddMessage(pe.Name, pe.JsonName, MessageType.Error, text, tv); - return true; - } - - /// - /// Checks whether a specified property has not had an error, then where is true adds an error for the - /// specified property and text. The property friendly text and value are automatically passed as the first two arguments to the string formatter. - /// - /// The property . - /// The property expression. - /// true indicates an error; otherwise, false. - /// The error text . - /// true indicates that the specified property has had an error, or is now considered in error; otherwise, false for no error. - public bool Check(Expression> propertyExpression, bool when, LText text) - { - var pe = CreatePropertyExpression(propertyExpression); - if (HasError(pe)) - return true; - - var tv = GetTextAndValue(pe); - if (!when) - return false; - - AddMessage(pe.Name, pe.JsonName, MessageType.Error, text, tv); - return true; - } - - /// - /// Checks whether a specified property has not had an error, then executes a predicate to determine whether an error has occurred (returns true) adding an error for the - /// specified property, text format and and additional values included in the text. The property friendly text and value are automatically passed as the first two arguments to the string formatter. - /// - /// The property . - /// The property expression. - /// The error checking predicate; a false result indicates an error. - /// The error composite format string. - /// The values that form part of the message text. - /// true indicates that the specified property has had an error, or is now considered in error; otherwise, false for no error. - public bool Check(Expression> propertyExpression, Func predicate, LText format, params object[] values) - { - var pe = CreatePropertyExpression(propertyExpression); - predicate.ThrowIfNull(nameof(predicate)); - - if (HasError(pe)) - return true; - - var tv = GetTextAndValue(pe); - if (!predicate((TProperty)tv[1])) - return false; - - AddMessage(pe.Name, pe.JsonName, MessageType.Error, format, [.. tv, .. values]); - return true; - } - - /// - /// Checks whether a specified property has not had an error, then where is true adds an error for the specified property, - /// text format and and additional values included in the text. The property friendly text and value are automatically passed as the first two arguments to the string formatter. - /// - /// The property . - /// The property expression. - /// true indicates an error; otherwise, false. - /// The error composite format string. - /// The values that form part of the message text. - /// true indicates that the specified property has had an error, or is now considered in error; otherwise, false for no error. - public bool Check(Expression> propertyExpression, bool when, LText format, params object[] values) - { - var pe = CreatePropertyExpression(propertyExpression); - if (HasError(pe)) - return true; - - var tv = GetTextAndValue(pe); - if (!when) - return false; - - AddMessage(pe.Name, pe.JsonName, MessageType.Error, format, [.. tv, .. values]); - return true; - } + /// + public Exception? ToException() => HasErrors ? new ValidationException(Messages!) : null; - /// - /// Gets the friendly text and value for a property expression. - /// - private object[] GetTextAndValue(PropertyExpression propertyExpression) => [propertyExpression.Text, propertyExpression.GetValue(Value)!]; + /// + IValidationResult IValidationResult.ThrowOnError() => ThrowOnError(); - /// - /// Creates the property expression from the expression. - /// - private static PropertyExpression CreatePropertyExpression(Expression> propertyExpression) => PropertyExpression.Create(propertyExpression); + /// + /// Throws a where an error was found (and optionally any warnings). + /// + /// The to support fluent-style method-chaining. + public ValidationContext ThrowOnError() + { + var ex = ToException(); + if (ex is not null) + throw ex; - /// - /// Gets the text for the specified property. - /// - /// The property . - /// The property expression. - /// The property text. - public LText GetText(Expression> propertyExpression) => CreatePropertyExpression(propertyExpression).Text; + return this; + } - /// - /// Creates the fully qualified name from an expression. - /// - private string CreateFullyQualifiedName(Expression> propertyExpression) - => CreateFullyQualifiedName(CreatePropertyExpression(propertyExpression)); + /// + /// Merges a into this. + /// + /// The to merge. + internal void MergeResult(IValidationResult? validationResult) + { + if (validationResult?.Messages is not null && validationResult.Messages.Count > 0) + GetMessages().AddRange(validationResult.Messages); + } - /// - /// Creates the fully qualified name from a property expression. - /// - private string CreateFullyQualifiedName(PropertyExpression propertyExpression) - => CreateFullyQualifiedName(propertyExpression.ThrowIfNull(nameof(propertyExpression)).Name, propertyExpression.JsonName); + /// + public Result ToResult() => HasErrors ? Result.ValidationError(Messages!) : Result.Success; - /// - /// Creates the fully qualified name using the property and json property names. - /// - private string CreateFullyQualifiedName(string propertyName, string? jsonPropertyName) - { - propertyName.ThrowIfNullOrEmpty(nameof(propertyName)); - return UsedJsonNames ? CreateFullyQualifiedJsonPropertyName(jsonPropertyName ?? propertyName) : CreateFullyQualifiedPropertyName(propertyName); - } + /// + /// Executes a further for the . + /// + /// The . + /// The . + /// This is useful in scenarios where further validation needs to occur for the , that needs an to be constructed dynamically at runtime with the same + /// , that is ostensibly a continuation/extension of any existing validation for that value. + public Task ValidateFurtherAsync(IValidatorEx validator, CancellationToken cancellationToken = default) => validator.ThrowIfNull().ValidateAsync(this, cancellationToken); - /// - /// Creates a fully qualified property name for the specified name. - /// - /// The property name. - /// The fully qualified property name. - internal string CreateFullyQualifiedPropertyName(string name) => FullyQualifiedEntityName == null ? name : name.StartsWith('[') ? FullyQualifiedEntityName + name : FullyQualifiedEntityName + "." + name; + /// + /// Executes a further dynamically created for the that enables further configuration. + /// + /// An action to configure the . + /// The . + /// This is useful in scenarios where further validation needs to occur for the , that needs an to be constructed dynamically at runtime with the same + /// , that is ostensibly a continuation/extension of any existing validation for that value. + public Task ValidateFurtherAsync(Action> configure, CancellationToken cancellationToken = default) + { + if (configure is null) + return Task.CompletedTask; - /// - /// Creates a fully qualified JSON property name for the specified name. - /// - /// The property name. - /// The fully qualified property name. - internal string CreateFullyQualifiedJsonPropertyName(string name) => FullyQualifiedJsonEntityName == null ? name : name.StartsWith('[') ? FullyQualifiedJsonEntityName + name : FullyQualifiedJsonEntityName + "." + name; + var v = Validator.Create(); + configure(v); + return ValidateFurtherAsync(v, cancellationToken); } } \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.BetweenRule.cs b/src/CoreEx.Validation/ValidationExtensions.BetweenRule.cs new file mode 100644 index 00000000..11302c50 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.BetweenRule.cs @@ -0,0 +1,178 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The minimum value. + /// The maximum value. + /// The minimum text (used in the error message); otherwise, uses the resulting value. + /// The maximum text (used in the error message); otherwise, uses the resulting value. + /// Indicates whether the between comparison is exclusive or inclusive (default). + /// The optional . + public static IPropertyRule Between(this IPropertyRule rule, TProperty min, TProperty max, LText? minText = null, LText? maxText = null, bool exclusiveBetween = false, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new BetweenRule(_ => min, _ => max, _ => minText, _ => maxText, exclusiveBetween, comparer)); + + /// + /// Chains a between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the minimum value. + /// The function to get the maximum value. + /// The minimum text formatter (used in the error message); otherwise, uses the resulting value. + /// The maximum text formatter (used in the error message); otherwise, uses the resulting value. + /// Indicates whether the between comparison is exclusive or inclusive (default). + /// The optional . + public static IPropertyRule Between(this IPropertyRule rule, Func, TProperty> min, Func, TProperty> max, Func? minText = null, Func? maxText = null, bool exclusiveBetween = false, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new BetweenRule(c => min.ThrowIfNull()(c), c => max.ThrowIfNull()(c), minText, maxText, exclusiveBetween, comparer)); + + /// + /// Chains an inclusive between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The minimum value. + /// The maximum value. + /// The minimum text (used in the error message); otherwise, uses the resulting value. + /// The maximum text (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule InclusiveBetween(this IPropertyRule rule, TProperty min, TProperty max, LText? minText = null, LText? maxText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new BetweenRule(_ => min, _ => max, _ => minText, _ => maxText, false, comparer)); + + /// + /// Chains an inclusive between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the minimum value. + /// The function to get the maximum value. + /// The minimum text formatter (used in the error message); otherwise, uses the resulting value. + /// The maximum text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule InclusiveBetween(this IPropertyRule rule, Func, TProperty> min, Func, TProperty> max, Func? minText = null, Func? maxText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new BetweenRule(c => min.ThrowIfNull()(c), c => max.ThrowIfNull()(c), minText, maxText, false, comparer)); + + /// + /// Chains an exclusive between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The minimum value. + /// The maximum value. + /// The minimum text (used in the error message); otherwise, uses the resulting value. + /// The maximum text (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule ExclusiveBetween(this IPropertyRule rule, TProperty min, TProperty max, LText? minText = null, LText? maxText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new BetweenRule(_ => min, _ => max, _ => minText, _ => maxText, true, comparer)); + + /// + /// Chains an exclusive between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the minimum value. + /// The function to get the maximum value. + /// The minimum text formatter (used in the error message); otherwise, uses the resulting value. + /// The maximum text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule ExclusiveBetween(this IPropertyRule rule, Func, TProperty> min, Func, TProperty> max, Func? minText = null, Func? maxText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new BetweenRule(c => min.ThrowIfNull()(c), c => max.ThrowIfNull()(c), minText, maxText, true, comparer)); + + /* Nullable/Struct */ + + /// + /// Chains a between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The minimum value. + /// The maximum value. + /// The minimum text (used in the error message); otherwise, uses the resulting value. + /// The maximum text (used in the error message); otherwise, uses the resulting value. + /// Indicates whether the between comparison is exclusive or inclusive (default). + /// The optional . + public static IPropertyRule Between(this IPropertyRule rule, TProperty min, TProperty max, LText? minText = null, LText? maxText = null, bool exclusiveBetween = false, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new BetweenRule(_ => min, _ => max, _ => minText, _ => maxText, exclusiveBetween, comparer)); + + /// + /// Chains a between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the minimum value. + /// The function to get the maximum value. + /// The minimum text formatter (used in the error message); otherwise, uses the resulting value. + /// The maximum text formatter (used in the error message); otherwise, uses the resulting value. + /// Indicates whether the between comparison is exclusive or inclusive (default). + /// The optional . + public static IPropertyRule Between(this IPropertyRule rule, Func, TProperty> min, Func, TProperty> max, Func? minText = null, Func? maxText = null, bool exclusiveBetween = false, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new BetweenRule(c => min.ThrowIfNull()(c), c => max.ThrowIfNull()(c), minText, maxText, exclusiveBetween, comparer)); + + /// + /// Chains an inclusive between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The minimum value. + /// The maximum value. + /// The minimum text (used in the error message); otherwise, uses the resulting value. + /// The maximum text (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule InclusiveBetween(this IPropertyRule rule, TProperty min, TProperty max, LText? minText = null, LText? maxText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new BetweenRule(_ => min, _ => max, _ => minText, _ => maxText, false, comparer)); + + /// + /// Chains an inclusive between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the minimum value. + /// The function to get the maximum value. + /// The minimum text formatter (used in the error message); otherwise, uses the resulting value. + /// The maximum text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule InclusiveBetween(this IPropertyRule rule, Func, TProperty> min, Func, TProperty> max, Func? minText = null, Func? maxText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new BetweenRule(c => min.ThrowIfNull()(c), c => max.ThrowIfNull()(c), minText, maxText, false, comparer)); + + /// + /// Chains an exclusive between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The minimum value. + /// The maximum value. + /// The minimum text (used in the error message); otherwise, uses the resulting value. + /// The maximum text (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule ExclusiveBetween(this IPropertyRule rule, TProperty min, TProperty max, LText? minText = null, LText? maxText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new BetweenRule(_ => min, _ => max, _ => minText, _ => maxText, true, comparer)); + + /// + /// Chains an exclusive between comparison () validation of a and value. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the minimum value. + /// The function to get the maximum value. + /// The minimum text formatter (used in the error message); otherwise, uses the resulting value. + /// The maximum text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule ExclusiveBetween(this IPropertyRule rule, Func, TProperty> min, Func, TProperty> max, Func? minText = null, Func? maxText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new BetweenRule(c => min.ThrowIfNull()(c), c => max.ThrowIfNull()(c), minText, maxText, true, comparer)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.CollectionRule.cs b/src/CoreEx.Validation/ValidationExtensions.CollectionRule.cs new file mode 100644 index 00000000..c5993bc4 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.CollectionRule.cs @@ -0,0 +1,136 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a collection () validation to the existing . + /// + /// The entity . + /// The item . + /// The to chain to. + /// Extends configuration . + public static CollectionRule, TItem> Collection(this IPropertyRule> rule, Func, TItem>.With, CollectionRule, TItem>.With> with) where TEntity : class + => (CollectionRule, TItem>)Chain(rule, new CollectionRule, TItem>(null, null, with)); + + /// + /// Chains a collection () validation to the existing . + /// + /// The entity . + /// The item . + /// The to chain to. + /// The maximum count. + /// Extends configuration . + public static CollectionRule, TItem> Collection(this IPropertyRule> rule, int maxCount, Func, TItem>.With, CollectionRule, TItem>.With>? with = null) where TEntity : class + => (CollectionRule, TItem>)Chain(rule, new CollectionRule, TItem>(null, _ => maxCount, with)); + + /// + /// Chains a collection () validation to the existing . + /// + /// The entity . + /// The item . + /// The to chain to. + /// The maximum count. + /// Extends configuration . + public static CollectionRule, TItem> Collection(this IPropertyRule> rule, Func>, int?>? maxCount, Func, TItem>.With, CollectionRule, TItem>.With>? with = null) where TEntity : class + => (CollectionRule, TItem>)Chain(rule, new CollectionRule, TItem>(null, maxCount, with)); + + /// + /// Chains a collection () validation to the existing . + /// + /// The entity . + /// The item . + /// The to chain to. + /// The minimum count. + /// The maximum count. + /// Extends configuration . + public static CollectionRule, TItem> Collection(this IPropertyRule> rule, int minCount, int? maxCount, Func, TItem>.With, CollectionRule, TItem>.With>? with = null) where TEntity : class + => (CollectionRule, TItem>)Chain(rule, new CollectionRule, TItem>(_ => minCount, _ => maxCount, with)); + + /// + /// Chains a collection () validation to the existing . + /// + /// The entity . + /// The item . + /// The to chain to. + /// The minimum count. + /// The maximum count. + /// Extends configuration . + public static CollectionRule, TItem> Collection(this IPropertyRule> rule, Func>, int>? minCount, Func>, int?>? maxCount, Func, TItem>.With, CollectionRule, TItem>.With>? with = null) where TEntity : class + => (CollectionRule, TItem>)Chain(rule, new CollectionRule, TItem>(minCount, maxCount, with)); + + /* WithDuplicateCheck-extensions */ + + /// + /// Sets the duplicate check based on the . + /// + /// The entity . + /// The property . + /// The item . + /// The . + /// The duplicate to be used in the error message. + /// The to support fluent-style method-chaining. + /// The defaults to . + public static CollectionRule.With WithDuplicateIdCheck(this CollectionRule.With with, LText? duplicateText = null) where TEntity : class where TProperty : IEnumerable where TItem : IIdentifierCore + => with.ThrowIfNull().WithDuplicateCheckingInternal(item => item.EntityKey, CompositeKeyComparer.Default, () => duplicateText ?? ValidatorStrings.IdentifierText); + + /// + /// Sets the duplicate check based on the . + /// + /// The entity . + /// The property . + /// The item . + /// The . + /// The duplicate to be used in the error message. + /// The to support fluent-style method-chaining. + /// The defaults to . + public static CollectionRule.With WithDuplicateKeyCheck(this CollectionRule.With with, LText? duplicateText = null) where TEntity : class where TProperty : IEnumerable where TItem : IEntityKey + => with.ThrowIfNull().WithDuplicateCheckingInternal(item => item.EntityKey, CompositeKeyComparer.Default, () => duplicateText ?? ValidatorStrings.KeyText); + + /// + /// Sets the duplicate check based on the . + /// + /// The entity . + /// The property . + /// The item . + /// The item property . + /// The . + /// The to reference the entity property. + /// The . + /// The duplicate to be used in the error message. + /// The to support fluent-style method-chaining. + /// The defaults to the resulting . + public static CollectionRule.With WithDuplicatePropertyCheck(this CollectionRule.With with, Expression> propertyExpression, IEqualityComparer? comparer = null, LText? duplicateText = null) where TEntity : class where TProperty : IEnumerable where TItem : class + { + var dcp = RuntimeMetadata.GetForExpression(propertyExpression.ThrowIfNull()); + return with.ThrowIfNull().WithDuplicateCheckingInternal(dcp.GetValue, comparer, () => duplicateText ??= dcp.Text); + } + + /// + /// Sets the duplicate check based on the item value (). + /// + /// The entity . + /// The property . + /// The item . + /// The . + /// The duplicate to be used in the error message. + /// The equality comparer. + /// The to support fluent-style method-chaining. + /// The defaults to . + public static CollectionRule.With WithDuplicateCheck(this CollectionRule.With with, IEqualityComparer? comparer = null, LText? duplicateText = null) where TEntity : class where TProperty : IEnumerable where TItem : IEquatable + => with.ThrowIfNull().WithDuplicateCheckingInternal(item => item, comparer, () => duplicateText ?? ValidatorStrings.ItemText); + + /// + /// Sets the duplicate check based on the . + /// + /// The entity . + /// The property . + /// The item . + /// The key . + /// The . + /// The key selector. + /// The equality comparer. + /// The duplicate to be used in the error message. + /// The to support fluent-style method-chaining. + public static CollectionRule.With WithDuplicateCheck(this CollectionRule.With with, Func keySelector, IEqualityComparer? comparer = null, LText? duplicateText = null) where TEntity : class where TProperty : IEnumerable + => with.ThrowIfNull().WithDuplicateCheckingInternal(keySelector, comparer, () => duplicateText ?? ValidatorStrings.ItemText); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.CommonRule.cs b/src/CoreEx.Validation/ValidationExtensions.CommonRule.cs new file mode 100644 index 00000000..6c15bf90 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.CommonRule.cs @@ -0,0 +1,24 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a common () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// The . + public static IPropertyRule Common(this IPropertyRule rule, InlineValidator commonValidator) where TEntity : class + => Chain(rule, new CommonRule(commonValidator.ThrowIfNull())); + + /// + /// Chains a common () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// The . + public static IPropertyRule Common(this IPropertyRule rule, InlineValidator commonValidator) where TEntity : class where TProperty : struct + => Chain(rule, new CommonRule(commonValidator.ThrowIfNull())); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.ComparePropertyRule.cs b/src/CoreEx.Validation/ValidationExtensions.ComparePropertyRule.cs new file mode 100644 index 00000000..f684b089 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.ComparePropertyRule.cs @@ -0,0 +1,32 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a property comparison () validation against another property () within the same entity; also confirms other property has no errors prior to comparison. + /// + /// The entity . + /// The property . + /// + /// The being extended. + /// The . + /// >The to reference the compare-to entity property. + /// The compare-to value text formatter (used in the error message); otherwise, uses the resulting compare-to value. + /// The optional . + public static IPropertyRule CompareProperty(this IPropertyRule rule, CompareOperator compareOperator, Expression> compareToPropertyExpression, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new ComparePropertyRule(compareOperator, compareToPropertyExpression, compareToText, comparer)); + + /// + /// Chains a property comparison () validation against another property () within the same entity; also confirms other property has no errors prior to comparison. + /// + /// The entity . + /// The property . + /// + /// The being extended. + /// The . + /// >The to reference the compare-to entity property. + /// The compare-to value text formatter (used in the error message); otherwise, uses the resulting compare-to value. + /// The optional . + public static IPropertyRule CompareProperty(this IPropertyRule rule, CompareOperator compareOperator, Expression> compareToPropertyExpression, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new ComparePropertyRule(compareOperator, compareToPropertyExpression, compareToText, comparer)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.CompareValueRule.cs b/src/CoreEx.Validation/ValidationExtensions.CompareValueRule.cs new file mode 100644 index 00000000..f7a2b235 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.CompareValueRule.cs @@ -0,0 +1,346 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The . + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule Compare(this IPropertyRule rule, CompareOperator compareOperator, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(compareOperator, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The . + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule Compare(this IPropertyRule rule, CompareOperator compareOperator, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(compareOperator, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule Equal(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.Equal, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule Equal(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.Equal, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule NotEqual(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.NotEqual, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule NotEqual(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.NotEqual, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule LessThan(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.LessThan, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule LessThan(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.LessThan, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule LessThanOrEqualTo(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.LessThanOrEqualTo, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule LessThanOrEqualTo(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.LessThanOrEqualTo, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule GreaterThanOrEqualTo(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.GreaterThanOrEqualTo, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule GreaterThanOrEqualTo(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.GreaterThanOrEqualTo, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule GreaterThan(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.GreaterThan, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule GreaterThan(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : IComparable + => Chain(rule, new CompareValueRule(CompareOperator.GreaterThan, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /* Nullable/Struct */ + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The . + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule Compare(this IPropertyRule rule, CompareOperator compareOperator, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(compareOperator, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The . + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule Compare(this IPropertyRule rule, CompareOperator compareOperator, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(compareOperator, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule Equal(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.Equal, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule Equal(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.Equal, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule NotEqual(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.NotEqual, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule NotEqual(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.NotEqual, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule LessThan(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.LessThan, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule LessThan(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.LessThan, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule LessThanOrEqualTo(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.LessThanOrEqualTo, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule LessThanOrEqualTo(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.LessThanOrEqualTo, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule GreaterThanOrEqualTo(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.GreaterThanOrEqualTo, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule GreaterThanOrEqualTo(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.GreaterThanOrEqualTo, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule GreaterThan(this IPropertyRule rule, TProperty compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.GreaterThan, _ => compareTo, compareToText, comparer)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value. + /// The value text formatter (used in the error message); otherwise, uses the resulting value. + /// The optional . + public static IPropertyRule GreaterThan(this IPropertyRule rule, Func, TProperty> compareTo, Func? compareToText = null, IComparer? comparer = null) where TEntity : class where TProperty : struct, IComparable + => Chain(rule, new CompareValueRule(CompareOperator.GreaterThan, c => compareTo.ThrowIfNull()(c), compareToText, comparer)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.CompareValuesRule.cs b/src/CoreEx.Validation/ValidationExtensions.CompareValuesRule.cs new file mode 100644 index 00000000..6a32cbf1 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.CompareValuesRule.cs @@ -0,0 +1,54 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value(s). + /// The optional . + /// Indicates whether to override the underlying property value with the corresponding matched value. + public static IPropertyRule CompareValues(this IPropertyRule rule, IEnumerable values, IEqualityComparer? comparer = null, bool overrideValueWhereMatched = false) where TEntity : class where TProperty : IEquatable + => Chain(rule, new CompareValuesRule(_ => values, comparer, overrideValueWhereMatched)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value(s). + /// The optional . + /// Indicates whether to override the underlying property value with the corresponding matched value. + public static IPropertyRule CompareValues(this IPropertyRule rule, Func, IEnumerable> values, IEqualityComparer? comparer = null, bool overrideValueWhereMatched = false) where TEntity : class where TProperty : IEquatable + => Chain(rule, new CompareValuesRule(c => values.ThrowIfNull()(c), comparer, overrideValueWhereMatched)); + + /* Nullable/Struct */ + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The compare-to value(s). + /// The optional . + /// Indicates whether to override the underlying property value with the corresponding matched value. + public static IPropertyRule CompareValues(this IPropertyRule rule, IEnumerable values, IEqualityComparer? comparer = null, bool overrideValueWhereMatched = false) where TEntity : class where TProperty : struct, IEquatable + => Chain(rule, new CompareValuesRule(_ => values, comparer, overrideValueWhereMatched)); + + /// + /// Chains a comparison () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the compare-to value(s). + /// The optional . + /// Indicates whether to override the underlying property value with the corresponding matched value. + public static IPropertyRule CompareValues(this IPropertyRule rule, Func, IEnumerable> values, IEqualityComparer? comparer = null, bool overrideValueWhereMatched = false) where TEntity : class where TProperty : struct, IEquatable + => Chain(rule, new CompareValuesRule(c => values.ThrowIfNull()(c), comparer, overrideValueWhereMatched)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.DecimalRule.cs b/src/CoreEx.Validation/ValidationExtensions.DecimalRule.cs new file mode 100644 index 00000000..256e8a94 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.DecimalRule.cs @@ -0,0 +1,94 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a , and numeric value () validation. + /// + /// The being extended. + /// The maximum number of significant digits (including ). + /// The maximum number of decimal places. + /// Indicates whether to allow negative values. + /// For example, to validate a number with the pattern '999.99', then would be 5 and would be 2. + public static IPropertyRule Decimal(this IPropertyRule rule, int? precision, int? scale = null, bool allowNegatives = true) where TEntity : class + => Chain(rule, new DecimalRule(_ => precision, _ => scale, _ => allowNegatives)); + + /// + /// Chains a , and numeric value () validation. + /// + /// The being extended. + /// The maximum number of significant digits (including ). + /// The maximum number of decimal places. + /// Indicates whether to allow negative values. + /// For example, to validate a number with the pattern '999.99', then would be 5 and would be 2. + public static IPropertyRule Decimal(this IPropertyRule rule, Func, int?>? precision, Func, int?>? scale = null, Func, bool>? allowNegatives = null) where TEntity : class + => Chain(rule, new DecimalRule(precision, scale, allowNegatives)); + + /// + /// Chains a , and numeric value () validation. + /// + /// The being extended. + /// The maximum number of significant digits (including ). + /// The maximum number of decimal places. + /// Indicates whether to allow negative values. + /// For example, to validate a number with the pattern '999.99', then would be 5 and would be 2. + public static IPropertyRule PrecisionScale(this IPropertyRule rule, int? precision, int? scale = null, bool allowNegatives = true) where TEntity : class where TProperty : IFloatingPoint + => Chain(rule, new DecimalRule(_ => precision, _ => scale, _ => allowNegatives)); + + /// + /// Chains a , and numeric value () validation. + /// + /// The being extended. + /// The maximum number of significant digits (including ). + /// The maximum number of decimal places. + /// Indicates whether to allow negative values. + /// For example, to validate a number with the pattern '999.99', then would be 5 and would be 2. + public static IPropertyRule PrecisionScale(this IPropertyRule rule, Func, int?>? precision, Func, int?>? scale = null, Func, bool>? allowNegatives = null) where TEntity : class where TProperty : IFloatingPoint + => Chain(rule, new DecimalRule(precision, scale, allowNegatives)); + + /* Nullable/Struct */ + + /// + /// Chains a , and numeric value () validation. + /// + /// The being extended. + /// The maximum number of significant digits (including ). + /// The maximum number of decimal places. + /// Indicates whether to allow negative values. + /// For example, to validate a number with the pattern '999.99', then would be 5 and would be 2. + public static IPropertyRule Decimal(this IPropertyRule rule, int? precision, int? scale = null, bool allowNegatives = true) where TEntity : class + => Chain(rule, new DecimalRule(_ => precision, _ => scale, _ => allowNegatives)); + + /// + /// Chains a , and numeric value () validation. + /// + /// The being extended. + /// The maximum number of significant digits (including ). + /// The maximum number of decimal places. + /// Indicates whether to allow negative values. + /// For example, to validate a number with the pattern '999.99', then would be 5 and would be 2. + public static IPropertyRule Decimal(this IPropertyRule rule, Func, int?>? precision, Func, int?>? scale = null, Func, bool>? allowNegatives = null) where TEntity : class + => Chain(rule, new DecimalRule(precision, scale, allowNegatives)); + + /// + /// Chains a , and numeric value () validation. + /// + /// The being extended. + /// The maximum number of significant digits (including ). + /// The maximum number of decimal places. + /// Indicates whether to allow negative values. + /// For example, to validate a number with the pattern '999.99', then would be 5 and would be 2. + public static IPropertyRule PrecisionScale(this IPropertyRule rule, int? precision, int? scale = null, bool allowNegatives = true) where TEntity : class where TProperty : struct, IFloatingPoint + => Chain(rule, new DecimalRule(_ => precision, _ => scale, _ => allowNegatives)); + + /// + /// Chains a , and numeric value () validation. + /// + /// The being extended. + /// The maximum number of significant digits (including ). + /// The maximum number of decimal places. + /// Indicates whether to allow negative values. + /// For example, to validate a number with the pattern '999.99', then would be 5 and would be 2. + public static IPropertyRule PrecisionScale(this IPropertyRule rule, Func, int?>? precision, Func, int?>? scale = null, Func, bool>? allowNegatives = null) where TEntity : class where TProperty : struct, IFloatingPoint + => Chain(rule, new DecimalRule(precision, scale, allowNegatives)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.DependsOnClause.cs b/src/CoreEx.Validation/ValidationExtensions.DependsOnClause.cs new file mode 100644 index 00000000..9b8e4bf6 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.DependsOnClause.cs @@ -0,0 +1,17 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Adds a depends on expression () clause to the existing . + /// + /// The entity . + /// The property . + /// The depends on property . + /// The being extended. + /// The depends on property expression. + /// The to support fluent-style method-chaining. + /// Represents a depends on clause; in that specified property () of the entity must have a non-default value, and not have a validation error, to continue. + public static IPropertyRule DependsOn(this IPropertyRule rule, Expression> dependsOnPropertyExpression) where TEntity : class + => AddClause(rule, new DependsOnClause(dependsOnPropertyExpression.ThrowIfNull())); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.DictionaryRule.cs b/src/CoreEx.Validation/ValidationExtensions.DictionaryRule.cs new file mode 100644 index 00000000..42f27c6d --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.DictionaryRule.cs @@ -0,0 +1,65 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a dictionary () validation to the existing . + /// + /// The entity . + /// The key . + /// The value . + /// The being extended. + /// Extends configuration . + public static DictionaryRule, TKey, TValue> Dictionary(this IPropertyRule> rule, Func, TKey, TValue>.With, DictionaryRule, TKey, TValue>.With> with) where TEntity : class where TKey : notnull where TValue : notnull + => (DictionaryRule, TKey, TValue>)Chain(rule, new DictionaryRule, TKey, TValue>(null, null, with)); + + /// + /// Chains a collection () validation to the existing . + /// + /// The entity . + /// The key . + /// The value . + /// The being extended. + /// The maximum count. + /// Extends configuration . + public static DictionaryRule, TKey, TValue> Dictionary(this IPropertyRule> rule, int maxCount, Func, TKey, TValue>.With, DictionaryRule, TKey, TValue>.With>? with = null) where TEntity : class where TKey : notnull where TValue : notnull + => (DictionaryRule, TKey, TValue>)Chain(rule, new DictionaryRule, TKey, TValue>(null, _ => maxCount, with)); + + /// + /// Chains a collection () validation to the existing . + /// + /// The entity . + /// The key . + /// The value . + /// The being extended. + /// The maximum count. + /// Extends configuration . + public static DictionaryRule, TKey, TValue> Dictionary(this IPropertyRule> rule, Func>, int?>? maxCount, Func, TKey, TValue>.With, DictionaryRule, TKey, TValue>.With>? with = null) where TEntity : class where TKey : notnull where TValue : notnull + => (DictionaryRule, TKey, TValue>)Chain(rule, new DictionaryRule, TKey, TValue>(null, maxCount, with)); + + /// + /// Chains a collection () validation to the existing . + /// + /// The entity . + /// The key . + /// The value . + /// The being extended. + /// The minimum count. + /// The maximum count. + /// Extends configuration . + public static DictionaryRule, TKey, TValue> Dictionary(this IPropertyRule> rule, int minCount, int? maxCount, Func, TKey, TValue>.With, DictionaryRule, TKey, TValue>.With>? with = null) where TEntity : class where TKey : notnull where TValue : notnull + => (DictionaryRule, TKey, TValue>)Chain(rule, new DictionaryRule, TKey, TValue>(_ => minCount, _ => maxCount, with)); + + /// + /// Chains a collection () validation to the existing . + /// + /// The entity . + /// The key . + /// The value . + /// The being extended. + /// The minimum count. + /// The maximum count. + /// Extends configuration . + public static DictionaryRule, TKey, TValue> Dictionary(this IPropertyRule> rule, Func>, int>? minCount, Func>, int?>? maxCount, Func, TKey, TValue>.With, DictionaryRule, TKey, TValue>.With>? with = null) where TEntity : class where TKey : notnull where TValue : notnull + => (DictionaryRule, TKey, TValue>)Chain(rule, new DictionaryRule, TKey, TValue>(minCount, maxCount, with)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.EmailRule.cs b/src/CoreEx.Validation/ValidationExtensions.EmailRule.cs new file mode 100644 index 00000000..7a53fab2 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.EmailRule.cs @@ -0,0 +1,24 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains an e-mail () with optional validation to the existing . + /// + /// The entity . + /// The being extended. + /// The maximum string length. + /// The . + public static IPropertyRule Email(this IPropertyRule rule, int maxLength) where TEntity : class + => Chain(rule, new EmailRule(_ => maxLength)); + + /// + /// Chains an e-mail () with optional validation to the existing . + /// + /// The entity . + /// The being extended. + /// The optional maximum string length. + /// The . + public static IPropertyRule Email(this IPropertyRule rule, Func, int?>? maxLength = null) where TEntity : class + => Chain(rule, new EmailRule(maxLength)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.EntityRule.cs b/src/CoreEx.Validation/ValidationExtensions.EntityRule.cs new file mode 100644 index 00000000..c8595944 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.EntityRule.cs @@ -0,0 +1,25 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains an entity () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// Extends configuration . + public static IPropertyRule Entity(this IPropertyRule rule, Action.With> with) where TEntity : class where TProperty : class? + => Chain(rule, new EntityRule(with)); + + /// + /// Chains an entity () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// The . + /// Consider using the as this provides additional validator options (where applicable). + public static IPropertyRule Entity(this IPropertyRule rule, IValidatorEx validator) where TEntity : class where TProperty : class? + => Entity(rule, w => w.WithValidator(validator)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.EnumRule.cs b/src/CoreEx.Validation/ValidationExtensions.EnumRule.cs new file mode 100644 index 00000000..33e637df --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.EnumRule.cs @@ -0,0 +1,77 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains an () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// An optional list of allowed values. + public static IPropertyRule Enum(this IPropertyRule rule, params TProperty[] allowed) where TEntity : class where TProperty : struct, Enum + => Chain(rule, new EnumRule(_ => allowed)); + + /// + /// Chains an () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// An optional list of allowed values. + public static IPropertyRule Enum(this IPropertyRule rule, IEnumerable allowed) where TEntity : class where TProperty : struct, Enum + => Chain(rule, new EnumRule(_ => allowed?.ToArray())); + + /// + /// Chains an () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// An optional list of allowed values. + public static IPropertyRule Enum(this IPropertyRule rule, Func, TProperty[]?>? allowed) where TEntity : class where TProperty : struct, Enum + => Chain(rule, new EnumRule(allowed)); + + /* Nullable-enum */ + + /// + /// Chains an () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// An optional list of allowed values. + public static IPropertyRule Enum(this IPropertyRule rule, params TProperty[] allowed) where TEntity : class where TProperty : struct, Enum + => Chain(rule, new EnumRule.NullableRule(_ => allowed)); + + /// + /// Chains an () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// An optional list of allowed values. + public static IPropertyRule Enum(this IPropertyRule rule, IEnumerable allowed) where TEntity : class where TProperty : struct, Enum + => Chain(rule, new EnumRule.NullableRule(_ => allowed?.ToArray())); + + /// + /// Chains an () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// An optional list of allowed values. + public static IPropertyRule Enum(this IPropertyRule rule, Func, TProperty[]?>? allowed) where TEntity : class where TProperty : struct, Enum + => Chain(rule, new EnumRule.NullableRule(allowed)); + + /* Enum-string */ + + /// + /// Chains a -based () validation to the existing . + /// + /// The entity . + /// The being extended. + /// The configuration function. + public static IPropertyRule Enum(this IPropertyRule rule, Func.EnumWith, EnumStringRule.EnumWith> with) where TEntity : class + => Chain(rule, new EnumStringRule(with)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.ErrorRule.cs b/src/CoreEx.Validation/ValidationExtensions.ErrorRule.cs new file mode 100644 index 00000000..f26e15ca --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.ErrorRule.cs @@ -0,0 +1,51 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a specified () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// The error text. + /// Use a succeeding conditional clause to control whether the is emitted. + public static IPropertyRule Error(this IPropertyRule rule, LText error) where TEntity : class + => Chain(rule, new ErrorRule(error)); + + /// + /// Chains a duplicate () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// Use a succeeding conditional clause to control whether the error () is emitted. + public static IPropertyRule Duplicate(this IPropertyRule rule) where TEntity : class => Error(rule, ValidatorStrings.DuplicateFormat); + + /// + /// Chains a not-found () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// Use a succeeding conditional clause to control whether the error () is emitted. + public static IPropertyRule NotFound(this IPropertyRule rule) where TEntity : class => Error(rule, ValidatorStrings.NotFoundFormat); + + /// + /// Chains an invalid () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// Use a succeeding conditional clause to control whether the error () is emitted. + public static IPropertyRule Invalid(this IPropertyRule rule) where TEntity : class => Error(rule, ValidatorStrings.InvalidFormat); + + /// + /// Chains an invalid () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// Use a succeeding conditional clause to control whether the error () is emitted. + public static IPropertyRule Immutable(this IPropertyRule rule) where TEntity : class => Error(rule, ValidatorStrings.ImmutableFormat); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.InteropRule.cs b/src/CoreEx.Validation/ValidationExtensions.InteropRule.cs new file mode 100644 index 00000000..9c67fa70 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.InteropRule.cs @@ -0,0 +1,26 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains an interop () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// The function to get the . + /// Indicates whether the validation will be performed where the property value is . + public static IPropertyRule Interop(this IPropertyRule rule, Func getValidator, bool validateWhenNull = false) where TEntity : class + => Chain(rule, new InteropRule(getValidator, validateWhenNull)); + + /// + /// Chains an interop () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// The . + /// Indicates whether the validation will be performed where the property value is . + public static IPropertyRule Interop(this IPropertyRule rule, IValidator validator, bool validateWhenNull = false) where TEntity : class + => Interop(rule, () => validator, validateWhenNull); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.MandatoryRule.cs b/src/CoreEx.Validation/ValidationExtensions.MandatoryRule.cs new file mode 100644 index 00000000..503bd107 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.MandatoryRule.cs @@ -0,0 +1,44 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a mandatory () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// Indicates that a validation error should occur when the value is . + /// Indicates that a validation error should occur when the value is considered empty. + public static IPropertyRule Mandatory(this IPropertyRule rule, bool mustNotBeDefault = true, bool mustNotBeEmpty = true) where TEntity : class + => Chain(rule, new MandatoryRule(mustNotBeDefault: _ => mustNotBeDefault, mustNotBeEmpty: _ => mustNotBeEmpty)); + + /// + /// Chains a mandatory () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// Indicates that a validation error should occur when the value is . + /// Indicates that a validation error should occur when the value is considered empty. + public static IPropertyRule Mandatory(this IPropertyRule rule, Func, bool>? mustNotBeDefault, Func, bool>? mustNotBeEmpty) where TEntity : class + => Chain(rule, new MandatoryRule(mustNotBeDefault ?? (_ => true), mustNotBeEmpty ?? (_ => true))); + + /// + /// Chains a null and not empty () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + public static IPropertyRule NotEmpty(this IPropertyRule rule) where TEntity : class + => Chain(rule, new MandatoryRule(mustNotBeDefault: _ => true, mustNotBeEmpty: _ => true)); + + /// + /// Chains a not null only () validation to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + public static IPropertyRule NotNull(this IPropertyRule rule) where TEntity : class + => Chain(rule, new MandatoryRule(mustNotBeDefault: _ => false, mustNotBeEmpty: _ => false)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.NullNoneEmptyRule.cs b/src/CoreEx.Validation/ValidationExtensions.NullNoneEmptyRule.cs new file mode 100644 index 00000000..bbe56b2c --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.NullNoneEmptyRule.cs @@ -0,0 +1,52 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a must be () validation to the existing . + /// + /// The entity . + /// The property . + public static IPropertyRule Null(this IPropertyRule rule) where TEntity : class where TProperty : class? + => Chain(rule, new NullNoneEmptyRule(mustBeNull: _ => true, mustBeDefault: _ => false, mustBeEmpty: _ => false)); + + /// + /// Chains a must be () validation to the existing . + /// + /// The entity . + /// The property . + public static IPropertyRule None(this IPropertyRule rule) where TEntity : class + => Chain(rule, new NullNoneEmptyRule(mustBeNull: _ => false, mustBeDefault: _ => true, mustBeEmpty: _ => false)); + + /// + /// Chains a must be empty () validation to the existing . + /// + /// The entity . + /// The property . + public static IPropertyRule Empty(this IPropertyRule rule) where TEntity : class where TProperty : class? + => Chain(rule, new NullNoneEmptyRule(mustBeNull: _ => false, mustBeDefault: _ => false, mustBeEmpty: _ => true)); + + /// + /// Chains a must be null () validation to the existing . + /// + /// The entity . + /// The property . + public static IPropertyRule Null(this IPropertyRule rule) where TEntity : class where TProperty : struct + => Chain(rule, new NullNoneEmptyRule(mustBeNull: _ => true, mustBeDefault: _ => false, mustBeEmpty: _ => false)); + + /// + /// Chains a must be () validation to the existing . + /// + /// The entity . + /// The property . + public static IPropertyRule None(this IPropertyRule rule) where TEntity : class where TProperty : struct + => Chain(rule, new NullNoneEmptyRule(mustBeNull: _ => false, mustBeDefault: _ => true, mustBeEmpty: _ => false)); + + /// + /// Chains a must be empty () validation to the existing . + /// + /// The entity . + /// The property . + public static IPropertyRule Empty(this IPropertyRule rule) where TEntity : class where TProperty : struct + => Chain(rule, new NullNoneEmptyRule(mustBeNull: _ => false, mustBeDefault: _ => false, mustBeEmpty: _ => true)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.NumericRule.cs b/src/CoreEx.Validation/ValidationExtensions.NumericRule.cs new file mode 100644 index 00000000..96c9ab5f --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.NumericRule.cs @@ -0,0 +1,64 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a numeric value () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// Indicates whether to allow negative values. + public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = true) where TEntity : class where TProperty : INumber + => Chain(rule, new NumericRule(_ => allowNegatives)); + + /// + /// Chains a numeric value () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// Indicates whether to allow negative values. + public static IPropertyRule Numeric(this IPropertyRule rule, Func, bool> allowNegatives) where TEntity : class where TProperty : INumber + => Chain(rule, new NumericRule(allowNegatives)); + + /// + /// Chains a positive-only numeric value () validation. + /// + /// The entity . + /// The property . + /// The being extended. + public static IPropertyRule Positive(this IPropertyRule rule) where TEntity : class where TProperty : INumber + => Chain(rule, new NumericRule(allowNegatives: _ => false)); + + /* Nullable/Struct */ + + /// + /// Chains a numeric value () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// Indicates whether to allow negative values. + public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = true) where TEntity : class where TProperty : struct, INumber + => Chain(rule, new NumericRule(_ => allowNegatives)); + + /// + /// Chains a numeric value () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// Indicates whether to allow negative values. + public static IPropertyRule Numeric(this IPropertyRule rule, Func, bool> allowNegatives) where TEntity : class where TProperty : struct, INumber + => Chain(rule, new NumericRule(allowNegatives)); + + /// + /// Chains a positive-only numeric value () validation. + /// + /// The entity . + /// The property . + /// The being extended. + public static IPropertyRule Positive(this IPropertyRule rule) where TEntity : class where TProperty : struct, INumber + => Chain(rule, new NumericRule(allowNegatives: _ => false)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.ReferenceDataRule.cs b/src/CoreEx.Validation/ValidationExtensions.ReferenceDataRule.cs new file mode 100644 index 00000000..f344d396 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.ReferenceDataRule.cs @@ -0,0 +1,57 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains an () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// Indicates whether to allow an value where is set to . + public static IPropertyRule ReferenceData(this IPropertyRule rule, bool allowInactive = false) where TEntity : class where TProperty : IReferenceData + => Chain(rule, new ReferenceDataRule(allowInactive)); + + /// + /// Chains an () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// Indicates whether to allow an value where is set to . + public static IPropertyRule IsValid(this IPropertyRule rule, bool allowInactive = false) where TEntity : class where TProperty : IReferenceData + => ReferenceData(rule, allowInactive); + + /* ReferenceData-string */ + + /// + /// Chains a -based () validation to the existing . + /// + /// The entity . + /// The being extended. + /// The function. + public static IPropertyRule ReferenceData(this IPropertyRule rule, Func.ReferenceDataWith, ReferenceDataCodeRule.ReferenceDataWith> with) where TEntity : class + => Chain(rule, new ReferenceDataCodeRule(with)); + + /* IReferenceDataCodeCollection */ + + /// + /// Chains an () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// Indicates whether to allow an value where is set to . + public static IPropertyRule ReferenceDataCodes(this IPropertyRule rule, bool allowInactive = false) where TEntity : class where TProperty : IReferenceDataCodeCollection + => Chain(rule, new ReferenceDataCodeCollectionRule(allowInactive)); + + /// + /// Chains an () validation. + /// + /// The entity . + /// The property . + /// The being extended. + /// Indicates whether to allow an value where is set to . + public static IPropertyRule AreValid(this IPropertyRule rule, bool allowInactive = false) where TEntity : class where TProperty : IReferenceDataCodeCollection + => ReferenceDataCodes(rule, allowInactive); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.StringRule.cs b/src/CoreEx.Validation/ValidationExtensions.StringRule.cs new file mode 100644 index 00000000..14b01d33 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.StringRule.cs @@ -0,0 +1,89 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a () validation to the existing . + /// + /// The entity . + /// The being extended. + /// The maximum string length. + /// The . + public static IPropertyRule String(this IPropertyRule rule, int maxLength) where TEntity : class + => Chain(rule, new StringRule(maxLength: _ => maxLength)); + + /// + /// Chains a () and validation to the existing . + /// + /// The entity . + /// The being extended. + /// The minimum string length. + /// The maximum string length. + /// The . + /// The . + public static IPropertyRule String(this IPropertyRule rule, int minLength, int? maxLength, Regex? regex = null) where TEntity : class + => Chain(rule, new StringRule(minLength: _ => minLength, maxLength: _ => maxLength, regex: _ => regex)); + + /// + /// Chains a () and validation to the existing . + /// + /// The entity . + /// The being extended. + /// The minimum string length. + /// The maximum string length. + /// The . + /// The . + public static IPropertyRule String(this IPropertyRule rule, Func, int>? minLength, Func, int?>? maxLength, Func, Regex?>? regex = null) where TEntity : class + => Chain(rule, new StringRule(minLength: minLength, maxLength: maxLength, regex: regex)); + + /// + /// Chains a () validation to the existing . + /// + /// The entity . + /// The being extended. + /// The . + /// The . + public static IPropertyRule String(this IPropertyRule rule, Regex regex) where TEntity : class + => Chain(rule, new StringRule(regex: _ => regex)); + + /// + /// Chains a () validation to the existing . + /// + /// The entity . + /// The being extended. + /// The . + /// The . + public static IPropertyRule Matches(this IPropertyRule rule, Regex regex) where TEntity : class + => Chain(rule, new StringRule(regex: _ => regex)); + + /// + /// Chains a () validation to the existing . + /// + /// The entity . + /// The being extended. + /// The exact string length. + /// The optional . + /// The . + public static IPropertyRule Length(this IPropertyRule rule, int exactLength, Regex? regex = null) where TEntity : class + => Chain(rule, new StringRule(minLength: _ => exactLength, maxLength: _ => exactLength, regex: _ => regex)); + + /// + /// Chains a () validation to the existing . + /// + /// The entity . + /// The being extended. + /// The minimum string length. + /// The . + public static IPropertyRule MinimumLength(this IPropertyRule rule, int minLength) where TEntity : class + => Chain(rule, new StringRule(minLength: _ => minLength)); + + /// + /// Chains a () validation to the existing . + /// + /// The entity . + /// The being extended. + /// The maximum string length. + /// The . + public static IPropertyRule MaximumLength(this IPropertyRule rule, int maxLength) where TEntity : class + => Chain(rule, new StringRule(maxLength: _ => maxLength)); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.WhenClause.cs b/src/CoreEx.Validation/ValidationExtensions.WhenClause.cs new file mode 100644 index 00000000..92bc63e6 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.WhenClause.cs @@ -0,0 +1,80 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Adds a entity predicate () clause to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// A predicate to determine whether the current rule is to executed. + /// The to support fluent-style method-chaining. + public static IPropertyRule WhenEntity(this IPropertyRule rule, Predicate predicate) where TEntity : class + => AddClause(rule, new WhenClause((c, _) => Task.FromResult(predicate.ThrowIfNull().Invoke(c.Entity)))); + + /// + /// Adds a property predicate () clause to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// A predicate to determine whether the current rule is to executed. + /// The to support fluent-style method-chaining. + public static IPropertyRule WhenValue(this IPropertyRule rule, Predicate predicate) where TEntity : class + => AddClause(rule, new WhenClause((c, _) => Task.FromResult(predicate.ThrowIfNull().Invoke(c.Value)))); + + /// + /// Adds a when () clause to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// A to determine whether the current rule is to executed. + /// The to support fluent-style method-chaining. + public static IPropertyRule When(this IPropertyRule rule, bool when) where TEntity : class + => AddClause(rule, new WhenClause((_, _) => Task.FromResult(when))); + + /// + /// Adds a function () clause to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// A function to determine whether the current rule is to executed. + /// The to support fluent-style method-chaining. + public static IPropertyRule When(this IPropertyRule rule, Func when) where TEntity : class + => AddClause(rule, new WhenClause((c, _) => Task.FromResult(when.ThrowIfNull().Invoke()))); + + /// + /// Adds a function () clause to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// A function to determine whether the current rule is to executed. + /// The to support fluent-style method-chaining. + public static IPropertyRule When(this IPropertyRule rule, Func, bool> when) where TEntity : class + => AddClause(rule, new WhenClause((c, _) => Task.FromResult(when.ThrowIfNull().Invoke(c)))); + + /// + /// Adds an function () clause to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// A to determine whether the current rule is to executed. + /// The to support fluent-style method-chaining. + public static IPropertyRule When(this IPropertyRule rule, PredicateAsync whenAsync) where TEntity : class + => AddClause(rule, new WhenClause(whenAsync)); + + /// + /// Adds a must have non- value () clause to the existing . + /// + /// The entity . + /// The property . + /// The being extended. + /// The to support fluent-style method-chaining. + public static IPropertyRule WhenHasValue(this IPropertyRule rule) where TEntity : class + => AddClause(rule, new WhenClause((c, _) => Task.FromResult(Comparer.Default.Compare(c.Value, default!) != 0))); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.WildcardRule.cs b/src/CoreEx.Validation/ValidationExtensions.WildcardRule.cs new file mode 100644 index 00000000..0e757a42 --- /dev/null +++ b/src/CoreEx.Validation/ValidationExtensions.WildcardRule.cs @@ -0,0 +1,25 @@ +namespace CoreEx.Validation; + +public static partial class ValidationExtensions +{ + /// + /// Chains a () validation to the existing . + /// + /// The entity . + /// The being extended. + /// The optional ; defaults to . + /// The . + public static IPropertyRule Wildcard(this IPropertyRule rule, Wildcard? wildcard = null) where TEntity : class + => Chain(rule, new WildcardRule(_ => wildcard)); + + /// + /// Chains a () validation to the existing . + /// + /// The entity . + /// The being extended. + /// The optional ; defaults to . + /// The . + public static IPropertyRule Wildcard(this IPropertyRule rule, Func, Wildcard?> wildcard) where TEntity : class + => Chain(rule, new WildcardRule(wildcard)); + +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationExtensions.cs b/src/CoreEx.Validation/ValidationExtensions.cs index bf036046..a10557fc 100644 --- a/src/CoreEx.Validation/ValidationExtensions.cs +++ b/src/CoreEx.Validation/ValidationExtensions.cs @@ -1,1796 +1,154 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using CoreEx.Http; -using CoreEx.Localization; -using CoreEx.RefData; -using CoreEx.Results; -using CoreEx.Validation.Clauses; -using CoreEx.Validation.Rules; -using CoreEx.Wildcards; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq.Expressions; -using System.Runtime.CompilerServices; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation +/// +/// Provides standard extensions. +/// +public static partial class ValidationExtensions { /// - /// Provides extension methods required by the validation framework (including support for fluent-style method chaining). + /// Adds a to the preceding . /// - public static class ValidationExtensions + /// The entity . + /// The property . + /// The being extended. + /// The to add. + /// The to support fluent-style method-chaining. + public static IPropertyRule AddClause(IPropertyRule rule, IPropertyClause clause) where TEntity : class { - #region Text - - /// - /// Updates the rule friendly name text used in validation messages (see . - /// - /// The entity . - /// The property . - /// The being extended. - /// The text for the rule. - /// A . - public static IPropertyRule Text(this IPropertyRule rule, LText text) where TEntity : class - { - rule.ThrowIfNull(nameof(rule)).Text = text; - return rule; - } - - #endregion - - #region When - - /// - /// Adds a to this where the must be true for the rule to be validated. - /// - /// The being extended. - /// A function to determine whether the preceeding rule is to be validated. - /// The . - public static IPropertyRule When(this IPropertyRule rule, Predicate predicate) where TEntity : class - { - if (predicate == null) - return rule; - - rule.ThrowIfNull(nameof(rule)).AddClause(new WhenClause(predicate)); - return rule; - } - - /// - /// Adds a to this where the must be true for the rule to be validated. - /// - /// The being extended. - /// A function to determine whether the preceeding rule is to be validated. - /// The . - public static IPropertyRule WhenValue(this IPropertyRule rule, Predicate predicate) where TEntity : class - { - if (predicate == null) - return rule; - - rule.ThrowIfNull(nameof(rule)).AddClause(new WhenClause(predicate)); - return rule; - } - - /// - /// Adds a to this where the must have a value (i.e. not the default value for the Type) for the rule to be validated. - /// - /// The being extended. - /// The . - public static IPropertyRule WhenHasValue(this IPropertyRule rule) where TEntity : class - => WhenValue(rule, (TProperty pv) => Comparer.Default.Compare(pv, default!) != 0); - - /// - /// Adds a to this which must be true for the rule to be validated. - /// - /// The being extended. - /// A function to determine whether the preceeding rule is to be validated. - /// The . - public static IPropertyRule When(this IPropertyRule rule, Func when) where TEntity : class - { - if (when == null) - return rule; - - rule.ThrowIfNull(nameof(rule)).AddClause(new WhenClause(when)); - return rule; - } - - /// - /// Adds a to this which must be true for the rule to be validated. - /// - /// The being extended. - /// A to determine whether the preceeding rule is to be validated. - /// The . - public static IPropertyRule When(this IPropertyRule rule, bool when) where TEntity : class - => When(rule, () => when); - - /// - /// Adds a to this that states that the - /// is equal to the specified - /// (). - /// - /// The being extended. - /// The . - /// The . - public static IPropertyRule WhenOperation(this IPropertyRule rule, OperationType operationType) where TEntity : class - => When(rule, x => ExecutionContext.Current.OperationType == operationType); - - /// - /// Adds a to this that states that the - /// is not equal to the specified - /// (). - /// - /// The being extended. - /// The . - /// The . - public static IPropertyRule WhenNotOperation(this IPropertyRule rule, OperationType operationType) where TEntity : class - => When(rule, x => ExecutionContext.Current.OperationType != operationType); - - #endregion - - #region Mandatory - - /// - /// Adds a mandatory validation (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Mandatory(this IPropertyRule rule, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new MandatoryRule { ErrorText = errorText }); - - /// - /// Adds a not empty validation (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - /// This is a synonym for . - public static IPropertyRule NotEmpty(this IPropertyRule rule, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new MandatoryRule { ErrorText = errorText }); - - /// - /// Adds a not null validation (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule NotNull(this IPropertyRule rule, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new NotNullRule { ErrorText = errorText }); - - #endregion - - #region None - - /// - /// Adds a none validation () where it is expected that the value equals its default. - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule None(this IPropertyRule rule, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new NoneRule { ErrorText = errorText }); - - /// - /// Adds an empty validation () where it is expected that the value equals its default. - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - /// This is a synonym for . - public static IPropertyRule Empty(this IPropertyRule rule, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new NoneRule { ErrorText = errorText }); - - /// - /// Adds a null validation () where it is expected that the value is null. - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Null(this IPropertyRule rule, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new NullRule { ErrorText = errorText }); - - #endregion - - #region Must - - /// - /// Adds a validation where the rule must return true to be considered valid (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The must predicate. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Must(this IPropertyRule rule, Predicate predicate, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new MustRule(predicate) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule function must return true to be considered valid (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The must function. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Must(this IPropertyRule rule, Func must, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new MustRule(must) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule value be true to be considered valid (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The must value. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Must(this IPropertyRule rule, bool must, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new MustRule(() => must) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule function must return true to be considered valid (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The must function. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule MustAsync(this IPropertyRule rule, Func> mustAsync, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new MustRule(mustAsync) { ErrorText = errorText }); - - #endregion - - #region Exists - - /// - /// Adds a validation where the rule exists return true to verify it exists (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The exists predicate. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Exists(this IPropertyRule rule, Predicate predicate, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ExistsRule(predicate) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule function exists return true to verify it exists (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The exists function. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule ExistsAsync(this IPropertyRule rule, Func> exists, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ExistsRule(exists) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule resultant value is true to verify it exists (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The exists value. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Exists(this IPropertyRule rule, bool exists, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ExistsRule((_, __) => Task.FromResult(exists)) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule function must return not null to verify it exists (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The exists function. - /// The error message format text (overrides the default). - /// Where the resultant value is an then existence is confirmed when and the the underlying is not null. - /// A . - public static IPropertyRule ValueExistsAsync(this IPropertyRule rule, Func> exists, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ExistsRule(exists) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule resultant value is not null to verify it exists (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The exists function. - /// The error message format text (overrides the default). - /// Where the resultant value is an then existence is confirmed when and the the underlying is not null. - /// A . - public static IPropertyRule ValueExists(this IPropertyRule rule, object? exists, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ExistsRule((_, __) => Task.FromResult(exists != null)) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule function must return a successful response to verify it exists (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The function. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule AgentExistsAsync(this IPropertyRule rule, Func> agentResult, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ExistsRule(async (v, ct) => await agentResult(v, ct).ConfigureAwait(false)) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule function must return a successful response to verify it exists (see ). - /// - /// The entity . - /// The property . - /// The corresponding . - /// The being extended. - /// The function. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule AgentExistsAsync(this IPropertyRule rule, Func>> agentResult, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ExistsRule(async (v, ct) => await agentResult(v, ct).ConfigureAwait(false)) { ErrorText = errorText }); - - #endregion - - #region Duplicate - - /// - /// Adds a validation where the rule must return false to not be considered a duplicate (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The must predicate. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Duplicate(this IPropertyRule rule, Predicate predicate, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DuplicateRule(predicate) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule function must return false to not be considered a duplicate (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The duplicate function. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Duplicate(this IPropertyRule rule, Func duplicate, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DuplicateRule(duplicate) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule value must be false to not be considered a duplicate (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The duplicate value. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Duplicate(this IPropertyRule rule, bool duplicate, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DuplicateRule(() => duplicate) { ErrorText = errorText }); - - /// - /// Adds a validation where considered a duplicate (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Duplicate(this IPropertyRule rule, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DuplicateRule(() => true) { ErrorText = errorText }); - - #endregion - - #region Immutable - - /// - /// Adds a validation where the rule must return true to be considered valid (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The must predicate. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Immutable(this IPropertyRule rule, Predicate predicate, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ImmutableRule(predicate) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule function must return true to be considered valid (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The must function. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Immutable(this IPropertyRule rule, Func immutable, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ImmutableRule(immutable) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule function must return true to be considered valid (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The must function. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule ImmutableAsync(this IPropertyRule rule, Func> immutableAsync, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ImmutableRule(immutableAsync) { ErrorText = errorText }); - - /// - /// Adds a validation where the rule value be true to be considered valid (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The must value. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Immutable(this IPropertyRule rule, bool immutable, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ImmutableRule(() => immutable) { ErrorText = errorText }); - - /// - /// Adds a validation where considered immutable (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Immutable(this IPropertyRule rule, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ImmutableRule(() => false) { ErrorText = errorText }); - - #endregion - - #region Between - - /// - /// Adds a between comparision validation against a specified from and to value (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare from value. - /// The compare to value. - /// The compare from text to be passed for the error message (default is to use ). - /// The compare to text to be passed for the error message (default is to use ). - /// Indicates whether the between comparison is exclusive or inclusive (default). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Between(this IPropertyRule rule, TProperty compareFromValue, TProperty compareToValue, LText? compareFromText = null, LText? compareToText = null, bool exclusiveBetween = false, LText ? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new BetweenRule(compareFromValue, compareToValue, compareFromText, compareToText, exclusiveBetween) { ErrorText = errorText }); - - /// - /// Adds a between comparision validation against from and to values returned by functions (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare from value function. - /// The compare to value function. - /// The compare from text function (default is to use the result of the ). - /// The compare to text function (default is to use the result of the ). - /// Indicates whether the between comparison is exclusive or inclusive (default). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Between(this IPropertyRule rule, Func compareFromValueFunction, Func compareToValueFunction, Func? compareFromTextFunction = null, Func? compareToTextFunction = null, bool exclusiveBetween = false, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new BetweenRule(compareFromValueFunction, compareToValueFunction, compareFromTextFunction, compareToTextFunction, exclusiveBetween) { ErrorText = errorText }); - - /// - /// Adds a between comparision validation against from and to values returned by async functions (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare from value function. - /// The compare to value function. - /// The compare from text function (default is to use the result of the ). - /// The compare to text function (default is to use the result of the ). - /// Indicates whether the between comparison is exclusive or inclusive (default). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule BetweenAsync(this IPropertyRule rule, Func> compareFromValueFunctionAsync, Func> compareToValueFunctionAsync, Func? compareFromTextFunction = null, Func? compareToTextFunction = null, bool exclusiveBetween = false, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new BetweenRule(compareFromValueFunctionAsync, compareToValueFunctionAsync, compareFromTextFunction, compareToTextFunction, exclusiveBetween) { ErrorText = errorText }); - - /// - /// Adds an inclusive between comparision validation against a specified from and to value (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare from value. - /// The compare to value. - /// The compare from text to be passed for the error message (default is to use ). - /// The compare to text to be passed for the error message (default is to use ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule InclusiveBetween(this IPropertyRule rule, TProperty compareFromValue, TProperty compareToValue, LText? compareFromText = null, LText? compareToText = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new BetweenRule(compareFromValue, compareToValue, compareFromText, compareToText, false) { ErrorText = errorText }); - - /// - /// Adds a inclusive between comparision validation against from and to values returned by functions (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare from value function. - /// The compare to value function. - /// The compare from text function (default is to use the result of the ). - /// The compare to text function (default is to use the result of the ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule InclusiveBetween(this IPropertyRule rule, Func compareFromValueFunction, Func compareToValueFunction, Func? compareFromTextFunction = null, Func? compareToTextFunction = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new BetweenRule(compareFromValueFunction, compareToValueFunction, compareFromTextFunction, compareToTextFunction, false) { ErrorText = errorText }); - - /// - /// Adds an exclusive between comparision validation against a specified from and to value (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare from value. - /// The compare to value. - /// The compare from text to be passed for the error message (default is to use ). - /// The compare to text to be passed for the error message (default is to use ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule ExclusiveBetween(this IPropertyRule rule, TProperty compareFromValue, TProperty compareToValue, LText? compareFromText = null, LText? compareToText = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new BetweenRule(compareFromValue, compareToValue, compareFromText, compareToText, true) { ErrorText = errorText }); - - /// - /// Adds a exclusive between comparision validation against from and to values returned by functions (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare from value function. - /// The compare to value function. - /// The compare from text function (default is to use the result of the ). - /// The compare to text function (default is to use the result of the ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule ExclusiveBetween(this IPropertyRule rule, Func compareFromValueFunction, Func compareToValueFunction, Func? compareFromTextFunction = null, Func? compareToTextFunction = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new BetweenRule(compareFromValueFunction, compareToValueFunction, compareFromTextFunction, compareToTextFunction, true) { ErrorText = errorText }); - - #endregion - - #region CompareValue - - /// - /// Adds a comparision validation against a specified value (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to value. - /// The compare to text to be passed for the error message (default is to use ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Equal(this IPropertyRule rule, TProperty compareToValue, LText? compareToText = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.Equal, compareToValue, compareToText) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a value returned by a function (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to function. - /// The compare to text function (default is to use the result of the ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Equal(this IPropertyRule rule, Func compareToValueFunction, Func? compareToTextFunction = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.Equal, compareToValueFunction, compareToTextFunction) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a specified value (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to value. - /// The compare to text to be passed for the error message (default is to use ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule NotEqual(this IPropertyRule rule, TProperty compareToValue, LText? compareToText = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.NotEqual, compareToValue, compareToText) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a value returned by a function (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to function. - /// The compare to text function (default is to use the result of the ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule NotEqual(this IPropertyRule rule, Func compareToValueFunction, Func? compareToTextFunction = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.NotEqual, compareToValueFunction, compareToTextFunction) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a specified value (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to value. - /// The compare to text to be passed for the error message (default is to use ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule LessThan(this IPropertyRule rule, TProperty compareToValue, LText? compareToText = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.LessThan, compareToValue, compareToText) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a value returned by a function (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to function. - /// The compare to text function (default is to use the result of the ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule LessThan(this IPropertyRule rule, Func compareToValueFunction, Func? compareToTextFunction = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.LessThan, compareToValueFunction, compareToTextFunction) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a specified value (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to value. - /// The compare to text to be passed for the error message (default is to use ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule LessThanOrEqualTo(this IPropertyRule rule, TProperty compareToValue, LText? compareToText = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.LessThanEqual, compareToValue, compareToText) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a value returned by a function (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to function. - /// The compare to text function (default is to use the result of the ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule LessThanOrEqualTo(this IPropertyRule rule, Func compareToValueFunction, Func? compareToTextFunction = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.LessThanEqual, compareToValueFunction, compareToTextFunction) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a specified value (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to value. - /// The compare to text to be passed for the error message (default is to use ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule GreaterThan(this IPropertyRule rule, TProperty compareToValue, LText? compareToText = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.GreaterThan, compareToValue, compareToText) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a value returned by a function (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to function. - /// The compare to text function (default is to use the result of the ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule GreaterThan(this IPropertyRule rule, Func compareToValueFunction, Func? compareToTextFunction = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.GreaterThan, compareToValueFunction, compareToTextFunction) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a specified value (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to value. - /// The compare to text to be passed for the error message (default is to use ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule GreaterThanOrEqualTo(this IPropertyRule rule, TProperty compareToValue, LText? compareToText = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.GreaterThanEqual, compareToValue, compareToText) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a value returned by a function (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to function. - /// The compare to text function (default is to use the result of the ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule GreaterThanOrEqualTo(this IPropertyRule rule, Func compareToValueFunction, Func? compareToTextFunction = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(CompareOperator.GreaterThanEqual, compareToValueFunction, compareToTextFunction) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a specified value (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The . - /// The compare to value. - /// The compare to text to be passed for the error message (default is to use ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule CompareValue(this IPropertyRule rule, CompareOperator compareOperator, TProperty compareToValue, LText? compareToText = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(compareOperator, compareToValue, compareToText) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a value returned by a function (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The . - /// The compare to function. - /// The compare to text function (default is to use the result of the ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule CompareValue(this IPropertyRule rule, CompareOperator compareOperator, Func compareToValueFunction, Func? compareToTextFunction = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(compareOperator, compareToValueFunction, compareToTextFunction) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against a value returned by an async function (). - /// - /// The entity . - /// The property . - /// The being extended. - /// The . - /// The compare to function. - /// The compare to text function (default is to use the result of the ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule CompareValueAsync(this IPropertyRule rule, CompareOperator compareOperator, Func> compareToValueFunctionAsync, Func? compareToTextFunction = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValueRule(compareOperator, compareToValueFunctionAsync, compareToTextFunction) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against one or more specified values (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to values. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule CompareValues(this IPropertyRule rule, IEnumerable compareToValues, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValuesRule(compareToValues) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against one or more specified values (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The compare to values function. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule CompareValues(this IPropertyRule rule, Func>> compareToValuesFunctionAsync, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValuesRule(compareToValuesFunctionAsync) { ErrorText = errorText }); - - /// - /// Adds a comparision validation against one or more specified values (see ). - /// - /// The entity . - /// The being extended. - /// The compare to values. - /// Indicates whether to ignore the casing of the value when comparing. - /// Indicates whether to override the underlying property value with the corresponding matched value. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule CompareValues(this IPropertyRule rule, IEnumerable compareToValues, bool ignoreCase, bool overrideValue = false, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CompareValuesRule(compareToValues) { EqualityComparer = ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal, OverrideValue = overrideValue, ErrorText = errorText }); - - #endregion - - #region CompareProperty - - /// - /// Adds a comparision validation against a specified property (see ). - /// - /// The entity . - /// The property . - /// The compare to property . - /// The being extended. - /// The . - /// The to reference the compare to entity property. - /// The compare to text to be passed for the error message (default is to use ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule CompareProperty(this IPropertyRule rule, CompareOperator compareOperator, Expression> compareToPropertyExpression, LText? compareToText = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new ComparePropertyRule(compareOperator, compareToPropertyExpression, compareToText) { ErrorText = errorText }); - - #endregion - - #region String - - /// - /// Adds a validation with a maximum length (see ). - /// - /// The entity . - /// The being extended. - /// The maximum string length. - /// The . - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule String(this IPropertyRule rule, int maxLength, Regex? regex = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new StringRule { MaxLength = maxLength, Regex = regex, ErrorText = errorText }); - - /// - /// Adds a validation with a minimum and maximum length (see ). - /// - /// The entity . - /// The being extended. - /// The minimum string length. - /// The maximum string length. - /// The . - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule String(this IPropertyRule rule, int minLength, int? maxLength, Regex? regex = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new StringRule { MinLength = minLength, MaxLength = maxLength, Regex = regex, ErrorText = errorText }); - - /// - /// Adds a validation with a (see ). - /// - /// The entity . - /// The being extended. - /// The . - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule String(this IPropertyRule rule, Regex? regex = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new StringRule { Regex = regex, ErrorText = errorText }); - - /// - /// Adds a validation with a (see ). - /// - /// The entity . - /// The being extended. - /// The . - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Matches(this IPropertyRule rule, Regex? regex = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new StringRule { Regex = regex, ErrorText = errorText }); - - /// - /// Adds a validation with an exact length (see ). - /// - /// The entity . - /// The being extended. - /// The exact string length. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Length(this IPropertyRule rule, int exactLength, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new StringRule { MinLength = exactLength, MaxLength = exactLength, ErrorText = errorText }); - - /// - /// Adds a validation with a minimum length (see ). - /// - /// The entity . - /// The being extended. - /// The minimum string length. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule MinimumLength(this IPropertyRule rule, int minimumLength, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new StringRule { MinLength = minimumLength, ErrorText = errorText }); - - /// - /// Adds a validation with a maximum length (see ). - /// - /// The entity . - /// The being extended. - /// The maximum string length. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule MaximumLength(this IPropertyRule rule, int maximumLength, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new StringRule { MaxLength = maximumLength, ErrorText = errorText }); - - #endregion - - #region Email - - /// - /// Adds an e-mail validation (see ). - /// - /// The entity . - /// The being extended. - /// The maximum string length for the e-mail address; defaults to 254. - /// The error message format text (overrides the default). - /// A . - /// The maximum length for an email address is '254' or '256' or '320' as per this article, - /// in the absense of a definitive answer '254' has been chosen as the default. As it appears there is no real correct answer, simply adjust accordingly. - public static IPropertyRule Email(this IPropertyRule rule, int? maxLength = 254, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new EmailRule { MaxLength = maxLength, ErrorText = errorText }); - - /// - /// Adds an e-mail validation (see ). - /// - /// The entity . - /// The being extended. - /// The maximum string length for the e-mail address; defaults to 254. - /// The error message format text (overrides the default). - /// A . - /// The maximum length for an email address is '254' or '256' or '320' as per this article, - /// in the absense of a definitive answer '254' has been chosen as the default. As it appears there is no real correct answer, simply adjust accordingly. - public static IPropertyRule EmailAddress(this IPropertyRule rule, int? maxLength = 254, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new EmailRule { MaxLength = maxLength, ErrorText = errorText }); - - #endregion - - #region Enum - - /// - /// Adds an validation to ensure that the value has been defined (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Enum(this IPropertyRule rule, LText? errorText = null) where TEntity : class where TProperty : struct, Enum - => rule.ThrowIfNull(nameof(rule)).AddRule(new EnumRule { ErrorText = errorText }); - - /// - /// Adds an validation to ensure that the value has been defined (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Enum(this IPropertyRule rule, LText? errorText = null) where TEntity : class where TProperty : struct, Enum - => rule.ThrowIfNull(nameof(rule)).AddRule(new NullableEnumRule { ErrorText = errorText }); - - /// - /// Adds an validation to ensure that the value has been defined (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule IsInEnum(this IPropertyRule rule, LText? errorText = null) where TEntity : class where TProperty : struct, Enum - => rule.ThrowIfNull(nameof(rule)).AddRule(new EnumRule { ErrorText = errorText }); - - /// - /// Adds an validation to ensure that the value has been defined (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule IsIsEnum(this IPropertyRule rule, LText? errorText = null) where TEntity : class where TProperty : struct, Enum - => rule.ThrowIfNull(nameof(rule)).AddRule(new NullableEnumRule { ErrorText = errorText }); - - /// - /// Enables the addition of an using an to validate against a specified . - /// - /// The entity . - /// The being extended. - /// A . - public static EnumValueRuleAs Enum(this IPropertyRule rule) where TEntity : class - => new(rule.ThrowIfNull(nameof(rule))) { }; - - #endregion - - #region Wildcard - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// The configuration (defaults to ). - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Wildcard(this IPropertyRule rule, Wildcard? wildcard = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new WildcardRule { Wildcard = wildcard, ErrorText = errorText }); - - #endregion - - #region Numeric - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The maximum digits. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = false, int? maxDigits = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { AllowNegatives = allowNegatives, MaxDigits = maxDigits, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The maximum digits. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = false, int? maxDigits = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { AllowNegatives = allowNegatives, MaxDigits = maxDigits, ErrorText = errorText }); - - /// - /// Adds a validation (see ); - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The maximum digits. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = false, int? maxDigits = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { AllowNegatives = allowNegatives, MaxDigits = maxDigits, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The maximum digits. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = false, int? maxDigits = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { AllowNegatives = allowNegatives, MaxDigits = maxDigits, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The maximum digits (including decimal places). - /// The maximum number of decimal places. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = false, int? maxDigits = null, int? decimalPlaces = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { AllowNegatives = allowNegatives, MaxDigits = maxDigits, DecimalPlaces = decimalPlaces, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The maximum digits (including decimal places). - /// The maximum number of decimal places. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = false, int? maxDigits = null, int? decimalPlaces = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { AllowNegatives = allowNegatives, MaxDigits = maxDigits, DecimalPlaces = decimalPlaces, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// The maximum digits (including decimal places). - /// The maximum number of decimal places. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule PrecisionScale(this IPropertyRule rule, int? precision = null, int? scale = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { MaxDigits = precision, DecimalPlaces = scale, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// The maximum digits (including decimal places). - /// The maximum number of decimal places. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule PrecisionScale(this IPropertyRule rule, int? precision = null, int? scale = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { MaxDigits = precision, DecimalPlaces = scale, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = false, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new NumericRule { AllowNegatives = allowNegatives, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = false, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new NumericRule { AllowNegatives = allowNegatives, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// The maximum digits (including decimal places). - /// The maximum number of decimal places. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule PrecisionScale(this IPropertyRule rule, int? precision = null, int? scale = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { MaxDigits = precision, DecimalPlaces = scale, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// The maximum digits (including decimal places). - /// The maximum number of decimal places. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule PrecisionScale(this IPropertyRule rule, int? precision = null, int? scale = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { MaxDigits = precision, DecimalPlaces = scale, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = false, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new NumericRule { AllowNegatives = allowNegatives, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Numeric(this IPropertyRule rule, bool allowNegatives = false, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new NumericRule { AllowNegatives = allowNegatives, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// The maximum digits (including decimal places). - /// The maximum number of decimal places. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule PrecisionScale(this IPropertyRule rule, int? precision = null, int? scale = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { MaxDigits = precision, DecimalPlaces = scale, ErrorText = errorText }); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The being extended. - /// The maximum digits (including decimal places). - /// The maximum number of decimal places. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule PrecisionScale(this IPropertyRule rule, int? precision = null, int? scale = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule { MaxDigits = precision, DecimalPlaces = scale, ErrorText = errorText }); - - #endregion - - #region Currency - - /// - /// Adds a currency () validation (see for an ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The maximum digits (including decimal places). - /// The that the decimal places will be derived from; - /// where null will be used as a default. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Currency(this IPropertyRule rule, bool allowNegatives = false, int? maxDigits = null, NumberFormatInfo? currencyFormatInfo = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule - { - AllowNegatives = allowNegatives, - MaxDigits = maxDigits, - DecimalPlaces = currencyFormatInfo == null ? NumberFormatInfo.CurrentInfo.CurrencyDecimalDigits : currencyFormatInfo.CurrencyDecimalDigits, - ErrorText = errorText - }); - - /// - /// Adds a currency validation (see ). - /// - /// The entity . - /// The being extended. - /// Indicates whether to allow negative values. - /// The maximum digits (including decimal places). - /// The that the decimal places will be derived from; - /// where null will be used as a default. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule Currency(this IPropertyRule rule, bool allowNegatives = false, int? maxDigits = null, NumberFormatInfo? currencyFormatInfo = null, LText? errorText = null) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new DecimalRule - { - AllowNegatives = allowNegatives, - MaxDigits = maxDigits, - DecimalPlaces = currencyFormatInfo == null ? NumberFormatInfo.CurrentInfo.CurrencyDecimalDigits : currencyFormatInfo.CurrencyDecimalDigits, - ErrorText = errorText - }); - - #endregion - - #region ReferenceData - - /// - /// Adds a validation (see ) to ensure the value is valid. - /// - /// The entity . - /// The property (must inherit from ). - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule IsValid(this IPropertyRule rule, LText? errorText = null) where TEntity : class where TProperty : IReferenceData? - => rule.ThrowIfNull(nameof(rule)).AddRule(new ReferenceDataRule { ErrorText = errorText }); - - /// - /// Adds a validation (see ) to ensure the list of SIDs are valid. - /// - /// The entity . - /// The property (must inherit from ). - /// The being extended. - /// Indicates whether duplicate values are allowed. - /// The minimum count. - /// The maximum count. - /// The error message format text (overrides the default). - /// A . - public static IPropertyRule AreValid(this IPropertyRule rule, bool allowDuplicates = false, int minCount = 0, int? maxCount = null, LText? errorText = null) where TEntity : class where TProperty : IReferenceDataCodeList? - => rule.ThrowIfNull(nameof(rule)).AddRule(new ReferenceDataSidListRule { AllowDuplicates = allowDuplicates, MinCount = minCount, MaxCount = maxCount, ErrorText = errorText }); - - /// - /// Adds a validation (see to ensure the Code is valid. - /// - /// The entity . - /// The being extended. - /// The error message format text (overrides the default). - /// A . - public static ReferenceDataCodeRuleAs RefDataCode(this IPropertyRule rule, LText? errorText = null) where TEntity : class - => new(rule.ThrowIfNull(nameof(rule)), errorText); - - #endregion - - #region Collection - - /// - /// Adds a collection () validation (see ) where the can be specified. - /// - /// The entity . - /// The property . - /// The being extended. - /// The minimum count. - /// The maximum count. - /// The item configuration. - /// Indicates whether the underlying collection item must not be null. - /// A . - public static IPropertyRule Collection(this IPropertyRule rule, int minCount = 0, int? maxCount = null, ICollectionRuleItem? item = null, bool allowNullItems = false) where TEntity : class where TProperty : System.Collections.IEnumerable? - { - var cr = new CollectionRule { MinCount = minCount, MaxCount = maxCount, Item = item, AllowNullItems = allowNullItems }; - return rule.ThrowIfNull(nameof(rule)).AddRule(cr); - } - - /// - /// Adds a collection () validation (see ) for the specified . - /// - /// The entity . - /// The property . - /// The property item . - /// The being extended. - /// The property item . - /// The minimum count. - /// The maximum count. - /// Indicates whether the underlying collection item must not be null. - /// A . - public static IPropertyRule Collection(this IPropertyRule rule, IValidatorEx itemValidator, int minCount = 0, int? maxCount = null, bool allowNullItems = false) where TEntity : class where TProperty : IEnumerable? - { - var cr = new CollectionRule { MinCount = minCount, MaxCount = maxCount, Item = CollectionRuleItem.Create(itemValidator), AllowNullItems = allowNullItems }; - return rule.ThrowIfNull(nameof(rule)).AddRule(cr); - } - - /// - /// Adds a collection () minimum count validation (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The minimum count. - /// A . - public static IPropertyRule MinimumCount(this IPropertyRule rule, int minCount) where TEntity : class where TProperty : System.Collections.ICollection? - => Collection(rule, minCount, null, null, true); - - /// - /// Adds a collection () maximum count validation (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The maximum count. - /// A . - public static IPropertyRule MaximumCount(this IPropertyRule rule, int maxCount) where TEntity : class where TProperty : System.Collections.ICollection? - => Collection(rule, 0, maxCount, null, true); - - #endregion - - #region Dictionary - - /// - /// Adds a dictionary () validation (see ) where the can be specified. - /// - /// The entity . - /// The property . - /// The being extended. - /// The minimum count. - /// The maximum count. - /// The item configuration. - /// Indicates whether the underlying dictionary keys must not be null. - /// Indicates whether the underlying dictionary values must not be null. - /// A . - public static IPropertyRule Dictionary(this IPropertyRule rule, int minCount = 0, int? maxCount = null, IDictionaryRuleItem? item = null, bool allowNullKeys = false, bool allowNullValues = false) where TEntity : class where TProperty : System.Collections.IDictionary? - { - var cr = new DictionaryRule { MinCount = minCount, MaxCount = maxCount, Item = item, AllowNullKeys = allowNullKeys, AllowNullValues = allowNullValues }; - return rule.ThrowIfNull(nameof(rule)).AddRule(cr); - } - - /// - /// Adds a dictionary () validation (see ) for the specified and . - /// - /// The entity . - /// The property . - /// The key . - /// The value . - /// The being extended. - /// The key . - /// The value . - /// The minimum count. - /// The maximum count. - /// Indicates whether the underlying dictionary keys must not be null. - /// Indicates whether the underlying dictionary values must not be null. - /// A . - public static IPropertyRule Dictionary(this IPropertyRule rule, IValidatorEx? keyValidator, IValidatorEx? valueValidator, int minCount = 0, int? maxCount = null, bool allowNullKeys = false, bool allowNullValues = false) where TEntity : class where TProperty : Dictionary? where TKey : notnull - { - var cr = new DictionaryRule { MinCount = minCount, MaxCount = maxCount, Item = DictionaryRuleItem.Create(keyValidator, valueValidator), AllowNullKeys = allowNullKeys, AllowNullValues = allowNullValues }; - return rule.ThrowIfNull(nameof(rule)).AddRule(cr); - } - - #endregion - - #region Entity - - /// - /// Adds an entity validation (see ). - /// - /// The entity . - /// The property . - /// The validator . - /// The being extended. - /// The validator. - /// A . - public static IPropertyRule Entity(this IPropertyRule rule, TValidator validator) where TEntity : class where TProperty : class? where TValidator : IValidatorEx - => rule.ThrowIfNull(nameof(rule)).AddRule(new EntityRule(validator)); - - /// - /// Enables the addition of an using a validator a specified validator . - /// - /// The entity . - /// The property . - /// The being extended. - /// An . - public static EntityRuleWith Entity(this IPropertyRule rule) where TEntity : class where TProperty : class? - => new(rule.ThrowIfNull(nameof(rule))); - - #endregion - - #region Interop - - /// - /// Adds an interop validation (see ) (intended for non-CoreEx.Validation). - /// - /// The entity . - /// The property . - /// The validator . - /// The being extended. - /// The validator. - /// A . - /// This is only intended to be leveraged for the root entity value being validated as no are passed meaning advanced capabilities will be ignored. - public static IPropertyRule Interop(this IPropertyRule rule, TValidator validator) where TEntity : class where TProperty : class? where TValidator : IValidator - => rule.ThrowIfNull(nameof(rule)).AddRule(new InteropRule(() => validator.ThrowIfNull(nameof(validator)))); - - /// - /// Adds an interop validation (see ) (intended for non-CoreEx.Validation). - /// - /// The entity . - /// The property . - /// The validator . - /// The being extended. - /// The function to return the . - /// A . - /// This is only intended to be leveraged for the root entity value being validated as no are passed meaning advanced capabilities will be ignored. - public static IPropertyRule Interop(this IPropertyRule rule, Func validatorFunc) where TEntity : class where TProperty : class? where TValidator : IValidator - => rule.ThrowIfNull(nameof(rule)).AddRule(new InteropRule(validatorFunc)); - - #endregion - - #region Custom - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The custom function. - /// A . - public static IPropertyRule Custom(this IPropertyRule rule, Func, Result> custom) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CustomRule(custom)); - - /// - /// Adds a validation (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The custom function. - /// A . - public static IPropertyRule CustomAsync(this IPropertyRule rule, Func, CancellationToken, Task> customAsync) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CustomRule(customAsync)); - - #endregion - - #region Common - - /// - /// Adds a common validation (see ). - /// - /// The entity . - /// The property . - /// The being extended. - /// The . - /// A . - public static IPropertyRule Common(this IPropertyRule rule, CommonValidator validator) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new CommonRule(validator)); - - #endregion - - #region Override/Default - - /// - /// Adds a value override (see ) using the specified . - /// - /// The entity . - /// The property . - /// The being extended. - /// The override function. - /// A . - public static IPropertyRule Override(this IPropertyRule rule, Func overrideFunc) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new OverrideRule(overrideFunc)); - - /// - /// Adds a value override (see ) using the specified . - /// - /// The entity . - /// The property . - /// The being extended. - /// The override function. - /// A . - public static IPropertyRule OverrideAsync(this IPropertyRule rule, Func> overrideFuncAsync) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new OverrideRule(overrideFuncAsync)); - - /// - /// Adds a value override (see ) using the specified . - /// - /// The entity . - /// The property . - /// The being extended. - /// The override value. - /// A . - public static IPropertyRule Override(this IPropertyRule rule, TProperty overrideValue) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new OverrideRule(overrideValue)); - - /// - /// Adds a default (see ) using the specified (overrides only where current value is the default for ) . - /// - /// The entity . - /// The property . - /// The being extended. - /// The override function. - /// A . - public static IPropertyRule Default(this IPropertyRule rule, Func defaultFunc) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new OverrideRule(defaultFunc) { OnlyOverrideDefault = true }); - - /// - /// Adds a default (see ) using the specified (overrides only where current value is the default for ) . - /// - /// The entity . - /// The property . - /// The being extended. - /// The override function. - /// A . - public static IPropertyRule DefaultAsync(this IPropertyRule rule, Func> defaultFuncAsync) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new OverrideRule(defaultFuncAsync) { OnlyOverrideDefault = true }); - - /// - /// Adds a default override (see ) using the specified (overrides only where current value is the default for ) . - /// - /// The entity . - /// The property . - /// The being extended. - /// The override value. - /// A . - public static IPropertyRule Default(this IPropertyRule rule, TProperty defaultValue) where TEntity : class - => rule.ThrowIfNull(nameof(rule)).AddRule(new OverrideRule(defaultValue) { OnlyOverrideDefault = true }); - - #endregion - - #region ValueValidator - -#if NETSTANDARD2_1 - /// - /// Enables (sets up) validation for a value. - /// - /// The value . - /// The value to validate. - /// The value name (defaults to ). - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// A . - public static ValueValidator Validate(this T? value, string? name = null, LText? text = null) => new(value, name, text); - - /// - /// Enables (sets up) validation for a value. - /// - /// The value . - /// The value to validate. - /// The value validation configuration logic. - /// The value name (defaults to ). - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// A . - public static ValueValidator Validate(this T? value, Action> configure, string? name = null, LText? text = null) - => new ValueValidator(value, name, text).Configure(configure); - - /// - /// Enables (sets up) validation for a value. - /// - /// The value . - /// The value to validate. - /// The . - /// The value name (defaults to ). - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// A . - public static ValueValidator Validate(this T? value, CommonValidator validator, string? name = null, LText? text = null) - => new ValueValidator(value, name, text).Configure(c => c.Common(validator)); -#else - /// - /// Enables (sets up) validation for a value. - /// - /// The value . - /// The value to validate. - /// The value name (defaults to name using the ). - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// A . - public static ValueValidator Validate(this T? value, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null) => new(value, name, text); - - /// - /// Enables (sets up) validation for a value. - /// - /// The value . - /// The value to validate. - /// The value validation configuration logic. - /// The value name (defaults to name using the ). - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// A . - public static ValueValidator Validate(this T? value, Action> configure, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null) - => new ValueValidator(value, name, text).Configure(configure); - - /// - /// Enables (sets up) validation for a value. - /// - /// The value . - /// The value to validate. - /// The . - /// The value name (defaults to name using the ). - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// A . - public static ValueValidator Validate(this T? value, CommonValidator validator, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null) - => new ValueValidator(value, name, text).Configure(c => c.Common(validator)); -#endif - - #endregion - - #region MultiValidator - - /// - /// Adds a to the . - /// - /// The value . - /// The . - /// The . - /// The (this) . - public static MultiValidator Add(this MultiValidator multiValidator, ValueValidator validator) - { - validator.ThrowIfNull(nameof(validator)); - multiValidator.ThrowIfNull(nameof(multiValidator)).Validators.Add(async ct => await validator.ValidateAsync(ct).ConfigureAwait(false)); - return multiValidator; - } - - #endregion - - #region Result + rule.AddClause(clause.ThrowIfNull()); + return rule; + } - /// - /// Executes the for the where the is . - /// - /// The . - /// The . - /// The value name (defaults to ). - /// The to use for the . - /// The function. - /// The . - /// The resulting . - /// Where the corresponding will be updated with the . - public static async Task> ValidateAsync(this Result result, Action>? validator, string? name = default, LText? text = default, CancellationToken cancellationToken = default) - { - validator.ThrowIfNull(nameof(validator)); + /// + /// Chains the current with the , creating a chained sequence of rules. + /// + /// The entity . + /// The property . + /// The being extended. + /// The next in the chain. + /// The to support fluent-style method-chaining. + public static IPropertyRule Chain(IPropertyRule rule, IPropertyRule nextRule) where TEntity : class + { + rule.ThrowIfNull().Chain(nextRule.ThrowIfNull()); + return nextRule; + } - return await result.ThenAsync(async v => - { - var vv = v.Validate(name, text); - vv.Configure(c => validator(c)); - var vr = await vv.ValidateAsync(cancellationToken).ConfigureAwait(false); - return vr.ToResult(); - }); - } + /// + /// Chains the current with the , creating a chained sequence of rules. + /// + /// The entity . + /// The property . + /// The being extended. + /// The next in the chain. + /// The to support fluent-style method-chaining. + public static IPropertyRule Chain(IPropertyRule rule, IPropertyRule nextRule) where TEntity : class where TProperty : struct + { + rule.ThrowIfNull().Chain(nextRule.ThrowIfNull()); + return nextRule; + } - /// - /// Executes the for the where the is . - /// - /// The . - /// The . - /// The value name (defaults to ). - /// The to use for the . - /// The function. - /// The . - /// The resulting . - /// Where the corresponding will be updated with the . - public static async Task> ValidateAsync(this Task> result, Action>? validator, string? name = default, LText? text = default, CancellationToken cancellationToken = default) - { - validator.ThrowIfNull(nameof(validator)); + /// + /// Sets (overrides) the property to be used within validation messages. + /// + /// The entity . + /// The property . + /// The being extended. + /// The property . + /// The to support fluent-style method-chaining. + public static IRootPropertyRule WithText(this IRootPropertyRule rule, LText? text) where TEntity : class + { + rule.ThrowIfNull().SetText(text); + return rule; + } - return await result.ThenAsync(async v => - { - var vv = v.Validate(name, text); - vv.Configure(c => validator(c)); - var vr = await vv.ValidateAsync(cancellationToken).ConfigureAwait(false); - return vr.ToResult(); - }); - } + /// + /// Sets (overrides) the property to be used within validation messages. + /// + /// The entity . + /// The property . + /// The being extended. + /// The property . + /// The to support fluent-style method-chaining. + public static IRootPropertyRule WithText(this IRootPropertyRule rule, LText? text) where TEntity : class where TProperty : struct + { + rule.ThrowIfNull().SetText(text); + return rule; + } -#if NETSTANDARD2_1 - /// - /// Executes the for the specified where the is . - /// - /// The . - /// The . - /// The . - /// The value to validate. - /// The . - /// The value name (defaults to ). - /// The to use for the . - /// The . - /// The resulting . - /// Validation only occurs where the is not null; otherwise, continues as expected. - public static async Task ValidatesAsync(this TResult result, T value, Action>? validator, string? name = default, LText? text = default, CancellationToken cancellationToken = default) where TResult : IResult -#else - /// - /// Executes the for the specified where the is . - /// - /// The . - /// The . - /// The . - /// The value to validate. - /// The . - /// The value name (defaults to name using the ). - /// The to use for the . - /// The . - /// The resulting . - /// Validation only occurs where the is not null; otherwise, continues as expected. - public static async Task ValidatesAsync(this TResult result, T value, Action>? validator, [CallerArgumentExpression(nameof(value))] string? name = default, LText? text = default, CancellationToken cancellationToken = default) where TResult : IResult -#endif - { - if (validator is null || result.IsFailure) - return result; + /// + /// Sets (overrides) the format and optional format provider to be used when formatting the property value within validation messages. + /// + /// The entity . + /// The property . + /// The being extended. + /// The format. + /// The optional + /// The quoting character so it appears as a literal string. + /// The to support fluent-style method-chaining. + /// The underlying type must implement as this results in being used. + public static IRootPropertyRule WithFormat(this IRootPropertyRule rule, string? format, IFormatProvider? formatProvider = null, char? quotingCharacter = '\'') where TEntity : class where TProperty : IFormattable + { + rule.ThrowIfNull().SetFormat(format, formatProvider, quotingCharacter); + return rule; + } - var vv = value.Validate(name, text); - vv.Configure(c => validator(c)); - var vr = await vv.ValidateAsync(cancellationToken).ConfigureAwait(false); - return vr.HasErrors ? (TResult)result.ToFailure(vr.ToException()!) : result; - } + /// + /// Sets (overrides) the format and optional format provider to be used when formatting the property value within validation messages. + /// + /// The entity . + /// The property . + /// The being extended. + /// The format. + /// The optional + /// The quoting character so it appears as a literal string. + /// The to support fluent-style method-chaining. + /// The underlying type must implement as this results in being used. + public static IRootPropertyRule WithFormat(this IRootPropertyRule rule, string? format, IFormatProvider? formatProvider = null, char? quotingCharacter = '\'') where TEntity : class where TProperty : struct, IFormattable + { + rule.ThrowIfNull().SetFormat(format, formatProvider, quotingCharacter); + return rule; + } -#if NETSTANDARD2_1 - /// - /// Executes the for the specified where the is . - /// - /// The . - /// The . - /// The . - /// The value to validate. - /// The . - /// The value name (defaults to ). - /// The to use for the . - /// The . - /// The resulting . - /// Validation only occurs where the is not null; otherwise, continues as expected. - public static async Task ValidatesAsync(this Task result, T value, Action>? validator, string? name = default, LText? text = default, CancellationToken cancellationToken = default) where TResult : IResult -#else - /// - /// Executes the for the specified where the is . - /// - /// The . - /// The . - /// The . - /// The value to validate. - /// The . - /// The value name (defaults to name using the ). - /// The to use for the . - /// The . - /// The resulting . - /// Validation only occurs where the is not null; otherwise, continues as expected. - public static async Task ValidatesAsync(this Task result, T value, Action>? validator, [CallerArgumentExpression(nameof(value))] string? name = default, LText? text = default, CancellationToken cancellationToken = default) where TResult : IResult -#endif - { - var r = await result.ConfigureAwait(false); - if (validator is null || r.IsFailure) - return r; + /// + /// Includes the specified . + /// + /// The entity . + /// The . + /// The included base entity . + /// The . + /// The base . + /// The to support fluent-style method-chaining. + /// Note: the is added internally as an rule; therefore, it will be executed in the order added in relation to other property-base rules. + public static TSelf Include(this ValidatorBase validator, IValidatorEx baseValidator) where TEntity : class, TInclude where TInclude : class where TSelf : ValidatorBase + => validator.ThrowIfNull().IncludeBase(baseValidator.ThrowIfNull()); - var vv = value.Validate(name, text); - vv.Configure(c => validator(c)); - var vr = await vv.ValidateAsync(cancellationToken).ConfigureAwait(false); - return vr.HasErrors ? (TResult)r.ToFailure(vr.ToException()!) : r; - } + /// + /// Creates an to enable validation of the specified . + /// + /// The value . + /// The value. + /// The action to configure the resulting . + /// The value name (defaults to name using the caller argument expression). + /// The friendly text name used in validation messages (defaults to as sentence case where not specified). + /// The JSON name where different to the name and using . + /// The . + /// The should be used to further configure the validation rules, clauses, etc. + /// Finally, the or , should be invoked to execute the underlying validation. + public static IValueValidator Validator(this T? value, Action, T>>? configure, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null, string? jsonName = null) where T : notnull + => new ValueValidator(value!, name ?? Validation.ValueName, jsonName, text, configure, null, null); - #endregion - } + /// + /// Creates an to enable validation of the specified . + /// + /// The value . + /// The value. + /// The action to configure the resulting . + /// The value name (defaults to name using the caller argument expression). + /// The friendly text name used in validation messages (defaults to as sentence case where not specified). + /// The JSON name where different to the name and using . + /// The . + /// The should be used to further configure the validation rules, clauses, etc. + /// Finally, the or , should be invoked to execute the underlying validation. + public static IValueValidator Validator(this T? value, Action, T?>>? configure, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null, string? jsonName = null) where T : struct + => new ValueValidator(value, name ?? Validation.ValueName, jsonName, text, configure, e => e.Value.GetValueOrDefault(), e => Comparer.Default.Compare(e.Value.GetValueOrDefault(), default) == 0); } \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationServiceCollectionExtensions.cs b/src/CoreEx.Validation/ValidationServiceCollectionExtensions.cs deleted file mode 100644 index 4a0a8375..00000000 --- a/src/CoreEx.Validation/ValidationServiceCollectionExtensions.cs +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Localization; -using CoreEx.Validation; -using System; -using System.Linq; -using System.Reflection; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extensions. - /// - public static class ValidationServiceCollectionExtensions - { - /// - /// Adds the , the , and as scoped services. - /// - /// The value . - /// The validator . - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddValidator(this IServiceCollection services) where TValidator : class, IValidatorEx - => AddValidatorWithInterfacesInternal(services); - - /// - /// Adds the , the , and as scoped services. - /// - private static IServiceCollection AddValidatorWithInterfacesInternal(this IServiceCollection services) where TValidator : class, IValidatorEx - => services.ThrowIfNull(nameof(services)) - .AddScoped, TValidator>() - .AddScoped>(sp => sp.GetRequiredService>()) - .AddScoped(sp => (TValidator)sp.GetRequiredService>()); - - /// - /// Adds the as a scoped service only. - /// - /// The validator . - /// The . - /// The for fluent-style method-chaining. - /// Note that this does not register the corresponding and ; use to explicitly perform. - public static IServiceCollection AddValidator(this IServiceCollection services) where TValidator : class, IValidatorEx - => AddValidatorInternal(services); - - /// - /// Adds the as a scoped service only. - /// - private static IServiceCollection AddValidatorInternal(this IServiceCollection services) where TValidator : class, IValidatorEx - => services.ThrowIfNull(nameof(services)).AddScoped(); - - /// - /// Adds all the validators from the specified as scoped services. - /// - /// The to infer the underlying . - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddValidators(this IServiceCollection services) - => AddValidators(services, [typeof(TAssembly).Assembly]); - - /// - /// Adds all the validators from the specified and as scoped services. - /// - /// The to infer the underlying . - /// The to infer the underlying . - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddValidators(this IServiceCollection services) - => AddValidators(services, [typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly]); - - /// - /// Adds all the validators from the specified , and as scoped services. - /// - /// The to infer the underlying . - /// The to infer the underlying . - /// The to infer the underlying . - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddValidators(this IServiceCollection services) - => AddValidators(services, [typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly, typeof(TAssembly3).Assembly]); - - /// - /// Adds all the validators from the specified as scoped services. - /// - /// The . - /// The assemblies. - /// Indicates whether to include internally defined types. - /// Indicates whether to also register the interfaces with (default); otherwise, with (just the validator instance itself). - /// The for fluent-style method-chaining. - public static IServiceCollection AddValidators(this IServiceCollection services, Assembly[] assemblies, bool includeInternalTypes = false, bool alsoRegisterInterfaces = true) - { - services.ThrowIfNull(nameof(services)); - - foreach (var assembly in assemblies.Distinct()) - { - var av = alsoRegisterInterfaces - ? typeof(ValidationServiceCollectionExtensions).GetMethod(nameof(AddValidatorWithInterfacesInternal), BindingFlags.Static | BindingFlags.NonPublic)! - : typeof(ValidationServiceCollectionExtensions).GetMethod(nameof(AddValidatorInternal), BindingFlags.Static | BindingFlags.NonPublic)!; - - foreach (var match in from type in includeInternalTypes ? assembly.GetTypes() : assembly.GetExportedTypes() - where !type.IsAbstract && !type.IsGenericTypeDefinition - let interfaces = type.GetInterfaces() - let genericInterfaces = interfaces.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidatorEx<>)) - let @interface = genericInterfaces.FirstOrDefault() - let valueType = @interface?.GetGenericArguments().FirstOrDefault() - where @interface != null - select new { valueType, type }) - { - if (alsoRegisterInterfaces) - av.MakeGenericMethod(match.valueType, match.type).Invoke(null, [services]); - else - av.MakeGenericMethod(match.type).Invoke(null, [services]); - } - } - - return services; - } - - /// - /// Adds the as the as a singleton service. - /// - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddValidationTextProvider(this IServiceCollection services) - => services.ThrowIfNull(nameof(services)).AddSingleton(); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationTextProvider.cs b/src/CoreEx.Validation/ValidationTextProvider.cs deleted file mode 100644 index 52ab56d5..00000000 --- a/src/CoreEx.Validation/ValidationTextProvider.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using System.Resources; - -namespace CoreEx.Validation -{ - /// - /// Provides the for validation localization. - /// - public class ValidationTextProvider : TextProviderBase - { - /// - /// Gets the that contains the texts for the validation. - /// - public static ResourceManager ResourceManager { get; } = new("CoreEx.Validation.Resources", typeof(ValidationTextProvider).Assembly); - - /// - protected override string? GetTextForKey(LText key) => key.KeyAndOrText is null ? null : ResourceManager.GetString(key.KeyAndOrText, System.Globalization.CultureInfo.CurrentCulture); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidationValue.cs b/src/CoreEx.Validation/ValidationValue.cs deleted file mode 100644 index d0835e23..00000000 --- a/src/CoreEx.Validation/ValidationValue.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Validation -{ - /// - /// Represents a validation value. - /// - /// The value . - public class ValidationValue - { - /// - /// Initializes a new instance of the class. - /// - /// The parent entity value. - /// The value. - internal ValidationValue(object? entity, T? value) - { - Entity = entity; - Value = value; - } - - /// - /// Gets or sets the entity value. - /// - public object? Entity { get; } - - /// - /// Gets the entity property value. - /// - public T? Value { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/Validator.cs b/src/CoreEx.Validation/Validator.cs index 2875ea14..f0cfd69d 100644 --- a/src/CoreEx.Validation/Validator.cs +++ b/src/CoreEx.Validation/Validator.cs @@ -1,118 +1,42 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using CoreEx.Validation.Rules; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections; -using System.Collections.Generic; - -namespace CoreEx.Validation +/// +/// Provides access to the validator capabilities. +/// +public static class Validator { /// - /// Provides access to the validator capabilities. + /// Create a new . /// - public static class Validator - { - /// - /// Create a . - /// - /// The entity . - /// A . - public static Validator Create() where TEntity : class => new(); - - /// - /// Create (or get) an instance of the pre-registered validator. - /// - /// The validator . - /// The ; defaults to where not specified. - /// The instance. - public static TValidator Create(IServiceProvider? serviceProvider = null) where TValidator : IValidatorEx - => (serviceProvider == null ? ExecutionContext.GetService() : serviceProvider.GetService()) - ?? throw new InvalidOperationException($"Attempted to get service '{typeof(TValidator).FullName}' but null was returned; this would indicate that the service has not been configured correctly."); - - /// - /// Create value validator (see ). - /// - /// The value . - /// An action with the to enable further configuration. - /// The . - /// This is a synonym for the . - public static CommonValidator CreateFor(Action>? validator = null) => CommonValidator.Create(validator); - - /// - /// Create a collection-based where the can be specified. - /// - /// The collection . - /// The minimum count. - /// The maximum count. - /// The item configuration. - /// Indicates whether the underlying collection item must not be null. - /// The for the collection. - public static CommonValidator CreateForCollection(int minCount = 0, int? maxCount = null, ICollectionRuleItem? item = null, bool allowNullItems = false) where TColl : class, IEnumerable? - => CreateFor(v => v.Collection(minCount, maxCount, item, allowNullItems)); - - /// - /// Create a collection-based for the specified . - /// - /// The collection . - /// The item . - /// The item . - /// The minimum count. - /// The maximum count. - /// Indicates whether the underlying collection item must not be null. - /// The for the collection. - public static CommonValidator CreateForCollection(IValidatorEx itemValidator, int minCount = 0, int? maxCount = null, bool allowNullItems = false) where TColl : class, IEnumerable? - => CreateFor(v => v.Collection(itemValidator, minCount, maxCount, allowNullItems)); - - /// - /// Create a dictionary-based where the can be specified. - /// - /// The dictionary . - /// The minimum count. - /// The maximum count. - /// The item configuration. - /// Indicates whether the underlying dictionary key can be null. - /// Indicates whether the underlying dictionary value can be null. - /// The for the dictionary. - public static CommonValidator CreateForDictionary(int minCount = 0, int? maxCount = null, IDictionaryRuleItem? item = null, bool allowNullKeys = false, bool allowNullValues = false) where TDict : class, IDictionary - => CreateFor(v => v.Dictionary(minCount, maxCount, item, allowNullKeys, allowNullValues)); + /// The entity . + /// The . + public static Validator Create() where TEntity : class => new(); - /// - /// Create a dictionary-based for the specified and . - /// - /// The dictionary . - /// The key . - /// The value . - /// The key . - /// The value . - /// The minimum count. - /// The maximum count. - /// Indicates whether the underlying dictionary key can be null. - /// Indicates whether the underlying dictionary value can be null. - /// The for the dictionary. - public static CommonValidator CreateForDictionary(IValidatorEx? keyValidator, IValidatorEx? valueValidator, int minCount = 0, int? maxCount = null, bool allowNullKeys = false, bool allowNullValues = false) where TDict : Dictionary? where TKey : notnull - => CreateFor(v => v.Dictionary(keyValidator, valueValidator, minCount, maxCount, allowNullKeys, allowNullValues)); + /// + /// Gets an instance of the pre-registered service. + /// + /// The validator . + /// The optional ; defaults to . + /// The instance. + public static TValidator Get(IServiceProvider? serviceProvider = null) where TValidator : IValidatorEx + => serviceProvider is null ? ExecutionContext.GetRequiredService() : serviceProvider.GetRequiredService(); - /// - /// Create a dictionary-based for the specified . - /// - /// The dictionary . - /// The key . - /// The value . - /// The value . - /// The minimum count. - /// The maximum count. - /// Indicates whether the underlying dictionary key can be null. - /// Indicates whether the underlying dictionary value can be null. - /// The for the dictionary. - public static CommonValidator CreateForDictionary(IValidatorEx? valueValidator, int minCount = 0, int? maxCount = null, bool allowNullKeys = false, bool allowNullValues = false) where TDict : Dictionary? where TKey : notnull - => CreateFor(v => v.Dictionary((IValidatorEx?)null, valueValidator, minCount, maxCount, allowNullKeys, allowNullValues)); + /// + /// Gets an instance of the pre-registered keyed service. + /// + /// The validator . + /// The service key. + /// The optional ; defaults to . + /// The instance. + public static TValidator GetKeyed(object? serviceKey, IServiceProvider? serviceProvider = null) where TValidator : IValidatorEx + => serviceProvider is null ? ExecutionContext.GetRequiredKeyedService(serviceKey) : serviceProvider.GetRequiredKeyedService(serviceKey); - /// - /// Creates a null . - /// - /// The . - /// A null ; i.e. simply null. - public static IValidatorEx? Null() => null; - } + /// + /// Creates a new inline. + /// + /// The value . + /// The action to configure the resulting . + /// The . + /// A common validator must be defined using a type to ensure broadest commonality and usage throughout. + public static CommonValidator CreateCommon(Action.Validator>? configure) where TValue : notnull => new(configure); } \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidatorBase.cs b/src/CoreEx.Validation/ValidatorBase.cs deleted file mode 100644 index 2c70bbb4..00000000 --- a/src/CoreEx.Validation/ValidatorBase.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Linq.Expressions; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Represents the base entity validator. - /// - /// The entity . - public abstract class ValidatorBase : IValidatorEx where TEntity : class - { - /// - /// Gets the underlying rules collection. - /// - internal protected List> Rules { get; } = []; - - /// - /// Gets the instance. - /// - public CoreEx.ExecutionContext ExecutionContext => ExecutionContext.Current; - - /// - /// Adds a to the validator. - /// - /// The property . - /// The to reference the entity property. - /// The . - public virtual IPropertyRule Property(Expression> propertyExpression) - { - PropertyRule rule = new(propertyExpression); - Rules.Add(rule); - return rule; - } - - /// - /// Adds a to the validator. - /// - /// The property . - /// The to reference the entity property. - /// The . - /// This is a synonym for to enable FluentValidation syntax. - public virtual IPropertyRule RuleFor(Expression> propertyExpression) => Property(propertyExpression); - - /// - public virtual Task> ValidateAsync(TEntity value, ValidationArgs? args, CancellationToken cancellationToken = default) => throw new NotSupportedException("Validate is not supported by this class."); - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidatorStrings.cs b/src/CoreEx.Validation/ValidatorStrings.cs index 03fc3d32..30b06444 100644 --- a/src/CoreEx.Validation/ValidatorStrings.cs +++ b/src/CoreEx.Validation/ValidatorStrings.cs @@ -1,217 +1,224 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using CoreEx.Localization; - -namespace CoreEx.Validation +/// +/// Provides the standard text format strings. +/// +/// For the format defaults within, the '{0}' and '{1}' placeholders represent a property's friendly text and value. Any placeholders '{2}', or above, are specific to the underlying validator/rule. +public static class ValidatorStrings { /// - /// Provides the standard text format strings. - /// - /// For the format defaults within, the '{0}' and '{1}' placeholders represent a property's friendly text and value itself. Any placeholders '{2}', or above, are specific to the underlying valitator. - public static class ValidatorStrings - { - /// - /// Gets or sets the format string for the compare equal error message. - /// - /// Defaults to: '{0} must be between {2} and {3}'. - public static LText BetweenInclusiveFormat { get; set; } = new("CoreEx.Validation.BetweenInclusiveFormat", "{0} must be between {2} and {3}."); - - /// - /// Gets or sets the format string for the compare equal error message. - /// - /// Defaults to: '{0} must be between {2} and {3} (exclusive)'. - public static LText BetweenExclusiveFormat { get; set; } = new("CoreEx.Validation.BetweenExclusiveFormat", "{0} must be between {2} and {3} (exclusive)."); - - /// - /// Gets or sets the format string for the compare equal error message. - /// - /// Defaults to: '{0} must be equal to {2}'. - public static LText CompareEqualFormat { get; set; } = new("CoreEx.Validation.CompareEqualFormat", "{0} must be equal to {2}."); - - /// - /// Gets or sets the format string for the compare not equal error message. - /// - /// Defaults to: '{0} must not be equal to {2}'. - public static LText CompareNotEqualFormat { get; set; } = new("CoreEx.Validation.CompareNotEqualFormat", "{0} must not be equal to {2}."); - - /// - /// Gets or sets the format string for the compare less than error message. - /// - /// Defaults to: '{0} must be less than {2}'. - public static LText CompareLessThanFormat { get; set; } = new("CoreEx.Validation.CompareLessThanFormat", "{0} must be less than {2}."); - - /// - /// Gets or sets the format string for the compare less than or equal error message. - /// - /// Defaults to: '{0} must be less than or equal to {2}'. - public static LText CompareLessThanEqualFormat { get; set; } = new("CoreEx.Validation.CompareLessThanEqualFormat", "{0} must be less than or equal to {2}."); - - /// - /// Gets or sets the format string for the compare greater than error message. - /// - /// Defaults to: '{0} must be greater than {2}'. - public static LText CompareGreaterThanFormat { get; set; } = new("CoreEx.Validation.CompareGreaterThanFormat", "{0} must be greater than {2}."); - - /// - /// Gets or sets the format string for the compare greater than or equal error message. - /// - /// Defaults to: '{0} must be greater than or equal to {2}'. - public static LText CompareGreaterThanEqualFormat { get; set; } = new("CoreEx.Validation.CompareGreaterThanEqualFormat", "{0} must be greater than or equal to {2}."); - - /// - /// Gets or sets the format string for the Maximum digits error message. - /// - /// Defaults to: '{0} must not exceed {2} digits in total'. - public static LText MaxDigitsFormat { get; set; } = new("CoreEx.Validation.MaxDigitsFormat", "{0} must not exceed {2} digits in total."); - - /// - /// Gets or sets the format string for the Decimal places error message. - /// - /// Defaults to: '{0} exceeds the maximum specified number of decimal places ({2})'. - public static LText DecimalPlacesFormat { get; set; } = new("CoreEx.Validation.DecimalPlacesFormat", "{0} exceeds the maximum specified number of decimal places ({2})."); - - /// - /// Gets or sets the format string for the duplicate error message. - /// - /// Defaults to: '{0} already exists and would result in a duplicate.' - public static LText DuplicateFormat { get; set; } = new("CoreEx.Validation.DuplicateFormat", "{0} already exists and would result in a duplicate."); - - /// - /// Gets or sets the format string for a duplicate value error message; includes ability to specify values. - /// - /// Defaults to: '{0} contains duplicates; {2} value '{3}' specified more than once'. - public static LText DuplicateValueFormat { get; set; } = new("CoreEx.Validation.DuplicateValueFormat", "{0} contains duplicates; {2} value '{3}' specified more than once."); - - /// - /// Gets or sets the format string for a duplicate value error message; no values specified. - /// - /// Defaults to: '{0} contains duplicates; {2} value specified more than once'. - public static LText DuplicateValue2Format { get; set; } = new("CoreEx.Validation.DuplicateValue2Format", "{0} contains duplicates; {2} value specified more than once."); - - /// - /// Gets or sets the format string for the minimum count error message. - /// - /// Defaults to: '{0} must have at least {2} item(s)'. - public static LText MinCountFormat { get; set; } = new("CoreEx.Validation.MinCountFormat", "{0} must have at least {2} item(s)."); - - /// - /// Gets or sets the format string for the maximum count error message. - /// - /// Defaults to: '{0} must not exceed {2} item(s)'. - public static LText MaxCountFormat { get; set; } = new("CoreEx.Validation.MaxCountFormat", "{0} must not exceed {2} item(s)."); - - /// - /// Gets or sets the format string for the exists error message. - /// - /// Defaults to: '{0} is not found; a valid value is required'. - public static LText ExistsFormat { get; set; } = new("CoreEx.Validation.ExistsFormat", "{0} is not found; a valid value is required."); - - /// - /// Gets or sets the format string for the immutable error message. - /// - /// Defaults to: '{0} is not allowed to change; please reset value'. - public static LText ImmutableFormat { get; set; } = new("CoreEx.Validation.ImmutableFormat", "{0} is not allowed to change; please reset value."); - - /// - /// Gets the format string for the Mandatory error message. - /// - /// Defaults to: '{0} is required'. This references . - public static LText MandatoryFormat => Validation.MandatoryFormat; - - /// - /// Gets or sets the format string for the must error message. - /// - /// Defaults to: '{0} is invalid'. - public static LText MustFormat { get; set; } = new("CoreEx.Validation.MustFormat", "{0} is invalid."); - - /// - /// Gets or sets the format string for the allow negatives error message. - /// - /// Defaults to: '{0} must not be negative'. - public static LText AllowNegativesFormat { get; set; } = new("CoreEx.Validation.AllowNegativesFormat", "{0} must not be negative."); - - /// - /// Gets or sets the format string for the invalid error message. - /// - /// Defaults to: '{0} is invalid'. - public static LText InvalidFormat { get; set; } = new("CoreEx.Validation.InvalidFormat", "{0} is invalid."); - - /// - /// Gets or sets the format string for the invalid items error message. - /// - /// Defaults to: '{0} contains one or more invalid items'. - public static LText InvalidItemsFormat { get; set; } = new("CoreEx.Validation.InvalidItemsFormat", "{0} contains one or more invalid items."); - - /// - /// Gets or sets the format string for the minimum length error message. - /// - /// Defaults to: '{0} must be at least {2} characters in length'. - public static LText MinLengthFormat { get; set; } = new("CoreEx.Validation.MinLengthFormat", "{0} must be at least {2} characters in length."); - - /// - /// Gets or sets the format string for the maximum length error message. - /// - /// Defaults to: '{0} must not exceed {2} characters in length'. - public static LText MaxLengthFormat { get; set; } = new("CoreEx.Validation.MaxLengthFormat", "{0} must not exceed {2} characters in length."); - - /// - /// Gets or sets the format string for the exact length error message. - /// - /// Defaults to: '{0} must be exactly {2} characters in length'. - public static LText ExactLengthFormat { get; set; } = new("CoreEx.Validation.ExactLengthFormat", "{0} must be exactly {2} characters in length."); - - /// - /// Gets or sets the format string for the regex error message. - /// - /// Defaults to: '{0} is invalid'. - public static LText RegexFormat { get; set; } = new("CoreEx.Validation.RegexFormat", "{0} is invalid."); - - /// - /// Gets or sets the format string for the wildcard error message. - /// - /// Defaults to: '{0} contains invalid or non-supported wildcard selection'. - public static LText WildcardFormat { get; set; } = new("CoreEx.Validation.WildcardFormat", "{0} contains invalid or non-supported wildcard selection."); - - /// - /// Gets or sets the format string for the collection null item error message. - /// - /// Defaults to: '{0} contains one or more items that are not specified'. - public static LText CollectionNullItemFormat { get; set; } = new("CoreEx.Validation.CollectionNullItemFormat", "{0} contains one or more items that are not specified."); - - /// - /// Gets or sets the format string for the dictionary null key error message. - /// - /// Defaults to: '{0} contains one or more keys that are not specified'. - public static LText DictionaryNullKeyFormat { get; set; } = new("CoreEx.Validation.DictionaryNullKeyFormat", "{0} contains one or more keys that are not specified."); - - /// - /// Gets or sets the format string for the dictionary null value error message. - /// - /// Defaults to: '{0} contains one or more values that are not specified'. - public static LText DictionaryNullValueFormat { get; set; } = new("CoreEx.Validation.DictionaryNullValueFormat", "{0} contains one or more values that are not specified."); - - /// - /// Gets or sets the format string for the invalid email message. - /// - /// Defaults to: '{0} is an invalid e-mail address'. - public static LText EmailFormat { get; set; } = new("CoreEx.Validation.EmailFormat", "{0} is an invalid e-mail address."); - - /// - /// Gets or sets the format string for when no (none) value is to be specified. - /// - /// Defaults to: '{0} must not be specified.'. - public static LText NoneFormat { get; set; } = new("CoreEx.Validation.NoneFormat", "{0} must not be specified."); - - /// - /// Gets or sets the string for the literal. - /// - /// Defaults to: 'Primary Key' - public static LText PrimaryKey { get; set; } = new("CoreEx.Validation.PrimaryKey", "Primary Key"); - - /// - /// Gets or sets the string for the literal. - /// - /// Defaults to: 'Identifier' - public static LText Identifier { get; set; } = new("CoreEx.Validation.Identifier", "Identifier"); - } + /// Gets or sets the format string for the compare equal error message. + /// + /// Defaults to: '{0} must be between {2} and {3}'. + public static LText BetweenInclusiveFormat { get; set; } = new("CoreEx.Validation.BetweenInclusiveFormat", "{0} must be between {2} and {3}."); + + /// + /// Gets or sets the format string for the compare equal error message. + /// + /// Defaults to: '{0} must be between {2} and {3} (exclusive)'. + public static LText BetweenExclusiveFormat { get; set; } = new("CoreEx.Validation.BetweenExclusiveFormat", "{0} must be between {2} and {3} (exclusive)."); + + /// + /// Gets or sets the format string for the compare equal error message. + /// + /// Defaults to: '{0} must be equal to {2}'. + public static LText CompareEqualFormat { get; set; } = new("CoreEx.Validation.CompareEqualFormat", "{0} must be equal to {2}."); + + /// + /// Gets or sets the format string for the compare not equal error message. + /// + /// Defaults to: '{0} must not be equal to {2}'. + public static LText CompareNotEqualFormat { get; set; } = new("CoreEx.Validation.CompareNotEqualFormat", "{0} must not be equal to {2}."); + + /// + /// Gets or sets the format string for the compare less than error message. + /// + /// Defaults to: '{0} must be less than {2}'. + public static LText CompareLessThanFormat { get; set; } = new("CoreEx.Validation.CompareLessThanFormat", "{0} must be less than {2}."); + + /// + /// Gets or sets the format string for the compare less than or equal error message. + /// + /// Defaults to: '{0} must be less than or equal to {2}'. + public static LText CompareLessThanEqualFormat { get; set; } = new("CoreEx.Validation.CompareLessThanEqualFormat", "{0} must be less than or equal to {2}."); + + /// + /// Gets or sets the format string for the compare greater than error message. + /// + /// Defaults to: '{0} must be greater than {2}'. + public static LText CompareGreaterThanFormat { get; set; } = new("CoreEx.Validation.CompareGreaterThanFormat", "{0} must be greater than {2}."); + + /// + /// Gets or sets the format string for the compare greater than or equal error message. + /// + /// Defaults to: '{0} must be greater than or equal to {2}'. + public static LText CompareGreaterThanEqualFormat { get; set; } = new("CoreEx.Validation.CompareGreaterThanEqualFormat", "{0} must be greater than or equal to {2}."); + + /// + /// Gets or sets the format string for the Maximum digits error message. + /// + /// Defaults to: '{0} exceeds the maximum digits ({2}).'. + public static LText MaxDigitsFormat { get; set; } = new("CoreEx.Validation.MaxDigitsFormat", "{0} exceeds the maximum digits ({2})."); + + /// + /// Gets or sets the format string for the Decimal places error message. + /// + /// Defaults to: '{0} exceeds the maximum decimal places ({2})'. + public static LText DecimalPlacesFormat { get; set; } = new("CoreEx.Validation.DecimalPlacesFormat", "{0} exceeds the maximum decimal places ({2})."); + + /// + /// Gets or sets the format string for the duplicate error message. + /// + /// Defaults to: '{0} already exists and would result in a duplicate.' + public static LText DuplicateFormat { get; set; } = new("CoreEx.Validation.DuplicateFormat", "{0} already exists and would result in a duplicate."); + + /// + /// Gets or sets the format string for a duplicate value error message; includes ability to specify values. + /// + /// Defaults to: '{0} contains duplicates; {2} value specified more than once'. + public static LText DuplicateValueFormat { get; set; } = new("CoreEx.Validation.DuplicateValue2Format", "{0} contains duplicates; {2} specified more than once."); + + /// + /// Gets or sets the format string for the minimum count error message. + /// + /// Defaults to: '{0} must have at least {2} item(s)'. + public static LText MinCountFormat { get; set; } = new("CoreEx.Validation.MinCountFormat", "{0} must have at least {2} item(s)."); + + /// + /// Gets or sets the format string for the maximum count error message. + /// + /// Defaults to: '{0} must not exceed {2} item(s)'. + public static LText MaxCountFormat { get; set; } = new("CoreEx.Validation.MaxCountFormat", "{0} must not exceed {2} item(s)."); + + /// + /// Gets or sets the format string for the not found error message. + /// + /// Defaults to: '{0} was not found'. + public static LText NotFoundFormat { get; set; } = new("CoreEx.Validation.NotFoundFormat", "{0} was not found."); + + /// + /// Gets or sets the format string for the immutable error message. + /// + /// Defaults to: '{0} is not allowed to change; please reset value'. + public static LText ImmutableFormat { get; set; } = new("CoreEx.Validation.ImmutableFormat", "{0} is not allowed to change; please reset value."); + + /// + /// Gets the format string for the Mandatory error message. + /// + /// Defaults to: '{0} is required'. This references . + public static LText MandatoryFormat => Validation.MandatoryFormat; + + /// + /// Gets or sets the format string for the allow negatives error message. + /// + /// Defaults to: '{0} must not be negative'. + public static LText AllowNegativesFormat { get; set; } = new("CoreEx.Validation.AllowNegativesFormat", "{0} must not be negative."); + + /// + /// Gets or sets the format string for the invalid error message. + /// + /// Defaults to: '{0} is invalid'. + public static LText InvalidFormat { get; set; } = new("CoreEx.Validation.InvalidFormat", "{0} is invalid."); + + /// + /// Gets or sets the format string for the invalid items error message. + /// + /// Defaults to: '{0} contains one or more invalid items'. + public static LText InvalidItemsFormat { get; set; } = new("CoreEx.Validation.InvalidItemsFormat", "{0} contains one or more invalid items."); + + /// + /// Gets or sets the format string for the minimum length error message. + /// + /// Defaults to: '{0} must be at least {2} characters in length'. + public static LText MinLengthFormat { get; set; } = new("CoreEx.Validation.MinLengthFormat", "{0} must be at least {2} character(s) in length."); + + /// + /// Gets or sets the format string for the maximum length error message. + /// + /// Defaults to: '{0} must not exceed {2} characters in length'. + public static LText MaxLengthFormat { get; set; } = new("CoreEx.Validation.MaxLengthFormat", "{0} must not exceed {2} character(s) in length."); + + /// + /// Gets or sets the format string for the exact length error message. + /// + /// Defaults to: '{0} must be exactly {2} characters in length'. + public static LText ExactLengthFormat { get; set; } = new("CoreEx.Validation.ExactLengthFormat", "{0} must be exactly {2} character(s) in length."); + + /// + /// Gets or sets the format string for the regex error message. + /// + /// Defaults to: '{0} is invalid'. + public static LText RegexFormat { get; set; } = new("CoreEx.Validation.RegexFormat", "{0} is invalid."); + + /// + /// Gets or sets the format string for the wildcard error message. + /// + /// Defaults to: '{0} contains invalid or non-supported wildcard selection'. + public static LText WildcardFormat { get; set; } = new("CoreEx.Validation.WildcardFormat", "{0} contains invalid or non-supported wildcard selection."); + + /// + /// Gets or sets the format string for the collection null item error message. + /// + /// Defaults to: '{0} contains one or more items that are not specified'. + public static LText CollectionNullItemFormat { get; set; } = new("CoreEx.Validation.CollectionNullItemFormat", "{0} contains one or more items that are not specified."); + + /// + /// Gets or sets the format string for the dictionary null key error message. + /// + /// Defaults to: '{0} contains one or more keys that are not specified'. + public static LText DictionaryNullKeyFormat { get; set; } = new("CoreEx.Validation.DictionaryNullKeyFormat", "{0} contains one or more keys that are not specified."); + + /// + /// Gets or sets the format string for the dictionary null value error message. + /// + /// Defaults to: '{0} contains one or more values that are not specified'. + public static LText DictionaryNullValueFormat { get; set; } = new("CoreEx.Validation.DictionaryNullValueFormat", "{0} contains one or more values that are not specified."); + + /// + /// Gets or sets the format string for the invalid email message. + /// + /// Defaults to: '{0} is an invalid e-mail address'. + public static LText EmailFormat { get; set; } = new("CoreEx.Validation.EmailFormat", "{0} is an invalid e-mail address."); + + /// + /// Gets or sets the format string for when no (none) value is to be specified. + /// + /// Defaults to: '{0} must not be specified.'. + public static LText NoneFormat { get; set; } = new("CoreEx.Validation.NoneFormat", "{0} must not be specified."); + + /// + /// Gets or sets the string for the literal. + /// + /// Defaults to: 'Primary Key' + public static LText PrimaryKeyText { get; set; } = new("CoreEx.Validation.PrimaryKey", "Primary Key"); + + /// + /// Gets or sets the string for the literal. + /// + /// Defaults to: 'Identifier' + public static LText IdentifierText { get; set; } = new("CoreEx.Validation.Identifier", "Identifier"); + + /// + /// Gets or sets the string for the literal. + /// + /// Defaults to: 'Key' + public static LText KeyText { get; set; } = new("CoreEx.Validation.Key", "Key"); + + /// + /// Gets or sets the string for the item literal. + /// + /// Defaults to: 'Item' + public static LText ItemText { get; set; } = new("CoreEx.Validation.Item", "Item"); + + /// + /// Gets or sets the default value . + /// + /// Defaults to: 'Value'. This references . + public static LText ValueText => Validation.ValueText; + + /// + /// Gets or sets the representation of . + /// + /// Defaults to: '<null>'. This references . + public static LText NullText => Validation.NullText; } \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidatorT.cs b/src/CoreEx.Validation/ValidatorT.cs index 1332a7ad..b1982e17 100644 --- a/src/CoreEx.Validation/ValidatorT.cs +++ b/src/CoreEx.Validation/ValidatorT.cs @@ -1,234 +1,85 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; -using CoreEx.Localization; -using CoreEx.Results; -using System; -using System.Linq.Expressions; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation +namespace CoreEx.Validation; + +/// +/// Provides entity validation. +/// +/// The entity . +/// See also . +public class Validator : ValidatorBase> where TEntity : class { - /// - /// Provides entity validation. - /// - /// The entity . - public class Validator : ValidatorBase where TEntity : class - { - private RuleSet? _currentRuleSet; - private Func, CancellationToken, Task>? _additionalAsync; - - /// - public override Task> ValidateAsync(TEntity value, ValidationArgs? args = null, CancellationToken cancellationToken = default) - { - return ValidationInvoker.Current.InvokeAsync(this, async (_, cancellationToken) => - { - var context = new ValidationContext(value, args ?? new ValidationArgs()); - if (value is null) - { - context.AddMessage(nameof(value), nameof(value), MessageType.Error, ValidatorStrings.MandatoryFormat, Validation.ValueTextDefault); - return context; - } - - // Validate each of the property rules. - foreach (var rule in Rules) - { - await rule.ValidateAsync(context, cancellationToken).ConfigureAwait(false); - - // Where in a failure state no further validation should be performed. - if (context.FailureResult.HasValue) - return context; - } - - var result = await OnValidateAsync(context, cancellationToken).ConfigureAwait(false); - if (result.IsSuccess && _additionalAsync != null) - result = await _additionalAsync(context, cancellationToken).ConfigureAwait(false); - - context.SetFailureResult(result); - return context; - }, cancellationToken); - } - - /// - /// Validate the entity value (post all configured property rules) enabling additional validation logic to be added by the inheriting classes. - /// - /// The . - /// The . - /// The corresponding . - /// The (see 'AddError' and related methods should be used for specific validation messages. Any will - /// override any existing validations and no further validations will occur. - protected virtual Task OnValidateAsync(ValidationContext context, CancellationToken cancellationToken) => Task.FromResult(Result.Success); - - /// - /// Adds a to the validator. - /// - /// The property . - /// The to reference the entity property. - /// The . - public override IPropertyRule Property(Expression> propertyExpression) - { - // Depending on the the state update either the ruleset rules or the underlying rules. - if (_currentRuleSet == null) - return base.Property(propertyExpression); - - return _currentRuleSet.Property(propertyExpression); - } - - /// - /// Adds the to the validator enabling additional configuration via the specified action. - /// - /// The property . - /// The to reference the entity property. - /// The action to act on the created . - /// The . - public Validator HasProperty(Expression> propertyExpression, Action>? property = null) - { - var p = Property(propertyExpression); - property?.Invoke(p); - return this; - } - - /// - /// Adds the to the validator enabling additional configuration via the specified action. - /// - /// The property . - /// The to reference the entity property. - /// The action to act on the created . - /// The . - /// This is a synonym for . - public Validator HasRuleFor(Expression> propertyExpression, Action>? property = null) => HasProperty(propertyExpression, property); - - /// - /// Adds a to the validator to enable a same typed validator to be included within the validator rule set. - /// - /// The to include (add). - /// The . - public Validator Include(IValidatorEx include) - { - include.ThrowIfNull(nameof(include)); - - if (_currentRuleSet == null) - Rules.Add(new IncludeBaseRule(include)); - else - _currentRuleSet.Rules.Add(new IncludeBaseRule(include)); - - return this; - } - - /// - /// Adds a to the validator to enable a base validator to be included within the validator rule set. - /// - /// The include in which inherits from. - /// The to include (add). - /// The . - public Validator IncludeBase(IValidatorEx include) where TInclude : class - { - include.ThrowIfNull(nameof(include)); + private List, CancellationToken, Task>>? _additionalAsync; - if (!typeof(TEntity).GetTypeInfo().IsSubclassOf(typeof(TInclude))) - throw new ArgumentException($"Type {typeof(TEntity).Name} must inherit from {typeof(TInclude).Name}.", nameof(include)); + /// + public async sealed override Task> ValidateAsync(TEntity value, ValidationArgs? args = null, CancellationToken cancellationToken = default) + { + var context = new ValidationContext(value, args ?? new ValidationArgs { FullyQualifiedEntityName = Validation.ValueName, FullyQualifiedJsonEntityName = null }); + await ValidateInternalAsync(context, cancellationToken).ConfigureAwait(false); + return context; + } - if (_currentRuleSet == null) - Rules.Add(new IncludeBaseRule(include)); - else - _currentRuleSet.Rules.Add(new IncludeBaseRule(include)); + /// + public async sealed override Task ValidateAndThrowAsync(TEntity value, ValidationArgs? args = null, CancellationToken cancellationToken = default) + => (await ValidateAsync(value, args, cancellationToken).ConfigureAwait(false)).ThrowOnError(); - return this; - } + /// + internal override Task ValidateAsync(IValidationContext context, CancellationToken cancellationToken) + => ValidateInternalAsync((ValidationContext)context, cancellationToken); - /// - /// Validate the entity value (post all configured property rules) enabling additional validation logic to be added. - /// - /// The asynchronous function to invoke. - /// The . - public Validator AdditionalAsync(Func, CancellationToken, Task> additionalAsync) + /// + /// Orchestrates the validation of the entity value + /// + private async Task ValidateInternalAsync(ValidationContext context, CancellationToken cancellationToken) + { + if (context.Value is null) { - if (_additionalAsync != null) - throw new InvalidOperationException("Additional can only be defined once for a Validator."); - - _additionalAsync = additionalAsync.ThrowIfNull(nameof(additionalAsync)); - return this; + context.AddError(Validation.MandatoryFormat, Validation.ValueText); + return; } - /// - /// Adds a that is conditionally invoked where the is true. - /// - /// The predicate. - /// The action to invoke where the method will update the corresponding rules. - /// The . - public RuleSet RuleSet(Predicate> predicate, Action action) + foreach (var rule in Rules) { - predicate.ThrowIfNull(nameof(predicate)); - action.ThrowIfNull(nameof(action)); - - return SetRuleSet(new RuleSet(predicate), (v) => action()); + await rule.ValidateAsync(context, cancellationToken).ConfigureAwait(false); } - /// - /// Adds a that is conditionally invoked where the is true. - /// - /// The predicate. - /// The action to invoke where the passed enables the rules to be updated. - /// The . - public Validator HasRuleSet(Predicate> predicate, Action> action) - { - predicate.ThrowIfNull(nameof(predicate)); - action.ThrowIfNull(nameof(action)); - - SetRuleSet(new RuleSet(predicate), action); - return this; - } + await OnValidateAsync(context, cancellationToken).ConfigureAwait(false); - /// - /// Sets the rule set and invokes the action. - /// - private RuleSet SetRuleSet(RuleSet ruleSet, Action> action) + if (_additionalAsync is not null) { - if (_currentRuleSet != null) - throw new InvalidOperationException("RuleSets only support a single level of nesting."); - - // Invoke the action that will add the entries to the ruleset not the underlying rules. - if (action != null) + foreach (var item in _additionalAsync) { - _currentRuleSet = ruleSet; - action(this); - _currentRuleSet = null; + await item(context, cancellationToken).ConfigureAwait(false); } - - // Add the ruleset to the rules. - Rules.Add(ruleSet); - return ruleSet; } + } - /// - /// Throws a where the is set based on the . - /// - /// The property . - /// The to reference the entity property. - /// The message text. - public void ThrowValidationException(Expression> propertyExpression, LText text) - { - var p = PropertyExpression.Create(propertyExpression); - throw new ValidationException(MessageItem.CreateErrorMessage(ValidationArgs.DefaultUseJsonNames ? p.JsonName : p.Name, text)); - } + /// + /// Validate the entity value (post all configured chained rules) enabling multiple additional validation functions to be added. + /// + /// The additional validation function. + /// The . + public Validator AdditionalAsync(Func, CancellationToken, Task> additionalAsync) + { + (_additionalAsync ??= []).Add(additionalAsync.ThrowIfNull()); + return this; + } - /// - /// Throws a where the is set based on the . The property - /// friendly text and are automatically passed as the first two arguments to the string formatter. - /// - /// The property . - /// The to reference the entity property. - /// The composite format string. - /// The property values (to be used as part of the format). - /// - public void ThrowValidationException(Expression> propertyExpression, LText format, TProperty propertyValue, params object[] values) - { - var p = PropertyExpression.Create(propertyExpression); - throw new ValidationException(MessageItem.CreateErrorMessage(ValidationArgs.DefaultUseJsonNames ? p.JsonName : p.Name, - string.Format(System.Globalization.CultureInfo.CurrentCulture, format, [p.Text, propertyValue!, .. values]))); - } + /// + /// Validates the entity (post all configured chained rules) enabling additional validation logic to be added by the inheriting class. + /// + /// The . + /// The . + protected virtual Task OnValidateAsync(ValidationContext context, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Validate the value with optional with a corresponding . + /// + /// The value. + /// An optional . + /// The . + /// The . + public async Task> ValidateWithResultAsync(TEntity value, ValidationArgs? args = null, CancellationToken cancellationToken = default) + { + var vc = await ValidateAsync(value, args, cancellationToken).ConfigureAwait(false); + return vc.HasErrors ? vc.ToResult() : value; } } \ No newline at end of file diff --git a/src/CoreEx.Validation/ValidatorT2.cs b/src/CoreEx.Validation/ValidatorT2.cs new file mode 100644 index 00000000..7a12c73b --- /dev/null +++ b/src/CoreEx.Validation/ValidatorT2.cs @@ -0,0 +1,14 @@ +namespace CoreEx.Validation; + +/// +/// Provides entity validation with a singleton instance. +/// +/// The entity . +/// The self . +public abstract class Validator : Validator where TEntity : class where TSelf : Validator, new() +{ + /// + /// Gets the default singleton instance. + /// + public static TSelf Default { get; } = new TSelf(); +} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValueValidationConfiguration.cs b/src/CoreEx.Validation/ValueValidationConfiguration.cs deleted file mode 100644 index 6bbb7f3c..00000000 --- a/src/CoreEx.Validation/ValueValidationConfiguration.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Enables the ability to configure the . - /// - public class ValueValidatorConfiguration : PropertyRuleBase, T> - { - /// - /// Initializes a new instance of the class. - /// - /// The property name. - /// The friendly text name used in validation messages (defaults to as ). - /// The JSON property name (defaults to ). - internal ValueValidatorConfiguration(string name, LText? text = null, string? jsonName = null) : base(name, text, jsonName) { } - - /// - /// Performs the underlying validation. - /// - /// The . - /// The . - /// The . - internal async Task, T>> ValidateAsync(ValidationValue validationValue, CancellationToken cancellationToken) - { - var ctx = new PropertyContext, T>(new ValidationContext>(validationValue, new ValidationArgs()), validationValue.Value, Name, JsonName, Text); - await InvokeAsync(ctx, cancellationToken).ConfigureAwait(false); - return new ValueValidatorResult, T>(ctx); - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValueValidator.cs b/src/CoreEx.Validation/ValueValidator.cs deleted file mode 100644 index 1176b1ee..00000000 --- a/src/CoreEx.Validation/ValueValidator.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Enables validation for a value. - /// - /// The value . - public class ValueValidator - { - private readonly ValueValidatorConfiguration _configuration; - private readonly ValidationValue _validationValue; - - /// - /// Initializes a new instance of the class. - /// - /// The value to validate. - /// The value name (defaults to ). - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - internal ValueValidator(T? value, string? name = null, LText? text = null) - { - _configuration = new ValueValidatorConfiguration(string.IsNullOrEmpty(name) ? Validation.ValueNameDefault : name, text); - _validationValue = new ValidationValue(null, value); - } - - /// - /// Gets the initiating value being validated. - /// - public T? Value { get => _validationValue.Value; } - - /// - /// Enables the validator underlying to be further configured. - /// - /// The . - /// The instance to support fluent-style method-chaining. - public ValueValidator Configure(Action>? validator) - { - validator?.Invoke(_configuration); - return this; - } - - /// - /// Validates the . - /// - /// The . - /// The . - public Task, T>> ValidateAsync(CancellationToken cancellationToken = default) - => ValidationInvoker.Current.InvokeAsync(this, (_, cancellationToken) => _configuration.ValidateAsync(_validationValue, cancellationToken), cancellationToken); - - /// - /// Validates the . - /// - /// Indicates whether to automatically throw a where . - /// The . - /// The . - public async Task, T>> ValidateAsync(bool throwOnError, CancellationToken cancellationToken = default) - { - var vr = await ValidateAsync(cancellationToken).ConfigureAwait(false); - return throwOnError ? vr.ThrowOnError() : vr; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/ValueValidatorResult.cs b/src/CoreEx.Validation/ValueValidatorResult.cs deleted file mode 100644 index 750ae869..00000000 --- a/src/CoreEx.Validation/ValueValidatorResult.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Results; -using System; - -namespace CoreEx.Validation -{ - /// - /// Enables the result of a . - /// - /// The entity . - /// The property . - public interface IValueValidatorResult : IValidationResult where TEntity : class { } - - /// - /// Represents the result of a . - /// - /// The entity . - /// The property . - /// The . - public sealed class ValueValidatorResult(PropertyContext context) : IValueValidatorResult where TEntity : class - { - private readonly PropertyContext _context = context.ThrowIfNull(nameof(context)); - - /// - public TProperty? Value => _context.Value; - - /// - public bool HasErrors => _context.Parent.FailureResult is not null || _context.HasError; - - /// - public MessageItemCollection? Messages => _context.Parent.Messages; - - /// - public Exception? ToException() => _context.Parent.ToException(); - - /// - IValidationResult IValidationResult.ThrowOnError() => ThrowOnError(); - - /// - public Result? FailureResult => _context.Parent.FailureResult; - - /// - public Result ToResult() => FailureResult.HasValue ? FailureResult.Value.Bind() : (HasErrors ? Result.ValidationError(Messages!) : Validation.ConvertValueToResult(Value!)); - - /// - public Result ToResult() => FailureResult ?? (HasErrors ? Result.ValidationError(Messages!) : Result.Success); - - /// - /// Throws a where an error was found (and optionally if warnings). - /// - /// Indicates whether to throw where only warnings exist. - /// The to support fluent-style method-chaining. - public ValueValidatorResult ThrowOnError(bool includeWarnings = false) - { - _context.Parent.ThrowOnError(includeWarnings); - return this; - } - } -} \ No newline at end of file diff --git a/src/CoreEx.Validation/strong-name-key.snk b/src/CoreEx.Validation/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/CoreEx/Abstractions/ETagGenerator.cs b/src/CoreEx/Abstractions/ETagGenerator.cs deleted file mode 100644 index a112bf63..00000000 --- a/src/CoreEx/Abstractions/ETagGenerator.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Json; -using System; -using System.Security.Cryptography; - -namespace CoreEx.Abstractions -{ - /// - /// Provides generator capabilities. - /// - public static class ETagGenerator - { - /// - /// Generates an ETag for a value by serializing to JSON and performing an hash. - /// - /// The . - /// The . - /// The value. - /// The generated ETag. - public static string? Generate(IJsonSerializer jsonSerializer, T? value) - { - jsonSerializer.ThrowIfNull(nameof(jsonSerializer)); - if (value == null) - return null; - - // Serialize to JSON and then hash. - byte[] hash; - var bd = jsonSerializer.SerializeToBinaryData(value); -#if NETSTANDARD2_1 - using var sha256 = SHA256.Create(); - hash = sha256.ComputeHash(bd.ToArray()); -#else - hash = SHA256.HashData(bd); -#endif - return Convert.ToBase64String(hash); - } - - /// - /// Generates a hash of the parts using . - /// - /// The parts to hash. - /// The hashed value. - public static string? GenerateHash(params string[] parts) - { - if (parts == null || parts.Length == 0) - return null; - - byte[] hash; -#if NETSTANDARD2_1 - var input = parts.Length == 1 ? parts[0] : string.Concat(parts); - using var sha256 = SHA256.Create(); - hash = sha256.ComputeHash(new BinaryData(input).ToArray()); -#else - if (parts.Length == 1) - hash = SHA256.HashData(new BinaryData(parts[0])); - else - { - using var ih = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - foreach (var part in parts) - { - ih.AppendData(new BinaryData(part)); - } - - hash = ih.GetCurrentHash(); - } - -#endif - return Convert.ToBase64String(hash); - } - - /// - /// Formats a as an by bookending with the requisite double quotes character; for example 'abc' would be formatted as '"abc"'. - /// - /// The value to format. - /// The formatted . - public static string? FormatETag(string? value) - { - if (value is null) - return null; - - if (value.StartsWith('\"') && value.EndsWith('\"')) - return value; - - if (value.StartsWith("W/\"") && value.EndsWith('\"')) - return value[2..]; - - return $"\"{value}\""; - } - - /// - /// Parses an by removing any weak prefix ('W/') double quotes character bookends; for example '"abc"' would be formatted as 'abc'. - /// - /// The to unformat. - /// The unformatted value. - public static string? ParseETag(string? etag) - { - if (string.IsNullOrEmpty(etag)) - return null; - - if (etag.StartsWith('\"') && etag.EndsWith('\"')) - return etag[1..^1]; - - if (etag.StartsWith("W/\"") && etag.EndsWith('\"')) - return etag[2..^1]; - - return etag; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/ErrorType.cs b/src/CoreEx/Abstractions/ErrorType.cs deleted file mode 100644 index ab45f392..00000000 --- a/src/CoreEx/Abstractions/ErrorType.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Abstractions -{ - /// - /// Represents an error type. - /// - public enum ErrorType - { - /// - /// Indicates a Validation error. - /// - ValidationError = 1, - - /// - /// Indicates a Business error. - /// - BusinessError = 2, - - /// - /// Indicates an Authorization error. - /// - AuthorizationError = 3, - - /// - /// Indicates a Concurrency error. - /// - ConcurrencyError = 4, - - /// - /// Indicates a Not Found error. - /// - NotFoundError = 5, - - /// - /// Indicates a Conflict error. - /// - ConflictError = 6, - - /// - /// Indicates a Duplicate error. - /// - DuplicateError = 7, - - /// - /// Indicates an Authentication error. - /// - AuthenticationError = 8, - - /// - /// Indicates a Transient error. - /// - TransientError = 9, - - /// - /// Indicates a Data Consistency error. - /// - DataConsistencyError = 10, - - /// - /// Indicates an unknown/unhandled error. - /// - UnhandledError = 88 - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/ExtendedException.cs b/src/CoreEx/Abstractions/ExtendedException.cs new file mode 100644 index 00000000..80179a4b --- /dev/null +++ b/src/CoreEx/Abstractions/ExtendedException.cs @@ -0,0 +1,109 @@ +namespace CoreEx.Abstractions; + +/// +/// Provides the base implementation. +/// +public abstract class ExtendedException: Exception, IExtendedException +{ + private readonly Lazy> _extensions = new(() => []); + + /// + /// Initializes a new instance of the class using the specified and . + /// + /// The error message. + /// The inner . + /// The . + /// The default logging enablement where no corresponding configuration setting is found. + public ExtendedException(LText? message, Exception? innerException, Type? exceptionType = null, bool defaultLoggingEnablement = false) : base(message, innerException) + { + ShouldBeLogged = Internal.GetConfigurationValueWithFallback($"CoreEx:Exceptions:{(exceptionType ?? GetType()).Name}:LoggingEnabled", "CoreEx:Exceptions:LoggingEnabled", defaultLoggingEnablement); + OnInitialize(); + } + + /// + /// Provides the opportunity to perform specific initialization. + /// + protected abstract void OnInitialize(); + + /// + public string? Detail { get; set; } + + /// + public string? ErrorType { get; set; } + + /// + public string? ErrorCode { get; set; } + + /// + public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.InternalServerError; + + /// + public bool IsError { get; set; } = true; + + /// + public bool IsTransient { get; set; } + + /// + public TimeSpan? RetryAfter { get; set; } + + /// + public bool ShouldBeLogged { get; set; } + + /// + public IDictionary Extensions => _extensions.Value; + + /// + public bool HasExtensions => _extensions.IsValueCreated && _extensions.Value.Count > 0; + + /// + public Result ToResult() => new(this); + + /// + public override string ToString() + { + var sb = new StringBuilder(base.ToString()); + sb.AppendLine(); + sb.Append($"-> ErrorType: {ErrorType}"); + if (ErrorCode is not null) + sb.Append($", ErrorCode: {ErrorCode}"); + + if (Detail is not null) + sb.Append($", Detail: {Detail}"); + + if (HasExtensions) + { + foreach (var kvp in Extensions) + sb.Append($", {kvp.Key}: {kvp.Value}"); + } + + return sb.ToString(); + } + + /// + /// Converts an into an where is an . + /// + /// The result . + /// The . + /// The resulting value. + /// Indicates whether to force conversion for any exception, not just those implementing and considered . + /// where the conversion was successful; otherwise, . + public static bool TryConvertExceptionToResult(Exception exception, [NotNullWhen(true)] out TResult? result, bool forceAnyException = false) + { + exception.ThrowIfNull(); + + // Where the result is an IResult (ROP) and the exception is considered an error then return as an IResult _failure_; unless being forced for any exception. + if (forceAnyException || (exception is IExtendedException eex && eex.IsError)) + { + var dresult = default(TResult); + if (dresult is IResult dir) + { + result = (TResult)dir.ToFailure(exception); + return true; + } + } + + // No conversion possible. + result = default; + return false; + } +} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/ExtendedExceptionT.cs b/src/CoreEx/Abstractions/ExtendedExceptionT.cs new file mode 100644 index 00000000..ecad2711 --- /dev/null +++ b/src/CoreEx/Abstractions/ExtendedExceptionT.cs @@ -0,0 +1,21 @@ +namespace CoreEx.Abstractions; + +/// +/// Provides the base implementation. +/// +/// The itself. +/// The error message. +/// The inner . +/// The default logging enablement where no corresponding configuration setting is found. +public abstract class ExtendedException(LText? message, Exception? innerException, bool defaultLoggingEnablement = false) + : ExtendedException(message, innerException, typeof(TSelf), defaultLoggingEnablement) where TSelf : ExtendedException +{ + /// + /// Gets the configured for the exception type. + /// + /// The default where not configured. + /// The optional ; otherwise, defaults from . + /// The . + protected HttpStatusCode GetConfiguredStatusCode(HttpStatusCode @default, IConfiguration? configuration = null) + => Internal.GetConfigurationValue($"CoreEx:Exception:{typeof(TSelf).Name}:StatusCode", @default, configuration); +} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/IEnumerableExtensions.cs b/src/CoreEx/Abstractions/IEnumerableExtensions.cs deleted file mode 100644 index 4cfd1897..00000000 --- a/src/CoreEx/Abstractions/IEnumerableExtensions.cs +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Wildcards; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq.Expressions; -using System.Threading.Tasks; - -namespace System.Linq -{ - /// - /// Provides additional extension methods. - /// - [DebuggerStepThrough] - public static class IEnumerableExtensions - { - /// - /// Performs the specified on each element in the sequence. - /// - /// The item . - /// The sequence to iterate. - /// The action to perform on each element. - /// The sequence. - public static IEnumerable ForEach(this IEnumerable sequence, Action action) - { - if (sequence == null) - return sequence!; - - action.ThrowIfNull(nameof(action)); - - foreach (TItem element in sequence.ThrowIfNull(nameof(sequence))) - { - action(element); - } - - return sequence; - } - - /// - /// Performs the specified on each element in the sequence asynchronously. - /// - /// The item . - /// The sequence to iterate. - /// The action to perform on each element. - /// The sequence. - public static async Task> ForEachAsync(this IEnumerable sequence, Func action) - { - if (sequence == null) - return sequence!; - - action.ThrowIfNull(nameof(action)); - - foreach (TItem element in sequence.ThrowIfNull(nameof(sequence))) - { - await action(element).ConfigureAwait(false); - } - - return sequence; - } - - /// - /// Creates a collection from a . - /// - /// The collection . - /// The item . - /// The sequence to iterate. - /// A new collection that contains the elements from the input sequence. - public static TColl ToCollection(this IEnumerable sequence) - where TColl : ICollection, new() - { - var coll = new TColl(); - ToCollection(sequence, coll); - return coll; - } - - /// - /// Add to a collection from a . - /// - /// The collection . - /// The item . - /// The sequence to iterate. - /// The collection to add the elements from the input sequence. - public static void ToCollection(this IEnumerable sequence, TColl coll) - where TColl : ICollection - { - coll.ThrowIfNull(nameof(coll)); - sequence.ForEach(coll.Add); - } - - /// - /// Filters a sequence of values based on a only true. - /// - /// The element . - /// The query. - /// A function to test each element for a condition. - /// Indicates to perform an underlying only when true; otherwise, no Where is invoked. - /// An that contains elements from the input sequence that satisfy the condition. - public static IEnumerable WhereWhen(this IEnumerable query, Func predicate, bool when) => when ? query.Where(predicate): query; - - /// - /// Filters a sequence of values using the specified and containing supported wildcards (intended for LINQ to Objects). - /// - /// The element . - /// The query. - /// The . - /// The text to query. - /// Indicates whether the comparison should ignore case (default) or not. - /// Indicates whether a null check should also be performed before the comparion occurs (defaults to true). - /// The configuration to use; where null it will use . - /// The resulting (updated) query. - public static IEnumerable WhereWildcard(this IEnumerable query, Func property, string? text, bool ignoreCase = true, bool checkForNull = true, Wildcard? wildcard = null) - where TElement : class - { - query.ThrowIfNull(nameof(query)); - property.ThrowIfNull(nameof(property)); - - var wc = wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; - var wr = wc.Parse(text).ThrowOnError(); - - // Exit stage left where nothing to do. - if (wr.Selection.HasFlag(WildcardSelection.None)) - return query; - - // Handle the Equal. - var sc = ignoreCase ? StringComparison.CurrentCultureIgnoreCase : StringComparison.CurrentCulture; - if (wr.Selection.HasFlag(WildcardSelection.Equal)) - return query.Where(x => - { - var v = property.Invoke(x); - return checkForNull ? v != null && v.Equals(text, sc) : v!.Equals(text, sc); - }); - - // Handle the easy Contains/StartsWith/Endswith. - if (!wr.Selection.HasFlag(WildcardSelection.SingleWildcard) && !wr.Selection.HasFlag(WildcardSelection.Embedded)) - { - if (wr.Selection.HasFlag(WildcardSelection.Single)) - return query; - - if (wr.Selection.HasFlag(WildcardSelection.Contains)) - return query.Where(x => - { - var v = property.Invoke(x); - return checkForNull ? v != null && v.Contains(wr.GetTextWithoutWildcards()!, sc) : v!.Contains(wr.GetTextWithoutWildcards()!, sc); - }); - else if (wr.Selection.HasFlag(WildcardSelection.StartsWith)) - return query.Where(x => - { - var v = property.Invoke(x); - return checkForNull ? v != null && v.StartsWith(wr.GetTextWithoutWildcards()!, sc) : v!.StartsWith(wr.GetTextWithoutWildcards()!, sc); - }); - else if (wr.Selection.HasFlag(WildcardSelection.EndsWith)) - return query.Where(x => - { - var v = property.Invoke(x); - return checkForNull ? v != null && v.EndsWith(wr.GetTextWithoutWildcards()!, sc) : v!.EndsWith(wr.GetTextWithoutWildcards()!, sc); - }); - } - - // Handle the remainder using a regex. - var regex = wr.CreateRegex(ignoreCase); - return query.Where(x => - { - var v = property.Invoke(x); - return v != null && regex.IsMatch(v); - }); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/IExtendedException.cs b/src/CoreEx/Abstractions/IExtendedException.cs index 0e79291c..97623356 100644 --- a/src/CoreEx/Abstractions/IExtendedException.cs +++ b/src/CoreEx/Abstractions/IExtendedException.cs @@ -1,44 +1,69 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Abstractions; -using System.Net; - -namespace CoreEx.Abstractions +/// +/// Enables the extended capabilities. +/// +public interface IExtendedException { /// - /// Enables the extended exception capabilities. - /// - public interface IExtendedException - { - /// - /// Gets the exception message. - /// - string Message { get; } - - /// - /// Gets the error type/reason. - /// - /// See for standard values. - string ErrorType { get; } - - /// - /// Gets the error code. - /// - /// See for standard values. - int ErrorCode { get; } - - /// - /// Gets the corresponding . - /// - HttpStatusCode StatusCode { get; } - - /// - /// Indicates whether exception is transient; i.e. is a candidate for a retry. - /// - bool IsTransient { get; } - - /// - /// Indicates whether the should be logged. - /// - bool ShouldBeLogged { get; } - } + /// Gets the exception message. + /// + string Message { get; } + + /// + /// Gets the exception detail. + /// + string? Detail { get; } + + /// + /// Gets the error type/category. + /// + string? ErrorType { get; } + + /// + /// Gets the error code. + /// + string? ErrorCode { get; } + + /// + /// Gets the corresponding . + /// + HttpStatusCode StatusCode { get; } + + /// + /// Indicates whether this exception exception should be treated as a known/supported error; versus, being an unexpected outcome and handled accordingly. + /// + bool IsError { get; } + + /// + /// Indicates whether exception is transient; i.e. is a candidate for a retry. + /// + bool IsTransient { get; } + + /// + /// Gets the retry after interval. + /// + TimeSpan? RetryAfter { get; } + + /// + /// Indicates whether the should be logged. + /// + bool ShouldBeLogged { get; } + + /// + /// Gets an of extension values. + /// + /// This enables additional values to be captured against the exception for later use/inspection. + IDictionary Extensions { get; } + + /// + /// Indicates whether there are any . + /// + bool HasExtensions { get; } + + /// + /// Converts the to a . + /// + /// The . + Result ToResult(); } \ No newline at end of file diff --git a/src/CoreEx/Abstractions/IQueryableExtensions.cs b/src/CoreEx/Abstractions/IQueryableExtensions.cs deleted file mode 100644 index 35b117e8..00000000 --- a/src/CoreEx/Abstractions/IQueryableExtensions.cs +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Entities; -using CoreEx.Wildcards; -using System.Collections.Generic; -using System.Linq.Expressions; - -namespace System.Linq -{ - /// - /// Adds additional extension methods to the . - /// - public static class IQueryableExtensions - { - /// - /// Adds paging to the query. - /// - /// The being queried. - /// The query. - /// The . - /// The query. - public static IQueryable WithPaging(this IQueryable query, PagingArgs? paging) - { - if (paging is null) - return query.WithPaging(0, null); - - if (paging.Option == PagingOption.TokenAndTake) - throw new ArgumentException("PagingArgs.Option must not be PagingOption.TokenAndTake.", nameof(paging)); - - return query.WithPaging(paging.Skip!.Value, paging.Take); - } - - /// - /// Adds paging to the query using the specified and . - /// - /// The being queried. - /// The query. - /// The specified number of elements in a sequence to bypass. - /// The specified number of contiguous elements from the start of a sequence. - /// The query. - public static IQueryable WithPaging(this IQueryable query, long skip, long? take = null) - { - var q = query.Skip(skip <= 0 ? 0 : (int)skip); - q = q.Take(take == null || take.Value < 1 ? (int)PagingArgs.DefaultTake : (int)take.Value); - return q; - } - - /// - /// Creates a collection from a . - /// - /// The collection . - /// The item . - /// The . - /// A new collection that contains the elements from the input sequence. - public static TColl ToCollection(this IQueryable query) - where TColl : ICollection, new() - { - var coll = new TColl(); - ToCollection(query, coll); - return coll; - } - - /// - /// Creates a collection from a mapping each element to a corresponding item. - /// - /// The collection . - /// The item . - /// The element . - /// >The . - /// The mapping function invoked for each element. - /// A new collection that contains the elements from the input sequence. - public static TColl ToCollection(this IQueryable query, Func mapToItem) - where TColl : ICollection, new() - { - var coll = new TColl(); - ToCollection(query, mapToItem, coll); - return coll; - } - - /// - /// Add to a collection from a . - /// - /// The collection . - /// The item . - /// The . - /// The collection to add the elements from the input sequence. - public static void ToCollection(this IQueryable query, TColl coll) - where TColl : ICollection - { - coll.ThrowIfNull(nameof(coll)); - - foreach (var item in query.ThrowIfNull(nameof(query))) - { - coll.Add(item); - } - } - - /// - /// Add to a collection from a mapping each element to a corresponding item. - /// - /// The collection . - /// The item . - /// The element . - /// >The . - /// The mapping function invoked for each element. - /// The collection to add the elements from the input sequence. - public static void ToCollection(this IQueryable query, Func mapToItem, TColl coll) - where TColl : ICollection - { - mapToItem.ThrowIfNull(nameof(mapToItem)); - coll.ThrowIfNull(nameof(coll)); - - foreach (var element in query.ThrowIfNull(nameof(query))) - { - coll.Add(mapToItem(element)); - } - } - - /// - /// Filters a sequence of values based on a only true. - /// - /// The element . - /// The query. - /// Indicates to perform an underlying only when true; - /// otherwise, no Where is invoked. - /// A function to test each element for a condition. - /// The resulting query. - public static IQueryable WhereWhen(this IQueryable query, bool when, Expression> predicate) - { - query.ThrowIfNull(nameof(query)); - - if (when) - return query.Where(predicate); - else - return query; - } - - /// - /// Filters a sequence of values based on a only when the is not the default value for the . - /// - /// The element . - /// The with value . - /// The query. - /// Indicates to perform an underlying only when the with is not the default - /// value; otherwise, no Where is invoked. - /// A function to test each element for a condition. - /// The resulting query. - public static IQueryable WhereWith(this IQueryable query, T with, Expression> predicate) - { - query.ThrowIfNull(nameof(query)); - - if (Comparer.Default.Compare(with, default!) != 0 && Comparer.Default.Compare(with, default!) != 0) - { - if (with is not string && with is System.Collections.IEnumerable ie && !ie.GetEnumerator().MoveNext()) - return query; - - return query.Where(predicate); - } - else - return query; - } - - /// - /// Filters a sequence of values using the specified and containing supported wildcards. - /// - /// The element . - /// The query. - /// The . - /// The text to query. - /// Indicates whether the comparison should ignore case (default) or not; will use when selected for comparisons. - /// Indicates whether a null check should also be performed before the comparion occurs (defaults to true). - /// The resulting (updated) query. - public static IQueryable WhereWildcard(this IQueryable query, Expression> property, string? text, bool ignoreCase = true, bool checkForNull = true) - { - query.ThrowIfNull(nameof(query)); - property.ThrowIfNull(nameof(property)); - - var wc = Wildcard.MultiBasic; - var wr = wc.Parse(text).ThrowOnError(); - - // Exit stage left where nothing to do. - if (wr.Selection.HasFlag(WildcardSelection.None) || wr.Selection.HasFlag(WildcardSelection.Single)) - return query; - - // Check the expression. - if (property.Body is not MemberExpression me) - throw new ArgumentException("Property expression must be of Type MemberExpression.", nameof(property)); - - Expression exp = me; - var s = wr.GetTextWithoutWildcards(); - if (ignoreCase) - { - s = s?.ToUpper(System.Globalization.CultureInfo.CurrentCulture); - exp = Expression.Call(me, typeof(string).GetMethod("ToUpper", System.Type.EmptyTypes)!)!; - } - - if (wr.Selection.HasFlag(WildcardSelection.Equal)) - exp = Expression.Equal(exp, Expression.Constant(s)); - else if (wr.Selection.HasFlag(WildcardSelection.EndsWith)) - exp = Expression.Call(exp, "EndsWith", null, Expression.Constant(s)); - else if (wr.Selection.HasFlag(WildcardSelection.StartsWith)) - exp = Expression.Call(exp, "StartsWith", null, Expression.Constant(s)); - else if (wr.Selection.HasFlag(WildcardSelection.Contains)) - exp = Expression.Call(exp, "Contains", null, Expression.Constant(s)); - else - throw new ArgumentException("Wildcard selection text is not supported.", nameof(text)); - - // Add check for not null. - if (checkForNull) - { - var ee = Expression.NotEqual(me, Expression.Constant(null)); - exp = Expression.AndAlso(ee, exp); - } - - // Create the final lambda expression. - return query.Where(Expression.Lambda>(exp, property.Parameters)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs b/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs deleted file mode 100644 index d680bebe..00000000 --- a/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs +++ /dev/null @@ -1,438 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx; -using CoreEx.Caching; -using CoreEx.Configuration; -using CoreEx.Entities; -using CoreEx.Events; -using CoreEx.Events.HealthChecks; -using CoreEx.Events.Subscribing; -using CoreEx.Hosting; -using CoreEx.Hosting.Work; -using CoreEx.Http; -using CoreEx.Json; -using CoreEx.Json.Merge; -using CoreEx.Json.Merge.Extended; -using CoreEx.Mapping; -using CoreEx.RefData; -using CoreEx.RefData.Caching; -using CoreEx.RefData.HealthChecks; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Reflection; - -namespace Microsoft.Extensions.DependencyInjection -{ - /// - /// Provides extensions. - /// - public static class IServiceCollectionExtensions - { - /// - /// Checks that the is not null. - /// - private static IServiceCollection CheckServices(IServiceCollection services) => services.ThrowIfNull(nameof(services)); - - /// - /// Removes all items from the for the specified . - /// - /// The service . - /// The . - /// true if item was successfully removed; otherwise, false. Also returns false where item was not found. - public static bool Remove(this IServiceCollection services) where TService : class - { - var descriptor = CheckServices(services).FirstOrDefault(d => d.ServiceType == typeof(TService)); - return descriptor != null && services.Remove(descriptor); - } - - /// - /// Adds the as the singleton service. - /// - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddSystemTime(this IServiceCollection services) => CheckServices(services).AddSingleton(sp => SystemTime.Default); - - /// - /// Adds a scoped service to instantiate a new instance. - /// - /// The . - /// The function to override the creation of the instance. - /// The for fluent-style method-chaining. - /// Where the is null, then the is used to create. - public static IServiceCollection AddExecutionContext(this IServiceCollection services, Func? executionContextFactory = null) - { - return CheckServices(services).AddScoped(sp => - { - var ec = executionContextFactory?.Invoke(sp) ?? ExecutionContext.Create?.Invoke() ?? - throw new InvalidOperationException("Unable to create 'ExecutionContext' instance; either (in order) 'executionContextFactory' resulted in null, or 'ExecutionContext.Create' resulted in null."); - - ec.ServiceProvider = sp; - - ExecutionContext.Reset(); - ExecutionContext.SetCurrent(ec); - - return ec; - }); - } - - /// - /// Adds a using the underlying . - /// - /// The client . - /// The . - /// The logical name of the to configure. - /// The delegate to configure the underlying . - /// An that can be used to configure the client. - public static IHttpClientBuilder AddTypedHttpClient(this IServiceCollection services, string name, Action? configure = null) where TClient : TypedHttpClientBase - => configure == null ? services.AddHttpClient(name) : services.AddHttpClient(name, configure); - - /// - /// Adds a using the underlying . - /// - /// The client . - /// The client implementation . - /// The . - /// The logical name of the to configure. - /// The delegate to configure the underlying . - /// An that can be used to configure the client. - public static IHttpClientBuilder AddTypedHttpClient(this IServiceCollection services, string name, Action? configure = null) where TClient : class where TImplementation : TypedHttpClientBase, TClient - => configure == null ? services.AddHttpClient(name) : services.AddHttpClient(name, configure); - - /// - /// Adds the as the singleton service. - /// - /// The . - /// The . - public static IServiceCollection AddDefaultSettings(this IServiceCollection services) => CheckServices(services).AddSettings(); - - /// - /// Adds the as the singleton service. - /// - /// The . - /// The . - /// Where the has not been registered then this will be registered automatically also. - public static IServiceCollection AddSettings(this IServiceCollection services) where TSettings : SettingsBase - { - CheckServices(services).AddSingleton(); - - if (services.FirstOrDefault(d => d.ServiceType == typeof(SettingsBase)) == null) - CheckServices(services).AddSingleton(); - - return services; - } - - /// - /// Adds the as the singleton service. - /// - /// The . - /// The . - public static IServiceCollection AddStringIdentifierGenerator(this IServiceCollection services) => CheckServices(services).AddSingleton, IdentifierGenerator>(); - - /// - /// Adds the as the singleton service. - /// - /// The . - /// The . - public static IServiceCollection AddGuidIdentifierGenerator(this IServiceCollection services) => CheckServices(services).AddSingleton, IdentifierGenerator>(); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The . - public static IServiceCollection AddLoggerEventPublisher(this IServiceCollection services) => CheckServices(services).AddEventPublisher(); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The . - public static IServiceCollection AddNullEventPublisher(this IServiceCollection services) => CheckServices(services).AddEventPublisher(); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The . - public static IServiceCollection AddEventPublisher(this IServiceCollection services) => CheckServices(services).AddEventPublisher(); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The . - /// The . - public static IServiceCollection AddEventPublisher(this IServiceCollection services) where TEventPublisher : class, IEventPublisher => CheckServices(services).AddScoped(); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The . - public static IServiceCollection AddLoggerEventSender(this IServiceCollection services) => CheckServices(services).AddEventSender(); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The . - public static IServiceCollection AddNullEventSender(this IServiceCollection services) => CheckServices(services).AddEventSender(); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The . - /// The . - public static IServiceCollection AddEventSender(this IServiceCollection services) where TEventSender : class, IEventSender => CheckServices(services).AddScoped(); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddEventSender(this IServiceCollection services, Action configure) where TEventSender : class, IEventSender => CheckServices(services).AddScoped(sp => - { - var sender = ActivatorUtilities.CreateInstance(sp); - configure(sp, sender); - return sender; - }); - - /// - /// Adds the as a singleton service. - /// - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddEventSubscriberOrchestrator(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddSingleton(sp => - { - var eso = new EventSubscriberOrchestrator(sp); - configure?.Invoke(sp, eso); - return eso; - }); - - /// - /// Adds all the types for a given that have at least one (see also ) as scoped services. - /// - /// The to infer the underlying . - /// The . - /// Indicates whether to include internally defined types. - /// The . - public static IServiceCollection AddEventSubscribers(this IServiceCollection services, bool includeInternalTypes = false) - { - foreach (var type in EventSubscriberOrchestrator.GetSubscribers(includeInternalTypes)) - { - CheckServices(services).TryAddScoped(type); - } - - return services; - } - - /// - /// Adds the as the and as the singleton services. - /// - /// The . - /// The . - public static IServiceCollection AddJsonSerializer(this IServiceCollection services) - => CheckServices(services).AddSingleton() - .AddSingleton(); - - /// - /// Adds the as the singleton service. - /// - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddJsonMergePatch(this IServiceCollection services, Action? configure = null) => AddJsonMergePatch(services, sp => - { - var jmpo = new JsonMergePatchExOptions(sp.GetService()); - configure?.Invoke(jmpo); - return new JsonMergePatchEx(jmpo); - }); - - /// - /// Adds the singleton service. - /// - /// The . - /// The function to create the instance.. - /// The . - public static IServiceCollection AddJsonMergePatch(this IServiceCollection services, Func? createFactory) => CheckServices(services).AddSingleton(sp => - { - return createFactory?.Invoke(sp) ?? throw new InvalidOperationException($"Unable to create '{nameof(IJsonMergePatch)}' instance; '{nameof(createFactory)}' resulted in null."); - }); - - /// - /// Adds the as the singleton service. - /// - /// The . - /// The action to enable the to be further configured. - /// The . - public static IServiceCollection AddCloudEventSerializer(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddSingleton(sp => - { - var ces = new CoreEx.Text.Json.CloudEventSerializer(sp.GetService()); - configure?.Invoke(sp, ces); - return ces; - }); - - /// - /// Adds the as the singleton service. - /// - /// The action to enable the to be further configured. - /// The . - /// The . - public static IServiceCollection AddEventDataSerializer(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddSingleton(sp => - { - var eds = new CoreEx.Text.Json.EventDataSerializer(sp.GetService(), sp.GetService()); - configure?.Invoke(sp, eds); - return eds; - }); - - /// - /// Adds the as the singleton service. - /// - /// The . - /// The optional ; will default where not specified. - /// The . - public static IServiceCollection AddEventDataFormatter(this IServiceCollection services, EventDataFormatter? formatter = null) => CheckServices(services).AddSingleton(_ => formatter ?? new EventDataFormatter()); - - /// - /// Adds the as the singleton service. - /// - /// The . - /// The . - public static IServiceCollection AddFileLockSynchronizer(this IServiceCollection services) => CheckServices(services).AddSingleton(); - - /// - /// Adds the created by as a singleton service. - /// - /// The . - /// The function to create the . - /// Indicates whether a corresponding should be configured. - /// The health check name; defaults to 'reference-data-orchestrator'. - /// The . - public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, Func createOrchestrator, bool healthCheck = true, string? healthCheckName = "reference-data-orchestrator") - { - CheckServices(services).AddSingleton(sp => createOrchestrator(sp)); - if (healthCheck) - services.AddHealthChecks().AddTypeActivatedCheck(healthCheckName ?? "reference-data-orchestrator"); - - return services; - } - - /// - /// Adds the using a as a singleton service automatically registering the (see ). - /// - /// The . - /// Indicates whether a corresponding should be configured. - /// The health check name; defaults to 'reference-data-orchestrator'. - /// The . - public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, bool healthCheck = true, string? healthCheckName = "reference-data-orchestrator") - => AddReferenceDataOrchestrator(services, sp => new ReferenceDataOrchestrator(sp, sp.GetService(), sp.GetService()).Register(), healthCheck, healthCheckName); - - /// - /// Adds the using a as a singleton service automatically registering the specified (see ). - /// - /// The to register. - /// The . - /// Indicates whether a corresponding should be configured. - /// The health check name; defaults to 'reference-data-orchestrator'. - /// The . - public static IServiceCollection AddReferenceDataOrchestrator(this IServiceCollection services, bool healthCheck = true, string? healthCheckName = "reference-data-orchestrator") where TProvider : IReferenceDataProvider - => AddReferenceDataOrchestrator(services, sp => new ReferenceDataOrchestrator(sp, sp.GetService(), sp.GetService()).Register(), healthCheck, healthCheckName); - - /// - /// Adds the as the scoped service. - /// - /// The . - /// The . - public static IServiceCollection AddRequestCache(this IServiceCollection services) => CheckServices(services).AddScoped(_ => new RequestCache()); - - /// - /// Registers all the (s) from the specified into a new that is then registered as a singleton service. - /// - /// The to infer the underlying . - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddMappers(this IServiceCollection services) - => AddMappers(services, [typeof(TAssembly).Assembly]); - - /// - /// Registers all the (s) from the specified and into a new that is then registered as a singleton service. - /// - /// The to infer the underlying . - /// The to infer the underlying . - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddMappers(this IServiceCollection services) - => AddMappers(services, [typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly]); - - /// - /// Registers all the (s) from the specified , and into a new that is then registered as a singleton service. - /// - /// The to infer the underlying . - /// The to infer the underlying . - /// The to infer the underlying . - /// The . - /// The for fluent-style method-chaining. - public static IServiceCollection AddMappers(this IServiceCollection services) - => AddMappers(services, [typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly, typeof(TAssembly3).Assembly]); - - /// - /// Registers all the (s) from the specified into a new that is then registered as a singleton service. - /// - /// The . - /// The assemblies to probe for mappers. - /// The for fluent-style method-chaining. - public static IServiceCollection AddMappers(this IServiceCollection services, params Assembly[] assemblies) - { - var mapper = new Mapper(); - mapper.Register(assemblies); - return services.AddSingleton(mapper); - } - - /// - /// Adds the as a singleton service. - /// - /// The . - /// The action to enable the to be further configured. - /// The for fluent-style method-chaining. - public static IServiceCollection AddWorkStateOrchestrator(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddSingleton(sp => - { - var wso = new WorkStateOrchestrator(sp.GetRequiredService(), sp.GetService(), sp.GetService(), sp.GetService()); - configure?.Invoke(sp, wso); - return wso; - }); - - /// - /// Adds an that will publish and send a health-check (message). - /// - /// The . - /// The health check name. Defaults to 'event-publisher'. - /// The factory. - /// The optional action to configure the . - /// The that should be reported when the health check reports a failure. If the provided value is null, then will be reported. - /// A list of tags that can be used for filtering health checks. - /// An optional representing the timeout of the check. - /// Where the is not specified then the registered will be used. - /// Note: Only use where the corresponding subscriber(s)/consumer(s) are aware and can ignore/filter to avoid potential downstream challenges. - public static IHealthChecksBuilder AddEventPublisherHealthCheck(this IHealthChecksBuilder builder, string? name = null, Func? eventPublisherFactory = null, Action? configure = null, HealthStatus? failureStatus = default, IEnumerable? tags = default, TimeSpan? timeout = default) - { - eventPublisherFactory.ThrowIfNull(nameof(eventPublisherFactory)); - - return builder.Add(new HealthCheckRegistration(name ?? "event-publisher", sp => - { - var ep = (eventPublisherFactory is null ? sp.GetService() : eventPublisherFactory(sp)) ?? throw new InvalidOperationException("An IEventPublisher was either not registered or the factory returned null."); - var hc = new EventPublisherHealthCheck(ep); - configure?.Invoke(sp, hc.Options); - return hc; - }, failureStatus, tags, timeout)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/IUniqueKey.cs b/src/CoreEx/Abstractions/IUniqueKey.cs deleted file mode 100644 index 4b022fd2..00000000 --- a/src/CoreEx/Abstractions/IUniqueKey.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Caching; - -namespace CoreEx.Abstractions -{ - /// - /// Identifies a type as containing a unique key of some sort without enabling the what or how. - /// - /// Should then implement and/or . - public interface IUniqueKey { } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Internal.cs b/src/CoreEx/Abstractions/Internal.cs index 3401be6e..38859813 100644 --- a/src/CoreEx/Abstractions/Internal.cs +++ b/src/CoreEx/Abstractions/Internal.cs @@ -1,45 +1,187 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +[assembly: InternalsVisibleTo("CoreEx.AspNetCore")] +[assembly: InternalsVisibleTo("CoreEx.Azure.Messaging.ServiceBus")] +[assembly: InternalsVisibleTo("CoreEx.Database")] +[assembly: InternalsVisibleTo("CoreEx.RefData")] +[assembly: InternalsVisibleTo("CoreEx.Validation")] -using CoreEx.Configuration; -using Microsoft.Extensions.Caching.Memory; -using System; -using System.Runtime.CompilerServices; +namespace CoreEx.Abstractions; -[assembly: - InternalsVisibleTo("CoreEx.AspNetCore, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5"), - InternalsVisibleTo("CoreEx.Azure, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5"), - InternalsVisibleTo("CoreEx.Database.SqlServer, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5"), - InternalsVisibleTo("CoreEx.Solace, PublicKey=00240000048000009400000006020000002400005253413100040000010001007dee530af6d801902d40685e9cd0a3d8991ddbf545be3ef6147c9f79bacd7464d92fbd94fee34e885c37e3dff4ea15a4f9978f1f614798e0f48e3a3d5bf15e8b2fba9c19b6966838f97444bc247bc101454946d70ac93207cf2c611956aed59c316f81f1bf8c8486f8f0b3f9adf93c2f07e06a86745f6dc4b819c2bc2f3fdad5"), -] - -namespace CoreEx.Abstractions +/// +/// Provides shareable internal capabilities. +/// +/// This is intended largely for internal usage only; use with caution. +public static class Internal { + private static readonly IConfiguration _emptyConfig = new ConfigurationBuilder().Build(); + private static IMemoryCache? _fallbackCache; + + /// + /// Gets an empty configuration instance that contains no settings or values. + /// + public static IConfiguration EmptyConfiguration => _emptyConfig; + + /// + /// Gets the internal cache service key. + /// + internal static string CacheServiceKey = "__coreex_internal_cache"; + + /// + /// Gets the CoreEx internal . + /// + internal static IMemoryCache MemoryCache => ExecutionContext.GetKeyedService(CacheServiceKey) ?? (_fallbackCache ??= new MemoryCache(new MemoryCacheOptions())); + /// - /// Provides shareable internal capabilities. + /// Tries to get the configuration value for the specified . /// - /// This is intended for internal usage only; use with caution. - public static class Internal + /// The configuration value . + /// The key of the configuration section. + /// The converted value. + /// The optional ; otherwise, defaults from . + /// indicates the configuration setting exists; otherwise, . + public static bool TryGetConfigurationValue(string key, out T? value, IConfiguration? configuration = null) { - private static IMemoryCache? _fallbackCache; - - /// - /// Gets the CoreEx . - /// - internal static IMemoryCache MemoryCache => ExecutionContext.GetService() ?? (_fallbackCache ??= new MemoryCache(new MemoryCacheOptions())); - - /// - /// Represents a cache for internal capabilities. - /// - public interface IInternalCache : IMemoryCache { } - - /// - /// Indicates whether the specified should be logged. - /// - /// The . - internal static bool ShouldExceptionBeLogged() where TException : Exception, IExtendedException => MemoryCache.GetOrCreate(typeof(TException), entry => - { - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); - return ExecutionContext.GetService()?.GetCoreExValue($"Log{typeof(TException).Name}") ?? false; - }); + var config = configuration ?? ExecutionContext.GetService(); + if (config?.GetSection(key)?.Value is null) + { + value = default!; + return false; + } + + value = config.ThrowWhen(config => config is null).GetValue(key); + return true; + } + + /// + /// Gets the configuration value for the specified . + /// + /// The configuration value . + /// The key of the configuration section. + /// The default value to use if no value is found. + /// The optional ; otherwise, defaults from . + /// The converted value. + public static T? GetConfigurationValue(string key, T? defaultValue = default, IConfiguration? configuration = null) + => TryGetConfigurationValue(key, out var value, configuration) ? value : defaultValue; + + /// + /// Gets the configuration value for the specified and where not found uses the alternate . + /// + /// The configuration value . + /// The key of the configuration section. + /// The alternate key of the configuration section. + /// The default value to use if no value is found. + /// The optional ; otherwise, defaults from . + /// The converted value. + public static T? GetConfigurationValueWithFallback(string key, string fallbackKey, T? defaultValue = default, IConfiguration? configuration = null) + => GetConfigurationValueWithFallback(key, () => fallbackKey, defaultValue, configuration); + + /// + /// Gets the configuration value for the specified and where not found uses the alternate . + /// + /// The configuration value . + /// The key of the configuration section. + /// The function to get the alternate key of the configuration section (only where needed). + /// The default value to use if no value is found. + /// The optional ; otherwise, defaults from . + /// The converted value. + /// This is intended to minimize the need to generate a fallback key until actually needed. + public static T? GetConfigurationValueWithFallback(string key, Func getFallbackKey, T? defaultValue = default, IConfiguration? configuration = null) + { + var config = configuration ?? ExecutionContext.GetService(); + if (config is null) + return defaultValue; + + if (TryGetConfigurationValue(key, out var value, config)) + return value; + + return GetConfigurationValue(getFallbackKey.ThrowIfNull()(), defaultValue, config); + } + + /// + /// Gets the value from the provided where it is a reference to a key; otherwise, returns the value as-is. + /// + /// The value or key. + /// The . + /// The configured value or value as-is. + /// Supports the retrieval of the value from where prefixed with 'config:' or '^', or is wrapped with '%'. + public static string GetValueFromConfigurationWhereApplicable(string valueOrKey, IConfiguration? configuration = null) + { + const string configPrefix = "config:"; + bool isConfigReference = false; + + if (valueOrKey.ThrowIfNullOrEmpty().StartsWith(configPrefix, StringComparison.OrdinalIgnoreCase)) + { + valueOrKey = valueOrKey[configPrefix.Length..]; + isConfigReference = true; + } + else if (valueOrKey.StartsWith('^')) + { + valueOrKey = valueOrKey[1..]; + isConfigReference = true; + } + else if (valueOrKey.StartsWith('%') && valueOrKey.EndsWith('%')) + { + valueOrKey = valueOrKey[1..^1]; + isConfigReference = true; + } + + return isConfigReference + ? GetConfigurationValue(valueOrKey, configuration: configuration) ?? throw new InvalidOperationException($"The required configuration key '{valueOrKey}' was not found.") + : valueOrKey; + } + + /// + /// Casts a of type to type (where they must be the same type). + /// + /// The from . + /// The to . + /// The value to cast. + /// Indicates whether to bypass the type check; use with extreme caution! + /// The casted value. + /// This uses a to perform as it is fastest means possible. Where there is a mismatch of types an will be thrown. + internal static TTo Cast(TFrom value, bool trustMe = false) + { + if (value is null) + return default!; + + if (value is TTo t) + return t; + + // Must be identical types, uses Unsafe.As to avoid boxing of value types. + if (typeof(TFrom) == typeof(TTo)) + return Unsafe.As(ref value); + + // Allow bypass of type check where caller is certain of compatibility (use with extreme caution). + if (trustMe) + return Unsafe.As(ref value); + + // Not compatible. + throw new InvalidCastException($"Cannot cast from {typeof(TFrom).Name} to {typeof(TTo).Name}. Only exact matches are supported."); + } + + /// + /// Gets a friendly formatted name with namespace. + /// + /// The . + /// The friendly formatted name with namespace. + public static string GetNamespaceFormattedName(Type type) => type.Namespace is null ? GetFormattedName(type) : $"{type.Namespace}.{GetFormattedName(type)}"; + + /// + /// Gets a friendly formatted name. + /// + /// The . + /// The friendly formatted name. + public static string GetFormattedName(Type type) => type.IsGenericType ? GetGenericFormattedName(type) : type.Name; + + /// + /// Gets the formatted name for a generic type. + /// + private static string GetGenericFormattedName(Type type) + { + var name = type.Name; + var tick = name.IndexOf('`'); + if (tick > 0) + name = name[..tick]; + + return $"{name}<{string.Join(',', type.GetGenericArguments().Select(GetFormattedName))}>"; } } \ No newline at end of file diff --git a/src/CoreEx/Abstractions/ObjectExtensions.cs b/src/CoreEx/Abstractions/ObjectExtensions.cs deleted file mode 100644 index efbd6c40..00000000 --- a/src/CoreEx/Abstractions/ObjectExtensions.cs +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Text; -using System; -using System.Diagnostics.CodeAnalysis; -#if NET6_0_OR_GREATER -using System.Runtime.CompilerServices; -#endif - -namespace CoreEx -{ - /// - /// Provides standard extensions. - /// - [System.Diagnostics.DebuggerStepThrough] - public static class ObjectExtensions - { - /// - /// Enables adjustment (changes) to a via an action. - /// - /// The . - /// The value to adjust. - /// The adjusting action (invoked only where the is not null). - /// The adjusted value (same instance). - /// Useful in scenarios to in-line simple changes to a value to simplify code. - [return: NotNullIfNotNull(nameof(value))] - public static T? Adjust(this T? value, Action adjuster) - { - if (value is not null) - adjuster?.Invoke(value); - - return value!; - } - - /// - /// Enables adjustment (changes) to a via an action when the is true. - /// - /// The . - /// The value to adjust. - /// The that determines whether the is invoked. - /// The adjusting action (invoked only where the is not null and the results in true). - /// The adjusted value (same instance). - /// Useful in scenarios to in-line simple changes to a value to simplify code. - [return: NotNullIfNotNull(nameof(value))] - public static T? AdjustWhen(this T? value, Predicate predicate, Action adjuster) - { - if (value is not null && predicate(value)) - adjuster?.Invoke(value); - - return value!; - } - - /// - /// Converts a into sentence case. - /// - /// The text to convert. - /// The as sentence case. - /// For example a value of 'VarNameDB' would return 'Var Name DB'. - /// Uses the function to perform the conversion. - [return: NotNullIfNotNull(nameof(text))] - public static string? ToSentenceCase(this string? text) => SentenceCase.ToSentenceCase(text); - -#if NET6_0_OR_GREATER - /// - /// Throws an if the is null. - /// - /// The . - /// The value to validate as non-null. - /// The name of the parameter with which the corresponds. - /// The to support fluent-style method-chaining. - public static T ThrowIfNull([NotNull] this T? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) - { - ArgumentNullException.ThrowIfNull(value, paramName); - return value; - } -#else - /// - /// Throws an if the is null. - /// - /// The . - /// The value to validate as non-null. - /// The name of the parameter with which the corresponds. - /// The to support fluent-style method-chaining. - public static T ThrowIfNull([NotNull] this T? value, string? paramName = "value") - { - if (value is null) - throw new ArgumentNullException(paramName); - - return value; - } -#endif - -#if NET7_0_OR_GREATER - /// - /// Throws an if the is null or . - /// - /// The value to validate as non-null. - /// The name of the parameter with which the corresponds. - /// The to support fluent-style method-chaining. - public static string ThrowIfNullOrEmpty([NotNull] this string? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) - { - ArgumentException.ThrowIfNullOrEmpty(value, paramName); - return value; - } -#else - /// - /// Throws an if the is null or . - /// - /// The value to validate as non-null. - /// The name of the parameter with which the corresponds. - /// The to support fluent-style method-chaining. - public static string ThrowIfNullOrEmpty([NotNull] this string? value, string? paramName = "value") - { - if (string.IsNullOrEmpty(value)) - throw new ArgumentNullException(paramName); - - return value; - } -#endif - -#if NET7_0_OR_GREATER - /// - /// Throws an if the is null or . - /// - /// The value to validate as non-null. - /// The name of the parameter with which the corresponds. - /// The to support fluent-style method-chaining. - public static string? ThrowIfEmpty(this string? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) - { - if (value is not null && 0 == value.Length) - throw new ArgumentException("The value cannot be an empty string.", paramName); - - return value; - } -#else - /// - /// Throws an if the is null or . - /// - /// The value to validate as non-null. - /// The name of the parameter with which the corresponds. - /// The to support fluent-style method-chaining. - public static string? ThrowIfEmpty(this string? value, string? paramName = "value") - { - if (value is not null && 0 == value.Length) - throw new ArgumentException("The value cannot be an empty string.", paramName); - - return value; - } -#endif - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/README.md b/src/CoreEx/Abstractions/README.md deleted file mode 100644 index 2da79648..00000000 --- a/src/CoreEx/Abstractions/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# CoreEx.Abstractions - -The `CoreEx.Abstractions` namespace provides key abstractions, or other largely internal capabilities. - -
- -## Motivation - -To enable other capabilities generally leveraged internally within _CoreEx_. - -
- -## Reflection - -The `Reflection` namespace is used internally whenever [reflection](https://learn.microsoft.com/en-us/dotnet/framework/reflection-and-codedom/reflection) is needed; this provides extended reusable capabilities, and caching thereof to improve overall performance. The key classes are as follows: [`TypeReflector`](./Reflection/TypeReflector.cs) [`PropertyReflector`](./Reflection/PropertyReflector.cs), and [`PropertyExpression`](./Reflection/PropertyExpression.cs). - -
- -## Extension methods - -A number of [extensions methods](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods) are defined to enable additional features. The naming convention for these is such that the file suffix is `Extentions`; e.g. [`ObjectExtensions`](./ObjectExtensions.cs). - -_Note:_ This convention is used in other namespaces as required to house additional extension methods where applicable. - -
- -## ETag generation - -The [`ETagGenerator`](./ETagGenerator.cs) is used, primary within the [`WebApis`](../WebApis/README.md) capabilities, to generate an [ETag](https://en.wikipedia.org/wiki/HTTP_ETag) value where not provided by the underlying data source. Essentially this is implemented by serializing the payload and hashing with [SHA256](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.sha256) to get a mostly unique value to be used for caching and/or concurrency. diff --git a/src/CoreEx/Abstractions/Reflection/IPropertyExpression.cs b/src/CoreEx/Abstractions/Reflection/IPropertyExpression.cs deleted file mode 100644 index 0d3ed8a3..00000000 --- a/src/CoreEx/Abstractions/Reflection/IPropertyExpression.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using System.Linq.Expressions; -using System.Reflection; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Enables the property capabilities. - /// - public interface IPropertyExpression - { - /// - /// Gets the corresponding . - /// - PropertyInfo PropertyInfo { get; } - - /// - /// Gets the property name. - /// - string Name { get; } - - /// - /// Gets the JSON property name (where applicable). - /// - string? JsonName { get; } - - /// - /// Indicates whether the property is JSON serializable. - /// - bool IsJsonSerializable { get; } - - /// - /// Indicates that the property is a class with properties (and is not a ). - /// - bool IsClass { get; } - - /// - /// Gets the property text. - /// - LText Text { get; } - - /// - /// Gets the default value. - /// - /// The default value. - object? GetDefault(); - - /// - /// Gets the property value for the given entity. - /// - /// The entity value. - /// The corresponding property value. - object? GetValue(object? entity); - - /// - /// Sets the property value for the given entity. - /// - /// The entity value. - /// The corresponding property value. - void SetValue(object entity, object? value); - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/IPropertyReflector.cs b/src/CoreEx/Abstractions/Reflection/IPropertyReflector.cs deleted file mode 100644 index dc21b113..00000000 --- a/src/CoreEx/Abstractions/Reflection/IPropertyReflector.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Reflection; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Enables a reflector for a given class property. - /// - public interface IPropertyReflector - { - /// - /// Gets the property name. - /// - string Name { get; } - - /// - /// Gets the JSON property name. - /// - string? JsonName { get; } - - /// - /// Gets the . - /// - TypeReflectorArgs Args { get; } - - /// - /// Gets the for storing additional data. - /// - Dictionary Data { get; } - - /// - /// Gets the corresponding . - /// - IPropertyExpression PropertyExpression { get; } - - /// - /// Gets the corresponding ; - /// - PropertyInfo PropertyInfo { get; } - - /// - /// Indicates that the property is a class with properties (and is not a ). - /// - bool IsClass { get; } - - /// - /// Indicates that the property is (and is not a ). - /// - bool IsEnumerable { get; } - - /// - /// Gets the parent entity . - /// - Type EntityType { get; } - - /// - /// Gets the property . - /// - Type Type { get; } - - /// - /// Gets the property . - /// - TypeReflectorTypeCode TypeCode { get; } - - /// - /// Gets the for the property. - /// - /// The corresponding . - ITypeReflector? GetTypeReflector(); - - /// - /// Compares two values for equality. - /// - /// The first value. - /// The second value. - /// true indicates that they are equal; otherwise, false. - bool Compare(object? x, object? y); - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/IReflectionCache.cs b/src/CoreEx/Abstractions/Reflection/IReflectionCache.cs deleted file mode 100644 index 110afd26..00000000 --- a/src/CoreEx/Abstractions/Reflection/IReflectionCache.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Caching.Memory; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Represents a cache for reflection operations. - /// - public interface IReflectionCache : IMemoryCache { } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/ITypeReflector.cs b/src/CoreEx/Abstractions/Reflection/ITypeReflector.cs deleted file mode 100644 index e70235ee..00000000 --- a/src/CoreEx/Abstractions/Reflection/ITypeReflector.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Enables a reflector for a given . - /// - public interface ITypeReflector - { - /// - /// Gets the . - /// - TypeReflectorArgs Args { get; } - - /// - /// Gets the entity . - /// - Type Type { get; } - - /// - /// Gets the . - /// - TypeReflectorTypeCode TypeCode { get; } - - /// - /// Gets the underlying item where implements . - /// - Type? ItemType { get; } - - /// - /// Gets the underlying . - /// - TypeReflectorTypeCode? ItemTypeCode { get; } - - /// - /// Gets the for storing additional data. - /// - Dictionary Data { get; } - - /// - /// Gets all the properties for the . - /// - IEnumerable GetProperties(); - - /// - /// Gets the for the specified property name where it exists. - /// - /// The property name. - /// The where property exists; otherwise, null. - /// true where the property exists; otherwise, false. - bool TryGetProperty(string name, [NotNullWhen(true)] out IPropertyReflector? property); - - /// - /// Gets the for the specified property name. - /// - /// The property name. - /// The . - IPropertyReflector GetProperty(string name); - - /// - /// Gets the for the specified JSON name. - /// - /// The JSON name. - /// The . - /// Uses the to match the JSON name. - IPropertyReflector? GetJsonProperty(string jsonName); - - /// - /// Creates a new instance of the using the default empty constructor. - /// - /// A new instance of the . - object CreateInstance() => Activator.CreateInstance(Type)!; - - /// - /// Gets the for . - /// - /// The corresponding . - ITypeReflector? GetItemTypeReflector(); - - /// - /// Compares two values for equality; - /// - /// The first value. - /// The second value. - /// true indicates that they are equal; otherwise, false. - bool Compare(object? x, object? y); - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs b/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs deleted file mode 100644 index e7b1182a..00000000 --- a/src/CoreEx/Abstractions/Reflection/PropertyExpression.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using CoreEx.Localization; -using CoreEx.Text; -using Microsoft.Extensions.Caching.Memory; -using System; -using System.ComponentModel.DataAnnotations; -using System.Linq.Expressions; -using System.Reflection; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Provides access to the common property expression capabilities. - /// - public static partial class PropertyExpression - { - /// - /// Gets the . - /// - internal static IMemoryCache Cache => ExecutionContext.GetService() ?? Internal.MemoryCache; - - /// - /// Gets or sets the absolute expiration . - /// - /// Defaults to 4 hours. - public static TimeSpan? AbsoluteExpirationTimespan { get; set; } = TimeSpan.FromHours(4); - - /// - /// Gets or sets the sliding expiration . - /// - /// Defaults to 30 minutes. - public static TimeSpan? SlidingExpirationTimespan { get; set; } = TimeSpan.FromMinutes(30); - - /// - /// Gets or sets the function to create the for the specified entity , and inferred text (being either the corresponding where specified - /// or the converted to ). - /// - /// Defaults the to the fully qualified name (being + '.' + ) and the to the inferred text. - public static Func CreatePropertyLText { get; set; } = (entityType, propertyInfo, text) => new LText($"{entityType.FullName}.{propertyInfo.Name}", text); - - /// - /// Validates, creates and compiles the property expression; whilst also determinig the property friendly . - /// - /// The entity . - /// The property . - /// The to reference the entity property. - /// The . Defaults to where not specified. - /// A which contains (in order) the compiled , member name and resulting property text. - /// Caching is used to improve performance; subsequent calls will return the corresponding cached value. - public static PropertyExpression Create(Expression> propertyExpression, IJsonSerializer? jsonSerializer = null) - => PropertyExpression.CreateInternal(propertyExpression.ThrowIfNull(nameof(propertyExpression)), DetermineJsonSerializer(jsonSerializer)); - - /// - /// Gets the from the cache. - /// - /// The entity . - /// The property name. - /// The . Defaults to where not specified. - /// The where found; otherwise, null. - public static IPropertyExpression? Get(Type entityType, string propertyName, IJsonSerializer? jsonSerializer = null) - => (IPropertyExpression?)Cache.Get((entityType, propertyName, DetermineJsonSerializer(jsonSerializer).GetType())) ?? null; - - /// - /// Determine the by firstly using the to find, then falling back to the . - /// - /// The . - /// This does scream Service Locator, which is considered an anti-pattern by some, but this avoids the added complexity of passing the where most implementations will default to the - /// implementation - this just avoids unnecessary awkwardness for sake of purity. Finally, this class is intended for largely internal use only. - private static IJsonSerializer DetermineJsonSerializer(IJsonSerializer? jsonSerializer) => jsonSerializer ?? ExecutionContext.GetService() ?? JsonSerializer.Default; - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/PropertyExpressionT.cs b/src/CoreEx/Abstractions/Reflection/PropertyExpressionT.cs deleted file mode 100644 index 8c4c62e7..00000000 --- a/src/CoreEx/Abstractions/Reflection/PropertyExpressionT.cs +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using CoreEx.Localization; -using CoreEx.RefData; -using Microsoft.Extensions.Caching.Memory; -using System; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Provides property capability. - /// - /// The entity . - /// The property . - /// The internal reflection comes at a performance cost; as such the resulting is cached using an . The - /// and enable additional basic policy configuration for the cached items. - public class PropertyExpression : IPropertyExpression - { - private readonly Func _getValue; - private readonly Action? _setValue; - - /// - /// Validates, creates and compiles the property expression; whilst also determinig the property friendly . - /// - /// The to reference the entity property. - /// The . Defaults to where not specified. - /// A which contains (in order) the compiled , member name and resulting property text. - internal static PropertyExpression CreateInternal(Expression> propertyExpression, IJsonSerializer jsonSerializer) - { - if (propertyExpression.ThrowIfNull(nameof(propertyExpression)).Body.NodeType != ExpressionType.MemberAccess) - throw new InvalidOperationException("Only Member access expressions are supported."); - - var cache = PropertyExpression.Cache; - var me = (MemberExpression)propertyExpression.Body; - - // Check cache and reuse as this is a *really* expensive operation. Key contains: Entity type, property name, and json serializer (in case configuration is different). - return cache.GetOrCreate((typeof(TEntity), me.Member.Name, jsonSerializer.GetType()), ce => - { - if (PropertyExpression.AbsoluteExpirationTimespan.HasValue) - ce.SetAbsoluteExpiration(PropertyExpression.AbsoluteExpirationTimespan.Value); - - if (PropertyExpression.SlidingExpirationTimespan.HasValue) - ce.SetSlidingExpiration(PropertyExpression.SlidingExpirationTimespan.Value); - - if (me.Member.MemberType != MemberTypes.Property) - throw new InvalidOperationException("Expression results in a Member that is not a Property."); - - if (!me.Member.DeclaringType!.GetTypeInfo().IsAssignableFrom(typeof(TEntity).GetTypeInfo())) - throw new InvalidOperationException("Expression results in a Member for a different Entity class."); - - string name = me.Member.Name; - - // Get the JSON property name (where configured). - var isSerializable = jsonSerializer.TryGetJsonName(me.Member, out var jn); - if (!isSerializable) - { - // Probe corresponding 'Sid' or 'Sids' properties (using the standardised naming convention) where IReferenceData Type. - if (me.Member is PropertyInfo rpi && rpi.PropertyType.IsClass && rpi.PropertyType.GetInterfaces().Contains(typeof(IReferenceData))) - { - var spi = me.Member.DeclaringType!.GetProperty($"{name}Sid") ?? me.Member.DeclaringType.GetProperty($"{name}Sids"); - if (spi != null) - jsonSerializer.TryGetJsonName(spi, out jn); - } - } - - // Either get the friendly text from a corresponding DisplayAttribute or split the member name into friendlier sentence case text. - DisplayAttribute? ca = me.Member.GetCustomAttribute(true); - - // Create a setter from the getter. - var pi = (PropertyInfo)me.Member; - Action? setValue = null; - if (pi.CanWrite) - { - var pte = Expression.Parameter(typeof(TEntity), "e"); - var ptp = Expression.Parameter(typeof(TProperty), "p"); - var exp = Expression.Lambda>(Expression.Call(pte, pi.GetSetMethod()!, ptp), pte, ptp); - setValue = exp.Compile(); - } - - // Create expression (with compilation also). - return new PropertyExpression(pi, name, jn, ca?.Name, isSerializable, propertyExpression.Compile(), setValue); - })!; - } - - /// - /// Initializes a new instance of the class. - /// - private PropertyExpression(PropertyInfo pi, string name, string? jsonName, string? text, bool isSerializable, Func getValue, Action? setValue) - { - PropertyInfo = pi; - Name = name; - JsonName = jsonName; - Text = PropertyExpression.CreatePropertyLText(typeof(TEntity), pi, text ?? Name.ToSentenceCase()); - IsJsonSerializable = isSerializable; - IsClass = PropertyInfo.PropertyType.IsClass && PropertyInfo.PropertyType != typeof(string); - _getValue = getValue; - _setValue = setValue; - } - - /// - public PropertyInfo PropertyInfo { get; } - - /// - public string Name { get; } - - /// - public string? JsonName { get; } - - /// - public LText Text { get; } - - /// - public bool IsJsonSerializable { get; } - - /// - public bool IsClass { get; } - - /// - object? IPropertyExpression.GetDefault() => default; - - /// - /// Gets the default value. - /// - /// - public TProperty? GetDefault() => default; - - /// - object? IPropertyExpression.GetValue(object? entity) => GetValue((TEntity)entity!); - - /// - /// Gets the property value for the given entity. - /// - /// The entity value. - /// The corresponding property value. - public TProperty? GetValue(TEntity? entity) => entity == null ? default : _getValue.Invoke(entity); - - /// - void IPropertyExpression.SetValue(object entity, object? value) => SetValue((TEntity)entity, value == null ? default : (TProperty?)value); - - /// - /// Sets the property value for the given entity. - /// - /// The entity value. - /// The corresponding property value. - public void SetValue(TEntity entity, TProperty? value) - { - if (_setValue == null) - throw new InvalidOperationException($"Property '{Name}' does not support a set (write) operation."); - - _setValue(entity.ThrowIfNull(nameof(entity)), value!); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/PropertyReflector.cs b/src/CoreEx/Abstractions/Reflection/PropertyReflector.cs deleted file mode 100644 index 2b25b4a4..00000000 --- a/src/CoreEx/Abstractions/Reflection/PropertyReflector.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Provides a reflector for a given property. - /// - /// The entity . - /// The property . - public class PropertyReflector : IPropertyReflector - { - private readonly Lazy> _data = new(true); - private ITypeReflector? _typeReflector; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The to reference the source entity property. - public PropertyReflector(TypeReflectorArgs args, Expression> propertyExpression) - { - Args = args.ThrowIfNull(nameof(args)); - PropertyExpression = Reflection.PropertyExpression.Create(propertyExpression.ThrowIfNull(nameof(propertyExpression)), args.JsonSerializer); - TypeCode = IsClass ? TypeReflectorTypeCode.Complex : TypeReflectorTypeCode.Simple; - IsEnumerable = IsClass && (PropertyInfo.PropertyType.IsArray || PropertyInfo.PropertyType.GetInterfaces().Any(x => x == typeof(IEnumerable))); - if (IsEnumerable) - { - _typeReflector = TypeReflector.GetReflector(Args, Type); - TypeCode = _typeReflector!.TypeCode; - } - } - - /// - public string Name => PropertyExpression.Name; - - /// - public string? JsonName => PropertyExpression.JsonName; - - /// - public TypeReflectorArgs Args { get; } - - /// - public Dictionary Data { get => _data.Value; } - - /// - IPropertyExpression IPropertyReflector.PropertyExpression => PropertyExpression; - - /// - /// Gets the compiled . - /// - public PropertyExpression PropertyExpression { get; } - - /// - public PropertyInfo PropertyInfo => PropertyExpression.PropertyInfo; - - /// - public bool IsClass => PropertyExpression.IsClass; - - /// - public bool IsEnumerable { get; } - - /// - public Type EntityType => typeof(TEntity); - - /// - public Type Type => typeof(TProperty); - - /// - public TypeReflectorTypeCode TypeCode { get; } - - /// - public ITypeReflector? GetTypeReflector() => _typeReflector ??= TypeReflector.GetReflector(Args, Type); - - /// - bool IPropertyReflector.Compare(object? x, object? y) => Compare((TProperty)(x ?? default(TProperty)!), (TProperty)(y ?? default(TProperty)!)); - - /// - /// Compares two values for equality. - /// - /// The first value. - /// The second value. - /// true indicates that they are equal; otherwise, false. - public bool Compare(TProperty? x, TProperty? y) - { - if (ReferenceEquals(x, y)) - return true; - - var left = x == null ? y : x; - var right = x == null ? x : y; - if (left == null || right == null) - return false; - - if (left is IEquatable eq) - return eq.Equals(right!); - else if (IsEnumerable) - return GetTypeReflector()!.Compare(x, y); - else - return EqualityComparer.Default.Equals(left, right); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/TypeReflector.cs b/src/CoreEx/Abstractions/Reflection/TypeReflector.cs deleted file mode 100644 index e86ecdfe..00000000 --- a/src/CoreEx/Abstractions/Reflection/TypeReflector.cs +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Caching.Memory; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Provides common reflection capabilities. - /// - public class TypeReflector - { - /// - /// Gets all of the properties () for a . - /// - /// The to reflect. - /// The . - /// The corresponding . - /// The default where not overridden are: , , and . - public static PropertyInfo[] GetProperties(Type type, BindingFlags? bindingFlags = null) - => type.ThrowIfNull(nameof(type)).GetProperties(bindingFlags ?? (BindingFlags.Public | BindingFlags.GetProperty | BindingFlags.SetProperty | BindingFlags.Instance)) - .Where(x => x.GetIndexParameters().Length == 0).GroupBy(x => x.Name).Select(g => g.First()).ToArray(); - - /// - /// Gets the for a . - /// - /// The to reflect. - /// The property name to find. - /// The . - /// The corresponding where found; otherwise, null. - /// The default where not overridden are: , , and . - public static PropertyInfo? GetPropertyInfo(Type type, string propertyName, BindingFlags? bindingFlags = null) - { - var pis = type.ThrowIfNull(nameof(type)).GetProperties(bindingFlags ?? (BindingFlags.Public | BindingFlags.GetProperty | BindingFlags.SetProperty | BindingFlags.Instance)) - .Where(x => x.Name == propertyName.ThrowIfNull(nameof(propertyName))).ToArray(); - - return pis.Length switch - { - 0 => null, - 1 => pis[0], - _ => pis.FirstOrDefault(x => x.DeclaringType == type) ?? pis.First() - }; - } - - /// - /// Gets (creates) the cached . - /// - /// The entity . - /// The . - /// The . - public static TypeReflector GetReflector(TypeReflectorArgs? args = null) - => (args ??= TypeReflectorArgs.Default).Cache.GetOrCreate(typeof(TEntity), ce => - { - var tr = new TypeReflector(args); - args.TypeBuilder?.Invoke(tr); - return (TypeReflector)ConfigureCacheEntry(ce, tr); - })!; - - /// - /// Gets the for the specified . - /// - /// The . - /// The entity . - /// The . - public static ITypeReflector GetReflector(TypeReflectorArgs? args, Type type) - => (args ??= TypeReflectorArgs.Default).Cache.GetOrCreate(type.ThrowIfNull(nameof(args)), ce => - { - var ec = typeof(TypeReflector<>).MakeGenericType(type).GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, null, [typeof(TypeReflectorArgs)], null)!; - var tr = (ITypeReflector)ec.Invoke([args]); - args.TypeBuilder?.Invoke(tr); - return ConfigureCacheEntry(ce, tr); - })!; - - /// - /// Configure the cache entry setting expirations. - /// - private static ITypeReflector ConfigureCacheEntry(ICacheEntry ce, ITypeReflector tr) - { - ce.SetAbsoluteExpiration(tr.Args.AbsoluteExpirationTimespan); - ce.SetSlidingExpiration(tr.Args.SlidingExpirationTimespan); - return tr; - } - - #region Collections - - /// - /// Gets the underlying item where an , , or . - /// - /// The . - /// The and corresponding item where a collection. - public static (TypeReflectorTypeCode TypeCode, Type? ItemType) GetCollectionItemType(Type type) - { - if ((type.ThrowIfNull(nameof(type))) == typeof(string) || type.IsPrimitive || type.IsValueType) - return (TypeReflectorTypeCode.Simple, null); - - if (type.IsArray) - return (TypeReflectorTypeCode.Array, type.GetElementType()); - - var (_, valueType) = GetDictionaryType(type); - if (valueType != null) - return (TypeReflectorTypeCode.IDictionary, valueType); - - var t = GetCollectionType(type); - if (t != null) - return (TypeReflectorTypeCode.ICollection, t); - - t = GetEnumerableType(type); - if (t != null) - return (TypeReflectorTypeCode.IEnumerable, t); - - var (ItemType, _) = GetEnumerableTypeFromAdd(type); - if (ItemType != null) - return (TypeReflectorTypeCode.IEnumerable, ItemType); - - return (TypeReflectorTypeCode.Complex, null); - } - - /// - /// Gets the underlying Type. - /// - private static Type? GetCollectionType(Type type) - { - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ICollection<>)) - return type.GetGenericArguments()[0]; - - var t = type.GetInterfaces().FirstOrDefault(x => x.GetTypeInfo().IsGenericType && x.GetGenericTypeDefinition() == typeof(ICollection<>)); - if (t == null) - return null; - - return ((t == typeof(ICollection<>)) ? type : t).GetGenericArguments()[0]; - } - - /// - /// Gets the underlying Type. - /// - private static Type? GetEnumerableType(Type type) - { - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) - return type.GetGenericArguments()[0]; - - var t = type.GetInterfaces().FirstOrDefault(x => x.GetTypeInfo().IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>)); - if (t == null) - { - t = type.GetInterfaces().FirstOrDefault(x => x == typeof(IEnumerable)); - if (t == null) - return null; - } - - var gas = ((t == typeof(IEnumerable)) ? type : t).GetGenericArguments(); - if (gas.Length == 0) - return null; - - if (type == typeof(IEnumerable<>).MakeGenericType([gas[0]])) - return gas[0]; - - return null; - } - - /// - /// Gets the underlying Types. - /// - public static (Type? keyType, Type? valueType) GetDictionaryType(Type type) - { - Type? t; - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IDictionary<,>)) - t = type; - else - t = type.GetInterfaces().FirstOrDefault(x => (x.GetTypeInfo().IsGenericType && x.GetGenericTypeDefinition() == typeof(IDictionary<,>))); - - if (t == null) - return (null, null); - - var gas = t.GetGenericArguments(); - if (gas.Length != 2) - return (null, null); - - return (gas[0], gas[1]); - } - - /// - /// Gets the underlying Type by inferring from the Add method. - /// - private static (Type? ItemType, MethodInfo? AddMethod) GetEnumerableTypeFromAdd(Type type) - { - var mi = type.GetMethod("Add"); - if (mi == null) - return (null, null); - - var ps = mi.GetParameters(); - return ps.Length == 1 ? (ps[0].ParameterType, mi) : (null, null); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/TypeReflectorArgs.cs b/src/CoreEx/Abstractions/Reflection/TypeReflectorArgs.cs deleted file mode 100644 index 4f77d526..00000000 --- a/src/CoreEx/Abstractions/Reflection/TypeReflectorArgs.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Reflection; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Provides the arguments passed to and through a . - /// - /// The . Defaults to . - /// The to use versus instantiating each per use (expensive operation). - public class TypeReflectorArgs(IJsonSerializer? jsonSerializer = null, IReflectionCache? cache = null) - { - private static readonly Lazy _default = new(() => new TypeReflectorArgs()); - - /// - /// Gets the default . - /// - public static TypeReflectorArgs Default => (ExecutionContext.HasCurrent ? ExecutionContext.Current?.ServiceProvider?.GetService() : null) ?? _default.Value; - - /// - /// Gets the . - /// - public IJsonSerializer JsonSerializer { get; } = jsonSerializer ?? Json.JsonSerializer.Default; - - /// - /// Gets the to use versus instantiating each per use. - /// - /// The and enable additional basic policy configuration for the cached items. - public IMemoryCache Cache { get; } = (MemoryCache?)cache ?? new MemoryCache(new MemoryCacheOptions()); - - /// - /// Gets or sets the absolute expiration . Default to 24 hours. - /// - public TimeSpan AbsoluteExpirationTimespan { get; set; } = TimeSpan.FromHours(24); - - /// - /// Gets or sets the sliding expiration . Default to 30 minutes. - /// - public TimeSpan SlidingExpirationTimespan { get; set; } = TimeSpan.FromMinutes(30); - - /// - /// Gets or sets the action to invoke to perform additional logic when reflecting/building the entity . - /// - public Action? TypeBuilder { get; set; } = null; - - /// - /// Indicates whether to automatically populate the entity properties. Defaults to true. - /// - /// Will invoked the optional as each property is being added. - public bool AutoPopulateProperties { get; set; } = true; - - /// - /// Gets or sets the function to invoke to perform additional logic when reflecting/building the property ; the result determines whether the - /// property should be included (true) or not (false) within the underlying properties collection. - /// - public Func? PropertyBuilder { get; set; } = null; - - /// - /// Defines the for finding the property/JSON names (defaults to ). - /// - public StringComparer NameComparer { get; set; } = StringComparer.Ordinal; - - /// - /// Gets or sets the override when getting the properties for the . Defaults to null (use the default). - /// - public BindingFlags? PropertyBindingFlags { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs b/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs deleted file mode 100644 index 2f8ecc8d..00000000 --- a/src/CoreEx/Abstractions/Reflection/TypeReflectorT.cs +++ /dev/null @@ -1,252 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Provides a reflector for a given . - /// - /// The entity . - public class TypeReflector : ITypeReflector - { - private readonly Dictionary _properties; - private readonly Dictionary _jsonProperties; - private readonly Lazy> _data = new(true); - private IItemEqualityComparer? _itemEqualityComparer; - private ITypeReflector? _itemReflector; - - /// - /// Initializes a new instance of the class. - /// - /// The optional . Defaults to . - internal TypeReflector(TypeReflectorArgs? args = null) - { - Args = args ?? TypeReflectorArgs.Default; - _properties = new Dictionary(StringComparer.Ordinal); - _jsonProperties = new Dictionary(Args.NameComparer ?? StringComparer.OrdinalIgnoreCase); - - var tr = TypeReflector.GetCollectionItemType(Type); - ItemType = tr.ItemType; - TypeCode = tr.TypeCode; - if (ItemType != null) - ItemTypeCode = TypeReflector.GetCollectionItemType(ItemType).TypeCode; - - if (!Args.AutoPopulateProperties) - return; - - var pe = Expression.Parameter(typeof(TEntity), "x"); - - foreach (var p in TypeReflector.GetProperties(typeof(TEntity), Args.PropertyBindingFlags)) - { - var lex = Expression.Lambda(Expression.Property(pe, p), pe); - var pr = (IPropertyReflector)Activator.CreateInstance(typeof(PropertyReflector<,>).MakeGenericType(typeof(TEntity), p.PropertyType), Args, lex)!; - - if (Args.PropertyBuilder != null && !Args.PropertyBuilder(pr)) - continue; - - AddProperty(pr); - } - } - - /// - public TypeReflectorArgs Args { get; private set; } - - /// - public Type Type => typeof(TEntity); - - /// - public TypeReflectorTypeCode TypeCode { get; } - - /// - public Type? ItemType { get; } - - /// - public TypeReflectorTypeCode? ItemTypeCode { get; } - - /// - public Dictionary Data { get => _data.Value; } - - /// - /// Adds a to the reflector. - /// - /// The property . - /// The to reference the entity property. - /// The . - public PropertyReflector Property(Expression> propertyExpression) - { - var pr = new PropertyReflector(Args, propertyExpression.ThrowIfNull(nameof(propertyExpression))); - AddProperty(pr); - return pr; - } - - /// - /// Adds the to the underlying property collections. - /// - private void AddProperty(IPropertyReflector propertyReflector) - { - if (_properties.ContainsKey(propertyReflector.ThrowIfNull(nameof(propertyReflector)).Name)) - throw new ArgumentException($"Property with name '{propertyReflector.Name}' can not be specified more than once.", nameof(propertyReflector)); - - if (propertyReflector.PropertyExpression.IsJsonSerializable && propertyReflector.JsonName != null) - { - if (_jsonProperties.ContainsKey(propertyReflector.JsonName)) - throw new ArgumentException($"Property with name '{propertyReflector.JsonName}' can not be specified more than once.", nameof(propertyReflector)); - - _jsonProperties.Add(propertyReflector.JsonName, propertyReflector); - } - - _properties.Add(propertyReflector.Name, propertyReflector); - } - - /// - public bool TryGetProperty(string name, [NotNullWhen(true)] out IPropertyReflector? property) => _properties.TryGetValue(name, out property); - - /// - public IPropertyReflector GetProperty(string name) - { - _properties.TryGetValue(name, out var value); - return value ?? throw new ArgumentException($"Property '{name}' not found for type '{Type.Name}'.", nameof(name)); - } - - /// - public IPropertyReflector? GetJsonProperty(string jsonName) - { - _jsonProperties.TryGetValue(jsonName, out var value); - return value; - } - - /// - /// Gets all the properties. - /// - public IEnumerable GetProperties() => _properties.Values; - - /// - public ITypeReflector? GetItemTypeReflector() => _itemReflector ??= TypeReflector.GetReflector(Args, ItemType!); - - #region Compare - - /// - bool ITypeReflector.Compare(object? x, object? y) => Compare((TEntity?)x, (TEntity?)y); - - /// - /// Compares two values for equality. - /// - /// The first value. - /// The second value. - /// true indicates that they are equal; otherwise, false. - public bool Compare(TEntity? x, TEntity? y) - { - if (ReferenceEquals(x, y)) - return true; - - var left = x ?? y; - var right = x == null ? x : y; - if (left == null || right == null) - return false; - - if (left is IEquatable eq) - return eq.Equals(right!); - else if (ItemTypeCode.HasValue) - return CompareSequence(x, y); - else - return EqualityComparer.Default.Equals(left, right); - } - - /// - /// Determines whether two sequences are equal by comparing the elements by using the default equality comparer for their type. - /// - /// The left value. - /// The second value. - /// true if the two source sequences are of equal length and their corresponding elements are equal according to the default equality comparer for their type; otherwise, false. - private bool CompareSequence(object? left, object? right) - { - _itemEqualityComparer ??= (IItemEqualityComparer)Activator.CreateInstance(typeof(ItemEqualityComparer<>).MakeGenericType(ItemType!))!; - - switch (TypeCode) - { - case TypeReflectorTypeCode.Array: - var al = (Array)left!; - var ar = (Array)right!; - if (al.Length != ar.Length) - return false; - - break; - - case TypeReflectorTypeCode.ICollection: - var cl = (ICollection)left!; - var cr = (ICollection)right!; - if (cl.Count != cr.Count) - return false; - - break; - - case TypeReflectorTypeCode.IDictionary: - var dl = (IDictionary)left!; - var dr = (IDictionary)right!; - if (dl.Count != dr.Count) - return false; - - var edl = dl.GetEnumerator(); - while (edl.MoveNext()) - { - if (!dr.Contains(edl.Key)) - return false; - - if (!_itemEqualityComparer!.IsEqual(edl.Value!, dr[edl.Key]!)) - return false; - } - - return true; - } - - // Inspired by: https://referencesource.microsoft.com/#System.Core/System/Linq/Enumerable.cs,9bdd6ef7ba6a5615 - var el = ((IEnumerable)left!).GetEnumerator(); - var er = ((IEnumerable)right!).GetEnumerator(); - while (el.MoveNext()) - { - if (!(er.MoveNext() && _itemEqualityComparer!.IsEqual(el.Current, er.Current))) - return false; - } - - if (er.MoveNext()) - return false; - - return true; - } - - #endregion - } - - /// - /// Enables a non-generics equality comparer. - /// - internal interface IItemEqualityComparer - { - /// - /// Compares two values for equality. - /// - /// The first value. - /// The second value. - /// true indicates that they are equal; otherwise, false. - bool IsEqual(object x, object y); - } - - /// - /// Provides the non-generics equality generic comparer; leveraging the generics comparer within. - /// - internal class ItemEqualityComparer : IItemEqualityComparer - { - /// - /// Compares two values for equality. - /// - /// The first value. - /// The second value. - /// true indicates that they are equal; otherwise, false. - public bool IsEqual(object x, object y) => EqualityComparer.Default.Equals((T)x, (T)y); - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Reflection/TypeReflectorTypeCode.cs b/src/CoreEx/Abstractions/Reflection/TypeReflectorTypeCode.cs deleted file mode 100644 index a29b95be..00000000 --- a/src/CoreEx/Abstractions/Reflection/TypeReflectorTypeCode.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Abstractions.Reflection -{ - /// - /// Represents the code. - /// - public enum TypeReflectorTypeCode - { - /// - /// Is a struct or , i.e. not any of the others. - /// - Simple, - - /// - /// Is a complex type being a class with properties (not identified as one of the possible collection types). - /// - Complex, - - /// - /// Is an . - /// - Array, - - /// - /// Is an . - /// - ICollection, - - /// - /// Is an . - /// - IEnumerable, - - /// - /// Is an . - /// - IDictionary, - } -} \ No newline at end of file diff --git a/src/CoreEx/Abstractions/Resource.cs b/src/CoreEx/Abstractions/Resource.cs index b60f6082..e502f10e 100644 --- a/src/CoreEx/Abstractions/Resource.cs +++ b/src/CoreEx/Abstractions/Resource.cs @@ -1,68 +1,44 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Abstractions; -using System; -using System.IO; -using System.Linq; -using System.Reflection; - -namespace CoreEx.Abstractions +/// +/// Provides utility functionality for embedded resources. +/// +public static class Resource { /// - /// Provides utility functionality for embedded resources. + /// Gets the named embedded resource from the inferred from the . /// - public static class Resource - { - /// - /// Gets the named embedded resource from the specified . - /// - /// The embedded resource name (matches to the end of the fully qualifed resource name). - /// The that contains the embedded resource; defaults to . - /// The ; otherwise, an will be thrown. - public static Stream GetStream(string resourceName, Assembly? assembly = null) - { - assembly ??= Assembly.GetCallingAssembly(); - var coll = assembly.GetManifestResourceNames().Where(x => x.EndsWith(resourceName, StringComparison.OrdinalIgnoreCase)); - return coll.Count() switch - { - 0 => throw new ArgumentException($"No embedded resource ending with '{resourceName}' was found in {assembly.FullName}.", nameof(resourceName)), - 1 => assembly.GetManifestResourceStream(coll.First())!, - _ => GetPrefixedStream(resourceName, assembly) - }; - } + /// The to infer the to find manifest resources (see ). + /// The embedded resource name (matches to the end of the fully qualifed resource name). + /// The ; otherwise, an will be thrown. + public static Stream GetStream(string resourceName) => GetStream(resourceName, typeof(TResource).Assembly); - /// - /// Multiple resources found so try prefixed stream as a fallback before failing. - /// - private static Stream GetPrefixedStream(string resourceName, Assembly assembly) + /// + /// Gets the named embedded resource from the specified . + /// + /// The embedded resource name (matches to the end of the fully qualifed resource name). + /// The that contains the embedded resource; defaults to . + /// The ; otherwise, an will be thrown. + public static Stream GetStream(string resourceName, Assembly? assembly = null) + { + assembly ??= Assembly.GetCallingAssembly(); + var coll = assembly.GetManifestResourceNames().Where(x => x.EndsWith(resourceName.ThrowIfNullOrEmpty(), StringComparison.OrdinalIgnoreCase)); + return coll.Count() switch { - if (resourceName.Length == 0 || resourceName[0] == '.') - throw new ArgumentException($"More than one embedded resource ending with '{resourceName}' was found in {assembly.FullName}.", nameof(resourceName)); - - return GetStream("." + resourceName, assembly); - } - - /// - /// Gets the named embedded resource from the inferred from the . - /// - /// The to infer the to find manifest resources (see ). - /// The embedded resource name (matches to the end of the fully qualifed resource name). - /// The ; otherwise, an will be thrown. - public static Stream GetStream(string resourceName) => GetStream(resourceName, typeof(TResource).Assembly); + 0 => throw new ArgumentException($"No embedded resource ending with '{resourceName}' was found in '{assembly.FullName}'.", nameof(resourceName)), + 1 => assembly.GetManifestResourceStream(coll.First())!, + _ => GetPrefixedStream(resourceName, assembly) + }; + } - /// - /// Gets the named embedded resource from the specified . - /// - /// The embedded resource name (matches to the end of the fully qualifed resource name). - /// The that contains the embedded resource; defaults to . - /// The ; otherwise, an will be thrown. - public static StreamReader GetStreamReader(string resourceName, Assembly? assembly = null) => new(GetStream(resourceName, assembly ?? Assembly.GetCallingAssembly())); + /// + /// Multiple resources found so try prefixed stream as a fallback before failing. + /// + private static Stream GetPrefixedStream(string resourceName, Assembly assembly) + { + if (resourceName.Length == 0 || resourceName[0] == '.') + throw new ArgumentException($"More than one embedded resource ending with '{resourceName}' was found in '{assembly.FullName}'.", nameof(resourceName)); - /// - /// Gets the named embedded resource from the inferred from the . - /// - /// The to infer the to find manifest resources (see ). - /// The embedded resource name (matches to the end of the fully qualifed resource name). - /// The ; otherwise, an will be thrown. - public static StreamReader GetStreamReader(string resourceName) => GetStreamReader(resourceName, typeof(TResource).Assembly); + return GetStream("." + resourceName, assembly); } } \ No newline at end of file diff --git a/src/CoreEx/AuthenticationException.cs b/src/CoreEx/AuthenticationException.cs index 9e6b976c..7260a6e9 100644 --- a/src/CoreEx/AuthenticationException.cs +++ b/src/CoreEx/AuthenticationException.cs @@ -1,72 +1,31 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Localization; -using System; -using System.Net; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents an Authentication exception. +/// +/// The defaults to: An authentication error occurred; the credentials provided are not valid. +/// The error message. +/// The inner . +public class AuthenticationException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(AuthenticationException).FullName, _message), innerException) { + private const string _message = "An authentication error occurred; the credentials provided are not valid."; + /// - /// Represents an Authentication exception. + /// Initializes a new instance of the class. /// - /// The defaults to: An authentication error occured; the credentials you provided are not valid. - public class AuthenticationException : Exception, IExtendedException - { - private const string _message = "An authentication error occurred; the credentials you provided are not valid."; - private static bool? _shouldExceptionBeLogged; - - /// - /// Get or sets the value. - /// - public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } - - /// - /// Initializes a new instance of the class. - /// - public AuthenticationException() : this(null!) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - public AuthenticationException(string? message) : base(message ?? new LText(typeof(AuthenticationException).FullName, _message)) { } + public AuthenticationException() : this(null) { } - /// - /// Initializes a new instance of the class using the specified and . - /// - /// The error message. - /// The inner . - public AuthenticationException(string? message, Exception innerException) : base(message ?? new LText(typeof(AuthenticationException).FullName, _message), innerException) { } - - /// - /// - /// - /// The value as a . - public string ErrorType => Abstractions.ErrorType.AuthenticationError.ToString(); - - /// - /// - /// - /// The value as a . - public int ErrorCode => (int)Abstractions.ErrorType.AuthenticationError; - - /// - /// - /// - /// The value. - public HttpStatusCode StatusCode => HttpStatusCode.Unauthorized; - - /// - /// - /// - /// false; is not considered transient. - public bool IsTransient => false; + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public AuthenticationException(LText? message) : this(message, null) { } - /// - /// - /// - /// The value. - public bool ShouldBeLogged => ShouldExceptionBeLogged; + /// + protected override void OnInitialize() + { + ErrorType = "authentication"; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.Unauthorized); } } \ No newline at end of file diff --git a/src/CoreEx/AuthorizationException.cs b/src/CoreEx/AuthorizationException.cs index a912030e..8b4f264b 100644 --- a/src/CoreEx/AuthorizationException.cs +++ b/src/CoreEx/AuthorizationException.cs @@ -1,72 +1,31 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Localization; -using System; -using System.Net; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents an Authorization exception. +/// +/// The defaults to: An authorization error occurred; you are not permitted to perform this action. +/// The error message. +/// The inner . +public class AuthorizationException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(AuthorizationException).FullName, _message), innerException) { + private const string _message = "An authorization error occurred; you are not permitted to perform this action."; + /// - /// Represents an Authorization exception. + /// Initializes a new instance of the class. /// - /// The defaults to: An authorization error occurred; you are not permitted to perform this action. - public class AuthorizationException : Exception, IExtendedException - { - private const string _message = "An authorization error occurred; you are not permitted to perform this action."; - private static bool? _shouldExceptionBeLogged; - - /// - /// Get or sets the value. - /// - public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } - - /// - /// Initializes a new instance of the class. - /// - public AuthorizationException() : this(null!) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - public AuthorizationException(string? message) : base(message ?? new LText(typeof(AuthorizationException).FullName, _message)) { } + public AuthorizationException() : this(null) { } - /// - /// Initializes a new instance of the class using the specified and . - /// - /// The error message. - /// The inner . - public AuthorizationException(string? message, Exception innerException) : base(message ?? new LText(typeof(AuthorizationException).FullName, _message), innerException) { } - - /// - /// - /// - /// The value as a . - public string ErrorType => Abstractions.ErrorType.AuthorizationError.ToString(); - - /// - /// - /// - /// The value as a . - public int ErrorCode => (int)Abstractions.ErrorType.AuthorizationError; - - /// - /// - /// - /// The value. - public HttpStatusCode StatusCode => HttpStatusCode.Forbidden; - - /// - /// - /// - /// false; is not considered transient. - public bool IsTransient => false; + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public AuthorizationException(LText? message) : this(message, null) { } - /// - /// - /// - /// The value. - public bool ShouldBeLogged => ShouldExceptionBeLogged; + /// + protected override void OnInitialize() + { + ErrorType = "authorization"; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.Forbidden); } } \ No newline at end of file diff --git a/src/CoreEx/BusinessException.cs b/src/CoreEx/BusinessException.cs index 26cf68b3..7a191e93 100644 --- a/src/CoreEx/BusinessException.cs +++ b/src/CoreEx/BusinessException.cs @@ -1,73 +1,34 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Localization; -using System; -using System.Net; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents a Business exception. +/// +/// This is a special purpose exception intended for a business-oriented error that could be returned to the consumer as-is. These are typically errors that are unlikely to be known upfront by the consuming +/// application and would be difficult to guard against. These are likely to occur regularly in a correctly functioning system. For example, a business rule violation such as "Customer cannot be deleted as they have +/// active orders" would be a good candidate for this type of exception. As distinct from a which is intended for known/expected errors, for example, "Customer name is required". +/// There is no default . +/// The error message. +/// The inner . +public class BusinessException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(BusinessException).FullName, _message), innerException) { + private const string _message = "A business error occurred."; + /// - /// Represents a Business exception. + /// Gets the business error type. /// - /// This is typically used for a business-oriented error that should be returned to the consumer. - /// The defaults to: A business error occurred. - public class BusinessException : Exception, IExtendedException - { - private const string _message = "A business error occurred."; - private static bool? _shouldExceptionBeLogged; - - /// - /// Get or sets the value. - /// - public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } - - /// - /// Initializes a new instance of the class. - /// - public BusinessException() : this(null!) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - public BusinessException(string? message) : base(message ?? new LText(typeof(BusinessException).FullName, _message)) { } + public const string BusinessErrorType = "business"; - /// - /// Initializes a new instance of the class using the specified and . - /// - /// The error message. - /// The inner . - public BusinessException(string? message, Exception innerException) : base(message ?? new LText(typeof(BusinessException).FullName, _message), innerException) { } - - /// - /// - /// - /// The value as a . - public string ErrorType => Abstractions.ErrorType.BusinessError.ToString(); - - /// - /// - /// - /// The value as a . - public int ErrorCode => (int)Abstractions.ErrorType.BusinessError; - - /// - /// - /// - /// The value. - public HttpStatusCode StatusCode => HttpStatusCode.BadRequest; - - /// - /// - /// - /// false; is not considered transient. - public bool IsTransient => false; + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public BusinessException(LText? message) : this(message.ThrowIfNull(), null) { } - /// - /// - /// - /// The value. - public bool ShouldBeLogged => ShouldExceptionBeLogged; + /// + protected override void OnInitialize() + { + ErrorType = BusinessErrorType; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.BadRequest); } } \ No newline at end of file diff --git a/src/CoreEx/Caching/CacheStrategy.cs b/src/CoreEx/Caching/CacheStrategy.cs new file mode 100644 index 00000000..abbe8b94 --- /dev/null +++ b/src/CoreEx/Caching/CacheStrategy.cs @@ -0,0 +1,23 @@ +namespace CoreEx.Caching; + +/// +/// Represents the underlying cache strategy. +/// +[Flags] +public enum CacheStrategy +{ + /// + /// Caching is applied locally; generally in-memory. + /// + Local = 1, + + /// + /// Caching is applied in a distributed manner. + /// + Distributed = 2, + + /// + /// Indicates caching is applied both (L1) and (L2). + /// + Hybrid = Local | Distributed, +} \ No newline at end of file diff --git a/src/CoreEx/Caching/DefaultCacheKeyProvider.cs b/src/CoreEx/Caching/DefaultCacheKeyProvider.cs new file mode 100644 index 00000000..6e1470c9 --- /dev/null +++ b/src/CoreEx/Caching/DefaultCacheKeyProvider.cs @@ -0,0 +1,26 @@ +namespace CoreEx.Caching; + +/// +/// Provides the default implementation of . +/// +/// The optional . +/// The optional . +/// This implementation uses and to provide namespacing/partitioning of cache keys where applicable. +public sealed class DefaultCacheKeyProvider(IHostSettings? hostSettings = null, ExecutionContext? executionContext = null) : ICacheKeyProvider +{ + private readonly IHostSettings? _hostSettings = hostSettings; + private readonly ExecutionContext? _executionContext = executionContext; + + /// + /// This implementation uses the following format: '{DomainName}:{TenantId}:{Key}' where DomainName is obtained from and TenantId is obtained from . + /// Where either value is not available, it is simply omitted along with the associated colon (':'). + public string GetFullyQualifiedCacheKey(string key) => _hostSettings is null + ? _executionContext?.TenantId is null ? key : $"{_executionContext.TenantId}:{key}" + : _executionContext?.TenantId is null ? $"{_hostSettings.DomainName}:{key}" : $"{_hostSettings.DomainName}:{_executionContext.TenantId}:{key}"; + + /// + /// This implementation uses the of as a prefix, then a ':', followed by the . For example, + /// the 'CoreEx.Testing.Product' class with a key or 'Abc' would result in 'Product:Abc'. + /// + public string GetEntityCacheKey(CompositeKey key) where T : IEntityKey => $"{typeof(T).Name}:{key.ToString() ?? string.Empty}"; +} \ No newline at end of file diff --git a/src/CoreEx/Caching/HybridCacheEntryOptions.cs b/src/CoreEx/Caching/HybridCacheEntryOptions.cs new file mode 100644 index 00000000..2282dfce --- /dev/null +++ b/src/CoreEx/Caching/HybridCacheEntryOptions.cs @@ -0,0 +1,121 @@ +namespace CoreEx.Caching; + +/// +/// Provides the entry options, including strategy, expiration and tags. +/// +public record class HybridCacheEntryOptions +{ + private static CacheStrategy? _defaultStrategy; + private static TimeSpan? _defaultLocalExpiration; + private static TimeSpan? _defaultDistributedExpiration; + + /// + /// Gets or sets the default . + /// + /// Defaults to settings 'CoreEx:Caching:DefaultStrategy'; otherwise, . + public static CacheStrategy DefaultStrategy + { + get => _defaultStrategy ??= Internal.GetConfigurationValue("CoreEx:Caching:DefaultStrategy", CacheStrategy.Hybrid); + set => _defaultStrategy = value; + } + + /// + /// Gets or sets the default . + /// + /// Defaults to settings 'CoreEx:Caching:DefaultLocalExpiration'; otherwise, five (5) minutes. + public static TimeSpan DefaultLocalExpiration + { + get => _defaultLocalExpiration ??= Internal.GetConfigurationValue("CoreEx:Caching:DefaultLocalExpiration", TimeSpan.FromMinutes(5)); + set => _defaultLocalExpiration = value; + } + + /// + /// Gets or sets the default . + /// + /// Defaults to settings 'CoreEx:Caching:DistributedExpiration'; otherwise, five (5) minutes. + public static TimeSpan DefaultDistributedExpiration + { + get => _defaultDistributedExpiration ??= Internal.GetConfigurationValue("CoreEx:Caching:DefaultDistributedExpiration", TimeSpan.FromMinutes(5)); + set => _defaultDistributedExpiration = value; + } + + /// + /// Creates a new using the defaults. + /// + /// The . + public static HybridCacheEntryOptions CreateDefault() => new() + { + Strategy = DefaultStrategy, + LocalExpiration = DefaultLocalExpiration, + DistributedExpiration = DefaultDistributedExpiration + }; + + /// + /// Creates a new using the specified configuration to retrieve the underlying configuration settings. + /// + /// The configuration name.. + /// The default local expiration used where not configured. + /// The default distributed expiration used where not configured. + /// The default used where not configured. + /// The . + public static HybridCacheEntryOptions CreateForName(string name, TimeSpan? localExpiration = null, TimeSpan? distributedExpiration = null, CacheStrategy? strategy = null) + { + var config = ExecutionContext.GetService() ?? Internal.EmptyConfiguration; // Avoids passing null. + + // Default from configuration. + return new HybridCacheEntryOptions + { + LocalExpiration = Internal.GetConfigurationValue($"CoreEx:Caching:{name}:LocalExpiration", localExpiration ?? DefaultLocalExpiration, config), + DistributedExpiration = Internal.GetConfigurationValue($"CoreEx:Caching:{name}:DistributedExpiration", distributedExpiration ?? DefaultDistributedExpiration, config), + Strategy = Internal.GetConfigurationValue($"CoreEx:Caching:{name}:Strategy", strategy ?? DefaultStrategy, config) + }; + } + + /// + /// Creates a new using the specified name to retrieve the underlying configuration settings. + /// + /// The cache . + /// The default local expiration used where not configured. + /// The default distributed expiration used where not configured. + /// The default used where not configured. + /// A instance associated with the specified type. + /// The is used as the name; see . + public static HybridCacheEntryOptions CreateFor(TimeSpan? localExpiration = null, TimeSpan? distributedExpiration = null, CacheStrategy? strategy = null) + => CreateForName(nameof(T), localExpiration, distributedExpiration, strategy); + + /// + /// Gets or sets the . + /// + public CacheStrategy Strategy { get; set; } = CacheStrategy.Hybrid; + + /// + /// Gets or sets the local cache expiration where applicable. + /// + /// Where then the implementation default local expiration is applied. + public TimeSpan? LocalExpiration { get; set; } + + /// + /// Gets or sets the distributed cache expiration where applicable. + /// + /// Where then the implementation default distributed expiration is applied. + public TimeSpan? DistributedExpiration { get; set; } + + /// + /// Gets or sets the associated tags used when setting the underlying cache entry. + /// + /// Tags enable grouped cache entry management where supported by the underlying cache implementation(s). + public string[]? Tags { get; set; } + + /// + /// Adds the specified tags to the cache entry options. + /// + /// The tags. + /// The to support fluent-style method-chaining. + public HybridCacheEntryOptions WithTags(params IEnumerable tags) + { + if (tags.Any()) + Tags = Tags is null ? [.. tags] : [.. Tags.Concat(tags).Distinct()]; + + return this; + } +} \ No newline at end of file diff --git a/src/CoreEx/Caching/ICacheKey.cs b/src/CoreEx/Caching/ICacheKey.cs deleted file mode 100644 index 88c1d572..00000000 --- a/src/CoreEx/Caching/ICacheKey.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using System.Text.Json.Serialization; - -namespace CoreEx.Caching -{ - /// - /// Provides the . - /// - public interface ICacheKey : IUniqueKey - { - /// - /// Gets the cache key. - /// - [JsonIgnore] - public CompositeKey CacheKey { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Caching/ICacheKeyProvider.cs b/src/CoreEx/Caching/ICacheKeyProvider.cs new file mode 100644 index 00000000..6a9888c0 --- /dev/null +++ b/src/CoreEx/Caching/ICacheKeyProvider.cs @@ -0,0 +1,24 @@ +namespace CoreEx.Caching; + +/// +/// Enables fully-qualified key formatting of a cache key where partitioning or namespacing is required. +/// +/// This is used to ensure that cache keys are unique across an application or service; for example, by prefixing with the domain and/or tenant names. See which implements as described. +public interface ICacheKeyProvider +{ + /// + /// Gets the fully-qualified cache key. + /// + /// The cache key. + /// The fully-qualified cache key. + string GetFullyQualifiedCacheKey(string key); + + /// + /// Gets the non-qualified cache key using the of and value. + /// + /// The cache value . + /// The . + /// The cache key. + /// The result of this should still be passed through the to get the final fully-qualified cache key. + string GetEntityCacheKey(CompositeKey key) where T : IEntityKey; +} \ No newline at end of file diff --git a/src/CoreEx/Caching/IHybridCache.cs b/src/CoreEx/Caching/IHybridCache.cs new file mode 100644 index 00000000..494611e4 --- /dev/null +++ b/src/CoreEx/Caching/IHybridCache.cs @@ -0,0 +1,79 @@ +namespace CoreEx.Caching; + +/// +/// Enables a hybrid cache (i.e. local and/or distributed) as defined by the . +/// +/// The is required for each method to specify the underlying cache behavior; therefore, it is important that the same options are reused +/// when accessing the same key as this may result in inconsistent/unexpected behavior. +public interface IHybridCache +{ + /// + /// Gets the . + /// + ICacheKeyProvider KeyProvider { get; } + + /// + /// Tries to get the cached value for the specified key. + /// + /// The cache value . + /// The cache key. + /// The optional . + /// The . + /// A tuple with a indicating whether the entry exists and the associated value where found (otherwise, ). + Task<(bool Exists, T? Value)> TryGetByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// Gets the cached value for the specified key. + /// + /// The cache value . + /// The cache key. + /// The optional . + /// The . + /// The cached value or default. + Task GetOrDefaultByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// Sets or overwrites the cache value for the specified key. + /// + /// The cache value . + /// The cache key. + /// The cache value. + /// The optional . + /// The . + Task SetByKeyAsync(string key, T value, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// Gets the cached value for the specified key using the to create and set where not found. + /// + /// The cache value . + /// The cache key. + /// The function used to create the cache value. + /// The optional . + /// The . + /// The cached value. + Task GetOrCreateByKeyAsync(string key, Func> factory, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// Removes the cached value for the specified key. + /// + /// The cache key. + /// The optional . + /// The . + Task RemoveByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// Remove all cached values that have the specified tag. + /// + /// The cache tag. + /// The optional . + /// The . + Task RemoveByTagAsync(string tag, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// Remove all cached values that have the specified tags. + /// + /// The cache tags. + /// The optional . + /// The . + Task RemoveByTagAsync(IEnumerable tags, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/CoreEx/Caching/IRequestCache.cs b/src/CoreEx/Caching/IRequestCache.cs deleted file mode 100644 index fffafda5..00000000 --- a/src/CoreEx/Caching/IRequestCache.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.Caching -{ - /// - /// Enables the short-lived request caching; intended to reduce data chattiness within the context of a request scope. - /// - public interface IRequestCache - { - /// - /// Gets the cached value associated with the specified and . - /// - /// The value . - /// The key of the value to get. - /// The cached value where found; otherwise, the default value for the . - /// true where found; otherwise, false. - bool TryGetValue(CompositeKey key, out T? value); - - /// - /// Sets (adds or overrides) the cache value for the specified and and returns . - /// - /// The value . - /// The key of the value to set. - /// The value to set. - /// The . - [return: NotNullIfNotNull(nameof(value))] - T? SetValue(CompositeKey key, T? value); - - /// - /// Removes the cached value associated with the specified and . - /// - /// The value . - /// The key of the value to remove. - /// true where found and removed; otherwise, false. - bool Remove(CompositeKey key); - - /// - /// Clears the cache for the specified . - /// - /// The value . - void Clear(); - - /// - /// Clears the cache for all types. - /// - void ClearAll(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Caching/MemoryOnlyHybridCache.cs b/src/CoreEx/Caching/MemoryOnlyHybridCache.cs new file mode 100644 index 00000000..9aa4f7f1 --- /dev/null +++ b/src/CoreEx/Caching/MemoryOnlyHybridCache.cs @@ -0,0 +1,65 @@ +namespace CoreEx.Caching; + +/// +/// Provides an implementation that uses in-memory caching only regardless of the specified . +/// +/// The optional . +/// The optional . +public sealed class MemoryOnlyHybridCache(ICacheKeyProvider? cacheKeyProvider = null, IMemoryCache? memoryCache = null) : IHybridCache +{ + private readonly IMemoryCache _memoryCache = memoryCache ?? new MemoryCache(new MemoryCacheOptions()); + + /// + public ICacheKeyProvider KeyProvider { get; } = cacheKeyProvider ?? new DefaultCacheKeyProvider(); + + /// + public async Task<(bool Exists, T? Value)> TryGetByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + if (_memoryCache.TryGetValue(KeyProvider.GetFullyQualifiedCacheKey(key), out T? value)) + return (true, value); + + return (false, default); + } + + /// + public async Task GetOrCreateByKeyAsync(string key, Func> factory, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + if (_memoryCache.TryGetValue(KeyProvider.GetFullyQualifiedCacheKey(key), out T? value)) + return value!; + + var result = await factory(cancellationToken).ConfigureAwait(false)!; + _memoryCache.Set(KeyProvider.GetFullyQualifiedCacheKey(key), result, options?.LocalExpiration ?? HybridCacheEntryOptions.DefaultLocalExpiration); + return result; + } + + /// + public Task GetOrDefaultByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + if (_memoryCache.TryGetValue(KeyProvider.GetFullyQualifiedCacheKey(key), out T? value)) + return Task.FromResult(value); + + return Task.FromResult(default); + } + + /// + public Task SetByKeyAsync(string key, T value, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + _memoryCache.Set(KeyProvider.GetFullyQualifiedCacheKey(key), value, options?.LocalExpiration ?? HybridCacheEntryOptions.DefaultLocalExpiration); + return Task.CompletedTask; + } + + /// + public Task RemoveByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + _memoryCache.Remove(KeyProvider.GetFullyQualifiedCacheKey(key)); + return Task.CompletedTask; + } + + /// + /// Throws a . + public Task RemoveByTagAsync(string tag, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + /// + /// Throws a . + public Task RemoveByTagAsync(IEnumerable tags, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/CoreEx/Caching/README.md b/src/CoreEx/Caching/README.md deleted file mode 100644 index cb730ada..00000000 --- a/src/CoreEx/Caching/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# CoreEx.Caching - -The `CoreEx.Caching` namespace provides additional caching capabilities. - -
- -## Motivation - -To provide additional capabilities to cache data to improve runtime performance. - -
- -## Request cache - -The [`IRequestCache`](./IRequestCache.cs) interface and corresponding [`RequestCache`](./RequestCache.cs) implementation are intended to provide generic short-lived request caching; for example, to reduce data chattiness within the context of a request scope. \ No newline at end of file diff --git a/src/CoreEx/Caching/RequestCache.cs b/src/CoreEx/Caching/RequestCache.cs deleted file mode 100644 index 95f24a79..00000000 --- a/src/CoreEx/Caching/RequestCache.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using System; -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Linq; - -namespace CoreEx.Caching -{ - /// - /// Provides a basic concurrent dictionary backed cache for short-lived data within the context of a request scope to reduce data-layer chattiness. - /// - public class RequestCache : IRequestCache - { - private readonly Lazy> _caching = new(true); - - /// - /// Gets the from the based on an order of precedence of , then , then . - /// - /// The . - /// The value. - /// The resulting . - /// Where the implements then the will be returned, then where implements then the - /// will be returned; otherwise, will be returned. - internal static CompositeKey GetKeyFromValue(T value) where T : IUniqueKey - { - if (value is null) - return CompositeKey.Empty; - else if (value is ICacheKey ck) - return ck.CacheKey; - else if (value is IEntityKey ek) - return ek.EntityKey; - else - return CompositeKey.Empty; - } - - /// - public bool TryGetValue(CompositeKey key, out T? value) - { - if (_caching.IsValueCreated && _caching.Value.TryGetValue(new (typeof(T), key), out object? val)) - { - value = (T?)val; - return true; - } - - value = default!; - return false; - } - - /// - [return: NotNullIfNotNull(nameof(value))] - public T? SetValue(CompositeKey key, T? value) - { - _caching.Value.AddOrUpdate(new(typeof(T), key), value, (_, __) => value); - return value; - } - - /// - /// Removes the cached value associated with the specified and key. - /// - /// The value . - /// The key of the value to remove. - /// true where found and removed; otherwise, false. - public bool Remove(CompositeKey key) => _caching.IsValueCreated && _caching.Value.TryRemove(new (typeof(T), key), out _); - - /// - /// Clears the cache for the specified . - /// - /// The value . - public void Clear() - { - if (!_caching.IsValueCreated) - return; - - foreach (var item in _caching.Value.Where(x => x.Key.Item1 == typeof(T)).ToList()) - { - _caching.Value.TryRemove(item.Key, out _); - } - } - - /// - /// Clears the cache for all types. - /// - public void ClearAll() - { - if (_caching.IsValueCreated) - _caching.Value.Clear(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Caching/RequestCacheExtensions.cs b/src/CoreEx/Caching/RequestCacheExtensions.cs deleted file mode 100644 index c955b6d8..00000000 --- a/src/CoreEx/Caching/RequestCacheExtensions.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; - -namespace CoreEx.Caching -{ - /// - /// Provides extension methods. - /// - public static class RequestCacheExtensions - { - /// - /// Gets the cached value associated with the specified and . - /// - /// The value . - /// The . - /// The key of the value to get. - /// The cached value where found; otherwise, the default value for the . - /// true where found; otherwise, false. - public static bool TryGetValue(this IRequestCache cache, IUniqueKey key, out T? value) => cache.TryGetValue(RequestCache.GetKeyFromValue(key), out value); - - /// - /// Gets the cached value associated with the specified and (converted to a ). - /// - /// The value . - /// The . - /// The key of the value to get. - /// The cached value where found; otherwise, the default value for the . - /// true where found; otherwise, false. - public static bool TryGetValue(this IRequestCache cache, object? key, out T? value) => cache.TryGetValue(new CompositeKey(key), out value); - - /// - /// Gets the cached value associated with the specified and where it exists; otherwise, adds and returns the value created by the . - /// - /// The value . - /// The . - /// The key of the value to get or add. - /// The factory function to create the value. - /// The cached value (existing or new). - public static async Task GetOrAddAsync(this IRequestCache cache, CompositeKey key, Func> addFactory) - { - addFactory.ThrowIfNull(nameof(addFactory)); - - if (cache.TryGetValue(key, out var value)) - return value; - - value = await addFactory().ConfigureAwait(false); - return cache.SetValue(key, value); - } - - /// - /// Gets the cached value associated with the specified and (converted to a ) where it exists; otherwise, adds and returns the value created by the . - /// - /// The value . - /// The . - /// The key of the value to get or add. - /// The factory function to create the value. - /// The cached value (existing or new). - public static Task GetOrAddAsync(this IRequestCache cache, object? key, Func> addFactory) => cache.GetOrAddAsync(new CompositeKey(key), addFactory); - - /// - /// Sets (adds or overrides) the cache value for the specified and 1 and returns . - /// - /// The value . - /// The . - /// The key of the value to set. - /// The value to set. - /// The . - [return: NotNullIfNotNull(nameof(value))] - public static T? SetValue(this IRequestCache cache, object? key, T? value) => cache.SetValue(new CompositeKey(key), value); - - /// - /// Sets (adds or overrides) the cache value for the specified and returns . - /// - /// The value . - /// The . - /// The value to set. - /// The . - [return: NotNullIfNotNull(nameof(value))] - public static T? SetValue(this IRequestCache cache, T? value) where T : IUniqueKey => value is null ? value : cache.SetValue(RequestCache.GetKeyFromValue(value), value); - - /// - /// Removes the cached value associated with the specified and . - /// - /// The value . - /// The . - /// The key of the value to remove. - /// true where found and removed; otherwise, false. - public static bool Remove(this IRequestCache cache, IUniqueKey key) => cache.Remove(RequestCache.GetKeyFromValue(key)); - - /// - /// Removes the cached value associated with the specified and (converted to a ). - /// - /// The value . - /// The . - /// The key of the value to remove. - /// true where found and removed; otherwise, false. - public static bool Remove(this IRequestCache cache, object? key) => cache.Remove(new CompositeKey(key)); - } -} \ No newline at end of file diff --git a/src/CoreEx/Caching/ResultExtensions.cs b/src/CoreEx/Caching/ResultExtensions.cs deleted file mode 100644 index ba0ecbc9..00000000 --- a/src/CoreEx/Caching/ResultExtensions.cs +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Threading.Tasks; - -namespace CoreEx.Caching -{ - /// - /// Provides -based extension methods. - /// - public static class ResultExtensions - { - /// - /// Gets the cached value associated with the specified and where it exists; otherwise, adds and returns the value created by the . - /// - /// The . - /// The . - /// The . - /// The key of the value to get or add. - /// The factory function to create the . - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Task> CacheGetOrAddAsync(this Result result, IRequestCache cache, object? key, Func>> addFactory) - => CacheGetOrAddAsync(result, cache, new CompositeKey(key), addFactory); - - /// - /// Gets the cached value associated with the specified and where it exists; otherwise, adds and returns the value created by the . - /// - /// The . - /// The . - /// The . - /// The key of the value to get or add. - /// The factory function to create the . - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Task> CacheGetOrAddAsync(this Result result, IRequestCache cache, CompositeKey key, Func>> addFactory) - => Task.FromResult(result).CacheGetOrAddAsync(cache, key, addFactory); - - /// - /// Gets the cached value associated with the specified and where it exists; otherwise, adds and returns the value created by the . - /// - /// The . - /// The . - /// The . - /// The key of the value to get or add. - /// The factory function to create the . - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Task> CacheGetOrAddAsync(this Task result, IRequestCache cache, object? key, Func>> addFactory) - => CacheGetOrAddAsync(result, cache, new CompositeKey(key), addFactory); - - /// - /// Gets the cached value associated with the specified and where it exists; otherwise, adds and returns the value created by the . - /// - /// The . - /// The . - /// The . - /// The key of the value to get or add. - /// The factory function to create the . - /// The resulting . - /// The caching is only performed where the corresponding has . - public static async Task> CacheGetOrAddAsync(this Task result, IRequestCache cache, CompositeKey key, Func>> addFactory) - { - cache.ThrowIfNull(nameof(cache)); - addFactory.ThrowIfNull(nameof(addFactory)); - - var r = await result.ConfigureAwait(false); - return await r.ThenAsAsync(async () => - { - if (cache.TryGetValue(key, out var val)) - return Result.Ok(val!); - - var ar = await addFactory().ConfigureAwait(false); - return ar.Then(v => - { - cache.SetValue(key, v); - return Result.Ok(v); - }); - }); - } - - /// - /// Sets (caches) the into the supplied (using the underlying ). - /// - /// The which must be an . - /// The . - /// The . - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Result CacheSet(this Result result, IRequestCache cache) where T : IUniqueKey - { - cache.ThrowIfNull(nameof(cache)); - return result.Then(r => { cache.SetValue(r); }); - } - - /// - /// Sets (caches) the into the supplied using the specified . - /// - /// The . - /// The . - /// The . - /// The key of the value to set. - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Result CacheSet(this Result result, IRequestCache cache, CompositeKey key) - { - cache.ThrowIfNull(nameof(cache)); - return result.Then(r => { cache.SetValue(key, r); }); - } - - /// - /// Sets (caches) the into the supplied (using the underlying ). - /// - /// The which must be an . - /// The . - /// The . - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Task> CacheSet(this Task> result, IRequestCache cache) where T : IUniqueKey - { - cache.ThrowIfNull(nameof(cache)); - return result.Then(r => { cache.SetValue(r); }); - } - - /// - /// Sets (caches) the into the supplied using the specified . - /// - /// The . - /// The . - /// The . - /// The key of the value to set. - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Task> CacheSet(this Task> result, IRequestCache cache, CompositeKey key) - { - cache.ThrowIfNull(nameof(cache)); - return result.Then(r => { cache.SetValue(key, r); }); - } - - /// - /// Removes the cached value associated with the specified and . - /// - /// The cached value . - /// The . - /// The . - /// The key of the value to remove. - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Result CacheRemove(this Result result, IRequestCache cache, CompositeKey key) - { - cache.ThrowIfNull(nameof(cache)); - return result.Then(() => { cache.Remove(key); }); - } - - /// - /// Removes the cached value associated with the specified and . - /// - /// The cached value . - /// The . - /// The . - /// The key of the value to remove. - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Result CacheRemove(this Result result, IRequestCache cache, object? key) - { - cache.ThrowIfNull(nameof(cache)); - return result.Then(() => { cache.Remove(key); }); - } - - /// - /// Removes the cached value associated with the specified (using the underlying ). - /// - /// The cached value . - /// The . - /// The . - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Result CacheRemove(this Result result, IRequestCache cache) where T : IUniqueKey - { - cache.ThrowIfNull(nameof(cache)); - return result.Then(r => { cache.Remove(r is null ? CompositeKey.Empty : RequestCache.GetKeyFromValue(r)); }); - } - - /// - /// Removes the cached value associated with the specified and . - /// - /// The cached value . - /// The . - /// The . - /// The key of the value to remove. - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Task CacheRemove(this Task result, IRequestCache cache, CompositeKey key) - { - cache.ThrowIfNull(nameof(cache)); - return result.Then(() => { cache.Remove(key); }); - } - - /// - /// Removes the cached value associated with the specified and . - /// - /// The cached value . - /// The . - /// The . - /// The key of the value to remove. - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Task CacheRemove(this Task result, IRequestCache cache, object? key) - { - cache.ThrowIfNull(nameof(cache)); - return result.Then(() => { cache.Remove(key); }); - } - - /// - /// Removes the cached value associated with the specified (using the underlying ). - /// - /// The cached value . - /// The . - /// The . - /// The resulting . - /// The caching is only performed where the corresponding has . - public static Task> CacheRemove(this Task> result, IRequestCache cache) where T : IUniqueKey - { - cache.ThrowIfNull(nameof(cache)); - return result.Then(r => { cache.Remove(r is null ? CompositeKey.Empty : RequestCache.GetKeyFromValue(r)); }); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/ConcurrencyException.cs b/src/CoreEx/ConcurrencyException.cs index ba1cdfd8..a2b6ebda 100644 --- a/src/CoreEx/ConcurrencyException.cs +++ b/src/CoreEx/ConcurrencyException.cs @@ -1,72 +1,31 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Localization; -using System; -using System.Net; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents a data Concurrency exception. +/// +/// The defaults to: A concurrency error occurred; please refresh the data and try again. +/// The error message. +/// The inner . +public class ConcurrencyException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(ConcurrencyException).FullName, _message), innerException) { + private const string _message = "A concurrency error occurred; please refresh the data and try again."; + /// - /// Represents a data Concurrency exception. + /// Initializes a new instance of the class. /// - /// The defaults to: A concurrency error occurred; please refresh the data and try again. - public class ConcurrencyException : Exception, IExtendedException - { - private const string _message = "A concurrency error occurred; please refresh the data and try again."; - private static bool? _shouldExceptionBeLogged; - - /// - /// Get or sets the value. - /// - public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } - - /// - /// Initializes a new instance of the class. - /// - public ConcurrencyException() : this(null!) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - public ConcurrencyException(string? message) : base(message ?? new LText(typeof(ConcurrencyException).FullName, _message)) { } + public ConcurrencyException() : this(null) { } - /// - /// Initializes a new instance of the class using the specified and . - /// - /// The error message. - /// The inner . - public ConcurrencyException(string? message, Exception innerException) : base(message ?? new LText(typeof(ConcurrencyException).FullName, _message), innerException) { } - - /// - /// - /// - /// The value as a . - public string ErrorType => Abstractions.ErrorType.ConcurrencyError.ToString(); - - /// - /// - /// - /// The value as a . - public int ErrorCode => (int)Abstractions.ErrorType.ConcurrencyError; - - /// - /// - /// - /// The value. - public HttpStatusCode StatusCode => HttpStatusCode.PreconditionFailed; - - /// - /// - /// - /// false; is not considered transient. - public bool IsTransient => false; + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public ConcurrencyException(LText? message) : this(message, null) { } - /// - /// - /// - /// The value. - public bool ShouldBeLogged => ShouldExceptionBeLogged; + /// + protected override void OnInitialize() + { + ErrorType = "concurrency"; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.PreconditionFailed); } } \ No newline at end of file diff --git a/src/CoreEx/Configuration/DefaultSettings.cs b/src/CoreEx/Configuration/DefaultSettings.cs deleted file mode 100644 index 5fc2c2ba..00000000 --- a/src/CoreEx/Configuration/DefaultSettings.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Configuration; - -namespace CoreEx.Configuration -{ - /// - /// Provides a default implementation with no prefixes defined. - /// - /// This is essentially just a light-weight wrapper over . - /// The . - public class DefaultSettings(IConfiguration? configuration = null) : SettingsBase(configuration) { } -} \ No newline at end of file diff --git a/src/CoreEx/Configuration/DeploymentInfo.cs b/src/CoreEx/Configuration/DeploymentInfo.cs deleted file mode 100644 index e5df18f7..00000000 --- a/src/CoreEx/Configuration/DeploymentInfo.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Configuration; - -namespace CoreEx.Configuration -{ - /// - /// Provides the common deployment information setting. - /// - /// This class should be inherited to add additional properties where required. - /// The . - public class DeploymentInfo(IConfiguration? configuration) - { - private const string Unspecified = ""; - private readonly IConfiguration? _configuration = configuration; - - /// - /// Gets the username who performed the deployment. - /// - public virtual string By => _configuration?.GetValue("Deployment.By") ?? Unspecified; - - /// - /// Gets the deployment build number. - /// - public virtual string Build => _configuration?.GetValue("Deployment.Build") ?? Unspecified; - - /// - /// Gets the name of the deployment job that deployed the . - /// - public virtual string Name => _configuration?.GetValue("Deployment.Name") ?? Unspecified; - - /// - /// Gets the deployment build version, such as the Git information (branch and commit) of the deployed . - /// - public virtual string Version => _configuration?.GetValue("Deployment.Version") ?? Unspecified; - - /// - /// Gets the date and time (UTC) when deployment was performed. - /// - public virtual string DateUtc => _configuration?.GetValue("Deployment.Date") ?? Unspecified; - } -} \ No newline at end of file diff --git a/src/CoreEx/Configuration/README.md b/src/CoreEx/Configuration/README.md deleted file mode 100644 index 5d50dae1..00000000 --- a/src/CoreEx/Configuration/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# CoreEx.Configuration - -The `CoreEx.Configuration` namespace primarily extends the .NET [configuration](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration), specifically the [`IConfiguration`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.configuration.iconfiguration) capabilities. - -
- -## Motivation - -To provide a more flexible, strongly typed, `IConfiguration` encapsulated provider; as well as providing a capability for _CoreEx_ to house and manage configuration. - -
- -## Settings base - -The [`SettingsBase`](./SettingsBase.cs) class provides the foundational abstract capabilities for retrieving configured settings. Enables the standard `GetValue` and `GetRequiredValue` methods to retrieve. - -The underlying constructor requires an `IConfiguration` instance and zero or more prefixes to use in order of precedence, first through to last, to find the underlying key/value pair. The prefixes enable a probing order, where specific overrides and common setting values can be supported. - -For example, in a microservices architecture, there may be multiple domains. So if there is a `Product` domain, then the following prefixes could be used: `Product` and `Common`. The `GetValue` method where passed a key of `ConnectionString`, would the search for the following (in order) stopping where the value has been configured: `Product/ConnectionString`, `Common/ConnectionString` and `ConnectionString`. This allows for shared settings with the opportunity to easily override where applicable. - -_Note:_ This plays nicely with the likes of the equivalent pattern that can used within the likes of [Azure App Configuration](https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview). - -
- -### Pre-configured settings - -There are a number of pre-configured settings within the [`SettingsBase`](./SettingsBase.cs) class that are used directly by _CoreEx_; these are intended to be overridden where applicable. Other `CoreEx` projects may add others leveraging extension methods within where applicable. - -
- -## Default settings - -The [`DefaultSettings`](./DefaultSettings.cs) class provides a basic implementation of `SettingsBase` to be used where no additional prefixing is required. This is also used throughout `CoreEx` as a default where a `SettingsBase` is expected, but not provided. - -
- -## Deployment info - -The [`DeploymentInfo`](./DeploymentInfo.cs) class provides a base line configuration for capturing and recording deployment information when the underlying application is deployed. These can then be accessed at run-time when performing the likes of [health checks](../HealthChecks/README.md) to validate the deployed version. - - diff --git a/src/CoreEx/Configuration/SettingsBase.cs b/src/CoreEx/Configuration/SettingsBase.cs deleted file mode 100644 index 00a04511..00000000 --- a/src/CoreEx/Configuration/SettingsBase.cs +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Hosting.Work; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; - -namespace CoreEx.Configuration -{ - /// - /// Provides the base -backed settings; see to further understand capabilities. - /// - public abstract class SettingsBase - { - private readonly List _prefixes = []; - private bool? _validationUseJsonNames; - private DateTimeTransform? _dateTimeTransform; - private StringTransform? _stringTransform; - private StringTrim? _stringTrim; - private StringCase? _stringCase; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The key prefixes to use in order of precedence, first through to last. - public SettingsBase(IConfiguration? configuration, params string[] prefixes) - { - Configuration = configuration; - Deployment = new DeploymentInfo(configuration); - - foreach (var prefix in prefixes) - { - if (string.IsNullOrEmpty(prefix)) - throw new ArgumentException("A prefix cannot be null or empty.", nameof(prefixes)); - - _prefixes.Add(prefix.EndsWith('/') ? prefix : string.Concat(prefix, '/')); - } - } - - /// - /// Gets the underlying . - /// - public IConfiguration? Configuration { get; } - - /// - /// Gets the value using the specified excluding any prefix (key is inferred where not specified using ). - /// - /// The value . - /// The key excluding any prefix (key is inferred where not specified using ). - /// The default fallback value used where no non-default value is found. - /// The corresponding setting value. - /// Where is 'Foo' and the provided prefixes are 'Product' and 'Common', then the following full keys will be attempted until a non-default value is found: - /// 'Product/Foo', 'Common/Foo', 'Foo' (no prefix), then finally the will be returned. - public T GetValue([CallerMemberName] string key = "", T defaultValue = default!) - { - // One-off replace double underscore with colon; enables support for both types. - var ckey = key.ThrowIfNullOrEmpty(nameof(key)).Replace("__", ":"); - - if (Configuration is null) - return defaultValue; - - // Try each prefix until found. - T kv; - foreach (var prefix in _prefixes) - { - if (TryGetValue(string.Concat(prefix, ckey), out kv)) - return kv; - } - - // Final without prefix. - return TryGetValue(ckey, out kv) ? kv : defaultValue; - } - - /// - /// Try get the value with key and alternate format alternatives. - /// - private bool TryGetValue(string key, out T value) - { - // Try the key as specified. - if (Configuration is not null && Configuration.GetSection(key)?.Value != null) - { - value = Configuration.GetValue(key)!; - return true; - } - - value = default!; - return false; - } - - /// - /// Gets the value using the specified excluding any prefix (key is inferred where not specified using ) and throws an where no corresponding - /// value has been configured. - /// - /// The value . - /// The key excluding any prefix (key is inferred where not specified using ). - /// The corresponding setting value. - /// Where is 'Foo' and the provided prefixes are 'Product' and 'Common', then the following full keys will be attempted until a non-default value is found: - /// 'Product/Foo', 'Common/Foo', 'Foo' (no prefix), then finally an will be thrown. - /// Thrown where the has not been configured. - public T GetRequiredValue([CallerMemberName] string key = "") - { - // One-off replace double underscore with colon; enables support for both types. - var ckey = key.ThrowIfNullOrEmpty(nameof(key)).Replace("__", ":"); - - if (Configuration == null) - throw new InvalidOperationException($"An IConfiguration instance is required where {nameof(GetRequiredValue)} is used."); - - // Try each prefix until found. - T kv; - foreach (var prefix in _prefixes) - { - if (TryGetValue(string.Concat(prefix, ckey), out kv)) - return kv; - } - - // Final without prefix. - return TryGetValue(ckey, out kv) ? kv : throw new ArgumentException($"Configuration key '{key}' has not been configured and the value is required.", nameof(key)); - } - - /// - /// Gets the value using the specified excluding any prefix (key is inferred where not specified using ). - /// - /// The value . - /// The key excluding any prefix (key is inferred where not specified using ). - /// The default fallback value used where no non-default value is found. - /// The corresponding setting value. - /// This is considered a standard setting and will be checked within the CoreEx: nested strructure first to enable clear separation. - internal T GetCoreExValue([CallerMemberName] string key = "", T defaultValue = default!) => TryGetValue($"CoreEx:{key.ThrowIfNullOrEmpty(nameof(key))}", out var value) ? value : GetValue(key, defaultValue); - - /// - /// Indicates whether to the include the underlying content in the externally returned result. - /// - /// Defaults to false. - public bool IncludeExceptionInResult => GetCoreExValue(nameof(IncludeExceptionInResult), false); - - /// - /// Gets the default maximum event publish collection size. - /// - /// Defaults to 100. - public int MaxPublishCollSize => GetCoreExValue(nameof(MaxPublishCollSize), 100); - - /// - /// Gets the from the environment variables. - /// - public DeploymentInfo Deployment { get; } - - /// - /// Indicates whether to include any extra Health Check data that might be considered sensitive. - /// - /// Defaults to false. - public bool IncludeSensitiveHealthCheckData => GetCoreExValue(nameof(IncludeSensitiveHealthCheckData), false); - - /// - /// Gets the ; i.e. page size. - /// - /// Defaults to 100. - public long PagingDefaultTake => GetCoreExValue(nameof(PagingDefaultTake), 100); - - /// - /// Gets the ; i.e. absolute maximum page size. - /// - /// Defaults to 1000. - public long PagingMaxTake => GetCoreExValue(nameof(PagingMaxTake), 1000); - - /// - /// Gets the default . - /// - /// Defaults to 2 hours. - public TimeSpan? RefDataCacheAbsoluteExpirationRelativeToNow => GetCoreExValue($"RefDataCache:{nameof(ICacheEntry.AbsoluteExpirationRelativeToNow)}", TimeSpan.FromHours(2)); - - /// - /// Gets the default . - /// - /// Defaults to 30 minutes. - public TimeSpan? RefDataCacheSlidingExpiration => GetCoreExValue($"RefDataCache:{nameof(ICacheEntry.SlidingExpiration)}", TimeSpan.FromMinutes(30)); - - /// - /// Indicates whether the validation (CoreEx.Validation) should use JSON names. - /// - /// Defaults to true. - /// The value is cached on first use and is then considered immutable as a change in behaviour may have unintended consequences. - public bool ValidationUseJsonNames => _validationUseJsonNames ??= GetCoreExValue(nameof(ValidationUseJsonNames), true); - - /// - /// Gets the . - /// - /// Defaults to 1 hour. - public TimeSpan WorkerExpiryTimeSpan => GetCoreExValue(nameof(WorkerExpiryTimeSpan), TimeSpan.FromHours(1)); - - /// - /// Gets the . - /// - /// Defaults to . - /// The value is cached on first use and is then considered immutable as a change in behaviour may have unintended consequences. - public DateTimeTransform DateTimeTransform => _dateTimeTransform ??= GetCoreExValue($"{nameof(Cleaner)}:{nameof(DateTimeTransform)}", Cleaner.DefaultDateTimeTransform); - - /// - /// Gets the . - /// - /// Defaults to . - /// The value is cached on first use and is then considered immutable as a change in behaviour may have unintended consequences. - public StringTransform StringTransform => _stringTransform ??= GetCoreExValue($"{nameof(Cleaner)}:{nameof(StringTransform)}", Cleaner.DefaultStringTransform); - - /// - /// Gets the . - /// - /// Defaults to . - /// The value is cached on first use and is then considered immutable as a change in behaviour may have unintended consequences. - public StringCase StringCase => _stringCase ??= GetCoreExValue($"{nameof(Cleaner)}:{nameof(StringCase)}", Cleaner.DefaultStringCase); - - /// - /// Gets the . - /// - /// Defaults to . - /// The value is cached on first use and is then considered immutable as a change in behaviour may have unintended consequences. - public StringTrim StringTrim => _stringTrim ??= GetCoreExValue($"{nameof(Cleaner)}:{nameof(StringTrim)}", Cleaner.DefaultStringTrim); - } -} \ No newline at end of file diff --git a/src/CoreEx/ConflictException.cs b/src/CoreEx/ConflictException.cs index 9869f9c3..d16ca0ec 100644 --- a/src/CoreEx/ConflictException.cs +++ b/src/CoreEx/ConflictException.cs @@ -1,73 +1,30 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Localization; -using System; -using System.Net; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents an Conflict exception. +/// +/// An example would be where the identifier provided for a create operation already exists. +/// The defaults to: A data conflict occurred. +public class ConflictException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(ConflictException).FullName, _message), innerException) { + private const string _message = "A data conflict occurred."; + /// - /// Represents an Conflict exception. + /// Initializes a new instance of the class. /// - /// An example would be where the identifier provided for a Create operation already exists. - /// The defaults to: A data conflict occurred. - public class ConflictException : Exception, IExtendedException - { - private const string _message = "A data conflict occurred."; - private static bool? _shouldExceptionBeLogged; - - /// - /// Get or sets the value. - /// - public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } - - /// - /// Initializes a new instance of the class. - /// - public ConflictException() : this(null!) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - public ConflictException(string? message) : base(message ?? new LText(typeof(ConflictException).FullName, _message)) { } + public ConflictException() : this(null) { } - /// - /// Initializes a new instance of the class using the specified and . - /// - /// The error message. - /// The inner . - public ConflictException(string? message, Exception innerException) : base(message ?? new LText(typeof(ConflictException).FullName, _message), innerException) { } - - /// - /// - /// - /// The value as a . - public string ErrorType => Abstractions.ErrorType.ConflictError.ToString(); - - /// - /// - /// - /// The value as a . - public int ErrorCode => (int)Abstractions.ErrorType.ConflictError; - - /// - /// - /// - /// The value. - public HttpStatusCode StatusCode => HttpStatusCode.Conflict; - - /// - /// - /// - /// false; is not considered transient. - public bool IsTransient => false; + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public ConflictException(LText? message) : this(message, null) { } - /// - /// - /// - /// The value. - public bool ShouldBeLogged => ShouldExceptionBeLogged; + /// + protected override void OnInitialize() + { + ErrorType = "conflict"; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.Conflict); } } \ No newline at end of file diff --git a/src/CoreEx/CoreEx.csproj b/src/CoreEx/CoreEx.csproj index 9eb1b2d9..4cf72288 100644 --- a/src/CoreEx/CoreEx.csproj +++ b/src/CoreEx/CoreEx.csproj @@ -1,74 +1,12 @@  - - - net6.0;net8.0;net9.0;netstandard2.1 - CoreEx - CoreEx - CoreEx .NET backend Extensions. - CoreEx .NET backend Extensions. - coreex entity microservices referencedata jsonserializer eventdata events httpclient settings railway-oriented - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - diff --git a/src/CoreEx/CoreExExtensions.ApplicationBuilder.cs b/src/CoreEx/CoreExExtensions.ApplicationBuilder.cs new file mode 100644 index 00000000..3b9e126c --- /dev/null +++ b/src/CoreEx/CoreExExtensions.ApplicationBuilder.cs @@ -0,0 +1,137 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Microsoft.Extensions.Hosting; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static class CoreExExtensions +{ + /// + /// Adds a singleton service. + /// + /// The . + /// The solution name; for example: 'Contoso. + /// The domain name; for example: 'Shopping. + /// The source ; for example 'urn:contoso:products'. + /// The for fluent-style method-chaining. + public static IHostApplicationBuilder AddHostSettings(this IHostApplicationBuilder builder, string? solutionName = null, string? domainName = null, Uri? source = null) + { + builder.ThrowIfNull(); + + var env = builder.Configuration.GetValue("CoreEx:Host:EnvironmentName") + ?? builder.Configuration.GetValue("COREEX_ENVIRONMENT") + ?? Environment.GetEnvironmentVariable("COREEX_ENVIRONMENT") + ?? builder.Environment.EnvironmentName; + + var hs = HostSettings.Create(builder.Configuration, env, solutionName, domainName, source); + builder.Properties[nameof(HostSettings)] = hs; + builder.Services.AddSingleton(hs); + return builder; + } + + /// + /// Adds an opinionated typed with idempotency key handler and standard resilience handlers. + /// + /// The typed client. + /// The . + /// The name of the . + /// An optional action to configure the . + /// An optional action to configure the . + /// The for fluent-style method-chaining. + /// The also represents the configuration section name for the ; as a minimum define the . + /// The two handlers added are added, in order specified, as follows: + /// + /// - Adds an idempotency key to outgoing HTTP requests. + /// Standard resilience handlers - Adds standard resilience policies as enabled by Microsoft.Extensions.Http.Resilience. + /// + /// + /// Example configuration section for a named 'ProductsApi': + /// + /// { + /// "ProductsApi": { + /// "BaseAddress": "https://api.contoso.com/", + /// "Resilience": { + /// ... // Resilience configuration as per Microsoft.Extensions.Http.Resilience documentation. + /// } + /// } + /// } + /// + /// + public static IHttpClientBuilder AddTypedHttpClient(this IHostApplicationBuilder builder, string name, Action? configureClient = null, Action? configureIdempotency = null) + where TClient : class + { + var config = builder.Configuration.GetSection(name) ?? throw new ArgumentException($"Unable to find configuration section for '{name}'."); + + var cb = builder.ThrowIfNull().Services.AddHttpClient(name, client => + { + // Set the standard configured setting. + client.BaseAddress = config.GetValue("BaseAddress") ?? throw new ArgumentException($"Unable to find '{nameof(HttpClient.BaseAddress)}' configuration for '{name}'."); + configureClient?.Invoke(client); + }); + + cb.AddIdempotencyKeyHandler((sp, handler) => configureIdempotency?.Invoke(sp, handler)); + + if (config.GetSection("Resilience").Exists()) + cb.AddStandardResilienceHandler(config); + else + cb.AddStandardResilienceHandler(); + + cb.AddTypedClient(); + + return cb; + } + + /// + /// Adds an opinionated typed with idempotency key handler and standard resilience handlers. + /// + /// The typed client. + /// The the typed client implementation. + /// The . + /// The name of the . + /// An optional action to configure the . + /// An optional action to configure the . + /// The for fluent-style method-chaining. + /// The also represents the configuration section name for the ; as a minimum define the . + /// The two handlers added are added, in order specified, as follows: + /// + /// - Adds an idempotency key to outgoing HTTP requests. + /// Standard resilience handlers - Adds standard resilience policies as enabled by Microsoft.Extensions.Http.Resilience. + /// + /// + /// Example configuration section for a named 'ProductsApi': + /// + /// { + /// "ProductsApi": { + /// "BaseAddress": "https://api.contoso.com/", + /// "Resilience": { + /// ... // Resilience configuration as per Microsoft.Extensions.Http.Resilience documentation. + /// } + /// } + /// } + /// + /// + public static IHttpClientBuilder AddTypedHttpClient(this IHostApplicationBuilder builder, string name, Action? configureClient = null, Action? configureIdempotency = null) + where TClient : class where TImplementation : class, TClient + { + var config = builder.Configuration.GetSection(name) ?? throw new ArgumentException($"Unable to find configuration section for '{name}'."); + + var cb = builder.ThrowIfNull().Services.AddHttpClient(name, client => + { + // Set the standard configured setting. + client.BaseAddress = config.GetValue("BaseAddress") ?? throw new ArgumentException($"Unable to find '{nameof(HttpClient.BaseAddress)}' configuration for '{name}'."); + configureClient?.Invoke(client); + }); + + cb.AddIdempotencyKeyHandler((sp, handler) => configureIdempotency?.Invoke(sp, handler)); + + if (config.GetSection("Resilience").Exists()) + cb.AddStandardResilienceHandler(config); + else + cb.AddStandardResilienceHandler(); + + cb.AddTypedClient(); + + return cb; + } +} \ No newline at end of file diff --git a/src/CoreEx/CoreExExtensions.DependencyInjection.cs b/src/CoreEx/CoreExExtensions.DependencyInjection.cs new file mode 100644 index 00000000..2f97b63b --- /dev/null +++ b/src/CoreEx/CoreExExtensions.DependencyInjection.cs @@ -0,0 +1,253 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static partial class CoreExExtensions +{ + /// + /// Adds a scoped service to instantiate a new instance using an . + /// + /// The . + /// The function to override the creation of the instance. + /// The for fluent-style method-chaining. + /// Where the is , then the is used to create. + public static IServiceCollection AddExecutionContext(this IServiceCollection services, Func? executionContextFactory = null) => services.ThrowIfNull().AddScoped(sp => + { + var ec = executionContextFactory?.Invoke(sp) ?? ExecutionContext.Create?.Invoke() ?? + throw new InvalidOperationException("Unable to create 'ExecutionContext' instance; either (in order) 'executionContextFactory' resulted in null, or 'ExecutionContext.Create' resulted in null."); + + ec.ServiceProvider = sp; + + ExecutionContext.Reset(); + ExecutionContext.SetCurrent(ec); + + return ec; + }); + + /// + /// Adds a scoped service to instantiate and a new instance. + /// + /// The . + /// The action to configure the instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddExecutionContext(this IServiceCollection services, Action? configure) => services.ThrowIfNull().AddScoped(sp => + { + var ec = ExecutionContext.Create?.Invoke() ?? + throw new InvalidOperationException("Unable to create 'ExecutionContext' instance; the 'ExecutionContext.Create' resulted in null."); + + ec.ServiceProvider = sp; + + configure?.Invoke(sp, ec); + + ExecutionContext.Reset(); + ExecutionContext.SetCurrent(ec); + + return ec; + }); + + /// + /// Dynamically registers all types within the specified assembly that have a defined as inferred by (using) the specified generic type. + /// + /// The to infer the from. + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddDynamicServicesUsing(this IServiceCollection services) + => services.ThrowIfNull().AddDynamicServicesUsing(typeof(TAssembly1).Assembly); + + /// + /// Dynamically registers all types within the specified assemblies that have a defined as inferred by (using) the specified generic types. + /// + /// The to infer the from. + /// The to infer the from. + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddDynamicServicesUsing(this IServiceCollection services) + => services.ThrowIfNull().AddDynamicServicesUsing(typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly); + + /// + /// Dynamically registers all types within the specified assemblies that have a defined as inferred by (using) the specified generic types. + /// + /// The to infer the from. + /// The to infer the from. + /// The to infer the from. + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddDynamicServicesUsing(this IServiceCollection services) + => services.ThrowIfNull().AddDynamicServicesUsing(typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly, typeof(TAssembly3).Assembly); + + /// + /// Dynamically registers all types within the specified assemblies that have a defined as inferred by (using) the specified generic types. + /// + /// The to infer the from. + /// The to infer the from. + /// The to infer the from. + /// The to infer the from. + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddDynamicServicesUsing(this IServiceCollection services) + => services.ThrowIfNull().AddDynamicServicesUsing(typeof(TAssembly1).Assembly, typeof(TAssembly2).Assembly, typeof(TAssembly3).Assembly, typeof(TAssembly4).Assembly); + + /// + /// Dynamically registers all types within the specified that have a defined . + /// + /// The . + /// The assemblies to probe for all types. + /// The for fluent-style method-chaining. + public static IServiceCollection AddDynamicServicesUsing(this IServiceCollection services, params IEnumerable assemblies) + { + foreach (var assembly in assemblies.Distinct()) + { + foreach (var match in from type in assembly.GetTypes() + where !type.IsAbstract && !type.IsGenericTypeDefinition + let sla = ServiceLifetimeAttribute.GetCustomAttribute(type) + where sla is not null + select new { type, sla }) + { + match.sla?.AddService(services, match.type); + } + } + + return services; + } + + /// + /// Adds a singleton service for the internal . + /// + /// The . + /// The for fluent-style method-chaining. + /// Where not explicitly registered then a static internal will be used. + public static IServiceCollection AddInternalCache(this IServiceCollection services) => services.ThrowIfNull().AddKeyedSingleton(CoreEx.Abstractions.Internal.CacheServiceKey); + + /// + /// Adds a scoped service for the using the . + /// + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddDefaultCacheKeyProvider(this IServiceCollection services) => services.ThrowIfNull().AddScoped(); + + /// + /// Adds a scoped service for the using the . + /// + /// The . + /// The for fluent-style method-chaining. + public static IServiceCollection AddMemoryOnlyHybridCache(this IServiceCollection services) => services.ThrowIfNull().AddScoped(); + + /// + /// Adds the to the HTTP client pipeline which automatically manages the addition of idempotency keys to requests. + /// + /// The . + /// The action to configure the instance. + /// The to support fluent-style method-chaining. + /// See for further details. + public static IHttpClientBuilder AddIdempotencyKeyHandler(this IHttpClientBuilder builder, Action? configure = null) => builder.ThrowIfNull().AddHttpMessageHandler(sp => + { + var handler = new IdempotencyKeyHandler(); + configure?.Invoke(sp, handler); + return handler; + }); + + /// + /// Adds a scoped service. + /// + /// The . + /// The action to configure the instance. + /// Indicates whether to also register as the service. + /// The for fluent-style method-chaining. + public static IServiceCollection AddHybridCacheSynchronizer(this IServiceCollection services, Action? configure = null, bool addAsISynchronizer = true) + { + services.ThrowIfNull().AddScoped(sp => + { + var synchronizer = ActivatorUtilities.CreateInstance(sp); + configure?.Invoke(sp, synchronizer); + return synchronizer; + }); + + if (addAsISynchronizer) + services.AddScoped(sp => sp.GetRequiredService()); + + return services; + } + + /// + /// Registers a post-configuration for all health checks to further configure and add the and tags where not currently defined. + /// + /// The . + /// The function to configure each instance. + /// The for fluent-style method-chaining. + /// The function should return to add the default tags, otherwise, to skip. + public static IServiceCollection PostConfigureAllHealthChecks(this IServiceCollection services, Func? configure = null) + { + return services.ThrowIfNull().PostConfigureAll(options => + { + foreach (var registration in options.Registrations) + { + if (configure?.Invoke(registration) ?? true) + { + registration.Tags.Add(nameof(HealthCheckTags.Startup)); + registration.Tags.Add(nameof(HealthCheckTags.Ready)); + } + } + }); + } + + /// + /// Adds a singleton service. + /// + /// The . + /// The action to configure the instance. + /// The for fluent-style method-chaining. + public static IServiceCollection AddHostedServiceManager(this IServiceCollection services, Action? configure = null) + { + return services.ThrowIfNull().AddSingleton(sp => + { + var hsm = ActivatorUtilities.CreateInstance(sp); + configure?.Invoke(sp, hsm); + return hsm; + }); + } + + /// + /// Adds a singleton keyed service that will be executed as a hosted service (i.e. in the background). + /// + /// The . + /// The keyed singleton and health check key. + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + /// Also automatically adds a health-check for the hosted service. + public static IServiceCollection AddHostedService(this IServiceCollection services, string serviceKey, Action? configure = null) where THostedService : HostedServiceBase + => AddHostedService(services, serviceKey, sp => ActivatorUtilities.CreateInstance(sp, sp.GetRequiredService().CreateLogger()), configure); + + /// + /// Adds a singleton keyed service that will be executed as a hosted service (i.e. in the background). + /// + /// The . + /// The keyed singleton and health check key. + /// The function to create the instance. + /// An optional action to configure the instance. + /// The for fluent-style method-chaining. + /// Also automatically adds a health-check for the hosted service. + public static IServiceCollection AddHostedService(this IServiceCollection services, string serviceKey, Func factory, Action? configure = null) where THostedService : HostedServiceBase + { + factory.ThrowIfNull(); + + // Register health check for the hosted service. + var hc = new HostedServiceHealthCheck(); + services.ThrowIfNull().AddHealthChecks().AddCheck(serviceKey.ThrowIfNullOrEmpty(), hc, tags: HealthCheckTags.StartUpAndReadyOnly); + + // Register the hosted service - this allows access to the instance by the service key where required; need by the HostedServiceManager. + services.AddKeyedSingleton(serviceKey, (sp, _) => + { + var hs = factory(sp); + hs.ServiceName = serviceKey; + hs.HealthCheck = hc; + configure?.Invoke(sp, hs); + return hs; + }); + + // Does not use 'AddHostedService' as this does not allow multiple instances to be registered with the same type; see https://github.com/dotnet/runtime/issues/38751. + return services.AddSingleton(sp => sp.GetRequiredKeyedService(serviceKey)); + } +} \ No newline at end of file diff --git a/src/CoreEx/CoreExExtensions.OpenTelemetry.cs b/src/CoreEx/CoreExExtensions.OpenTelemetry.cs new file mode 100644 index 00000000..a6473d12 --- /dev/null +++ b/src/CoreEx/CoreExExtensions.OpenTelemetry.cs @@ -0,0 +1,38 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace OpenTelemetry.Trace; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static class CoreExExtensions +{ + /// + /// Enables CoreEx OpenTelemetry instrumentation (including ). + /// + /// The . + /// The to support fluent-style method-chaining. + public static OpenTelemetryBuilder WithCoreExTelemetry(this OpenTelemetryBuilder builder) + => builder.ThrowIfNull().ThrowIfNull() + .WithTracing(t => t.AddHttpClientInstrumentation().WithCoreExSources()) + .WithMetrics(m => m.AddHttpClientInstrumentation().AddRuntimeInstrumentation().AddProcessInstrumentation().AddMeter("Polly")); + + /// + /// Enables (adds) the CoreEx-specified OpenTelemetry tracing sources. + /// + /// The . + /// The to support fluent-style method-chaining. + public static TracerProviderBuilder WithCoreExSources(this TracerProviderBuilder builder) => builder.ThrowIfNull() + .AddInvokerAsSource() + .AddInvokerAsSource() + .AddInvokerAsSource(); + + /// + /// Adds the as the using the . + /// + /// The . + /// The . + /// The to support fluent-style method-chaining. + public static TracerProviderBuilder AddInvokerAsSource(this TracerProviderBuilder builder) where TInvoker : InvokerBase + => builder.ThrowIfNull().AddSource(InvokerNameAttribute.GetName()); +} \ No newline at end of file diff --git a/src/CoreEx/Data/DataExtensions.ITotalCount.cs b/src/CoreEx/Data/DataExtensions.ITotalCount.cs new file mode 100644 index 00000000..cd680902 --- /dev/null +++ b/src/CoreEx/Data/DataExtensions.ITotalCount.cs @@ -0,0 +1,90 @@ +namespace CoreEx.Data; + +public static partial class DataExtensions +{ + /// + /// Sets the of the elements in the sequence where is . + /// + /// The element . + /// The . + /// The total count of the elements in the sequence. + /// The to support fluent-style method-chaining. + public static TSource WithTotalCount(this TSource source, long totalCount) where TSource : ITotalCount => source.WithTotalCount(() => totalCount); + + /// + /// Sets the of the elements in the sequence where is . + /// + /// The element . + /// The . + /// The function to determine the total count of the elements in the sequence. + /// The to support fluent-style method-chaining. + /// The function is wrapped in a try/catch, and will swallow and log any exception that occurs versus failing. The in the event of an exception will + /// be set to indicating that the total count is unknown. + public static TSource WithTotalCount(this TSource source, Func totalCount) where TSource : ITotalCount + { + source.ThrowIfNull(); + if (!source.IsCountRequested || totalCount is null) + return source; + + try + { + source.WithTotalCount(totalCount()); + } + catch (Exception ex) + { + WithTotalCountException(ex); + } + + return source; + } + + /// + /// Sets the of the elements in the sequence where is . + /// + /// The element . + /// The . + /// The function to determine the total count of the elements in the sequence. + /// The to support fluent-style method-chaining. + /// The function is wrapped in a try/catch, and will swallow and log any exception that occurs versus failing. The in the event of an exception will + /// be set to indicating that the total count is unknown. + public static async Task WithTotalCountAsync(this TSource source, Func> totalCount) where TSource : ITotalCount + => await WithTotalCountAsync(source, async _ => await totalCount().ConfigureAwait(false), default).ConfigureAwait(false); + + /// + /// Sets the of the elements in the sequence where is . + /// + /// The element . + /// The . + /// The function to determine the total count of the elements in the sequence. + /// The . + /// The to support fluent-style method-chaining. + /// The function is wrapped in a try/catch, and will swallow and log any exception that occurs versus failing. The in the event of an exception will + /// be set to indicating that the total count is unknown. + public async static Task WithTotalCountAsync(this TSource source, Func> totalCount, CancellationToken cancellationToken = default) where TSource : ITotalCount + { + source.ThrowIfNull(); + if (!source.IsCountRequested || totalCount is null) + return source; + + try + { + source.WithTotalCount(await totalCount(cancellationToken).ConfigureAwait(false)); + } + catch (Exception ex) + { + WithTotalCountException(ex); + } + + return source; + } + + /// + /// Common total count exception handling; i.e. logging the exception as a warning where possible. + /// + private static void WithTotalCountException(Exception ex) + { + var logger = ExecutionContext.GetService()?.CreateLogger(typeof(ITotalCount)); + if (logger is not null && logger.IsEnabled(LogLevel.Warning)) + logger.LogWarning(ex, "Unable to determine the total count of the elements in the sequence; the total count will not be returned as a result: {Message}.", ex.Message); + } +} \ No newline at end of file diff --git a/src/CoreEx/Data/DataExtensions.Where.cs b/src/CoreEx/Data/DataExtensions.Where.cs new file mode 100644 index 00000000..8dd934d6 --- /dev/null +++ b/src/CoreEx/Data/DataExtensions.Where.cs @@ -0,0 +1,93 @@ +namespace CoreEx.Data; + +public static partial class DataExtensions +{ + /// + /// Filters a sequence of values based on a only . + /// + /// The element . + /// The sequence of elements. + /// Indicates to perform an underlying only when ; otherwise, no Where is invoked. + /// A function to test each element for a condition. + /// An that contains elements from the input sequence that satisfy the condition. + public static IQueryable WhereWhen(this IQueryable source, bool when, Expression> predicate) => when ? source.Where(predicate) : source; + + /// + /// Filters a sequence based on a only when the is not the default value for the . + /// + /// The element . + /// The with value . + /// The sequence of elements. + /// Indicates to perform an underlying only when the with is not the default value; otherwise, no Where is invoked. + /// A function to test each element for a condition. + /// An that contains elements from the input sequence that satisfy the condition. + /// Where the is an it will also ensure there is at least a single item. + public static IQueryable WhereWith(this IQueryable source, TWith with, Expression> predicate) + { + if (Comparer.Default.Compare(with, default!) != 0) + { + if (with is not string && with is IEnumerable ie && !ie.GetEnumerator().MoveNext()) + return source; + + return source.Where(predicate); + } + + return source; + } + + /// + /// Filters a sequence using the specified and containing supported wildcards. + /// + /// The element . + /// The sequence of elements. + /// The function to select the element value to match. + /// The pattern to match the result of each element . + /// Indicates whether the comparison should ignore case (default) or not. + /// Indicates whether a check should also be performed before the comparion occurs (defaults to ). + /// The configuration to use; where it will use . + /// An that contains the elements from the sequence after applying the wildcard . + public static IQueryable WhereWildcard(this IQueryable source, Expression> selector, string? pattern, bool ignoreCase = true, bool checkForNull = true, Wildcard? wildcard = null) where TSource : class + { + selector.ThrowIfNull(); + + var wc = wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; + var wr = wc.Parse(pattern).ThrowOnError(); + + // Exit stage left where nothing to do. + if (wr.Selection.HasFlag(WildcardSelection.None) || wr.Selection.HasFlag(WildcardSelection.Single)) + return source; + + // Check the expression. + if (selector.Body is not MemberExpression me) + throw new ArgumentException("Selector expression must be of Type MemberExpression.", nameof(selector)); + + Expression exp = me; + var s = wr.GetTextWithoutWildcards(); + if (ignoreCase) + { + s = s?.ToUpper(CultureInfo.CurrentCulture); + exp = Expression.Call(me, typeof(string).GetMethod("ToUpper", Type.EmptyTypes)!)!; + } + + if (wr.Selection.HasFlag(WildcardSelection.Equal)) + exp = Expression.Equal(exp, Expression.Constant(s)); + else if (wr.Selection.HasFlag(WildcardSelection.EndsWith)) + exp = Expression.Call(exp, "EndsWith", null, Expression.Constant(s)); + else if (wr.Selection.HasFlag(WildcardSelection.StartsWith)) + exp = Expression.Call(exp, "StartsWith", null, Expression.Constant(s)); + else if (wr.Selection.HasFlag(WildcardSelection.Contains)) + exp = Expression.Call(exp, "Contains", null, Expression.Constant(s)); + else + throw new ArgumentException("Wildcard selection pattern is not supported for an IQueryable; must result in an Equal, StartsWith, EndsWith or Contains only.", nameof(pattern)); + + // Add check for not null. + if (checkForNull) + { + var ee = Expression.NotEqual(me, Expression.Constant(null)); + exp = Expression.AndAlso(ee, exp); + } + + // Create the final lambda expression. + return source.Where(Expression.Lambda>(exp, selector.Parameters)); + } +} \ No newline at end of file diff --git a/src/CoreEx/Data/DataExtensions.With.cs b/src/CoreEx/Data/DataExtensions.With.cs new file mode 100644 index 00000000..eb55222b --- /dev/null +++ b/src/CoreEx/Data/DataExtensions.With.cs @@ -0,0 +1,21 @@ +namespace CoreEx.Data; + +public static partial class DataExtensions +{ + /// + /// Filters a sequence according to the specified or . + /// + /// The element . + /// The sequence of elements. + /// The . + /// An that contains the elements from the sequence after applying the . + /// The where will default to . + public static IQueryable WithPaging(this IQueryable source, PagingArgs? paging = null) + { + if (paging?.IsNone ?? false) + return source; + + paging ??= PagingArgs.Create(); + return source.Skip(paging.Skip).Take(paging.Take); + } +} \ No newline at end of file diff --git a/src/CoreEx/Data/DataExtensions.cs b/src/CoreEx/Data/DataExtensions.cs new file mode 100644 index 00000000..ae27e2c2 --- /dev/null +++ b/src/CoreEx/Data/DataExtensions.cs @@ -0,0 +1,6 @@ +namespace CoreEx.Data; + +/// +/// Provides standard data extensions. +/// +public static partial class DataExtensions { } diff --git a/src/CoreEx/Data/IItemsResult.cs b/src/CoreEx/Data/IItemsResult.cs new file mode 100644 index 00000000..f14306e9 --- /dev/null +++ b/src/CoreEx/Data/IItemsResult.cs @@ -0,0 +1,34 @@ +namespace CoreEx.Data; + +/// +/// Enables the and for a collection result. +/// +/// Generally an is not intended for serialized ; the underlying is serialized with the returned as . +public interface IItemsResult +{ + /// + /// Gets the underlying item . + /// + Type ItemType { get; } + + /// + /// Gets the underlying . + /// + IEnumerable? Items { get; } + + /// + /// Gets the . + /// + PagingResult? Paging { get; } + + /// + /// Indicates whether there are one or more . + /// + bool ItemsHasAny(); + + /// + /// Gets the count of the . + /// + /// The count. + int GetItemsCount(); +} \ No newline at end of file diff --git a/src/CoreEx/Data/IItemsResultT.cs b/src/CoreEx/Data/IItemsResultT.cs new file mode 100644 index 00000000..076f4718 --- /dev/null +++ b/src/CoreEx/Data/IItemsResultT.cs @@ -0,0 +1,27 @@ +namespace CoreEx.Data; + +/// +/// Enables the typed (for a ). +/// +/// The underlying item . +/// Generally an is not intended to be serialized and returned as content; +/// the underlying should be serialized with the returned as . +public interface IItemsResult : IItemsResult +{ + /// + Type IItemsResult.ItemType => typeof(TItem); + + /// + IEnumerable? IItemsResult.Items => Items; + + /// + /// Gets or sets the underlying collection. + /// + new IEnumerable? Items { get; } + + /// + bool IItemsResult.ItemsHasAny() => Items?.Any() ?? false; + + /// + int IItemsResult.GetItemsCount() => Items is null ? 0 : Items.Count(); +} \ No newline at end of file diff --git a/src/CoreEx/Data/ILogicallyDeleted.cs b/src/CoreEx/Data/ILogicallyDeleted.cs new file mode 100644 index 00000000..869ab3af --- /dev/null +++ b/src/CoreEx/Data/ILogicallyDeleted.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Data; + +/// +/// Enables a mutable logical data model state. +/// +public interface ILogicallyDeleted : IReadOnlyLogicallyDeleted +{ + /// + bool IReadOnlyLogicallyDeleted.IsDeleted => IsDeleted; + + /// + /// Indicates whether the data model is considered logically deleted. + /// + new bool IsDeleted { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx/Data/IPartitionKey.cs b/src/CoreEx/Data/IPartitionKey.cs new file mode 100644 index 00000000..5e1b1758 --- /dev/null +++ b/src/CoreEx/Data/IPartitionKey.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Data; + +/// +/// Enables a mutable . +/// +public interface IPartitionKey : IReadOnlyPartitionKey +{ + /// + string? IReadOnlyPartitionKey.PartitionKey => PartitionKey; + + /// + /// Gets or sets the partition key. + /// + new string? PartitionKey { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx/Data/IPrimaryKey.cs b/src/CoreEx/Data/IPrimaryKey.cs new file mode 100644 index 00000000..34eeafb0 --- /dev/null +++ b/src/CoreEx/Data/IPrimaryKey.cs @@ -0,0 +1,17 @@ +namespace CoreEx.Data; + +/// +/// Enables the read-only . +/// +public interface IPrimaryKey : IEntityKey +{ + /// + /// Gets the primary key (represented as a ). + /// + [JsonIgnore] + CompositeKey PrimaryKey { get; } + + /// + [JsonIgnore] + CompositeKey IEntityKey.EntityKey => PrimaryKey; +} \ No newline at end of file diff --git a/src/CoreEx/Data/IReadOnlyLogicallyDeleted.cs b/src/CoreEx/Data/IReadOnlyLogicallyDeleted.cs new file mode 100644 index 00000000..26669952 --- /dev/null +++ b/src/CoreEx/Data/IReadOnlyLogicallyDeleted.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Data; + +/// +/// Enables a read-only logical data model state. +/// +public interface IReadOnlyLogicallyDeleted +{ + /// + /// Indicates whether the data model is considered logically deleted. + /// + bool IsDeleted { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Data/IReadOnlyPartitionKey.cs b/src/CoreEx/Data/IReadOnlyPartitionKey.cs new file mode 100644 index 00000000..78601656 --- /dev/null +++ b/src/CoreEx/Data/IReadOnlyPartitionKey.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Data; + +/// +/// Enables a read-only . +/// +public interface IReadOnlyPartitionKey +{ + /// + /// Gets the partition key. + /// + string? PartitionKey { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Data/IReadOnlyTenantId.cs b/src/CoreEx/Data/IReadOnlyTenantId.cs new file mode 100644 index 00000000..c96cf447 --- /dev/null +++ b/src/CoreEx/Data/IReadOnlyTenantId.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Data; + +/// +/// Enables a read-only . +/// +public interface IReadOnlyTenantId +{ + /// + /// Gets the tenant identifier. + /// + string? TenantId { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Data/IReadOnlyTypeDiscriminator.cs b/src/CoreEx/Data/IReadOnlyTypeDiscriminator.cs new file mode 100644 index 00000000..ee41a7ab --- /dev/null +++ b/src/CoreEx/Data/IReadOnlyTypeDiscriminator.cs @@ -0,0 +1,13 @@ +namespace CoreEx.Data; + +/// +/// Enables a read-only to identify the underlying type of the data model. +/// +public interface IReadOnlyTypeDiscriminator +{ + /// + /// Gets the type discriminator name. + /// + /// This defaults to the corresponding ; otherwise, the underlying . + string? TypeDiscriminator { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Data/ITenantId.cs b/src/CoreEx/Data/ITenantId.cs new file mode 100644 index 00000000..4450de12 --- /dev/null +++ b/src/CoreEx/Data/ITenantId.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Data; + +/// +/// Enables a mutable . +/// +public interface ITenantId : IReadOnlyTenantId +{ + /// + string? IReadOnlyTenantId.TenantId => TenantId; + + /// + /// Gets the tenant identifier. + /// + new string? TenantId { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx/Data/ITotalCount.cs b/src/CoreEx/Data/ITotalCount.cs new file mode 100644 index 00000000..4eacddd1 --- /dev/null +++ b/src/CoreEx/Data/ITotalCount.cs @@ -0,0 +1,28 @@ +namespace CoreEx.Data; + +/// +/// Enables the total count capabilities for a sequence. +/// +public interface ITotalCount +{ + /// + /// Indicates whether to get the total count (see ) when performing the underlying query (defaults to ). + /// + /// This may result in a secondary query and therefore impact overall performance; this should be used judiciously. + /// There are no guarantees that a count will be performed, as this will depend on the underlying implementation; hence, it is simply a request. + public bool IsCountRequested { get; } + + /// + /// Gets the total count of the elements in the sequence. + /// + /// A value indicates that the total count is unknown. + public long? TotalCount { get; } + + /// + /// Sets the total count of the elements in the sequence. + /// + /// The total count of the elements in the sequence. + /// A or negative value indicates that the total count is unknown. + /// The is only set when is . + void WithTotalCount(long? totalCount); +} \ No newline at end of file diff --git a/src/CoreEx/Data/ITypeDiscriminator.cs b/src/CoreEx/Data/ITypeDiscriminator.cs new file mode 100644 index 00000000..9c0b4bd8 --- /dev/null +++ b/src/CoreEx/Data/ITypeDiscriminator.cs @@ -0,0 +1,16 @@ +namespace CoreEx.Data; + +/// +/// Enables a mutable to identify the underlying type of the data model. +/// +public interface ITypeDiscriminator : IReadOnlyTypeDiscriminator +{ + /// + string? IReadOnlyTypeDiscriminator.TypeDiscriminator => TypeDiscriminator; + + /// + /// Gets or sets the type discriminator name. + /// + /// This defaults to the corresponding ; otherwise, the underlying . + new string? TypeDiscriminator { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx/Data/ItemsResultT.cs b/src/CoreEx/Data/ItemsResultT.cs new file mode 100644 index 00000000..7eb70532 --- /dev/null +++ b/src/CoreEx/Data/ItemsResultT.cs @@ -0,0 +1,53 @@ +namespace CoreEx.Data; + +/// +/// Provides an that supports and corresponding . +/// +/// The underlying entity . +/// Generally an is not intended to be serialized and returned as content; +/// the underlying should be serialized with the returned as . +/// Use to specify that no paging was requested/applied. +public sealed record class ItemsResult : IItemsResult, ITotalCount +{ + /// + /// Initializes a new instance of the class defaulting the . + /// + public ItemsResult() : this(new PagingArgs()) { } + + /// + /// Initializes a new instance of the class with (defaults where ). + /// + /// Defaults the to the specified . + public ItemsResult(PagingArgs? paging) + { + if (paging is null || !paging.IsNone) + Paging = new PagingResult(paging); + } + + /// + /// Initializes a new instance of the class with the specified and optional . + /// + /// The initial items. + /// Defaults the to the requesting . + public ItemsResult(IEnumerable? items, PagingArgs? paging = null) : this(paging ?? PagingArgs.None) => Items = items; + + /// + public IEnumerable? Items { get; set => field = field is null ? value : throw new InvalidOperationException($"{nameof(Items)} can only be set once."); } + + /// + public PagingResult? Paging { get; private set; } + + /// + /// Indicates whether the total count has been requested. + /// + private bool IsCountRequested => Paging is not null && Paging.IsCountRequested; + + /// + bool ITotalCount.IsCountRequested => IsCountRequested; + + /// + long? ITotalCount.TotalCount => IsCountRequested ? Paging?.TotalCount : null; + + /// + void ITotalCount.WithTotalCount(long? totalCount) => Paging.AdjustWhen(_ => IsCountRequested, p => p.WithTotalCount(totalCount)); +} \ No newline at end of file diff --git a/src/CoreEx/Data/Model.cs b/src/CoreEx/Data/Model.cs new file mode 100644 index 00000000..efd10181 --- /dev/null +++ b/src/CoreEx/Data/Model.cs @@ -0,0 +1,143 @@ +namespace CoreEx.Data; + +/// +/// Provides utility capabilities for models. +/// +public static class Model +{ + /// + /// Prepares the model for Create by setting (overriding) the , , and . + /// + /// The model . + /// The model. + /// The optional . + /// The . + /// Invokes the following: + /// + /// . + /// . + /// . + /// + /// + [return: NotNullIfNotNull(nameof(model))] + public static TModel? PrepareCreate(TModel? model, ExecutionContext? executionContext = null) + { + if (executionContext is null) + ExecutionContext.TryGetCurrent(out executionContext); + + return PrepareCreateChangeLog(PrepareTypeDiscriminator(PrepareTenantId(model, executionContext)), executionContext); + } + + /// + /// Prepares the model for Update by setting (overriding) the , , and . + /// + /// The model . + /// The model. + /// The optional . + /// The . + /// Invokes the following: + /// + /// . + /// . + /// . + /// + /// + [return: NotNullIfNotNull(nameof(model))] + public static TModel? PrepareUpdate(TModel? model, ExecutionContext? executionContext = null) + { + if (executionContext is null) + ExecutionContext.TryGetCurrent(out executionContext); + + return PrepareUpdateChangeLog(PrepareTypeDiscriminator(PrepareTenantId(model, executionContext)), executionContext); + } + + /// + /// Prepares the by setting (overriding) with the . + /// + /// The model . + /// The model. + /// The optional . + /// The . + [return: NotNullIfNotNull(nameof(model))] + public static TModel? PrepareTenantId(TModel? model, ExecutionContext? executionContext = null) + { + if (model is null || model is not ITenantId ti) + return model; + + if (executionContext is null) + ExecutionContext.TryGetCurrent(out executionContext); + + ti.TenantId = executionContext?.TenantId; + return model; + } + + /// + /// Prepares the by setting (overriding) with the specified or, where not specified, the . + /// + /// The model . + /// The model. + /// The optional type discriminator override. + /// The . + [return: NotNullIfNotNull(nameof(model))] + public static TModel? PrepareTypeDiscriminator(TModel? model, string? typeDiscriminator = null) + { + if (model is null || model is not ITypeDiscriminator td) + return model; + + if (string.IsNullOrEmpty(typeDiscriminator) && Schema.TryGetMetadata(out var metadata)) + typeDiscriminator = metadata.Name; + + td.TypeDiscriminator = typeDiscriminator; + return model; + } + + /// + /// Prepares the or for Create by setting (overriding) the and . + /// + /// The model . + /// The model. + /// The optional . + /// The . + [return: NotNullIfNotNull(nameof(model))] + public static TModel? PrepareCreateChangeLog(TModel? model, ExecutionContext? executionContext = null) + { + if (model is null) + return model; + + if (model is IChangeLog changeLog) + changeLog.ChangeLog = ChangeLog.CreateCreated(executionContext); + else if (model is IChangeLogEx changeLogEx) + { + var (UserName, Timestamp) = ChangeLog.GetChangeLogInfo(executionContext); + changeLogEx.CreatedBy = UserName; + changeLogEx.CreatedOn = Timestamp; + } + + return model; + } + + /// + /// Prepares the or for Update by setting (overriding) the and . + /// + /// The model . + /// The model. + /// The optional . + /// The . + [return: NotNullIfNotNull(nameof(model))] + public static TModel? PrepareUpdateChangeLog(TModel? model, ExecutionContext? executionContext = null) + { + if (model is null) + return model; + + if (model is IChangeLog changeLog) + changeLog.ChangeLog = ChangeLog.CreateChanged(changeLog.ChangeLog, executionContext); + else if (model is IChangeLogEx changeLogEx) + { + var (UserName, Timestamp) = ChangeLog.GetChangeLogInfo(executionContext); + changeLogEx.UpdatedBy = UserName; + changeLogEx.UpdatedOn = Timestamp; + } + + return model; + } +} \ No newline at end of file diff --git a/src/CoreEx/Data/PagingArgs.cs b/src/CoreEx/Data/PagingArgs.cs new file mode 100644 index 00000000..85897af4 --- /dev/null +++ b/src/CoreEx/Data/PagingArgs.cs @@ -0,0 +1,91 @@ +namespace CoreEx.Data; + +/// +/// Represents position-based paging arguments; specifically and . +/// +public record class PagingArgs +{ + private static int? _defaultTake; + private static int? _maximumTake; + + /// + /// Creates a new default with no . + /// + /// The specified number of elements in a sequence to bypass. + /// The specified number of contiguous elements from the start of a sequence. + /// Indicates whether to get the total count (see ) when performing the underlying query. + public static PagingArgs Create(int skip = 0, int? take = null, bool count = false) => new(skip, take, count); + + /// + /// Creates a new default with . + /// + /// The specified number of elements in a sequence to bypass. + /// The specified number of contiguous elements from the start of a sequence. + public static PagingArgs CreateWithCount(int skip = 0, int? take = null) => new(skip, take, count: true); + + /// + /// Gets or sets the default . + /// + /// Defaults to settings 'CoreEx:Data:PagingArgs:DefaultTake'; otherwise, 25. + public static int DefaultTake + { + get => _defaultTake ?? Internal.GetConfigurationValue("CoreEx:Data:PagingArgs:DefaultTake", 25); + set => _defaultTake = value < 1 ? null : value; + } + + /// + /// Gets or sets the maximum . + /// + /// Defaults to settings 'CoreEx:Data:PagingArgs:MaximumTake'; otherwise, 1000. + public static int MaximumTake + { + get => _maximumTake ?? Internal.GetConfigurationValue("CoreEx:Data:PagingArgs:MaximumTake", 1000); + set => _maximumTake = value < 1 ? null : value; + } + + /// + /// Represents a that will explicitly not be applied. + /// + /// This instance is immutable. + public static PagingArgs None { get; } = new() { IsNone = true }; + + /// + /// Initializes a new instance of the class. + /// + /// The specified number of elements in a sequence to bypass. + /// The specified number of contiguous elements from the start of a sequence. + /// Indicates whether to get the total count (see ) when performing the underlying query. + public PagingArgs(int skip = 0, int? take = null, bool count = false) + { + Skip = skip; + Take = take ?? DefaultTake; + IsCountRequested = count; + } + + /// + /// Gets the specified number of elements in a sequence to bypass. + /// + public int Skip { get => field; init => CheckImmutable(field = value < 0 ? 0 : value); } + + /// + /// Gets the specified number of contiguous elements from the start of a sequence. + /// + public int Take { get => field; init => field = CheckImmutable(value <= 0 ? DefaultTake : Math.Min(MaximumTake, value)); } + + /// + /// Indicates whether to get the total count (see ) when performing the underlying query (defaults to ). + /// + /// This may result in a secondary query and therefore impact overall performance; this should be used judiciously. + /// There are no guarantees that a count will be performed, as this will depend on the underlying implementation; hence, it is simply a request. + public bool IsCountRequested { get; init => field = CheckImmutable(value); } + + /// + /// Immutability value check for and properties where . + /// + private T CheckImmutable(T value) => IsNone ? throw new InvalidOperationException($"The {nameof(PagingArgs)} cannot be mutated when {nameof(IsNone)} is true.") : value; + + /// + /// Indicates whether the is the instance. + /// + public bool IsNone { get; private init; } +} \ No newline at end of file diff --git a/src/CoreEx/Data/PagingResult.cs b/src/CoreEx/Data/PagingResult.cs new file mode 100644 index 00000000..d2a2d02b --- /dev/null +++ b/src/CoreEx/Data/PagingResult.cs @@ -0,0 +1,35 @@ +namespace CoreEx.Data; + +/// +/// Represents the resulting paging response including . +/// +public record class PagingResult : PagingArgs, ITotalCount +{ + /// + /// Initializes a new instance of the class with the specified . + /// + /// The . + public PagingResult(PagingArgs? paging = null) + { + Skip = paging?.Skip ?? 0; + Take = paging?.Take ?? DefaultTake; + IsCountRequested = paging?.IsCountRequested ?? false; + } + + /// + public long? TotalCount { get; private set; } + + /// + void ITotalCount.WithTotalCount(long? totalCount) => WithTotalCount(totalCount); + + /// + /// Sets the of the elements in the sequence. + /// + /// The total count of the elements in the sequence. + /// The to support fluent-style method-chaining. + public PagingResult WithTotalCount(long? totalCount) + { + TotalCount = totalCount is null || totalCount.Value < 0 ? null : totalCount.Value; + return this; + } +} \ No newline at end of file diff --git a/src/CoreEx/Data/PartitionKey.cs b/src/CoreEx/Data/PartitionKey.cs new file mode 100644 index 00000000..91df6e66 --- /dev/null +++ b/src/CoreEx/Data/PartitionKey.cs @@ -0,0 +1,79 @@ +namespace CoreEx.Data; + +/// +/// Provides the capabilities. +/// +public class PartitionKey +{ + /// + /// Gets the default partition size. + /// + public const int DefaultPartitionSize = 4; + + /// + /// Gets the maximum byte count for stack allocation when converting strings to UTF-8. + /// + private const int MaxStackAllocByteCount = 256; + + /// + /// Gets the hash-based partition identifier/number for the specified based on the planned . + /// + /// The deterministic partition key. + /// The partition size (i.e the number of possible partitions). + /// Indicates whether to ignore case. + /// The resulting partition identifier/number. + /// The must be a universal, deterministic, and culture-independent ; where in doubt use which will enable. + public static int GetPartitionId(string partitionKey, int partitionSize = DefaultPartitionSize, bool ignoreCase = true) + { + partitionKey = partitionKey.ThrowIfNull().Trim().ThrowIfNullOrEmpty().ThrowWhen(pk => pk.Length > 256, nameof(partitionKey)); + partitionSize.ThrowWhen(ps => ps <= 0 || ps > 256, nameof(partitionSize)); + + static int GenerateIdFromHash(ReadOnlySpan bytes, int partitionSize) + { + Span hash = stackalloc byte[32]; + SHA256.HashData(bytes, hash); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(hash); + + return (partitionSize & (partitionSize - 1)) == 0 + ? (int)(value & (uint)(partitionSize - 1)) + : (int)(value % (uint)partitionSize); + } + + // Faster path for GUIDs: hash the 16-byte representation directly. + if (Guid.TryParse(partitionKey, out var guid)) + { + Span guidBytes = stackalloc byte[16]; + guid.TryWriteBytes(guidBytes); + return GenerateIdFromHash(guidBytes, partitionSize); + } + + // Slower path for strings as casing and encoding is an additional step. + var normalized = ignoreCase ? partitionKey.ToUpperInvariant() : partitionKey; + var byteCount = Encoding.UTF8.GetByteCount(normalized); + Span utf8 = byteCount <= MaxStackAllocByteCount ? stackalloc byte[byteCount] : new byte[byteCount]; + Encoding.UTF8.GetBytes(normalized.AsSpan(), utf8); + return GenerateIdFromHash(utf8, partitionSize); + } + + /// + /// Gets the hash-based partition identifier/number for the specified based on the planned formatted as a string with leading zeros where appropriate. + /// + /// The deterministic partition key. + /// The partition size (i.e the number of possible partitions). + /// Indicates whether to ignore case. + /// The resulting partition identifier/number. + /// The must be a universal, deterministic, and culture-independent ; where in doubt use which will enable. + public static string GetPartitionIdAsString(string partitionKey, int partitionSize = DefaultPartitionSize, bool ignoreCase = true) + { + var id = GetPartitionId(partitionKey, partitionSize, ignoreCase); + var maxId = partitionSize - 1; + var digits = maxId switch + { + < 10 => 1, + < 100 => 2, + _ => 3 + }; + + return id.ToString($"D{digits}", CultureInfo.InvariantCulture); + } +} \ No newline at end of file diff --git a/src/CoreEx/Data/PartitionPicker.cs b/src/CoreEx/Data/PartitionPicker.cs new file mode 100644 index 00000000..6a108314 --- /dev/null +++ b/src/CoreEx/Data/PartitionPicker.cs @@ -0,0 +1,191 @@ +namespace CoreEx.Data; + +/// +/// Determines which partitions a worker should probe on a given polling iteration. +/// +/// +/// Design goals: +/// +/// No sticky ownership of partitions (workers can come and go freely). +/// Deterministic distribution across workers (reduces repository contention / herd effects). +/// Temporal locality: if a partition just produced work, try it again first. +/// Stable behavior across OS/arch. +/// +/// Note: this class is not thread-safe for concurrent use by multiple workers; create one instance per worker task (to be used for its lifetime). +/// +public sealed class PartitionPicker +{ + /* To be clear up-front; this has been largely developed with assistance from AI - suggest using it to explain where applicable :-) */ + + private readonly Guid _workerId = Guid.NewGuid(); + private readonly int _partitionSize; + private readonly int _perWorkerPartitionCount; + private readonly int _rotationSeconds; + private readonly int _epochSkew; + private int _prioritizedPartition = -1; + + /// + /// Initializes a new instance of the class. + /// + /// The total number of partitions available for distribution. + /// The number of partitions assigned to each worker (is essentially the probe count per polling loop). + /// The time interval, in seconds, for rotating partition assignments. + public PartitionPicker(int partitionSize, int perWorkerPartitionCount, int rotationSeconds = 5) + { + _partitionSize = partitionSize.ThrowWhen(_partitionSize => _partitionSize <= 0); + _perWorkerPartitionCount = perWorkerPartitionCount.ThrowWhen(perWorkerPartitionCount => perWorkerPartitionCount <= 0 || perWorkerPartitionCount > partitionSize); + _rotationSeconds = rotationSeconds.ThrowWhen(rotationSeconds => rotationSeconds <= 0); + + // Derive a small deterministic skew from worker-id so epoch boundaries do not cause synchronized shifts. + static uint Hash32(byte[] bytes) + { + var hash = SHA256.HashData(bytes); + return (uint)(hash[0] | (hash[1] << 8) | (hash[2] << 16) | (hash[3] << 24)); + } + + _epochSkew = (int)(Hash32(_workerId.ToByteArray()) % 3); + } + + /// + /// Gets the total number of partitions available for distribution. + /// + public int PartitionSize => _partitionSize; + + /// + /// Gets the number of partitions assigned to each worker. + /// + public int PerWorkerPartitionCount => _perWorkerPartitionCount; + + /// + /// Gets the time interval, in seconds, for rotating partition assignments. + /// + public int RotationSeconds => _rotationSeconds; + + /// + /// Gets an ordered set of partitions to probe during the next poll loop. + /// + public int[] GetNextPartitions(DateTimeOffset utcNow) + { + long epoch = utcNow.ToUnixTimeSeconds() / _rotationSeconds; + + // Apply worker-specific skew to reduce boundary synchronization. + epoch += _epochSkew; + + // Fast path: probe all partitions: useful in local dev when running a single worker and you want full sweep. + if (_perWorkerPartitionCount == _partitionSize) + { + var all = new int[_partitionSize]; + int ls = System.Threading.Volatile.Read(ref _prioritizedPartition); + int idx = 0; + + // Drain last-success first (temporal locality) + if ((uint)ls < (uint)_partitionSize) + all[idx++] = ls; + + for (int p = 0; p < _partitionSize; p++) + { + if (p == ls) continue; + all[idx++] = p; + } + + return all; + } + + // Fast path: only one partition per loop. Minimizes repository chatter but may slow discovery of work. + if (_perWorkerPartitionCount == 1) + { + int ls = System.Threading.Volatile.Read(ref _prioritizedPartition); + if ((uint)ls < (uint)_partitionSize) + return [ls]; + + int start = (int)(Hash32(_workerId, epoch, salt: 1) % (uint)_partitionSize); + return [start]; + } + + // Deterministic start position for this worker + epoch. + int startPos = (int)(Hash32(_workerId, epoch, salt: 1) % (uint)_partitionSize); + + // Deterministic stride ensures good coverage of partitions; for power-of-two totals (32), stride must be odd. + int strideCandidate = (int)(Hash32(_workerId, epoch, salt: 2) % (uint)(_partitionSize - 1)) + 1; + int stride = EnsureCoprimeStride(_partitionSize, strideCandidate); + + var result = new List(_perWorkerPartitionCount + 1); + var seen = new HashSet(_perWorkerPartitionCount + 1); + + // Prefer last successful partition first. + int last = System.Threading.Volatile.Read(ref _prioritizedPartition); + if ((uint)last < (uint)_partitionSize && seen.Add(last)) + result.Add(last); + + // Walk partitions in (start + i*stride) mod total; long arithmetic avoids overflow. + for (int i = 0; result.Count < _perWorkerPartitionCount; i++) + { + int p = (int)(((long)startPos + ((long)i * stride)) % _partitionSize); + + if (seen.Add(p)) + result.Add(p); + } + + return [.. result]; + } + + /// + /// Hashes worker-id + epoch + salt into a stable 32-bit value to avoid runtime-dependent GetHashCode behavior. + /// + private static uint Hash32(Guid workerId, long epoch, int salt) + { + Span buffer = stackalloc byte[16 + sizeof(long) + sizeof(int)]; + + workerId.TryWriteBytes(buffer); + BitConverter.TryWriteBytes(buffer[16..], epoch); + BitConverter.TryWriteBytes(buffer[(16 + sizeof(long))..], salt); + + var hash = SHA256.HashData(buffer); + return (uint)(hash[0] | (hash[1] << 8) | (hash[2] << 16) | (hash[3] << 24)); + } + + /// + /// Ensures stride is coprime with total. For power-of-two totals (e.g., 32), coprime means odd. + /// + private static int EnsureCoprimeStride(int total, int candidate) + { + // Computes the greatest common divisor (GCD) of two integers using the classic Euclidean algorithm. + static int Gcd(int a, int b) + { + while (b != 0) + { + int t = a % b; + a = b; + b = t; + } + + return Math.Abs(a); + } + + if ((total & (total - 1)) == 0) + { + candidate |= 1; + if (candidate >= total) candidate -= 2; + if (candidate <= 0) candidate = 1; + return candidate; + } + + while (Gcd(candidate, total) != 1) + { + candidate++; + if (candidate >= total) candidate = 1; + } + return candidate; + } + + /// + /// Prioritizes the specified partition for the next pick, by virtue of it being the most likely to have immediate work given recent successful processing. + /// + /// This should only be called where work was completed successfully for the specified and that there is a high-likelihood of immediate work for that partition. + /// This is a hint and not a guarantee that the partition will be picked first next time, but it will increase the likelihood. + public void PrioritizePartition(int partitionId) + { + if ((uint)partitionId < (uint)_partitionSize) + System.Threading.Volatile.Write(ref _prioritizedPartition, partitionId); + } +} \ No newline at end of file diff --git a/src/CoreEx/Data/QueryArgs.cs b/src/CoreEx/Data/QueryArgs.cs new file mode 100644 index 00000000..17dc5919 --- /dev/null +++ b/src/CoreEx/Data/QueryArgs.cs @@ -0,0 +1,91 @@ +namespace CoreEx.Data; + +/// +/// Represents basic dynamic query arguments. +/// +/// This is not intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to filter and order an underlying query. +public class QueryArgs +{ + /// + /// Create a new . + /// + /// The basic dynamic OData-like $filter statement. + /// The basic dynamic OData-like $orderby statement. + public static QueryArgs Create(string? filter = null, string? orderBy = null) => new() { Filter = filter, OrderBy = orderBy }; + + /// + /// Gets or sets the basic dynamic OData-like $filter statement. + /// + public string? Filter { get; set; } + + /// + /// Gets or sets the basic dynamic OData-like $orderby statement. + /// + public string? OrderBy { get; set; } + + /// + /// Gets or sets the list of included fields. + /// + /// The and are mutually exclusive. + public List? IncludeFields { get; set; } + + /// + /// Gets or sets the list of excluded fields. + /// + /// The and are mutually exclusive. + public List? ExcludeFields { get; set; } + + /// + /// Indicates whether to include any related texts for the resulting item(s). + /// + public bool IsIncludeText { get; set; } + + /// + /// Indicates whether to include inactive items for the resulting item(s). + /// + public bool IsIncludeInactive { get; set; } + + /// + /// Adds the specified fields to the list. + /// + /// The fields to include. + /// The to support fluent-style method-chaining. + public QueryArgs WithFields(params IEnumerable fields) + { + IncludeFields ??= []; + IncludeFields.AddRange(fields); + return this; + } + + /// + /// Adds the specified fields to the list.` + /// + /// The fields to exclude. + /// The to support fluent-style method-chaining. + public QueryArgs WithoutFields(params IEnumerable fields) + { + ExcludeFields ??= []; + ExcludeFields.AddRange(fields); + return this; + } + + /// + /// Indicates whether to include any related texts for the resulting item(s); see . + /// + /// The to support fluent-style method-chaining. + public QueryArgs IncludeText() + { + IsIncludeText = true; + return this; + } + + /// + /// Indicates whether to include inactive items for the resulting item(s); see . + /// + /// The to support fluent-style method-chaining. + public QueryArgs IncludeInactive() + { + IsIncludeInactive = true; + return this; + } +} \ No newline at end of file diff --git a/src/CoreEx/DataConsistencyException.cs b/src/CoreEx/DataConsistencyException.cs index e62bd01b..634a7e43 100644 --- a/src/CoreEx/DataConsistencyException.cs +++ b/src/CoreEx/DataConsistencyException.cs @@ -1,73 +1,34 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Localization; -using System; -using System.Net; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents a Data Consistency exception. +/// +/// An example would be where the operation would result in data consistency error; i.e. possible data corruption may occur. +/// This is not considered an error ( is set to ). +/// The defaults to: A potential data consistency error occurred. +/// The error message. +/// The inner . +public class DataConsistencyException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(DataConsistencyException).FullName, _message), innerException, true) { + private const string _message = "A potential data consistency error occurred."; + /// - /// Represents a data Consistency exception. + /// Initializes a new instance of the class. /// - /// An example would be where the operation would result in data consistency error; i.e. possible data corruption may occur. - /// The defaults to: A potential data consistency error occurred. - public class DataConsistencyException : Exception, IExtendedException - { - private const string _message = "A potential data consistency error occurred."; - private static bool? _shouldExceptionBeLogged; - - /// - /// Get or sets the value. - /// - public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } - - /// - /// Initializes a new instance of the class. - /// - public DataConsistencyException() : this(null!) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - public DataConsistencyException(string? message) : base(message ?? new LText(typeof(DataConsistencyException).FullName, _message)) { } + public DataConsistencyException() : this(null) { } - /// - /// Initializes a new instance of the class using the specified and . - /// - /// The error message. - /// The inner . - public DataConsistencyException(string? message, Exception innerException) : base(message ?? new LText(typeof(DataConsistencyException).FullName, _message), innerException) { } - - /// - /// - /// - /// The value as a . - public string ErrorType => Abstractions.ErrorType.DataConsistencyError.ToString(); - - /// - /// - /// - /// The value as a . - public int ErrorCode => (int)Abstractions.ErrorType.DataConsistencyError; - - /// - /// - /// - /// The value. - public HttpStatusCode StatusCode => HttpStatusCode.Conflict; - - /// - /// - /// - /// false; is not considered transient. - public bool IsTransient => false; + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public DataConsistencyException(LText? message) : this(message, null) { } - /// - /// - /// - /// The value. - public bool ShouldBeLogged => ShouldExceptionBeLogged; + /// + protected override void OnInitialize() + { + ErrorType = "data-consistency"; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.InternalServerError); + IsError = false; } } \ No newline at end of file diff --git a/src/CoreEx/DependencyInjection/ScopedServiceAttribute.cs b/src/CoreEx/DependencyInjection/ScopedServiceAttribute.cs new file mode 100644 index 00000000..89de5483 --- /dev/null +++ b/src/CoreEx/DependencyInjection/ScopedServiceAttribute.cs @@ -0,0 +1,11 @@ +namespace CoreEx.DependencyInjection; + +/// +/// Indicates that the underlying implementation should be registered as a service with an optional . +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class ScopedServiceAttribute : ServiceLifetimeAttribute +{ + /// + public override ServiceLifetime Lifetime => ServiceLifetime.Scoped; +} \ No newline at end of file diff --git a/src/CoreEx/DependencyInjection/ScopedServiceAttributeT.cs b/src/CoreEx/DependencyInjection/ScopedServiceAttributeT.cs new file mode 100644 index 00000000..7b0d1018 --- /dev/null +++ b/src/CoreEx/DependencyInjection/ScopedServiceAttributeT.cs @@ -0,0 +1,12 @@ +namespace CoreEx.DependencyInjection; + +/// +/// Indicates that the underlying implementation should be registered as a service with an optional . +/// +/// The service . +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class ScopedServiceAttribute() : ServiceLifetimeAttribute(typeof(TService)) +{ + /// + public override ServiceLifetime Lifetime => ServiceLifetime.Scoped; +} \ No newline at end of file diff --git a/src/CoreEx/DependencyInjection/ServiceLifetimeAttribute.cs b/src/CoreEx/DependencyInjection/ServiceLifetimeAttribute.cs new file mode 100644 index 00000000..3fa3b0f2 --- /dev/null +++ b/src/CoreEx/DependencyInjection/ServiceLifetimeAttribute.cs @@ -0,0 +1,63 @@ +namespace CoreEx.DependencyInjection; + +/// +/// Provides the base auto-registering capability with optional . +/// +/// The service . +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public abstract class ServiceLifetimeAttribute(Type? ServiceType = null) : Attribute +{ + /// + /// Gets the . + /// + public abstract ServiceLifetime Lifetime { get; } + + /// + /// Gets or sets the key of the service. + /// + public string? Key { get; set; } + + /// + /// Gets or sets the service . + /// + public Type? ServiceType { get; set; } = ServiceType; + + /// + /// Adds the to the specified using the underlying configuration. + /// + /// The . + /// The implementation . + public void AddService(IServiceCollection services, Type implementationType) + { + services.ThrowIfNull(); + implementationType.ThrowIfNull(); + + if (ServiceType is not null && !ServiceType.IsAssignableFrom(implementationType)) + throw new InvalidOperationException($"The service type '{ServiceType}' is not assignable from the implementation type '{implementationType}'."); + + services.TryAdd(new ServiceDescriptor(ServiceType ?? implementationType, Key, implementationType, Lifetime)); + } + + /// + /// Adds the to the specified using the underlying configuration. + /// + /// The . + /// The implementation . + /// The factory method to create the service. + public void AddService(IServiceCollection services, Type implementationType, Func factory) + { + services.ThrowIfNull(); + implementationType.ThrowIfNull(); + if (ServiceType is not null && !ServiceType.IsAssignableFrom(implementationType)) + throw new InvalidOperationException($"The service type '{ServiceType}' is not assignable from the implementation type '{implementationType}'."); + + services.TryAdd(new ServiceDescriptor(ServiceType ?? implementationType, Key, factory.ThrowIfNull(), Lifetime)); + } + + /// + /// Gets the for the specified . + /// + /// The to get the for. + /// The where found; otherwise, . + public static ServiceLifetimeAttribute? GetCustomAttribute(Type type) => type.GetCustomAttributes(typeof(ServiceLifetimeAttribute), true).SingleOrDefault() as ServiceLifetimeAttribute; +} \ No newline at end of file diff --git a/src/CoreEx/DependencyInjection/SingletonServiceAttribute.cs b/src/CoreEx/DependencyInjection/SingletonServiceAttribute.cs new file mode 100644 index 00000000..9e71bf74 --- /dev/null +++ b/src/CoreEx/DependencyInjection/SingletonServiceAttribute.cs @@ -0,0 +1,11 @@ +namespace CoreEx.DependencyInjection; + +/// +/// Indicates that the underlying implementation should be registered as a service with an optional . +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class SingletonServiceAttribute : ServiceLifetimeAttribute +{ + /// + public override ServiceLifetime Lifetime => ServiceLifetime.Singleton; +} \ No newline at end of file diff --git a/src/CoreEx/DependencyInjection/SingletonServiceAttributeT.cs b/src/CoreEx/DependencyInjection/SingletonServiceAttributeT.cs new file mode 100644 index 00000000..bc63c8aa --- /dev/null +++ b/src/CoreEx/DependencyInjection/SingletonServiceAttributeT.cs @@ -0,0 +1,12 @@ +namespace CoreEx.DependencyInjection; + +/// +/// Indicates that the underlying implementation should be registered as a service with an optional . +/// +/// The service . +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class SingletonServiceAttribute() : ServiceLifetimeAttribute(typeof(TService)) +{ + /// + public override ServiceLifetime Lifetime => ServiceLifetime.Singleton; +} \ No newline at end of file diff --git a/src/CoreEx/DependencyInjection/TransientServiceAttribute.cs b/src/CoreEx/DependencyInjection/TransientServiceAttribute.cs new file mode 100644 index 00000000..f73d00d4 --- /dev/null +++ b/src/CoreEx/DependencyInjection/TransientServiceAttribute.cs @@ -0,0 +1,11 @@ +namespace CoreEx.DependencyInjection; + +/// +/// Indicates that the underlying implementation should be registered as a service with an optional . +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class TransientServiceAttribute : ServiceLifetimeAttribute +{ + /// + public override ServiceLifetime Lifetime => ServiceLifetime.Transient; +} \ No newline at end of file diff --git a/src/CoreEx/DependencyInjection/TransientServiceAttributeT.cs b/src/CoreEx/DependencyInjection/TransientServiceAttributeT.cs new file mode 100644 index 00000000..b31773b0 --- /dev/null +++ b/src/CoreEx/DependencyInjection/TransientServiceAttributeT.cs @@ -0,0 +1,12 @@ +namespace CoreEx.DependencyInjection; + +/// +/// Indicates that the underlying implementation should be registered as a service with an optional . +/// +/// The service . +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class TransientServiceAttribute() : ServiceLifetimeAttribute(typeof(TService)) +{ + /// + public override ServiceLifetime Lifetime => ServiceLifetime.Transient; +} \ No newline at end of file diff --git a/src/CoreEx/DuplicateException.cs b/src/CoreEx/DuplicateException.cs index 8e75c22f..11ad7845 100644 --- a/src/CoreEx/DuplicateException.cs +++ b/src/CoreEx/DuplicateException.cs @@ -1,72 +1,31 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Localization; -using System; -using System.Net; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents a data Duplicate exception. +/// +/// The defaults to: A duplicate error occurred. +/// The error message. +/// The inner . +public class DuplicateException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(DuplicateException).FullName, _message), innerException) { + private const string _message = "A duplicate error occurred."; + /// - /// Represents a data Duplicate exception. + /// Initializes a new instance of the class. /// - /// The defaults to: A duplicate error occurred. - public class DuplicateException : Exception, IExtendedException - { - private const string _message = "A duplicate error occurred."; - private static bool? _shouldExceptionBeLogged; - - /// - /// Get or sets the value. - /// - public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } - - /// - /// Initializes a new instance of the class. - /// - public DuplicateException() : this(null!) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - public DuplicateException(string? message) : base(message ?? new LText(typeof(DuplicateException).FullName, _message)) { } + public DuplicateException() : this(null) { } - /// - /// Initializes a new instance of the class using the specified and . - /// - /// The error message. - /// The inner . - public DuplicateException(string? message, Exception innerException) : base(message ?? new LText(typeof(DuplicateException).FullName, _message), innerException) { } - - /// - /// - /// - /// The value as a . - public string ErrorType => Abstractions.ErrorType.DuplicateError.ToString(); - - /// - /// - /// - /// The value as a . - public int ErrorCode => (int)Abstractions.ErrorType.DuplicateError; - - /// - /// - /// - /// The value. - public HttpStatusCode StatusCode => HttpStatusCode.Conflict; - - /// - /// - /// - /// false; is not considered transient. - public bool IsTransient => false; + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public DuplicateException(LText? message) : this(message, null) { } - /// - /// - /// - /// The value. - public bool ShouldBeLogged => ShouldExceptionBeLogged; + /// + protected override void OnInitialize() + { + ErrorType = "duplicate"; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.Conflict); } } \ No newline at end of file diff --git a/src/CoreEx/Entities/Abstractions/IIdentifier.cs b/src/CoreEx/Entities/Abstractions/IIdentifier.cs new file mode 100644 index 00000000..8103b603 --- /dev/null +++ b/src/CoreEx/Entities/Abstractions/IIdentifier.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Entities.Abstractions; + +/// +/// Enables a mutable capability. +/// +public interface IIdentifier : IReadOnlyIdentifier +{ + /// + object? IIdentifierCore.Id => Id; + + /// + /// Gets or sets the identifier. + /// + new object? Id { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/Abstractions/IIdentifierCore.cs b/src/CoreEx/Entities/Abstractions/IIdentifierCore.cs new file mode 100644 index 00000000..2a62e6ee --- /dev/null +++ b/src/CoreEx/Entities/Abstractions/IIdentifierCore.cs @@ -0,0 +1,31 @@ +namespace CoreEx.Entities.Abstractions; + +/// +/// Enables the core read-only and related capabilities. +/// +public interface IIdentifierCore : IEntityKey +{ + /// + /// Gets the identifier. + /// + object? Id { get; } + + /// + /// Gets the . + /// + [JsonIgnore] + Type IdType { get; } + + /// + /// Indicates whether the is read-only. + /// + [JsonIgnore] + bool IsIdReadOnly { get; } + + /// + /// Sets (overrides) the identifier. + /// + /// The identifier. + /// Must not be . + void SetIdentifier(object? id); +} \ No newline at end of file diff --git a/src/CoreEx/Entities/Abstractions/IReadOnlyIdentifier.cs b/src/CoreEx/Entities/Abstractions/IReadOnlyIdentifier.cs new file mode 100644 index 00000000..aa725714 --- /dev/null +++ b/src/CoreEx/Entities/Abstractions/IReadOnlyIdentifier.cs @@ -0,0 +1,6 @@ +namespace CoreEx.Entities.Abstractions; + +/// +/// Enables a read-only capability. +/// +public interface IReadOnlyIdentifier : IIdentifierCore { } \ No newline at end of file diff --git a/src/CoreEx/Entities/ChangeLog.cs b/src/CoreEx/Entities/ChangeLog.cs index 4aaa813f..97aa10df 100644 --- a/src/CoreEx/Entities/ChangeLog.cs +++ b/src/CoreEx/Entities/ChangeLog.cs @@ -1,94 +1,129 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -using System; - -namespace CoreEx.Entities +/// +/// Provides a implementation. +/// +public record class ChangeLog() : IReadOnlyChangeLogEx, IRuntimeMetadata, IDefault { + private static readonly ChangeLog _empty = new(); + /// - /// Provides a . + /// Gets the username and timestamp information for usage. /// - public class ChangeLog : IChangeLogAudit + /// The optional . + /// The user and timestamp information. + public static (string? UserName, DateTimeOffset Timestamp) GetChangeLogInfo(ExecutionContext? executionContext = null) { - /// - /// Gets or sets the created . - /// - public DateTime? CreatedDate { get; set; } - - /// - /// Gets or sets the created by (username). - /// - public string? CreatedBy { get; set; } - - /// - /// Gets or sets the updated . - /// - public DateTime? UpdatedDate { get; set; } - - /// - /// Gets or sets the updated by (username). - /// - public string? UpdatedBy { get; set; } - - /// - /// Prepares the by setting the Created properties. - /// - /// The value . - /// The value. - /// The optional . - /// Creates or updates the where implements . - public static void PrepareCreated(T value, ExecutionContext? executionContext = null) - { - if (value != null && value is IChangeLogAuditLog cl) - cl.ChangeLogAudit = PrepareCreated(cl.ChangeLogAudit ?? new ChangeLog(), executionContext); - } + if (executionContext is null) + ExecutionContext.TryGetCurrent(out executionContext); + + return (executionContext?.User?.UserName ?? AuthenticationUser.EnvironmentUser.UserName, executionContext?.Timestamp ?? Runtime.UtcNow); + } - /// - /// Prepares the by setting the Created properties. - /// - /// The . - /// The optional . - /// A new or updated with Created properties set. - public static IChangeLogAudit PrepareCreated(IChangeLogAudit changeLog, ExecutionContext? executionContext = null) + /// + /// Creates a new setting the and . + /// + /// The optional . + /// A . + public static ChangeLog CreateCreated(ExecutionContext? executionContext = null) + { + var (UserName, Timestamp) = GetChangeLogInfo(executionContext); + + return new ChangeLog { - changeLog.ThrowIfNull(nameof(changeLog)).CreatedBy = GetUsername(executionContext); - changeLog.CreatedDate = GetTimestamp(executionContext); - return changeLog; - } + CreatedBy = UserName, + CreatedOn = Timestamp + }; + } + + /// + /// Creates a copy (or new) setting the and . + /// + /// The optional to copy from. + /// The optional . + /// A . + public static ChangeLog CreateChanged(ChangeLog? changeLog = null, ExecutionContext? executionContext = null) + { + var (UserName, Timestamp) = GetChangeLogInfo(executionContext); - /// - /// Prepares the by setting the Updated properties. - /// - /// The value . - /// The value. - /// The optional . - /// Creates or updates the where implements . - public static void PrepareUpdated(T value, ExecutionContext? executionContext = null) + return new ChangeLog { - if (value is not null && value is IChangeLogAuditLog cl) - cl.ChangeLogAudit = PrepareUpdated(cl.ChangeLogAudit ?? new ChangeLog(), executionContext); - } + CreatedBy = changeLog?.CreatedBy, + CreatedOn = changeLog?.CreatedOn, + UpdatedBy = UserName, + UpdatedOn = Timestamp + }; + } - /// - /// Prepares the by setting the Updated properties. - /// - /// The . - /// The optional . - /// A new or updated with Updated properties set. - public static IChangeLogAudit PrepareUpdated(IChangeLogAudit changeLog, ExecutionContext? executionContext = null) + /// + /// Creates a new from an . + /// + /// The . + /// The where the result is not ; otherwise, . + public static ChangeLog? CreateFrom(IReadOnlyChangeLogEx? changeLog) + { + var cl = new ChangeLog(changeLog); + return cl.IsDefault() ? null : cl; + } + + /// + public static IEnumerable GetStaticPropertyRuntimeMetadata() + { + yield return new PropertyRuntimeMetadata(nameof(CreatedBy), static e => e.CreatedBy, clean: CleanOption.CleanAndDefault); + yield return new PropertyRuntimeMetadata(nameof(CreatedOn), static e => e.CreatedOn, clean: CleanOption.CleanAndDefault); + yield return new PropertyRuntimeMetadata(nameof(UpdatedBy), static e => e.UpdatedBy, clean: CleanOption.CleanAndDefault); + yield return new PropertyRuntimeMetadata(nameof(UpdatedOn), static e => e.UpdatedOn, clean: CleanOption.CleanAndDefault); + } + + /// + /// Gets an empty instance. + /// + public static ChangeLog Empty => _empty; + + /// + /// Initializes a new instance of the class with an . + /// + /// The . + public ChangeLog(IReadOnlyChangeLog? changeLog) : this((IReadOnlyChangeLogEx?)changeLog?.ChangeLog) { } + + /// + /// Initializes a new instance of the class. + /// + /// The optional . + public ChangeLog(IReadOnlyChangeLogEx? changeLog) : this() + { + if (changeLog is not null) { - changeLog.ThrowIfNull(nameof(changeLog)).UpdatedBy = GetUsername(executionContext); - changeLog.UpdatedDate = GetTimestamp(executionContext); - return changeLog; + CreatedBy = changeLog.CreatedBy; + CreatedOn = changeLog.CreatedOn; + UpdatedBy = changeLog.UpdatedBy; + UpdatedOn = changeLog.UpdatedOn; } + } - /// - /// Gets the username. - /// - private static string GetUsername(ExecutionContext? ec) => ec != null ? ec.UserName : (ExecutionContext.HasCurrent ? ExecutionContext.Current.UserName : ExecutionContext.EnvironmentUserName); + /// + [ReadOnly(true)] + public string? CreatedBy { get; init => field = Cleaner.Clean(value); } - /// - /// Gets the timestamp. - /// - private static DateTime GetTimestamp(ExecutionContext? ec) => ec != null ? ec.Timestamp : SystemTime.Timestamp; + /// + [ReadOnly(true)] + public DateTimeOffset? CreatedOn { get; init; } + + /// + [ReadOnly(true)] + public string? UpdatedBy { get; init => field = Cleaner.Clean(value); } + + /// + [ReadOnly(true)] + public DateTimeOffset? UpdatedOn { get; init; } + + /// + public virtual IEnumerable GetPropertyRuntimeMetadata() + { + foreach (var pr in GetStaticPropertyRuntimeMetadata()) + yield return pr; } + + /// + public bool IsDefault() => RuntimeMetadata.IsDefault(this); } \ No newline at end of file diff --git a/src/CoreEx/Entities/CleanOption.cs b/src/CoreEx/Entities/CleanOption.cs new file mode 100644 index 00000000..668cac43 --- /dev/null +++ b/src/CoreEx/Entities/CleanOption.cs @@ -0,0 +1,31 @@ +namespace CoreEx.Entities; + +/// +/// Represents the option for cleaning an property value. +/// +public enum CleanOption +{ + /// + /// Indicates that the value should be used. + /// + UseDefault, + + /// + /// No cleaning required, the value will remain as-is. + /// + None, + + /// + /// The value will be cleaned. + /// + /// Where the property is an all sub-properties will also be cleaned (where applicable). + /// Where the property is not an then the and will have the same outcome. + Clean, + + /// + /// The value will be cleaned and defaulted. + /// + /// Where the property is an all sub-properties will also be cleaned (where applicable). Also, where the property is the value will be defaulted. + /// Where the property is not an then the and will have the same outcome. + CleanAndDefault +} \ No newline at end of file diff --git a/src/CoreEx/Entities/Cleaner.cs b/src/CoreEx/Entities/Cleaner.cs index 8089cf83..21eeab70 100644 --- a/src/CoreEx/Entities/Cleaner.cs +++ b/src/CoreEx/Entities/Cleaner.cs @@ -1,333 +1,197 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -using CoreEx.Configuration; -using CoreEx.Globalization; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; - -namespace CoreEx.Entities +/// +/// Provides capabilities to clean a specified value. +/// +public static class Cleaner { + private static StringTrim? _stringTrim; + private static StringTransform? _stringTransform; + private static StringCase? _stringCase; + private static DateTimeTransform? _dateTimeTransform; + private static CleanOption? _cleanOption; + /// - /// Provides capabilities to clean a specified value. + /// Resets the , , and to their respective default values. /// - [System.Diagnostics.DebuggerStepThrough] - public static class Cleaner + public static void ResetDefaults() { - private static DateTimeTransform _dateTimeTransform = DateTimeTransform.DateTimeUtc; - private static StringTransform _stringTransform = StringTransform.EmptyToNull; - private static StringTrim _stringTrim = StringTrim.End; - private static StringCase _stringCase = StringCase.None; - - /// - /// Gets or sets the default for all entities unless explicitly overridden. Defaults to . - /// - public static DateTimeTransform DefaultDateTimeTransform - { - get => _dateTimeTransform; - set => _dateTimeTransform = value == DateTimeTransform.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultDateTimeTransform)) : value; - } - - /// - /// Gets or sets the default for all entities unless explicitly overridden. Defaults to . - /// - public static StringTransform DefaultStringTransform - { - get => _stringTransform; - set => _stringTransform = value == StringTransform.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultStringTransform)) : value; - } - - /// - /// Gets or sets the default for all entities unless explicitly overridden. Defaults to . - /// - public static StringTrim DefaultStringTrim - { - get => _stringTrim; - set => _stringTrim = value == StringTrim.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultStringTrim)) : value; - } - - /// - /// Gets or sets the default for all entities unless explicitly overridden. Defaults to . - /// - public static StringCase DefaultStringCase - { - get => _stringCase; - set => _stringCase = value == StringCase.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultStringCase)) : value; - } - - /// - /// Cleans a . - /// - /// The value to clean. - /// The cleaned value. - /// The will be trimmed and transformed using the respective and values. - public static string? Clean(string? value) => Clean(value, StringTrim.UseDefault, StringTransform.UseDefault, StringCase.UseDefault); - - /// - /// Cleans a using the specified and . - /// - /// The value to clean. - /// The (defaults to ). - /// The (defaults to ). - /// The (defaults to ). - /// The cleaned value. - public static string? Clean(string? value, StringTrim trim = StringTrim.UseDefault, StringTransform transform = StringTransform.UseDefault, StringCase casing = StringCase.UseDefault) - { - if (transform == StringTransform.UseDefault) - transform = ExecutionContext.GetService()?.StringTransform ?? DefaultStringTransform; - - // Handle a null string. - if (value == null) - { - if (transform == StringTransform.NullToEmpty) - return string.Empty; - else - return value; - } - - if (trim == StringTrim.UseDefault) - trim = ExecutionContext.GetService()?.StringTrim ?? DefaultStringTrim; + _stringTrim = null; + _stringTransform = null; + _stringCase = null; + _dateTimeTransform = null; + _cleanOption = null; + } - // Trim the string. - var tmp = trim switch - { - StringTrim.Both => value.Trim(), - StringTrim.Start => value.TrimStart(), - StringTrim.End => value.TrimEnd(), - _ => value, - }; + /// + /// Gets or sets the default for all values unless explicitly overridden. Defaults to . + /// + public static StringTrim DefaultStringTrim + { + get => _stringTrim ??= Internal.GetConfigurationValue("CoreEx:Entities:Cleaner:DefaultStringTrim", StringTrim.End); + set => _stringTrim = value == StringTrim.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultStringTrim)) : value; + } - // Transform the string. - tmp = transform switch - { - StringTransform.EmptyToNull => (tmp.Length == 0) ? null : tmp, - StringTransform.NullToEmpty => tmp ?? string.Empty, - _ => tmp, - }; + /// + /// Gets or sets the default for all values unless explicitly overridden. Defaults to . + /// + public static StringTransform DefaultStringTransform + { + get => _stringTransform ??= Internal.GetConfigurationValue("CoreEx:Entities:Cleaner:DefaultStringTransform", StringTransform.EmptyToNull); + set => _stringTransform = value == StringTransform.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultStringTransform)) : value; + } - if (string.IsNullOrEmpty(tmp)) - return tmp; + /// + /// Gets or sets the default for all values unless explicitly overridden. Defaults to . + /// + public static StringCase DefaultStringCase + { + get => _stringCase ??= Internal.GetConfigurationValue("CoreEx:Entities:Cleaner:DefaultStringCase", StringCase.None); + set => _stringCase = value == StringCase.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultStringCase)) : value; + } - // Apply casing to the string. - if (casing == StringCase.UseDefault) - casing = ExecutionContext.GetService()?.StringCase ?? DefaultStringCase; + /// + /// Gets or sets the default for all values unless explicitly overridden. Defaults to . + /// + public static DateTimeTransform DefaultDateTimeTransform + { + get => _dateTimeTransform ??= Internal.GetConfigurationValue("CoreEx:Entities:Cleaner:DefaultDateTimeTransform", DateTimeTransform.DateTimeUtc); + set => _dateTimeTransform = value == DateTimeTransform.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultDateTimeTransform)) : value; + } - return casing switch - { - StringCase.Lower => CultureInfo.CurrentCulture.TextInfo.ToCasing(tmp, TextInfoCasing.Lower), - StringCase.Upper => CultureInfo.CurrentCulture.TextInfo.ToCasing(tmp, TextInfoCasing.Upper), - StringCase.Title => CultureInfo.CurrentCulture.TextInfo.ToCasing(tmp, TextInfoCasing.Title), - _ => tmp, - }; - } + /// + /// Gets or sets the default for all values unless explicitly overridden. Defaults to . + /// + public static CleanOption DefaultCleanOption + { + get => _cleanOption ??= Internal.GetConfigurationValue("CoreEx:Entities:Cleaner:DefaultCleanOption", CleanOption.CleanAndDefault); + set => _cleanOption = value == CleanOption.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultCleanOption)) : value; + } - /// - /// Cleans a value. - /// - /// The value to clean. - /// The cleaned value. - /// The will be transformed using . - public static DateTime Clean(DateTime value) => Clean(value, DateTimeTransform.UseDefault); + /// + /// Cleans a using the specified and . + /// + /// The value to clean. + /// The (defaults to ). + /// The (defaults to ). + /// The (defaults to ). + /// The cleaned value. + public static string? Clean(string? value, StringTrim trim = StringTrim.UseDefault, StringTransform transform = StringTransform.UseDefault, StringCase casing = StringCase.UseDefault) + { + if (transform == StringTransform.UseDefault) + transform = DefaultStringTransform; - /// - /// Cleans a value. - /// - /// The value to clean. - /// The to be applied. - /// The cleaned value. - /// Will attempt to use as a default where possible. - public static DateTime Clean(DateTime value, DateTimeTransform transform) + // Handle a null string. + if (value is null) { - if (transform == DateTimeTransform.UseDefault) - transform = ExecutionContext.GetService()?.DateTimeTransform ?? DefaultDateTimeTransform; - - switch (transform) - { - case DateTimeTransform.DateOnly: - if (value.Kind == DateTimeKind.Unspecified && value.TimeOfDay == TimeSpan.Zero) - return value; - else - return DateTime.SpecifyKind(value.Date, DateTimeKind.Unspecified); - - case DateTimeTransform.DateTimeLocal: - if (value.Kind != DateTimeKind.Local) - { - if (value == DateTime.MinValue || value == DateTime.MaxValue || value.Kind == DateTimeKind.Unspecified) - return DateTime.SpecifyKind(value, DateTimeKind.Local); - else - return (value.Kind == DateTimeKind.Local) ? value : TimeZoneInfo.ConvertTime(value, TimeZoneInfo.Local); - } - - break; - - case DateTimeTransform.DateTimeUtc: - if (value.Kind != DateTimeKind.Utc) - { - if (value == DateTime.MinValue || value == DateTime.MaxValue || value.Kind == DateTimeKind.Unspecified) - return DateTime.SpecifyKind(value, DateTimeKind.Utc); - else - return (value.Kind == DateTimeKind.Utc) ? value : TimeZoneInfo.ConvertTime(value, TimeZoneInfo.Utc); - } - - break; - - case DateTimeTransform.DateTimeUnspecified: - if (value.Kind != DateTimeKind.Unspecified) - return DateTime.SpecifyKind(value, DateTimeKind.Unspecified); - - break; - } - - return value; + if (transform == StringTransform.NullToEmpty) + return string.Empty; + else + return null; } - /// - /// Cleans a value. - /// - /// The value to clean. - /// The cleaned value. - /// The will be transformed using . - public static DateTime? Clean(DateTime? value) => Clean(value, DateTimeTransform.UseDefault); + if (trim == StringTrim.UseDefault) + trim = DefaultStringTrim; - /// - /// Cleans a value. - /// - /// The value to clean. - /// The to be applied. - /// The cleaned value. - public static DateTime? Clean(DateTime? value, DateTimeTransform transform) + // Trim the string. + var tmp = trim switch + { + StringTrim.End => value.TrimEnd(), + StringTrim.Both => value.Trim(), + StringTrim.Start => value.TrimStart(), + _ => value, + }; + + // Transform the string. + tmp = transform switch { - if (value == null || !value.HasValue) - return value; + StringTransform.EmptyToNull => string.IsNullOrEmpty(tmp) ? null : tmp, + StringTransform.NullToEmpty => tmp ?? string.Empty, + _ => tmp, + }; - return Clean(value.Value, transform); - } + if (string.IsNullOrEmpty(tmp)) + return tmp; - /// - /// Cleans a value and overrides the value with null when the value is . - /// - /// The . - /// The value to clean. - /// The cleaned value. - /// This invokes with 'overrideWithDefaultWhenIsInitial' parameter set to true. - public static T Clean(T value) => Clean(value, true); + // Apply casing to the string. + if (casing == StringCase.UseDefault) + casing = DefaultStringCase; - /// - /// Cleans a value. - /// - /// The . - /// The value to clean. - /// Indicates whether to override the value with default when the value is . - /// The cleaned value. - public static T Clean(T value, bool overrideWithDefaultWhenIsInitial) + return casing switch { - if (value is string str) - return (T)Convert.ChangeType(Clean(str, StringTrim.UseDefault, StringTransform.UseDefault, StringCase.UseDefault), typeof(string), CultureInfo.CurrentCulture)!; - else if (value is DateTime dte) - return (T)Convert.ChangeType(Clean(dte, DateTimeTransform.UseDefault), typeof(DateTime), CultureInfo.CurrentCulture); - - if (value is ICleanUp ic) - ic.CleanUp(); - - if (overrideWithDefaultWhenIsInitial && value is IInitial ii && ii.IsInitial) - return default!; + StringCase.None => tmp, + StringCase.Lower => CultureInfo.CurrentCulture.TextInfo.ToCasing(tmp, TextInfoCasing.Lower), + StringCase.Upper => CultureInfo.CurrentCulture.TextInfo.ToCasing(tmp, TextInfoCasing.Upper), + StringCase.Title => CultureInfo.CurrentCulture.TextInfo.ToCasing(tmp, TextInfoCasing.Title), + _ => tmp + }; + } - return value; - } + /// + /// Cleans a value. + /// + /// The value to clean. + /// The to be applied. + /// The cleaned value. + public static DateTime Clean(DateTime value, DateTimeTransform transform) + { + if (transform == DateTimeTransform.UseDefault) + transform = DefaultDateTimeTransform; - /// - /// Cleans one or more values where they implement . - /// - /// The values to clean. - public static void CleanUp(params object?[] values) + switch (transform) { - if (values != null) - { - foreach (object? o in values) + case DateTimeTransform.DateTimeUtc: + if (value.Kind != DateTimeKind.Utc) { - if (o != null && o is ICleanUp value) - value.CleanUp(); + if (value == DateTime.MinValue || value == DateTime.MaxValue || value.Kind == DateTimeKind.Unspecified) + return DateTime.SpecifyKind(value, DateTimeKind.Utc); + else + return (value.Kind == DateTimeKind.Utc) ? value : TimeZoneInfo.ConvertTime(value, TimeZoneInfo.Utc); } - } - } - /// - /// Indicates whether a value is considered in its default state. - /// - /// The . - /// The value to check. - /// true indicates that the value is initial; otherwise, false. - /// This determines whether is initial by comparing against its default value; this does not leverage . - public static bool IsDefault(T value) => value == null || Comparer.Default.Compare(value, default!) == 0; + break; - /// - /// Indicates whether a value is considered in its default state. - /// - /// The . - /// The value to check. - /// The default value override. - /// true indicates that the value is initial; otherwise, false. - /// This determines whether is initial by comparing against its default value; this does not leverage . - public static bool IsDefault(T value, T @default) => Comparer.Default.Compare(value, @default) == 0; + case DateTimeTransform.DateTimeLocal: + if (value.Kind != DateTimeKind.Local) + { + if (value == DateTime.MinValue || value == DateTime.MaxValue || value.Kind == DateTimeKind.Unspecified) + return DateTime.SpecifyKind(value, DateTimeKind.Local); + else + return (value.Kind == DateTimeKind.Local) ? value : TimeZoneInfo.ConvertTime(value, TimeZoneInfo.Local); + } - /// - /// Resets the by overridding with where . - /// - /// The value . - /// The value. - /// The optional . - public static void ResetTenantId(T? value, ExecutionContext? executionContext = null) - { - if (value == null || value is not ITenantId ti) - return; + break; - if (executionContext is null) - { + case DateTimeTransform.DateOnly: + if (value.Kind == DateTimeKind.Unspecified && value.TimeOfDay == TimeSpan.Zero) + return value; + else + return DateTime.SpecifyKind(value.Date, DateTimeKind.Unspecified); - if (ExecutionContext.HasCurrent) - ti.TenantId = ExecutionContext.Current.TenantId; - } - else - ti.TenantId = executionContext.TenantId; - } + case DateTimeTransform.DateTimeUnspecified: + if (value.Kind != DateTimeKind.Unspecified) + return DateTime.SpecifyKind(value, DateTimeKind.Unspecified); - /// - /// Prepares the value for create by encapsulating and . - /// - /// The value . - /// The value. - /// The optional . - /// The value to support fluent-style method-chaining. - [return: NotNullIfNotNull(nameof(value))] - public static T? PrepareCreate(T? value, ExecutionContext? executionContext = null) - { - if (value is not null) - { - ChangeLog.PrepareCreated(value, executionContext); - ResetTenantId(value, executionContext); - } - - return value; + break; } - /// - /// Prepares the value for update by encapsulating and . - /// - /// The value . - /// The value. - /// The optional . - /// The value to support fluent-style method-chaining. - [return: NotNullIfNotNull(nameof(value))] - public static T? PrepareUpdate(T? value, ExecutionContext? executionContext = null) - { - if (value is not null) - { - ChangeLog.PrepareUpdated(value, executionContext); - ResetTenantId(value, executionContext); - } - - return value; - } + return value; } + + /// + /// Cleans a value. + /// + /// The value to clean. + /// The to be applied. + /// The cleaned value. + [return: NotNullIfNotNull(nameof(value))] + public static DateTime? Clean(DateTime? value, DateTimeTransform transform) => value is null || !value.HasValue ? value : Clean(value.Value, transform); + + /// + /// Cleans a value. + /// + /// The value . + /// The value to clean. + /// The cleaned . + public static T? Clean(T value) => RuntimeMetadata.Clean(value); } \ No newline at end of file diff --git a/src/CoreEx/Entities/CollectionResult.cs b/src/CoreEx/Entities/CollectionResult.cs deleted file mode 100644 index bcf4c4f6..00000000 --- a/src/CoreEx/Entities/CollectionResult.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Net.Http; - -namespace CoreEx.Entities -{ - /// - /// Represents a basic class with a and underlying . - /// - /// The result collection . - /// The underlying entity . - /// Generally an is not intended to be (de)serialized. For an the underlying is (de)serialized only, with the - /// included within the corresponding . The implementations have specific functionality included to (de)serialize the only, dropping the - /// ; see as an example. - [System.Diagnostics.DebuggerStepThrough] - public abstract class CollectionResult : ICollectionResult, IPagingResult - where TColl : List, new() - where TItem : class - { - private TColl? _collection; - - /// - /// Initializes a new instance of the class. - /// - public CollectionResult() { } - - /// - /// Initializes a new instance of the class with . - /// - /// Defaults the to the requesting . - protected CollectionResult(PagingArgs? paging) - { - if (paging is not null) - Paging = new PagingResult(paging); - } - - /// - public TColl Items - { - get => _collection ??= new TColl(); - set => _collection = value.ThrowIfNull(nameof(value)); - } - - /// - public PagingResult? Paging { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/CompositeKey.Conversion.cs b/src/CoreEx/Entities/CompositeKey.Conversion.cs new file mode 100644 index 00000000..c8bdc765 --- /dev/null +++ b/src/CoreEx/Entities/CompositeKey.Conversion.cs @@ -0,0 +1,178 @@ +namespace CoreEx.Entities; + +public partial struct CompositeKey +{ + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(string? identifier) => Create(identifier); + + /// + /// Implicitly converts an to a . + /// + /// The identifier. + public static implicit operator CompositeKey(short identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(short? identifier) => Create(identifier); + + /// + /// Implicitly converts an to a . + /// + /// The identifier. + public static implicit operator CompositeKey(int identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(int? identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(long identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(long? identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(Guid identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(Guid? identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(char identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(char? identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(bool identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(bool? identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(DateTime identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(DateTime? identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(DateTimeOffset identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(DateTimeOffset? identifier) => Create(identifier); + + /// + /// Implicitly converts an to a . + /// + /// The identifier. + public static implicit operator CompositeKey(ushort identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(ushort? identifier) => Create(identifier); + + /// + /// Implicitly converts an to a . + /// + /// The identifier. + public static implicit operator CompositeKey(uint identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(uint? identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(ulong identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(ulong? identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(DateOnly identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(DateOnly? identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(TimeOnly identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(TimeOnly? identifier) => Create(identifier); + + /// + /// Implicitly converts a to a . + /// + /// The identifier. + public static implicit operator CompositeKey(byte[] identifier) => Create(identifier); + + /// + /// Implicitly converts an to a . + /// + /// The key. + public static implicit operator CompositeKey(object?[]? key) => key is null ? new() : new(key); +} \ No newline at end of file diff --git a/src/CoreEx/Entities/CompositeKey.cs b/src/CoreEx/Entities/CompositeKey.cs index 73f133a7..ce2caeb8 100644 --- a/src/CoreEx/Entities/CompositeKey.cs +++ b/src/CoreEx/Entities/CompositeKey.cs @@ -1,534 +1,217 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using System; -using System.Collections.Immutable; -using System.Globalization; -using System.Text; - -namespace CoreEx.Entities +namespace CoreEx.Entities; + +/// +/// Represents an immutable composite key. +/// +/// May contain zero or more that represent the composite key. +/// NOTE: For performance-critical scenarios with 1-4 arguments, use the generic overloads to avoid boxing of value types. +/// The is largely intended for .NET code use only, as such there is no specific JSON serialization support enabled by design. The following code snippet demonstrates intended usage. +/// +/// public class SalesOrderItem +/// { +/// [JsonPropertyName("order")] +/// public string? OrderNumber { get; set; } +/// +/// [JsonPropertyName("item")] +/// public int ItemNumber { get; set; } +/// +/// [JsonIgnore()] +/// public CompositeKey SalesOrderItemKey => CompositeKey.Create(OrderNumber, ItemNumber); +/// } +/// +/// +public readonly partial struct CompositeKey() : IEquatable { + private readonly ImmutableArray _args = []; + private readonly object? _fastPath = null; + private readonly byte _count = 0; + /// - /// Represents an immutable composite key. + /// Creates a new from the argument values. /// - /// May contain zero or more that represent the composite key. A subset of the the .NET built-in types - /// are supported: , , , , , , , , , (converted to a ) and . - /// Extended support is enabled for types such that the is used. - /// A is not generally intended to be a first-class JSON-serialized property type, although is supported (see ); but, to be used in a read-only non-serialized manner to group (encapsulate) other properties - /// into a single value. The is also used within the , and .Example as follows: - /// - /// public class SalesOrderItem - /// { - /// [JsonPropertyName("order")] - /// public string? OrderNumber { get; set; } - /// - /// [JsonPropertyName("item")] - /// public int ItemNumber { get; set; } - /// - /// [JsonIgnore()] - /// public CompositeKey SalesOrderItemKey => CompositeKey.Create(OrderNumber, ItemNumber); - /// } - /// - [System.Diagnostics.DebuggerStepThrough] - [System.Diagnostics.DebuggerDisplay("Args = {ToString()}")] - public readonly struct CompositeKey : IEquatable - { - private static readonly string[] _singleEmptyArray = [string.Empty]; - private readonly ImmutableArray _args; - - /// - /// Represents an empty . - /// - public static readonly CompositeKey Empty = new(); - - /// - /// Creates a new from the argument values, - /// - /// The argument values for the key. - /// The . - public static CompositeKey Create(params object?[] args) => new(args); - - /// - /// Initializes a new structure. - /// -#if NET8_0_OR_GREATER - public CompositeKey() => _args = []; -#else - public CompositeKey() => _args = ImmutableArray.Empty; -#endif - - /// - /// Initializes a new structure with one or more values that represent the composite key. - /// - /// The argument values for the key. - public CompositeKey(params object?[] args) - { - if (args == null) - { -#if NET8_0_OR_GREATER - _args = [null]; -#else - _args = new object?[] { null }.ToImmutableArray(); -#endif - return; - } - -#if !RELEASE - // Validate supported types in Debug-mode only; fast-path for Release. - object? temp; - for (int idx = 0; idx < args.Length; idx++) - { - temp = args[idx] == null ? null : args[idx] switch - { - string str => str, - char c => c, - short s => s, - int i => i, - long l => l, - Guid g => g, - DateTime dt => dt, - DateTimeOffset dto => dto, - ushort us => us, - uint ui => ui, - ulong ul => ul, - IReferenceData rd => rd?.Code, - _ => throw new ArgumentException($"{nameof(CompositeKey)} argument Type '{args[idx]!.GetType().FullName}' is not supported; must be one of the following: " - + "string, char, short, int, long, ushort, uint, ulong, Guid, DateTime and DateTimeOffset.") - }; - } -#endif - -#if NET8_0_OR_GREATER - _args = [.. args]; -#else - _args = args.ToImmutableArray(); -#endif - } - - /// - /// Gets the argument values for the key. - /// - /// The are immutable. - public ImmutableArray Args => _args; + /// The argument values for the key. + /// The . + public static CompositeKey Create(params IEnumerable args) => new(args); - /// - /// Asserts the length and throws an where the length is not as expected. - /// - /// The expected length. - /// The to support fluent-style method-chaining. - public CompositeKey AssertLength(int length) - { - if (length < 0) - throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than or equal to zero."); - - if (_args.Length != length) - throw new ArgumentException($"The number of arguments within the {nameof(CompositeKey)} must equal {length}.", nameof(length)); - - return this; - } + /// + /// Creates a new from a single argument value without boxing (performance optimized). + /// + /// The argument . + /// The argument value for the key. + /// The . + public static CompositeKey Create(T arg) => new(ValueTuple.Create(arg), 1); - /// - /// Determines whether the current is equal to another . - /// - /// The other . - /// true if the values are equal; otherwise, false. - /// Uses the . - public bool Equals(CompositeKey other) => CompositeKeyComparer.Default.Equals(this, other); + /// + /// Creates a new from two argument values without boxing (performance optimized). + /// + /// The first argument . + /// The second argument . + /// The first argument value. + /// The second argument value. + /// The . + public static CompositeKey Create(T1 arg1, T2 arg2) => new((arg1, arg2), 2); - /// - /// Determines whether the current is equal to another . - /// - /// The other . - /// true if the values are equal; otherwise, false. - public override bool Equals(object? obj) => obj is CompositeKey key && Equals(key); + /// + /// Creates a new from three argument values without boxing (performance optimized). + /// + /// The first argument . + /// The second argument . + /// The third argument . + /// The first argument value. + /// The second argument value. + /// The third argument value. + /// The . + public static CompositeKey Create(T1 arg1, T2 arg2, T3 arg3) => new((arg1, arg2, arg3), 3); - /// - /// Returns a hash code for the . - /// - /// A hash code for the . - /// Uses the . - public override int GetHashCode() => CompositeKeyComparer.Default.GetHashCode(this); + /// + /// Creates a new from four argument values without boxing (performance optimized). + /// + /// The first argument . + /// The second argument . + /// The third argument . + /// The fourth argument . + /// The first argument value. + /// The second argument value. + /// The third argument value. + /// The fourth argument value. + /// The . + public static CompositeKey Create(T1 arg1, T2 arg2, T3 arg3, T4 arg4) => new((arg1, arg2, arg3, arg4), 4); - /// - /// Compares two types for equality. - /// - /// The left . - /// The right . - /// true indicates equal; otherwise, false for not equal. - public static bool operator ==(CompositeKey left, CompositeKey right) => left.Equals(right); + /// + /// Initializes a new from the argument values. + /// + /// The argument values for the key. Passing an explicit creates a key with one null value; passing no arguments creates an empty key. + public CompositeKey(params IEnumerable? args) : this() => _args = args is null ? [null] : [.. args]; - /// - /// Compares two types for non-equality. - /// - /// The left . - /// The right . - /// true indicates not equal; otherwise, false for equal. - public static bool operator !=(CompositeKey left, CompositeKey right) => !(left == right); + /// + /// Initializes a new using fast path storage (no boxing). + /// + private CompositeKey(object? fastPath, byte count) : this([]) + { + _fastPath = fastPath; + _count = count; + _args = default; + } - /// - /// Determines whether the is considered initial; i.e. all have their default value. - /// - /// true indicates that the is initial; otherwise, false. - public bool IsInitial - { - get - { - if (Args == null || Args.Length == 0) - return true; + /// + /// Gets the argument values for the key. + /// + /// The are immutable. When using fast path storage, this property materializes the arguments from the tuple. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + public ImmutableArray Args => _count == 0 ? _args : MaterializeArgs(); - foreach (var arg in Args) - { - if (arg != null && !arg.Equals(GetDefaultValue(arg.GetType()))) - return false; - } + /// + /// Gets whether this key uses fast path storage (no boxing). + /// + internal bool IsFastPath => _count > 0; - return true; - } - } + /// + /// Gets the fast path storage object (tuple). + /// + internal object? FastPath => _fastPath; - /// - /// Gets the default value for a specified . - /// - private static object? GetDefaultValue(Type type) => type.IsValueType ? Activator.CreateInstance(type) : null; + /// + /// Gets the count of arguments in fast path storage. + /// + internal byte FastPathCount => _count; - /// - /// Returns the as a comma-separated . - /// - /// The composite key as a . - /// Each value is formatted in an invariant manner for portability and consistency. A null is formatted as . A - /// is written as-is, there is no special escaping et. performed. - public override string? ToString() => ToString(','); + /// + /// Materializes the arguments from fast path storage into an immutable array. + /// + private ImmutableArray MaterializeArgs() + { + if (_fastPath is not ITuple tuple) + return []; - /// - /// Returns the as a with the separated by the . - /// - /// The seperator character. - /// The composite key as a . - public string? ToString(char separator) + return _count switch { - if (Args.Length == 0 || (Args.Length == 1 && Args[0] is null)) - return null; - - if (Args.Length == 1 && Args[0] is string s) - return s; - - var sb = new StringBuilder(); - for (int i = 0; i < Args.Length; i++) - { - if (i > 0) - sb.Append(separator); - - if (Args[i] is null) - continue; - - switch (Args[i]!.GetType()) - { - case Type t when t == typeof(int) || t == typeof(long) || t == typeof(short) || t == typeof(uint) || t == typeof(ulong) || t == typeof(ushort): - sb.AppendFormat(NumberFormatInfo.InvariantInfo, "{0}", Args[i]); - break; - - case Type t when t == typeof(DateTime) || t == typeof(DateTimeOffset): - sb.AppendFormat(DateTimeFormatInfo.InvariantInfo, "{0:O}", Args[i]); - break; - - default: - sb.Append(Args[i]); - break; - } - } - - return sb.ToString(); - } - - /// - /// Returns the as a JSON . - /// - /// The composite key as a JSON . - /// Uses the internally. - public string ToJsonString() => Json.JsonSerializer.Default.Serialize(this); - - /// - /// Creates a new from serialized (see ); - /// - /// The JSON string. - /// The . - /// Uses the internally. - public static CompositeKey CreateFromJson(string json) => (string.IsNullOrEmpty(json) || json == "null") ? new CompositeKey() : Json.JsonSerializer.Default.Deserialize(json); - - /// - /// Try and create a new from a string-based () where the key is of the specified. - /// - /// The key . - /// The key. - /// The resulting - /// true indicates that the was successfully created; otherwise, false - /// The types specified must represent exact match of underlying parts. - /// There is no specific character escaping etc. performed automatically. - public static bool TryCreateFromString(string? key, out CompositeKey compositeKey) => TryCreateFromString(key, [typeof(T)], out compositeKey); - - /// - /// Creates a new from a string-based () where each underlying part is of the specified. - /// - /// The key for the first part. - /// The key for the second part. - /// The key. - /// The resulting - /// true indicates that the was successfully created; otherwise, false - /// The types specified must represent exact match of underlying parts. - public static bool TryCreateFromString(string? key, out CompositeKey compositeKey) => TryCreateFromString(key, [typeof(T1), typeof(T2)], out compositeKey); - - /// - /// Creates a new from a string-based () where each underlying part is of the specified. - /// - /// The key for the first part. - /// The key for the second part. - /// The key for the third part. - /// The key. - /// The resulting - /// true indicates that the was successfully created; otherwise, false - /// The types specified must represent exact match of underlying parts. - public static bool TryCreateFromString(string? key, out CompositeKey compositeKey) => TryCreateFromString(key, [typeof(T1), typeof(T2), typeof(T3)], out compositeKey); - - /// - /// Try and create a new from a string-based representation () where each underlying part is of the specified. - /// - /// The key. - /// The array. - /// The resulting - /// true indicates that the was successfully created; otherwise, false - /// The types specified must represent exact match of underlying parts. - public static bool TryCreateFromString(string? key, Type[] types, out CompositeKey compositeKey) => TryCreateFromString(key, ',', types, out compositeKey); + 1 => [tuple[0]], + 2 => [tuple[0], tuple[1]], + 3 => [tuple[0], tuple[1], tuple[2]], + 4 => [tuple[0], tuple[1], tuple[2], tuple[3]], + _ => [] + }; + } - /// - /// Try and create a new from a string-based () where the key is of the specified. - /// - /// The key . - /// The key. - /// The seperator character. - /// The resulting - /// true indicates that the was successfully created; otherwise, false - /// The types specified must represent exact match of underlying parts. - public static bool TryCreateFromString(string? key, char separator, out CompositeKey compositeKey) => TryCreateFromString(key, separator, [typeof(T)], out compositeKey); + /// + /// Determines whether the current is equal to another . + /// + /// The other . + /// if the values are equal; otherwise, . + /// Uses the . + public bool Equals(CompositeKey other) => CompositeKeyComparer.Default.Equals(this, other); - /// - /// Creates a new from a string-based () where each underlying part is of the specified. - /// - /// The key for the first part. - /// The key for the second part. - /// The key. - /// The seperator character. - /// The resulting - /// true indicates that the was successfully created; otherwise, false - /// The types specified must represent exact match of underlying parts. - public static bool TryCreateFromString(string? key, char separator, out CompositeKey compositeKey) => TryCreateFromString(key, separator, [typeof(T1), typeof(T2)], out compositeKey); + /// + /// Determines whether the current is equal to another . + /// + /// The other . + /// if the values are equal; otherwise, . + public override bool Equals(object? obj) => obj is CompositeKey key && Equals(key); - /// - /// Creates a new from a string-based () where each underlying part is of the specified. - /// - /// The key for the first part. - /// The key for the second part. - /// The key for the third part. - /// The key. - /// The seperator character. - /// The resulting - /// true indicates that the was successfully created; otherwise, false - /// The types specified must represent exact match of underlying parts. - public static bool TryCreateFromString(string? key, char separator, out CompositeKey compositeKey) => TryCreateFromString(key, separator, [typeof(T1), typeof(T2), typeof(T3)], out compositeKey); + /// + /// Returns a hash code for the . + /// + /// A hash code for the . + /// Uses the . + public override int GetHashCode() => CompositeKeyComparer.Default.GetHashCode(this); - /// - /// Try and create a new from a string-based representation () where each underlying part is of the specified. - /// - /// The key. - /// The seperator character. - /// The array. - /// The resulting - /// true indicates that the was successfully created; otherwise, false - /// The types specified must represent exact match of underlying parts. - public static bool TryCreateFromString(string? key, char separator, Type[] types, out CompositeKey compositeKey) - { - var parts = string.IsNullOrEmpty(key) ? _singleEmptyArray : key.Split(separator, StringSplitOptions.None); - if (parts.Length != types.Length) - { - compositeKey = Empty; - return false; - } + /// + /// Compares two types for equality. + /// + /// The left . + /// The right . + /// indicates equal; otherwise, for not equal. + public static bool operator ==(CompositeKey left, CompositeKey right) => left.Equals(right); - var args = new object?[types.Length]; + /// + /// Compares two types for non-equality. + /// + /// The left . + /// The right . + /// indicates not equal; otherwise, for equal. + public static bool operator !=(CompositeKey left, CompositeKey right) => !(left == right); - for (int i = 0; i < parts.Length; i++) - { - var part = parts[i]; - var type = Nullable.GetUnderlyingType(types[i]); - if (type is not null) - { - if (string.IsNullOrEmpty(part)) - { - args[i] = null; - continue; - } - } - else - type = types[i]; + /// + /// Gets the string representation of the . + /// + /// The string representation. + /// Uses the configured . + public override string? ToString() => ToStringFormatter(this); - if (!(type switch - { - Type t when t == typeof(string) => TryParse(args, i, () => (true, part.Length == 0 ? null : part)), - Type t when t == typeof(char) => TryParse(args, i, () => part.Length == 0 ? (true, ' ') : (part.Length == 1 ? (true, part[0]) : (false, ' '))), - Type t when t == typeof(short) => TryParse(args, i, () => short.TryParse(part, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out short v) ? (true, v) : (false, 0)), - Type t when t == typeof(int) => TryParse(args, i, () => int.TryParse(part, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out int v) ? (true, v) : (false, 0)), - Type t when t == typeof(long) => TryParse(args, i, () => long.TryParse(part, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out long v) ? (true, v) : (false, 0)), - Type t when t == typeof(Guid) => TryParse(args, i, () => Guid.TryParse(part, out Guid v) ? (true, v) : (false, Guid.Empty)), - Type t when t == typeof(DateTime) => TryParse(args, i, () => DateTime.TryParseExact(part, "O", DateTimeFormatInfo.InvariantInfo, DateTimeStyles.RoundtripKind, out DateTime v) ? (true, v) : (false, DateTime.MinValue)), - Type t when t == typeof(DateTimeOffset) => TryParse(args, i, () => DateTimeOffset.TryParseExact(part, "O", DateTimeFormatInfo.InvariantInfo, DateTimeStyles.RoundtripKind, out DateTimeOffset v) ? (true, v) : (false, DateTimeOffset.MinValue)), - Type t when t == typeof(uint) => TryParse(args, i, () => uint.TryParse(part, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out uint v) ? (true, v) : (false, 0)), - Type t when t == typeof(ulong) => TryParse(args, i, () => ulong.TryParse(part, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out ulong v) ? (true, v) : (false, 0)), - Type t when t == typeof(ushort) => TryParse(args, i, () => ushort.TryParse(part, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out ushort v) ? (true, v) : (false, 0)), - _ => TryParse(args, i, () => (false, part)) - })) - { - compositeKey = Empty; - return false; - } - } + /// + /// Gets or sets the formatter function. + /// + /// The default implementation will format each argument (from the ) to be universal, deterministic, and culture-independent. + public static Func ToStringFormatter + { + get; + set => field = value.ThrowIfNull(); + } = ck => string.Join(',', ck.Args.Select(ArgumentToString)); - compositeKey = new CompositeKey(args); - return true; - } + /// + /// Converts an argument into a universal, deterministic, and culture-independent . + /// + private static string? ArgumentToString(object? arg) + { + if (arg is null) + return null; - /// - /// Attempt parse and update the array. - /// - private static bool TryParse(object?[] args, int index, Func<(bool, T)> parse) + return arg switch { - (bool parsed, T value) = parse(); - args[index] = value; - return parsed; - } - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(string? identifier) => new(identifier); - - /// - /// Implicitly converts an to a . - /// - /// The identifier. - public static implicit operator CompositeKey(short identifier) => new(identifier); - - /// - /// Implicitly converts an to a . - /// - /// The identifier. - public static implicit operator CompositeKey(short? identifier) => new(identifier); - - /// - /// Implicitly converts an to a . - /// - /// The identifier. - public static implicit operator CompositeKey(int identifier) => new(identifier); - - /// - /// Implicitly converts an to a . - /// - /// The identifier. - public static implicit operator CompositeKey(int? identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(long identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(long? identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(Guid identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(Guid? identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(char? identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(char identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(DateTime? identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(DateTime identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(DateTimeOffset? identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(DateTimeOffset identifier) => new(identifier); - - /// - /// Implicitly converts an to a . - /// - /// The identifier. - public static implicit operator CompositeKey(ushort identifier) => new(identifier); - - /// - /// Implicitly converts an to a . - /// - /// The identifier. - public static implicit operator CompositeKey(ushort? identifier) => new(identifier); - - /// - /// Implicitly converts an to a . - /// - /// The identifier. - public static implicit operator CompositeKey(uint identifier) => new(identifier); - - /// - /// Implicitly converts an to a . - /// - /// The identifier. - public static implicit operator CompositeKey(uint? identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(ulong identifier) => new(identifier); - - /// - /// Implicitly converts a to a . - /// - /// The identifier. - public static implicit operator CompositeKey(ulong? identifier) => new(identifier); + string s => s, + Guid g => g.ToString("D"), + DateTimeOffset dto => dto.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture), + DateTime dt => dt.ToString("o", CultureInfo.InvariantCulture), + float f => f.ToString("R", CultureInfo.InvariantCulture), + double d => d.ToString("R", CultureInfo.InvariantCulture), + decimal m => m.ToString("G", CultureInfo.InvariantCulture), + bool b => b ? "true" : "false", + byte[] bytes => Convert.ToBase64String(bytes), + TimeSpan ts => ts.ToString("c", CultureInfo.InvariantCulture), + DateOnly d => d.ToString("O", CultureInfo.InvariantCulture), + TimeOnly t => t.ToString("O", CultureInfo.InvariantCulture), + char c => c.ToString(), + _ => Convert.ToString(arg, CultureInfo.InvariantCulture), + }; } } \ No newline at end of file diff --git a/src/CoreEx/Entities/CompositeKeyComparer.cs b/src/CoreEx/Entities/CompositeKeyComparer.cs index 17a3828e..38c4d3a0 100644 --- a/src/CoreEx/Entities/CompositeKeyComparer.cs +++ b/src/CoreEx/Entities/CompositeKeyComparer.cs @@ -1,67 +1,134 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -using System.Collections.Generic; - -namespace CoreEx.Entities +/// +/// Represents a comparer of equality for a . +/// +public class CompositeKeyComparer : IEqualityComparer { + private static readonly CompositeKeyComparer _default = new(); + private static readonly object _nothing = new(); + + /// + /// Gets the default instance. + /// + public static CompositeKeyComparer Default => _default; + + /// + /// Determines whether the specified values are equal from a perspective. + /// + /// The first object to compare. + /// The second object to compare. + /// if the values are equal; otherwise, . + /// This method supports comparing both and values. + public new bool Equals(object? x, object? y) + { + if (x is CompositeKey ckx && y is CompositeKey cky) + return Equals(ckx, cky); + else if (x is IEntityKey ekx && y is IEntityKey eky) + return Equals(ekx.EntityKey, eky.EntityKey); + else + return false; + } + /// - /// Represents a comparer of equality for a . + /// Determines whether the specified values are equal. /// - [System.Diagnostics.DebuggerStepThrough] - public class CompositeKeyComparer : IEqualityComparer + /// The first to compare. + /// The second to compare. + /// if the values are equal; otherwise, . + public bool Equals(CompositeKey x, CompositeKey y) { - private static readonly CompositeKeyComparer _default = new(); - private static readonly object _nullObject = new(); - - /// - /// Gets the default instance. - /// - public static CompositeKeyComparer Default => _default; - - /// - /// Determines whether the specified values are equal. - /// - /// The first to compare. - /// The second to compare. - /// true if the values are equal; otherwise, false. - public bool Equals(CompositeKey x, CompositeKey y) + // Fast path optimization: compare tuples directly without boxing + if (x.IsFastPath && y.IsFastPath) { - if (x.Args == null && y.Args == null) - return true; - else if (x.Args == null || y.Args == null) + if (x.FastPathCount != y.FastPathCount) return false; - else if (x.Args!.Length != y.Args!.Length) + + if (x.FastPath is not ITuple tupleX || y.FastPath is not ITuple tupleY) return false; - for (int i = 0; i < x.Args.Length; i++) + for (int i = 0; i < x.FastPathCount; i++) { - if (!GetArgValue(x.Args[i]).Equals(GetArgValue(y.Args[i]))) + if (!GetArgValue(tupleX[i]).Equals(GetArgValue(tupleY[i]))) return false; } return true; } - /// - /// Returns a hash code for the . - /// - /// The for which a hash code is to be returned. - /// A hash code for the . - public int GetHashCode(CompositeKey key) + // Fallback to Args comparison (materializes if needed) + if (x.Args == null && y.Args == null) + return true; + else if (x.Args == null || y.Args == null) + return false; + else if (x.Args!.Length != y.Args!.Length) + return false; + + for (int i = 0; i < x.Args.Length; i++) { - if (key.Args.Length == 0) - return 0; + if (!GetArgValue(x.Args[i]).Equals(GetArgValue(y.Args[i]))) + return false; + } - int hashCode = 0; - for (int i = 0; i < key.Args.Length; i++) - hashCode ^= GetArgValue(key.Args[i]).GetHashCode(); + return true; + } - return hashCode; + /// + /// Returns a hash code for the specified value. + /// + /// The value for which a hash code is to be returned. + /// A hash code for the . + /// This method supports both and values. + public int GetHashCode(object? value) + { + if (value is CompositeKey key) + return GetHashCode(key); + else if (value is IEntityKey entityKey) + return GetHashCode(entityKey.EntityKey); + else + return value is null ? _nothing.GetHashCode() : value.GetHashCode(); + } + + /// + /// Returns a hash code for the . + /// + /// The for which a hash code is to be returned. + /// A hash code for the . + public int GetHashCode(CompositeKey key) + { + // Fast path optimization: compute hash without boxing + if (key.IsFastPath) + { + if (key.FastPath is not ITuple tuple) + return 0; + + return key.FastPathCount switch + { + 0 => 0, + 1 => GetArgValue(tuple[0]).GetHashCode(), + 2 => HashCode.Combine(GetArgValue(tuple[0]), GetArgValue(tuple[1])), + 3 => HashCode.Combine(GetArgValue(tuple[0]), GetArgValue(tuple[1]), GetArgValue(tuple[2])), + 4 => HashCode.Combine(GetArgValue(tuple[0]), GetArgValue(tuple[1]), GetArgValue(tuple[2]), GetArgValue(tuple[3])), + _ => 0 + }; } - /// - /// Gets the argument value (handles a null value). - /// - private static object GetArgValue(object? arg) => arg ?? _nullObject; + // Fallback to Args (materializes if needed) + if (key.Args.Length == 0) + return 0; + + if (key.Args.Length == 1) + return GetArgValue(key.Args[0]).GetHashCode(); + + var hashCode = new HashCode(); + for (int i = 0; i < key.Args.Length; i++) + hashCode.Add(GetArgValue(key.Args[i])); + + return hashCode.ToHashCode(); } + + /// + /// Gets the argument value or nothing. + /// + private static object GetArgValue(object? arg) => arg ?? _nothing; } \ No newline at end of file diff --git a/src/CoreEx/Entities/DataMap.cs b/src/CoreEx/Entities/DataMap.cs new file mode 100644 index 00000000..e09fe919 --- /dev/null +++ b/src/CoreEx/Entities/DataMap.cs @@ -0,0 +1,49 @@ +namespace CoreEx.Entities; + +/// +/// Provides a simple extension of for use as a data map with -based keys and values. +/// +/// The value . +/// Additionally, the -based key when serialized to JSON will not be converted to camelCase as is often the default for JSON serialization. This is to ensure that the key +/// is preserved as-is when serialized and deserialized, which is important for scenarios where the key may be case-sensitive or where the original casing needs to be maintained. +/// See . +/// Note that the documentation indicates that it should be considered obsolete; hence not used, and is provided as a modern alternative. +public class DataMap : Dictionary +{ + /// + /// Initializes a new instance of the class. + /// + public DataMap() : base() { } + + /// + /// Initializes a new instance of the class with the specified dictionary. + /// + /// The dictionary whose elements are copied to the new . + public DataMap(IDictionary dictionary) : base(dictionary) { } + + /// + /// Initializes a new instance of the class that uses the specified string comparer for key comparisons. + /// + /// The to use for comparing keys. + public DataMap(IEqualityComparer? comparer) : base(comparer) { } + + /// + /// Initializes a new instance of the class with the specified initial capacity. + /// + /// The number of elements that the can initially contain. + public DataMap(int capacity) : base(capacity) { } + + /// + /// Initializes a new instance of the class with the specified dictionary and string comparer for key comparisons. + /// + /// The dictionary whose elements are copied to the new . + /// The to use for comparing keys. + public DataMap(IDictionary dictionary, IEqualityComparer? comparer) : base(dictionary, comparer) { } + + /// + /// Initializes a new instance of the class with the specified initial capacity and key comparer. + /// + /// The number of elements that the can initially contain. + /// The to use for comparing keys. + public DataMap(int capacity, IEqualityComparer? comparer) : base(capacity, comparer) { } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/DateTimeTransform.cs b/src/CoreEx/Entities/DateTimeTransform.cs index f058ef5e..55fe992f 100644 --- a/src/CoreEx/Entities/DateTimeTransform.cs +++ b/src/CoreEx/Entities/DateTimeTransform.cs @@ -1,44 +1,38 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -using System; - -namespace CoreEx.Entities +/// +/// Represents a transform option for a value. +/// +/// Note: consider using a or type as these may be more applicable.See . +public enum DateTimeTransform { /// - /// Represents a transform option for a value. + /// Indicates that the value should be used. /// - /// The option to perform specified transformations ensures that a class is updated with the correctly specified values and . - /// This is especially important where serialization is required across the likes of time-zones to ensure that the correct values are passed. - public enum DateTimeTransform - { - /// - /// Indicates that the value should be used. - /// - UseDefault, + UseDefault, - /// - /// No transform required; the value will be updated as-is. - /// - None, + /// + /// No transform required; the value will be left as-is. + /// + None, - /// - /// A DateOnly transform is required; the value will be updated with the only and the will be set to . - /// - DateOnly, + /// + /// A DateOnly transform is required; the value will be updated with the only and the will be set to . + /// + DateOnly, - /// - /// A DateTime transform is required; the value will be updated as-is and the will be set to . - /// - DateTimeLocal, + /// + /// A DateTime transform is required; the value will be updated as-is and the will be set to . + /// + DateTimeLocal, - /// - /// A DateTime transform is required; the value will be updated as-is and the will be set to . - /// - DateTimeUtc, + /// + /// A DateTime transform is required; the value will be updated as-is and the will be set to . + /// + DateTimeUtc, - /// - /// A DateTime transform is required; the value will be updated as-is and the will be set to . - /// - DateTimeUnspecified - } + /// + /// A DateTime transform is required; the value will be updated as-is and the will be set to . + /// + DateTimeUnspecified } \ No newline at end of file diff --git a/src/CoreEx/Entities/ETag.cs b/src/CoreEx/Entities/ETag.cs new file mode 100644 index 00000000..e609e958 --- /dev/null +++ b/src/CoreEx/Entities/ETag.cs @@ -0,0 +1,198 @@ +namespace CoreEx.Entities; + +/// +/// Provides the capabilities. +/// +public static class ETag +{ + /// + /// Compares two values. + /// + /// The first entity tag. + /// The second entity tag. + /// true where the values match; otherwise, false. + public static bool TryCompare(IReadOnlyETag? etagA, IReadOnlyETag? etagB) => TryCompare(etagA?.ETag, etagB?.ETag); + + /// + /// Compares two ETag values. + /// + /// The first entity tag. + /// The second entity tag. + /// true where the values match; otherwise, false. + public static bool TryCompare(string? etagA, string? etagB) => etagA == etagB; + + /// + /// Compares two values and throws a where they do not match. + /// + /// The first entity tag. + /// The second entity tag. + /// The override. + /// The adjuster. + public static void Compare(IReadOnlyETag? etagA, IReadOnlyETag? etagB, LText? message = null, Action? adjuster = null) => Compare(etagA?.ETag, etagB?.ETag, message, adjuster); + + /// + /// Compares two ETag values and throws a where they do not match. + /// + /// The first entity tag. + /// The second entity tag. + /// The override. + /// The adjuster. + public static void Compare(string? etagA, string? etagB, LText? message = null, Action? adjuster = null) + { + if (TryCompare(etagA, etagB)) + return; + + var cex = new ConcurrencyException(message); + adjuster?.Invoke(cex); + throw cex; + } + + /// + /// Compares two values and returns a where they do not match. + /// + /// The first entity tag. + /// The second entity tag. + /// The override. + /// The adjuster. + /// where the values match; otherwise, . + public static Result CompareWithResult(IReadOnlyETag? etagA, IReadOnlyETag? etagB, LText? message = null, Action? adjuster = null) => CompareWithResult(etagA?.ETag, etagB?.ETag, message, adjuster); + + /// + /// Compares two ETag values and returns a where they do not match. + /// + /// The first entity tag. + /// The second entity tag. + /// The override. + /// The adjuster. + /// where the values match; otherwise, . + public static Result CompareWithResult(string? etagA, string? etagB, LText? message = null, Action? adjuster = null) + { + if (TryCompare(etagA, etagB)) + return Result.Success; + + var cex = new ConcurrencyException(message); + adjuster?.Invoke(cex); + return cex; + } + + /// + /// Formats a as an by bookending with the requisite double quotes character; for example 'abc' would be formatted as '"abc"'. + /// + /// The value to format. + /// The formatted . + [return: NotNullIfNotNull(nameof(value))] + public static string? FormatETag(string? value) + { + if (value is null) + return value; + + if (value.StartsWith('\"') && value.EndsWith('\"')) + return value; + + if (value.StartsWith("W/\"") && value.EndsWith('\"')) + return value[2..]; + + return $"\"{value}\""; + } + + /// + /// Parses an by removing any weak prefix ('W/') double quotes character bookends; for example '"abc"' would be formatted as 'abc'. + /// + /// The to unformat. + /// The unformatted value. + [return: NotNullIfNotNull(nameof(etag))] + public static string? ParseETag(string? etag) => string.IsNullOrEmpty(etag) ? etag : ParseETag(etag.AsSpan()); + + /// + /// Parses an by removing any weak prefix ('W/') double quotes character bookends; for example '"abc"' would be formatted as 'abc'. + /// + /// The to unformat. + /// The unformatted value. + [return: NotNullIfNotNull(nameof(etag))] + public static string ParseETag(ReadOnlySpan etag) + { + if (etag.IsEmpty) + return etag.ToString(); + + if (etag[0] == '\"' && etag[^1] == '\"') + return etag[1..^1].ToString(); + + if (etag.StartsWith("W/\"") && etag[^1] == '\"') + return etag[2..^1].ToString(); + + return etag.ToString(); + } + + /// + /// Generates an ETag for a by serializing to JSON and using an hash. + /// + /// The . + /// The value. + /// The optional . + /// Additional parts to include in the hash. + /// The generated ETag. + [return: NotNullIfNotNull(nameof(value))] + public static string? Generate(T? value, JsonSerializerOptions? jsonSerializerOptions = null, params IEnumerable parts) + { + if (value is null) + return null; + + // Serialize to JSON and then hash. + var bytes = JsonSerializer.SerializeToUtf8Bytes(value, jsonSerializerOptions ?? JsonDefaults.SerializerOptions); + byte[] hash; + + if (!parts.Any()) + hash = SHA256.HashData(bytes); + else + { + using var ih = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + ih.AppendData(bytes); + foreach (var part in parts) + { + ih.AppendData(new BinaryData(part)); + } + + hash = ih.GetCurrentHash(); + } + + return ConvertHash(hash); + } + + /// + /// Generates an ETag of the using an hash. + /// + /// The parts to hash. + /// The generated ETag. + public static string? Generate(params string[] parts) + { + if (parts is null || parts.Length == 0) + return null; + + byte[] hash; + + if (parts.Length == 1) + hash = SHA256.HashData(new BinaryData(parts[0])); + else + { + using var ih = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (var part in parts) + { + ih.AppendData(new BinaryData(part)); + } + + hash = ih.GetCurrentHash(); + } + + return ConvertHash(hash); + } + + /// + /// Converts the hash into a twelve (12) character string. + /// + private static string ConvertHash(byte[] hash) + { + // A hash function produces a fixed-size output — for SHA-256 that’s 256 bits (32 bytes). For an ETag we do not need the whole 256 bits. Hash functions like SHA-256 distribute entropy evenly across all bytes. + // There is no “hot” region — any subset of bytes is just as random and unique-looking as any other. Therefore, grabbing the first 6 bytes is a perfectly valid way to shorten it. + return Convert.ToHexString(hash, 0, 6).ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/EntitiesExtensions.FeatureSupport.cs b/src/CoreEx/Entities/EntitiesExtensions.FeatureSupport.cs new file mode 100644 index 00000000..2ae15d1e --- /dev/null +++ b/src/CoreEx/Entities/EntitiesExtensions.FeatureSupport.cs @@ -0,0 +1,46 @@ +namespace CoreEx.Entities; + +public static partial class EntitiesExtensions +{ + extension(FeatureSupport support) + { + /// + /// Indicates whether the is . + /// + public bool IsNone => support == FeatureSupport.NotSupported; + + /// + /// Indicates whether the is . + /// + public bool IsReadOnly => support == FeatureSupport.ReadOnly; + + /// + /// Indicates whether the is . + /// + public bool IsMutable => support == FeatureSupport.Mutable; + + /// + /// Indicates whether the is supported (not ). + /// + public bool IsSupported => support != FeatureSupport.NotSupported; + + /// + /// Determines whether the has the specified feature; being , or . + /// + /// The to determine the feature support for. + /// The mutable (is assignable from) feature. + /// The read-only (is assignable from) feature. + /// The . + /// Where implements then returns ; otherwise, where implements + /// then returns , finally returning + public static FeatureSupport Determine() + { + if (typeof(TMutable).IsAssignableFrom(typeof(T))) + return FeatureSupport.Mutable; + else if (typeof(TReadonly).IsAssignableFrom(typeof(T))) + return FeatureSupport.ReadOnly; + else + return FeatureSupport.NotSupported; + } + } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/EntitiesExtensions.IEnumerable.cs b/src/CoreEx/Entities/EntitiesExtensions.IEnumerable.cs new file mode 100644 index 00000000..aec01069 --- /dev/null +++ b/src/CoreEx/Entities/EntitiesExtensions.IEnumerable.cs @@ -0,0 +1,123 @@ +namespace CoreEx.Entities; + +/// +/// Provides entity-oriented extension methods. +/// +public static partial class EntitiesExtensions +{ + /// + /// Filters a sequence of values based on a only . + /// + /// The element . + /// The sequence of elements. + /// Indicates to perform an underlying only when ; otherwise, no Where is invoked. + /// A function to test each element for a condition. + /// An that contains elements from the input sequence that satisfy the condition. + public static IEnumerable WhereWhen(this IEnumerable source, bool when, Func predicate) => when ? source.Where(predicate) : source; + + /// + /// Filters a sequence based on a only when the is not the default value for the . + /// + /// The element . + /// The with value . + /// The sequence of elements. + /// Indicates to perform an underlying only when the with is not the default value; otherwise, no Where is invoked. + /// A function to test each element for a condition. + /// An that contains elements from the input sequence that satisfy the condition. + /// Where the is an it will also ensure there is at least a single item. + public static IEnumerable WhereWith(this IEnumerable source, TWith with, Func predicate) + { + if (Comparer.Default.Compare(with, default!) != 0) + { + if (with is not string && with is IEnumerable ie && !ie.GetEnumerator().MoveNext()) + return source; + + return source.Where(predicate); + } + + return source; + } + + /// + /// Filters a sequence using the specified and containing supported wildcards. + /// + /// The element . + /// The sequence of elements. + /// The function to select the element value to match. + /// The pattern to match the result of each element . + /// Indicates whether the comparison should ignore case (default) or not. + /// Indicates whether a check should also be performed before the comparion occurs (defaults to ). + /// The configuration to use; where it will use . + /// An that contains the elements from the sequence after applying the wildcard . + public static IEnumerable WhereWildcard(this IEnumerable source, Func selector, string? pattern, bool ignoreCase = true, bool checkForNull = true, Wildcard? wildcard = null) where TSource : class + { + selector.ThrowIfNull(); + + var wc = wildcard ?? Wildcard.Default ?? Wildcard.MultiBasic; + var wr = wc.Parse(pattern).ThrowOnError(); + + // Exit stage left where nothing to do. + if (wr.Selection.HasFlag(WildcardSelection.None)) + return source; + + // Handle the Equal. + var sc = ignoreCase ? StringComparison.CurrentCultureIgnoreCase : StringComparison.CurrentCulture; + if (wr.Selection.HasFlag(WildcardSelection.Equal)) + return source.Where(x => + { + var v = selector(x); + return checkForNull ? v is not null && v.Equals(pattern, sc) : v!.Equals(pattern, sc); + }); + + // Handle the easy Contains/StartsWith/Endswith. + if (!wr.Selection.HasFlag(WildcardSelection.SingleWildcard) && !wr.Selection.HasFlag(WildcardSelection.Embedded)) + { + if (wr.Selection.HasFlag(WildcardSelection.Single)) + return source; + + if (wr.Selection.HasFlag(WildcardSelection.Contains)) + return source.Where(x => + { + var v = selector(x); + return checkForNull ? v is not null && v.Contains(wr.GetTextWithoutWildcards()!, sc) : v!.Contains(wr.GetTextWithoutWildcards()!, sc); + }); + else if (wr.Selection.HasFlag(WildcardSelection.StartsWith)) + return source.Where(x => + { + var v = selector(x); + return checkForNull ? v is not null && v.StartsWith(wr.GetTextWithoutWildcards()!, sc) : v!.StartsWith(wr.GetTextWithoutWildcards()!, sc); + }); + else if (wr.Selection.HasFlag(WildcardSelection.EndsWith)) + return source.Where(x => + { + var v = selector(x); + return checkForNull ? v is not null && v.EndsWith(wr.GetTextWithoutWildcards()!, sc) : v!.EndsWith(wr.GetTextWithoutWildcards()!, sc); + }); + } + + // Handle the remainder using a regex. + var regex = wr.CreateRegex(ignoreCase); + return source.Where(x => + { + var v = selector(x); + return v is not null && regex.IsMatch(v); + }); + } + + /// + /// Filters a sequence according to the specified or . + /// + /// The element . + /// The sequence of elements. + /// The . + /// An that contains the elements from the sequence after applying the . + /// The where will default to . + public static IEnumerable WithPaging(this IEnumerable source, PagingArgs? paging = null) + { + if (paging?.IsNone ?? false) + return source; + + paging ??= PagingArgs.Create(); + return source.Skip(paging.Skip).Take(paging.Take); + } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/EntityKeyCollection.cs b/src/CoreEx/Entities/EntityKeyCollection.cs deleted file mode 100644 index 509bdbb7..00000000 --- a/src/CoreEx/Entities/EntityKeyCollection.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Collections.Generic; -using System.Linq; -using System; - -namespace CoreEx.Entities -{ - /// - /// Represents an with -based items. - /// - /// The item . - /// This class is underpinned by a and does not manage/guarantee uniqueness. - public class EntityKeyCollection : List, ICompositeKeyCollection where T : IEntityKey - { - /// - public bool ContainsKey(CompositeKey key) => this.Any(x => key.Equals(x.EntityKey)); - - /// - /// Indicates whether an item with the specified exists. - /// - /// The key values. - /// true where the item exists; otherwise, false. - public bool ContainsKey(params object?[] keys) => ContainsKey(new CompositeKey(keys)); - - /// - public T? GetByKey(CompositeKey key) => this.Where(x => key.Equals(x.EntityKey)).FirstOrDefault(); - - /// - /// Gets the first item with the specified . - /// - /// The key values. - /// The item where found; otherwise, null. - public T? GetByKey(params object?[] keys) => GetByKey(new CompositeKey(keys)); - - /// - public bool IsAnyDuplicates() => Count != Math.Min(1, this.Count(x => x == null)) + this.Where(x => x != null).Select(x => x.EntityKey).Distinct(CompositeKeyComparer.Default).Count(); - - /// - public void RemoveByKey(CompositeKey key) => this.Where(x => x.EntityKey == key).ToList().ForEach(pk => Remove(pk)); - - /// - /// Removes all items with the specified primary . - /// - /// The key values. - public void RemoveByKey(params object?[] keys) => RemoveByKey(new CompositeKey(keys)); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/ChangeLogEx.cs b/src/CoreEx/Entities/Extended/ChangeLogEx.cs deleted file mode 100644 index 1ba6cfc9..00000000 --- a/src/CoreEx/Entities/Extended/ChangeLogEx.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; - -namespace CoreEx.Entities.Extended -{ - /// - /// Represents an extended . - /// - public class ChangeLogEx : EntityBase, IChangeLogAudit - { - private DateTime? _createdDate; - private string? _createdBy; - private DateTime? _updatedDate; - private string? _updatedBy; - - /// - /// Gets or sets the created . - /// - public DateTime? CreatedDate { get => _createdDate; set => SetValue(ref _createdDate, value); } - - /// - /// Gets or sets the created by (username). - /// - public string? CreatedBy { get => _createdBy; set => SetValue(ref _createdBy, value); } - - /// - /// Gets or sets the updated . - /// - public DateTime? UpdatedDate { get => _updatedDate; set => SetValue(ref _updatedDate, value); } - - /// - /// Gets or sets the updated by (username). - /// - public string? UpdatedBy { get => _updatedBy; set => SetValue(ref _updatedBy, value); } - - /// - protected override IEnumerable GetPropertyValues() - { - yield return CreateProperty(nameof(CreatedDate), CreatedDate, v => CreatedDate = v); - yield return CreateProperty(nameof(CreatedBy), CreatedBy, v => CreatedBy = v); - yield return CreateProperty(nameof(UpdatedDate), UpdatedDate, v => UpdatedDate = v); - yield return CreateProperty(nameof(CreatedBy), UpdatedBy, v => UpdatedBy = v); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/CollectionItemChangedEventArgs.cs b/src/CoreEx/Entities/Extended/CollectionItemChangedEventArgs.cs deleted file mode 100644 index 7974350b..00000000 --- a/src/CoreEx/Entities/Extended/CollectionItemChangedEventArgs.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.ComponentModel; - -namespace CoreEx.Entities.Extended -{ - /// - /// Provides data for the when an item within an collection is changed. - /// - /// The item that had the property change. - /// The name of the property that changed. - public class CollectionItemChangedEventArgs(object? item, string? propertyName) : PropertyChangedEventArgs(propertyName) - { - /// - /// Gets the item that had the property change. - /// - public object? Item { get; } = item; - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/CollectionItemChangedEventHandler.cs b/src/CoreEx/Entities/Extended/CollectionItemChangedEventHandler.cs deleted file mode 100644 index 376bd6d0..00000000 --- a/src/CoreEx/Entities/Extended/CollectionItemChangedEventHandler.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities.Extended -{ - /// - /// Represents the method that will handle the event raised when a property is changed on a collection item. - /// - /// The source of the event. - /// The . - public delegate void CollectionItemChangedEventHandler(object sender, CollectionItemChangedEventArgs e); -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/EntityBase.cs b/src/CoreEx/Entities/Extended/EntityBase.cs deleted file mode 100644 index 4dd7cd8a..00000000 --- a/src/CoreEx/Entities/Extended/EntityBase.cs +++ /dev/null @@ -1,177 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Text.Json.Serialization; - -namespace CoreEx.Entities.Extended -{ - /// - /// Provides the base capabilities for an entended entity. - /// - /// Inherits from and implements , and . These additional capabilities are internally implemented by using the - /// which must return a for each and every updateable property. - /// - [System.Diagnostics.DebuggerStepThrough] - public abstract class EntityBase : EntityCore, ICleanUp, IInitial, ICopyFrom - { - /// - /// Creates a new . - /// - /// The property . - /// The property name. - /// The property value. - /// The action to set (override) the value with the specified value. - /// The optional default value. - /// The . - protected static PropertyValue CreateProperty(string name, T value, Action setValue, T? defaultValue = default) => new(name,value, setValue, defaultValue); - - /// - [JsonIgnore] - public virtual bool IsInitial => !GetPropertyValues().Any(x => !x.IsInitial); - - /// - /// Gets all the property values () for the entity. - /// - /// An for all properties. - /// Used to enable additional capabilities such as , , , , , - /// and . - protected abstract IEnumerable GetPropertyValues(); - - /// - public override int GetHashCode() - { - var hash = new HashCode(); - GetPropertyValues().ForEach(pv => hash.Add(pv.GetHashCode())); - return hash.ToHashCode(); - } - - /// - public override bool Equals(object? other) - { - if (other is not EntityBase otherv) - return false; - else if (ReferenceEquals(this, other)) - return true; - else if (GetType() != other.GetType()) - return false; - - var el = GetPropertyValues().GetEnumerator(); - var er = otherv.GetPropertyValues().GetEnumerator(); - while (el.MoveNext()) - { - er.MoveNext(); - if (!el.Current.AreEqual(er.Current)) - return false; - } - - return true; - } - - /// - /// Compares two values for equality. - /// - /// A. - /// B. - /// true indicates equal; otherwise, false for not equal. - public static bool operator ==(EntityBase? a, EntityBase? b) => Equals(a, b); - - /// - /// Compares two values for non-equality. - /// - /// A. - /// B. - /// true indicates not equal; otherwise, false for equal. - public static bool operator !=(EntityBase? a, EntityBase? b) => !Equals(a, b); - - /// - /// Performs a deep copy from another object updating this instance. - /// - /// The object to copy from. - public virtual void CopyFrom(object? other) - { - if (ReferenceEquals(this, other)) - return; - - if (other is EntityBase otherv) - { - var t = GetType(); - var to = other.GetType(); - if (t == other.GetType()) - { - var el = GetPropertyValues().GetEnumerator(); - var er = otherv.GetPropertyValues().GetEnumerator(); - while (el.MoveNext()) - { - er.MoveNext(); - el.Current.CopyFrom(er.Current); - } - - return; - } - - if (t.IsAssignableFrom(to) || t.IsSubclassOf(to)) - { - var el = GetPropertyValues().GetEnumerator(); - var er = otherv.GetPropertyValues().GetEnumerator(); - - var opvc = new PropertyValueCollection(); - while (er.MoveNext()) { opvc.Add(er.Current); } - - while (el.MoveNext()) - { - if (opvc.TryGetValue(el.Current.Name, out var opv)) - el.Current.CopyFrom(opv); - } - - return; - } - } - - throw new ArgumentException($"Other value must be the same type or is assignable/subclass from: {GetType().FullName}.", nameof(other)); - } - - private class PropertyValueCollection : KeyedCollection - { - protected override string GetKeyForItem(IPropertyValue item) => item.Name; - } - - /// - public virtual void CleanUp() => GetPropertyValues().ForEach(pv => pv.Clean()); - - /// - public override string ToString() - { - if (this is IIdentifier ii) - return $"{base.ToString()} Id={ii.Id}"; - else if (this is IPrimaryKey pk) - return $"{base.ToString()} PrimaryKey={pk.PrimaryKey}"; - else if (this is IEntityKey ek) - return $"{base.ToString()} EntityKey={ek.EntityKey}"; - else - return base.ToString()!; - } - - /// - protected override void OnAcceptChanges() - { - foreach (var pv in GetPropertyValues()) - { - if (pv.Value is EntityCore ec) - ec.AcceptChanges(); - } - } - - /// - protected override void OnMakeReadOnly() - { - foreach (var pv in GetPropertyValues()) - { - if (pv.Value is EntityCore ec) - ec.MakeReadOnly(); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/EntityBaseCollection.cs b/src/CoreEx/Entities/Extended/EntityBaseCollection.cs deleted file mode 100644 index c796abe2..00000000 --- a/src/CoreEx/Entities/Extended/EntityBaseCollection.cs +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Linq; - -namespace CoreEx.Entities.Extended -{ - /// - /// Represents an collection class. - /// - /// The . - /// The collection itself. - [System.Diagnostics.DebuggerStepThrough] - public abstract class EntityBaseCollection : ObservableCollection, INotifyCollectionItemChanged, IEntityBaseCollection - where TEntity : EntityBase - where TSelf : EntityBaseCollection, new() - { - /// - /// Initializes a new instance of the class. - /// - protected EntityBaseCollection() : base() => OnInitialization(); - - /// - /// Initializes a new instance of the class. - /// - /// The collection to add. - protected EntityBaseCollection(IEnumerable collection) : base(collection) => OnInitialization(); - - /// - /// Provides an opportunity to extend initialization when the object is constructed. - /// - /// Added to support scenarios whether the class is defined using the likes of partial classes to provide a means to easily add functionality during the constructor process. - protected virtual void OnInitialization() { } - - /// - /// Adds the items of the specified collection to the end of the . - /// - /// The collection containing the items to add. - public void AddRange(IEnumerable collection) - { - if (collection == null) - return; - - foreach (TEntity item in collection) - { - Add(item); - } - } - - /// - /// Gets a wrapper around the . - /// - /// This is provided to enable the likes of LINQ based queries over the collection. - public new IEnumerable Items => base.Items; - - /// - /// Creates a deep copy of the entity collection (all items will also be cloned). - /// - /// A deep copy of the entity collection. - public object Clone() - { - var clone = new TSelf(); - this.ForEach(item => clone.Add(item.ForceClone())); - return clone; - } - - /// - public override bool Equals(object? other) - { - if (other is not TSelf otherv) - return false; - else if (ReferenceEquals(this, other)) - return true; - else if (other == null) - return false; - else if (Count != otherv.Count) - return false; - - for (int i = 0; i < Count; i++) - { - if (!this[i].Equals(otherv[i])) - return false; - } - - return true; - } - - /// - /// Compares two values for equality. - /// - /// A. - /// B. - /// true indicates equal; otherwise, false for not equal. - public static bool operator ==(EntityBaseCollection? a, EntityBaseCollection? b) => Equals(a, b); - - /// - /// Compares two values for non-equality. - /// - /// A. - /// B. - /// true indicates not equal; otherwise, false for equal. - public static bool operator !=(EntityBaseCollection? a, EntityBaseCollection? b) => !Equals(a, b); - - /// - /// Returns a hash code for the . - /// - /// A hash code for the . - public override int GetHashCode() - { - var hash = new HashCode(); - foreach (var item in this) - { - hash.Add(item.GetHashCode()); - } - - return hash.ToHashCode(); - } - - /// - /// Performs a clean-up of the resetting item values as appropriate to ensure a basic level of data consistency. - /// - public void CleanUp() => this.ForEach(item => item.CleanUp()); - - /// - /// Collections do not support an initial state; will always be false. - /// - /// The collection reference should be set to null to achieve . - bool IInitial.IsInitial => false; - - /// - /// Overrides the method. - /// - /// The . - /// Sets to true. - protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) - { - if (IsReadOnly) - throw new InvalidOperationException("Collection is read only; item(s) cannot be added, updated or deleted."); - - base.OnCollectionChanged(e); - - if (e?.OldItems != null) - { - foreach (var item in e.OldItems) - { - var ei = (EntityBase)item; - if (ei != null) - ei.PropertyChanged -= Item_PropertyChanged; - } - } - - if (e?.NewItems != null) - { - foreach (var item in e.NewItems) - { - var ei = (EntityBase)item; - if (ei != null) - ei.PropertyChanged += Item_PropertyChanged; - } - } - - IsChanged = true; - } - - /// - /// Handles the item change and propogates. - /// - private void Item_PropertyChanged(object? sender, PropertyChangedEventArgs e) - { - IsChanged = true; - OnItemChanged(new CollectionItemChangedEventArgs(sender, e.PropertyName)); - } - - /// - /// Raises the event. - /// - /// The . - protected void OnItemChanged(CollectionItemChangedEventArgs e) => CollectionItemChanged?.Invoke(this, e); - - /// - public event CollectionItemChangedEventHandler? CollectionItemChanged; - - /// - /// Resets the entity state to unchanged by accepting the changes. - /// - /// This will trigger an for each item. - public virtual void AcceptChanges() - { - this.ForEach(item => item.AcceptChanges()); - IsChanged = false; - } - - /// - /// Indicates whether the collection has changed. - /// - public bool IsChanged { get; private set; } - - /// - public bool IsReadOnly { get; private set; } - - /// - /// - /// - /// This will trigger a for each item. - public void MakeReadOnly() - { - this.ForEach(item => item.MakeReadOnly()); - IsChanged = false; - IsReadOnly = true; - } - - /// - protected override void ClearItems() - { - CheckReadOnly(() => - { - CheckReentrancy(); - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, this)); - base.ClearItems(); - }); - } - - /// - protected override void InsertItem(int index, TEntity item) => CheckReadOnly(() => base.InsertItem(index, item)); - - /// - protected override void MoveItem(int oldIndex, int newIndex) => CheckReadOnly(() => base.MoveItem(oldIndex, newIndex)); - - /// - protected override void RemoveItem(int index) => CheckReadOnly(() => base.RemoveItem(index)); - - /// - protected override void SetItem(int index, TEntity item) => CheckReadOnly(() => base.SetItem(index, item)); - - /// - /// Checks if readonly and throws; otherwise, executes action. - /// - private void CheckReadOnly(Action action) - { - if (IsReadOnly) - throw new InvalidOperationException("Collection is read only; item(s) cannot be added, updated or deleted."); - else - action(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/EntityBaseDictionary.cs b/src/CoreEx/Entities/Extended/EntityBaseDictionary.cs deleted file mode 100644 index 738dbc54..00000000 --- a/src/CoreEx/Entities/Extended/EntityBaseDictionary.cs +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Linq; - -namespace CoreEx.Entities.Extended -{ - /// - /// Represents an dictionary class with a key of Type . - /// - /// The . - /// The dictionary itself. - /// The key is constrained to be of Type as this is a constraint related to JSON serialization. - public class EntityBaseDictionary : ObservableDictionary, INotifyPropertyChanged, INotifyCollectionItemChanged, IEntityBaseCollection - where TEntity : EntityBase, new() - where TSelf : EntityBaseDictionary, new() - { - /// - /// Initializes a new instance of the class using the for the comparer. - /// - protected EntityBaseDictionary() : base(StringComparer.OrdinalIgnoreCase) => OnInitialization(); - - /// - /// Initializes a new instance of the class using the for the comparer adding the passed . - /// - /// The items to add. - protected EntityBaseDictionary(IEnumerable> items) : base(items, StringComparer.OrdinalIgnoreCase) => OnInitialization(); - - /// - /// Provides an opportunity to extend initialization when the object is constructed. - /// - /// Added to support scenarios whether the class is defined using the likes of partial classes to provide a means to easily add functionality during the constructor process. - protected virtual void OnInitialization() { } - - /// - /// Gets or sets the automatic key selector from the item where the key is not explicitly specified. - /// - /// Default is to use the where implemented and results in a non-null ; otherwise, an - protected Func ItemKeySelector { get; set; } = item => (item is IEntityKey ek ? ek.EntityKey.ToString() : null) ?? throw new InvalidOperationException($"The item must implement {nameof(IEntityKey)} to automatically infer the key which also must not be null."); - - /// - /// Adds a range of to the dictionary inferring the key from each item using the . - /// - /// The items to add. - public void AddRange(IEnumerable items) => items.ForEach(Add); - - /// - /// Adds an item to the dictionary inferring the key from the itself using the . - /// - /// The item to add. - public void Add(TEntity item) => Add(ItemKeySelector(item), item); - - /// - /// Creates a deep copy of the entity dictionary (all items will also be cloned). - /// - /// A deep copy of the entity dictionary. - public object Clone() - { - var clone = new TSelf(); - this.ForEach(item => clone.Add(item.Key, item.Value == null ? default! : item.Value.Clone())); - return clone; - } - - /// - public override bool Equals(object? other) - { - if (other is not TSelf otherv) - return false; - else if (ReferenceEquals(this, other)) - return true; - else if (other == null) - return false; - else if (Count != otherv.Count) - return false; - - foreach (var item in this) - { - if (!otherv.TryGetValue(item.Key, out var value)) - return false; - - if (!item.Value.Equals(value)) - return false; - } - - return true; - } - - /// - /// Compares two values for equality. - /// - /// A. - /// B. - /// true indicates equal; otherwise, false for not equal. - public static bool operator ==(EntityBaseDictionary? a, EntityBaseDictionary? b) => Equals(a, b); - - /// - /// Compares two values for non-equality. - /// - /// A. - /// B. - /// true indicates not equal; otherwise, false for equal. - public static bool operator !=(EntityBaseDictionary? a, EntityBaseDictionary? b) => !Equals(a, b); - - /// - /// Returns a hash code for the . - /// - /// A hash code for the . - public override int GetHashCode() - { - var hash = new HashCode(); - foreach (var item in this) - { - hash.Add(item.Key.GetHashCode()); - hash.Add(item.Value?.GetHashCode() ?? 0); - } - - return hash.ToHashCode(); - } - - /// - /// Performs a clean-up of the resetting item values as appropriate to ensure a basic level of data consistency. - /// - public void CleanUp() => this.ForEach(item => item.Value?.CleanUp()); - - /// - /// Collections do not support an initial state; will always be false. - /// - /// The collection reference should be set to null to achieve . - bool IInitial.IsInitial => false; - - /// - /// Overrides the method. - /// - /// The . - /// Sets to true. - protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) - { - if (IsReadOnly) - throw new InvalidOperationException("Collection is read only; item(s) cannot be added, updated or deleted."); - - base.OnCollectionChanged(e); - - if (e?.OldItems != null) - { - foreach (var item in e.OldItems) - { - var ci = (KeyValuePair)item; - var ei = (EntityBase)ci.Value; - if (ei != null) - ei.PropertyChanged -= Item_PropertyChanged; - } - } - - if (e?.NewItems != null) - { - foreach (var item in e.NewItems) - { - var ci = (KeyValuePair)item; - var ei = (EntityBase)ci.Value; - if (ei != null) - ei.PropertyChanged += Item_PropertyChanged; - } - } - - IsChanged = true; - } - - /// - /// Handles the item change and propogates. - /// - private void Item_PropertyChanged(object? sender, PropertyChangedEventArgs e) - { - IsChanged = true; - OnItemChanged(new CollectionItemChangedEventArgs(sender, e.PropertyName)); - } - - /// - /// Raises the event. - /// - /// The . - protected void OnItemChanged(CollectionItemChangedEventArgs e) => CollectionItemChanged?.Invoke(this, e); - - /// - public event CollectionItemChangedEventHandler? CollectionItemChanged; - - /// - /// Resets the entity state to unchanged by accepting the changes. - /// - /// This will trigger an for each item. - public virtual void AcceptChanges() - { - this.ForEach(item => item.Value?.AcceptChanges()); - IsChanged = false; - } - - /// - /// Indicates whether the collection has changed. - /// - public bool IsChanged { get; private set; } - - /// - public bool IsReadOnly { get; private set; } - - /// - /// - /// - /// This will trigger a for each item. - public void MakeReadOnly() - { - this.ForEach(item => item.Value?.MakeReadOnly()); - IsChanged = false; - IsReadOnly = true; - } - - /// - protected override void ClearItems() - { - CheckReadOnly(() => - { - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, this)); - base.ClearItems(); - }); - } - - /// - protected override void AddItem(KeyValuePair item) => CheckReadOnly(() => base.AddItem(item)); - - /// - protected override void ReplaceItem(KeyValuePair oldItem, KeyValuePair newItem) => CheckReadOnly(() => base.ReplaceItem(oldItem, newItem)); - - /// - protected override bool RemoveItem(KeyValuePair item) => CheckReadOnly(() => base.RemoveItem(item)); - - /// - /// Checks if readonly and throws; otherwise, executes action. - /// - private void CheckReadOnly(Action action) - { - if (IsReadOnly) - throw new InvalidOperationException("Collection is read only; item(s) cannot be added, updated or deleted."); - else - action(); - } - - /// - /// Checks if readonly and throws; otherwise, executes action. - /// - private bool CheckReadOnly(Func func) - { - if (IsReadOnly) - throw new InvalidOperationException("Collection is read only; item(s) cannot be added, updated or deleted."); - else - return func(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/EntityCollectionResult.cs b/src/CoreEx/Entities/Extended/EntityCollectionResult.cs deleted file mode 100644 index 323d338e..00000000 --- a/src/CoreEx/Entities/Extended/EntityCollectionResult.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Net.Http; - -namespace CoreEx.Entities.Extended -{ - /// - /// Represents an class with a and underlying . - /// - /// The collection . - /// The entity item . - /// The entity itself. - /// Generally an is not intended for serialized ; the underlying is serialized with the returned as . - [System.Diagnostics.DebuggerStepThrough] - public class EntityCollectionResult : EntityBase, ICollectionResult, IPagingResult - where TColl : EntityBaseCollection, new() - where TEntity : EntityBase, new() - where TSelf : EntityCollectionResult, new() - { - private PagingResult? _paging; - private TColl? _collection; - - /// - /// Initializes a new instance of the class. - /// - public EntityCollectionResult() { } - - /// - /// Initializes a new instance of the class. - /// - /// Defaults the to the requesting . - public EntityCollectionResult(PagingArgs? paging) - { - if (paging != null) - _paging = Paging is PagingResult pr ? pr : new PagingResult(paging); - } - - /// - /// Gets or sets the underlying collection. - /// - public TColl Items { get => _collection ??= new TColl(); set => SetValue(ref _collection, value); } - - /// - /// Gets or sets the . - /// - /// Where this value is null it indicates that the paging was unable to be determined. - public PagingResult? Paging { get => _paging; set => SetValue(ref _paging, value); } - - /// - /// Gets the item . - /// - Type ICollectionResult.ItemType => typeof(TEntity); - - /// - protected override IEnumerable GetPropertyValues() - { - yield return CreateProperty(nameof(Paging), Paging, v => Paging = v); - yield return CreateProperty(nameof(Items), Items, v => Items = v!); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/EntityConsts.cs b/src/CoreEx/Entities/Extended/EntityConsts.cs deleted file mode 100644 index da6a378c..00000000 --- a/src/CoreEx/Entities/Extended/EntityConsts.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities.Extended -{ - /// - /// Provides constants for the extended entities. - /// - public static class EntityConsts - { - /// - /// Gets or sets the value is immutable message. - /// - public static string ValueIsImmutableMessage { get; set; } = "Value is immutable; cannot be changed once already set to a value."; - - /// - /// Gets or sets the entity is read only message. - /// - public static string EntityIsReadOnlyMessage { get; set; } = "Entity is read only; property cannot be changed."; - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/EntityCore.cs b/src/CoreEx/Entities/Extended/EntityCore.cs deleted file mode 100644 index e0ed66db..00000000 --- a/src/CoreEx/Entities/Extended/EntityCore.cs +++ /dev/null @@ -1,320 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Runtime.CompilerServices; -using System.Text.Json.Serialization; - -namespace CoreEx.Entities.Extended -{ - /// - /// Represents the core Entity capabilities including support. - /// - /// The class is not thread-safe; it does however, place a lock around all set operations to minimise concurrency challenges. - [System.Diagnostics.DebuggerStepThrough] - public abstract class EntityCore : INotifyPropertyChanged, IChangeTracking, IReadOnly - { -#if NET9_0_OR_GREATER - private readonly System.Threading.Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - private Dictionary? _propertyEventHandlers; - - /// - /// Initializes a new instance of the class. - /// - protected EntityCore() => OnInitialization(); - - /// - /// Provides an opportunity to extend initialization when the object is constructed. - /// - /// Added to support scenarios whether the class is defined using the likes of partial classes to provide a means to easily add functionality during the constructor process. - protected virtual void OnInitialization() { } - - /// - /// Occurs when a property value changes. - /// - public event PropertyChangedEventHandler? PropertyChanged; - - /// - /// Trigger the property(s) changed. - /// - private void TriggerPropertyChanged(string propertyName, params string[] propertyNames) - { - IsChanged = true; - - OnPropertyChanged(propertyName); - - foreach (string name in propertyNames) - { - if (!string.IsNullOrEmpty(name)) - OnPropertyChanged(name); - } - } - - /// - /// Raises the event (typically overridden with additional logic). - /// - /// The property name. - protected virtual void OnPropertyChanged(string propertyName) => RaisePropertyChanged(propertyName); - - /// - /// Raises the event only (). - /// - /// The property name. - protected void RaisePropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName.ThrowIfNull(nameof(propertyName)))); - - /// - /// Gets a property value (automatically instantiating new where current value is null). - /// - /// The property . - /// The property value to get. - static protected T GetAutoValue(ref T propertyValue) where T : class, new() => propertyValue ??= new T(); - - /// - /// Sets a property value and raises the event where applicable. - /// - /// The property value to set. - /// The value to set. - /// The default value to perform immutable check against. - /// Indicates whether the value is immutable; can not be changed once set. - /// The name of the primary property that changed. - /// true indicates that the property value changed; otherwise, false. - protected bool SetValue(ref T propertyValue, T setValue, bool immutable = false, T @default = default!, [CallerMemberName] string? propertyName = null) - { - propertyName.ThrowIfNullOrEmpty(nameof(propertyName)); - - lock (_lock) - { - // Check and see if the value has changed or not; exit if being set to same value. - var isChanged = true; - T val = Cleaner.Clean(setValue, false); - if (ReferenceEquals(propertyValue, val)) - isChanged = false; - else if (propertyValue is IComparable) - { - if (Comparer.Default.Compare(val, propertyValue) == 0) - isChanged = false; - } - - if (!isChanged) - return false; - - // Test is read only. - if (IsReadOnly) - throw new InvalidOperationException(EntityConsts.EntityIsReadOnlyMessage); - - // Test immutability. - if (immutable && Comparer.Default.Compare(propertyValue, @default) != 0) - throw new InvalidOperationException(EntityConsts.ValueIsImmutableMessage); - - // Unwire old value. - INotifyPropertyChanged? npc; - if (propertyValue != null) - { - npc = propertyValue as INotifyPropertyChanged; - if (npc != null) - npc.PropertyChanged -= GetValue_PropertyChanged(propertyName); - } - - // Update the property and trigger the property changed. - propertyValue = val; - TriggerPropertyChanged(propertyName); - - // Wire up new value. - if (val != null) - { - npc = val as INotifyPropertyChanged; - if (npc != null) - npc.PropertyChanged += GetValue_PropertyChanged(propertyName); - } - - return true; - } - } - - /// - /// Gets the for the named property. - /// - private PropertyChangedEventHandler GetValue_PropertyChanged(string propertyName) - { - _propertyEventHandlers ??= []; - - if (!_propertyEventHandlers.ContainsKey(propertyName)) - _propertyEventHandlers.Add(propertyName, (sender, e) => TriggerPropertyChanged(propertyName)); - - return _propertyEventHandlers[propertyName]; - } - - /// - /// Sets a property value and raises the event where applicable. - /// - /// The property value to set. - /// The value to set. - /// The . - /// The (defaults to ). - /// The (defaults to ). - /// Indicates whether the value is immutable; can not be changed once set. - /// The name of the primary property that changed. - /// true indicates that the property value changed; otherwise, false. - protected bool SetValue(ref string? propertyValue, string? setValue, StringTrim trim, StringTransform transform = StringTransform.UseDefault, StringCase casing = StringCase.UseDefault, bool immutable = false, [CallerMemberName] string? propertyName = null) - { - propertyName.ThrowIfNullOrEmpty(nameof(propertyName)); - - lock (_lock) - { - string? val = Cleaner.Clean(setValue, trim, transform, casing); - if (val == propertyValue) - return false; - - if (IsReadOnly) - throw new InvalidOperationException(EntityConsts.EntityIsReadOnlyMessage); - - if (immutable && propertyValue != null) - throw new InvalidOperationException(EntityConsts.ValueIsImmutableMessage); - - propertyValue = val!; - TriggerPropertyChanged(propertyName); - - return true; - } - } - - /// - /// Sets a property value and raises the event where applicable. - /// - /// The property value to set. - /// The value to set. - /// The . - /// The (defaults to ). - /// The (defaults to ). - /// Indicates whether the value is immutable; can not be changed once set. - /// The name of the primary property that changed. - /// true indicates that the property value changed; otherwise, false. - protected bool SetValue(ref string? propertyValue, string? setValue, StringTransform transform, StringTrim trim = StringTrim.UseDefault, StringCase casing = StringCase.UseDefault, bool immutable = false, [CallerMemberName] string? propertyName = null) - => SetValue(ref propertyValue, setValue, trim, transform, casing, immutable, propertyName); - - /// - /// Sets a property value and raises the event where applicable. - /// - /// The property value to set. - /// The value to set. - /// The . - /// The (defaults to ). - /// The (defaults to ). - /// Indicates whether the value is immutable; can not be changed once set. - /// The name of the primary property that changed. - /// true indicates that the property value changed; otherwise, false. - protected bool SetValue(ref string? propertyValue, string? setValue, StringCase casing, StringTransform transform = StringTransform.UseDefault, StringTrim trim = StringTrim.UseDefault, bool immutable = false, [CallerMemberName] string? propertyName = null) - => SetValue(ref propertyValue, setValue, trim, transform, casing, immutable, propertyName); - - /// - /// Sets a property value and raises the event where applicable. - /// - /// The property value to set. - /// The value to set. - /// The to be applied (defaults to ). - /// Indicates whether the value is immutable; can not be changed once set. - /// The name of the primary property that changed. - /// true indicates that the property value changed; otherwise, false. - protected bool SetValue(ref DateTime propertyValue, DateTime setValue, DateTimeTransform transform = DateTimeTransform.UseDefault, bool immutable = false, [CallerMemberName] string? propertyName = null) - { - propertyName.ThrowIfNullOrEmpty(nameof(propertyName)); - - lock (_lock) - { - DateTime val = Cleaner.Clean(setValue, transform); - if (val == propertyValue) - return false; - - if (IsReadOnly) - throw new InvalidOperationException(EntityConsts.EntityIsReadOnlyMessage); - - if (immutable && propertyValue != DateTime.MinValue) - throw new InvalidOperationException(EntityConsts.ValueIsImmutableMessage); - - propertyValue = val; - TriggerPropertyChanged(propertyName); - return true; - } - } - - /// - /// Sets a property value and raises the event where applicable. - /// - /// The property value to set. - /// The value to set. - /// Indicates whether the value is immutable; can not be changed once set. - /// The to be applied. - /// The name of the primary property that changed. - /// true indicates that the property value changed; otherwise, false. - protected bool SetValue(ref DateTime? propertyValue, DateTime? setValue, DateTimeTransform transform, bool immutable = false, [CallerMemberName] string? propertyName = null) - { - propertyName.ThrowIfNullOrEmpty(nameof(propertyName)); - - lock (_lock) - { - DateTime? val = Cleaner.Clean(setValue, transform); - if (val == propertyValue) - return false; - - if (IsReadOnly) - throw new InvalidOperationException(EntityConsts.EntityIsReadOnlyMessage); - - if (immutable && propertyValue != null) - throw new InvalidOperationException(EntityConsts.ValueIsImmutableMessage); - - propertyValue = val; - TriggerPropertyChanged(propertyName); - return true; - } - } - - /// - /// - /// - /// This will trigger the to perform the operation for all properties. - public void AcceptChanges() - { - lock (_lock) - { - OnAcceptChanges(); - IsChanged = false; - } - } - - /// - /// Applies the to all the underlying properties. - /// - protected virtual void OnAcceptChanges() { } - - /// - [JsonIgnore] - public bool IsChanged { get; private set; } - - /// - [JsonIgnore] - public bool IsReadOnly { get; private set; } - - /// - /// - /// - /// This will trigger the to perform the operation for all properties. - public void MakeReadOnly() - { - lock (_lock) - { - OnMakeReadOnly(); - IsChanged = false; - IsReadOnly = true; - } - } - - /// - /// Applies the to all the underlying properties. - /// - protected virtual void OnMakeReadOnly() { } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/EntityKeyBaseCollection.cs b/src/CoreEx/Entities/Extended/EntityKeyBaseCollection.cs deleted file mode 100644 index 0f1cd662..00000000 --- a/src/CoreEx/Entities/Extended/EntityKeyBaseCollection.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CoreEx.Entities.Extended -{ - /// - /// Represents an class. - /// - /// The . - /// The collection itself. - [System.Diagnostics.DebuggerStepThrough] - public abstract class EntityKeyBaseCollection : EntityBaseCollection, ICompositeKeyCollection - where TEntity : EntityBase, IEntityKey - where TSelf : EntityBaseCollection, new() - { - /// - /// Initializes a new instance of the class. - /// - protected EntityKeyBaseCollection() : base() { } - - /// - /// Initializes a new instance of the class. - /// - /// The entities to add. - protected EntityKeyBaseCollection(IEnumerable collection) : base(collection) { } - - /// - public bool ContainsKey(CompositeKey key) => Items.Any(x => key.Equals(x.EntityKey)); - - /// - /// Indicates whether an item with the specified exists. - /// - /// The key values. - /// true where the item exists; otherwise, false. - public bool ContainsKey(params object?[] keys) => ContainsKey(new CompositeKey(keys)); - - /// - /// Gets the first item by the specified primary . - /// - /// The . - /// The first item where found; otherwise, default. - public TEntity? GetByKey(CompositeKey key) => Items.Where(x => key.Equals(x.EntityKey)).FirstOrDefault(); - - /// - /// Gets the first item by the specified primary . - /// - /// The key values. - /// The first item where found; otherwise, default. - public TEntity? GetByKey(params object?[] keys) => GetByKey(new CompositeKey(keys)); - - /// - public bool IsAnyDuplicates() => Count != Math.Min(1, this.Count(ek => ek == null)) + this.Where(ek => ek != null).Select(ek => ek.EntityKey).Distinct(CompositeKeyComparer.Default).Count(); - - /// - public void RemoveByKey(CompositeKey key) => this.Where(ek => ek.EntityKey == key).ToList().ForEach(ek => Remove(ek)); - - /// - /// Removes all items with the specified primary . - /// - /// The key values. - public void RemoveByKey(params object?[] keys) => RemoveByKey(new CompositeKey(keys)); - - /// - public override bool Equals(object? obj) => base.Equals(obj); - - /// - public override int GetHashCode() => base.GetHashCode(); - - /// - /// Compares two values for equality. - /// - /// A. - /// B. - /// true indicates equal; otherwise, false for not equal. - public static bool operator ==(EntityKeyBaseCollection? a, EntityKeyBaseCollection? b) => Equals(a, b); - - /// - /// Compares two values for non-equality. - /// - /// A. - /// B. - /// true indicates not equal; otherwise, false for equal. - public static bool operator !=(EntityKeyBaseCollection? a, EntityKeyBaseCollection? b) => !Equals(a, b); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/ExtendedExtensions.cs b/src/CoreEx/Entities/Extended/ExtendedExtensions.cs deleted file mode 100644 index 47f20d11..00000000 --- a/src/CoreEx/Entities/Extended/ExtendedExtensions.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Entities.Extended -{ - /// - /// Provides extension methods for the extended entities capabilities. - /// - public static class ExtendedExtensions - { - /// - /// Creates a clone of by instantiating a new instance and performing a from the value. - /// - /// The entity . - /// The from value. - /// The new cloned instance. - public static T Clone(this T from) where T : ICopyFrom, new() - { - var clone = new T(); - clone.CopyFrom(from); - return clone; - } - - /// - /// Creates (attempts even where default contstructor status is unknown) a clone of by instantiating a new instance and performing a from the value. - /// - /// The entity . - /// The from value. - /// The new cloned instance. - internal static T ForceClone(this T from) where T : EntityBase - { - var clone = Activator.CreateInstance(); - clone.CopyFrom(from); - return clone; - } - - /// - /// Creates a new instance and performs a using the specified . - /// - /// The entity . - /// The from value. - /// The new copied instance. - public static T CopyFromAs(this EntityBase value) where T : EntityBase, new() => new T().Adjust(v => v.CopyFrom(value)); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/IChangeLogEx.cs b/src/CoreEx/Entities/Extended/IChangeLogEx.cs deleted file mode 100644 index 8663dd22..00000000 --- a/src/CoreEx/Entities/Extended/IChangeLogEx.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities.Extended -{ - /// - /// Provides an extended . - /// - public interface IChangeLogEx : IChangeLogAuditLog - { - /// - IChangeLogAudit? IChangeLogAuditLog.ChangeLogAudit - { - get => ChangeLog; - set - { - if (value is null) - ChangeLog = null; - else if (value is ChangeLogEx cl) - ChangeLog = cl; - else - { - ChangeLog = new ChangeLogEx - { - CreatedBy = value.CreatedBy, - CreatedDate = value.CreatedDate, - UpdatedBy = value.UpdatedBy, - UpdatedDate = value.UpdatedDate - }; - } - } - } - - /// - /// Gets or set the . - /// - ChangeLogEx? ChangeLog { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/ICopyFrom.cs b/src/CoreEx/Entities/Extended/ICopyFrom.cs new file mode 100644 index 00000000..6bc6cf68 --- /dev/null +++ b/src/CoreEx/Entities/Extended/ICopyFrom.cs @@ -0,0 +1,14 @@ +namespace CoreEx.Entities.Extended; + +/// +/// Enables a . +/// +public interface ICopyFrom +{ + /// + /// Copies into this. + /// + /// The from value. + /// Only mutable (set) properties will be copied; i.e. read-only (get) properties will remain unchanged. + void CopyFrom(TFrom from) where TFrom : class; +} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/IDefault.cs b/src/CoreEx/Entities/Extended/IDefault.cs new file mode 100644 index 00000000..fa9ba215 --- /dev/null +++ b/src/CoreEx/Entities/Extended/IDefault.cs @@ -0,0 +1,14 @@ +namespace CoreEx.Entities.Extended; + +/// +/// Enables a means to determine if a value is in its default state. +/// +/// For example; all underlying properties for an object have their respective default value. +public interface IDefault +{ + /// + /// Indicates whether the value is in its default state. + /// + /// indicates that the value is considered default; otherwise, . + bool IsDefault(); +} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/IEntityBaseCollection.cs b/src/CoreEx/Entities/Extended/IEntityBaseCollection.cs deleted file mode 100644 index 5822b2f5..00000000 --- a/src/CoreEx/Entities/Extended/IEntityBaseCollection.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Entities.Extended -{ - /// - /// Represents an collection and therefore certain capabilities can be assumed. - /// - public interface IEntityBaseCollection : ICloneable, ICleanUp, IInitial { } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/IIdentifierGenerator.cs b/src/CoreEx/Entities/Extended/IIdentifierGenerator.cs new file mode 100644 index 00000000..a2880ef2 --- /dev/null +++ b/src/CoreEx/Entities/Extended/IIdentifierGenerator.cs @@ -0,0 +1,36 @@ +namespace CoreEx.Entities.Extended; + +/// +/// Enables the generation of a new identifier value for any identifier . +/// +public interface IIdentifierGenerator +{ + /// + /// Generates a new (version 7 or other sequential GUID preferred). + /// + /// The newly generated . + Guid GenerateGuid(); + + /// + /// Generate a new identifier value. + /// + /// The identifier . + /// The newly generated identifier. + Task GenerateIdentifierAsync(); + + /// + /// Generate a new identifier value. + /// + /// The identifier . + /// The to generate for. + /// The newly generated identifier. + /// The allows for the likes of different identity sequences per for example. + Task GenerateIdentifierAsync() where TFor : class; + + /// + /// Assigns a generated identifier to the where the has a default value. + /// + /// The to generate for. + /// The value to assign an identifier for. + Task AssignIdentifierAsync(TFor value) where TFor : class; +} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/INotifyCollectionItemChanged.cs b/src/CoreEx/Entities/Extended/INotifyCollectionItemChanged.cs deleted file mode 100644 index 94cd6c13..00000000 --- a/src/CoreEx/Entities/Extended/INotifyCollectionItemChanged.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities.Extended -{ - /// - /// Notifies consumers when an underlying collection item changes. - /// - public interface INotifyCollectionItemChanged - { - /// - /// Occurs when the contents for a collection item is changed. - /// - event CollectionItemChangedEventHandler CollectionItemChanged; - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/IPropertyValue.cs b/src/CoreEx/Entities/Extended/IPropertyValue.cs deleted file mode 100644 index 6fb68735..00000000 --- a/src/CoreEx/Entities/Extended/IPropertyValue.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Entities.Extended -{ - /// - /// Defines the property value capabilities enabled by . - /// - public interface IPropertyValue - { - /// - /// Gets the property name. - /// - string Name { get; } - - /// - /// Gets the property value. - /// - object? Value { get; } - - /// - /// Indicates whether the value is considered the initial value. - /// - bool IsInitial { get; } - - /// - /// Cleans a value and overrides the value with null when the value is . - /// - public void Clean(); - - /// - /// Sets (overrides) the underlying with the specified . - /// - /// The overridding value. - /// This is needed to support and functionality. - void SetValue(object? value); - - /// - /// Indicates whether the other is equal to this. - /// - /// The to compare to. - /// true indicates they are equal; otherwise, false for not equal. - bool AreEqual(IPropertyValue propertyValue); - - /// - /// Gets the hash code for the . - /// - /// The hash code for the . - int GetHashCode(); - - /// - /// Performs a copy or clone from the other . - /// - /// The to copy or clone from. - void CopyFrom(IPropertyValue propertyValue); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/IdentifierGenerator.cs b/src/CoreEx/Entities/Extended/IdentifierGenerator.cs new file mode 100644 index 00000000..181b1fed --- /dev/null +++ b/src/CoreEx/Entities/Extended/IdentifierGenerator.cs @@ -0,0 +1,50 @@ +namespace CoreEx.Entities.Extended; + +/// +/// Provides a and where each is created using a . +/// +public class IdentifierGenerator : IIdentifierGenerator +{ + private static readonly IIdentifierGenerator _default = new IdentifierGenerator(); + + /// + /// Gets the current from the , or the default where not available. + /// + public static IIdentifierGenerator Current => ExecutionContext.GetService() ?? _default; + + /// + public Guid GenerateGuid() +#if NET9_0_OR_GREATER + => Guid.CreateVersion7(); +#else + => Guid.NewGuid(); +#endif + + /// + public Task GenerateIdentifierAsync() => Task.FromResult(typeof(TId) switch + { + Type _ when typeof(TId) == typeof(string) => Internal.Cast(GenerateGuid().ToString()), + Type _ when typeof(TId) == typeof(Guid) => Internal.Cast(GenerateGuid()), + _ => throw new NotSupportedException($"Identifier Type '{typeof(TId).Name}' is not supported; only String or Guid.") + }); + + /// + public async Task GenerateIdentifierAsync() where TFor : class => await GenerateIdentifierAsync().ConfigureAwait(false); + + /// + public async Task AssignIdentifierAsync(TFor value) where TFor : class + { + if (value is not IReadOnlyIdentifier ii) + return; + + if (value is IIdentifier iis) + iis.Id ??= await GenerateIdentifierAsync().ConfigureAwait(false); + else if (value is IIdentifier iig) + { + if (iig.Id == Guid.Empty) + iig.Id = await GenerateIdentifierAsync().ConfigureAwait(false); + } + else + throw new NotSupportedException($"Identifier Type '{ii.IdType.Name}' is not supported; only String or Guid."); + } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/ObservableDictionary.cs b/src/CoreEx/Entities/Extended/ObservableDictionary.cs deleted file mode 100644 index 23265ac6..00000000 --- a/src/CoreEx/Entities/Extended/ObservableDictionary.cs +++ /dev/null @@ -1,267 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using System.Linq; - -namespace CoreEx.Entities.Extended -{ - /// - /// An observable that supports . - /// - /// The key . - /// The value . - public class ObservableDictionary : IDictionary, IDictionary, IReadOnlyDictionary, INotifyCollectionChanged, INotifyPropertyChanged where TKey : notnull - { - private readonly Dictionary _dict; - private Func? _keyModifier; - - /// - /// Initializes a new instance of the with the default for the type of the key. - /// - public ObservableDictionary() => _dict = []; - - /// - /// Initializes a new instance of the with the specified . - /// - /// The implementation to use when comparing keys, or null to use the default for the type of the key. - public ObservableDictionary(IEqualityComparer comparer) => _dict = new(comparer); - - /// - /// Initializes a new instance of the with the default for the type of the key adding the passed . - /// - /// The items to add. - public ObservableDictionary(IEnumerable> collection) => _dict = new(collection); - - /// - /// Initializes a new instance of the with the specified adding the passed . - /// - /// The implementation to use when comparing keys, or null to use the default for the type of the key. - /// The items to add. - public ObservableDictionary(IEnumerable> collection, IEqualityComparer comparer) => _dict = new(collection, comparer); - - /// - /// Gets or sets the function that enabled modification of the key before usage. - /// - /// Enables an opportunity to modify the key before used internally; for example, to modify so that the key value is always uppercase. - /// The by default leverages this function; however, the when overridden may chose to ignore. - public Func? KeyModifier - { - get => _keyModifier; - set - { - if (_dict.Count > 0) - throw new InvalidOperationException($"{nameof(KeyModifier)} can only be updated when there are no items contained already within the dictionary; i.e. {nameof(Count)} must be zero."); - - _keyModifier = value; - } - } - - /// - public int Count => _dict.Count; - - /// - bool IDictionary.IsFixedSize => ((IDictionary)_dict).IsFixedSize; - - /// - bool ICollection.IsSynchronized => ((IDictionary)_dict).IsSynchronized; - - /// - bool IDictionary.IsReadOnly => ((IDictionary)_dict).IsReadOnly; - - /// - bool ICollection>.IsReadOnly => ((ICollection>)_dict).IsReadOnly; - - /// - object ICollection.SyncRoot => ((IDictionary)_dict).SyncRoot; - - /// - ICollection IDictionary.Keys => ((IDictionary)_dict).Keys; - - /// - public IEnumerable Keys => ((IReadOnlyDictionary)_dict).Keys; - - /// - ICollection IDictionary.Keys => ((IDictionary)_dict).Keys; - - /// - ICollection IDictionary.Values => ((IDictionary)_dict).Values; - - /// - public IEnumerable Values => ((IReadOnlyDictionary)_dict).Values; - - /// - ICollection IDictionary.Values => ((IDictionary)_dict).Values; - - /// - public object? this[object key] { get => this[(TKey)key]; set => this[(TKey)key] = (TValue)value!; } - - /// - TValue IDictionary.this[TKey key] { get => this[key]; set => this[key] = value; } - - /// - public TValue this[TKey key] - { - get => _dict[OnModifyKey(key)]; - - set - { - if (TryGetValue(key, out var old)) - { - if (ReferenceEquals(old, value)) - return; - else - ReplaceItem(new KeyValuePair(key, old), new KeyValuePair(key, value)); - } - else - AddItem(new KeyValuePair(key, value)); - } - } - - /// - void IDictionary.Add(object key, object? value) => Add((TKey)key, (TValue)value!); - - /// - public void Add(TKey key, TValue value) => AddItem(new KeyValuePair(key, value)); - - /// - void ICollection>.Add(KeyValuePair item) => AddItem(item); - - /// - void ICollection.CopyTo(Array array, int index) => ((ICollection)_dict).CopyTo(array, index); - - /// - void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) => ((ICollection>)_dict).CopyTo(array, arrayIndex); - - /// - void IDictionary.Remove(object key) => Remove((TKey)key); - - /// - IDictionaryEnumerator IDictionary.GetEnumerator() => ((IDictionary)_dict).GetEnumerator(); - - /// - IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_dict).GetEnumerator(); - - /// - public IEnumerator> GetEnumerator() => _dict.GetEnumerator(); - - /// - public bool Contains(object key) => ContainsKey((TKey)key); - - /// - bool ICollection>.Contains(KeyValuePair item) => ((ICollection>)_dict).Contains(item); - - /// - public bool ContainsKey(TKey key) => _dict.ContainsKey(OnModifyKey(key)); - - /// - public bool TryGetValue(TKey key, [NotNullWhen(true)] out TValue value) => _dict.TryGetValue(OnModifyKey(key), out value!); - - /// - public bool Remove(TKey key) => TryGetValue(key, out var value) && RemoveItem(new KeyValuePair(key, value)); - - /// - bool ICollection>.Remove(KeyValuePair item) => RemoveItem(item); - - /// - public void Clear() - { - if (Count > 0) - ClearItems(); - } - - /// - /// Clears all items from the dictionary. - /// - protected virtual void ClearItems() - { - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, this.ToList())); - _dict.Clear(); - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - RaisePropertyChanged(); - } - - /// - /// Replaces the item within the dictionary. - /// - /// The old item being replaced. - /// The new replacement item. - protected virtual void ReplaceItem(KeyValuePair oldItem, KeyValuePair newItem) - { - oldItem = new KeyValuePair(OnModifyKey(oldItem.Key), oldItem.Value); - newItem = new KeyValuePair(OnModifyKey(newItem.Key), newItem.Value); - - _dict[newItem.Key] = newItem.Value; - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newItem, oldItem)); - RaisePropertyChanged(); - } - - /// - /// Adds the item to the dictionary. - /// - /// The that was added. - protected virtual void AddItem(KeyValuePair item) - { - item = new KeyValuePair(OnModifyKey(item.Key), item.Value); - - _dict.Add(item.Key, item.Value); - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item)); - RaisePropertyChanged(); - } - - /// - /// Removes the item from the dictionary. - /// - /// The that was removed. - /// true if the item was successfully removed from the ; otherwise, false. - protected virtual bool RemoveItem(KeyValuePair item) - { - item = new KeyValuePair(OnModifyKey(item.Key), item.Value); - if (!_dict.Remove(item.Key)) - return false; - - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item)); - RaisePropertyChanged(); - return true; - } - - /// - /// Raises the event. - /// - /// The . - protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs e) => CollectionChanged?.Invoke(this, e); - - /// - /// Occurs when the collection changes, either by adding or removing an item. - /// - public event NotifyCollectionChangedEventHandler? CollectionChanged; - - /// - /// Invokes the method. - /// - private void RaisePropertyChanged() => OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); - - /// - /// Raises the event. - /// - /// The . - protected virtual void OnPropertyChanged(PropertyChangedEventArgs e) => PropertyChanged?.Invoke(this, e); - - /// - /// Occurs when a property changes, either within the dictionary or to an item within. - /// - public event PropertyChangedEventHandler? PropertyChanged; - - /// - /// Modifies the key before usage; by default uses the . - /// - /// The key. - /// The modified key. - /// Enables an opportunity to modify the key before used internally; for example, to modify so that it is always uppercase. - protected virtual TKey OnModifyKey(TKey key) => KeyModifier is null ? key : KeyModifier(key); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/Extended/PropertyValue.cs b/src/CoreEx/Entities/Extended/PropertyValue.cs deleted file mode 100644 index 299728f5..00000000 --- a/src/CoreEx/Entities/Extended/PropertyValue.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; - -namespace CoreEx.Entities.Extended -{ - /// - /// Provides the property value capabilities enabled by . - /// - /// The property name. - /// The property value. - /// The action to set (override) the value with the specified value. - /// The optional default value override. - public struct PropertyValue(string name, T value, Action setValue, T? defaultValue = default) : IPropertyValue - { - private readonly Action _setValue = setValue; - - /// - public string Name { get; } = name.ThrowIfNullOrEmpty(nameof(name)); - - /// - readonly object? IPropertyValue.Value => Value; - - /// - /// Gets the property value. - /// - public T? Value { get; private set; } = value; - - /// - /// Gets the default value. - /// - public T? DefaultValue { get; } = defaultValue ?? default; - - /// - public void Clean() => SetValue(Cleaner.Clean(Value)); - - /// - public readonly bool IsInitial => Cleaner.IsDefault(Value, DefaultValue); - - /// - void IPropertyValue.SetValue(object? value) => SetValue((T)value!); - - /// - /// Sets (override) the underlying with the specified . - /// - /// The overridding value. - public void SetValue(T? value) - { - _setValue(value); - Value = value; - } - - /// - readonly bool IPropertyValue.AreEqual(IPropertyValue value) => AreEqual((PropertyValue)value!); - - /// - /// Indicates whether the other is equal to this. - /// - /// The value to compare to. - /// true indicates they are equal; otherwise, false for not equal. - public readonly bool AreEqual(PropertyValue propertyValue) => Value == null && propertyValue.Value == null || (Value == null ? propertyValue.Value!.Equals(Value) : Value.Equals(propertyValue.Value)); - - /// - public override readonly int GetHashCode() => Value?.GetHashCode() ?? 0; - - /// - /// Performs a copy or clone from the other . - /// - /// The to copy or clone from. - void IPropertyValue.CopyFrom(IPropertyValue propertyValue) => CopyFrom((PropertyValue)propertyValue); - - /// - void CopyFrom(PropertyValue propertyValue) - { - if (propertyValue.Value is null || Comparer.Default.Compare(propertyValue.Value, default!) == 0) - { - SetValue(default); - return; - } - - if (propertyValue.Value is string s) - { - SetValue((T)(object)s); - return; - } - - if (propertyValue is ICloneable clone) - { - SetValue((T)clone); - return; - } - - if (propertyValue.Value is EntityBase eb) - { - var v = Activator.CreateInstance() as EntityBase; - v!.CopyFrom(eb); - SetValue((T)(object)v); - return; - } - - if (propertyValue.Value is IEntityBaseCollection ebc) - { - SetValue((T)ebc.Clone()); - return; - } - - SetValue(propertyValue.Value); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/FeatureSupport.cs b/src/CoreEx/Entities/FeatureSupport.cs new file mode 100644 index 00000000..ae83be75 --- /dev/null +++ b/src/CoreEx/Entities/FeatureSupport.cs @@ -0,0 +1,22 @@ +namespace CoreEx.Entities; + +/// +/// Represents the feature support. +/// +public enum FeatureSupport +{ + /// + /// Indicates that the feature is not supported. + /// + NotSupported, + + /// + /// Indicates that the feature is partially supported; i.e. is read-only. + /// + ReadOnly, + + /// + /// Indicates that the feature is fully supported; i.e. is mutable. + /// + Mutable +} \ No newline at end of file diff --git a/src/CoreEx/Entities/IChangeLog.cs b/src/CoreEx/Entities/IChangeLog.cs index e758477d..da57e675 100644 --- a/src/CoreEx/Entities/IChangeLog.cs +++ b/src/CoreEx/Entities/IChangeLog.cs @@ -1,38 +1,15 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -namespace CoreEx.Entities +/// +/// Enables a mutable . +/// +public interface IChangeLog : IReadOnlyChangeLog { + /// + ChangeLog? IReadOnlyChangeLog.ChangeLog => ChangeLog; + /// - /// Provides the . + /// Gets the . /// - public interface IChangeLog : IChangeLogAuditLog - { - /// - IChangeLogAudit? IChangeLogAuditLog.ChangeLogAudit - { - get => ChangeLog; - set - { - if (value is null) - ChangeLog = null; - else if (value is ChangeLog cl) - ChangeLog = cl; - else - { - ChangeLog = new ChangeLog - { - CreatedBy = value.CreatedBy, - CreatedDate = value.CreatedDate, - UpdatedBy = value.UpdatedBy, - UpdatedDate = value.UpdatedDate - }; - } - } - } - - /// - /// Gets or set the . - /// - ChangeLog? ChangeLog { get; set; } - } + new ChangeLog? ChangeLog { get; set; } } \ No newline at end of file diff --git a/src/CoreEx/Entities/IChangeLogAudit.cs b/src/CoreEx/Entities/IChangeLogAudit.cs deleted file mode 100644 index 45d0177e..00000000 --- a/src/CoreEx/Entities/IChangeLogAudit.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Entities -{ - /// - /// Provides the audit properties. - /// - public interface IChangeLogAudit - { - /// - /// Gets or sets the created . - /// - DateTime? CreatedDate { get; set; } - - /// - /// Gets or sets the created by (username). - /// - string? CreatedBy { get; set; } - - /// - /// Gets or sets the updated . - /// - DateTime? UpdatedDate { get; set; } - - /// - /// Gets or sets the updated by (username). - /// - string? UpdatedBy { get; set; } - - /// - /// Indicates whether all the properties for the are initial (default). - /// - public bool IsInitial => CreatedDate == default && CreatedBy == default && UpdatedDate == default && UpdatedBy == default; - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IChangeLogAuditLog.cs b/src/CoreEx/Entities/IChangeLogAuditLog.cs deleted file mode 100644 index ae74f53a..00000000 --- a/src/CoreEx/Entities/IChangeLogAuditLog.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities -{ - /// - /// Provides the . - /// - public interface IChangeLogAuditLog - { - /// - /// Gets or set the value. - /// - IChangeLogAudit? ChangeLogAudit { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IChangeLogEx.cs b/src/CoreEx/Entities/IChangeLogEx.cs new file mode 100644 index 00000000..7547856a --- /dev/null +++ b/src/CoreEx/Entities/IChangeLogEx.cs @@ -0,0 +1,39 @@ +namespace CoreEx.Entities; + +/// +/// Enables a mutable , , , and . +/// +public interface IChangeLogEx : IReadOnlyChangeLogEx +{ + /// + string? IReadOnlyChangeLogEx.CreatedBy => CreatedBy; + + /// + DateTimeOffset? IReadOnlyChangeLogEx.CreatedOn => CreatedOn; + + /// + string? IReadOnlyChangeLogEx.UpdatedBy => UpdatedBy; + + /// + DateTimeOffset? IReadOnlyChangeLogEx.UpdatedOn => UpdatedOn; + + /// + /// Gets or sets the user who created the entity. + /// + new string? CreatedBy { get; set; } + + /// + /// Gets or sets the timestamp of when the entity was created. + /// + new DateTimeOffset? CreatedOn { get; set; } + + /// + /// Gets or sets the user who last updated the entity. + /// + new string? UpdatedBy { get; set; } + + /// + /// Gets or sets the timestamp of when the entity was last updated. + /// + new DateTimeOffset? UpdatedOn { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/ICleanUp.cs b/src/CoreEx/Entities/ICleanUp.cs deleted file mode 100644 index 1d9f6cbb..00000000 --- a/src/CoreEx/Entities/ICleanUp.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities -{ - /// - /// Provides a means to the class. - /// - /// See . - public interface ICleanUp - { - /// - /// Cleans up the properties/state of the class. - /// - void CleanUp(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/ICollectionResult.cs b/src/CoreEx/Entities/ICollectionResult.cs deleted file mode 100644 index 84848395..00000000 --- a/src/CoreEx/Entities/ICollectionResult.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections; -using System.Net.Http; - -namespace CoreEx.Entities -{ - /// - /// Provides the and for a collection result. - /// - /// Generally an is not intended for serialized ; the underlying is serialized with the returned as . - public interface ICollectionResult - { - /// - /// Gets the underlying item . - /// - Type ItemType { get; } - - /// - /// Gets the . - /// - Type CollectionType { get; } - - /// - /// Gets or sets the . - /// - PagingResult? Paging { get; set; } - - /// - /// Gets the underlying . - /// - ICollection Items { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/ICollectionResultT.cs b/src/CoreEx/Entities/ICollectionResultT.cs deleted file mode 100644 index ea0448ca..00000000 --- a/src/CoreEx/Entities/ICollectionResultT.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Net.Http; - -namespace CoreEx.Entities -{ - /// - /// Provides the typed . - /// - /// The The underlying item . - /// Generally an is not intended for serialized ; the underlying is serialized with the returned as . - public interface ICollectionResult : ICollectionResult - { - /// - /// Gets the underlying . - /// - new ICollection Items { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/ICollectionResultT2.cs b/src/CoreEx/Entities/ICollectionResultT2.cs deleted file mode 100644 index 2585c88c..00000000 --- a/src/CoreEx/Entities/ICollectionResultT2.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Net.Http; - -namespace CoreEx.Entities -{ - /// - /// Provides the typed . - /// - /// The result collection . - /// The underlying item . - /// Generally an is not intended for serialized ; the underlying is serialized with the returned as . - public interface ICollectionResult : ICollectionResult where TColl : ICollection, new() - { - /// - Type ICollectionResult.ItemType => typeof(TItem); - - /// - Type ICollectionResult.CollectionType => typeof(TColl); - - /// - /// Gets or sets the underlying collection. - /// - new TColl Items { get; set; } - - /// - /// Gets the underlying . - /// - ICollection ICollectionResult.Items { get => (ICollection)Items; set => Items = value == null ? new TColl() : (TColl)value; } - - /// - /// Gets the underlying . - /// - ICollection ICollectionResult.Items { get => Items; set => Items = value == null ? new TColl() : (TColl)value; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/ICompositeKeyCollection.cs b/src/CoreEx/Entities/ICompositeKeyCollection.cs deleted file mode 100644 index 9fa5abd3..00000000 --- a/src/CoreEx/Entities/ICompositeKeyCollection.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Collections; - -namespace CoreEx.Entities -{ - /// - /// Enables the collection capabilities. - /// - public interface ICompositeKeyCollection: ICollection, IList - { - /// - /// Indicates whether an item with the specified exists. - /// - /// The . - /// true where the item exists; otherwise, false. - bool ContainsKey(CompositeKey key); - - /// - /// Gets the first item with the specified . - /// - /// The . - /// The item where found; otherwise, null. - object? GetByKey(CompositeKey key); - - /// - /// Removes all items with the specified primary . - /// - /// The . - void RemoveByKey(CompositeKey key); - - /// - /// Indicates whether there are any duplicate items in the collection. - /// - /// true where there are one or more duplicates; otherwise, false where all items are unique. - bool IsAnyDuplicates(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/ICompositeKeyCollectionT.cs b/src/CoreEx/Entities/ICompositeKeyCollectionT.cs deleted file mode 100644 index fba87577..00000000 --- a/src/CoreEx/Entities/ICompositeKeyCollectionT.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Collections.Generic; - -namespace CoreEx.Entities -{ - /// - /// Provides the typed capabilities. - /// - /// The collection item . - public interface ICompositeKeyCollection : ICompositeKeyCollection, ICollection - { - /// - object? ICompositeKeyCollection.GetByKey(CompositeKey key) => GetByKey(key); - - /// - /// Gets the first item using the specified primary . - /// - /// The . - /// The item where found; otherwise, null. - new T? GetByKey(CompositeKey key); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IContract.cs b/src/CoreEx/Entities/IContract.cs new file mode 100644 index 00000000..743a57aa --- /dev/null +++ b/src/CoreEx/Entities/IContract.cs @@ -0,0 +1,6 @@ +namespace CoreEx.Entities; + +/// +/// Enables the core contract capabilities. +/// +public interface IContract : IRuntimeMetadata, Extended.ICopyFrom, Extended.IDefault { } \ No newline at end of file diff --git a/src/CoreEx/Entities/IContractT.cs b/src/CoreEx/Entities/IContractT.cs new file mode 100644 index 00000000..4949d707 --- /dev/null +++ b/src/CoreEx/Entities/IContractT.cs @@ -0,0 +1,7 @@ +namespace CoreEx.Entities; + +/// +/// Enables the core contract capabilities. +/// +/// The contract . +public interface IContract : IContract, IEquatable { } \ No newline at end of file diff --git a/src/CoreEx/Entities/ICopyFrom.cs b/src/CoreEx/Entities/ICopyFrom.cs deleted file mode 100644 index c174169b..00000000 --- a/src/CoreEx/Entities/ICopyFrom.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities -{ - /// - /// Provides the ability to perform a deep another object. - /// - public interface ICopyFrom - { - /// - /// Performs a deep copy from another object updating this instance. - /// - /// The other value to copy from. - void CopyFrom(object? other); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IETag.cs b/src/CoreEx/Entities/IETag.cs index 1619efc2..393530b5 100644 --- a/src/CoreEx/Entities/IETag.cs +++ b/src/CoreEx/Entities/IETag.cs @@ -1,15 +1,15 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -namespace CoreEx.Entities +/// +/// Enables a mutable for the likes of versioning (optimistic concurrency). +/// +public interface IETag : IReadOnlyETag { + /// + string? IReadOnlyETag.ETag => ETag; + /// - /// Provides the property for the likes of versioning (optimistic concurrency). + /// Gets or sets the entity tag. /// - public interface IETag - { - /// - /// Gets or sets the entity tag. - /// - string? ETag { get; set; } - } + new string? ETag { get; set; } } \ No newline at end of file diff --git a/src/CoreEx/Entities/IEntityKey.cs b/src/CoreEx/Entities/IEntityKey.cs index 24962805..7b2c68bb 100644 --- a/src/CoreEx/Entities/IEntityKey.cs +++ b/src/CoreEx/Entities/IEntityKey.cs @@ -1,21 +1,15 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -using CoreEx.Abstractions; -using System.Text.Json.Serialization; - -namespace CoreEx.Entities +/// +/// Enables base entity key support using a . +/// +/// To enable key-based support in a consistent and standardized manner then this interface must be implemented; for example, see . +public interface IEntityKey { /// - /// Provides base entity key support using a . + /// Gets the key for the entity as a . /// - /// To enable key-based support in a consistent and standardized manner then this interface must be implemented; i.e. and . - public interface IEntityKey : IUniqueKey - { - /// - /// Gets the key for the entity as a . - /// - /// The key represented as a . - [JsonIgnore] - CompositeKey EntityKey { get; } - } + /// The key represented as a . + [JsonIgnore] + CompositeKey EntityKey { get; } } \ No newline at end of file diff --git a/src/CoreEx/Entities/IIdentifier.cs b/src/CoreEx/Entities/IIdentifier.cs deleted file mode 100644 index f7a27bf7..00000000 --- a/src/CoreEx/Entities/IIdentifier.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Text.Json.Serialization; - -namespace CoreEx.Entities -{ - /// - /// Enables the capability. - /// - public interface IIdentifier : IEntityKey - { - /// - /// Gets or sets the identifier. - /// - object? Id { get; set; } - - /// - /// Gets the . - /// - [JsonIgnore] - Type IdType { get; } - - /// - [JsonIgnore] - CompositeKey IEntityKey.EntityKey => new(Id); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IIdentifierGenerator.cs b/src/CoreEx/Entities/IIdentifierGenerator.cs deleted file mode 100644 index a03b0bfb..00000000 --- a/src/CoreEx/Entities/IIdentifierGenerator.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading.Tasks; - -namespace CoreEx.Entities -{ - /// - /// Enables the generation of a new identifier value for any identifier . - /// - public interface IIdentifierGenerator - { - /// - /// Generate a new identifier value. - /// - /// The identifier . - /// The to generate for. - /// The newly generated identifier. - /// The allows for the likes of different identity sequences per for example. - Task GenerateIdentifierAsync(); - - /// - /// Assigns a generated identifier to the where the has a default value. - /// - /// The to generate for. - /// The value to assign an identifier for. - Task AssignIdentifierAsync(TFor value); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IIdentifierGeneratorT.cs b/src/CoreEx/Entities/IIdentifierGeneratorT.cs deleted file mode 100644 index 10b27b17..00000000 --- a/src/CoreEx/Entities/IIdentifierGeneratorT.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Threading.Tasks; - -namespace CoreEx.Entities -{ - /// - /// Enables the generation of a new identifier value. - /// - /// The identifier . - public interface IIdentifierGenerator - { - /// - /// Generate a new identifier value. - /// - /// The to generate for. - /// The newly generated identifier. - /// The allows for the likes of different identity sequences per for example. - Task GenerateIdentifierAsync(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IIdentifierT.cs b/src/CoreEx/Entities/IIdentifierT.cs index 0989dde4..5d49ad7d 100644 --- a/src/CoreEx/Entities/IIdentifierT.cs +++ b/src/CoreEx/Entities/IIdentifierT.cs @@ -1,28 +1,41 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -using System; -using System.Text.Json.Serialization; - -namespace CoreEx.Entities +/// +/// Enables a mutable identifier () capability. +/// +/// The identifier . +/// The is intended for primitive types; e.g. , , and . +/// See also the immutable . +public interface IIdentifier : IIdentifier, IReadOnlyIdentifier { + /// + TId IReadOnlyIdentifier.Id => Id; + + /// + /// Gets or sets the identifier. + /// + new TId Id { get; set; } + + /// + object? IIdentifierCore.Id => Id; + + /// + object? IIdentifier.Id { get => Id; set => Id = (TId)value!; } + + /// + [JsonIgnore] + Type IIdentifierCore.IdType => typeof(TId); + + /// + [JsonIgnore] + bool IIdentifierCore.IsIdReadOnly => false; + + /// + void IIdentifierCore.SetIdentifier(object? id) => Id = (TId)id!; + /// - /// Enables the identifier () capability. + /// Sets (overrides) the identifier. /// - /// The identifier . - /// The is contrained to and to largely limit the to primitive types; e.g. , , - /// and . - public interface IIdentifier : IIdentifier where TId : IComparable, IEquatable - { - /// - object? IIdentifier.Id { get => Id; set => Id = (TId)value!; } - - /// - /// Gets or sets the identifier. - /// - new TId? Id { get; set; } - - /// - [JsonIgnore] - Type IIdentifier.IdType => typeof(TId); - } + /// The identifier. + public void SetIdentifier(TId id) => Id = id; } \ No newline at end of file diff --git a/src/CoreEx/Entities/IInitial.cs b/src/CoreEx/Entities/IInitial.cs deleted file mode 100644 index aa803115..00000000 --- a/src/CoreEx/Entities/IInitial.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities -{ - /// - /// Provides a means to determine if a class is in its initial/default state and therefore the reference to this value should be reset to null during a or - /// as per the overrideWithNullWhenIsInitial parameter. - /// - /// See . - public interface IInitial - { - /// - /// Indicates whether considered initial; i.e. all properties have their initial/default value. - /// - bool IsInitial { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/ILogicallyDeleted.cs b/src/CoreEx/Entities/ILogicallyDeleted.cs deleted file mode 100644 index c3bca045..00000000 --- a/src/CoreEx/Entities/ILogicallyDeleted.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities -{ - /// - /// Enables an entity to identify whether it is logically deleted. - /// - public interface ILogicallyDeleted - { - /// - /// Indicates whether the entity is considered logically deleted. - /// - bool? IsDeleted { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IPagingResult.cs b/src/CoreEx/Entities/IPagingResult.cs deleted file mode 100644 index e41954e1..00000000 --- a/src/CoreEx/Entities/IPagingResult.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities -{ - /// - /// Provides a . - /// - public interface IPagingResult - { - /// - /// Gets or sets the . - /// - PagingResult? Paging { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IPartitionKey.cs b/src/CoreEx/Entities/IPartitionKey.cs deleted file mode 100644 index b7e6ed33..00000000 --- a/src/CoreEx/Entities/IPartitionKey.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities -{ - /// - /// Provides the . - /// - public interface IPartitionKey - { - /// - /// Gets the partition key. - /// - public string? PartitionKey { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IPrimaryKey.cs b/src/CoreEx/Entities/IPrimaryKey.cs deleted file mode 100644 index 0d0f6462..00000000 --- a/src/CoreEx/Entities/IPrimaryKey.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Text.Json.Serialization; - -namespace CoreEx.Entities -{ - /// - /// Provides the . - /// - public interface IPrimaryKey : IEntityKey - { - /// - /// Gets the primary key (represented as a ). - /// - [JsonIgnore] - CompositeKey PrimaryKey { get; } - - /// - [JsonIgnore] - CompositeKey IEntityKey.EntityKey => PrimaryKey; - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IReadOnly.cs b/src/CoreEx/Entities/IReadOnly.cs deleted file mode 100644 index 6a2fe77f..00000000 --- a/src/CoreEx/Entities/IReadOnly.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities -{ - /// - /// Provides a means to the class. - /// - public interface IReadOnly - { - /// - /// Indicates whether the entity is read only (see ). - /// - public bool IsReadOnly { get; } - - /// - /// Makes the entity read-only; such that it will no longer support any property changes (see ). - /// - public void MakeReadOnly(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IReadOnlyChangeLog.cs b/src/CoreEx/Entities/IReadOnlyChangeLog.cs new file mode 100644 index 00000000..570529f2 --- /dev/null +++ b/src/CoreEx/Entities/IReadOnlyChangeLog.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Entities; + +/// +/// Enables a read-only . +/// +public interface IReadOnlyChangeLog +{ + /// + /// Gets the . + /// + ChangeLog? ChangeLog { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/IReadOnlyChangeLogEx.cs b/src/CoreEx/Entities/IReadOnlyChangeLogEx.cs new file mode 100644 index 00000000..1a5bbc5d --- /dev/null +++ b/src/CoreEx/Entities/IReadOnlyChangeLogEx.cs @@ -0,0 +1,27 @@ +namespace CoreEx.Entities; + +/// +/// Enables a read-only , , , and . +/// +public interface IReadOnlyChangeLogEx +{ + /// + /// Gets the user who created the entity. + /// + string? CreatedBy { get; } + + /// + /// Gets the timestamp of when the entity was created. + /// + DateTimeOffset? CreatedOn { get; } + + /// + /// Gets the user who last updated the entity. + /// + string? UpdatedBy { get; } + + /// + /// Gets the timestamp of when the entity was last updated. + /// + DateTimeOffset? UpdatedOn { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/IReadOnlyETag.cs b/src/CoreEx/Entities/IReadOnlyETag.cs new file mode 100644 index 00000000..360d2705 --- /dev/null +++ b/src/CoreEx/Entities/IReadOnlyETag.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Entities; + +/// +/// Enables a read-only for the likes of versioning (optimistic concurrency). +/// +public interface IReadOnlyETag +{ + /// + /// Gets the entity tag. + /// + string? ETag { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/IReadOnlyIdentifierT.cs b/src/CoreEx/Entities/IReadOnlyIdentifierT.cs new file mode 100644 index 00000000..f15928b6 --- /dev/null +++ b/src/CoreEx/Entities/IReadOnlyIdentifierT.cs @@ -0,0 +1,33 @@ +namespace CoreEx.Entities; + +/// +/// Enables a read-only identifier () capability. +/// +/// The identifier . +/// The is intended for primitive types; e.g. , , and . +/// See also the mutable . +public interface IReadOnlyIdentifier : IReadOnlyIdentifier +{ + /// + object? IIdentifierCore.Id { get => Id; } + + /// + /// Gets the identifier. + /// + new TId Id { get; } + + /// + [JsonIgnore] + Type IIdentifierCore.IdType => typeof(TId); + + /// + [JsonIgnore] + CompositeKey IEntityKey.EntityKey => CompositeKey.Create(Id); + + /// + [JsonIgnore] + bool IIdentifierCore.IsIdReadOnly => true; + + /// + void IIdentifierCore.SetIdentifier(object? id) => throw new InvalidOperationException("Identifier is read-only."); +} \ No newline at end of file diff --git a/src/CoreEx/Entities/ITenantId.cs b/src/CoreEx/Entities/ITenantId.cs deleted file mode 100644 index 1544d717..00000000 --- a/src/CoreEx/Entities/ITenantId.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities -{ - /// - /// Provides the . - /// - public interface ITenantId - { - /// - /// Gets or sets the tenant identifier. - /// - string? TenantId { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/IValueResult.cs b/src/CoreEx/Entities/IValueResult.cs new file mode 100644 index 00000000..27ff6f4c --- /dev/null +++ b/src/CoreEx/Entities/IValueResult.cs @@ -0,0 +1,19 @@ +namespace CoreEx.Entities; + +/// +/// Enables a result wrapper (non-error) that contains additional context. +/// +/// This is not intended for error scenarios, as the likes of an , or enable accordingly. +internal interface IValueResult +{ + /// + /// Gets the value. + /// + object? Value { get; } + + /// + /// Gets the resulting . + /// + /// This does not imply that the result has to be used in an HTTP context; just that this represents a number of well-known statuses. + HttpStatusCode? StatusCode { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/IValueResultT.cs b/src/CoreEx/Entities/IValueResultT.cs new file mode 100644 index 00000000..1e65bd6f --- /dev/null +++ b/src/CoreEx/Entities/IValueResultT.cs @@ -0,0 +1,17 @@ +namespace CoreEx.Entities; + +/// +/// Enables a typed result wrapper (non-error) that contains additional context. +/// +/// The value . +/// This is not intended for error scenarios, as the likes of an , or enable accordingly. +internal interface IValueResult : IValueResult +{ + /// + object? IValueResult.Value => Value; + + /// + /// Gets the value. + /// + new T Value { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Entities/IdentifierGenerator.cs b/src/CoreEx/Entities/IdentifierGenerator.cs deleted file mode 100644 index 36b4eb42..00000000 --- a/src/CoreEx/Entities/IdentifierGenerator.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading.Tasks; - -namespace CoreEx.Entities -{ - /// - /// Provides an for both a and where each is created using . - /// - public class IdentifierGenerator : IIdentifierGenerator, IIdentifierGenerator, IIdentifierGenerator - { - /// - public async Task GenerateIdentifierAsync() => typeof(TId) switch - { - Type _ when typeof(TId) == typeof(string) => (TId)Convert.ChangeType(await ((IIdentifierGenerator)this).GenerateIdentifierAsync().ConfigureAwait(false), typeof(TId)), - Type _ when typeof(TId) == typeof(Guid) => (TId)Convert.ChangeType(await ((IIdentifierGenerator)this).GenerateIdentifierAsync().ConfigureAwait(false), typeof(TId)), - _ => throw new NotSupportedException($"Identifier Type '{typeof(TId).Name}' is not supported; only String or Guid.") - }; - - /// - public async Task AssignIdentifierAsync(TFor value) - { - if (value is not IIdentifier ii) - return; - - if (value is IIdentifier iis) - iis.Id ??= await ((IIdentifierGenerator)this).GenerateIdentifierAsync().ConfigureAwait(false); - else if (value is IIdentifier iig) - { - if (iig.Id == Guid.Empty) - iig.Id = await ((IIdentifierGenerator)this).GenerateIdentifierAsync().ConfigureAwait(false); - } - else - throw new NotSupportedException($"Identifier Type '{ii.IdType.Name}' is not supported; only String or Guid."); - } - - /// - /// Generate a new identifier value being a formatted as a . - /// - /// The to generate for. - /// The newly generated identifier. - /// The allows for the likes of different identity sequences per for example. - public Task GenerateIdentifierAsync() => Task.FromResult(Guid.NewGuid().ToString()); - - /// - /// Generate a new identifier value being a - /// - /// The to generate for. - /// The newly generated identifier. - /// The allows for the likes of different identity sequences per for example. - Task IIdentifierGenerator.GenerateIdentifierAsync() => Task.FromResult(Guid.NewGuid()); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/MessageCollection.cs b/src/CoreEx/Entities/MessageCollection.cs new file mode 100644 index 00000000..7f0e759c --- /dev/null +++ b/src/CoreEx/Entities/MessageCollection.cs @@ -0,0 +1,42 @@ +namespace CoreEx.Entities; + +/// +/// Represents a collection. +/// +public class MessageItemCollection : ObservableCollection +{ + /// + /// Initializes a new instance of the class. + /// + public MessageItemCollection() { } + + /// + /// Initializes a new instance of the class. + /// + /// Initial messages to add. + public MessageItemCollection(IEnumerable messages) : base(messages) { } + + /// + /// Adds zero or more to the collection. + /// + /// The messages. + public void AddRange(IEnumerable messages) + { + foreach (var m in messages) + Add(m); + } + + /// + /// Determines whether a exists for a selected . + /// + /// The . + /// if a message exists; otherwise, . + public bool ContainsType(MessageType type) => this.Any(x => x.Type == type); + + /// + /// Gets a new items for the selected . + /// + /// The . + /// The new . + public MessageItemCollection GetMessagesForType(MessageType type) => new(this.Where(x => x.Type == type)); +} \ No newline at end of file diff --git a/src/CoreEx/Entities/MessageItem.Create.cs b/src/CoreEx/Entities/MessageItem.Create.cs new file mode 100644 index 00000000..8f5a3b0e --- /dev/null +++ b/src/CoreEx/Entities/MessageItem.Create.cs @@ -0,0 +1,95 @@ +namespace CoreEx.Entities; + +public partial record class MessageItem +{ + /// + /// Creates a new with a specified and text. + /// + /// The . + /// The message text. + /// A . + public static MessageItem CreateMessage(MessageType type, LText text) => new() { Type = type, Text = text }; + + /// + /// Creates a new with a specified , text format and and additional values included in the text. + /// + /// The . + /// The composite format string. + /// The values that form part of the message text. + /// A . + public static MessageItem CreateMessage(MessageType type, LText format, params IEnumerable values) => new() { Type = type, Text = format.EnsureNoArgs().WithArgs(values) }; + + /// + /// Creates a new with the specified , and text. + /// + /// The property name. + /// The . + /// The message text. + /// A . + public static MessageItem CreateMessage(string? property, MessageType type, LText text) => new() { Property = property, Type = type, Text = text }; + + /// + /// Creates a new with the specified , , text format and additional values included in the text. + /// + /// The property name. + /// The . + /// The composite format string. + /// The values that form part of the message text. + /// A . + public static MessageItem CreateMessage(string? property, MessageType type, LText format, params IEnumerable values) + => new() { Property = property, Type = type, Text = format.EnsureNoArgs().WithArgs(values) }; + + /// + /// Creates a new with the specified and text. + /// + /// The property name. + /// The message text. + /// A . + public static MessageItem CreateErrorMessage(string? property, LText text) => new() { Property = property, Type = MessageType.Error, Text = text }; + + /// + /// Creates a new with the specified , text format and additional values included in the text. + /// + /// The property name. + /// The composite format string. + /// The values that form part of the message text. + /// A . + public static MessageItem CreateErrorMessage(string? property, LText format, params IEnumerable values) + => new() { Property = property, Type = MessageType.Error, Text = format.EnsureNoArgs().WithArgs(values) }; + + /// + /// Creates a new with the specified and text. + /// + /// The property name. + /// The message text. + /// A . + public static MessageItem CreateWarningMessage(string? property, LText text) => new() { Property = property, Type = MessageType.Warning, Text = text }; + + /// + /// Creates a new with the specified , text format and additional values included in the text. + /// + /// The property name. + /// The composite format string. + /// The values that form part of the message text. + /// A . + public static MessageItem CreateWarningMessage(string? property, LText format, params IEnumerable values) + => new() { Property = property, Type = MessageType.Warning, Text = format.EnsureNoArgs().WithArgs(values) }; + + /// + /// Creates a new with the specified and text. + /// + /// The property name. + /// The message text. + /// A . + public static MessageItem CreateInfoMessage(string? property, LText text) => new() { Property = property, Type = MessageType.Info, Text = text }; + + /// + /// Creates a new with the specified , text format and additional values included in the text. + /// + /// The property name. + /// The composite format string. + /// The values that form part of the message text. + /// A . + public static MessageItem CreateInfoMessage(string? property, LText format, params IEnumerable values) + => new() { Property = property, Type = MessageType.Info, Text = format.EnsureNoArgs().WithArgs(values) }; +} \ No newline at end of file diff --git a/src/CoreEx/Entities/MessageItem.cs b/src/CoreEx/Entities/MessageItem.cs index 38bf4d80..9797d213 100644 --- a/src/CoreEx/Entities/MessageItem.cs +++ b/src/CoreEx/Entities/MessageItem.cs @@ -1,113 +1,48 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -using CoreEx.Localization; -using System.Text.Json.Serialization; - -namespace CoreEx.Entities +/// +/// Represents a . +/// +[DebuggerDisplay("Type = {Type}, Text = {Text}, Property = {Property}")] +public partial record class MessageItem() { /// - /// Represents a . + /// Creates a new instance of the class. /// - [System.Diagnostics.DebuggerDisplay("Type = {Type}, Text = {Text}, Property = {Property}")] - [System.Diagnostics.DebuggerStepThrough] - public class MessageItem + /// The . + /// The message . + /// The optional property that the message relates to. + public MessageItem(MessageType type, LText text, string? property = null) : this() { - #region Static - - /// - /// Creates a new with a specified and text. - /// - /// The . - /// The message text. - /// A . - public static MessageItem CreateMessage(MessageType type, LText text) => new() { Type = type, Text = text }; - - /// - /// Creates a new with a specified , text format and and additional values included in the text. - /// - /// The . - /// The composite format string. - /// The values that form part of the message text. - /// A . - public static MessageItem CreateMessage(MessageType type, LText format, params object[] values) => new() { Type = type, Text = string.Format(System.Globalization.CultureInfo.CurrentCulture, format, values) }; - - /// - /// Creates a new with the specified , and text. - /// - /// The property name. - /// The . - /// The message text. - /// A . - public static MessageItem CreateMessage(string? property, MessageType type, LText text) => new() { Property = property, Type = type, Text = text }; - - /// - /// Creates a new with the specified , , text format and additional values included in the text. - /// - /// The property name. - /// The . - /// The composite format string. - /// The values that form part of the message text. - /// A . - public static MessageItem CreateMessage(string? property, MessageType type, LText format, params object?[] values) - => new() { Property = property, Type = type, Text = string.Format(System.Globalization.CultureInfo.CurrentCulture, format, values) }; - - /// - /// Creates a new with the specified and text. - /// - /// The property name. - /// The message text. - /// A . - public static MessageItem CreateErrorMessage(string? property, LText text) => new() { Property = property, Type = MessageType.Error, Text = text }; - - /// - /// Creates a new with the specified , text format and additional values included in the text. - /// - /// The property name. - /// The composite format string. - /// The values that form part of the message text. - /// A . - public static MessageItem CreateErrorMessage(string property, LText format, params object?[] values) - => new() { Property = property, Type = MessageType.Error, Text = string.Format(System.Globalization.CultureInfo.CurrentCulture, format, values) }; - - #endregion - - /// - /// Gets the message severity type. - /// - public MessageType Type { get; set; } - - /// - /// Gets or sets the message text. - /// - public string? Text { get; set; } + Type = type; + Text = text; + Property = property; + } - /// - /// Gets or sets the name of the property that the message relates to. - /// - public string? Property { get; set; } + /// + /// Gets the message severity type. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public MessageType Type { get; set; } - /// - /// Gets or sets an optional user tag associated with the message. - /// - /// Note: This property is not serialized/deserialized. - [JsonIgnore] - public object? Tag { get; set; } + /// + /// Gets or sets the message . + /// + public LText? Text { get; set; } - /// - /// Returns the message . - /// - /// The message . - public override string ToString() => Text ?? string.Empty; + /// + /// Gets or sets the name of the property that the message relates to. + /// + public string? Property { get; set; } - /// - /// Sets the and returns instance to enable fluent-style. - /// - /// The name of the property that the message relates to. - /// This instance. - public MessageItem SetProperty(string property) - { - Property = property; - return this; - } + /// + /// Sets the . + /// + /// The name of the property that the message relates to. + /// This to support fluent-style method-chaining. + public MessageItem WithProperty(string property) + { + Property = property; + return this; } } \ No newline at end of file diff --git a/src/CoreEx/Entities/MessageItemCollection.cs b/src/CoreEx/Entities/MessageItemCollection.cs deleted file mode 100644 index 192b3673..00000000 --- a/src/CoreEx/Entities/MessageItemCollection.cs +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Localization; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Text; - -namespace CoreEx.Entities -{ - /// - /// Represents a collection. - /// - [System.Diagnostics.DebuggerStepThrough] - public class MessageItemCollection : ObservableCollection - { - /// - /// Initializes a new instance of the class. - /// - public MessageItemCollection() { } - - /// - /// Initializes a new instance of the class. - /// - /// Initial messages to add. - public MessageItemCollection(IEnumerable messages) : base(messages) { } - - /// - /// Adds zero or more to the collection. - /// - /// The messages. - public void AddRange(IEnumerable messages) => messages.ForEach(Add); - - /// - /// Adds a new for a specified and text. - /// - /// The . - /// The message text. - /// A . - public MessageItem Add(MessageType type, LText text) - { - MessageItem item = MessageItem.CreateMessage(type, text); - this.Add(item); - return item; - } - - /// - /// Adds a new for a specified , text format and additional values included in the text. - /// - /// The . - /// The composite format string. - /// The values that form part of the message text. - /// A . - public MessageItem Add(MessageType type, LText format, params object[] values) - { - MessageItem item = MessageItem.CreateMessage(type, format, values); - this.Add(item); - return item; - } - - /// - /// Adds a new for the specified , and text. - /// - /// The property name. - /// The . - /// The message text. - /// A . - public MessageItem Add(string? property, MessageType type, LText text) - { - MessageItem item = MessageItem.CreateMessage(property, type, text); - this.Add(item); - return item; - } - - /// - /// Adds a new for the specified , , text format and additional values included in the text. - /// - /// The property name. - /// The . - /// The composite format string. - /// The values that form part of the message text. - /// A . - public MessageItem Add(string? property, MessageType type, LText format, params object?[] values) - { - MessageItem item = MessageItem.CreateMessage(property, type, format, values); - this.Add(item); - return item; - } - - /// - /// Adds a new for a specified text. - /// - /// The message text. - /// A . - public MessageItem AddError(LText text) => Add(MessageType.Error, text); - - /// - /// Adds a new for a specified text format and additional values included in the text. - /// - /// The composite format string. - /// The values that form part of the message text. - /// A . - public MessageItem AddError(LText format, params object[] values) => Add(MessageType.Error, format, values); - - /// - /// Adds a new for the specified and text. - /// - /// The property name. - /// The message text. - /// A . - public MessageItem AddPropertyError(string property, LText text) => Add(property, MessageType.Error, text); - - /// - /// Adds a new for the specified , text format and and additional values included in the text. - /// - /// The property name. - /// The composite format string. - /// The values that form part of the message text. - /// A . - public MessageItem AddPropertyError(string property, LText format, params object[] values) => Add(property, MessageType.Error, format, values); - - /// - /// Adds a new for a specified text. - /// - /// The message text. - /// A . - public MessageItem AddWarning(LText text) => Add(MessageType.Warning, text); - - /// - /// Adds a new for a specified text format and additional values included in the text. - /// - /// The composite format string. - /// The values that form part of the message text. - /// A . - public MessageItem AddWarning(LText format, params object[] values) => Add(MessageType.Warning, format, values); - - /// - /// Adds a new for the specified and text. - /// - /// The property name. - /// The message text. - /// A . - public MessageItem AddPropertyWarning(string property, LText text) => Add(property, MessageType.Warning, text); - - /// - /// Adds a new for the specified , text format and and additional values included in the text. - /// - /// The property name. - /// The composite format string. - /// The values that form part of the message text. - /// A . - public MessageItem AddPropertyWarning(string property, LText format, params object[] values) => Add(property, MessageType.Warning, format, values); - - /// - /// Adds a new for a specified text. - /// - /// The message text. - /// A . - public MessageItem AddInfo(LText text) => Add(MessageType.Info, text); - - /// - /// Adds a new for a specified text format and additional values included in the text. - /// - /// The composite format string. - /// The values that form part of the message text. - /// A . - public MessageItem AddInfo(LText format, params object[] values) => Add(MessageType.Info, format, values); - - /// - /// Adds a new for the specified and text. - /// - /// The property name. - /// The message text. - /// A . - public MessageItem AddPropertyInfo(string property, LText text) => Add(property, MessageType.Info, text); - - /// - /// Adds a new for the specified , text format and and additional values included in the text. - /// - /// The property name. - /// The composite format string. - /// The values that form part of the message text. - /// A . - public MessageItem AddPropertyInfo(string property, LText format, params object[] values) => Add(property, MessageType.Info, format, values); - - /// - /// Gets a new for a selected . - /// - /// Message severity type. - /// A new . - public MessageItemCollection GetMessagesForType(MessageType type) => new(this.Where(x => x.Type == type)); - - /// - /// Gets a new for a selected and . - /// - /// Message severity type. - /// The name of the property that the message relates to. - /// A new . - public MessageItemCollection GetMessagesForType(MessageType type, string property) => new(this.Where(x => x.Type == type && x.Property == property)); - - /// - /// Gets a new for a selected . - /// - /// The name of the property that the message relates to. - /// A new . - public MessageItemCollection GetMessagesForProperty(string property) => new(this.Where(x => x.Property == property)); - - /// - /// Determines whether a message exists for a . - /// - /// The name of the property that the message relates to. - /// true if a message exists; otherwise, false. - public bool ContainsError(string property) => ContainsType(MessageType.Error, property); - - /// - /// Determines whether a message exists for a selected . - /// - /// The . - /// true if a message exists; otherwise, false. - public bool ContainsType(MessageType type) => this.Any(x => x.Type == type); - - /// - /// Determines whether a message exists for a selected and . - /// - /// The . - /// The name of the property that the message relates to. - /// true if a message exists; otherwise, false. - public bool ContainsType(MessageType type, string property) => this.Any(x => x.Type == type && x.Property == property); - - /// - /// Determines whether a message exists for a selected . - /// - /// The name of the property that the message relates to. - /// true if a message exists; otherwise, false. - public bool ContainsProperty(string property) => this.Any(x => x.Property == property); - - /// - /// Outputs the list of messages as a . - /// - /// The list of messages as a . - public override string ToString() - { - if (Count == 0) - return new LText("None."); - - var sb = new StringBuilder(); - foreach (var item in this) - { - if (sb.Length > 0) - sb.AppendLine(); - - sb.Append($"{item.Type}: {item.Text}"); - if (!string.IsNullOrEmpty(item.Property)) - sb.Append($" [{item.Property}]"); - } - - return sb.ToString(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/MessageType.cs b/src/CoreEx/Entities/MessageType.cs index 51d0d963..32116cbb 100644 --- a/src/CoreEx/Entities/MessageType.cs +++ b/src/CoreEx/Entities/MessageType.cs @@ -1,25 +1,22 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -namespace CoreEx.Entities +/// +/// Represents the type of . +/// +public enum MessageType { /// - /// Represents the type of . + /// Indicates an informational message. /// - public enum MessageType - { - /// - /// Indicates an informational message. - /// - Info = 0, + Info = 0, - /// - /// Indicates a warning message. - /// - Warning = 1, + /// + /// Indicates a warning message. + /// + Warning = 1, - /// - /// Indicates an error message. - /// - Error = 2, - } + /// + /// Indicates an error message. + /// + Error = 2, } \ No newline at end of file diff --git a/src/CoreEx/Entities/PagingArgs.cs b/src/CoreEx/Entities/PagingArgs.cs deleted file mode 100644 index 549e5fb4..00000000 --- a/src/CoreEx/Entities/PagingArgs.cs +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using System; -using System.Text.Json.Serialization; - -namespace CoreEx.Entities -{ - /// - /// Represents position-based paging being a) and , b) and , or c) and . The and (and ) - /// are static settings to encourage page-size consistency, as well as limit the maximum value possible. - /// - [System.Diagnostics.DebuggerStepThrough] - public class PagingArgs : IEquatable - { - private static long? _defaultTake; - private static long? _maxTake; - - /// - /// Gets or sets the default size. - /// - /// Defaults to where specified; otherwise, 100. - public static long DefaultTake - { - get => _defaultTake ?? ExecutionContext.GetService()?.PagingDefaultTake ?? 100; - - set - { - if (value > 0) - _defaultTake = value; - } - } - - /// - /// Gets or sets the absolute maximum size allowed. - /// - /// Defaults to where specified; otherwise, 1000. - public static long MaxTake - { - get => _maxTake ?? ExecutionContext.GetService()?.PagingMaxTake ?? 1000; - - set - { - if (value > 0) - _maxTake = value; - } - } - - /// - /// Gets or sets the default . - /// - /// Defaults to false. - public static bool DefaultIsGetCount { get; set; } - - /// - /// Indicates whether -based paging is supported. - /// - /// Defaults to false. - public static bool IsTokenSupported { get; set; } = false; - - /// - /// Creates a for a specified page number and size. - /// - /// The number. - /// The page (defaults to ). - /// Indicates whether to get the total count (see ) when performing the underlying query (defaults to where null). - /// The . - public static PagingArgs CreatePageAndSize(long page, long? size = null, bool? isGetCount = null) - { - var pa = new PagingArgs - { - Page = page < 0 ? 1 : page, - Take = !size.HasValue || size.Value < 1 ? DefaultTake : Math.Min(size.Value, MaxTake), - IsGetCount = isGetCount == null ? DefaultIsGetCount : isGetCount.Value - }; - - pa.Skip = (pa.Page.Value - 1) * pa.Size; - return pa; - } - - /// - /// Creates a for a specified skip and take. - /// - /// The value. - /// The value (defaults to ). - /// Indicates whether to get the total count (see ) when performing the underlying query (defaults to where null). - /// The . - public static PagingArgs CreateSkipAndTake(long skip, long? take = null, bool? isGetCount = null) => new() - { - Skip = skip < 0 ? 0 : skip, - Take = !take.HasValue || take.Value < 1 ? DefaultTake : (take.Value > MaxTake ? MaxTake : take.Value), - IsGetCount = isGetCount == null ? DefaultIsGetCount : isGetCount.Value - }; - - /// - /// Creates a for a specified token and take. - /// - /// The to use to get the next page of elements. - /// The value (defaults to ). - /// Indicates whether to get the total count (see ) when performing the underlying query (defaults to where null). - /// The . - public static PagingArgs CreateTokenAndTake(string token, long? take = null, bool? isGetCount = null) => new () - { - Token = IsTokenSupported ? token.ThrowIfNullOrEmpty() : throw new NotSupportedException($"{nameof(Token)}-based paging is not supported."), - Take = !take.HasValue || take.Value< 1 ? DefaultTake : (take.Value > MaxTake? MaxTake : take.Value), - IsGetCount = isGetCount == null ? DefaultIsGetCount : isGetCount.Value - }; - - /// - /// Initializes a new instance of the class with default and . - /// - public PagingArgs() - { - Skip = 0; - Take = DefaultTake; - IsGetCount = DefaultIsGetCount; - } - - /// - /// Initializes a new instance of the class copying the values from . - /// - /// The to copy from. - public PagingArgs(PagingArgs pagingArgs) - { - Skip = pagingArgs.ThrowIfNull().Skip; - Take = pagingArgs.Take; - Page = pagingArgs.Page; - Token = pagingArgs.Token; - IsGetCount = pagingArgs.IsGetCount; - } - - /// - /// Gets the page number for the elements in a sequence to select (see ). - /// - public long? Page { get; internal protected set; } - - /// - /// Gets the specified number of elements in a sequence to bypass. - /// - public long? Skip { get; internal protected set; } - - /// - /// Gets the token to use to get the next page of elements (see ). - /// - public string? Token { get; internal protected set; } - - /// - /// Indicates the . - /// - public PagingOption Option => Page is not null ? PagingOption.PageAndSize : (Token is not null ? PagingOption.TokenAndTake : PagingOption.SkipAndTake); - - /// - /// Gets the page size (synonym for ). - /// - [JsonIgnore] - public long Size => Take; - - /// - /// Gets the specified number of contiguous elements from the start of a sequence. - /// - public long Take { get; internal protected set; } - - /// - /// Overrides/updates the value. - /// - /// The new skip value. - /// The instance to support fluent-style method chaining. - public PagingArgs OverrideSkip(long skip) - { - if (Option == PagingOption.TokenAndTake) - throw new InvalidOperationException($"Cannot override {nameof(Skip)} where {nameof(Option)} is {nameof(PagingOption.TokenAndTake)}."); - - if (skip == Skip) - return this; - - Skip = skip < 0 ? 0 : skip; - return this; - } - - /// - /// Overrides/updates the value bypassing the checking. - /// - /// The new take value. - /// The instance to support fluent-style method chaining. - public PagingArgs OverrideTake(long take) - { - Take = take < 0 ? 0 : take; - return this; - } - - /// - /// Indicates whether to get the total count (see ) when performing the underlying query (defaults to false). - /// - [JsonPropertyName("count")] - public bool IsGetCount { get; set; } = false; - - #region Equality - - /// - public override bool Equals(object? obj) => obj is PagingArgs pa && Equals(pa); - - /// - public bool Equals(PagingArgs? other) => other is not null && Skip == other.Skip && Take == other.Take && Page == other.Page && Token == other.Token && IsGetCount == other.IsGetCount; - - /// - /// Indicates whether the current is equal to another . - /// - public static bool operator ==(PagingArgs? left, PagingArgs? right) => (left is null && right is null) || (left is not null && right is not null && left.Equals(right)); - - /// - /// Indicates whether the current is not equal to another . - /// - public static bool operator !=(PagingArgs? left, PagingArgs? right) => !(left == right); - - /// - public override int GetHashCode() => HashCode.Combine(Skip, Take, Page, Token, IsGetCount); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/PagingOption.cs b/src/CoreEx/Entities/PagingOption.cs deleted file mode 100644 index 56985ede..00000000 --- a/src/CoreEx/Entities/PagingOption.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Entities -{ - /// - /// Represents the option. - /// - public enum PagingOption - { - /// - /// Indicates that the was used to instantiate. - /// - PageAndSize, - - /// - /// Indicates that the was used to instantiate. - /// - SkipAndTake, - - /// - /// Indicates that the was used to instantiate. - /// - TokenAndTake - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/PagingResult.cs b/src/CoreEx/Entities/PagingResult.cs deleted file mode 100644 index 3068805a..00000000 --- a/src/CoreEx/Entities/PagingResult.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Drawing; -using System.Text.Json.Serialization; - -namespace CoreEx.Entities -{ - /// - /// Represents the resulting paging response including and where applicable for the subsequent query. - /// - [System.Diagnostics.DebuggerStepThrough] - public class PagingResult : PagingArgs, IEquatable - { - /// - /// Creates a for a specified page number and size. - /// - /// The number. - /// The page (defaults to ). - /// Indicates whether to get the total count (see ) when performing the underlying query (defaults to where null). - /// The . - public static new PagingResult CreatePageAndSize(long page, long? size = null, bool? isGetCount = null) => new(PagingArgs.CreatePageAndSize(page, size, isGetCount)); - - /// - /// Creates a for a specified skip and take. - /// - /// The value. - /// The value (defaults to ). - /// Indicates whether to get the total count (see ) when performing the underlying query (defaults to where null). - /// The . - public static new PagingResult CreateSkipAndTake(long skip, long? take = null, bool? isGetCount = null) => new(PagingArgs.CreateSkipAndTake(skip, take, isGetCount)); - - /// - /// Creates a for a specified token and take. - /// - /// The to use to get the next page of elements. - /// The value (defaults to ). - /// Indicates whether to get the total count (see ) when performing the underlying query (defaults to where null). - /// The . - public static new PagingResult CreateTokenAndTake(string token, long? take = null, bool? isGetCount = null) => new(PagingArgs.CreateTokenAndTake(token, take, isGetCount)); - - /// - /// Initializes a new instance of the class from a and optional . - /// - /// The . - /// The total record count where applicable. - /// Where the and are both provided the will be automatically created. - public PagingResult(PagingArgs? pagingArgs = null, long? totalCount = null) - { - pagingArgs ??= new PagingArgs(); - - Skip = pagingArgs.Skip; - Take = pagingArgs.Take; - Page = pagingArgs.Page; - Token = pagingArgs.Token; - IsGetCount = pagingArgs.IsGetCount; - TotalCount = (totalCount.HasValue && totalCount.Value < 0) ? null : totalCount; - } - - /// - /// Initializes a new instance of the class from a (copies values). - /// - /// The . - public PagingResult(PagingResult pagingResult) : this(pagingResult, pagingResult?.TotalCount) { } - - /// - /// Gets or sets the total count of the elements in the sequence (a null value indicates that the total count is unknown). - /// - public long? TotalCount { get; set; } - - /// - /// Gets the calculated total pages for all elements in the sequence where the is equal to . - /// - [JsonIgnore()] - public long? TotalPages => Option == PagingOption.PageAndSize && TotalCount.HasValue ? (long)System.Math.Ceiling(TotalCount.Value / (double)Take) : null; - - #region Equality - - /// - public override bool Equals(object? obj) => obj is PagingResult pr && Equals(pr); - - /// - public bool Equals(PagingResult? other) => other is not null && Skip == other.Skip && Take == other.Take && Page == other.Page && Token == other.Token && IsGetCount == other.IsGetCount && TotalCount == other.TotalCount; - - /// - /// Indicates whether the current is equal to another . - /// - public static bool operator ==(PagingResult? left, PagingResult? right) => (left is null && right is null) || (left is not null && right is not null && left.Equals(right)); - - /// - /// Indicates whether the current is not equal to another . - /// - public static bool operator !=(PagingResult? left, PagingResult? right) => !(left == right); - - /// - public override int GetHashCode() => HashCode.Combine(Skip, Take, Page, Token, IsGetCount, TotalCount); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/QueryArgs.cs b/src/CoreEx/Entities/QueryArgs.cs deleted file mode 100644 index 0910c9ef..00000000 --- a/src/CoreEx/Entities/QueryArgs.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using System.Collections.Generic; - -namespace CoreEx.Entities -{ - /// - /// Represents basic dynamic query arguments. - /// - /// This is not intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to filter and order an underlying query. - public class QueryArgs - { - /// - /// Create a new . - /// - /// The basic dynamic OData-like $filter statement. - /// The basic dynamic OData-like $orderby statement. - public static QueryArgs Create(string? filter = null, string? orderBy = null) => new() { Filter = filter, OrderBy = orderBy }; - - /// - /// Gets or sets the basic dynamic OData-like $filter statement. - /// - public string? Filter { get; set; } - - /// - /// Gets or sets the basic dynamic OData-like $orderby statement. - /// - public string? OrderBy { get; set; } - - /// - /// Gets or sets the list of included fields. - /// - /// Currently these are only used within CoreEx for JSON serialization filtering (see ). - public List? IncludeFields { get; set; } - - /// - /// Gets or sets the list of excluded fields. - /// - /// Currently these are only used within CoreEx for JSON serialization filtering (see ). - public List? ExcludeFields { get; set; } - - /// - /// Indicates whether to include any related texts for the item(s). - /// - /// For example, include corresponding for any ReferenceData values returned in the JSON response payload. - public bool IsTextIncluded { get; set; } - - /// - /// Appends the to the . - /// - /// The fields to append. - /// The to support fluent-style method-chaining. - public QueryArgs Include(params string[] fields) - { - (IncludeFields ??= []).AddRange(fields); - return this; - } - - /// - /// Appends the to the . - /// - /// The fields to append. - /// The to support fluent-style method-chaining. - public QueryArgs Exclude(params string[] fields) - { - (ExcludeFields ??= []).AddRange(fields); - return this; - } - - /// - /// Indicates whether to include any related texts for the item(s); see . - /// - /// The to support fluent-style method-chaining. - /// For example, include corresponding for any ReferenceData values returned in the JSON response payload. - public QueryArgs IncludeText() - { - IsTextIncluded = true; - return this; - } - - /// - /// An implicit cast from a filter to a . - /// - /// The . - /// The corresponding . - public static implicit operator QueryArgs(string? filter) => Create(filter); - } -} \ No newline at end of file diff --git a/src/CoreEx/Entities/README.md b/src/CoreEx/Entities/README.md deleted file mode 100644 index b1801c4c..00000000 --- a/src/CoreEx/Entities/README.md +++ /dev/null @@ -1,132 +0,0 @@ -# CoreEx.Entities - -The `CoreEx.Entities` namespace is a key namespace used for the definition of entities and/or models to enable additional and extended capabilities. - -
- -## Motivation - -Entities ([aggregate roots](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/microservice-domain-model#the-domain-entity-pattern) (i.e. [DDD](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/ddd-oriented-microservice)), [value-objects](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/implement-value-objects), [POCO](https://en.wikipedia.org/wiki/Plain_old_CLR_object)s, whatever in your parlance) and [data models](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-design) play a key role within an application architecture. They are often implemented using a pattern of sorts to standardized functionality, implementation, etc. This namespace provides a number of common/standard capabilities that can be leveraged to standardize and create functionally richer entities and/or data models (where they leverage these features). - -
- -## Entity key - -Where an entity needs to be uniquely identified a key of some description is required; it will consist of one or more values. The [`CompositeKey`](./CompositeKey.cs) struct provides a standardized representation of the key value, with a corresponding [`IEntityKey`](./IEntityKey.cs) interface enabling the base `CompositeKey` value access. - -However, in practice one of the following two interfaces should be used to enable the key: - -Interface | Description --|- -[`IIdentifier`](./IIdentifierT.cs) | Enables a single `Id` property. This is the most common means to name and represent a single identifier value; the underlying type can be a `string`, `int`, `Guid`, etc. -[`IPrimaryKey`](./IPrimaryKey.cs) | Enables a primary key that can consist of one or more values, represented as a `CompositeKey`. The values that represent the composite key must in turn be represented as properties for the entity as the underlying composite`IPrimaryKey.PrimaryKey` property is read-only. - -Additionally, there is an [`EntityKeyCollection`](./EntityKeyCollection.cs) that supports objects that implement `IEntityKey`. This collection does _not_ manage/guarantee uniqueness by design, instead provides an `IsAnyDuplicates` to identify. Where uniqueness is required, then some form of `IDictionary` should be leveraged instead. - -
- -### Identifier generation - -Where an identfier value needs to be _generated_ at runtime the following enable: - -Type | Description --|- -[`IIdentifierGenerator`](./IIdentifierGeneratorT.cs) | Provides the logic to generate a new identifier for a specified `Id` type. -[`IIdentifierGenerator`](./IIdentifierGenerator.cs) | Provides the logic to generate an identifier for an entity value. -[`IdentifierGenerator`](./IdentifierGenerator.cs) | Provides the default logic to generate a `string` or `Guid` identifier (from a new `Guid`). - -Where an alternate identifier generation is required, for example [nanoid](https://github.com/codeyu/nanoid-net), then a new [`IIdentifierGenerator`](./IIdentifierGenerator.cs) implementation will be required. - -
- -## Paging - -Within an application, where dealing with entity collections, a standardized approach to paging may be desired. The [`PagingArgs`](./PagingArgs.cs) provides a standard means to specify the `Page` and `Size`, or alternative `Skip` and `Take`; with an additional request to get the _total count_ (`IsGetCount`). There is a corresponding [`PagingResult`](./PagingResult.cs) that is intended to house the resulting `TotalCount` where requested. - -The [`ICollectionResult`](./ICollectionResult.cs) interface and corresponding [`CollectionResult`](./CollectionResult.cs) implementation provide a standardized means to capture the result of the likes of a paged query, to access the `Paging` result and underlying collection `Items`. The [WebApis](../WebApis) namespace leverages this to manage the serialization of this result in a consistent manner. - -
- -## Cleaning - -An entity and its properties, generatlly where represented as a POCO, may contain state that is not considered consistent and is a candidate for cleaning; being the replacement (change) of the property where it meets one of the generic conditions to cleanse. This does not imply post this that an entity should be considered in a final valid state, only specific validation logic can ascertain this; however, at least some basic value assumptions can be made as a result. - -The [`Cleaner`](./Cleaner.cs) class enables the cleansing logic. Provides `Clean` methods to cleanse the following .NET types as follows: - -Type | Description --|- -`string` | Cleans using following:
• [`StringTrim`](./StringTrim.cs): `None`, `Both`, `Start` or `End` (default).
• [`StringTransform`](./StringTransform.cs): `None`, `NullToEmpty` or `EmptyToNull` (default).
• [`StringCase`](./StringCase.cs): `None` (default), `Lower`, `Upper` or `Title`. -`DateTime` | Cleans using [`DateTimeTransform`](./DateTimeTransform.cs): `None`, `DateOnly`, `DateTimeLocal`, `DateTimeUtc (default)`, or `DateTimeUnspecified`. -[`IInitial`](./IInitial.cs) | Provides a means to determine if the value is in its initial/default (`IsInitial`) state and therefore the reference to this should be set to `null`. This is essentially the equivalent of the `StringTransform.EmptyToNull`, but for an object. -[`ICleanUp`](./ICleanUp.cs) | Enables additional cleansing for the object instance by invoking the `CleanUp` method where implemented. - -
- -## Change audit - -This relates to the standardized change auditing, being the capture of the respective create and update, user and timestamp. The [`IChangeLogAudit`](./IChangeLogAudit.cs) interface defines the standard properties, with the [`ChangeLog`](./ChangeLog.cs) class representing an implementation. - -Within an implementation the base [`IChangeLogAuditLog`](./IChangeLogAuditLog.cs) interface, generally accessed via the [`IChangeLog`](./IChangeLog.cs) interface, enables access to the underlying `ChangeLog` property. - -Additionally, the [`ChangeLog`](./ChangeLog.cs) class provides static methods to either `PrepareCreated` or `PrepareUpdated` to set the underlying properties accordingly. This is the preferred method to perform this action. - -
- -## Messages - -The [`MessageItem`](./MessageItem.cs) and corresponding [`MessageItemCollection`](./MessageItemCollection.cs) provide a _CoreEx_-wide standardized means to define and manage messages; including a [`MessageType`](./MessageType.cs) (`Error`, `Warning` or `Info`) and `Text`, with optional `Property` for usage by the likes of [Validation](../Validation) where applicable. - -A number of static `Create` methods are also provided to simplify the creation leveraging [composite formatting](https://learn.microsoft.com/en-us/dotnet/standard/base-types/composite-formatting) and [`LText`](../Localization/LText.cs) (for localization and/or multilingual). - -
- -## Additional capabilities - -The following enable additional ad-hoc capabilties to be enabled. - -
- -### Optimistic concurrency - -Where the likes of entity versioning and/or optimistic concurrency is required then the [`IETag`](./IEtag.cs) interface provides the requisite [`ETag`](https://en.wikipedia.org/wiki/HTTP_ETag). This is the preferred approach for the likes of RESTful APIs that support [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match). - -Additionally, see how the [WebApis](../WebApis) namespace leverages; including the corresponding [`ETagGenerator`](../Abstractions/ETagGenerator.cs). - -
- -### Multi-tenancy - -Within a multi-tenanted implementation the [`ITenantId`](./ITenantId.cs) interface can enable the underlying `TenantId` where it needs to be attributed to an entity (or most likely the underlying data model). - -
- -### Logical delete - -Within an implementation the [`ILogicallyDeleted`](./ILogicallyDeleted.cs) interface can enable logical versus physical delete (`IsDeleted`) to be attributed to an entity (or most likely the underlying data model). - -
- -## Extended capabilities - -The `CoreEx.Entities.Extended` namespace contains extended, more advanced, capabilties as follows. - -
- -### Entity base - -To support more advanced entity capabilities there are two key base classes depending on the desired functionality required, the latter inherits from the former. - -Class | Description --|- -[`EntityCore`](./Extended/EntityCore.cs) | Provides the core [`INotifyPropertyChanged`](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.inotifypropertychanged), [`IChangeTracking`](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.ichangetracking), and [`IReadOnly`](./IReadonly.cs) support. The underlying `SetValue` method must be used for all property value updates to enable. -[`EntityBase`](./Extended/EntityBase.cs) | Extends the above `EntityCore`, adding [`ICleanUp`](./ICleanUp.cs), [`IInitial`](./IInitial.cs) and [`ICopyFrom`](./ICopyFrom.cs) support. To function correctly, the underlying `GetPropertyValues` method must be overridden with each property [yielded](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/yield) as a [`PropertyValue`](./Extended/PropertyValue.cs) to enable. See [`ChangeLogEx`](./Extended/ChangeLogEx.cs) as an example implementation. There is an `EntityBase` extension method to support deep `Clone` operations (note that the `ICloneable` interface is _not_ explicitly supported by default). - -There is a corresponding [`EntityBaseCollection`](./Extended/EntityBaseCollection.cs) and [`EntityCollectionResult`](./Extended/EntityCollectionResult.cs) to support an `EntityBase` collection and paging result equivalence. - -Additionally, there is an [`EntityBaseDictionary`](./Extended/EntityBaseDictionary.cs) that inherits from the [`ObservableDictionary`](./Extended/ObservableDictionary.cs) where an `IDictionary` implementation is required. - -
- -### Extended change audit - -The [`ChangeLogEx`](./Extended/ChangeLogEx.cs) is an extended (inherits `EntityBase`) implementation of the [`ChangeLog`](./ChangeLog.cs) that offers equivalent audit properties; however, supports all extended features where applicable. diff --git a/src/CoreEx/Entities/StringCase.cs b/src/CoreEx/Entities/StringCase.cs index feb2f755..42e97a34 100644 --- a/src/CoreEx/Entities/StringCase.cs +++ b/src/CoreEx/Entities/StringCase.cs @@ -1,38 +1,33 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -using System.Globalization; - -namespace CoreEx.Entities +/// +/// Represents a casing conversion option. +/// +/// See . +public enum StringCase { /// - /// Represents a casing conversion option. + /// Indicates that the value should be used. /// - /// See . - public enum StringCase - { - /// - /// Indicates that the value should be used. - /// - UseDefault, + UseDefault, - /// - /// No casing conversion required; the value will remain as-is. - /// - None, + /// + /// No casing conversion required; the value will remain as-is. + /// + None, - /// - /// The string value will be converted to lower case (see ). - /// - Lower, + /// + /// The string value will be converted to lower case (see ). + /// + Lower, - /// - /// The string value will be converted to upper case (see ). - /// - Upper, + /// + /// The string value will be converted to upper case (see ). + /// + Upper, - /// - /// The string value will be converted to title case (see ). - /// - Title - } + /// + /// The string value will be converted to title case (see ). + /// + Title } \ No newline at end of file diff --git a/src/CoreEx/Entities/StringTransform.cs b/src/CoreEx/Entities/StringTransform.cs index de75db80..61804ff8 100644 --- a/src/CoreEx/Entities/StringTransform.cs +++ b/src/CoreEx/Entities/StringTransform.cs @@ -1,31 +1,28 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -namespace CoreEx.Entities +/// +/// Represents a transform option for a value. +/// +/// See . +public enum StringTransform { /// - /// Represents a transform option for a value. + /// Indicates that the value should be used. /// - /// See . - public enum StringTransform - { - /// - /// Indicates that the value should be used. - /// - UseDefault, + UseDefault, - /// - /// No transform required; the value will remain as-is. - /// - None, + /// + /// No transform required; the value will remain as-is. + /// + None, - /// - /// The string will be transformed from a null to value. - /// - NullToEmpty, + /// + /// The string will be transformed from a to value. + /// + NullToEmpty, - /// - /// The string will be transformed from an value to a null. - /// - EmptyToNull - } + /// + /// The string will be transformed from an value to a . + /// + EmptyToNull } \ No newline at end of file diff --git a/src/CoreEx/Entities/StringTrim.cs b/src/CoreEx/Entities/StringTrim.cs index f17f8220..640717ba 100644 --- a/src/CoreEx/Entities/StringTrim.cs +++ b/src/CoreEx/Entities/StringTrim.cs @@ -1,36 +1,33 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Entities; -namespace CoreEx.Entities +/// +/// Represents the trimming of white space characters from a . +/// +/// See . +public enum StringTrim { /// - /// Represents the trimming of white space characters from a . + /// Indicates that the value should be used. /// - /// See . - public enum StringTrim - { - /// - /// Indicates that the value should be used. - /// - UseDefault, + UseDefault, - /// - /// The string is left unchanged. - /// - None, + /// + /// The string is left unchanged. + /// + None, - /// - /// Removes all occurences of white space characters from the beginning and ending of a string; i.e. - /// - Both, + /// + /// Removes all occurrences of white space characters from the beginning and ending of a string; i.e. + /// + Both, - /// - /// Removes all occurences of white space characters from the beginning of a string; i.e. - /// - Start, + /// + /// Removes all occurrences of white space characters from the beginning of a string; i.e. + /// + Start, - /// - /// Removes all occurences of white space characters from the end of a string; i.e. - /// - End - } + /// + /// Removes all occurrences of white space characters from the end of a string; i.e. + /// + End } \ No newline at end of file diff --git a/src/CoreEx/Entities/ValueResult.cs b/src/CoreEx/Entities/ValueResult.cs new file mode 100644 index 00000000..0cc7e6a9 --- /dev/null +++ b/src/CoreEx/Entities/ValueResult.cs @@ -0,0 +1,22 @@ +namespace CoreEx.Entities; + +/// +/// Provides a typed result wrapper (non-error) that contains additional context. +/// +/// The value . +/// This is not intended for error scenarios, as the likes of an , or enable accordingly. +/// The value. +/// The resulting . +public class ValueResult(T value = default!, HttpStatusCode? statusCode = null) : IValueResult +{ + /// + /// Gets or sets the value. + /// + public T Value { get; set; } = value; + + /// + /// Gets or sets the resulting . + /// + /// This does not imply that the result has to be used in an HTTP context; just that this represents a number of well-known statuses. + public HttpStatusCode? StatusCode { get; set; } = statusCode; +} \ No newline at end of file diff --git a/src/CoreEx/Entities/Writable.cs b/src/CoreEx/Entities/Writable.cs new file mode 100644 index 00000000..866892d1 --- /dev/null +++ b/src/CoreEx/Entities/Writable.cs @@ -0,0 +1,30 @@ +namespace CoreEx.Entities; + +/// +/// Represents the intended writable state of an entity property, indicating whether it can be modified and under what conditions. +/// +/// This is typically used to indicate the allowed operations for a property, such as whether it can be modified during creation, update, or both. It, however, does not enforce these rules; +/// it is merely descriptive. This is intended for the likes of OpenAPI for example, to mark up the properties as indicated. +/// See also . +public enum Writable +{ + /// + /// The property is always writable, meaning it can be modified during both creation and update operations. + /// + Always = 0, + + /// + /// The property is never writable, meaning it cannot be modified during either creation or update operations; is effectively read-only. + /// + Never = 1, + + /// + /// The property is writable only during creation operations, meaning it can be set when an entity is created but cannot be modified during update operations. + /// + CreateOnly = 2, + + /// + /// The property is writable only during update operations, meaning it cannot be set when an entity is created but can be modified during update operations. + /// + UpdateOnly = 3 +} \ No newline at end of file diff --git a/src/CoreEx/Entities/WritableAttribute.cs b/src/CoreEx/Entities/WritableAttribute.cs new file mode 100644 index 00000000..71204100 --- /dev/null +++ b/src/CoreEx/Entities/WritableAttribute.cs @@ -0,0 +1,16 @@ +namespace CoreEx.Entities; + +/// +/// Specifies the intended writable state of an entity property, indicating whether it can be modified and under what conditions. +/// +/// This is typically used to indicate the allowed operations for a property, such as whether it can be modified during creation, update, or both. It, however, does not enforce these rules; +/// it is merely descriptive. This is intended for the likes of OpenAPI for example, to mark up the properties as indicated. +/// See also . +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public class WritableAttribute(Writable writable = Writable.Always) : Attribute +{ + /// + /// Gets the intent. + /// + public Writable Writable { get; } = writable; +} \ No newline at end of file diff --git a/src/CoreEx/Events/Attachments/EventAttachment.cs b/src/CoreEx/Events/Attachments/EventAttachment.cs deleted file mode 100644 index 3b3f238f..00000000 --- a/src/CoreEx/Events/Attachments/EventAttachment.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Events.Attachments -{ - /// - /// Represents the attachment reference metadata. - /// - public class EventAttachment - { - /// - /// Gets or sets the optional attachment content type. - /// - public string? ContentType { get; set; } - - /// - /// Gets or sets the attachment reference (i.e. file location). - /// - public string? Attachment { get; set; } - - /// - /// Indicates whether the is considered empty (not specified). - /// - public bool IsEmpty => string.IsNullOrEmpty(Attachment) && string.IsNullOrEmpty(ContentType); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Attachments/IAttachmentStorage.cs b/src/CoreEx/Events/Attachments/IAttachmentStorage.cs deleted file mode 100644 index 23dcfd56..00000000 --- a/src/CoreEx/Events/Attachments/IAttachmentStorage.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events.Attachments -{ - /// - /// Enables the reading and writing of a attachment that exceeds the as identified by a corresponding . - /// - /// This is the enabling interface to support the Claim-Check pattern. This should be used - /// by the to perform in an underlying messaging sub-system agnostic manner. - public interface IAttachmentStorage - { - /// - /// Gets or sets the maximum size (length) of the representation to become an attachment. - /// - /// Typically it is the serialized used for the ; however, additional metadata depending on the serializer may (and is likely) to be sent and therefore - /// should be considered when setting this value; i.e. this should be less than the maximum value supported by the underlying messaging sub-system. In addition, just because a messaging sub-system can support a large message - /// length does not necessarily mean that this is the best approach; it may be more efficient or less costly to store separately. - int MaxDataSize { get; set; } - - /// - /// Writes the to the underlying storage and returns the details. - /// - /// The initiating . - /// The attachment contents serialized as . - /// The . - /// The details. - Task WriteAsync(EventData @event, BinaryData attachmentData, CancellationToken cancellationToken); - - /// - /// Reads the from the underlying storage and returns the contents as . - /// - /// The details. - /// The . - /// The attachment contents as . - Task ReadAync(EventAttachment attachment, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/CloudEventSerializerBase.cs b/src/CoreEx/Events/CloudEventSerializerBase.cs deleted file mode 100644 index def781ec..00000000 --- a/src/CoreEx/Events/CloudEventSerializerBase.cs +++ /dev/null @@ -1,281 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CloudNative.CloudEvents; -using CoreEx.Events.Attachments; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Net.Mime; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Provides the base capabilities. - /// - /// The . - public abstract class CloudEventSerializerBase(EventDataFormatter? eventDataFormatter) : IEventSerializer - { - private const string SubjectName = "subject"; - private const string ActionName = "action"; - private const string CorrelationIdName = "correlationid"; - private const string PartitionKeyName = "partitionkey"; - private const string TenantIdName = "tenantid"; - private const string ETagName = "etag"; - private const string KeyName = "key"; - - /// - /// Gets the list of reserved attribute names. - /// - /// The reserved names are as follows: 'id', 'time', 'type', 'source', 'subject', 'action', 'correlationid', 'tenantid', 'etag', 'partitionkey', 'key'. Also, - /// an attribute name must consist of lowercase letters and digits only; any that contain other characters will be ignored. - public static string[] ReservedNames { get; } = ["id", "time", "type", "source", SubjectName, ActionName, CorrelationIdName, TenantIdName, ETagName, PartitionKeyName, KeyName]; - - /// - public EventDataFormatter EventDataFormatter { get; } = eventDataFormatter ?? new EventDataFormatter(); - - /// - public IAttachmentStorage? AttachmentStorage { get; set; } - - /// - public CustomEventSerializers CustomSerializers { get; } = new(); - - /// - public async Task DeserializeAsync(BinaryData eventData, CancellationToken cancellationToken = default) - { - CloudEvent ce; - var @event = new EventData(); - if (AttachmentStorage is null) - { - ce = await DecodeAsync(eventData, cancellationToken).ConfigureAwait(false); - @event.Value = ce.Data; - } - else - { - ce = await DecodeAsync(eventData, cancellationToken).ConfigureAwait(false); - if (ce.Data is not null && ce.Data is EventAttachment attachment && !attachment.IsEmpty) - { - var val = await AttachmentStorage.ReadAync(attachment, cancellationToken).ConfigureAwait(false)!; - @event.Value = EventDataFormatter.JsonSerializer!.Deserialize(val)!; - } - else - { - ce = await DecodeAsync(eventData, cancellationToken).ConfigureAwait(false); - @event.Value = ce.Data; - } - } - - DeserializeFromCloudEvent(ce, @event); - return @event; - } - - /// - public async Task> DeserializeAsync(BinaryData eventData, CancellationToken cancellationToken = default) - { - CloudEvent ce; - var @event = new EventData(); - if (AttachmentStorage is null) - { - ce = await DecodeAsync(eventData, cancellationToken).ConfigureAwait(false); - @event.Value = (T)ce.Data!; - } - else - { - ce = await DecodeAsync(eventData, cancellationToken).ConfigureAwait(false); - if (ce.Data is not null && ce.Data is EventAttachment attachment && !attachment.IsEmpty) - { - var val = await AttachmentStorage.ReadAync(attachment, cancellationToken).ConfigureAwait(false)!; - @event.Value = EventDataFormatter.JsonSerializer!.Deserialize(val)!; - } - else - { - ce = await DecodeAsync(eventData, cancellationToken).ConfigureAwait(false); - @event.Value = (T)ce.Data!; - } - } - - DeserializeFromCloudEvent(ce, @event); - return @event; - } - - /// - public async Task DeserializeAsync(BinaryData eventData, Type valueType, CancellationToken cancellationToken = default) - { - var mi = GetType().GetMethod(nameof(DeserializeAsync), 1, [typeof(BinaryData), typeof(CancellationToken)])!; - dynamic task = mi.MakeGenericMethod(valueType.ThrowIfNull(nameof(valueType))).Invoke(this, new object[] { eventData, cancellationToken })!; - return await task.ConfigureAwait(false); - } - - /// - /// Deserializes from the into the . - /// - private void DeserializeFromCloudEvent(CloudEvent cloudEvent, EventData @event) - { - @event.Id = cloudEvent.Id; - @event.Timestamp = cloudEvent.Time; - @event.Type = cloudEvent.Type; - @event.Source = cloudEvent.Source; - - if (TryGetExtensionAttribute(cloudEvent, SubjectName, out string? val)) - @event.Subject = val; - - if (TryGetExtensionAttribute(cloudEvent, ActionName, out val)) - @event.Action = val; - - if (TryGetExtensionAttribute(cloudEvent, CorrelationIdName, out val)) - @event.CorrelationId = val; - else - @event.CorrelationId = null; - - if (TryGetExtensionAttribute(cloudEvent, PartitionKeyName, out val)) - @event.PartitionKey = val; - - if (TryGetExtensionAttribute(cloudEvent, TenantIdName, out val)) - @event.TenantId = val; - - if (TryGetExtensionAttribute(cloudEvent, ETagName, out val)) - @event.ETag = val; - - if (TryGetExtensionAttribute(cloudEvent, KeyName, out val)) - @event.Key = val; - - foreach (var att in cloudEvent.ExtensionAttributes) - { - if (!ReservedNames.Contains(att.Name) && TryGetExtensionAttribute(cloudEvent, att.Name, out val)) - @event.AddAttribute(att.Name, val); - } - - OnDeserialize(cloudEvent, @event); - } - - /// - /// Invoked after the standard properties have been updated from the to enable further customization where required. - /// - /// The source . - /// The corresponding . - protected virtual void OnDeserialize(CloudEvent cloudEvent, EventData @event) { } - - /// - /// Decodes (deserializes) the JSON into a . - /// - /// The . - protected abstract Task DecodeAsync(BinaryData eventData, CancellationToken cancellation); - - /// - /// Decodes (deserializes) the typed into a . - /// - /// The . - protected abstract Task DecodeAsync(BinaryData eventData, CancellationToken cancellation); - - /// - public Task SerializeAsync(EventData @event, CancellationToken cancellationToken = default) - { - @event = @event.ThrowIfNull(nameof(@event)).Copy(); - return SerializeToCloudEventAsync(@event, cancellationToken); - } - - /// - public Task SerializeAsync(EventData @event, CancellationToken cancellationToken = default) - { - @event = @event.ThrowIfNull(nameof(@event)).Copy(); - return SerializeToCloudEventAsync(@event, cancellationToken); - } - - /// - /// Serializes the . - /// - private async Task SerializeToCloudEventAsync(EventData @event, CancellationToken cancellationToken) - { - var ce = new CloudEvent - { - Id = @event.Id, - Time = @event.Timestamp, - Type = @event.Type ?? throw new InvalidOperationException($"CloudEvents must have a Type; the {nameof(EventDataFormatter)} should be updated to set."), - Source = @event.Source ?? throw new InvalidOperationException($"CloudEvents must have a Source; the {nameof(EventDataFormatter)} should be updated to set.") - }; - - SetExtensionAttribute(ce, SubjectName, @event.Subject); - SetExtensionAttribute(ce, ActionName, @event.Action); - SetExtensionAttribute(ce, CorrelationIdName, @event.CorrelationId); - SetExtensionAttribute(ce, PartitionKeyName, @event.PartitionKey); - SetExtensionAttribute(ce, TenantIdName, @event.TenantId); - SetExtensionAttribute(ce, ETagName, @event.ETag); - SetExtensionAttribute(ce, KeyName, @event.Key); - - if (@event.Attributes != null) - { - foreach (var att in @event.Attributes.Where(x => !string.IsNullOrEmpty(x.Key) && x.Key.All(c => char.IsLetterOrDigit(c)))) - { - SetExtensionAttribute(ce, EventDataFormatter.TextInfo.ToLower(att.Key), att.Value); - } - } - - OnSerialize(@event, ce); - - if (@event.Value is not null) - { - ce.DataContentType = MediaTypeNames.Application.Json; - ce.Data = CustomSerializers.SerializeToBinaryData(@event, EventDataFormatter.JsonSerializer!, true); - - // Where attachments are supported, check the size of the data and write to the attachment storage if required. - if (AttachmentStorage is not null) - { - var data = CustomSerializers.SerializeToBinaryData(@event, EventDataFormatter.JsonSerializer!, true); - if (data.ToMemory().Length >= AttachmentStorage!.MaxDataSize) - ce.Data = await AttachmentStorage.WriteAsync(@event, data, cancellationToken).ConfigureAwait(false); - } - } - - return await EncodeAsync(ce, cancellationToken).ConfigureAwait(false); - } - - /// - /// Encodes (serializes) the into a . - /// - /// The . - /// The . - /// The resulting . - protected abstract Task EncodeAsync(CloudEvent cloudEvent, CancellationToken cancellationToken); - - /// - /// Invoked after the standard properties have been updated to the to enable further customization where required. - /// - /// The source . - /// The corresponding . - protected virtual void OnSerialize(EventDataBase @event, CloudEvent cloudEvent) { } - - /// - /// Sets the extension attribute where not default value. - /// - /// The . - /// The attribute name. - /// The attribute value. - protected static void SetExtensionAttribute(CloudEvent ce, string name, T value) - { - if (Comparer.Default.Compare(value, default!) == 0) - return; - - ce[name] = value; - } - - /// - /// Gets the extension attribute value. - /// - /// The . - /// The attribute name. - /// The attribute value. - /// true indicates that the extension attribute exists; otherwise, false. - private static bool TryGetExtensionAttribute(CloudEvent ce, string name, [NotNullWhen(true)] out T? value) - { - value = default!; - var val = ce[name]; - if (val == null) - return false; - - value = (T)val; - return true; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/CustomEventSerializers.cs b/src/CoreEx/Events/CustomEventSerializers.cs deleted file mode 100644 index 7cc3c1a2..00000000 --- a/src/CoreEx/Events/CustomEventSerializers.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using System; -using System.Collections.Generic; - -namespace CoreEx.Events -{ - /// - /// Enables the adding of for specific types to customize the serialization. - /// - /// This allows the JSON to be manipulated before it is sent; for example, to remove properties and/or mask content where applicable. - public class CustomEventSerializers - { - private readonly Dictionary _serializers = []; - - /// - /// Adds a serializer for the specified type. - /// - /// The . - /// The to be used to perform the serialization. - /// The to enable fluent-style method-chaining. - public CustomEventSerializers Add(CustomEventSerializer serializer) - { - _serializers.Add(typeof(T), serializer); - return this; - } - - /// - /// Serialize the () to JSON . - /// - /// The for serialization. - /// The to be used. - /// Indicates whether the is serialized only (true); or alternatively, the complete including all metadata (false). - /// The JSON . - public virtual BinaryData SerializeToBinaryData(EventData @event, IJsonSerializer jsonSerializer, bool serializeValueOnly) - { - @event.ThrowIfNull(nameof(@event)); - jsonSerializer.ThrowIfNull(nameof(jsonSerializer)); - - if (@event.Value is not null && _serializers.TryGetValue(@event.Value.GetType(), out var serializer)) - return serializer(@event, jsonSerializer, serializeValueOnly); - else - return DefaultEventSerializer(@event, jsonSerializer, serializeValueOnly); - } - - /// - /// Provides the default event serialization. - /// - public static CustomEventSerializer DefaultEventSerializer { get; } = (@event, jsonSerializer, serializeValueOnly) => - { - @event.ThrowIfNull(nameof(@event)); - jsonSerializer.ThrowIfNull(nameof(jsonSerializer)); - - return serializeValueOnly ? jsonSerializer.SerializeToBinaryData(@event.Value) : jsonSerializer.SerializeToBinaryData(@event); - }; - } - - /// - /// Represents the method that provides the custom event serialization. - /// - /// The for serialization. - /// The to be used. - /// Indicates whether the is serialized only (true); or alternatively, the complete including all metadata (false). - /// - public delegate BinaryData CustomEventSerializer(EventData @event, IJsonSerializer jsonSerializer, bool serializeValueOnly); -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventData.cs b/src/CoreEx/Events/EventData.cs deleted file mode 100644 index 582b2813..00000000 --- a/src/CoreEx/Events/EventData.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Events -{ - /// - /// Represents the core event data with a generic . - /// - public class EventData : EventDataBase - { - /// - /// Initializes a new instance of the class. - /// - public EventData() : base() { } - - /// - /// Initializes a new instance of the class copying from another (excludes ). - /// - /// The to copy from. - /// Does not copy the underlying ; this must be set explicitly. - public EventData(EventDataBase @event) : base(@event) { } - - /// - /// Gets or sets the underlying data. - /// - public object? Value { get; set; } - - /// - /// Copies the (including the ) creating a new instance. - /// - /// A new instance. - public EventData Copy() => new(this) { Value = Value }; - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventDataBase.cs b/src/CoreEx/Events/EventDataBase.cs deleted file mode 100644 index 18d41ca0..00000000 --- a/src/CoreEx/Events/EventDataBase.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace CoreEx.Events -{ - /// - /// Provides the base event data. - /// - public abstract class EventDataBase : IIdentifier, ITenantId, IPartitionKey, IETag - { - private Dictionary? _internal; - - /// - /// Initializes a new instance of the class. - /// - protected EventDataBase() { } - - /// - /// Initializes a new instance of the class copying the metadata from another . - /// - /// The to copy the metadata from. - protected EventDataBase(EventDataBase @event) => CopyMetadata(@event); - - /// - /// Gets or sets the unique event identifier. - /// - public string? Id { get; set; } - - /// - /// Gets or sets the event subject. - /// - /// This is the core subject. Often this will be the name (noun) of the entity being published. - public string? Subject { get; set; } - - /// - /// Gets or sets the event action. - /// - /// This is the action or command (verb) related to the . - public string? Action { get; set; } - - /// - /// Gets or sets the event type. - /// - /// This describes the type of occurrence which has happened. Often this attribute is used for routing, observability, policy enforcement, etc. - public string? Type { get; set; } - - /// - /// Gets or sets the event source. - /// - /// This describes the event producer. Often this will include information such as the type of the event source, the organization publishing the event, the process that produced the event, and some unique identifiers. - public Uri? Source { get; set; } - - /// - /// Gets or sets the event timestamp. - /// - public DateTimeOffset? Timestamp { get; set; } - - /// - /// Gets or sets the event correlation identifier. - /// - public string? CorrelationId { get; set; } - - /// - /// Gets or sets the event key. - /// - public string? Key { get; set; } - - /// - /// Gets or sets the tenant identifier. - /// - public string? TenantId { get; set; } - - /// - /// Gets or sets the partition key. - /// - public string? PartitionKey { get; set; } - - /// - /// Gets or sets the entity tag. - /// - public string? ETag { get; set; } - - /// - /// Gets or sets the list of extended/additional attributes to be published/sent. - /// - /// The key and value are both of to ensure that the dictionary can be serialized/deserialized consistently where required. - /// It is recommeded to use the for data not intended for publishing/sending purposes. - public IDictionary? Attributes { get; set; } - - /// - /// Indicates whether there are any items within the dictionary. - /// - [JsonIgnore()] - public bool HasAttributes => Attributes != null && Attributes.Count > 0; - - /// - /// Adds a new attribute to the . - /// - /// The key. - /// The value. - public void AddAttribute(string key, string value) => (Attributes ??= new Dictionary()).Add(key, value.ThrowIfNull(nameof(value))); - - /// - /// Determines whether the contain an attribute with the specified . - /// - /// The key. - /// true indicates that the attribute exists; otherwise, false. - public bool HasAttribute(string key) => Attributes is not null && Attributes.ContainsKey(key); - - /// - /// Gets the associated with the specified . - /// - /// The key. - /// The value where exists; otherwise, null. - /// true indicates that the attribute exists; otherwise, false. - public bool TryGetAttribute(string key, [NotNullWhen(true)] out string? value) - { - if (Attributes is null) - { - value = null; - return false; - } - - return Attributes.TryGetValue(key, out value); - } - - /// - /// Gets the internal properties; note that these are for internal storage pre-publishing and sending; and therefore are not automatically published. - /// - /// It is recommened to use the for the purposes of publishing and sending of additional data. - [JsonIgnore()] - public IDictionary Internal => _internal ??= []; - - /// - /// Indicates whether there are any items within the dictionary. - /// - [JsonIgnore()] - public bool HasInternal => _internal != null && _internal.Count > 0; - - /// - /// Copies the metadata from the specified replacing existing. - /// - /// The to copy from. - public void CopyMetadata(EventDataBase @event) - { - Id = (@event.ThrowIfNull(nameof(@event))).Id; - Timestamp = @event.Timestamp; - Subject = @event.Subject; - Action = @event.Action; - Type = @event.Type; - Source = @event.Source; - CorrelationId = @event.CorrelationId; - TenantId = @event.TenantId; - PartitionKey = @event.PartitionKey; - ETag = @event.ETag; - Key = @event.Key; - - if (@event.HasAttributes) - { - Attributes ??= new Dictionary(); - Attributes.Clear(); - foreach (var attribute in @event.Attributes!) - Attributes.Add(attribute.Key, attribute.Value); - } - else - Attributes?.Clear(); - - if (@event.HasInternal) - { - _internal ??= new Dictionary(@event.Internal); - _internal.Clear(); - foreach (var attribute in @event.Internal) - _internal.Add(attribute.Key, attribute.Value); - } - else - _internal?.Clear(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventDataFormatter.cs b/src/CoreEx/Events/EventDataFormatter.cs deleted file mode 100644 index 7f1cd194..00000000 --- a/src/CoreEx/Events/EventDataFormatter.cs +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using CoreEx.Globalization; -using CoreEx.Json; -using System; -using System.Globalization; - -namespace CoreEx.Events -{ - /// - /// Provides the formatting options and corresponding . - /// - /// This enables further standardized formatting of the prior to serialization. - /// The , and will default, where null, to , and - /// respectively. - public class EventDataFormatter - { - /// - /// Initializes a new instance of the class. - /// - public EventDataFormatter() => TypeDefault = _ => TypeDefaultToValueTypeName ? "None" : null; - - /// - /// Gets or sets the property selection; where a property is not selected its value will be reset to null. - /// - /// Defaults to . - public EventDataProperty PropertySelection { get; set; } = EventDataProperty.All; - - /// - /// Gets or sets the to manage the underlying applications. - /// - public TextInfo TextInfo { get; set; } = CultureInfo.InvariantCulture.TextInfo; - - /// - /// Gets or sets the value casing conversion. - /// - /// Defaults to . - public TextInfoCasing TypeCasing = TextInfoCasing.Lower; - - /// - /// Indicates whether to append the to the value. - /// - /// This is applied before . - public bool TypeAppendKey { get; set; } = false; - - /// - /// Indicates whether to append the to the value. - /// - /// This is applied after . - public bool TypeAppendEntityKey { get; set; } = false; - - /// - /// Gets or sets the separator character. - /// - /// Defaults to '.'. - public char TypeSeparatorCharacter { get; set; } = '.'; - - /// - /// Indicates whether to default the to the value where null. - /// - public bool TypeDefaultToValueTypeName { get; set; } = true; - - /// - /// Gets or sets the value casing conversion. - /// - /// Defaults to . - public TextInfoCasing SubjectCasing = TextInfoCasing.Lower; - - /// - /// Indicates whether to append the to the value. - /// - /// This is applied before . - public bool SubjectAppendKey { get; set; } = false; - - /// - /// Indicates whether to append the to the value. - /// - /// This is applied after . - public bool SubjectAppendEntityKey { get; set; } = false; - - /// - /// Gets or sets the separator character. - /// - /// Defaults to '.'. - public char SubjectSeparatorCharacter { get; set; } = '.'; - - /// - /// Indicates whether to default the to the value where null. - /// - public bool SubjectDefaultToValueTypeName { get; set; } = false; - - /// - /// Gets or sets the value casing conversion. - /// - /// Defaults to . - public TextInfoCasing ActionCasing = TextInfoCasing.Lower; - - /// - /// Gets or sets the default delegate to be used where not specified. - /// - /// Defaults to null. - public Func? SourceDefault { get; set; } - - /// - /// Gets or sets the default delegate to be used where not specified. - /// - /// Defaults to a function that returns 'None' when is true. - public Func? TypeDefault { get; set; } - - /// - /// Gets or sets the separator character. - /// - /// Defaults to ','. - public char KeySeparatorCharacter { get; set; } = ','; - - /// - /// Indicates whether to default the to the value where it implements . - /// - /// This is applied before . - public bool ETagDefaultFromValue { get; set; } = true; - - /// - /// Indicates whether to default the to the value by using the . - /// - /// This is applied after . - public bool ETagDefaultGenerated { get; set; } = false; - - /// - /// Indicates whether to default the to the value where it implements . - /// - public bool PartitionKeyDefaultFromValue { get; set; } = true; - - /// - /// Indicates whether to default the to the value where it implements . - /// - public bool TenantIdDefaultFromValue { get; set; } = true; - - /// - /// Gets or sets the . - /// - /// Required for the . - public IJsonSerializer? JsonSerializer { get; set; } - - /// - /// Formats the using the configured formatting options where applicable. - /// - /// The to format. - public virtual void Format(EventData @event) - { - var value = @event.Value; - - @event.Id ??= Guid.NewGuid().ToString(); - @event.Timestamp ??= new DateTimeOffset(SystemTime.Timestamp); - - if (PropertySelection.HasFlag(EventDataProperty.Key)) - { - if (@event.Key is null && value is not null && value is IEntityKey ek) - @event.Key = ek.EntityKey.ToString(KeySeparatorCharacter); - } - - if (PropertySelection.HasFlag(EventDataProperty.Subject)) - { - if (@event.Subject == null && SubjectDefaultToValueTypeName && value != null) - @event.Subject = TextInfo.ToCasing(GetDataType(value, SubjectSeparatorCharacter), SubjectCasing); - else if (@event.Subject != null && SubjectCasing != TextInfoCasing.None) - @event.Subject = TextInfo.ToCasing(@event.Subject, SubjectCasing); - - if (SubjectAppendKey && @event.Key != null) - @event.Subject = Concatenate(@event.Subject, SubjectSeparatorCharacter, @event.Key); - else if (SubjectAppendEntityKey && value is IEntityKey ek) - @event.Subject = Concatenate(@event.Subject, SubjectSeparatorCharacter, ek.EntityKey.ToString(KeySeparatorCharacter)); - } - else - @event.Subject = null; - - if (PropertySelection.HasFlag(EventDataProperty.Action)) - { - if (@event.Action != null && ActionCasing != TextInfoCasing.None) - @event.Action = TextInfo.ToCasing(@event.Action, ActionCasing); - } - else - @event.Action = null; - - if (PropertySelection.HasFlag(EventDataProperty.Type)) - { - if (@event.Type == null && TypeDefaultToValueTypeName && value != null) - @event.Type = TextInfo.ToCasing(GetDataType(value, TypeSeparatorCharacter), TypeCasing); - else if (@event.Type != null && TypeCasing != TextInfoCasing.None) - @event.Type = TextInfo.ToCasing(@event.Type, TypeCasing); - - if (TypeAppendKey && @event.Key != null) - @event.Type = Concatenate(@event.Type, TypeSeparatorCharacter, @event.Key); - else if (TypeAppendEntityKey && value is IEntityKey ek) - @event.Type = Concatenate(@event.Type, TypeSeparatorCharacter, ek.EntityKey.ToString(KeySeparatorCharacter)); - - @event.Type ??= TextInfo.ToCasing(TypeDefault?.Invoke(@event), TypeCasing); - } - else - @event.Type = null; - - if (PropertySelection.HasFlag(EventDataProperty.CorrelationId)) - @event.CorrelationId ??= ExecutionContext.HasCurrent ? ExecutionContext.Current.CorrelationId : @event.Id; - else - @event.CorrelationId = null; - - if (PropertySelection.HasFlag(EventDataProperty.TenantId)) - { - if (@event.TenantId is null && TenantIdDefaultFromValue && value is not null && value is ITenantId tid) - @event.TenantId = tid.TenantId; - } - else - @event.TenantId = null; - - if (PropertySelection.HasFlag(EventDataProperty.PartitionKey)) - { - if (@event.PartitionKey is null && PartitionKeyDefaultFromValue && value is not null && value is IPartitionKey pk) - @event.PartitionKey = pk.PartitionKey; - } - else - @event.PartitionKey = null; - - if (PropertySelection.HasFlag(EventDataProperty.ETag)) - { - if (@event.ETag == null) - { - if (ETagDefaultFromValue && value != null && value is IETag etag) - @event.ETag = etag.ETag; - - if (@event.ETag == null && ETagDefaultGenerated && value != null) - @event.ETag = ETagGenerator.Generate(JsonSerializer ?? throw new InvalidOperationException($"The {nameof(JsonSerializer)} must be provided for the {nameof(ETagDefaultGenerated)} to function."), value); - } - } - else - @event.ETag = null; - - if (!PropertySelection.HasFlag(EventDataProperty.Attributes) && @event.HasAttributes) - @event.Attributes = null; - - if (PropertySelection.HasFlag(EventDataProperty.Source)) - @event.Source ??= SourceDefault?.Invoke(@event); - else - @event.Source = null; - - if (!PropertySelection.HasFlag(EventDataProperty.Key)) - @event.Key = null; - - if (@event.Value is not null && @event.Value is IEventDataFormatter formattable) - formattable.Format(@event); - } - - /// - /// Gets the formatted EventData.Data Type name. - /// - private static string? GetDataType(object? val, char separator) - { - if (val == null) - return null; - - var name = val.GetType().FullName; - return separator == '.' ? name : name?.Replace('.', separator); - } - - /// - /// Concatenate to make new string value. - /// - private static string? Concatenate(string? left, char separator, string? right) - { - if (string.IsNullOrEmpty(left)) - return right; - else if (string.IsNullOrEmpty(right)) - return left; - else - return string.Concat(left, separator, right); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventDataProperty.cs b/src/CoreEx/Events/EventDataProperty.cs deleted file mode 100644 index 72305cba..00000000 --- a/src/CoreEx/Events/EventDataProperty.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Events -{ - /// - /// Represents the property selection. - /// - /// The , and are non-selectable; i.e. are always included. - [Flags] - public enum EventDataProperty - { - /// - /// Represents no properties. - /// - None = 0, - - /// - /// Selects the property. - /// - Subject = 1, - - /// - /// Selects the property. - /// - Action = 2, - - /// - /// Selects the property. - /// - Type = 4, - - /// - /// Selects the property. - /// - Source = 8, - - /// - /// Selects the property. - /// - TenantId = 16, - - /// - /// Selects the property. - /// - PartitionKey = 32, - - /// - /// Selects the property. - /// - ETag = 64, - - /// - /// Selects the property. - /// - CorrelationId = 128, - - /// - /// Selects the property. - /// - Key = 256, - - /// - /// Selects the property. - /// - Attributes = 512, - - /// - /// Selects all of the properties. - /// - All = AllExceptAttributes | Attributes, - - /// - /// Selects all of the properties except . - /// - AllExceptAttributes = Subject | Action | Type | Source | TenantId | PartitionKey | ETag | CorrelationId | Key, - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventDataSerializerBase.cs b/src/CoreEx/Events/EventDataSerializerBase.cs deleted file mode 100644 index 88f06a46..00000000 --- a/src/CoreEx/Events/EventDataSerializerBase.cs +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events.Attachments; -using CoreEx.Json; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Provides the base capabilities. - /// - /// The indicates whether the is serialized only (default); or alternatively, the complete . - /// The . - /// The . - public abstract class EventDataSerializerBase(IJsonSerializer jsonSerializer, EventDataFormatter? eventDataFormatter) : IEventSerializer - { - /// - /// Gets the . - /// - public IJsonSerializer JsonSerializer { get; } = jsonSerializer.ThrowIfNull(nameof(jsonSerializer)); - - /// - public EventDataFormatter EventDataFormatter { get; } = eventDataFormatter ?? new EventDataFormatter(); - - /// - public IAttachmentStorage? AttachmentStorage { get; set; } - - /// - public CustomEventSerializers CustomSerializers { get; } = new(); - - /// - /// Indicates whether the is serialized only (true); or alternatively, the complete including all metadata (false). - /// - /// Defaults to true. - public bool SerializeValueOnly { get; set; } = true; - - /// - public async Task DeserializeAsync(BinaryData eventData, CancellationToken cancellationToken = default) - { - var (Attachment, Data) = await DeserializeAttachmentAsync(eventData, cancellationToken).ConfigureAwait(false); - - if (SerializeValueOnly) - return new EventData { Value = JsonSerializer.Deserialize(Data) }; - - if (AttachmentStorage is null || Attachment is null) - return JsonSerializer.Deserialize(eventData)!; - - return new EventData(DeserializeAsBase(eventData)) { Value = JsonSerializer.Deserialize(Data)! }; - } - - /// - public async Task> DeserializeAsync(BinaryData eventData, CancellationToken cancellationToken = default) - { - var (Attachment, Data) = await DeserializeAttachmentAsync(eventData, cancellationToken).ConfigureAwait(false); - - if (SerializeValueOnly) - return new EventData { Value = JsonSerializer.Deserialize(Data)! }; - - if (AttachmentStorage is null || Attachment is null) - return JsonSerializer.Deserialize>(Data)!; - - return new EventData(DeserializeAsBase(eventData)) { Value = JsonSerializer.Deserialize(Data)! }; - } - - /// - public async Task DeserializeAsync(BinaryData eventData, Type valueType, CancellationToken cancellationToken = default) - { - valueType.ThrowIfNull(nameof(valueType)); - var (Attachment, Data) = await DeserializeAttachmentAsync(eventData, cancellationToken).ConfigureAwait(false); - var edvt = typeof(EventData<>).MakeGenericType(valueType); - EventData ed; - - if (SerializeValueOnly) - { - ed = (EventData)Activator.CreateInstance(edvt)!; - ed.Value = JsonSerializer.Deserialize(Data, valueType); - return ed; - } - - if (AttachmentStorage is null || Attachment is null) - return (EventData)JsonSerializer.Deserialize(Data, edvt)!; - - ed = (EventData)Activator.CreateInstance(edvt)!; - ed.CopyMetadata(DeserializeAsBase(eventData)); - ed.Value = JsonSerializer.Deserialize(Data, valueType); - return ed; - } - - /// - /// Determine whether the event data is an attachment reference; and if so, replace current event data with the attachment contents. - /// - private async Task<(EventAttachment? Attachment, BinaryData Data)> DeserializeAttachmentAsync(BinaryData eventData, CancellationToken cancellationToken) - { - EventAttachment? attachment = null; - var data = eventData; - if (AttachmentStorage is not null) - { - attachment = SerializeValueOnly ? JsonSerializer.Deserialize(eventData) : JsonSerializer.Deserialize>(eventData)?.Value; - if (attachment is not null && !attachment.IsEmpty) - data = await AttachmentStorage.ReadAync(attachment, cancellationToken).ConfigureAwait(false); - } - - return (attachment is not null && attachment.IsEmpty ? null : attachment, data); - } - - /// - /// Deserializes as the base . - /// - private EventData DeserializeAsBase(BinaryData eventData) - => SerializeValueOnly ? new EventData { Value = JsonSerializer.Deserialize(eventData) } : JsonSerializer.Deserialize(eventData)!; - - /// - public Task SerializeAsync(EventData @event, CancellationToken cancellationToken = default) - => SerializeInternalAsync(@event, cancellationToken); - - /// - public Task SerializeAsync(EventData @event, CancellationToken cancellationToken = default) - => SerializeInternalAsync(@event, cancellationToken); - - /// - /// Serializes the to a and optionally writes any attachment. - /// - private async Task SerializeInternalAsync(EventData @event, CancellationToken cancellationToken = default) - { - BinaryData data; - EventAttachment attachment; - - // Only serializes the value. - if (SerializeValueOnly) - { - data = CustomSerializers.SerializeToBinaryData(@event, JsonSerializer, SerializeValueOnly); - if (AttachmentStorage is null || data.ToMemory().Length <= AttachmentStorage!.MaxDataSize) - return data; - - // Create the attachment and serialize the event with the attachment reference. - attachment = await AttachmentStorage.WriteAsync(@event, data, cancellationToken).ConfigureAwait(false); - return JsonSerializer.SerializeToBinaryData(attachment); - } - - // Serializes the complete event including metadata. - if (AttachmentStorage is null) - return CustomSerializers.SerializeToBinaryData(@event, JsonSerializer, SerializeValueOnly); - - // Serialize the value and check if needs to be an attachment. - data = CustomSerializers.SerializeToBinaryData(@event, JsonSerializer, true); - if (data.ToMemory().Length < AttachmentStorage!.MaxDataSize) - return CustomSerializers.SerializeToBinaryData(@event, JsonSerializer, false); - - // Create the attachment and re-serialize the event with the attachment reference. - attachment = await AttachmentStorage.WriteAsync(@event, data, cancellationToken).ConfigureAwait(false); - return JsonSerializer.SerializeToBinaryData(new EventData(@event) { Value = attachment }); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventDataT.cs b/src/CoreEx/Events/EventDataT.cs deleted file mode 100644 index 72f26085..00000000 --- a/src/CoreEx/Events/EventDataT.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Events -{ - /// - /// Represents the with a typed . - /// - /// The . - public class EventData : EventData - { - /// - /// Initializes a new instance of the class. - /// - public EventData() : base() => Value = default!; - - /// - /// Initializes a new instance of the class copying from another (excludes ). - /// - /// The to copy from. - /// Does not copy the underlying ; this must be set explicitly. - public EventData(EventDataBase @event) : base(@event) { } - - /// - /// Gets or sets the event data. - /// - public new T Value { get => (T)base.Value!; set => base.Value = value; } - - /// - /// Copies the (including the ) creating a new instance. - /// - /// A new instance. - public new EventData Copy() => new(this) { Value = Value }; - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventExtensions.cs b/src/CoreEx/Events/EventExtensions.cs deleted file mode 100644 index 5705475e..00000000 --- a/src/CoreEx/Events/EventExtensions.cs +++ /dev/null @@ -1,587 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; - -namespace CoreEx.Events -{ - /// - /// Provides extension methods for events. - /// - public static class EventExtensions - { - /// - /// Creates an and publishes. - /// - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishEvent(this IEventPublisher publisher, string? subject = null) => publisher.Publish(CreateEvent(publisher, subject, null)); - - /// - /// Creates an and publishes. - /// - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishEvent(this IEventPublisher publisher, string? subject, string? action = null) => publisher.Publish(CreateEvent(publisher, subject, action)); - - /// - /// Creates an including the specified and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishEvent(this IEventPublisher publisher, string? subject, string? action, CompositeKey? key) => publisher.Publish(CreateEvent(publisher, subject, action, key)); - - /// - /// Creates an including the specified and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishEvent(this IEventPublisher publisher, string? subject, string? action, params object?[] keyArgs) => publisher.Publish(CreateEvent(publisher, subject, action, keyArgs)); - - /// - /// Creates an and publishes. - /// - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishEvent(this IEventPublisher publisher, Uri source, string? subject = null) => publisher.Publish(CreateEvent(publisher, source, subject)); - - /// - /// Creates an and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishEvent(this IEventPublisher publisher, Uri source, string? subject, string? action = null) => publisher.Publish(CreateEvent(publisher, source, subject, action)); - - /// - /// Creates an including the specified and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishEvent(this IEventPublisher publisher, Uri source, string? subject, string? action, CompositeKey? key) => publisher.Publish(CreateEvent(publisher, source, subject, action, key)); - - /// - /// Creates an including the specified and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishEvent(this IEventPublisher publisher, Uri source, string? subject, string? action, params object?[] keyArgs) => publisher.Publish(CreateEvent(publisher, source, subject, action, keyArgs)); - - /// - /// Creates an and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishValueEvent(this IEventPublisher publisher, T value, Uri source, string? subject = null) - => publisher.Publish(CreateValueEvent(publisher, value, source, subject)); - - /// - /// Creates an and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishValueEvent(this IEventPublisher publisher, T value, Uri source, string? subject, string? action = null) - => publisher.Publish(CreateValueEvent(publisher, value, source, subject, action)); - - /// - /// Creates an including the specified and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishValueEvent(this IEventPublisher publisher, T value, Uri source, string? subject, string? action, CompositeKey? key) - => publisher.Publish(CreateValueEvent(publisher, value, source, subject, action, key)); - - /// - /// Creates an including the specified and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishValueEvent(this IEventPublisher publisher, T value, Uri source, string? subject, string? action, params object?[] keyArgs) - => publisher.Publish(CreateValueEvent(publisher, value, source, subject, action, keyArgs)); - - /// - /// Creates an and publishes. - /// - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishValueEvent(this IEventPublisher publisher, T value, string? subject = null) - => publisher.Publish(CreateValueEvent(publisher, value, subject, null)); - - /// - /// Creates an and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishValueEvent(this IEventPublisher publisher, T value, string? subject, string? action = null) - => publisher.Publish(CreateValueEvent(publisher, value, subject, action)); - - /// - /// Creates an including the specified and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishValueEvent(this IEventPublisher publisher, T value, string? subject, string? action, CompositeKey? key) - => publisher.Publish(CreateValueEvent(publisher, value, subject, action, key)); - - /// - /// Creates an including the specified and publishes. - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishValueEvent(this IEventPublisher publisher, T value, string? subject, string? action, params object?[] keyArgs) - => publisher.Publish(CreateValueEvent(publisher, value, subject, action, keyArgs)); - - /// - /// Creates an and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishNamedEvent(this IEventPublisher publisher, string name, Uri source, string? subject = null) => publisher.PublishNamed(name, CreateEvent(publisher, source, subject)); - - /// - /// Creates an and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishNamedEvent(this IEventPublisher publisher, string name, Uri source, string? subject, string? action = null) => publisher.PublishNamed(name, CreateEvent(publisher, source, subject, action)); - - /// - /// Creates an including the specified and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishNamedEvent(this IEventPublisher publisher, string name, Uri source, string? subject, string? action, CompositeKey? key) => publisher.PublishNamed(name, CreateEvent(publisher, source, subject, action, key)); - - /// - /// Creates an including the specified and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishNamedEvent(this IEventPublisher publisher, string name, Uri source, string? subject, string? action, params object?[] keyArgs) => publisher.PublishNamed(name, CreateEvent(publisher, source, subject, action, keyArgs)); - - /// - /// Creates an and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishNamedEvent(this IEventPublisher publisher, string name, string? subject = null) => publisher.PublishNamed(name, CreateEvent(publisher, subject, null)); - - /// - /// Creates an and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishNamedEvent(this IEventPublisher publisher, string name, string? subject, string? action = null) => publisher.PublishNamed(name, CreateEvent(publisher, subject, action)); - - /// - /// Creates an including the specified and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishNamedEvent(this IEventPublisher publisher, string name, string? subject, string? action, CompositeKey? key) => publisher.PublishNamed(name, CreateEvent(publisher, subject, action, key)); - - /// - /// Creates an including the specified and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - public static IEventPublisher PublishNamedEvent(this IEventPublisher publisher, string name, string? subject, string? action, params object?[] keyArgs) => publisher.PublishNamed(name, CreateEvent(publisher, subject, action, keyArgs)); - - /// - /// Creates an and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishNamedValueEvent(this IEventPublisher publisher, string name, T value, Uri source, string? subject = null) => publisher.PublishNamed(name, CreateValueEvent(publisher, value, source, subject)); - - /// - /// Creates an and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishNamedValueEvent(this IEventPublisher publisher, string name, T value, Uri source, string? subject, string? action = null) => publisher.PublishNamed(name, CreateValueEvent(publisher, value, source, subject, action)); - - /// - /// Creates an including the specified and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishNamedValueEvent(this IEventPublisher publisher, string name, T value, Uri source, string? subject, string? action, CompositeKey? key) - => publisher.PublishNamed(name, CreateValueEvent(publisher, value, source, subject, action, key)); - - /// - /// Creates an including the specified and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishNamedValueEvent(this IEventPublisher publisher, string name, T value, Uri source, string? subject, string? action, params object?[] keyArgs) - => publisher.PublishNamed(name, CreateValueEvent(publisher, value, source, subject, action, keyArgs)); - - /// - /// Creates an and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishNamedValueEvent(this IEventPublisher publisher, string name, T value, string? subject = null) => publisher.PublishNamed(name, CreateValueEvent(publisher, value, subject, null)); - - /// - /// Creates an and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishNamedValueEvent(this IEventPublisher publisher, string name, T value, string? subject, string? action = null) => publisher.PublishNamed(name, CreateValueEvent(publisher, value, subject, action)); - - /// - /// Creates an including the specified and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishNamedValueEvent(this IEventPublisher publisher, string name, T value, string? subject, string? action, CompositeKey? key) - => publisher.PublishNamed(name, CreateValueEvent(publisher, value, subject, action, key)); - - /// - /// Creates an including the specified and publishes to a named destination (e.g. queue or topic). - /// - /// The . - /// The destination name. - /// The . - /// The . - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// The is automatically inferred from the where implemented. - public static IEventPublisher PublishNamedValueEvent(this IEventPublisher publisher, string name, T value, string? subject, string? action, params object?[] keyArgs) - => publisher.PublishNamed(name, CreateValueEvent(publisher, value, subject, action, keyArgs)); - - /// - /// Creates an . - /// - /// The . - /// The . - /// The . - public static EventData CreateEvent(this IEventPublisher publisher, string? subject = null) - => UpdateEventData(publisher, new() { Subject = subject }, null); - - /// - /// Creates an . - /// - /// The . - /// The . - /// The . - /// The . - public static EventData CreateEvent(this IEventPublisher publisher, string? subject, string? action = null) - => UpdateEventData(publisher, new() { Subject = subject, Action = action }, null); - - /// - /// Creates an including the specified . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - public static EventData CreateEvent(this IEventPublisher publisher, string? subject, string? action, CompositeKey? key) - => UpdateEventData(publisher, new() { Subject = subject, Action = action }, key); - - /// - /// Creates an including the specified . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - public static EventData CreateEvent(this IEventPublisher publisher, string? subject, string? action, params object?[] keyArgs) - => CreateEvent(publisher, subject, action, new CompositeKey(keyArgs)); - - /// - /// Creates an . - /// - /// The . - /// The . - /// The . - /// The . - public static EventData CreateEvent(this IEventPublisher publisher, Uri source, string? subject = null) - => UpdateEventData(publisher, new() { Source = source.ThrowIfNull(nameof(source)), Subject = subject }, null); - - /// - /// Creates an . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - public static EventData CreateEvent(this IEventPublisher publisher, Uri source, string? subject, string? action = null) - => UpdateEventData(publisher, new() { Source = source.ThrowIfNull(nameof(source)), Subject = subject, Action = action }, null); - - /// - /// Creates an including the specified . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - public static EventData CreateEvent(this IEventPublisher publisher, Uri source, string? subject, string? action, CompositeKey? key) - => UpdateEventData(publisher, new() { Source = source.ThrowIfNull(nameof(source)), Subject = subject, Action = action }, key); - - /// - /// Creates an including the specified . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - public static EventData CreateEvent(this IEventPublisher publisher, Uri source, string? subject, string? action, params object?[] keyArgs) - => CreateEvent(publisher, source, subject, action, new CompositeKey(keyArgs)); - - /// - /// Creates an . - /// - /// The . - /// The . - /// The . - /// The . - /// The is automatically inferred from the where implemented. - public static EventData CreateValueEvent(this IEventPublisher publisher, T value, string? subject = null) - => (EventData)UpdateEventData(publisher, new EventData() { Value = value.ThrowIfNull(nameof(value)), Subject = subject }, value is IEntityKey ek ? (CompositeKey?)ek.EntityKey : null); - - /// - /// Creates an . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The is automatically inferred from the where implemented. - public static EventData CreateValueEvent(this IEventPublisher publisher, T value, string? subject, string? action = null) - => (EventData)UpdateEventData(publisher, new EventData() { Value = value.ThrowIfNull(nameof(value)), Subject = subject, Action = action }, value is IEntityKey ek ? (CompositeKey?)ek.EntityKey : null); - - /// - /// Creates an including the specified . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - /// The is automatically inferred from the where implemented. - public static EventData CreateValueEvent(this IEventPublisher publisher, T value, string? subject, string? action, CompositeKey key) - => (EventData)UpdateEventData(publisher, new EventData() { Value = value.ThrowIfNull(nameof(value)), Subject = subject, Action = action }, key); - - /// - /// Creates an including the specified . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - /// The is automatically inferred from the where implemented. - public static EventData CreateValueEvent(this IEventPublisher publisher, T value, string? subject, string? action, params object?[] keyArgs) - => (EventData)UpdateEventData(publisher, new EventData() { Value = value.ThrowIfNull(nameof(value)), Subject = subject, Action = action }, keyArgs.Length == 1 && keyArgs[0] is CompositeKey ck ? ck : new CompositeKey(keyArgs)); - - /// - /// Creates an . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The is automatically inferred from the where implemented. - public static EventData CreateValueEvent(this IEventPublisher publisher, T value, Uri source, string? subject = null) - => (EventData)UpdateEventData(publisher, new EventData() { Value = value.ThrowIfNull(nameof(value)), Source = source.ThrowIfNull(nameof(source)), Subject = subject }, value is IEntityKey ek ? (CompositeKey?)ek.EntityKey : null); - - /// - /// Creates an . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - /// The is automatically inferred from the where implemented. - public static EventData CreateValueEvent(this IEventPublisher publisher, T value, Uri source, string? subject, string? action = null) - => (EventData)UpdateEventData(publisher, new EventData() { Value = value.ThrowIfNull(nameof(value)), Source = source.ThrowIfNull(nameof(source)), Subject = subject, Action = action }, value is IEntityKey ek ? (CompositeKey?)ek.EntityKey : null); - - /// - /// Creates an including the specified . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - /// The is automatically inferred from the where implemented. - public static EventData CreateValueEvent(this IEventPublisher publisher, T value, Uri source, string? subject, string? action, CompositeKey key) - => (EventData)UpdateEventData(publisher, new EventData() { Value = value.ThrowIfNull(nameof(value)), Source = source.ThrowIfNull(nameof(source)), Subject = subject, Action = action }, key); - - /// - /// Creates an including the specified . - /// - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - /// The . - /// The is automatically inferred from the where implemented. - public static EventData CreateValueEvent(this IEventPublisher publisher, T value, Uri source, string? subject, string? action, params object?[] keyArgs) - => (EventData)UpdateEventData(publisher, new EventData() { Value = value.ThrowIfNull(nameof(value)), Source = source.ThrowIfNull(nameof(source)), Subject = subject, Action = action }, keyArgs.Length == 1 && keyArgs[0] is CompositeKey ck ? ck : new CompositeKey(keyArgs)); - - /// - /// Adds the formatted to the and stores the underlying within . - /// - private static EventData UpdateEventData(IEventPublisher publisher, EventData @event, CompositeKey? key) - { - if (key is not null) - { - @event.Key = key.Value.ToString(publisher.EventDataFormatter.KeySeparatorCharacter); - @event.Internal.Add(nameof(EventDataBase.Key), key.Value.Args); - } - - return @event; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventPublisher.cs b/src/CoreEx/Events/EventPublisher.cs deleted file mode 100644 index 6a4ac0cb..00000000 --- a/src/CoreEx/Events/EventPublisher.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Provides the event publishing and sending; being the event formatting, - /// serialization and send. - /// - /// The . - /// The . - /// The . - public class EventPublisher(EventDataFormatter? eventDataFormatter, IEventSerializer eventSerializer, IEventSender eventSender) : IEventPublisher, IDisposable - { - private readonly ConcurrentQueue<(string? Destination, EventData Event)> _queue = new(); - - /// - /// Gets the . - /// - public EventDataFormatter EventDataFormatter { get; } = eventDataFormatter ?? new EventDataFormatter(); - - /// - /// Gets the . - /// - public IEventSerializer EventSerializer { get; } = eventSerializer.ThrowIfNull(nameof(eventSerializer)); - - /// - /// Gets the . - /// - public IEventSender EventSender { get; } = eventSender.ThrowIfNull(nameof(eventSender)); - - /// - public bool IsEmpty => _queue.IsEmpty; - - /// - /// Sends one or more objects. - /// - /// One or more objects to be published. - /// The to support fluent-style method-chaining. - public IEventPublisher Publish(params EventData[] events) => PublishInternal(null, events); - - /// - /// Sends one or more objects to a named destination (e.g. queue or topic). - /// - /// The destination name. - /// One or more objects to be published. - /// The to support fluent-style method-chaining. - /// The could represent a queue name or equivalent where appropriate. - public IEventPublisher PublishNamed(string name, params EventData[] events) => PublishInternal(name.ThrowIfNullOrEmpty(nameof(name)), events); - - /// - /// Performs the formatting and queues internally. - /// - private EventPublisher PublishInternal(string? name, params EventData[] events) - { - foreach (var @event in events) - { - EventDataFormatter.Format(@event); - _queue.Enqueue((name, @event)); - } - - return this; - } - - /// - /// - /// - /// - /// Initially performs the serialization for each queued event, then performs a single send for all. - public Task SendAsync(CancellationToken cancellationToken = default) => EventPublisherInvoker.Current.InvokeAsync(this, async (_, cancellationToken) => - { - var list = new List(); - while (_queue.TryDequeue(out var item)) - { - var bd = await EventSerializer.SerializeAsync(item.Event, cancellationToken).ConfigureAwait(false); - var esd = new EventSendData(item.Event) { Destination = item.Destination, Data = bd }; - await OnEventSendAsync(item.Destination, item.Event, esd, cancellationToken).ConfigureAwait(false); - list.Add(esd); - } - - await EventSender.SendAsync([.. list], cancellationToken).ConfigureAwait(false); - }, cancellationToken); - - /// - /// Invoked on the send of the . - /// - /// The destination name. - /// The (after the is applied). - /// The corresponding after invocation. - /// The . - protected virtual Task OnEventSendAsync(string? name, EventData eventData, EventSendData eventSendData, CancellationToken cancellationToken) => Task.CompletedTask; - - /// - public virtual void Reset() => _queue.Clear(); - - /// - /// Dispose of resources. - /// - /// Thrown when there are unsent events. - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases the unmanaged resources used by the and optionally releases the managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) { } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventPublisherInvoker.cs b/src/CoreEx/Events/EventPublisherInvoker.cs deleted file mode 100644 index b6af9e5b..00000000 --- a/src/CoreEx/Events/EventPublisherInvoker.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Provides the invocation wrapping for the instances. - /// - public class EventPublisherInvoker : InvokerBase - { - private const string InvokerEventSender = "invoker.eventsender"; - private static EventPublisherInvoker? _default; - - /// - /// Gets the current configured instance (see ). - /// - public static EventPublisherInvoker Current => CoreEx.ExecutionContext.GetService() ?? (_default ??= new EventPublisherInvoker()); - - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, EventPublisher invoker, Func func) => throw new NotSupportedException(); - - /// - protected override Task OnInvokeAsync(InvokeArgs invokeArgs, EventPublisher invoker, Func> func, CancellationToken cancellationToken) - { - invokeArgs.Activity?.AddTag(InvokerEventSender, invoker.EventSender.GetType().FullName); - return base.OnInvokeAsync(invokeArgs, invoker, func, cancellationToken); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventSendData.cs b/src/CoreEx/Events/EventSendData.cs deleted file mode 100644 index 3428fdfe..00000000 --- a/src/CoreEx/Events/EventSendData.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Events -{ - /// - /// Represents the where the is the serialized (see or - /// ) representation that is to used for a . - /// - public class EventSendData : EventDataBase - { - /// - /// Initializes a new instance of the class. - /// - public EventSendData() { } - - /// - /// Initializes a new instance of the class copying from another excluding the underlying and . - /// - /// The . - public EventSendData(EventDataBase @event) : base(@event) { } - - /// - /// Get or sets the optional destination name (i.e. queue or topic). - /// - public string? Destination { get; set; } - - /// - /// Gets or sets the . - /// - public BinaryData? Data { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventSendException.cs b/src/CoreEx/Events/EventSendException.cs deleted file mode 100644 index 9ed26e15..00000000 --- a/src/CoreEx/Events/EventSendException.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; - -namespace CoreEx.Events -{ - /// - /// Represents an with a collection of . - /// - public class EventSendException : Exception - { - /// - /// Initializes a new instance of the class with a and . - /// - /// The exception message. - /// The events that were not sent. - public EventSendException(string message, IEnumerable? notSentEvents = null) : base(message) => NotSentEvents = notSentEvents; - - /// - /// Initializes a new instance of the class with a , and . - /// - /// The exception message. - /// The inner . - /// The events that were not sent. - public EventSendException(string message, Exception innerException, IEnumerable? notSentEvents = null) : base(message, innerException) => NotSentEvents = notSentEvents; - - /// - /// Gets the events that were not sent to enable further exception processing of these where required. - /// - public IEnumerable? NotSentEvents { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventSubscriberBase.cs b/src/CoreEx/Events/EventSubscriberBase.cs deleted file mode 100644 index 7ebd6e9f..00000000 --- a/src/CoreEx/Events/EventSubscriberBase.cs +++ /dev/null @@ -1,273 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using CoreEx.Events.Subscribing; -using CoreEx.Hosting.Work; -using CoreEx.Localization; -using CoreEx.Validation; -using Microsoft.Extensions.Logging; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Provides the base event subscriber capabilities. - /// - /// The . - /// The . - /// The . - /// The . - /// The . - public abstract class EventSubscriberBase(IEventDataConverter eventDataConverter, ExecutionContext executionContext, SettingsBase settings, ILogger logger, EventSubscriberInvoker? eventSubscriberInvoker = null) : IErrorHandling - { - private static EventSubscriberInvoker? _invoker; - private ErrorHandler? _errorHandler; - - /// - /// Gets the standard message error text. - /// - public static readonly LText MessageErrorText = new($"{typeof(BusinessException).FullName}.{nameof(MessageErrorText)}", "Invalid message; body was not provided, contained invalid JSON, or was incorrectly formatted:"); - - /// - /// Gets the standard required value error text. - /// - public static readonly LText RequiredErrorText = new($"{typeof(BusinessException).FullName}.{nameof(RequiredErrorText)}", $"{MessageErrorText} Value is required."); - - /// - /// Gets the standard null event error text. - /// - public static readonly LText NullEventErrorText = new($"{typeof(BusinessException).FullName}.{nameof(NullEventErrorText)}", $"{MessageErrorText} Event deserialized as null."); - - /// - /// Gets the . - /// - public IEventDataConverter EventDataConverter { get; } = eventDataConverter.ThrowIfNull(nameof(eventDataConverter)); - - /// - /// Gets the . - /// - public ExecutionContext ExecutionContext { get; } = executionContext.ThrowIfNull(nameof(executionContext)); - - /// - /// Gets the . - /// - public SettingsBase Settings { get; } = settings.ThrowIfNull(nameof(settings)); - - /// - /// Gets the . - /// - public ILogger Logger { get; } = logger.ThrowIfNull(nameof(logger)); - - /// - /// Gets the . - /// - public EventSubscriberInvoker EventSubscriberInvoker { get; } = eventSubscriberInvoker ?? (_invoker ??= new EventSubscriberInvoker()); - - /// - /// Gets or sets the where an occurs during / /. - /// - /// Defaults to . - public ErrorHandling EventDataDeserializationErrorHandling { get; set; } = ErrorHandling.HandleBySubscriber; - - /// - /// Defaults to . - public ErrorHandling UnhandledHandling { get; set; } = ErrorHandling.HandleBySubscriber; - - /// - /// Defaults to . - public ErrorHandling SecurityHandling { get; set; } = ErrorHandling.HandleBySubscriber; - - /// - /// Defaults to . - public ErrorHandling TransientHandling { get; set; } = ErrorHandling.Retry; - - /// - /// Defaults to . - public ErrorHandling NotFoundHandling { get; set; } = ErrorHandling.HandleBySubscriber; - - /// - /// Defaults to . - public ErrorHandling ConcurrencyHandling { get; set; } = ErrorHandling.HandleBySubscriber; - - /// - /// Defaults to . - public ErrorHandling DataConsistencyHandling { get; set; } = ErrorHandling.HandleBySubscriber; - - /// - /// Defaults to . - public ErrorHandling InvalidDataHandling { get; set; } = ErrorHandling.HandleBySubscriber; - - /// - /// Defaults to . - public ErrorHandling? WorkStateAlreadyFinishedHandling { get; set; } = ErrorHandling.HandleBySubscriber; - - /// - /// Gets or sets the optional . - /// - public IEventSubscriberInstrumentation? Instrumentation { get; set; } - - /// - /// Gets or sets the optional to orchestrate and track ; this enables the likes of the async request-response pattern. - /// - /// The is set from the and the is set from the to enable the underlying operations. Where an - /// is thrown the will be set to as it is unknown as to whether the message will be reprocessed. - public WorkStateOrchestrator? WorkStateOrchestrator { get; set; } - - /// - /// Gets or sets the . - /// - public ErrorHandler ErrorHandler { get => _errorHandler ??= new ErrorHandler(); set => _errorHandler = value; } - - /// - /// Performs any checks prior to the processing of the . - /// - /// The unique identifier from the originiating message. - /// The originating message. - /// The . - /// The . - /// true indicates that processing can continue; otherwise, false. - /// Where there is a corresponding for the and it is not in a state that is considered valid for processing then processing will not occur. - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Future proofing.")] - protected async Task OnBeforeProcessingAsync(string identifier, object originatingMessage, EventSubscriberArgs args, CancellationToken cancellationToken = default) - { - args.ThrowIfNull(nameof(args)); - if (WorkStateOrchestrator is null) - { - args.SetState(this, identifier, null); - return true; - } - - var wr = await WorkStateOrchestrator.GetAsync(identifier, cancellationToken).ConfigureAwait(false); - args.SetState(this, identifier, wr); - if (wr is null || WorkStatus.InProgress.HasFlag(wr.Status)) - return true; - - if (wr.Status == WorkStatus.Created) - { - await WorkStateOrchestrator.StartAsync(identifier, cancellationToken).ConfigureAwait(false); - return true; - } - - if (WorkStateAlreadyFinishedHandling is null) - return true; - - await ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(identifier, new EventSubscriberException($"Unable to process message as corresponding work state status is {wr.Status}: {wr.Reason ?? "Unexpected state."}") { ExceptionSource = EventSubscriberExceptionSource.WorkStateAlreadyFinished }, WorkStateAlreadyFinishedHandling.Value, Logger) { Instrumentation = Instrumentation, WorkOrchestrator = null }, cancellationToken).ConfigureAwait(false); - return false; - } - - /// - /// Deserializes () the into the specified value containg metadata only. - /// - /// The originating message. - /// The . - protected Task DeserializeEventMetaDataOnlyAsync(object originatingMessage, CancellationToken cancellationToken = default) - => EventDataConverter.ConvertFromMetadataOnlyAsync(originatingMessage, cancellationToken); - - /// - /// Deserializes () the into the specified value. - /// - /// The unique identifier from the originiating message. - /// The originating message. - /// The . - /// The where deserialized successfully; otherwise, null. - protected async Task DeserializeEventAsync(string identifier, object originatingMessage, CancellationToken cancellationToken = default) - { - try - { - var @event = await EventDataConverter.ConvertFromAsync(originatingMessage, cancellationToken).ConfigureAwait(false); - if (@event is not null) - return @event; - } - catch (Exception ex) - { - await ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(identifier, new EventSubscriberException($"{MessageErrorText} {ex.Message}", ex) { ExceptionSource = EventSubscriberExceptionSource.EventDataDeserialization }, EventDataDeserializationErrorHandling, Logger) { Instrumentation = Instrumentation, WorkOrchestrator = WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - return null; - } - - await ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(identifier, new EventSubscriberException(NullEventErrorText) { ExceptionSource = EventSubscriberExceptionSource.EventDataDeserialization }, EventDataDeserializationErrorHandling, Logger) { Instrumentation = Instrumentation, WorkOrchestrator = WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - return null; - } - - /// - /// Deserializes () the into the specified . - /// - /// The unique identifier from the originiating message. - /// The originating message. - /// The optional . - /// The . - /// The or () where deserialized successfully; otherwise, the corresponding . - protected async Task DeserializeEventAsync(string identifier, object originatingMessage, Type? valueType, CancellationToken cancellationToken = default) - { - if (valueType is null) - return await DeserializeEventAsync(identifier, originatingMessage, cancellationToken).ConfigureAwait(false); - - try - { - var @event = await EventDataConverter.ConvertFromAsync(originatingMessage, valueType, cancellationToken).ConfigureAwait(false)!; - if (@event is not null) - return @event; - } - catch (Exception ex) - { - await ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(identifier, new EventSubscriberException($"{MessageErrorText} {ex.Message}", ex) { ExceptionSource = EventSubscriberExceptionSource.EventDataDeserialization }, EventDataDeserializationErrorHandling, Logger) { Instrumentation = Instrumentation, WorkOrchestrator = WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - return null; - } - - await ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(identifier, new EventSubscriberException(NullEventErrorText) { ExceptionSource = EventSubscriberExceptionSource.EventDataDeserialization }, EventDataDeserializationErrorHandling, Logger) { Instrumentation = Instrumentation, WorkOrchestrator = WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - return null; - } - - /// - /// Deserializes () the into the specified . - /// - /// The . - /// The unique identifier from the originiating message. - /// The originating message. - /// Indicates whether the value is required; will consider invalid where null. - /// The to validate the deserialized value. - /// The . - /// The where deserialized successfully; otherwise, the corresponding . - /// Will result in an where a deserialization error occurs, or where or error occurs. - protected async Task?> DeserializeEventAsync(string identifier, object originatingMessage, bool valueIsRequired = true, IValidator? validator = null, CancellationToken cancellationToken = default) - { - // Deserialize the event. - EventData? @event; - try - { - @event = await EventDataConverter.ConvertFromAsync(originatingMessage, cancellationToken).ConfigureAwait(false)!; - } - catch (Exception ex) - { - await ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(identifier, new EventSubscriberException($"{MessageErrorText} {ex.Message}", ex) { ExceptionSource = EventSubscriberExceptionSource.EventDataDeserialization }, EventDataDeserializationErrorHandling, Logger) { Instrumentation = Instrumentation, WorkOrchestrator = WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - return null; - } - - if (@event is null) - { - await ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(identifier, new EventSubscriberException(NullEventErrorText) { ExceptionSource = EventSubscriberExceptionSource.EventDataDeserialization }, EventDataDeserializationErrorHandling, Logger) { Instrumentation = Instrumentation, WorkOrchestrator = WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - return null; - } - - // Perform the requested validation where applicable. - Exception? vex = null; - if (valueIsRequired && @event.Value == null) - vex = new ValidationException(RequiredErrorText); - else if (@event.Value != null && validator != null) - { - var vr = await validator.ValidateAsync(@event.Value, cancellationToken).ConfigureAwait(false); - if (vr.HasErrors) - vex = vr.ToException(); - } - - // Exit where the event is considered valid. - if (vex is null) - return @event; - - // Handle the validation exception. - await ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(identifier, new EventSubscriberException(vex.Message, vex), ErrorHandler.DetermineErrorHandling(this, vex), Logger) { Instrumentation = Instrumentation, WorkOrchestrator = WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - return null; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/EventSubscriberException.cs b/src/CoreEx/Events/EventSubscriberException.cs deleted file mode 100644 index ff7b13b3..00000000 --- a/src/CoreEx/Events/EventSubscriberException.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Events.Subscribing; -using System; -using System.Net; - -namespace CoreEx.Events -{ - /// - /// Represents an event subscriber that implements , that also takes on the characterics of the where applicable. - /// - /// This is intended for internal CoreEx use only to manage errors/exceptions; throwing or catching directly may result in unintended side-effects. - public sealed class EventSubscriberException : Exception, IExtendedException - { - /// - /// Initializes a new instance of the class with a . - /// - /// The . - public EventSubscriberException(string message) : base(message) { } - - /// - /// Initializes a new instance of the class with a and - /// - /// The . - /// The . - public EventSubscriberException(string message, Exception innerException) : base(message, innerException) => IsTransient = InnerExtendedException?.IsTransient ?? false; - - /// - /// Gets the . - /// - /// Defaults to . - public EventSubscriberExceptionSource ExceptionSource { get; set; } = EventSubscriberExceptionSource.Subscriber; - - /// - /// Gets the as an where applicable. - /// - private IExtendedException? InnerExtendedException => InnerException is IExtendedException eex ? eex : null; - - /// - /// Indicates that there is an and that it implements . - /// - public bool HasInnerExtendedException => InnerException is IExtendedException; - - /// - /// Gets or sets the used when handling the error. - /// - /// See . - public ErrorHandling ErrorHandling { get; set; } = ErrorHandling.HandleByHost; - - /// - /// Gets the error type/reason. - /// - /// See either the or for standard values. - public string ErrorType => InnerExtendedException?.ErrorType ?? (ExceptionSource == EventSubscriberExceptionSource.Subscriber ? Abstractions.ErrorType.UnhandledError.ToString() : ExceptionSource.ToString()); - - /// - public int ErrorCode => InnerExtendedException?.ErrorCode ?? (int)ExceptionSource; - - /// - public HttpStatusCode StatusCode => InnerExtendedException?.StatusCode ?? HttpStatusCode.InternalServerError; - - /// - public bool IsTransient { get; set; } - - /// - public bool ShouldBeLogged => false; - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/HealthChecks/EventPublisherHealthCheck.cs b/src/CoreEx/Events/HealthChecks/EventPublisherHealthCheck.cs deleted file mode 100644 index 1c206354..00000000 --- a/src/CoreEx/Events/HealthChecks/EventPublisherHealthCheck.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events.HealthChecks -{ - /// - /// Provides a health check for the by sending a health-check (see ). - /// - /// The . - /// Note: Only use where the corresponding subscriber(s)/consumer(s) are aware and can ignore/filter to avoid potential downstream challenges. - public class EventPublisherHealthCheck(IEventPublisher eventPublisher) : IHealthCheck - { - private readonly IEventPublisher _eventPublisher = eventPublisher.ThrowIfNull(nameof(eventPublisher)); - - /// - /// Gets or sets the . - /// - public EventPublisherHealthCheckOptions Options { get; } = new EventPublisherHealthCheckOptions(); - - /// - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - var options = Options ?? new EventPublisherHealthCheckOptions(); - var @event = options.EventData.Copy(); - if (options.Destination is null) - _eventPublisher.Publish(@event); - else - _eventPublisher.PublishNamed(options.Destination, @event); - - await _eventPublisher.SendAsync(cancellationToken).ConfigureAwait(false); - return HealthCheckResult.Healthy(null, new Dictionary { { "destination", options.Destination ?? "" }, { "published", @event } } ); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/HealthChecks/EventPublisherHealthCheckOptions.cs b/src/CoreEx/Events/HealthChecks/EventPublisherHealthCheckOptions.cs deleted file mode 100644 index 8905c793..00000000 --- a/src/CoreEx/Events/HealthChecks/EventPublisherHealthCheckOptions.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Events.HealthChecks -{ - /// - /// Gets or sets the health-check options for the . - /// - public class EventPublisherHealthCheckOptions - { - /// - /// Gets or sets the destination name (e.g. queue or topic). - /// - public string? Destination { get; set; } - - /// - /// Gets or sets the health-check template. - /// - public EventData EventData { get; } = new EventData - { - Subject = "health.check", - Action = "probe", - Type = "health.check", - Source = new Uri("health/detailed", UriKind.Relative) - }; - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/IEventConverterT.cs b/src/CoreEx/Events/IEventConverterT.cs deleted file mode 100644 index e97ba48b..00000000 --- a/src/CoreEx/Events/IEventConverterT.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Enables conversion between and type. - /// - /// The messaging sub-system . - public interface IEventDataConverter : IEventDataConverter where TMessage : class - { - /// - async Task IEventDataConverter.ConvertToAsync(EventData @event, CancellationToken cancellationToken) => await ConvertToAsync(@event, cancellationToken).ConfigureAwait(false); - - /// - Task IEventDataConverter.ConvertFromMetadataOnlyAsync(object message, CancellationToken cancellationToken) => ConvertFromMetadataOnlyAsync((TMessage)message, cancellationToken); - - /// - Task IEventDataConverter.ConvertFromAsync(object message, CancellationToken cancellationToken) => ConvertFromAsync(message, null, cancellationToken); - - /// - Task IEventDataConverter.ConvertFromAsync(object message, Type? valueType, CancellationToken cancellationToken) => ConvertFromAsync((TMessage)message, valueType, cancellationToken); - - /// - Task> IEventDataConverter.ConvertFromAsync(object message, CancellationToken cancellationToken) => ConvertFromAsync((TMessage)message, cancellationToken); - - /// - /// Converts the to a . - /// - /// The . - /// The . - /// The value. - new Task ConvertToAsync(EventData @event, CancellationToken cancellationToken); - - /// - /// Converts from a messaging sub-system value to an value the metadata properties only (that underlying should be ignored). - /// - /// The messaging sub-system value. - /// The . - /// The . - Task ConvertFromMetadataOnlyAsync(TMessage message, CancellationToken cancellationToken); - - /// - /// Converts from a to an value. - /// - /// The value. - /// The . - /// The value. - public Task ConvertFromAsync(TMessage message, CancellationToken cancellationToken) => ConvertFromAsync(message, null, cancellationToken); - - /// - /// Converts from a to an or value depending on . - /// - /// The value. - /// The . - /// The . - /// The or value. - Task ConvertFromAsync(TMessage message, Type? valueType, CancellationToken cancellationToken); - - /// - /// Converts from a to an value. - /// - /// The . - /// The value. - /// The . - /// The value. - Task> ConvertFromAsync(TMessage message, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/IEventDataConverter.cs b/src/CoreEx/Events/IEventDataConverter.cs deleted file mode 100644 index 675b5cf4..00000000 --- a/src/CoreEx/Events/IEventDataConverter.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Enables conversion between and messaging sub-system type. - /// - public interface IEventDataConverter - { - /// - /// Converts the to a messaging sub-system value. - /// - /// The . - /// The . - /// The messaging sub-system value. - Task ConvertToAsync(EventData @event, CancellationToken cancellationToken); - - /// - /// Converts from a messaging sub-system value to an value the metadata properties only (that underlying should be ignored). - /// - /// The messaging sub-system value. - /// The . - /// The . - Task ConvertFromMetadataOnlyAsync(object message, CancellationToken cancellationToken); - - /// - /// Converts from a messaging sub-system value to an value. - /// - /// The messaging sub-system value. - /// The . - /// The . - public Task ConvertFromAsync(object message, CancellationToken cancellationToken) => ConvertFromAsync(message, null, cancellationToken); - - /// - /// Converts from a messaging sub-system value to an or depending on . - /// - /// The messaging sub-system value. - /// The . - /// The . - /// The or . - Task ConvertFromAsync(object message, Type? valueType, CancellationToken cancellationToken); - - /// - /// Converts from a messaging sub-system value to an . - /// - /// The . - /// The messaging sub-system value. - /// The . - /// The . - Task> ConvertFromAsync(object message, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/IEventDataFormatter.cs b/src/CoreEx/Events/IEventDataFormatter.cs deleted file mode 100644 index 9a877a9f..00000000 --- a/src/CoreEx/Events/IEventDataFormatter.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Events -{ - /// - /// Enables additional formatting of an by the . - /// - /// Invoked by the where formatting an and the corresponding value implements. - public interface IEventDataFormatter - { - /// - /// Format the . - /// - /// The being formatted. - void Format(EventData eventData); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/IEventPublisher.cs b/src/CoreEx/Events/IEventPublisher.cs deleted file mode 100644 index d525c252..00000000 --- a/src/CoreEx/Events/IEventPublisher.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Defines the standardized Event publishing and sending (generally encapsulates the and orchestration). - /// - /// Note to implementers: The Publish* methods should only cache/store the events queue (order must be maintained) to be sent; they should only be sent where is explicitly requested. - /// The key reason for queuing the published events it to promote a single atomic send operation; i.e. all events should be sent together, and either succeed or fail together. - /// The convenience methods will create an or formatting the (where applicable) using the - /// ; whilst also, adding the corresponding to with a key of 'Key'. These can be further - /// referenced during formal formatting/publish to add additional context/functionality as required. - public interface IEventPublisher - { - /// - /// Gets the corresponding . - /// - EventDataFormatter EventDataFormatter { get; } - - /// - /// Indicates whether the internal queue is empty. - /// - /// true where empty; otherwise, false. - /// The queue will not be empty where events have been published but not sent or cleared. - bool IsEmpty { get; } - - /// - /// Publishes (queues in-process) one or more objects ready for . - /// - /// One or more objects to be published. - /// The to support fluent-style method-chaining. - IEventPublisher Publish(params EventData[] events); - - /// - /// Publishes (queues in-process) one or more objects for a named destination (e.g. queue or topic) ready for . - /// - /// The destination name. - /// One or more objects to be published. - /// The name could represent a queue name or equivalent where appropriate. - /// The to support fluent-style method-chaining. - IEventPublisher PublishNamed(string name, params EventData[] events); - - /// - /// Sends all previously published (queued) events. - /// - /// The . - Task SendAsync(CancellationToken cancellationToken = default); - - /// - /// Resets by clearing the internal cache/store. - /// - void Reset(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/IEventSender.cs b/src/CoreEx/Events/IEventSender.cs deleted file mode 100644 index 6f5f145f..00000000 --- a/src/CoreEx/Events/IEventSender.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Defines the standardized Event sending via the actual messaging platform/protocol. - /// - /// The is expected to be already serialized . - public interface IEventSender - { - /// - /// Sends one or more objects. - /// - /// One or more objects to be sent. - /// The . - /// The . - Task SendAsync(IEnumerable events, CancellationToken cancellationToken = default); - - /// - /// Occurs after a successful . - /// - event EventHandler? AfterSend; - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/IEventSerializer.cs b/src/CoreEx/Events/IEventSerializer.cs deleted file mode 100644 index 9a760978..00000000 --- a/src/CoreEx/Events/IEventSerializer.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events.Attachments; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Defines an to/from serializer. - /// - public interface IEventSerializer - { - /// - /// Gets the . - /// - EventDataFormatter EventDataFormatter { get; } - - /// - /// Gets or sets the optional to use for an . - /// - public IAttachmentStorage? AttachmentStorage { get; set; } - - /// - /// Gets the which enables the or serialization to be customized per . - /// - public CustomEventSerializers CustomSerializers { get; } - - /// - /// Serializes the to a . - /// - /// The . - /// The . - /// The event . - Task SerializeAsync(EventData @event, CancellationToken cancellationToken = default); - - /// - /// Serializes the to a . - /// - /// The . - /// The . - /// The . - /// The event . - Task SerializeAsync(EventData @event, CancellationToken cancellationToken = default); - - /// - /// Deserializes the to an . - /// - /// The event . - /// The . - /// The . - Task DeserializeAsync(BinaryData eventData, CancellationToken cancellationToken = default); - - /// - /// Deserializes the to an . - /// - /// The . - /// The event . - /// The . - /// The . - Task> DeserializeAsync(BinaryData eventData, CancellationToken cancellationToken = default); - - /// - /// Deserializes the to a specified . - /// - /// The event . - /// The . - /// The . - /// The . - Task DeserializeAsync(BinaryData eventData, Type valueType, CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/InMemoryPublisher.cs b/src/CoreEx/Events/InMemoryPublisher.cs deleted file mode 100644 index 4813a464..00000000 --- a/src/CoreEx/Events/InMemoryPublisher.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Concurrent; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Provides an in-memory publisher which can be used for the likes of testing. - /// - /// Where a is provided then each will also be logged during Send. - /// The optional for logging the events (each ). - /// The optional for the logging. Defaults to - /// The ; defaults where not specified. - /// The optional . Defaults to . - public class InMemoryPublisher(ILogger? logger = null, IJsonSerializer? jsonSerializer = null, EventDataFormatter? eventDataFormatter = null, IEventSerializer? eventSerializer = null) : EventPublisher(eventDataFormatter, eventSerializer ?? new Text.Json.EventDataSerializer(), new InMemorySender()) - { - private readonly ILogger? _logger = logger; - private readonly IJsonSerializer _jsonSerializer = jsonSerializer ?? Json.JsonSerializer.Default; - private readonly ConcurrentDictionary> _dict = new(); - private const string NullName = "!@#$%"; - - /// - protected override Task OnEventSendAsync(string? name, EventData eventData, EventSendData eventSendData, CancellationToken cancellationToken) - { - var queue = _dict.GetOrAdd(name ?? NullName, _ => new ConcurrentQueue()); - queue.Enqueue(eventData); - - if (_logger != null) - { - var sb = new StringBuilder("Event send"); - if (!string.IsNullOrEmpty(name)) - sb.Append($" (destination: '{name}')"); - - sb.AppendLine(" ->"); - - var json = _jsonSerializer.Serialize(eventData, JsonWriteFormat.Indented); - sb.Append(json); - _logger.LogInformation("{Event}", sb.ToString()); - } - - return Task.CompletedTask; - } - - /// - /// Gets the list of destination names (i.e. queue or topic) used for sending. - /// - /// An array of names. - /// Where (with no name) is used the underlying destination name will be null. - public string?[] GetNames() => [.. _dict.Keys]; - - /// - /// Gets the events sent (in order) to the named destination. - /// - /// The destination name. - /// The corresponding events. - public EventData[] GetEvents(string? name = null) => _dict.TryGetValue(name ?? NullName, out var queue) ? [.. queue] : []; - - /// - /// Resets (clears) the in-memory state. - /// - public override void Reset() - { - base.Reset(); - _dict.Clear(); - ((InMemorySender)EventSender).Reset(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/InMemorySender.cs b/src/CoreEx/Events/InMemorySender.cs deleted file mode 100644 index 305e2630..00000000 --- a/src/CoreEx/Events/InMemorySender.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Provides an in-memory sender which can be used for the likes of testing. - /// - public class InMemorySender : IEventSender - { - private readonly ConcurrentQueue _queue = new(); - - /// - public Task SendAsync(IEnumerable events, CancellationToken cancellationToken = default) - { - events.ForEach(_queue.Enqueue); - AfterSend?.Invoke(this, EventArgs.Empty); - return Task.CompletedTask; - } - - /// - /// Gets the events sent (in order). - /// - public EventSendData[] GetEvents() => [.. _queue]; - - /// - /// Resets (clears) the in-memory state. - /// - public void Reset() => _queue.Clear(); - - /// - public event EventHandler? AfterSend; - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/LoggerEventPublisher.cs b/src/CoreEx/Events/LoggerEventPublisher.cs deleted file mode 100644 index 61ed60db..00000000 --- a/src/CoreEx/Events/LoggerEventPublisher.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using Microsoft.Extensions.Logging; -using System; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Represents an event publisher; whereby the events are output using . - /// - /// This is intended for testing and/or prototyping purposes. - /// The . - /// The . Defaults where not specified. - /// The . Defaults to . - public class LoggerEventPublisher(ILogger logger, EventDataFormatter? eventDataFormatter, IJsonSerializer? jsonSerializer) : EventPublisher(eventDataFormatter, new CoreEx.Text.Json.EventDataSerializer(), new NullEventSender()) - { - private readonly ILogger _logger = logger.ThrowIfNull(nameof(logger)); - private readonly IJsonSerializer _jsonSerializer = jsonSerializer ?? JsonSerializer.Default; - - /// - protected override Task OnEventSendAsync(string? name, EventData eventData, EventSendData eventSendData, CancellationToken cancellation) - { - var sb = new StringBuilder("Event send"); - if (!string.IsNullOrEmpty(name)) - sb.Append($" (destination: '{name}')"); - - sb.AppendLine(" ->"); - - var json = _jsonSerializer.Serialize(eventData, JsonWriteFormat.Indented); - sb.Append(json); - _logger.LogInformation("{Event}", sb.ToString()); - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/LoggerEventSender.cs b/src/CoreEx/Events/LoggerEventSender.cs deleted file mode 100644 index 5287fa7f..00000000 --- a/src/CoreEx/Events/LoggerEventSender.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Text.Json.Nodes; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Represents an event sender; whereby the is output using . - /// - /// This is intended for testing and/or prototyping purposes. - /// The . - public class LoggerEventSender(ILogger logger) : IEventSender - { - private readonly ILogger _logger = logger.ThrowIfNull(nameof(logger)); - - /// - public Task SendAsync(IEnumerable events, CancellationToken cancellation) - { - var i = 0; - foreach (var @event in events) - { - string data; - - try - { - var jo = JsonNode.Parse(@event.Data); - data = jo == null ? "" : jo.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); - } - catch - { - data = @event.Data == null ? "" : @event.Data.ToString(); - } - - _logger.LogInformation("{Event}", $"Event[{i}].Metadata = {Json.JsonSerializer.Default.Serialize(new EventData(@event), Json.JsonWriteFormat.Indented)}{Environment.NewLine}Event[{i}].Data = {data}"); - i++; - } - - AfterSend?.Invoke(this, EventArgs.Empty); - return Task.CompletedTask; - } - - /// - public event EventHandler? AfterSend; - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/NullEventPublisher.cs b/src/CoreEx/Events/NullEventPublisher.cs deleted file mode 100644 index 4f94b751..00000000 --- a/src/CoreEx/Events/NullEventPublisher.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Events -{ - /// - /// Represents a null event publisher; whereby the events are simply swallowed/discarded on send. - /// - public class NullEventPublisher : EventPublisher - { - /// - /// Initializes a new instance of the class. - /// - public NullEventPublisher() : base(null, new Text.Json.EventDataSerializer(), new NullEventSender()) { } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/NullEventSender.cs b/src/CoreEx/Events/NullEventSender.cs deleted file mode 100644 index 8f3a71b6..00000000 --- a/src/CoreEx/Events/NullEventSender.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events -{ - /// - /// Represents a null event sender; whereby the events are simply swallowed/discarded on send. - /// - public class NullEventSender : IEventSender - { - /// - public Task SendAsync(IEnumerable events, CancellationToken cancellationToken = default) - { - AfterSend?.Invoke(this, EventArgs.Empty); - return Task.CompletedTask; - } - - /// - public event EventHandler? AfterSend; - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/README.md b/src/CoreEx/Events/README.md deleted file mode 100644 index e0b3c308..00000000 --- a/src/CoreEx/Events/README.md +++ /dev/null @@ -1,123 +0,0 @@ -# CoreEx.Events - -The `CoreEx.Events` namespace provides extended capabilities to facilitate the publishing and subscribing of events (messages) in a consistent, but flexible, manner. - -
- -## Motivation - -To provide an event/message publishing capability that is flexible enough to support varying message formatting, from basic JSON payloads, all the way through to more advanced formatting such as [CloudEvents](https://cloudevents.io/). Additionally, to decouple the message formatting from the actual sending in a _pluggable_ manner, to be able to leverage any messaging platform (e.g. [Azure Service Bus](https://azure.microsoft.com/en-us/services/service-bus), [Solace](https://solace.com/), [Kafka](https://kafka.apache.org/), etc.) consistently. - -
- -## Publishing - -Event publishing is enabled by the following key capabilities: - -Capability | Description --|- -[`EventData`](./EventDataT.cs) | Provides the core properties (data) for an event/message. The standard properties are enabled by [`EventDataBase`](./EventDataBase.cs). Additional `Attributes` can be added where required; however, the key properties for most scenarios should be accounted for. -[`EventDataFormatter`](./EventDataFormatter.cs) | Formats an `EventData` instance, setting additional properties, etc. to meet the requirements of the application. There are a number of options within this class to support a rich level of formatting, and this class can be inherited to support more advanced scenarios as necessary. -[`IEventSerializer`](./IEventSerializer.cs) | Provides the capbilities to serialize the `EventData` into a corresponding [`BinaryData`](https://docs.microsoft.com/en-us/dotnet/api/system.binarydata) (i.e. `byte[]`) format ready for sending. An [`EventDataSerializerBase`](./EventDataSerializerBase.cs) and [`CloudEventSerializerBase`](./CloudEventSerializerBase.cs) provide the base implementation to perform basic JSON serialization or CloudEvents JSON serialization respectively, using either `System.Text.Json` or `Newtonsoft.Json` as required. -[`IEventSender`](./IEventSender.cs) | Performs the event sending via the actual messaging platform/protocol. For example, an Azure [`ServiceBusSender`](../../CoreEx.Azure/ServiceBus/ServiceBusSender.cs) implementation is provided. -[`IEventPublisher`](./IEventPublisher.cs) | Enables the publishing via the `Publish` method to internally queue the messages, and when ready perform a `SendAsync` to send the one or more published events in an atomic operation. The `IEventPublisher` is responsible for __orchestrating__ the `EventDataFormatter`, `IEventSerializer` and `IEventSender`. The [`EventPublisher`](./EventPublisher.cs) provides the default implementation.

To enable the likes of unit testing, the [`InMemoryPublisher`](./InMemoryPublisher.cs) provides an in-memory implementation that enables the logically sent events to be inspected to verify content, etc.

The [`NullEventPublisher`](./NullEventPublisher.cs) represents an event publisher whereby the events are simply swallowed/discarded on send.

The [`LoggerEventPublisher`](./LoggerEventPublisher.cs) represents an event publisher whereby the events are logged (`ILogger.LogInformation`) on send. - -
- -### ServiceBusSender - -The [`ServiceBusSender`](../../CoreEx.Azure/ServiceBus/ServiceBusSender.cs) is an [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview) `IEventSender` implementation; this is designed to _batch_ publish one or more messages to one or more queues/topics. See the corresponding [documentation](../../CoreEx.Azure/ServiceBus/README.md) for more information. - -
- -### EventOutboxEnqueueBase - -The [`EventOutboxEnqueueBase`](../../CoreEx.Database.SqlServer/Outbox/EventOutboxEnqueueBase.cs) provides a Microsoft SQL Server `IEventSender` to support the [transactional outbox pattern](https://microservices.io/patterns/data/transactional-outbox.html); i.e. persists the events within the database within a [transactional](https://learn.microsoft.com/en-us/dotnet/api/system.transactions.transactionscope) context. - -[_DbEx_](https://github.com/Avanade/DbEx/blob/main/docs/SqlServerEventOutbox.md) provides the capabilities to generate the required Microsoft SQL Server and C# artefacts to support. The [`MyEf.Hr`](https://github.com/Avanade/Beef/tree/master/samples/MyEf.Hr) sample within [_Beef_](https://github.com/Avanade/Beef) demonstrates end-to-end usage. - -
- -## Subscribing - -Event subscribing is more tightly coupled, in that the implementation is going to be more aligned to the capabilities of the underlying messaging platform. However, the intent is to still decouple the messaging platform from the underlying processing by deserializing the message back into the originating (formatted) [`EventData`](./EventData.cs) or [`EventData`](./EventDataT.cs) where required using the previously discussed [`IEventSerializer`](./IEventSerializer.cs). This has the added advantage that the underlying messaging platform can evolve over time within minimal change, whilst the underlying processing logic can remain largely constant. - -The [`EventSubscriberBase`](./EventSubscriberBase.cs) provides the messaging platform host agnostic base functionality that should be inherited. This provides the base [`IErrorHandling`](./Subscribing/IErrorHandling.cs) configuration, being the corresponding [`ErrorHandling`](./Subscribing/ErrorHandling.cs) action per error type. - -The `EventSubscriberBase.DeserializeEventAsync` methods manage the deserialization of the originating message using an [`IEventDataConverter`](./IEventDataConverter.cs) that encapsulates the `IEventSerializer` functionality (including handling exceptions) to perform the message conversion into the corresponding [`EventData`](./EventDataT.cs) or [`EventData`](./EventDataT.cs). - -The [`EventSubscriberInvoker`](./Subscribing/EventSubscriberInvoker.cs) via the `EventSubscriberBase.EventSubscriberInvoker` property **must** be used to invoke the underlying processing as this includes the [`IErrorHandling`](./Subscribing/IErrorHandling.cs) logic; converting any errors into an [`EventSubscriberException`](./EventSubscriberException.cs). The `EventSubscriberException.IsTransient` property allows for the inheriting host to _retry_ where applicable and/or supported (versus possible [dead letter](https://en.wikipedia.org/wiki/Dead_letter_queue) where supported). - -
- -### ServiceBusSubscriber - -The [`ServiceBusSubscriber`](../../CoreEx.Azure/ServiceBus/ServiceBusSubscriber.cs) is an [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview) implementation; this is designed to process messages from a queue/topic that has a singlular [`EventData`](./EventData.cs) or [`EventData`](./EventDataT.cs) type. See the corresponding [documentation](../../CoreEx.Azure/ServiceBus/README.md) for more information. - -
- -## Orchestrated subscribing - -Within an [event-driven architecture](https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/event-driven) multiple events (message types) may be published/produced to the same underlying messaging platform. Therefore, there may be the need to subscribe/consume to one or more events (message types) in the published order (sequence). - -To enable the [`EventSubscriberOrchestrator`](./subscribing/EventSubscriberOrchestrator.cs) enables none or more subscribers ([`IEventSubscriber`](./subscribing/IEventSubscriber.cs)) to be added (`EventSubscriberOrchestrator.AddSubscribers`). To simplify the implementation of an `IEventSubscriber` the [`SubscriberBase`](./subscribing/SubscriberBase.cs) and [`SubscriberBase`](./subscribing/SubscriberBaseT.cs) enable. These also include [`IErrorHandling`](./Subscribing/IErrorHandling.cs) configuration, being the corresponding [`ErrorHandling`](./Subscribing/ErrorHandling.cs) action per error type to enable _subscriber_-specific handling where applicable. - -The `IEventSubscriber` implementation provides the corresponding `ReceiveAsync` method that must be overridden to implement the specific processing functionality. Additionally, the [`SubscriberBase`](./subscribing/SubscriberBaseT.cs) supports the specification of an [`IValidator`](../Validation/IValidatorT.cs) to pre-validate the `EventData.Value` before invoking the `ReceiveAsync`. - -One or more [`EventSubscriberAttribute`](./subscribing/EventSubscriberAttribute.cs) must be specified for the `IEventSubscriber` to configure the subscription matching criteria (includes wildcard support). The `EventSubscriberOrchestrator` for each event invocation will iterate through the subscribers (`IEventSubscriber`) and use the `EventSubscriberAttribute` to match; where there is a single match that matched `IEventSubscriber` will be invoked. - -The underlying `IEventSubscriber` must also be registered as services (see `IServiceCollection.AddEventSubscribers`) so that they can be instantiated using the underlying `IServivceProvider` from the host (enables dependency injection). - -The following demonstrates an `IEventSubscriber` implementation. - -``` csharp -[EventSubscriber("my.hr.employee", "created", "updated")] -public class EmployeeeSubscriber : SubscriberBase -{ - public override Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) - { - // Perform requisite business logic. - return Task.FromResult(Result.Success); - } -``` - -
- -### ServiceBusOrchestratedSubscriber - -The [`ServiceBusOrchestratedSubscriber`](../../CoreEx.Azure/ServiceBus/ServiceBusOrchestratedSubscriber.cs) is an [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview) implementation that inherits from ``EventSubscriberBase`` and supports `EventSubscriberOrchestrator` functionality. See the corresponding [documentation](../../CoreEx.Azure/ServiceBus/README.md) for more information. - -The [`MyEf.Hr`](https://github.com/Avanade/Beef/tree/master/samples/MyEf.Hr) sample within [_Beef_](https://github.com/Avanade/Beef) demonstrates end-to-end usage. - -
- -## Advanced - -The following provides further advanced capabilities. - -
- -### Claim-check pattern - -The [claim-check pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/claim-check) is a messaging pattern that enables the payload to be stored externally to the message. This is useful where the payload is large and/or the message is to be published to multiple subscribers. - -The [`IAttachmentStorage`](./Attachments/IAttachmentStorage.cs) enables a pluggable approach to support the storage of the payload attachment (represented by an [`EventAttachment`](./Attachments/EventAttachment.cs)); enabling flexibility with respect to persistence; e.g. Azure, AWS, on-premises, etc. Azure blob storage support is provided by the [`BlobStorageAttachmentStorage`](../../CoreEx.Azure/Storage/BlobAttachmentStorage.cs) and [`BlobSasStorageAttachmentStorage`](../../CoreEx.Azure/Storage/BlobSasAttachmentStorage.cs) implementations. - -To further simplify the implementation, and to separate this capability from the underlying messaging sub-system, the [`IEventSerializer.AttachmentStorage`](./IEventSerializer.cs) property enables the optional specification. Where this value is non-null and the serialized `EventData.Value` length is greater than or equal to the `IAttachmentStorage.MaxDataSize`, then this will be stored as an attachment and the `EventData.Value` will be set to the corresponding `EventAttachment` value. The [`IEventSender`](./IEventSender.cs) is _not_ attachment aware, the [`EventSendData.Data`](./EventSendData.cs) will simply contain the appropriate serialized `BinaryData` (original content or `EventAttachment`) essentially decoupling serialization from sending. - -The following demonstrates an example `EventAttachment` serialization where the attachment reference (e.g. filename) is set to the underlying unique `EventData.Id` value. - -``` json -{ - "contentType": "application/json", - "attachment": "550e8400-e29b-41d4-a716-446655440000.json" -} -``` - -The following demonstrates the dependency injection registration to configure the `AttachmentStorage` property for a given `IEventSerializer`. - -``` csharp -services.AddCloudEventSerializer((sp, ces) => ces.AttachmentStorage = new XxxAttachmentStorage { MaxDataSize = 1000000 }); -``` - -_Note:_ the attachments are not automatically deleted; this is the responsibility of the consuming developer/application. \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/ErrorHandler.cs b/src/CoreEx/Events/Subscribing/ErrorHandler.cs deleted file mode 100644 index 4e296971..00000000 --- a/src/CoreEx/Events/Subscribing/ErrorHandler.cs +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using Microsoft.Extensions.Logging; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Provides the standardized error handling to ensure/enable consistency of behaviour. - /// - /// The only reason this class is not sealed is to allow the to be overridden to enable specific functionality to the environment where required. - public class ErrorHandler - { - private const string LogFormat = "{Message} [Source: {Source}, Handling: {Handling}]"; - - /// - /// Determines the based on the and . - /// - /// The configuration. - /// The . - /// The . - public static ErrorHandling DetermineErrorHandling(IErrorHandling errorHandling, Exception exception) - => (exception.ThrowIfNull(nameof(exception))) is IExtendedException eex ? DetermineErrorHandling(errorHandling, eex) : errorHandling.UnhandledHandling; - - /// - /// Determines the based on the and . - /// - /// The configuration. - /// The . - /// The . - public static ErrorHandling DetermineErrorHandling(IErrorHandling errorHandling, IExtendedException extendedException) => (extendedException.ThrowIfNull(nameof(extendedException))).ErrorCode switch - { - (int)ErrorType.AuthenticationError or (int)ErrorType.AuthorizationError => errorHandling.SecurityHandling, - (int)ErrorType.BusinessError or (int)ErrorType.ConflictError or (int)ErrorType.DuplicateError or (int)ErrorType.ValidationError => errorHandling.InvalidDataHandling, - (int)ErrorType.ConcurrencyError => errorHandling.ConcurrencyHandling, - (int)ErrorType.DataConsistencyError => errorHandling.DataConsistencyHandling, - (int)ErrorType.NotFoundError => errorHandling.NotFoundHandling, - (int)ErrorType.TransientError => errorHandling.TransientHandling, - _ => errorHandling.UnhandledHandling - }; - - /// - /// Handles (actions) the error as defined by the . - /// - /// The . - /// The . - /// Where the is not thrown from within or the existing exception is bubbled, then subsequent processing should be assumed to complete gracefully without continuing. - /// An value of will result in a throw as it has already been converted into a ; as such, should generally be handled prior to invocation. - public async Task HandleErrorAsync(ErrorHandlerArgs args, CancellationToken cancellationToken) - { - // Set the configured error handling for the exception. - if (args.ErrorHandling != ErrorHandling.HandleByHost) - args.Exception.ErrorHandling = args.ErrorHandling; - - // Where the exception is known then exception and stack trace need not be logged. - var ex = args.Exception.HasInnerExtendedException ? null : args.Exception; - - // Handle based on error handling configuration. - switch (args.ErrorHandling) - { - case ErrorHandling.HandleByHost: - case ErrorHandling.HandleBySubscriber: - args.Exception.IsTransient = false; - if (args.WorkOrchestrator is not null) - await args.WorkOrchestrator.IndeterminateAsync(args.Identifier!, args.Exception.Message, cancellationToken); - - args.Instrumentation?.Instrument(args.ErrorHandling, args.Exception); - throw args.Exception; - - case ErrorHandling.Retry: - args.Exception.IsTransient = true; - if (args.WorkOrchestrator is not null) - await args.WorkOrchestrator.IndeterminateAsync(args.Identifier!, args.Exception.Message, cancellationToken); - - args.Instrumentation?.Instrument(args.ErrorHandling, args.Exception); - throw args.Exception; - - case ErrorHandling.CompleteAsSilent: - if (args.WorkOrchestrator is not null) - await args.WorkOrchestrator.FailAsync(args.Identifier!, args.Exception.Message, cancellationToken); - - args.Instrumentation?.Instrument(args.ErrorHandling, args.Exception); - args.Logger.LogDebug(ex, LogFormat, args.Exception.Message, args.Exception.ExceptionSource, args.ErrorHandling.ToString()); - break; - - case ErrorHandling.CompleteWithInformation: - if (args.WorkOrchestrator is not null) - await args.WorkOrchestrator.FailAsync(args.Identifier!, args.Exception.Message, cancellationToken); - - args.Instrumentation?.Instrument(args.ErrorHandling, args.Exception); - args.Logger.LogInformation(ex, LogFormat, args.Exception.Message, args.Exception.ExceptionSource, args.ErrorHandling.ToString()); - break; - - case ErrorHandling.CompleteWithWarning: - if (args.WorkOrchestrator is not null) - await args.WorkOrchestrator.FailAsync(args.Identifier!, args.Exception.Message, cancellationToken); - - args.Instrumentation?.Instrument(args.ErrorHandling, args.Exception); - args.Logger.LogWarning(ex, LogFormat, args.Exception.Message, args.Exception.ExceptionSource, args.ErrorHandling.ToString()); - break; - - case ErrorHandling.CompleteWithError: - if (args.WorkOrchestrator is not null) - await args.WorkOrchestrator.FailAsync(args.Identifier!, args.Exception.Message, cancellationToken); - - args.Instrumentation?.Instrument(args.ErrorHandling, args.Exception); - args.Logger.LogError(ex, LogFormat, args.Exception.Message, args.Exception.ExceptionSource, args.ErrorHandling.ToString()); - break; - - case ErrorHandling.CriticalFailFast: - args.Exception.IsTransient = false; - if (args.WorkOrchestrator is not null) - await args.WorkOrchestrator.FailAsync(args.Identifier!, args.Exception.Message, cancellationToken); - - args.Instrumentation?.Instrument(args.ErrorHandling, args.Exception); - args.Logger.LogCritical(ex, LogFormat, args.Exception.Message, args.Exception.ExceptionSource, args.ErrorHandling.ToString()); - FailFast(args.Exception); - throw args.Exception; // A backup in case FailFast does not function as expected; should _not_ get here! - } - } - - /// - /// Handles the . - /// - /// The . - /// By default invokes . This method should be overridden where a different behaviour is required. - protected virtual void FailFast(EventSubscriberException eventSubscriberException) => Environment.FailFast(eventSubscriberException.Message, eventSubscriberException); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/ErrorHandlerArgs.cs b/src/CoreEx/Events/Subscribing/ErrorHandlerArgs.cs deleted file mode 100644 index 358787e7..00000000 --- a/src/CoreEx/Events/Subscribing/ErrorHandlerArgs.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Hosting.Work; -using Microsoft.Extensions.Logging; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Provides the arguments. - /// - /// The corresponding unique identifier. - /// The . - /// The option. - /// The . - public sealed class ErrorHandlerArgs(string? identifier, EventSubscriberException eventSubscriberException, ErrorHandling errorHandling, ILogger logger) - { - /// - /// Gets the corresponding unique identifier. - /// - public string? Identifier { get; } = identifier; - - /// - /// Gets the . - /// - public EventSubscriberException Exception { get; } = eventSubscriberException.ThrowIfNull(nameof(eventSubscriberException)); - - /// - /// Gets the option. - /// - public ErrorHandling ErrorHandling { get; } = errorHandling; - - /// - /// Gets the . - /// - public ILogger Logger { get; } = logger.ThrowIfNull(nameof(logger)); - - /// - /// Gets or sets the optional . - /// - public IEventSubscriberInstrumentation? Instrumentation { get; set; } - - /// - /// Gets or sets the optional . - /// - public WorkStateOrchestrator? WorkOrchestrator { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/ErrorHandling.cs b/src/CoreEx/Events/Subscribing/ErrorHandling.cs deleted file mode 100644 index f74c9d00..00000000 --- a/src/CoreEx/Events/Subscribing/ErrorHandling.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Logging; -using System; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Provides the result options. - /// - public enum ErrorHandling - { - /// - /// Indicates that when the corresponding error occurs the underlying will continue to bubble up the stack as unhandled to the executing host. - /// - /// The host is the process that initiated the underlying . - HandleByHost, - - /// - /// Indicates that when the corresponding error occurs that it should be handled by the subscriber. - /// - /// Depending on the underlying messaging subsystem this may result in the likes of the message being deadlettered (or equivalent) where supported. May result in (bubble up the stack as unhandled to the executing host) - /// where subscriber is unable to handle appropriately. - /// Where the underlying implements and the property is set to true any configured retries - /// will be performed until exhausted; then will bubble up the stack as unhandled to the executing host. - HandleBySubscriber, - - /// - /// Indicates that when the corresponding error occurs that it may be transient and should be retried (where possible). - /// - /// Results in a where the property is set (overridden) to true. - /// A retry will only occur where the implementation supports/enables. - Retry, - - /// - /// Indicates that when the corresponding error occurs this is expected and the current event/message should be completed without further processing and logging (i.e. silently). - /// - /// A will be logged where applicable to support debugging. - CompleteAsSilent, - - /// - /// Indicates that when the corresponding error occurs this is expected and should be completed without further processing after logging as . - /// - CompleteWithInformation, - - /// - /// Indicates that when the corresponding error occurs this is expected and should be completed without further processing after logging as . - /// - CompleteWithWarning, - - /// - /// Indicates that when the corresponding error occurs this is expected and should be completed without further processing after logging as . - /// - CompleteWithError, - - /// - /// Indicates that when the corresponding error occurs the is invoked to immediately terminate the underlying process. - /// - /// Note: this must be tested thoroughly by the developer to ensure that there are no negative side-effects of the process terminating; equally, the - /// may need to be overridden to achieve the desired outcome. Before termination the error will be logged as . - CriticalFailFast - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/EventSubscriberArgs.cs b/src/CoreEx/Events/Subscribing/EventSubscriberArgs.cs deleted file mode 100644 index c8432fa1..00000000 --- a/src/CoreEx/Events/Subscribing/EventSubscriberArgs.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Hosting.Work; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Provides the arguments; is ostensibly a with a key and value. - /// - /// This enables runtime state to passed through to the underlying subscriber receive logic where applicable. - public class EventSubscriberArgs : Dictionary - { - private EventSubscriberBase? _owner; - private string? _id; - private WorkState? _workState; - - /// - /// Sets the initial subscriber state. - /// - /// The owning . - /// The event/messagage identifier. - /// The corresponding (where applicable). - internal void SetState(EventSubscriberBase owner, string id, WorkState? workState) - { - if (_owner is not null) - throw new InvalidOperationException($"An existing {nameof(EventSubscriberArgs)} instance may not be reused across subscriber executions; a new instance is required per event/message."); - - _owner = owner.ThrowIfNull(nameof(owner)); - _id = id.ThrowIfNullOrEmpty(nameof(id)); - _workState = workState; - } - - /// - /// Gets the owning . - /// - /// This is set automatically by the . - public EventSubscriberBase Owner => _owner ?? throw new InvalidOperationException($"The {nameof(Owner)} property has not yet been configured by the {nameof(EventSubscriberBase)} infrastructure."); - - /// - /// Gets the event/messagage identifier. - /// - public string Id => _id ?? throw new InvalidOperationException($"The {nameof(Id)} property has not yet been configured by the {nameof(EventSubscriberBase)} infrastructure."); - - /// - /// Gets the (where applicable). - /// - public WorkState? WorkState => _workState ?? throw new InvalidOperationException($"The {nameof(WorkState)} property has not yet been configured by the {nameof(EventSubscriberBase)} infrastructure."); - - /// - /// Indicates whether the subscribing event/message has corresponding . - /// - public bool HasWorkState => _workState != null; - - /// - /// Sets the corresponding result data with the specified (where ). - /// - /// The . - /// The . - public Task SetWorkStateDataAsync(BinaryData data, CancellationToken cancellationToken = default) - { - if (!HasWorkState) - throw new InvalidOperationException($"This event/message is not being {nameof(WorkState)} tracked/orchestrated therefore tracking data is unable to be set."); - - return Owner.WorkStateOrchestrator!.SetDataAsync(Id, data, cancellationToken); - } - - /// - /// Sets the corresponding result data with the specified serialized as JSON (where ). - /// - /// The value to JSON serialize as the result data. - /// The . - public Task SetWorkStateDataAsync(TValue value, CancellationToken cancellationToken = default) - { - if (!HasWorkState) - throw new InvalidOperationException($"This event/message is not being {nameof(WorkState)} tracked/orchestrated therefore tracking data is unable to be set."); - - return Owner.WorkStateOrchestrator!.SetDataAsync(Id, value, cancellationToken); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/EventSubscriberAttribute.cs b/src/CoreEx/Events/Subscribing/EventSubscriberAttribute.cs deleted file mode 100644 index ce6aae7b..00000000 --- a/src/CoreEx/Events/Subscribing/EventSubscriberAttribute.cs +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Wildcards; -using System; -using System.Linq; -using System.Reflection; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Defines the matching criteria. - /// - /// The matching supports wildcards where specifically allowed; this is performed by (see for supported capabilities). - [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] - public sealed class EventSubscriberAttribute : Attribute - { - /// - /// Initializes a new instance of the class. - /// - /// The template path (may contain wildcards). - /// The templates where at least one must match where specified (may contain wildcards). - public EventSubscriberAttribute(string? subject = null, params string[]? actions) - { - Subject = subject; - Actions = actions; - } - - /// - /// Initializes a new instance of the class. - /// - /// The template (may contain wildcards) - /// The template path (may contain wildcards). - public EventSubscriberAttribute(Uri source, string? type = null) - { - Source = source; - Type = type; - } - - /// - /// Gets or sets the template path (may contain wildcards). - /// - public string? Subject { get; set; } - - /// - /// Gets or sets the template(s) where at least one must match where specified (may contain wildcards). - /// - public string[]? Actions { get; set; } - - /// - /// Gets or sets the template ( may contain wildcards). - /// - public Uri? Source { get; set; } - - /// - /// Gets or sets the template path (may contain wildcards). - /// - public string? Type { get; set; } - - /// - /// Indicates whether the matching should ignore case (default) or not. - /// - public bool IgnoreCase { get; set; } = true; - - /// - /// Gets or sets the extended match method name that is to be invoked on the to perform any extended match for the declared . - /// - /// The extended match method signature must be: public static bool ExtendedMatchName(EventData, EventSubscriberArgs). The current will be passed as the parameter. The method must return a bool where true - /// indicates a match; otherwise, false. - /// The static method will only be invoked where the , and have resulted in a match; i.e. this allows additional match filtering. - public string? ExtendedMatchMethod { get; set; } - - /// - /// Gets or sets the . - /// - internal MethodInfo? ExtendedMatchMethodInfo { get; set; } - - /// - /// Indicates whether the actual event metadata matches the subscribing criteria. - /// - /// The . - /// The actual to match. - /// true indicates a match; otherwise, false. - /// Where the actual event metadata values are all null then it will immediately fail the match. - public bool IsMatch(EventDataFormatter formatter, EventData @event) => IsMatch(formatter, @event.Subject, @event.Type, @event.Action, @event.Source); - - /// - /// Matches on the key subject, type and action properties. - /// - private bool IsMatch(EventDataFormatter formatter, string? subject, string? type, string? action, Uri? source) - { - if (string.IsNullOrEmpty(subject) && string.IsNullOrEmpty(type) && string.IsNullOrEmpty(action) && source is null) - return false; - - if (subject != null && !IsMatch(Subject, subject, formatter.SubjectSeparatorCharacter)) - return false; - - if (type != null && !IsMatch(Type, type, formatter.TypeSeparatorCharacter)) - return false; - - if (source != null && !IsMatchSource(Source, source)) - return false; - - if (Actions == null || Actions.Length == 0) - return true; - - if (action == null) - return false; - - foreach (var at in Actions) - { - if (string.IsNullOrEmpty(at) || at == action || at == "*") - return true; - - if (Wildcard.Default.Parse(at).CreateRegex(IgnoreCase).IsMatch(action)) - return true; - } - - return false; - } - - /// - /// Splits template and action into parts using seperator, then compares part by part using wildcards to determine match. - /// - /// The template. - /// The actual value. - /// The seperator character. - /// Wildcards are matached using the functionality; supports the standard wildcards characters being '*' () and '?' (). - /// To support matching the contents of all children paths (regardless of depth) the double wilcard '**' must be used; for example 'root/**' allows 'root/abc' and 'root/abc/def', etc. - public bool IsMatch(string? template, string? actual, char seperator = '/') - { - if (string.IsNullOrEmpty(template) || template == "*" || template == actual) - return true; - - if (string.IsNullOrEmpty(actual)) - return false; - - var tparts = template.Split(seperator); - var aparts = actual.Split(seperator); - - if (tparts.Length > aparts.Length) - return false; - - for (int i = 0; i < tparts.Length; i++) - { - if (i > aparts.Length) - return false; - - if (!Wildcard.Default.Parse(tparts[i]).CreateRegex(IgnoreCase).IsMatch(aparts[i])) - return false; - } - - if (aparts.Length > tparts.Length && tparts.Last() != "**") - return false; - - return true; - } - - /// - /// Matches the source URI. - /// - private bool IsMatchSource(Uri? template, Uri actual) - { - if (template == null || (!template.IsAbsoluteUri && template.OriginalString == "*")) - return true; - - if (!template.IsAbsoluteUri) - return IsMatch(template.OriginalString, RemoveLeadingSeperator(actual.IsAbsoluteUri ? actual.AbsolutePath : actual.OriginalString, '/'), '/'); - - if (!actual.IsAbsoluteUri) - return false; - - if (template.Host != actual.Host || template.HostNameType != actual.HostNameType || template.Port != actual.Port || template.Scheme != actual.Scheme) - return false; - - return IsMatch(RemoveLeadingSeperator(template.AbsolutePath, '/'), RemoveLeadingSeperator(actual.AbsolutePath, '/'), '/'); - } - - /// - /// Removes the leading seperator. - /// - private static string RemoveLeadingSeperator(string text, char seperator) => text.StartsWith(seperator) ? text[1..] : text; - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/EventSubscriberExceptionSource.cs b/src/CoreEx/Events/Subscribing/EventSubscriberExceptionSource.cs deleted file mode 100644 index 01823642..00000000 --- a/src/CoreEx/Events/Subscribing/EventSubscriberExceptionSource.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Hosting.Work; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Provides additional context related to the source of the . - /// - public enum EventSubscriberExceptionSource - { - /// - /// Indicates that the relates to the execution of the underlying subscriber receiving as implemented by the consumer (see also ). - /// - Subscriber = 2201, - - /// - /// Indicates that the relates to the . - /// - EventDataDeserialization = 2202, - - /// - /// Indicates that the relates to the . - /// - OrchestratorNotSubscribed = 2203, - - /// - /// Indicates that the relates to the . - /// - OrchestratorAmbiquousSubscriber = 2204, - - /// - /// Indicates that the relates to the having a of . - /// - WorkStateAlreadyFinished = 2205 - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/EventSubscriberInstrumentationBase.cs b/src/CoreEx/Events/Subscribing/EventSubscriberInstrumentationBase.cs deleted file mode 100644 index b0ff214c..00000000 --- a/src/CoreEx/Events/Subscribing/EventSubscriberInstrumentationBase.cs +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using System; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Provides the base capabilities; specifically the method. - /// - public abstract class EventSubscriberInstrumentationBase : IEventSubscriberInstrumentation - { - private enum SubscriberResult - { - Complete, - Retry, - Error, - Critical - } - - /// - /// Gets or sets the instrumentation name format. - /// - /// The '{0}' represents the supplied prefix, the '{1}' represents the result ('Complete', 'Retry', 'Error', 'Critical'), and the '{2}' represents the suffix ('Success' or corresponding error suffix). - /// The following represent possible outcomes: 'Prefix.Complete.Success', 'Prefix.Complete.NotSubscribed', 'Prefix.Retry.AuthenticationError', 'Prefix.Error.NotFoundError', etc. - public string InstrumentationNameFormat { get; set; } = "{0}.{1}.{2}"; - - /// - /// Gets or sets the instrumentation result where considered complete. - /// - public string CompleteResultText { get; set; } = nameof(SubscriberResult.Complete); - - /// - /// Gets or sets the instrumentation result where considered an error. - /// - public string ErrorResultText { get; set; } = nameof(SubscriberResult.Error); - - /// - /// Gets or sets the instrumentation result where a . - /// - public string RetryResultText { get; set; } = nameof(SubscriberResult.Retry); - - /// - /// Gets or sets the instrumentation result where considered critical (see ). - /// - public string CriticalResultText { get; set; } = nameof(SubscriberResult.Critical); - - /// - /// Gets or sets the instrumentation suffix for a successful completion. - /// - public string SuccessSuffix { get; set; } = "Success"; - - /// - /// Gets or sets the instrumentation suffix for an see or . - /// - public string UnhandledErrorSuffix { get; set; } = nameof(ErrorType.UnhandledError); - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string EventDataErrorSuffix { get; set; } = $"{nameof(EventSubscriberExceptionSource.EventDataDeserialization)}Error"; - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string AmbiquousSubscriberErrorSuffix { get; set; } = $"{nameof(EventSubscriberExceptionSource.OrchestratorAmbiquousSubscriber)}Error"; - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string NotSubscribedSuffix { get; set; } = "NotSubscribed"; - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string ValidationErrorSuffix { get; set; } = nameof(ErrorType.ValidationError); - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string BusinessErrorSuffix { get; set; } = nameof(ErrorType.BusinessError); - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string AuthorizationErrorSuffix { get; set; } = nameof(ErrorType.AuthorizationError); - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string ConcurrencyErrorSuffix { get; set; } = nameof(ErrorType.ConcurrencyError); - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string NotFoundErrorSuffix { get; set; } = nameof(ErrorType.NotFoundError); - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string ConflictErrorSuffix { get; set; } = nameof(ErrorType.ConflictError); - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string DuplicateErrorSuffix { get; set; } = nameof(ErrorType.DuplicateError); - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string AuthenticationErrorSuffix { get; set; } = nameof(ErrorType.AuthenticationError); - - /// - /// Gets or sets the instrumentation suffix for a see . - /// - public string DataConsistencyErrorSuffix { get; set; } = nameof(ErrorType.DataConsistencyError); - - /// - /// Gets or sets the instrumentation suffix for a . - /// - public string TransientErrorSuffix { get; set; } = nameof(ErrorType.TransientError); - - /// - /// Indicates whether to always set the suffix to where the result is a . - /// - /// When set this will ignore all the other potential error suffixes where the overall result is considered as success. - public bool AlwaysSuffixSuccessOnResultOfComplete { get; set; } = false; - - /// - public abstract void Instrument(ErrorHandling? errorHandling = null, Exception? exception = null); - - /// - /// Gets the instrumentation name based on the configured , , and values. - /// - /// The instrumentation prefix. - /// The where applicable; otherwise, null which indicates success. - /// The where applicable. - /// The corresponding instrumentation name. - /// See also . - protected string GetInstrumentName(string prefix, ErrorHandling? errorHandling, Exception? exception) - { - var (result, resultText) = errorHandling switch - { - null or ErrorHandling.CompleteAsSilent or ErrorHandling.CompleteWithInformation or ErrorHandling.CompleteWithWarning or ErrorHandling.CompleteWithError => (SubscriberResult.Complete, CompleteResultText), - ErrorHandling.Retry => (SubscriberResult.Retry, RetryResultText), - ErrorHandling.CriticalFailFast => (SubscriberResult.Critical, CriticalResultText), - _ => (SubscriberResult.Error, ErrorResultText) - }; - - var suffix = errorHandling switch - { - null => SuccessSuffix, - _ => result == SubscriberResult.Complete && AlwaysSuffixSuccessOnResultOfComplete ? SuccessSuffix : GetExceptionSuffix(exception) - }; - - return string.Format(InstrumentationNameFormat, prefix.ThrowIfNullOrEmpty(nameof(prefix)), resultText, suffix); - } - - /// - /// Gets the suffix from the exception. - /// - private string GetExceptionSuffix(Exception? exception) => (exception is not null && exception is EventSubscriberException esex) ? esex.ExceptionSource switch - { - EventSubscriberExceptionSource.OrchestratorNotSubscribed => NotSubscribedSuffix, - EventSubscriberExceptionSource.EventDataDeserialization => EventDataErrorSuffix, - EventSubscriberExceptionSource.OrchestratorAmbiquousSubscriber => AmbiquousSubscriberErrorSuffix, - _ => esex.ErrorCode switch - { - (int)ErrorType.ValidationError => ValidationErrorSuffix, - (int)ErrorType.BusinessError => BusinessErrorSuffix, - (int)ErrorType.AuthorizationError => AuthorizationErrorSuffix, - (int)ErrorType.ConcurrencyError => ConcurrencyErrorSuffix, - (int)ErrorType.NotFoundError => NotFoundErrorSuffix, - (int)ErrorType.ConflictError => ConflictErrorSuffix, - (int)ErrorType.DuplicateError => DuplicateErrorSuffix, - (int)ErrorType.AuthenticationError => AuthenticationErrorSuffix, - (int)ErrorType.TransientError => TransientErrorSuffix, - (int)ErrorType.DataConsistencyError => DataConsistencyErrorSuffix, - _ => UnhandledErrorSuffix - } - } : UnhandledErrorSuffix; - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/EventSubscriberInvoker.cs b/src/CoreEx/Events/Subscribing/EventSubscriberInvoker.cs deleted file mode 100644 index d77c7d03..00000000 --- a/src/CoreEx/Events/Subscribing/EventSubscriberInvoker.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Provides the standard invoker functionality. - /// - public class EventSubscriberInvoker : InvokerBase { } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/EventSubscriberOrchestrator.cs b/src/CoreEx/Events/Subscribing/EventSubscriberOrchestrator.cs deleted file mode 100644 index 50e57415..00000000 --- a/src/CoreEx/Events/Subscribing/EventSubscriberOrchestrator.cs +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using System.Threading; -using Microsoft.Extensions.DependencyInjection; -using CoreEx.Abstractions; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Represents an orchestrator that leverages -metadata to match a subscribed based on the corresponding - /// (s) defined enabling the processing of multiple subscribers. - /// - /// Additionally, (and alike) can be defined to further manage the processing of events/messages, both expected and unexpected. - /// The optional (where not specified will attempt to use , etc). - public class EventSubscriberOrchestrator(IServiceProvider? serviceProvider = null) - { - private readonly List<(IEnumerable Attributes, Type SubscriberType, Type? ValueType)> _subscribers = []; - - /// - /// Gets all the types for a given that have at least one . - /// - /// The to infer the underlying . - /// Indicates whether to include internally defined types. - /// The types. - public static Type[] GetSubscribers(bool includeInternalTypes = false) - => (from type in includeInternalTypes ? typeof(TAssembly).Assembly.GetTypes() : typeof(TAssembly).Assembly.GetExportedTypes() - where !type.IsAbstract && !type.IsGenericTypeDefinition - let inherits = typeof(SubscriberBase).IsAssignableFrom(type) - let attributes = type.GetCustomAttributes(true) - where inherits && attributes.Any() - select type).ToArray(); - - /// - /// Gets the . - /// - protected IServiceProvider? ServiceProvider { get; } = serviceProvider; - - /// - /// Gets or sets the optional . - /// - /// Where not specified an instance will be requested from the ; otherwise, a default instance will be instantiated. - public EventDataFormatter? EventDataFormatter { get; set; } - - /// - /// Gets or sets the where an event is encountered that has not been subscribed to. Defaults to . - /// - public ErrorHandling NotSubscribedHandling { get; set; } = ErrorHandling.HandleBySubscriber; - - /// - /// Gets or sets the where an event is encountered that has more than one subscriber (is ambiguous). Defaults to . - /// - public ErrorHandling AmbiquousSubscriberHandling { get; set; } = ErrorHandling.CriticalFailFast; - - /// - /// Use (set) the where an event is encountered that has not been subscribed to. - /// - /// The . - /// The to support fluent-style method-chaining. - public EventSubscriberOrchestrator UseNotSubscribedHandling(ErrorHandling notSubscribedHandling) - { - NotSubscribedHandling = notSubscribedHandling; - return this; - } - - /// - /// Use (set) the where an event is encountered that has more than one subscriber (is ambiguous). - /// - /// The . - /// The to support fluent-style method-chaining. - public EventSubscriberOrchestrator UseAmbiquousSubscriberHandling(ErrorHandling ambiquousSubscriberHandling) - { - AmbiquousSubscriberHandling = ambiquousSubscriberHandling; - return this; - } - - /// - /// Adds the . - /// - /// The . - /// The to support fluent-style method-chaining. - public EventSubscriberOrchestrator AddSubscriber() where TSubscriber : SubscriberBase => AddSubscribers(typeof(TSubscriber)); - - /// - /// Adds the subscribers; being where the is and has at least one applied. - /// - /// The types to add. - /// The to support fluent-style method-chaining. - public EventSubscriberOrchestrator AddSubscribers(params Type[] types) - { - foreach (var type in types.Distinct()) - { - if (_subscribers.Any(x => x.SubscriberType == type)) - continue; - - if (!TryGetEventDataValueType(type, out var valueType)) - throw new ArgumentException($"Type '{type.FullName}' must inherit from {typeof(SubscriberBase).Name} or {typeof(SubscriberBase<>).Name}.", nameof(types)); - - var atts = type.GetCustomAttributes(true); - if (atts != null && atts.Any()) - { - foreach (var att in atts) - { - if (att.ExtendedMatchMethod is not null) - { - var mi = type.GetMethod(att.ExtendedMatchMethod, BindingFlags.Public | BindingFlags.Static); - if (mi == null || mi.ReturnParameter.ParameterType != typeof(bool) || mi.GetParameters().Length != 2 || mi.GetParameters()[0].ParameterType != typeof(EventData) || mi.GetParameters()[1].ParameterType != typeof(EventSubscriberArgs)) - throw new ArgumentException($"Type '{type.FullName}' has Attribute with {nameof(EventSubscriberAttribute.ExtendedMatchMethod)} of {att.ExtendedMatchMethod} that either does not exist or has an invalid method signature defined.", nameof(types)); - - att.ExtendedMatchMethodInfo = mi; - } - } - - _subscribers.Add((atts, type, valueType)); - } - else - throw new ArgumentException($"Type '{type.FullName}' must have at least one {nameof(EventSubscriberAttribute)} applied.", nameof(types)); - } - - return this; - } - - /// - /// Trys to determine whether inherits from or and what the is where applicable. - /// - private static bool TryGetEventDataValueType(Type subscriberType, out Type? valueType) - { - Type? t = subscriberType; - while (t != null && t != typeof(object)) - { - if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(SubscriberBase<>)) - { - valueType = t.GetGenericArguments()[0]; - return true; - } - - t = t.BaseType; - } - - valueType = null; - return typeof(SubscriberBase).IsAssignableFrom(subscriberType); - } - - /// - /// Trys to match and return a from within the registered subscribers; whilst also determining the resulting . - /// - /// The parent (owning) . - /// The actual . - /// The optional . - /// The resulting match (bool), and where found. - public (bool Matched, IEventSubscriber? Subscriber, Type? ValueType) TryMatchSubscriber(EventSubscriberBase parent, EventData @event, EventSubscriberArgs args) - { - parent.ThrowIfNull(nameof(parent)); - @event.ThrowIfNull(nameof(@event)); - - if (TryMatchSubscriberInternal(@event, args, out var subscriber, out var valueType)) - return (true, subscriber, valueType); - - return (false, subscriber, valueType); - } - - /// - /// Try and match a subscriber. - /// - private bool TryMatchSubscriberInternal(EventData @event, EventSubscriberArgs args, out IEventSubscriber? subscriber, out Type? valueType) - { - subscriber = null; - valueType = null; - var eventDataFormatter = EventDataFormatter ?? ServiceProvider?.GetService() ?? ExecutionContext.GetService() ?? new EventDataFormatter(); - - foreach (var item in _subscribers) - { - foreach (var att in item.Attributes) - { - if (att.IsMatch(eventDataFormatter, @event)) - { - if (subscriber != null) - return false; - - if (att.ExtendedMatchMethodInfo is not null && !(bool)att.ExtendedMatchMethodInfo.Invoke(null, [@event, args])!) - return false; - - subscriber = (IEventSubscriber)(ServiceProvider?.GetService(item.SubscriberType) ?? ExecutionContext.GetRequiredService(item.SubscriberType)); - valueType = item.ValueType; - } - } - } - - return subscriber != null; - } - - /// - /// Receive and process the . - /// - /// The parent (owning) . - /// The that should receive the . - /// The . - /// The optional . - /// The . - public async virtual Task ReceiveAsync(EventSubscriberBase parent, IEventSubscriber subscriber, EventData @event, EventSubscriberArgs? args = null, CancellationToken cancellationToken = default) - { - parent.ThrowIfNull(nameof(parent)); - subscriber.ThrowIfNull(nameof(subscriber)); - @event.ThrowIfNull(nameof(@event)); - - try - { - await parent.EventSubscriberInvoker.InvokeAsync(parent, async (_, ct) => - { - var result = await subscriber!.ReceiveAsync(@event, args ??= [], ct); - result.ThrowOnError(); - - // Perform the complete/success instrumentation. - if (parent.WorkStateOrchestrator is not null) - await parent.WorkStateOrchestrator.CompleteAsync(@event.Id!, ct).ConfigureAwait(false); - - parent.Instrumentation?.Instrument(); - }, cancellationToken).ConfigureAwait(false); - } - catch (EventSubscriberException) { throw; } // This is considered as handled and should not be rethrown. - catch (Exception ex) when (ex is IExtendedException eex) - { - // Handle the exception based on the subscriber configuration. - var handling = ErrorHandler.DetermineErrorHandling(subscriber, eex); - if (handling == ErrorHandling.HandleByHost) - throw; - - await parent.ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(@event.Id, new EventSubscriberException(ex.Message, ex), handling, parent.Logger) { Instrumentation = parent.Instrumentation, WorkOrchestrator = parent.WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (subscriber.UnhandledHandling != ErrorHandling.HandleByHost) // Where unhandled is none, just let the unhandled exception bubble up. - { - await parent.ErrorHandler.HandleErrorAsync(new ErrorHandlerArgs(@event.Id, new EventSubscriberException(ex.Message, ex), subscriber.UnhandledHandling, parent.Logger) { Instrumentation = parent.Instrumentation, WorkOrchestrator = parent.WorkStateOrchestrator }, cancellationToken).ConfigureAwait(false); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/IErrorHandling.cs b/src/CoreEx/Events/Subscribing/IErrorHandling.cs deleted file mode 100644 index 5a8e4e8b..00000000 --- a/src/CoreEx/Events/Subscribing/IErrorHandling.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Hosting.Work; -using System; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Provides the standard options based on the of encountered. - /// - /// The is largely responsible for the handling at runtime. - public interface IErrorHandling - { - /// - /// Gets the for when an unhandled (none of the others) is encountered. - /// - ErrorHandling UnhandledHandling { get; } - - /// - /// Gets the for when a or is encountered. - /// - ErrorHandling SecurityHandling { get; } - - /// - /// Gets the for when a is encountered. - /// - ErrorHandling TransientHandling { get; } - - /// - /// Gets the for when a is encountered. - /// - ErrorHandling NotFoundHandling { get; } - - /// - /// Gets the for when a is encountered. - /// - ErrorHandling ConcurrencyHandling { get; } - - /// - /// Gets the for when a is encountered. - /// - ErrorHandling DataConsistencyHandling { get; } - - /// - /// Gets the for when a , , or is encountered. - /// - ErrorHandling InvalidDataHandling { get; } - - /// - /// Gets the for when the corresponding has a already of . - /// - /// A null indicates that the should not be verified before processing and work should occur regardless. - ErrorHandling? WorkStateAlreadyFinishedHandling { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/IEventSubscriber.cs b/src/CoreEx/Events/Subscribing/IEventSubscriber.cs deleted file mode 100644 index b3999564..00000000 --- a/src/CoreEx/Events/Subscribing/IEventSubscriber.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading.Tasks; -using System.Threading; -using CoreEx.Results; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Defines the core event subscriber capabilities. - /// - public interface IEventSubscriber : IErrorHandling - { - /// - /// Gets the or . - /// - Type EventDataType { get; } - - /// - /// Gets the if any. - /// - Type? ValueType { get; } - - /// - /// Received and process the subscribed . - /// - /// The . - /// The . - /// The . - /// The resulting . - Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/IEventSubscriberInstrumentation.cs b/src/CoreEx/Events/Subscribing/IEventSubscriberInstrumentation.cs deleted file mode 100644 index 89a99d21..00000000 --- a/src/CoreEx/Events/Subscribing/IEventSubscriberInstrumentation.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Enables instrumentation for an . - /// - /// See also . - public interface IEventSubscriberInstrumentation - { - /// - /// Records instrumentation based on the and values. - /// - /// The corresponding value where an error ocurred; otherwise, null for success. - /// The corresponding where there is an error (will be of Type where is not ). - void Instrument(ErrorHandling? errorHandling = null, Exception? exception = null); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/SubscriberBase.cs b/src/CoreEx/Events/Subscribing/SubscriberBase.cs deleted file mode 100644 index 32c0ab6b..00000000 --- a/src/CoreEx/Events/Subscribing/SubscriberBase.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading.Tasks; -using System.Threading; -using CoreEx.Results; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Represents an with no (supports ). - /// - /// This is for use when the has no corresponding ; or the does not need to be deserialized. - public abstract class SubscriberBase : IEventSubscriber - { - /// - public virtual Type EventDataType => typeof(EventData); - - /// - public virtual Type? ValueType => null; - - /// - /// Defaults to indicating that the configuration will be used; override explicitly to set specific handling behaviour where applicable. - public virtual ErrorHandling UnhandledHandling => ErrorHandling.HandleByHost; - - /// - /// Defaults to indicating that the configuration will be used; override explicitly to set specific handling behaviour where applicable. - public virtual ErrorHandling SecurityHandling => ErrorHandling.HandleByHost; - - /// - /// Defaults to indicating that the configuration will be used; override explicitly to set specific handling behaviour where applicable. - public virtual ErrorHandling TransientHandling => ErrorHandling.HandleByHost; - - /// - /// Defaults to indicating that the configuration will be used; override explicitly to set specific handling behaviour where applicable. - public virtual ErrorHandling NotFoundHandling => ErrorHandling.HandleByHost; - - /// - /// Defaults to indicating that the configuration will be used; override explicitly to set specific handling behaviour where applicable. - public virtual ErrorHandling ConcurrencyHandling => ErrorHandling.HandleByHost; - - /// - /// Defaults to indicating that the configuration will be used; override explicitly to set specific handling behaviour where applicable. - public virtual ErrorHandling DataConsistencyHandling => ErrorHandling.HandleByHost; - - /// - /// Defaults to indicating that the configuration will be used; override explicitly to set specific handling behaviour where applicable. - public virtual ErrorHandling InvalidDataHandling => ErrorHandling.HandleByHost; - - /// - /// Defaults to indicating that the configuration will be used; override explicitly to set specific handling behaviour where applicable. - public virtual ErrorHandling? WorkStateAlreadyFinishedHandling => ErrorHandling.HandleByHost; - - /// - public abstract Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx/Events/Subscribing/SubscriberBaseT.cs b/src/CoreEx/Events/Subscribing/SubscriberBaseT.cs deleted file mode 100644 index 0639a11a..00000000 --- a/src/CoreEx/Events/Subscribing/SubscriberBaseT.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Validation; -using System; -using System.Threading.Tasks; -using System.Threading; -using CoreEx.Results; - -namespace CoreEx.Events.Subscribing -{ - /// - /// Represents an with a of (supports ). - /// - /// The . - /// The optional for the value. - /// Indicates whether the is required; defaults to true. - /// This is for use when the has to be deserialized. - /// Additionally, and enable a consistent validation approach prior to the underlying being invoked. - public abstract class SubscriberBase(IValidator? valueValidator = null, bool valueIsRequired = true) : SubscriberBase - { - /// - public override Type EventDataType => typeof(EventData); - - /// - public override Type? ValueType => typeof(TValue); - - /// - /// Indicates whether the is required. - /// - protected bool ValueIsRequired { get; set; } = valueIsRequired; - - /// - /// Gets or sets the optional for the value. - /// - protected IValidator? ValueValidator { get; set; } = valueValidator; - - /// - public async sealed override Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) - { - return await Result.Go(@event.ThrowIfNull(nameof(@event))) - .When(ed => ValueIsRequired && ed.Value is null, _ => Result.ValidationError(EventSubscriberBase.RequiredErrorText)) - .ThenAs(ed => ed is EventData edvx ? edvx : new EventData(ed).Adjust(e => e.Value = (TValue)ed.Value!)) - .WhenAsync(ed => ValueValidator != null, async ed => - { - var vr = await ValueValidator!.ValidateAsync(ed.Value, cancellationToken).ConfigureAwait(false); - return vr.HasErrors ? Result>.ValidationError(vr.Messages) : Result.Ok(ed); - }) - .ThenAsAsync(ed => ReceiveAsync(ed, args, cancellationToken)); - } - - /// - /// Receive and process the subscribed typed . - /// - /// The . - /// The . - /// The . - /// Where and/or are specified then this method will only be invoked where the aforementioned validation has occured and the underlying - /// is considered valid. - public abstract Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx/ExecutionContext.Infra.cs b/src/CoreEx/ExecutionContext.Infra.cs new file mode 100644 index 00000000..ffd0dd17 --- /dev/null +++ b/src/CoreEx/ExecutionContext.Infra.cs @@ -0,0 +1,220 @@ +namespace CoreEx; + +public partial class ExecutionContext +{ + private static readonly AsyncLocal _asyncLocal = new(); + + private bool _disposed; +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + /// + /// Gets or sets the function to create a default instance. + /// + public static Func? Create { get; set; } = () => new(); + + /// + /// Indicates whether the has a value. + /// + public static bool HasCurrent => _asyncLocal.Value is not null; + + /// + /// Gets the current for the executing thread graph (see ). + /// + /// Where not previously set (see ) then the will be invoked as a backup to create an instance on first access. + /// The should be used to dispose and clear the current where no longer needed. + /// Finally, where no current instance a will be thrown. + public static ExecutionContext Current => _asyncLocal.Value ??= Create?.Invoke() ?? + throw new InvalidOperationException("There is currently no ExecutionContext.Current instance; this must be set (SetCurrent) prior to access. Use ExecutionContext.HasCurrent to verify value and avoid this exception if appropriate."); + + /// + /// Tries to get the current for the executing thread graph (see ). + /// + /// The where ; otherwise, . + /// Indicates whether to throw an when there is no current instance. + /// when ; otherwise, . + public static bool TryGetCurrent([NotNullWhen(true)] out ExecutionContext? executionContext, bool throwWhereNull = false) + { + executionContext = _asyncLocal.Value; + + if (executionContext is null && throwWhereNull) + throw new InvalidOperationException("There is currently no ExecutionContext.Current instance; this must be set (SetCurrent) prior to access."); + + return executionContext is not null; + } + + /// + /// Resets (clears) the . + /// + public static void Reset() + { + if (TryGetCurrent(out var executionContext)) + executionContext.Dispose(); + + _asyncLocal.Value = null; + } + + /// + /// Sets the instance (only allowed where is ). + /// + /// The instance. + public static void SetCurrent(ExecutionContext executionContext) + { + if (HasCurrent) + throw new InvalidOperationException("The SetCurrent method can only be used where there is no Current instance."); + + _asyncLocal.Value = executionContext.ThrowIfNull(); + } + + /// + /// Gets the service of from the . + /// + /// The service . + /// The corresponding instance. + public static T? GetService() + { + if (TryGetCurrent(out var executionContext) && executionContext.ServiceProvider is not null) + return executionContext.ServiceProvider.GetService(); + + return default; + } + + /// + /// Gets the service of from the using the . + /// + /// The service . + /// The service key. + /// The corresponding instance. + public static T? GetKeyedService(object? serviceKey) + { + if (TryGetCurrent(out var executionContext) && executionContext.ServiceProvider is not null) + return executionContext.ServiceProvider.GetKeyedService(serviceKey); + + return default; + } + + /// + /// Gets the service of from the and will throw an where not found. + /// + /// The service . + /// The corresponding instance. + public static T GetRequiredService() where T : notnull + { + if (TryGetCurrent(out var executionContext) && executionContext.ServiceProvider is not null) + return executionContext.ServiceProvider.GetRequiredService(); + + throw new InvalidOperationException($"Attempted to get service '{typeof(T).FullName}' but there is either no ExecutionContext.Current or the ExecutionContext.ServiceProvider has not been configured."); + } + + /// + /// Gets the service of from the using the and will throw an where not found. + /// + /// The service . + /// The service key. + /// The corresponding instance. + public static T GetRequiredKeyedService(object? serviceKey) where T : notnull + { + if (TryGetCurrent(out var executionContext) && executionContext.ServiceProvider is not null) + return executionContext.ServiceProvider.GetRequiredKeyedService(serviceKey); + + throw new InvalidOperationException($"Attempted to get service '{typeof(T).FullName}' but there is either no ExecutionContext.Current or the ExecutionContext.ServiceProvider has not been configured."); + } + + /// + /// Gets the service of from the . + /// + /// The service . + /// The corresponding instance. + public static object? GetService(Type type) + { + type.ThrowIfNull(); + if (TryGetCurrent(out var executionContext) && executionContext.ServiceProvider is not null) + return executionContext.ServiceProvider.GetService(type); + + return null; + } + + /// + /// Gets the service of from the using the . + /// + /// The service . + /// The service key. + /// The corresponding instance. + public static object? GetKeyedService(Type type, object? serviceKey) + { + type.ThrowIfNull(); + if (TryGetCurrent(out var executionContext) && executionContext.ServiceProvider is not null) + return executionContext.ServiceProvider.GetKeyedServices(type, serviceKey).FirstOrDefault(s => s?.GetType() == type); + + return null; + } + + /// + /// Gets the service of from the and will throw an where not found. + /// + /// The service . + /// The corresponding instance. + public static object GetRequiredService(Type type) + { + type.ThrowIfNull(); + if (TryGetCurrent(out var executionContext) && executionContext.ServiceProvider is not null) + return executionContext.ServiceProvider.GetRequiredService(type); + + throw new InvalidOperationException($"Attempted to get service '{type.FullName}' but there is either no ExecutionContext.Current or the ExecutionContext.ServiceProvider has not been configured."); + } + + /// + /// Gets the service of from the using the and will throw an where not found. + /// + /// The service . + /// The service key. + /// The corresponding instance. + public static object GetRequiredKeyedService(Type type, object? serviceKey) + { + type.ThrowIfNull(); + if (TryGetCurrent(out var executionContext) && executionContext.ServiceProvider is not null) + return executionContext.ServiceProvider.GetRequiredKeyedService(type, serviceKey); + + throw new InvalidOperationException($"Attempted to get service '{type.FullName}' but there is either no ExecutionContext.Current or the ExecutionContext.ServiceProvider has not been configured."); + } + + /// + /// Gets the related where the is ; otherwise, returns . + /// + /// The text function that is only executed where is . + public static string? GetRelatedText(Func text) => TryGetCurrent(out var ec) && ec.IncludeRelatedText ? text.ThrowIfNull()() : null; + + /// + /// Dispose of resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// 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. + protected virtual void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + lock (_lock) + { + if (!_disposed) + { + if (_asyncLocal.Value == this) + _asyncLocal.Value = null; + + _disposed = true; + ServiceProvider = null; + } + } + } + } +} \ No newline at end of file diff --git a/src/CoreEx/ExecutionContext.cs b/src/CoreEx/ExecutionContext.cs index 488601d8..b898ab7b 100644 --- a/src/CoreEx/ExecutionContext.cs +++ b/src/CoreEx/ExecutionContext.cs @@ -1,375 +1,113 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.RefData; -using CoreEx.Results; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents a thread-bound (request) execution context using . +/// +/// Used to house/pass context parameters and capabilities that are outside of the general operation arguments. This class should be extended by consumers where additional properties are required. +/// The implements ; however, from a standard implementation perspective there are no unmanaged resources leveraged. The will result in a . +public partial class ExecutionContext : IDisposable, IReadOnlyTenantId { + private DateTimeOffset? _timestamp; + private MessageItemCollection? _messages; + private Lazy> _attributes = new(true); + private bool _isCopied; + /// - /// Represents a thread-bound (request) execution context using . + /// Gets or sets the . /// - /// Used to house/pass context parameters and capabilities that are outside of the general operation arguments. This class should be extended by consumers where additional properties are required. - /// The implements ; however, from a standard implementation perspective there are no unmanaged resources leveraged. The will result in a . - public class ExecutionContext : ITenantId, IDisposable - { - private static readonly AsyncLocal _asyncLocal = new(); - - private DateTime? _timestamp; - private Lazy _messages = new(CreateWithNoErrorTypeSupport, true); - private Lazy> _properties = new(true); - private IReferenceDataContext? _referenceDataContext; - private HashSet? _roles; - private HashSet? _permissions; - private bool _isCopied; - private bool _disposed; -#if NET9_0_OR_GREATER - private readonly System.Threading.Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - - /// - /// Gets or sets the function to create a default instance. - /// - public static Func? Create { get; set; } = () => new ExecutionContext(); - - /// - /// Indicates whether the has a value. - /// - public static bool HasCurrent => _asyncLocal.Value != null; - - /// - /// Gets the current for the executing thread graph (see ). - /// - /// Where not previously set (see ) then the will be invoked as a backup to create an instance on first access. - /// The should be used to dispose and clear the current where no longer needed. - public static ExecutionContext Current => _asyncLocal.Value ??= Create?.Invoke() ?? - throw new InvalidOperationException("There is currently no ExecutionContext.Current instance; this must be set (SetCurrent) prior to access. Use ExecutionContext.HasCurrent to verify value and avoid this exception if appropriate."); - - /// - /// Resets (disposes and clears) the . - /// - public static void Reset() - { - if (HasCurrent) - Current.Dispose(); - - _asyncLocal.Value = null; - } - - /// - /// Sets the instance (only allowed where is false). - /// - /// The instance. - public static void SetCurrent(ExecutionContext executionContext) - { - if (HasCurrent) - throw new InvalidOperationException("The SetCurrent method can only be used where there is no Current instance."); - - _asyncLocal.Value = executionContext.ThrowIfNull(nameof(executionContext)); - } - - /// - /// Gets the service of from the . - /// - /// The service . - /// The corresponding instance. - public static T? GetService() - { - if (HasCurrent && Current.ServiceProvider != null) - return Current.ServiceProvider.GetService(); - - return default; - } - - /// - /// Gets the service of from the and will throw an where not found. - /// - /// The service . - /// The corresponding instance. - public static T GetRequiredService() where T : notnull - { - if (HasCurrent && Current.ServiceProvider != null) - return Current.ServiceProvider.GetRequiredService(); - - throw new InvalidOperationException($"Attempted to get service '{typeof(T).FullName}' but there is either no ExecutionContext.Current or the ExecutionContext.ServiceProvider has not been configured."); - } - - /// - /// Gets the service of from the . - /// - /// The service . - /// The corresponding instance. - public static object? GetService(Type type) - { - type.ThrowIfNull(nameof(type)); - if (HasCurrent && Current.ServiceProvider != null) - return Current.ServiceProvider.GetService(type); - - return null; - } - - /// - /// Gets the service of from the and will throw an where not found. - /// - /// The service . - /// The corresponding instance. - public static object GetRequiredService(Type type) - { - type.ThrowIfNull(nameof(type)); - if (HasCurrent && Current.ServiceProvider != null) - return Current.ServiceProvider.GetRequiredService(type); - - throw new InvalidOperationException($"Attempted to get service '{type.FullName}' but there is either no ExecutionContext.Current or the ExecutionContext.ServiceProvider has not been configured."); - } - - /// - /// Gets the username from the settings. - /// - /// The fully qualified username. - public static string EnvironmentUserName => Environment.UserDomainName == null ? Environment.UserName : Environment.UserDomainName + "\\" + Environment.UserName; - - /// - /// Gets the . - /// - /// This is automatically set via the . - public IServiceProvider? ServiceProvider { get; set; } - - /// - /// Gets or sets the correlation identifier. - /// - /// Defaults to . - public string CorrelationId { get; set; } = Guid.NewGuid().ToString().ToLowerInvariant(); - - /// - /// Gets or sets the . - /// - public OperationType OperationType { get; set; } - - /// - /// Indicates whether text serialization is enabled; see . - /// - public bool IsTextSerializationEnabled { get; set; } - - /// - /// Gets or sets the corresponding user name. - /// - public string UserName { get; set; } = EnvironmentUserName; - - /// - /// Gets or sets the corresponding user identifier. - /// - public string? UserId { get; set; } - - /// - /// Gets or sets the tenant identifier. - /// - public string? TenantId { get; set; } - - /// - /// Gets or sets the timestamp for the lifetime; i.e (to enable consistent execution-related timestamping). - /// - /// Defaults to ; where this has not been registered it will default to . The value will also be passed through and will have the configured applied. - /// This value will remain unchanged for the life of the to ensure consistency of the value. - public DateTime Timestamp { get => _timestamp ??= Cleaner.Clean(SystemTime.Get().UtcNow); set => _timestamp = Cleaner.Clean(value); } - - /// - /// Gets the that is intended to be returned to the originating consumer. - /// - /// This is not intended to be a replacement for returning errors/exceptions; as such, if a with a of is added a corresponding - /// will be thrown. This is ultimately intended for warning and information messages that provide additional context outside of the intended operation result. - /// There are no guarantees that these messages will be returned; it is the responsibility of the hosting process to manage. - public MessageItemCollection Messages { get => _messages.Value; } - - /// - /// Indicates whether there are any . - /// - public bool HasMessages => _messages.IsValueCreated && _messages.Value.Count > 0; - - /// - /// Gets the properties for passing/storing additional data. - /// - public ConcurrentDictionary Properties { get => _properties.Value; } - - /// - /// Gets the . - /// - /// Where not configured will automatically instantiate a on first access. - public IReferenceDataContext ReferenceDataContext => _referenceDataContext ??= (GetService() ?? new ReferenceDataContext()); + public IServiceProvider? ServiceProvider { get; set; } - /// - /// Indicates whether this instance was created as a result of a operation. - /// - public bool IsACopy => _isCopied; + /// + public string? TenantId { get; set; } - /// - /// Creates a new (or uses the specified ) and returns the new . - /// - /// The optional . - /// The as an . - /// Performs a followed by a corresponding . - /// Useful for scoped scenarios where the underlying will be automatically invoked, such as the following: - /// - /// using var ec = ExecutionContext.CreateNew(); - /// - /// // or - /// - /// using (ExecutionContext.CreateNew()) - /// { - /// } - /// - /// - public static ExecutionContext CreateNew(ExecutionContext? executionContext = null) - { - Reset(); - SetCurrent(executionContext ?? Create?.Invoke() ?? new ExecutionContext()); - return Current; - } - - /// - /// Creates a copy of the using the function to instantiate before copying or referencing all underlying properties. - /// - /// The new instance. - /// This is intended for advanced scenarios and may have unintended consequences where not used correctly. - /// Note: the , , , Roles and Permissions share same instance, i.e. are not copied. - public virtual ExecutionContext CreateCopy() - { - var ec = Create == null ? throw new InvalidOperationException($"The {nameof(Create)} function must not be null to create a copy.") : Create(); - ec._timestamp = _timestamp; - ec._messages = _messages; - ec._properties = _properties; - ec._referenceDataContext = _referenceDataContext; - ec._roles = _roles; - ec._permissions = _permissions; - ec.ServiceProvider = ServiceProvider; - ec.CorrelationId = CorrelationId; - ec.OperationType = OperationType; - ec.IsTextSerializationEnabled = IsTextSerializationEnabled; - ec.UserName = UserName; - ec.UserId = UserId; - ec.TenantId = TenantId; - ec._isCopied = true; - return ec; - } - - /// - /// Dispose of resources. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases the unmanaged resources used by the and optionally releases the managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (disposing && !_disposed) - { - lock (_lock) - { - if (!_disposed) - { - if (!_isCopied && _messages.IsValueCreated) - _messages.Value.CollectionChanged -= Messages_CollectionChanged; - - _disposed = true; - } - } - } - } + /// + /// Gets or sets the timestamp () for the lifetime; i.e (to enable consistent execution-related timestamping). + /// + /// This value is intended to remain unchanged for the life of the to ensure consistency of the value. + public DateTimeOffset Timestamp { get => _timestamp ??= ServiceProvider?.GetService()?.GetUtcNow() ?? TimeProvider.System.GetUtcNow(); set => _timestamp = value; } - /// - /// Create a new with the contrainst that no messages can be added. - /// - private static MessageItemCollection CreateWithNoErrorTypeSupport() - { - var messages = new MessageItemCollection(); - messages.CollectionChanged += Messages_CollectionChanged; - return messages; - } + /// + /// Gets the execution that should generally be returned to the end-consumer. + /// + public MessageItemCollection? Messages => _messages; - /// - /// Handles the CollectionChanged event to ensure that no error messages are added. - /// - private static void Messages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) - { - if (e.NewItems is not null && e.NewItems.OfType().Any(m => m.Type == MessageType.Error)) - throw new InvalidOperationException("An error message cannot be added to the ExecutionContext.Messages collection; this is intended for warning and information messages only."); - } + /// + /// Indicates whether there are any . + /// + public bool HasMessages => _messages is not null && _messages.Count > 0; - #region Security + /// + /// Gets the additional execution context attributes. + /// + public ConcurrentDictionary Attributes { get => _attributes.Value; } - /// - /// Gets the list of roles for the (as previously set). - /// - public IEnumerable GetRoles() => _roles == null ? Array.Empty() : _roles; + /// + /// Gets or sets the corresponding . + /// + public AuthenticationUser User { get; set => field = value.ThrowIfNull(); } = AuthenticationUser.EnvironmentUser; - /// - /// Sets (replaces) the roles the current user is in (the roles should be unique). - /// - /// The of roles the user is in. - public virtual void SetRoles(IEnumerable roles) => _roles = new HashSet(roles); + /// + /// Gets or sets the UI . + /// + public CultureInfo UICulture { get; set; } = CultureInfo.CurrentUICulture; - /// - /// Gets the list of permissions for the (as previously set). - /// - public IEnumerable GetPermissions() => _permissions == null ? Array.Empty() : _permissions; + /// + /// Indicates whether is specified within the current request to include related text(s) where available. + /// + public bool IncludeRelatedText { get; set; } - /// - /// Sets (replaces) the permissions the current user is in (the roles should be unique). - /// - /// The of roles the user is in. - public virtual void SetPermissions(IEnumerable roles) => _permissions = new HashSet(roles); + /// + /// Gets or sets the corresponding CRUD operation type (Create, Read, Update and Delete). + /// + public OperationType OperationType { get; set; } = OperationType.Unspecified; - /// - /// Checks whether the user has the required (see and ). - /// - /// The permission to validate. - /// The corresponding . - public virtual Result UserIsAuthorized(string permission) - { - permission.ThrowIfNullOrEmpty(nameof(permission)); - return _permissions is not null && _permissions.Contains(permission) ? Result.Success : Result.AuthorizationError(); - } + /// + /// Adds a message to the . + /// + /// The message text. + /// The to support fluent-style method-chaining. + public ExecutionContext AddWarningMessage(LText text) + { + (_messages ??= []).Add(new MessageItem(MessageType.Warning, text)); + return this; + } - /// - /// Checks whether the user has the required permission (as a combination of an and ). - /// - /// The entity name. - /// The action name. - /// The corresponding . - /// This default implementation formats as {entity}.{action} and invokes . - /// An example is Customer and Create formatted as Customer.Create. - public virtual Result UserIsAuthorized(string entity, string action) - { - entity.ThrowIfNullOrEmpty(nameof(entity)); - action.ThrowIfNullOrEmpty(nameof(action)); - return UserIsAuthorized($"{entity}.{action}"); - } + /// + /// Adds a message to the . + /// + /// The message text. + /// The to support fluent-style method-chaining. + public ExecutionContext AddInfoMessage(LText text) + { + (_messages ??= []).Add(new MessageItem(MessageType.Info, text)); + return this; + } - /// - /// Determines whether the user is in the specified role (see and ). - /// - /// The role name. - /// The corresponding . - public virtual Result UserIsInRole(string role) - { - role.ThrowIfNullOrEmpty(nameof(role)); - return _roles is not null && _roles.Contains(role) ? Result.Success : Result.AuthorizationError(); - } + /// + /// Indicates whether this instance was created as a result of a operation. + /// + public bool IsACopy => _isCopied; - #endregion + /// + /// Creates a copy of the using the function to instantiate before copying or referencing all underlying properties. + /// + /// The new instance. + /// This is intended for advanced scenarios and may have unintended consequences where not used correctly. + /// Note: the , and share the same instance, i.e. are not copied. The is updated accordingly to indicate the current copy state. + public virtual ExecutionContext CreateCopy() + { + var ec = Create is null ? throw new InvalidOperationException($"The {nameof(Create)} function must not be null to create a copy.") : Create(); + ec._timestamp = _timestamp; + ec._messages = _messages; + ec.ServiceProvider = ServiceProvider; + ec.User = User; + ec.TenantId = TenantId; + ec.UICulture = UICulture; + ec._isCopied = true; + + if (_attributes.IsValueCreated) + ec._attributes = new Lazy>(new ConcurrentDictionary(_attributes.Value)); + + return ec; } } \ No newline at end of file diff --git a/src/CoreEx/Extensions.ExtendedException.cs b/src/CoreEx/Extensions.ExtendedException.cs new file mode 100644 index 00000000..adb761dc --- /dev/null +++ b/src/CoreEx/Extensions.ExtendedException.cs @@ -0,0 +1,83 @@ +namespace CoreEx; + +public static partial class Extensions +{ + /// + /// Gets the extension name for the dictionary to use for the value. + /// + public const string KeyExtensionName = "key"; + + /// + /// Sets (overrides) the to the specified . + /// + /// The . + /// The . + /// The error code. + /// The to support fluent-style method-chaining. + public static TException WithErrorCode(this TException exception, string errorCode) where TException : ExtendedException + => exception.ThrowIfNull().Adjust(ex => ex.ErrorCode = errorCode.ThrowIfNullOrEmpty()); + + /// + /// Sets (overrides) the to the specified . + /// + /// The . + /// The . + /// The error type. + /// The to support fluent-style method-chaining. + public static TException WithErrorType(this TException exception, string errorType) where TException : ExtendedException + => exception.ThrowIfNull().Adjust(ex => ex.ErrorType = errorType.ThrowIfNullOrEmpty()); + + /// + /// Sets (overrides) the to the specified . + /// + /// The . + /// The . + /// The . + /// The to support fluent-style method-chaining. + public static TException WithStatusCode(this TException exception, HttpStatusCode statusCode) where TException : ExtendedException + => exception.ThrowIfNull().Adjust(ex => ex.StatusCode = statusCode); + + /// + /// Sets (overrides) the to the specified . + /// + /// The . + /// The . + /// The error detail. + /// The to support fluent-style method-chaining. + public static TException WithDetail(this TException exception, string detail) where TException : ExtendedException + => exception.ThrowIfNull().Adjust(ex => ex.Detail = detail); + + /// + /// Adds (overrides) the specified (using within the dictionary). + /// + /// The . + /// The . + /// The . + /// The key value. + /// The to support fluent-style method-chaining. + /// This is a convenience method for adding a key using the (see ). + public static TException WithKey(this TException exception, TKey key) where TException : ExtendedException + => exception.ThrowIfNull().WithExtension(KeyExtensionName, key); + + /// + /// Adds (overrides) the specified and pair within the dictionary. + /// + /// The . + /// The . + /// The . + /// The extension name. + /// The extension value. + /// The to support fluent-style method-chaining. + public static TException WithExtension(this TException exception, string name, T value) where TException : ExtendedException + => exception.ThrowIfNull().Adjust(ex => ex.Extensions[name.ThrowIfNullOrEmpty()] = value.ThrowIfNull()); + + /// + /// Sets (overrides) the to . + /// + /// The . + /// The . + /// The optional retry-after interval; defaults to . + /// The to support fluent-style method-chaining. + public static TException AsTransient(this TException exception, TimeSpan? retryAfter = null) where TException : ExtendedException + => exception.ThrowIfNull().Adjust(ex => ex.IsTransient = true).Adjust(ex => ex.RetryAfter = retryAfter ?? TransientException.DefaultRetryAfter); +} \ No newline at end of file diff --git a/src/CoreEx/Extensions.HttpRequestMessage.cs b/src/CoreEx/Extensions.HttpRequestMessage.cs new file mode 100644 index 00000000..3b591940 --- /dev/null +++ b/src/CoreEx/Extensions.HttpRequestMessage.cs @@ -0,0 +1,159 @@ +namespace CoreEx; + +public static partial class Extensions +{ + /// + /// Adds the with the specified (where no ). + /// + /// The . + /// The entity tag value. + /// The to support fluent-style method-chaining. + public static HttpRequestMessage WithIfMatch(this HttpRequestMessage request, string? etag) + { + request.ThrowIfNull(); + + if (etag is not null) + request.Headers.IfMatch.Add(new EntityTagHeaderValue(ETag.FormatETag(etag))); + + return request; + } + + /// + /// Adds the with the specified (where no ). + /// + /// The . + /// The entity tag value. + /// The to support fluent-style method-chaining. + public static HttpRequestMessage WithIfNoneMatch(this HttpRequestMessage request, string? etag) + { + request.ThrowIfNull(); + + if (etag is not null) + request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(ETag.FormatETag(etag))); + + return request; + } + + /// + /// Sets the to . + /// + /// The . + /// The to support fluent-style method-chaining. + public static HttpRequestMessage WithMergePatchJsonContentType(this HttpRequestMessage request) + { + request.ThrowIfNull().Content?.Headers.ContentType = new MediaTypeHeaderValue(HttpNames.MergePatchJsonMediaTypeName); + return request; + } + + /// + /// Adds the with the specified or allow to default . + /// + /// The . + /// The idempotency key. + /// The to support fluent-style method-chaining. + /// Where no is specified, a new is generated and used. + public static HttpRequestMessage WithIdempotencyKey(this HttpRequestMessage request, string? idempotencyKey = null) + { + request.ThrowIfNull().Headers.Add(HttpNames.IdempotencyKeyHeaderName, idempotencyKey ?? Guid.NewGuid().ToString()); + return request; + } + + /// + /// Adds to the by adding to the . + /// + /// The . + /// The . + /// The to support fluent-style method-chaining. + public static HttpRequestMessage WithPaging(this HttpRequestMessage request, PagingArgs? paging) + => request.ThrowIfNull().WithPaging(paging?.Skip, paging?.Take, paging?.IsCountRequested ?? false); + + /// + /// Adds to the by adding to the . + /// + /// The . + /// The specified number of elements in a sequence to bypass. + /// The specified number of contiguous elements from the start of a sequence. + /// Indicates whether to get the total count when performing the underlying query + /// The to support fluent-style method-chaining. + public static HttpRequestMessage WithPaging(this HttpRequestMessage request, long? skip = null, long? take = null, bool count = false) + => AddQuery(request, qb => qb + .AddQuery(HttpNames.PagingSkipQueryStringName, skip?.ToString()) + .AddQuery(HttpNames.PagingTakeQueryStringName, take?.ToString()) + .AddQuery(HttpNames.PagingCountQueryStringName, count ? "true" : null)); + + /// + /// Adds to the by adding to the . + /// + /// The . + /// The . + /// The to support fluent-style method-chaining. + public static HttpRequestMessage WithQuery(this HttpRequestMessage request, QueryArgs? query) + => WithQuery(request, query?.Filter, query?.OrderBy, query?.IncludeFields, query?.ExcludeFields); + + /// + /// Adds to the by adding to the . + /// + /// The . + /// The basic dynamic OData-like $filter statement. + /// The basic dynamic OData-like $orderby statement. + /// The list of included fields. + /// The list of excluded fields. + /// The to support fluent-style method-chaining. + public static HttpRequestMessage WithQuery(this HttpRequestMessage request, string? filter = null, string? orderBy = null, IEnumerable? include = null, IEnumerable? exclude = null) + => AddQuery(request, qb => qb + .AddQuery(HttpNames.QueryFilterQueryStringName, filter) + .AddQuery(HttpNames.QueryOrderByQueryStringName, orderBy) + .AddQuery(HttpNames.IncludeFieldsQueryStringName, include is null ? null : string.Join(',', include.Select(i => i?.Trim()).Where(i => !string.IsNullOrEmpty(i)))) + .AddQuery(HttpNames.ExcludeFieldsQueryStringName, exclude is null ? null : string.Join(',', exclude.Select(e => e?.Trim()).Where(e => !string.IsNullOrEmpty(e))))); + + /// + /// Adds to the by adding to the . + /// + /// The . + /// The list of included fields. + /// The to support fluent-style method-chaining. + public static HttpRequestMessage WithFields(this HttpRequestMessage request, params IEnumerable fields) => WithQuery(request, include: fields); + + /// + /// Adds to the by adding to the . + /// + /// The . + /// The list of excluded fields. + /// The to support fluent-style method-chaining. + public static HttpRequestMessage WithoutFields(this HttpRequestMessage request, params IEnumerable fields) => WithQuery(request, exclude: fields); + + /// + /// Adds URI query parameters to the . + /// + private static HttpRequestMessage AddQuery(HttpRequestMessage request, Action queryBuilder) + { + request.ThrowIfNull(); + + var builder = new UriBuilder(request.RequestUri!); + var sb = new StringBuilder(builder.Query); + + queryBuilder(sb); + + builder.Query = sb.ToString(); + request.RequestUri = builder.Uri; + + return request; + + } + + /// + /// Adds a URI query parameter to the . + /// + private static StringBuilder AddQuery(this StringBuilder sb, string name, string? value) + { + value = value?.Trim(); + if (string.IsNullOrEmpty(value)) + return sb; + + if (sb.Length > 0) + sb.Append('&'); + + sb.Append($"{name}={value}"); + return sb; + } +} \ No newline at end of file diff --git a/src/CoreEx/Extensions.HttpResponseMessage.cs b/src/CoreEx/Extensions.HttpResponseMessage.cs new file mode 100644 index 00000000..07850203 --- /dev/null +++ b/src/CoreEx/Extensions.HttpResponseMessage.cs @@ -0,0 +1,161 @@ +namespace CoreEx; + +public static partial class Extensions +{ + /// + /// Converts the into a where not and the content media type is . + /// + /// The . + /// The . + /// The corresponding or . + public static async Task ToProblemDetailsAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default) + { + if (response.ThrowIfNull().IsSuccessStatusCode) + return null; + + if (!MediaTypeNames.Application.ProblemJson.Equals(response.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase)) + return null; + + try + { + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var pd = await JsonSerializer.DeserializeAsync(new MemoryStream(Encoding.UTF8.GetBytes(content)), JsonDefaults.SerializerOptions, cancellationToken).ConfigureAwait(false); + if (pd is not null) + return new ProblemDetailsException(pd, new HttpRequestException($"{CreateMessage(response)} Problem details:{content}")); + } + catch { } // Swallow and assume not a problem details. + + return null; + } + + /// + /// Where the is not successful () and the content media type is , this method + /// converts the into a (see and invokes ; + /// otherwise, continues without error. + /// + /// The . + /// The . + /// The corresponding or . + public static async Task ThrowOnBusinessExceptionAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default) + { + var pde = await response.ToProblemDetailsAsync(cancellationToken).ConfigureAwait(false); + pde?.ThrowOnBusinessException(); + return pde; + } + + /// + /// Where the is not successful () will throw a corresponding exception; otherwise, continues without error. + /// + /// The . + /// The . + /// Where the response is not successful () and the content media type is , the content is + /// converted into a and returned as the error; otherwise, an is returned as the error. + /// Additionally, where the is considered a , it is thrown as such. + /// Finally, where the response is successful, not action is taken. + public static async Task ThrowOnErrorAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default) + { + if (response.IsSuccessStatusCode) + return; + + var pde = await response.ThrowOnBusinessExceptionAsync(cancellationToken).ConfigureAwait(false); + if (pde is not null) + throw pde; + + throw new HttpRequestException(CreateMessage(response), null, response.StatusCode); + } + + /// + /// Creates the standard error message for a non-successful . + /// + private static string CreateMessage(HttpResponseMessage response) => string.IsNullOrWhiteSpace(response.ReasonPhrase) + ? $"Response status code does not indicate success: {(int)response.StatusCode}." + : $"Response status code does not indicate success: {(int)response.StatusCode} ({response.ReasonPhrase})."; + + /// + /// Converts the into a . + /// + /// The . + /// The . + /// The corresponding . + /// Where the response is not successful () and the content media type is , the content is + /// converted into a and returned as the error; otherwise, an is returned as the error. + /// Additionally, where the is considered a , it is returned as such. + /// Finally, where the response is successful, a is returned. + public static async Task ToResultAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default) + { + if (response.ThrowIfNull().IsSuccessStatusCode) + return Result.Success; + + var pde = await response.ToProblemDetailsAsync(cancellationToken).ConfigureAwait(false); + if (pde is not null) + return pde.TryGetBusinessException(out var be) ? be : pde; + + return Result.Fail(new HttpRequestException(CreateMessage(response), null, response.StatusCode)); + } + + /// + /// Converts the into a including the deserialized JSON response value where successful. + /// + /// The response value . + /// The . + /// The optional . + /// The . + /// The corresponding . + /// Where the response is not successful () and the content media type is , the content is + /// converted into a and returned as the error; otherwise, an is returned as the error. + /// Additionally, where the is considered a , it is returned as such. + /// Finally, where the response is successful, a is returned that includes the deserialized response value. + public static async Task> ToResultAsync(this HttpResponseMessage response, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + // Where not successful, reuse the non-generic version above to get the error details. + if (!response.ThrowIfNull().IsSuccessStatusCode) + return await ToResultAsync(response, cancellationToken).ConfigureAwait(false); + + // Where successful, attempt to read the content as JSON and return as the value. + var value = await response.Content.ReadFromJsonAsync(jsonSerializerOptions ?? JsonDefaults.SerializerOptions, cancellationToken).ConfigureAwait(false); + return value; + } + + /// + /// Converts the into a including the deserialized JSON response value where successful. + /// + /// The . + /// The . + /// The corresponding . + /// Where the response is not successful () and the content media type is , the content is + /// converted into a and returned as the error; otherwise, an is returned as the error. + /// Additionally, where the is considered a , it is returned as such. + /// Finally, where the response is successful, a is returned that includes the deserialized response value. + public static Task> ToResultAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default) => ToResultAsync(response, null, cancellationToken); + + /// + /// Gets the deserialized JSON response value from the where successful; otherwise, will throw a corresponding exception. + /// + /// The response value . + /// The . + /// The optional . + /// The . + /// The value where successful. + /// Where the response is not successful () and the content media type is , the content is + /// converted into a and returned as the error; otherwise, an is returned as the error. + /// Additionally, where the is considered a , it is thrown as such. + /// Finally, where the response is successful, the deserialized response value is returned. + public async static Task GetValueAsync(this HttpResponseMessage response, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) + { + var result = await response.ThrowIfNull().ToResultAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false); + return result.Value; + } + + /// + /// Gets the deserialized JSON response value from the where successful; otherwise, will throw a corresponding exception. + /// + /// The response value . + /// The . + /// The . + /// The value where successful. + /// Where the response is not successful () and the content media type is , the content is + /// converted into a and returned as the error; otherwise, an is returned as the error. + /// Additionally, where the is considered a , it is thrown as such. + /// Finally, where the response is successful, the deserialized response value is returned. + public static Task GetValueAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default) => GetValueAsync(response, null, cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx/Extensions.IEnumerable.cs b/src/CoreEx/Extensions.IEnumerable.cs new file mode 100644 index 00000000..a18991a8 --- /dev/null +++ b/src/CoreEx/Extensions.IEnumerable.cs @@ -0,0 +1,41 @@ +namespace CoreEx; + +public static partial class Extensions +{ + /// + /// Creates a from an according to a specified key selector and element selector functions. + /// + /// The source . + /// The element . + /// The source sequence. + /// A function to extract the key from each element. + /// A function to map each element to the value. + /// An optional equality comparer for the keys. + /// A containing the mapped elements. + public static DataMap ToDataMap(this IEnumerable source, Func keySelector, Func elementSelector, IEqualityComparer? comparer = null) + { + source.ThrowIfNull(); + keySelector.ThrowIfNull(); + elementSelector.ThrowIfNull(); + + if (source is ICollection collection) + { + if (collection.Count == 0) + return new(comparer); + + var dataMap = new DataMap(collection.Count); + foreach (var item in collection) + dataMap.Add(keySelector(item), elementSelector(item)); + + return dataMap; + } + else + { + var dataMap = new DataMap(comparer); + foreach (var item in source) + dataMap.Add(keySelector(item), elementSelector(item)); + + return dataMap; + } + } +} \ No newline at end of file diff --git a/src/CoreEx/Extensions.IHybridCache.cs b/src/CoreEx/Extensions.IHybridCache.cs new file mode 100644 index 00000000..b7cfc697 --- /dev/null +++ b/src/CoreEx/Extensions.IHybridCache.cs @@ -0,0 +1,117 @@ +namespace CoreEx; + +public static partial class Extensions +{ + /// + /// Tries to get the cached value for the specified key. + /// + /// The . + /// The . + /// The . + /// A tuple with a indicating whether the entry exists and the associated value where found (otherwise, ). + public static async Task<(bool Exists, T? Value)> TryGetAsync(this IHybridCache cache, CompositeKey key, CancellationToken cancellationToken = default) where T : IEntityKey + => await TryGetAsync(cache, key, null, cancellationToken).ConfigureAwait(false); + + /// + /// Tries to get the cached value for the specified key. + /// + /// The . + /// The . + /// The optional . + /// The . + /// A tuple with a indicating whether the entry exists and the associated value where found (otherwise, ). + public static async Task<(bool Exists, T? Value)> TryGetAsync(this IHybridCache cache, CompositeKey key, HybridCacheEntryOptions? options, CancellationToken cancellationToken = default) where T : IEntityKey + => await cache.TryGetByKeyAsync(cache.KeyProvider.GetEntityCacheKey(key), options ?? HybridCacheEntryOptions.CreateFor(), cancellationToken).ConfigureAwait(false); + + /// + /// Gets the cached value for the specified key or the default where not found. + /// + /// The cache value . + /// The . + /// The . + /// The . + /// The cached value or the default where not found. + public static async Task GetOrDefaultAsync(this IHybridCache cache, CompositeKey key, CancellationToken cancellationToken = default) where T : IEntityKey + => await GetOrDefaultAsync(cache, key, null, cancellationToken).ConfigureAwait(false); + + /// + /// Gets the cached value for the specified key or the default where not found. + /// + /// The cache value . + /// The . + /// The . + /// The optional . + /// The . + /// The cached value or the default where not found. + public static async Task GetOrDefaultAsync(this IHybridCache cache, CompositeKey key, HybridCacheEntryOptions? options, CancellationToken cancellationToken = default) where T : IEntityKey + => await cache.GetOrDefaultByKeyAsync(cache.KeyProvider.GetEntityCacheKey(key), options ?? HybridCacheEntryOptions.CreateFor(), cancellationToken).ConfigureAwait(false); + + /// + /// Sets or overwrites the cache . + /// + /// The cache value . + /// The . + /// The cache value. + /// The . + /// Uses the to determine the cache key. + public static async Task SetAsync(this IHybridCache cache, T value, CancellationToken cancellationToken = default) where T : IEntityKey + => await SetAsync(cache, value, null, cancellationToken).ConfigureAwait(false); + + /// + /// Sets or overwrites the cache . + /// + /// The cache value . + /// The . + /// The cache value. + /// The optional . + /// The . + /// Uses the to determine the cache key. + public static async Task SetAsync(this IHybridCache cache, T value, HybridCacheEntryOptions? options, CancellationToken cancellationToken = default) where T : IEntityKey + => await cache.SetByKeyAsync(cache.KeyProvider.GetEntityCacheKey(value.EntityKey), value, options ?? HybridCacheEntryOptions.CreateFor(), cancellationToken).ConfigureAwait(false); + + /// + /// Gets the cached value for the specified key using the to create and set where not found. + /// + /// The cache value . + /// The . + /// The . + /// The function used to create the cache value. + /// The . + /// Uses the to determine the cache key. + public static async Task GetOrCreateAsync(this IHybridCache cache, CompositeKey key, Func> factory, CancellationToken cancellationToken = default) where T : IEntityKey + => await cache.GetOrCreateByKeyAsync(cache.KeyProvider.GetEntityCacheKey(key), factory, null, cancellationToken).ConfigureAwait(false); + + /// + /// Gets the cached value for the specified key using the to create and set where not found. + /// + /// The cache value . + /// The . + /// The . + /// The function used to create the cache value. + /// The optional . + /// The . + /// Uses the to determine the cache key. + public static async Task GetOrCreateAsync(this IHybridCache cache, CompositeKey key, Func> factory, HybridCacheEntryOptions? options, CancellationToken cancellationToken = default) where T : IEntityKey + => await cache.GetOrCreateByKeyAsync(cache.KeyProvider.GetEntityCacheKey(key), factory, options ?? HybridCacheEntryOptions.CreateFor(), cancellationToken).ConfigureAwait(false); + + /// + /// Removes the cached value for the specified key. + /// + /// The . + /// The . + /// The . + /// Uses the to determine the cache key. + public static async Task RemoveAsync(this IHybridCache cache, CompositeKey key, CancellationToken cancellationToken = default) where T : IEntityKey + => await cache.RemoveAsync(key, null, cancellationToken).ConfigureAwait(false); + + /// + /// Removes the cached value for the specified key. + /// + /// The . + /// The . + /// The optional . + /// The . + /// Uses the to determine the cache key. + public static async Task RemoveAsync(this IHybridCache cache, CompositeKey key, HybridCacheEntryOptions? options, CancellationToken cancellationToken = default) where T : IEntityKey + => await cache.RemoveByKeyAsync(cache.KeyProvider.GetEntityCacheKey(key), options ?? HybridCacheEntryOptions.CreateFor(), cancellationToken).ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/CoreEx/Extensions.OperationType.cs b/src/CoreEx/Extensions.OperationType.cs new file mode 100644 index 00000000..c60e2040 --- /dev/null +++ b/src/CoreEx/Extensions.OperationType.cs @@ -0,0 +1,42 @@ +namespace CoreEx; + +public static partial class Extensions +{ + extension(OperationType operationType) + { + /// + /// Indicates whether the is a . + /// + public bool IsGet => operationType == OperationType.Get; + + /// + /// Indicates whether the is a . + /// + public bool IsCreate => operationType == OperationType.Create; + + /// + /// Indicates whether the is aN . + /// + public bool IsUpdate => operationType == OperationType.Update; + + /// + /// Indicates whether the is a . + /// + public bool IsDelete => operationType == OperationType.Delete; + + /// + /// Indicates whether the is . + /// + public bool IsUnspecified => operationType == OperationType.Unspecified; + + /// + /// Indicates whether the is a or . + /// + public bool IsRead => operationType == OperationType.Get || operationType == OperationType.Query; + + /// + /// Indicates whether the is a or or . + /// + public bool IsMutation => operationType == OperationType.Create || operationType == OperationType.Update || operationType == OperationType.Delete; + } +} \ No newline at end of file diff --git a/src/CoreEx/Extensions.cs b/src/CoreEx/Extensions.cs new file mode 100644 index 00000000..e3cc1eee --- /dev/null +++ b/src/CoreEx/Extensions.cs @@ -0,0 +1,187 @@ +namespace CoreEx; + +/// +/// Provides standard extensions. +/// +public static partial class Extensions +{ + /// + /// Throws an if the is . + /// + /// The . + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + [DebuggerStepThrough] + [return: NotNull] + public static T ThrowIfNull([NotNull] this T? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + ArgumentNullException.ThrowIfNull(value, paramName); + return value; + } + + /// + /// Throws an if the is or . + /// + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + [DebuggerStepThrough] + [return: NotNull] + public static string ThrowIfNullOrEmpty([NotNull] this string? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + ArgumentException.ThrowIfNullOrEmpty(value, paramName); + return value; + } + + /// + /// Throws an if the is . + /// + /// The value to validate as non-null. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + [DebuggerStepThrough] + [return: NotNullIfNotNull(nameof(value))] + public static string? ThrowIfEmpty(this string? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + if (value is not null && value.Length == 0) + throw new ArgumentException("The value cannot be an empty string.", paramName); + + return value; + } + + /// + /// Throws an when the execution results in . + /// + /// The . + /// The value to validate. + /// The predicate. + /// The error message; defaults to the predicate expression. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + [DebuggerStepThrough] + [return: NotNullIfNotNull(nameof(value))] + public static T ThrowWhen(this T value, Func predicate, [CallerArgumentExpression(nameof(predicate))] string? message = null, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + if (predicate(value)) + throw new ArgumentException(message, paramName); + + return value; + } + + /// + /// Throws an when the is less than zero. + /// + /// The . + /// The value to validate. + /// The optional message to include in the exception if the condition is met. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + [DebuggerStepThrough] + public static T ThrowIfLessThanZero(this T value, string? message = null, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : System.Numerics.INumber + => (value < T.Zero) ? throw new ArgumentOutOfRangeException(paramName, message.ThrowIfEmpty() ?? "The value cannot be less than zero.") : value; + + /// + /// Throws an when the is less than or equal to zero. + /// + /// The . + /// The value to validate. + /// The optional message to include in the exception if the condition is met. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + [DebuggerStepThrough] + public static T ThrowIfLessThanOrEqualToZero(this T value, string? message = null, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : System.Numerics.INumber + => (value <= T.Zero) ? throw new ArgumentOutOfRangeException(paramName, message.ThrowIfEmpty() ?? "The value cannot be less than or equal to zero.") : value; + + /// + /// Throws an when the is greater than zero. + /// + /// The . + /// The value to validate. + /// The optional message to include in the exception if the condition is met. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + [DebuggerStepThrough] + public static T ThrowIfGreaterThanZero(this T value, string? message = null, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : System.Numerics.INumber + => (value > T.Zero) ? throw new ArgumentOutOfRangeException(paramName, message.ThrowIfEmpty() ?? "The value cannot be greater than zero.") : value; + + /// + /// Throws an when the is greater than or equal to zero. + /// + /// The . + /// The value to validate. + /// The optional message to include in the exception if the condition is met. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + [DebuggerStepThrough] + public static T ThrowIfGreaterThanOrEqualToZero(this T value, string? message = null, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : System.Numerics.INumber + => (value >= T.Zero) ? throw new ArgumentOutOfRangeException(paramName, message.ThrowIfEmpty() ?? "The value cannot be greater than or equal to zero.") : value; + + /// + /// Throws an when the is equal to zero. + /// + /// The . + /// The value to validate. + /// The optional message to include in the exception if the condition is met. + /// The name of the parameter with which the corresponds. + /// The to support fluent-style method-chaining. + [DebuggerStepThrough] + public static T ThrowIfEqualToZero(this T value, string? message = null, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : System.Numerics.INumber + => (value == T.Zero) ? throw new ArgumentOutOfRangeException(paramName, message.ThrowIfEmpty() ?? "The value cannot be equal to zero.") : value; + + /// + /// Enables adjustment (changes) to a via an action. + /// + /// The . + /// The value to adjust. + /// The adjusting action (invoked only where the is not ). + /// The adjusted value (same instance). + /// Useful in scenarios to in-line simple changes to a value to simplify code. + [DebuggerStepThrough] + [return: NotNullIfNotNull(nameof(value))] + public static T? Adjust(this T? value, Action? adjuster) + { + if (value is not null) + adjuster?.Invoke(value); + + return value!; + } + + /// + /// Enables adjustment (changes) to a via an action when the is . + /// + /// The . + /// The value to adjust. + /// The that determines whether the is invoked. + /// The adjusting action (invoked only where the is not and the results in ). + /// The adjusted value (same instance). + /// Useful in scenarios to in-line simple changes to a value to simplify code. + [DebuggerStepThrough] + [return: NotNullIfNotNull(nameof(value))] + public static T? AdjustWhen(this T? value, Predicate predicate, Action adjuster) + { + if (value is not null && predicate(value)) + adjuster?.Invoke(value); + + return value!; + } + + /// + /// Converts a into sentence case. + /// + /// The text to convert. + /// The as sentence case. + /// For example a value of 'VarNameDB' would return 'Var name DB'. + /// Uses to perform the conversion. + [DebuggerStepThrough] + [return: NotNullIfNotNull(nameof(text))] + public static string? ToSentenceCase(this string? text) => SentenceCase.ToSentenceCase(text); + + /// + /// Indicates whether the exception is (including ). + /// + /// The . + /// indicates canceled; otherwise, . + [DebuggerStepThrough] + public static bool IsCanceled(this Exception ex) => ex is OperationCanceledException || (ex is AggregateException aex && aex.InnerException is OperationCanceledException); +} \ No newline at end of file diff --git a/src/CoreEx/GlobalUsing.cs b/src/CoreEx/GlobalUsing.cs new file mode 100644 index 00000000..fe78a745 --- /dev/null +++ b/src/CoreEx/GlobalUsing.cs @@ -0,0 +1,61 @@ +global using CoreEx; +global using CoreEx.Abstractions; +global using CoreEx.Caching; +global using CoreEx.Data; +global using CoreEx.DependencyInjection; +global using CoreEx.Entities; +global using CoreEx.Entities.Abstractions; +global using CoreEx.Entities.Extended; +global using CoreEx.Globalization; +global using CoreEx.HealthChecks; +global using CoreEx.Hosting; +global using CoreEx.Http; +global using CoreEx.Http.Abstractions; +global using CoreEx.Hosting.Synchronization; +global using CoreEx.Invokers; +global using CoreEx.Json; +global using CoreEx.Localization; +global using CoreEx.Mapping.Converters.Abstractions; +global using CoreEx.RefData.Abstractions; +global using CoreEx.Results; +global using CoreEx.Results.Abstractions; +global using CoreEx.Metadata; +global using CoreEx.Schemas; +global using CoreEx.Security; +global using CoreEx.Text; +global using CoreEx.Wildcards; +global using Microsoft.Extensions.Caching.Memory; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection.Extensions; +global using Microsoft.Extensions.Diagnostics.HealthChecks; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using OpenTelemetry; +global using OpenTelemetry.Metrics; +global using OpenTelemetry.Trace; +global using System.Buffers; +global using System.Buffers.Binary; +global using System.Collections; +global using System.Collections.Concurrent; +global using System.Collections.Immutable; +global using System.Collections.ObjectModel; +global using System.ComponentModel; +global using System.ComponentModel.DataAnnotations; +global using System.Diagnostics; +global using System.Diagnostics.CodeAnalysis; +global using System.Globalization; +global using System.Linq.Expressions; +global using System.Net; +global using System.Net.Http.Headers; +global using System.Net.Http.Json; +global using System.Net.Mime; +global using System.Reflection; +global using System.Runtime.CompilerServices; +global using System.Security.Cryptography; +global using System.Text; +global using System.Text.Json; +global using System.Text.Json.Nodes; +global using System.Text.Json.Serialization; +global using System.Text.RegularExpressions; +global using ExecutionContext = CoreEx.ExecutionContext; \ No newline at end of file diff --git a/src/CoreEx/Globalization/GlobalizationExtensions.cs b/src/CoreEx/Globalization/GlobalizationExtensions.cs new file mode 100644 index 00000000..54542c24 --- /dev/null +++ b/src/CoreEx/Globalization/GlobalizationExtensions.cs @@ -0,0 +1,23 @@ +namespace CoreEx.Globalization; + +/// +/// Provides extension methods. +/// +public static class GlobalizationExtensions +{ + /// + /// Converts the specified to the selected . + /// + /// The . + /// The text to convert. + /// The selected . + /// The converted text. + [return: NotNullIfNotNull(nameof(text))] + public static string? ToCasing(this TextInfo textInfo, string? text, TextInfoCasing casing) => casing switch + { + TextInfoCasing.Lower => text is null ? null : textInfo.ToLower(text), + TextInfoCasing.Upper => text is null ? null : textInfo.ToUpper(text), + TextInfoCasing.Title => text is null ? null : textInfo.ToTitleCase(text), + _ => text + }; +} \ No newline at end of file diff --git a/src/CoreEx/Globalization/README.md b/src/CoreEx/Globalization/README.md deleted file mode 100644 index dc0792fd..00000000 --- a/src/CoreEx/Globalization/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# CoreEx.Globalization - -The `CoreEx.Globalization` namespace provides additional globalization capabilities. - -
- -## Motivation - -To enable additional globalization capabilities. - -
- -## String casing - -Added a [`TextInfoCasing`](./TextInfoCasing.cs) enum and corresponding [`TextInfo`](https://learn.microsoft.com/en-us/dotnet/api/system.globalization.textinfo).[`ToCasing`](./TextInfoExtensions.cs) extension method to perform the selected casing. - - diff --git a/src/CoreEx/Globalization/TextInfoCasing.cs b/src/CoreEx/Globalization/TextInfoCasing.cs index 95744b72..9d835f5d 100644 --- a/src/CoreEx/Globalization/TextInfoCasing.cs +++ b/src/CoreEx/Globalization/TextInfoCasing.cs @@ -1,32 +1,27 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Globalization; -using System.Globalization; - -namespace CoreEx.Globalization +/// +/// Provides the casing selection. +/// +public enum TextInfoCasing { /// - /// Provides the casing selection. + /// No text casing is to be applied; leave as-is. /// - public enum TextInfoCasing - { - /// - /// No text casing is to be applied; leave as-is. - /// - None, + None, - /// - /// Use . - /// - Lower, + /// + /// Use . + /// + Lower, - /// - /// Use . - /// - Upper, + /// + /// Use . + /// + Upper, - /// - /// Use . - /// - Title - } + /// + /// Use . + /// + Title } \ No newline at end of file diff --git a/src/CoreEx/Globalization/TextInfoExtensions.cs b/src/CoreEx/Globalization/TextInfoExtensions.cs deleted file mode 100644 index d34e1787..00000000 --- a/src/CoreEx/Globalization/TextInfoExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Globalization; - -namespace CoreEx.Globalization -{ - /// - /// Provides extension methods. - /// - public static class TextInfoExtensions - { - /// - /// Converts the specified to the selected . - /// - /// The . - /// The text to convert. - /// The selected . - /// The converted text. - public static string? ToCasing(this TextInfo textInfo, string? text, TextInfoCasing casing) => casing switch - { - TextInfoCasing.Lower => text == null ? null : textInfo.ToLower(text), - TextInfoCasing.Upper => text == null ? null : textInfo.ToUpper(text), - TextInfoCasing.Title => text == null ? null : textInfo.ToTitleCase(text), - _ => text - }; - } -} \ No newline at end of file diff --git a/src/CoreEx/HealthChecks/Extensions.cs b/src/CoreEx/HealthChecks/Extensions.cs new file mode 100644 index 00000000..f4c5775f --- /dev/null +++ b/src/CoreEx/HealthChecks/Extensions.cs @@ -0,0 +1,15 @@ +namespace CoreEx.HealthChecks; + +/// +/// Provides extension methods for . +/// +public static class Extensions +{ + extension(HealthCheckTags healthCheckTags) + { + /// + /// Gets the and tags. + /// + public static string[] StartUpAndReadyOnly => [nameof(HealthCheckTags.Startup), nameof(HealthCheckTags.Ready)]; + } +} \ No newline at end of file diff --git a/src/CoreEx/HealthChecks/HealthCheckTags.cs b/src/CoreEx/HealthChecks/HealthCheckTags.cs new file mode 100644 index 00000000..af13df92 --- /dev/null +++ b/src/CoreEx/HealthChecks/HealthCheckTags.cs @@ -0,0 +1,22 @@ +namespace CoreEx.HealthChecks; + +/// +/// Provides the standard health check tags. +/// +public enum HealthCheckTags +{ + /// + /// Liveness probe. + /// + Live, + + /// + /// Readiness probe. + /// + Ready, + + /// + /// Startup probe. + /// + Startup +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/ConcurrentSynchronizer.cs b/src/CoreEx/Hosting/ConcurrentSynchronizer.cs deleted file mode 100644 index 84cad8b4..00000000 --- a/src/CoreEx/Hosting/ConcurrentSynchronizer.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Hosting -{ - /// - /// An that performs no synchronization in that will always return true resulting in concurrent execution. - /// - /// This should be used in scenarios where synchronization is not required as this is handled externally or is not needed. - public sealed class ConcurrentSynchronizer : IServiceSynchronizer - { - /// - public bool Enter(string? name = null) => true; - - /// - public void Exit(string? name) { } - - /// - public void Dispose() { } - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Extensions.ServiceStatus.cs b/src/CoreEx/Hosting/Extensions.ServiceStatus.cs new file mode 100644 index 00000000..a91650e0 --- /dev/null +++ b/src/CoreEx/Hosting/Extensions.ServiceStatus.cs @@ -0,0 +1,58 @@ +namespace CoreEx.Hosting; + +/// +/// Provides standard extensions. +/// +public static partial class Extensions +{ + extension(ServiceStatus status) + { + /// + /// Indicates whether the is in its initial state. + /// + /// Being . + public bool IsInitializing => status == ServiceStatus.Initializing; + + /// + /// Indicates whether the is in a pause state. + /// + /// Being either or . + public bool IsPause => status == ServiceStatus.Pausing || status == ServiceStatus.Paused; + + /// + /// Indicates whether the is in a stop state. + /// + /// Being either or . + public bool IsStop => status == ServiceStatus.Stopping || status == ServiceStatus.Stopped; + + /// + /// Indicates whether the is in a sleep state. + /// + /// Being . + public bool IsAsleep => status == ServiceStatus.Sleeping; + + /// + /// Indicates whether the is in a running state. + /// + /// Being . + public bool IsRunning => status == ServiceStatus.Running; + + /// + /// Indicates whether the is in a state that the service can be started. + /// + /// Note that the service can only be started from the state. + public bool CanStart => status.IsInitializing; + + /// + /// Indicates whether the is in a state that the service can be paused. + /// + /// Note that the service can be paused from either the or states. + public bool CanPause => status == ServiceStatus.Sleeping || status == ServiceStatus.Running; + + /// + /// Indicates whether the is in a state that the service can be resumed. + /// + /// Note that the service can only be resumed from the state. + public bool CanResume => status == ServiceStatus.Paused; + } +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/FileLockSynchronizer.cs b/src/CoreEx/Hosting/FileLockSynchronizer.cs deleted file mode 100644 index 05eb0129..00000000 --- a/src/CoreEx/Hosting/FileLockSynchronizer.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Concurrent; -using System.IO; -using System.Linq; -using CoreEx.Configuration; - -namespace CoreEx.Hosting -{ - /// - /// An that performs synchronization by taking an exclusive lock on a file. - /// - /// A lock file is created per with a name of and extension of '.lock'; e.g. 'Namespace.Class.lock'. For this to function correctly all running - /// instances must be referencing the same shared directory as specified by the (see ). - /// The . - public class FileLockSynchronizer(SettingsBase settings) : IServiceSynchronizer - { - /// - /// Gets the configuration key that defines the directory path for the exclusive lock files. - /// - public const string ConfigKey = "FileLockSynchronizerPath"; - - private readonly string _path = settings.ThrowIfNull(nameof(settings)).GetCoreExValue(ConfigKey) ?? throw new ArgumentException($"Configuration setting '{ConfigKey}' either does not exist or has no value.", nameof(settings)); - private readonly ConcurrentDictionary _dict = new(); - private bool _disposed; - - /// - public bool Enter(string? name = null) - { - if (!Directory.Exists(_path)) - throw new ArgumentException($"Configuration setting '{ConfigKey}' path does not exist: {_path}"); - - var fn = Path.Combine(_path, $"{typeof(T).FullName}{(name == null ? "" : $".{name}")}.lock"); - - try - { - // Is exclusive for this invocation only where genuinely creating. - bool exclusiveLock = false; - _dict.GetOrAdd(GetName(name), _ => { exclusiveLock = true; return File.Create(fn, 1, FileOptions.DeleteOnClose); }); - return exclusiveLock; - } - catch (IOException) { return false; } // Already exists and locked! - catch (Exception ex) - { - throw new InvalidOperationException($"Unexpected exception whilst attemptiong to create file '{fn}' with an exclusive lock: {ex.Message}", ex); - } - } - - /// - public void Exit(string? name) - { - if (_dict.TryRemove(GetName(name), out var fs)) - fs.Dispose(); - } - - /// - public void Dispose() - { - if (!_disposed) - { - _disposed = true; - _dict.Values.ForEach(fs => fs.Dispose()); - } - - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases the unmanaged resources used by the and optionally releases the managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) { } - - /// - /// Gets the full name. - /// - private static string GetName(string? name) => $"{typeof(T).FullName}{(name == null ? "" : $".{name}")}"; - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/HealthChecks/TimerHostedServiceHealthCheck.cs b/src/CoreEx/Hosting/HealthChecks/TimerHostedServiceHealthCheck.cs deleted file mode 100644 index 2a258487..00000000 --- a/src/CoreEx/Hosting/HealthChecks/TimerHostedServiceHealthCheck.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Hosting.HealthChecks -{ - /// - /// Provides an for a reporting its . - /// - /// The indicates current state; assumes healthy where reported. - public sealed class TimerHostedServiceHealthCheck : IHealthCheck - { - private volatile Dictionary? _data; - - /// - /// Gets or sets the last updated health check data. - /// - /// An initial null value indicates unhealthy as no status has been reported. - public Dictionary? Data { get => _data; set => _data = value.ThrowIfNull(nameof(Data)); } - - /// - /// Gets or sets the latest to report. - /// - /// Defaults to ; no health check reported. - public HealthCheckResult Result { get; set; } = HealthCheckResult.Unhealthy("No health check reported."); - - /// - public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) => Task.FromResult(Result); - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/HostSettings.cs b/src/CoreEx/Hosting/HostSettings.cs new file mode 100644 index 00000000..58e097c2 --- /dev/null +++ b/src/CoreEx/Hosting/HostSettings.cs @@ -0,0 +1,51 @@ +namespace CoreEx.Hosting; + +/// +/// Provides standardized runtime/host settings. +/// +public class HostSettings : IHostSettings +{ + private Uri? _source; + + /// + /// Creates a new using the to realize the and properties where not specifically provided. + /// + /// The . + /// The environment name; for example 'Development'. + /// The area name; for example 'contoso'. + /// The domain name; for example 'products'. + /// The source ; for example 'urn:contoso:products'. + /// The . + public static HostSettings Create(IConfiguration configuration, string environmentName, string? solutionName = null, string? domainName = null, Uri? source = null) + { + configuration.ThrowIfNull(); + + var sn = solutionName ?? Internal.GetConfigurationValue("CoreEx:Host:SolutionName", null, configuration) ?? throw new ArgumentException($"{nameof(SolutionName)} must either be specified or configured 'CoreEx:Host:SolutionName'"); + var dn = domainName ?? Internal.GetConfigurationValue("CoreEx:Host:DomainName", null, configuration) ?? throw new ArgumentException($"{nameof(DomainName)} must either be specified or configured 'CoreEx:Host:DomainName'"); + var su = source ?? Internal.GetConfigurationValue("CoreEx:Host:Source", null, configuration); + + return new HostSettings + { + SolutionName = sn, + DomainName = dn, + EnvironmentName = environmentName, + Source = su + }; + } + + /// + public required string SolutionName { get; init => field = value.ThrowIfNullOrEmpty(); } + + /// + public required string DomainName { get; init => field = value.ThrowIfNullOrEmpty(); } + + /// + public required string EnvironmentName { get; init => field = value.ThrowIfNullOrEmpty(); } + + /// + public Uri? Source + { + get => _source ??= new Uri($"urn:{SolutionName.Replace('.', ':').ToLowerInvariant()}:{DomainName.ToLowerInvariant()}"); + init => _source = value; + } +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/HostStartup.cs b/src/CoreEx/Hosting/HostStartup.cs deleted file mode 100644 index bd3a7acd..00000000 --- a/src/CoreEx/Hosting/HostStartup.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace CoreEx.Hosting -{ - /// - /// Provides standardized host startup capabilities - /// - public class HostStartup : IHostStartup - { - /// - public virtual void ConfigureAppConfiguration(HostBuilderContext context, IConfigurationBuilder config) { } - - /// - public virtual void ConfigureHostConfiguration(IConfigurationBuilder config) { } - - /// - public virtual void ConfigureServices(IServiceCollection services) { } - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/HostStartupExtensions.cs b/src/CoreEx/Hosting/HostStartupExtensions.cs deleted file mode 100644 index e9c91683..00000000 --- a/src/CoreEx/Hosting/HostStartupExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Hosting; - -namespace CoreEx.Hosting -{ - /// - /// Provides extensions. - /// - public static class HostStartupExtensions - { - /// - /// Configures the with the capabilities. - /// - /// The . - /// The . - /// The . - public static IHostBuilder ConfigureHostStartup(this IHostBuilder hostBuilder) where TStartup : class, IHostStartup, new() - { - var startup = new TStartup(); - return hostBuilder.ThrowIfNull(nameof(hostBuilder)).ConfigureAppConfiguration(startup.ConfigureAppConfiguration) - .ConfigureHostConfiguration(startup.ConfigureHostConfiguration) - .ConfigureServices((hbc, sc) => startup.ConfigureServices(sc)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/HostedServiceBase.cs b/src/CoreEx/Hosting/HostedServiceBase.cs new file mode 100644 index 00000000..8a412386 --- /dev/null +++ b/src/CoreEx/Hosting/HostedServiceBase.cs @@ -0,0 +1,380 @@ +namespace CoreEx.Hosting; + +/// +/// Represents a base class for an . +/// +/// Provides common properties and methods for hosted services including status reporting and health check integration. +/// The should be used to initialize the service, such as reading configuration settings, etc. versus via the constructor. +public abstract class HostedServiceBase : IHostedService, IDisposable +{ + private string? _serviceConfigurationSectionName; + private string? _serviceName; + private HostedServiceHealthCheck? _healthCheck; + private ServiceStatus _status = ServiceStatus.Initializing; + private int _disposed; + + /// + /// Gets the argument for specifying that the should execute as a no-op (i.e. no operation); i.e. do nothing. + /// + /// This is to support the likes of testing where the underlying hosted services should not execute. + public const string NoOpArgument = "--no-op-hosted-services"; + + /// + /// Gets the to be used for all hosted services. + /// + protected static HostedServiceInvoker HostedServiceInvoker { get; } = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public HostedServiceBase(IServiceProvider serviceProvider, ILogger logger) + { + ServiceProvider = serviceProvider.ThrowIfNull(); + Logger = logger.ThrowIfNull(); + Configuration = ServiceProvider.GetService() ?? throw new InvalidOperationException($"No {nameof(IConfiguration)} available in the service provider."); + } + + /// + /// Gets the . + /// + protected IServiceProvider ServiceProvider { get; } + + /// + /// Gets the . + /// + protected IConfiguration Configuration { get; } + + /// + /// Gets the . + /// + protected ILogger Logger { get; } + + /// + /// Gets ot sets the service configuration section name (used for configuration lookup). + /// + /// Defaults to the . + public string ServiceConfigurationSectionName + { + get => _serviceConfigurationSectionName ??= GetType().Name; + set => _serviceConfigurationSectionName = SetValueWhenStatusIsInitializedOnly(value.ThrowIfEmpty()); + } + + /// + /// Gets or sets the service name (used for logging). + /// + /// Defaults to the . + public string ServiceName + { + get => _serviceName ??= GetType().Name; + set => _serviceName = SetValueWhenStatusIsInitializedOnly(value.ThrowIfEmpty()); + } + + /// + /// Gets or sets the optional to report health; where set the health status will be reported on each change. + /// + public HostedServiceHealthCheck? HealthCheck { get => _healthCheck; set => _healthCheck = SetValueWhenStatusIsInitializedOnly(value); } + + /// + /// Get or sets an optional tag that can be used to store additional information about the service instance; e.g. assigning a tenant identifier where applicable. + /// + public object? Tag { get; set; } + + /// + /// Gets the synchronization lock. + /// +#if NET9_0_OR_GREATER + protected Lock SyncLock { get; } = new(); +#else + protected object SyncLock { get; } = new(); +#endif + + /// + /// Indicates whether and are supported. + /// + public bool ArePauseAndResumeSupported { get; protected set; } = false; + + /// + /// Gets the current . + /// + /// The should always be updated within a to ensure thread safety. + public ServiceStatus Status + { + get => _status; + protected set => _status = ReportHealthStatus(value); + } + + /// + /// Reports the on status change. + /// + /// The . + /// The . + private ServiceStatus ReportHealthStatus(ServiceStatus status) + { + if (_healthCheck is not null) + { + var data = new Dictionary + { + { "service", ServiceName }, + { "status", status.ToString() } + }; + + _healthCheck.Result = OnReportHealthStatus(data); + } + + return status; + } + + /// + /// Sets the value when the is only; otherwise, throws an . + /// + /// The value . + /// The value. + /// The value to support fluent-style method-chaining. + protected T SetValueWhenStatusIsInitializedOnly(T value) + { + if (!Status.IsInitializing) + throw new InvalidOperationException($"Cannot set value when status is {Status}."); + + return value; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + if (Abstractions.Internal.TryGetConfigurationValue(NoOpArgument, out var nop, Configuration) && (nop is null || nop.Value)) + { + lock (SyncLock) + { + Status = ServiceStatus.NoOp; + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} no-op.", ServiceName); + } + + return; + } + + lock (SyncLock) + { + Status = ServiceStatus.Initializing; + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} initializing.", ServiceName); + } + + await OnInitializeAsync(cancellationToken).ConfigureAwait(false); + + lock (SyncLock) + { + Status = ServiceStatus.Starting; + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} starting.", ServiceName); + } + + var status = await OnStartAsync(cancellationToken).ConfigureAwait(false); + + lock (SyncLock) + { + if (Status == ServiceStatus.Starting) + Status = status; + + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} started.", ServiceName); + } + } + + /// + /// The hosted service is being initialized. + /// + /// The . + /// This is called prior to the and provides an opportunity to perform any initialization logic. + /// Note: where overriding invoke the base first to ensure initialization occurs in the correct sequence. Failing to invoke the base + /// will likely result in unintended side-effects/errors. + protected virtual Task OnInitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// A hosted service start has been requested. + /// + /// The . + /// The as a result of the start; typically either or . + protected abstract Task OnStartAsync(CancellationToken cancellationToken); + + /// + /// Pauses the hosted service. + /// + /// The . + /// Pausing will only be performed where the current is either or . + public async Task PauseAsync(CancellationToken cancellationToken) + { + if (!ArePauseAndResumeSupported) + throw new NotSupportedException($"{ServiceName} does not support pausing and resuming."); + + lock (SyncLock) + { + if (!Status.CanPause) + return; + + Status = ServiceStatus.Pausing; + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} pausing.", ServiceName); + } + + await OnPauseAsync(cancellationToken).ConfigureAwait(false); + + lock (SyncLock) + { + if (Status == ServiceStatus.Pausing) + { + Status = ServiceStatus.Paused; + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} paused.", ServiceName); + } + } + } + + /// + /// A hosted service pause has been requested. + /// + /// The . + /// The will automatically be set to prior to execution. It will then be automatically set to post execution + /// where the is still . + protected virtual Task OnPauseAsync(CancellationToken cancellationToken) => throw new NotImplementedException($"Where pause and resume are supported then the {nameof(OnPauseAsync)} method must be implemented."); + + /// + /// Initiates a without waiting for completion (fire-and-forget). + /// + /// Use this method only when calling from likes of API endpoints or in loops where you need an immediate return. + public void Pause() => _ = Task.Run(async () => + { + try + { + await PauseAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + if (Logger.IsEnabled(LogLevel.Error)) + Logger.LogError(ex, "{ServiceName} pause (fire-and-forget) failed: {Error}", ServiceName, ex.Message); + } + }); + + /// + /// Resumes the hosted service. + /// + /// The . + /// Resuming will only be performed where the current is . + /// The is responsible for updating the . + public async Task ResumeAsync(CancellationToken cancellationToken) + { + if (!ArePauseAndResumeSupported) + throw new NotSupportedException($"{ServiceName} does not support pausing and resuming."); + + lock (SyncLock) + { + if (!Status.CanResume) + return; + + Status = ServiceStatus.Resuming; + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} resuming.", ServiceName); + } + + await OnResumeAsync(cancellationToken).ConfigureAwait(false); + + lock (SyncLock) + { + if (Status == ServiceStatus.Resuming) + { + Status = ServiceStatus.Running; + + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} resumed.", ServiceName); + } + } + } + + /// + /// A hosted service resume has been requested. + /// + /// The . + /// The will automatically be set to prior to execution. It will then be automatically set to post execution + /// where the is still . + protected virtual Task OnResumeAsync(CancellationToken cancellationToken) => throw new NotImplementedException($"Where pause and resume are supported then the {nameof(OnResumeAsync)} method must be implemented."); + + /// + /// Initiates a without waiting for completion (fire-and-forget). + /// + /// Use this method only when calling from likes of API endpoints or in loops where you need an immediate return. + public void Resume() => _ = Task.Run(async () => + { + try + { + await ResumeAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + if (Logger.IsEnabled(LogLevel.Error)) + Logger.LogError(ex, "{ServiceName} resume (fire-and-forget) failed: {Error}", ServiceName, ex.Message); + } + }); + + /// + /// A stop will always be executed regardless of current . + public async Task StopAsync(CancellationToken cancellationToken) + { + lock (SyncLock) + { + if (Status.IsStop) + + Status = ServiceStatus.Stopping; + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} stop requested.", ServiceName); + } + + await OnStopAsync(cancellationToken); + + lock (SyncLock) + { + Status = ServiceStatus.Stopped; + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} stopped.", ServiceName); + } + } + + /// + /// A hosted service stop has been requested. + /// + /// The . + /// The will automatically be set to prior to execution. It will then be automatically set to post execution. + protected abstract Task OnStopAsync(CancellationToken cancellationToken); + + /// + /// Provides an opportunity to override the health status reporting. + /// + /// The status data. + /// The . + protected abstract HealthCheckResult OnReportHealthStatus(Dictionary data); + + /// + /// Dispose of resources. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + lock (SyncLock) + { + Status = ServiceStatus.Stopped; + } + + Dispose(true); + } + + GC.SuppressFinalize(this); + } + + /// + /// 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. + protected virtual void Dispose(bool disposing) { } +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/HostedServiceHealthCheck.cs b/src/CoreEx/Hosting/HostedServiceHealthCheck.cs new file mode 100644 index 00000000..d725b0ce --- /dev/null +++ b/src/CoreEx/Hosting/HostedServiceHealthCheck.cs @@ -0,0 +1,25 @@ +namespace CoreEx.Hosting; + +/// +/// Provides an for a reporting its . +/// +/// The indicates current state; assumes healthy where reported. +public sealed class HostedServiceHealthCheck : IHealthCheck +{ + private volatile Dictionary? _data; + + /// + /// Gets or sets the last updated health check data. + /// + /// An initial null value indicates unhealthy as no status has been reported. + public Dictionary? Data { get => _data; set => _data = value.ThrowIfNull(nameof(Data)); } + + /// + /// Gets or sets the latest to report. + /// + /// Defaults to ; no health check reported. + public HealthCheckResult Result { get; set; } = HealthCheckResult.Unhealthy("No health check reported."); + + /// + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) => Task.FromResult(Result); +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/HostedServiceInvoker.cs b/src/CoreEx/Hosting/HostedServiceInvoker.cs new file mode 100644 index 00000000..1e8747d4 --- /dev/null +++ b/src/CoreEx/Hosting/HostedServiceInvoker.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Hosting; + +/// +/// Provides the invoker. +/// +[InvokerName("CoreEx.Hosting.HostedService")] +public class HostedServiceInvoker : InvokerBase +{ + /// + /// Tracing is disabled for this invoker as the is typically used for background processing and therefore may be long-running, or high-frequency, and therefore not ideal for tracing. + public override bool IsTracingDisabled => true; +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/HostedServiceManager.cs b/src/CoreEx/Hosting/HostedServiceManager.cs new file mode 100644 index 00000000..d25f95b0 --- /dev/null +++ b/src/CoreEx/Hosting/HostedServiceManager.cs @@ -0,0 +1,117 @@ +namespace CoreEx.Hosting; + +/// +/// Provides management capabilities for instances. +/// +/// The instances must also be registered as a . +public sealed class HostedServiceManager(IServiceProvider serviceProvider) +{ + private readonly IServiceProvider _serviceProvider = serviceProvider.ThrowIfNull(); + + /// + /// Gets or sets the pre-check function that is invoked prior to any operation. + /// + /// This function can be used to perform any necessary validation, such as authorization, before operations are executed on a hosted service. The parameter is the selected + /// where specified; otherwise, represents All. + public Func> PreCheckAsync { get; set => field = value.ThrowIfNull(); } = (_, _) => Result.SuccessTask; + + /// + /// Gets the status of all registered hosted services. + /// + /// The . + /// A dictionary of hosted service and corresponding service pairs. + public Task>> GetAllStatusesAsync(CancellationToken cancellationToken = default) + => PreCheckAsync(string.Empty, cancellationToken) + .ThenAs(() => + { + var dict = new Dictionary(); + foreach (var service in GetAllHostedServices()) + { + if (!dict.TryAdd(service.ServiceName, service.Status)) + return Result.ValidationError($"The hosted service with key '{service.ServiceName}' is ambiguous; more than one exists with name."); + } + + return Result>.Ok(dict); + }); + + /// + /// Pauses all registered hosted services. + /// + /// The . + /// This is a fire-and-forget operation and each pause will occur in the background; therefore, it may immediately appear as if the status did not change. + public Task PauseAllAsync(CancellationToken cancellationToken = default) + => PreCheckAsync(string.Empty, cancellationToken) + .Then(() => + { + foreach (var service in GetAllHostedServices()) + service.Pause(); + }); + + /// + /// Resumes all registered hosted services. + /// + /// The . + /// This is a fire-and-forget operation and each resume will occur in the background; therefore, it may immediately appear as if the status did not change. + public Task ResumeAllAsync(CancellationToken cancellationToken = default) + => PreCheckAsync(string.Empty, cancellationToken) + .Then(() => + { + foreach (var service in GetAllHostedServices()) + service.Resume(); + }); + + /// + /// Gets all the registered hosted services, optionally filtered by the specified service name. + /// + private IEnumerable GetAllHostedServices(string? serviceName = null) + => _serviceProvider.GetServices().OfType().Where(s => serviceName is null || s.ServiceName == serviceName); + + /// + /// Gets the status of the specified hosted service. + /// + /// The unique hosted service key. + /// The . + /// The . + public Task> GetStatusAsync(string serviceKey, CancellationToken cancellationToken = default) + => GetHostedServiceAsync(serviceKey, cancellationToken) + .ThenAs(hs => hs.Status); + + /// + /// Initiates a pause for the specified hosted service. + /// + /// The unique hosted service key. + /// The . + /// This is a fire-and-forget operation and will occur in the background; therefore, it may immediately appear as if the status did not change. + public Task PauseAsync(string serviceKey, CancellationToken cancellationToken = default) + => GetHostedServiceAsync(serviceKey, cancellationToken) + .ThenAs(hs => hs.Pause()); + + /// + /// Initiates a resume for the specified hosted service. + /// + /// The unique hosted service key. + /// The . + /// This is a fire-and-forget operation and will occur in the background; therefore, it may immediately appear as if the status did not change. + public Task ResumeAsync(string serviceKey, CancellationToken cancellationToken = default) + => GetHostedServiceAsync(serviceKey, cancellationToken) + .ThenAs(hs => hs.Resume()); + + /// + /// Gets the for the specified . + /// + /// The unique hosted service key. + /// The . + /// The . + private Task> GetHostedServiceAsync(string serviceKey, CancellationToken cancellationToken) + => PreCheckAsync(serviceKey.ThrowIfNullOrEmpty(), cancellationToken) + .ThenAs(() => + { + var hsc = GetAllHostedServices(serviceKey).ToArray(); + return hsc.Length switch + { + 0 => Result.NotFoundError($"The hosted service with key '{serviceKey}' is not registered."), + 1 => Result.Ok(hsc[0]), + _ => Result.ValidationError($"The hosted service with key '{serviceKey}' is ambiguous; more than one exists with name.") + }; + }); +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/IHostSettings.cs b/src/CoreEx/Hosting/IHostSettings.cs new file mode 100644 index 00000000..5b990eba --- /dev/null +++ b/src/CoreEx/Hosting/IHostSettings.cs @@ -0,0 +1,29 @@ +namespace CoreEx.Hosting; + +/// +/// Enables standardized runtime/host settings. +/// +public interface IHostSettings +{ + /// + /// Gets the solution name. + /// + string SolutionName { get; } + + /// + /// Gets the domain name. + /// + string DomainName { get; } + + /// + /// Gets the environment name. + /// + /// This is automatically set by the following environment variables: COREEX_ENVIRONMENT (primary) or ASPNETCORE_ENVIRONMENT (secondary). + string EnvironmentName { get; } + + /// + /// Gets the source . + /// + /// This typically represents the base application/service URL used for the likes of event source. + Uri? Source { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/IHostStartup.cs b/src/CoreEx/Hosting/IHostStartup.cs deleted file mode 100644 index 6990b6a7..00000000 --- a/src/CoreEx/Hosting/IHostStartup.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace CoreEx.Hosting -{ - /// - /// Enables standardized host startup capabilities - /// - public interface IHostStartup - { - /// - /// Sets up the configuration for the remainder of the build process and application. - /// - /// - /// - /// This is intended to be invoked by the . - void ConfigureAppConfiguration(HostBuilderContext context, IConfigurationBuilder config); - - /// - /// Sets up the configuration for the builder itself to initialize the . - /// - /// The . - /// This is intended to be invoked by the . - void ConfigureHostConfiguration(IConfigurationBuilder config); - - /// - /// Adds services to the container. - /// - /// The . - /// This is intended to be invoked by the . - void ConfigureServices(IServiceCollection services); - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/IServiceSynchronizer.cs b/src/CoreEx/Hosting/IServiceSynchronizer.cs deleted file mode 100644 index 818791ef..00000000 --- a/src/CoreEx/Hosting/IServiceSynchronizer.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Hosting -{ - /// - /// Enables concurrency management to synchronize the underlying execution. - /// - /// The must acquire and hold a lock until the corresponding is invoked. Where a lock is unable to be acquired then a false must be returned to advise the caller - /// that processing can not occur at this time as another process is currently executing. A result of true indicates the lock was acquired and will be held until the corresponding . - public interface IServiceSynchronizer : IDisposable - { - /// - /// Acquires a lock on the specified and optional . - /// - /// true if the lock is aquired; otherwise, false. - /// The to lock. - bool Enter(string? name = null); - - /// - /// Releases the lock on the specified and optional . - /// - /// The to unlock. - void Exit(string? name = null); - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/README.md b/src/CoreEx/Hosting/README.md deleted file mode 100644 index 8862ec52..00000000 --- a/src/CoreEx/Hosting/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# CoreEx.Hosting - -The `CoreEx.Hosting` namespace provides additional [hosted service (worker)](https://learn.microsoft.com/en-us/dotnet/core/extensions/workers) runtime capabilities. - -
- -## Motivation - -To enable improved hosted service consistency and testability, plus additional [`IHostedService`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.hosting.ihostedservice) runtime capabilities. - -
- -## Host startup - -To improve consistency and testability the [`IHostStartup`](./IHostStartup.cs) and [`HostStartup`](./HostStartup) implementations are provided. By seperating out the key [Dependency Injection (DI)](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection) configuration from the underlying host configuration enables the DI configuration to be tested in isolation against a _test-host_ where applicable. - -The following is an example of a `HostStartup` implementation. - -```csharp -public class Startup : HostStartup -{ - public override void ConfigureAppConfiguration(HostBuilderContext context, IConfigurationBuilder config) - { - config.AddEnvironmentVariables("Prefix_"); - } - - /// - public override void ConfigureServices(IServiceCollection services) - { - services - .AddSettings() - .AddExecutionContext() - .AddJsonSerializer(); - } -} -``` - -The following is an example of a `Program` implementation that initiates a host and uses the [`ConfigureHostStartup`](HostStartupExtensions.cs) extension method to integrate the `Startup` functionality. This has an added advantage of being able to add specific startup capabilities directly to a host that should not be available to the _test-host_ (as demonstrated by `ConfigureFunctionsWorkerDefaults`). - -```csharp -new HostBuilder() - .ConfigureFunctionsWorkerDefaults() - .ConfigureHostStartup() - .Build().Run(); -``` - -
- -## Hosted services - -The following additional [`IHostedService`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.hosting.ihostedservice) implementations are provided. - -Class | Description --|- -[`TimerHostedServiceBase`](./TimerHostedServiceBase.cs) | Provides an `IHostedService` implementation that performs _work_ at a specified `Interval`. -[`SynchronizedTimerHostedServiceBase`](./SynchronizedTimerHostedServiceBase.cs) | Extends `TimerHostedServiceBase` adding [concurrency synchronization](#Concurrency-synchronization) to ensure only a single host can perform _work_ at a time (synchronously). - -
- -## Concurrency synchronization - -To ensure only a single host can perform _work_ at a time concurrency implementation is required; this is enabled by implementing the `Enter` and `Exit` methods defined by the [`IServiceSynchronizer`](./IServiceSynchronizer.cs) interface. The following implementations are provided. - -Class | Description --|- -[`ConcurrentSynchronizer`](./ConcurrentSynchronizer.cs) | Performs _no_ synchronization in that `Enter` will always return `true` resulting in concurrent execution. -[`FileLockSynchronizer`](./FileLockSynchronizer.cs) | Performs synchronization by taking an exclusive lock on a file. \ No newline at end of file diff --git a/src/CoreEx/Hosting/ServiceBase.cs b/src/CoreEx/Hosting/ServiceBase.cs deleted file mode 100644 index c69281d3..00000000 --- a/src/CoreEx/Hosting/ServiceBase.cs +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Hosting -{ - /// - /// Represents the base class for a self-orchestrated service to for a specified . - /// - public abstract class ServiceBase - { - private string? _name; - private int? _maxIterations; - - /// - /// The configuration settings name for . - /// - public const string MaxIterationsName = nameof(MaxIterations); - - /// - /// Gets or sets the default used where the specified is less than or equal to zero. Defaults to one iteration. - /// - public static int DefaultMaxIterations { get; set; } = 1; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// The ; defaults to instance from the where not specified. - public ServiceBase(IServiceProvider serviceProvider, ILogger logger, SettingsBase? settings = null) - { - ServiceProvider = serviceProvider.ThrowIfNull(nameof(serviceProvider)); - Logger = logger.ThrowIfNull(nameof(logger)); - Settings = settings ?? ServiceProvider.GetService() ?? new DefaultSettings(ServiceProvider.GetRequiredService()); - } - - /// - /// Gets the . - /// - protected IServiceProvider ServiceProvider; - - /// - /// Gets the . - /// - protected SettingsBase Settings { get; } - - /// - /// Gets the . - /// - protected ILogger Logger { get; } - - /// - /// Gets the service name (used for the likes of configuration and logging). - /// - /// Defaults to the . - public virtual string ServiceName => _name ??= GetType().Name; - - /// - /// Gets or sets the maximum number of iterations per execution. - /// - public virtual int MaxIterations - { - get => _maxIterations ?? DefaultMaxIterations; - set => _maxIterations = value <= 0 ? DefaultMaxIterations : value; - } - - /// - /// up to the specified number of . - /// - /// The . - /// Each invocation of the will be managed within the context of a new Dependency Injection (DI) scope that is passed for direct usage. - public async Task ExecuteAsync(CancellationToken cancellationToken) - { - for (int i = 0; i < MaxIterations; i++) - { - if (cancellationToken.IsCancellationRequested) - return; - - await ServiceInvoker.Current.InvokeAsync(this, async (_, cancellationToken) => - { - // Create a scope in which to perform the execution. - using var scope = ServiceProvider.CreateScope(); - ExecutionContext.Reset(); - - try - { - if (!await ExecuteAsync(scope.ServiceProvider, cancellationToken).ConfigureAwait(false)) - return; - } - catch (Exception ex) - { - if (ex is TaskCanceledException || (ex is AggregateException aex && aex.InnerException is TaskCanceledException)) - return; - - Logger.LogCritical(ex, "{ServiceName} failure as a result of an unexpected exception: {Error}", ServiceName, ex.Message); - throw; - } - }, cancellationToken).ConfigureAwait(false); - } - } - - /// - /// Invoked to perform the per-iteration work. - /// - /// The scoped . - /// The . - /// true indicates to execute the next iteration (i.e. continue); otherwise, false to stop. - /// Each invocation of the will be managed within the context of a new Dependency Injection (DI) scope that is passed for direct usage. - protected abstract Task ExecuteAsync(IServiceProvider scopedServiceProvider, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/ServiceInvoker.cs b/src/CoreEx/Hosting/ServiceInvoker.cs deleted file mode 100644 index 0a71520f..00000000 --- a/src/CoreEx/Hosting/ServiceInvoker.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; - -namespace CoreEx.Hosting -{ - /// - /// Provides the invocation wrapping for the service instances. - /// - public class ServiceInvoker : InvokerBase - { - private static ServiceInvoker? _default; - - /// - /// Gets the current configured instance (see ). - /// - public static ServiceInvoker Current => CoreEx.ExecutionContext.GetService() ?? (_default ??= new ServiceInvoker()); - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/ServiceStatus.cs b/src/CoreEx/Hosting/ServiceStatus.cs new file mode 100644 index 00000000..61011305 --- /dev/null +++ b/src/CoreEx/Hosting/ServiceStatus.cs @@ -0,0 +1,58 @@ +namespace CoreEx.Hosting; + +/// +/// Represents the status of a service; for example, and . +/// +public enum ServiceStatus +{ + /// + /// Initializing, but not started. + /// + Initializing, + + /// + /// No-op; i.e. the service has been explicitly requested to not perform any work. + /// + NoOp, + + /// + /// Starting; i.e. the start has been initiated. + /// + Starting, + + /// + /// Sleeping; the service is in between executions. + /// + Sleeping, + + /// + /// Running; the service is executing work. + /// + Running, + + /// + /// Pausing; i.e. the pause has been initiated. + /// + Pausing, + + /// + /// Paused; the service is paused. + /// + Paused, + + /// + /// Resuming; i.e. the resume has been initiated. + /// + /// There is no Resumed status; should return to either or . + Resuming, + + /// + /// Stopping; i.e. the stop has been initiated. + /// + Stopping, + + /// + /// Stopped; the service is stopped. + /// + Stopped +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Synchronization/HybridCacheSynchronizer.cs b/src/CoreEx/Hosting/Synchronization/HybridCacheSynchronizer.cs new file mode 100644 index 00000000..c88d9cc0 --- /dev/null +++ b/src/CoreEx/Hosting/Synchronization/HybridCacheSynchronizer.cs @@ -0,0 +1,81 @@ +namespace CoreEx.Hosting.Synchronization; + +/// +/// Provides an -based . +/// +/// The . +public sealed class HybridCacheSynchronizer(IHybridCache cache) : ISynchronizer +{ + private readonly IHybridCache _cache = cache.ThrowIfNull(); + private readonly ConcurrentDictionary _options = new(); + + /// + /// Gets or sets the optional (default) . + /// + public HybridCacheEntryOptions? Options { get; set; } + + /// + /// Indicates whether instrumentation is enabled. + /// + /// Default is . + /// A synchronizer is likely to be used frequently and as such cause instrumentation noise; therefore, is disabled by default. + public bool IsInstrumentationEnabled { get; set; } = false; + + /// + public async Task EnterAsync(string? name = null, CancellationToken cancellationToken = default) + { + var key = GetFullName(name); + var uid = Runtime.NewId(); + var opt = Options ?? HybridCacheEntryOptions.CreateFor(); + + // Copy the options and add a tag with the unique identifier. + opt = (opt with { }).WithTags(uid); + + using (SuppressInstrumentationScope.Begin(!IsInstrumentationEnabled)) + { + // Attempt to add the value; if it already exists, then another process has entered. + var cuid = await _cache.GetOrCreateByKeyAsync(key, _ => + { + _options.TryAdd(key, (opt, uid)); + return Task.FromResult(uid); + }, opt, cancellationToken); + + return cuid == uid; + } + } + + /// + public async Task ExitAsync(string? name = null, CancellationToken cancellationToken = default) + { + var key = GetFullName(name); + if (_options.TryRemove(key, out var val)) + { + using (SuppressInstrumentationScope.Begin(!IsInstrumentationEnabled)) + { + // Remove the cache entry by the unique tag. If another process has entered or entry has expired, there is nothing we can do about it at this point. + await _cache.RemoveByTagAsync(val.UId, val.Options, cancellationToken); + } + + return; + } + + throw new InvalidOperationException($"The synchronizer for '{key}' has not been entered by this process."); + } + + /// + public async ValueTask DisposeAsync() + { + // Clean-up any remaining owned locks using the uid; there should be none where all have been properly exited. + foreach (var entry in _options) + { + await _cache.RemoveByTagAsync(entry.Value.UId, entry.Value.Options); + } + + _options.Clear(); + } + + /// + /// Gets the full name. + /// + private static string GetFullName(string? name) => $"{typeof(T).Name}{(name == null ? "" : $":{name}")}"; +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Synchronization/ISynchronizer.cs b/src/CoreEx/Hosting/Synchronization/ISynchronizer.cs new file mode 100644 index 00000000..ab565bca --- /dev/null +++ b/src/CoreEx/Hosting/Synchronization/ISynchronizer.cs @@ -0,0 +1,26 @@ +namespace CoreEx.Hosting.Synchronization; + +/// +/// Enables concurrency management to synchronize the underlying execution. +/// +/// The must acquire and hold a lock until the corresponding is invoked. Where a lock is unable to be acquired then a must be returned to advise the caller +/// that processing can not occur at this time as another process is currently executing. A result of indicates the lock was acquired and will be held until the corresponding . +public interface ISynchronizer : IAsyncDisposable +{ + /// + /// Acquires a lock on the specified and optional . + /// + /// The to lock. + /// The optional name to differentiate the lock. + /// The . + /// where the lock is aquired; otherwise,. + Task EnterAsync(string? name = null, CancellationToken cancellationToken = default); + + /// + /// Releases the lock on the specified and optional . + /// + /// The to unlock. + /// The optional name to differentiate the lock. + /// The . + Task ExitAsync(string? name = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/SynchronizedTimerHostedServiceBase.cs b/src/CoreEx/Hosting/SynchronizedTimerHostedServiceBase.cs index 40e34fe9..6f9dae5a 100644 --- a/src/CoreEx/Hosting/SynchronizedTimerHostedServiceBase.cs +++ b/src/CoreEx/Hosting/SynchronizedTimerHostedServiceBase.cs @@ -1,68 +1,52 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Hosting; -using CoreEx.Configuration; -using CoreEx.Hosting.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Hosting +/// +/// Extends the and adds support to the to manage synchronized concurrency of execution. +/// +/// The . +/// The implementing (self) ; also used for synchronization. +/// The . +/// The . +/// Each timer-based invocation of the will be managed within the context of a new Dependency Injection (DI) +/// scope and . As the may be scoped, it will be automatically resolved from the scoped before use. +/// A is provided to enable a one-off change to the timer where required. +public abstract class SynchronizedTimerHostedServiceBase(IServiceProvider serviceProvider, ILogger logger) + : TimerHostedServiceBase(serviceProvider, logger) where TSynchronizer : class, ISynchronizer where TSelf : SynchronizedTimerHostedServiceBase { /// - /// Extends the and adds to the to manage concurrency of execution. + /// Gets or sets the optional name to differentiate the synchronization lock. /// - /// The in which to perform the for. - /// The . - /// The . - /// The ; defaults to instance from the where not specified. - /// The ; defaults to where not specified. - /// The optional to report health. - public abstract class SynchronizedTimerHostedServiceBase(IServiceProvider serviceProvider, ILogger logger, SettingsBase? settings = null, IServiceSynchronizer? synchronizer = null, TimerHostedServiceHealthCheck? healthCheck = null) - : TimerHostedServiceBase(serviceProvider, logger, settings, healthCheck) + protected string? SynchronizerName { get; set; } + + /// + protected sealed override async Task OnExecuteAsync(ExecutionContext executionContext, CancellationToken cancellationToken) { - /// - /// Gets the . - /// - protected IServiceSynchronizer Synchronizer { get; } = synchronizer ?? new ConcurrentSynchronizer(); + var synchronizer = ExecutionContext.GetRequiredService(); - /// - /// Gets or sets the optional synchronization name (used by and ). - /// - protected string? SynchronizationName { get; set; } + // Attempt to enter the synchronizer; if not successful, simply exit. + var entered = await synchronizer.EnterAsync(SynchronizerName, cancellationToken).ConfigureAwait(false); + if (!entered) + return false; - /// - /// Triggered to perform the work as a result of the is a synchronized manner. - /// - /// The scoped . - /// The . - /// Note: do not override this method as this implements the sychronization management; use to implement desired functionality. - /// Each timer-based invocation of the will be managed within the context of a new Dependency Injection (DI) - /// scope that is passed for direct usage. - protected async override Task ExecuteAsync(IServiceProvider scopedServiceProvider, CancellationToken cancellationToken) + // Execute within the synchronizer; and exit once complete (regardless of outcome). + try { - // Ensure we have synchronized control; if not exit immediately. - if (!Synchronizer.Enter(SynchronizationName)) - return; - - try - { - await SynchronizedExecuteAsync(scopedServiceProvider, cancellationToken).ConfigureAwait(false); - } - finally - { - Synchronizer.Exit(SynchronizationName); - } + return await SynchronizedExecuteAsync(executionContext, cancellationToken).ConfigureAwait(false); + } + finally + { + await synchronizer.ExitAsync(SynchronizerName, cancellationToken).ConfigureAwait(false); } - - /// - /// Triggered to perform the work as a result of the with the context of a and . - /// - /// The scoped . - /// The . - /// Each timer-based invocation of the will be managed within the context of a new Dependency Injection (DI) - /// scope that is passed for direct usage. - protected abstract Task SynchronizedExecuteAsync(IServiceProvider scopedServiceProvider, CancellationToken cancellationToken); } + + /// + /// Triggered to perform the work as a result of the within a scoped and synchronized via the . + /// + /// The . + /// The . + /// indicates that the should be re-executed immediately (without an interval); otherwise, + /// to re-execute after the configured . + /// Each timer-based invocation of the will be managed within the context of a new Dependency Injection (DI) + /// scope and corresponding . + protected abstract Task SynchronizedExecuteAsync(ExecutionContext executionContext, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/CoreEx/Hosting/TimerHostedServiceBase.cs b/src/CoreEx/Hosting/TimerHostedServiceBase.cs index 33848147..f409c6e7 100644 --- a/src/CoreEx/Hosting/TimerHostedServiceBase.cs +++ b/src/CoreEx/Hosting/TimerHostedServiceBase.cs @@ -1,343 +1,519 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using CoreEx.Hosting.HealthChecks; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Hosting +namespace CoreEx.Hosting; + +/// +/// Represents a base class for an based on an to work. +/// +/// Each timer-based invocation of the will be managed within the context of a new Dependency Injection (DI) +/// scope. +/// A is provided to enable a one-off change to the timer where required. +public abstract class TimerHostedServiceBase : HostedServiceBase { + private readonly SemaphoreSlim _signal = new(0); + private Task? _backgroundTask; + private CancellationTokenSource? _backgroundCts; + private CancellationTokenSource? _delayCts; + private TimeSpan? _oneOffInterval; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public TimerHostedServiceBase(IServiceProvider serviceProvider, ILogger logger) : base(serviceProvider, logger) => ArePauseAndResumeSupported = true; + + /// + /// Gets or sets the action to configure the prior to executing the method. + /// + public Action? ExecutionContextConfigure { get; set; } + + /// + /// Gets or sets the first timer start interval. + /// + /// Defaults to . This is used as a maximum, in that the actual start is determined using a random value up to this value to ensure staggering of execution where multiple hosts are triggered at the same time. + public TimeSpan FirstInterval { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } + + /// + /// Gets or sets the timer interval . + /// + /// Defaults to 500 milliseconds. + public TimeSpan Interval { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } = TimeSpan.FromMilliseconds(500); + + /// + /// Gets or sets the timer start interval after an unhandled that occurs during the execution where is . + /// + /// Defaults to . + public TimeSpan OnUnhandledInterval { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } + + /// + /// Indicates whether to automatically halt the service on an unhandled that occurs during the execution of the method. + /// + /// indicates that the service should be ; otherwise, indicates to continue executing after the next interval. + /// Defaults to . + public bool PauseOnUnhandledException { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } = true; + + /// + /// Gets or sets the maximum number of consecutive immediate executions (i.e. without an interval) before forcing a sleep interval. + /// + /// Defaults to 100. This is a safety mechanism to prevent runaway execution where the method continually returns indicating to execute immediately without an interval. + public int MaxConsecutiveExecutions { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value.ThrowIfLessThanOrEqualToZero()); } = 100; + + /// + /// Gets the last execution . + /// + public DateTimeOffset LastExecuted { get; protected set; } = DateTimeOffset.MinValue; + + /// + /// Gets the last execution ; indicates success. + /// + public Exception? LastException { get; protected set; } + /// - /// Represents a base class for an based on an to work. + /// Provides an opportunity to explicitly handle an unhandled that occurs during the execution of the method. /// - /// Each timer-based invocation of the will be managed witin the context of a new Dependency Injection (DI) - /// scope. - /// A is provided to enable a one-off change to the timer where required. - public abstract class TimerHostedServiceBase : IHostedService, IDisposable + /// The unhandled . + /// indicates that the service should be ; otherwise, indicates to continue executing after the next interval. + /// The is automatically logged prior to invoking this method. + /// The default result is the . + /// Also, consider using the to adjust the next interval where applicable. + protected virtual bool OnUnhandledException(Exception exception) => PauseOnUnhandledException; + + /// + protected async override Task OnInitializeAsync(CancellationToken cancellationToken) + { + // Execute the base initialization to ensure potential dependencies are available. + await base.OnInitializeAsync(cancellationToken).ConfigureAwait(false); + + // Helper to get the default-value where the provided value is not valid (i.e. less than or equal to zero); otherwise, returns the provided value. + static TimeSpan GetDefault(TimeSpan value, TimeSpan defaultValue) => value <= TimeSpan.Zero ? defaultValue : value; + + // Get the configuration-based settings. + Interval = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:Interval", "CoreEx:Host:Services:Interval", Interval, Configuration).ThrowWhen(interval => interval <= TimeSpan.Zero, nameof(Interval), "Interval must be positive."); + FirstInterval = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:FirstInterval", "CoreEx:Host:Services:FirstInterval", GetDefault(FirstInterval, Interval), Configuration).ThrowWhen(firstInterval => firstInterval <= TimeSpan.Zero, nameof(FirstInterval), "FirstInterval must be positive."); + OnUnhandledInterval = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OnUnhandledInterval", "CoreEx:Host:Services:OnUnhandledInterval", GetDefault(OnUnhandledInterval, Interval), Configuration).ThrowWhen(onUnhandledInterval => onUnhandledInterval <= TimeSpan.Zero, nameof(OnUnhandledInterval), "OnUnhandledInterval must be positive."); + PauseOnUnhandledException = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:PauseOnUnhandledException", "CoreEx:Host:Services:PauseOnUnhandledException", PauseOnUnhandledException, Configuration); + MaxConsecutiveExecutions = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:MaxConsecutiveExecutions", "CoreEx:Host:Services:MaxConsecutiveExecutions", MaxConsecutiveExecutions, Configuration).ThrowWhen(max => max <= 0, nameof(MaxConsecutiveExecutions), "MaxConsecutiveExecutions must be positive."); + + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} settings: Interval={Interval}, FirstInterval={FirstInterval}, OnUnhandledInterval={OnUnhandledInterval}, PauseOnUnhandledException={PauseOnUnhandledException}, MaxConsecutiveExecutions={MaxConsecutiveExecutions}", + ServiceName, Interval, FirstInterval, OnUnhandledInterval, PauseOnUnhandledException, MaxConsecutiveExecutions); + } + + /// + protected sealed override async Task OnStartAsync(CancellationToken cancellationToken) { - private static readonly Random _random = new(); - -#if NET9_0_OR_GREATER - private readonly System.Threading.Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - private readonly TimerHostedServiceHealthCheck? _healthCheck; - private TimerHostedServiceStatus _status = TimerHostedServiceStatus.Initialized; - private string? _name; - private CancellationTokenSource? _cts; - private Timer? _timer; - private TimeSpan? _oneOffInterval; - private Task? _executeTask; - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// The ; defaults to instance from the where not specified. - /// The optional to report health. - public TimerHostedServiceBase(IServiceProvider serviceProvider, ILogger logger, SettingsBase? settings = null, TimerHostedServiceHealthCheck? healthCheck = null) + if (Logger.IsEnabled(LogLevel.Debug)) + Logger.LogDebug("{ServiceName} starting. Timer first/interval {FirstInterval}/{Interval}.", ServiceName, FirstInterval, Interval); + + _backgroundCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + await OnStartingAsync(_backgroundCts.Token).ConfigureAwait(false); + + // Start the background loop. + _backgroundTask = Task.Run(async () => { - ServiceProvider = serviceProvider.ThrowIfNull(nameof(serviceProvider)); - Logger = logger.ThrowIfNull(nameof(logger)); - Settings = settings ?? ServiceProvider.GetService() ?? new DefaultSettings(ServiceProvider.GetRequiredService()); - _healthCheck = healthCheck; - } + try + { + await RunPeriodicAsync(_backgroundCts.Token).ConfigureAwait(false); + } + catch (Exception ex) when (!ex.IsCanceled()) + { + if (Logger.IsEnabled(LogLevel.Critical)) + Logger.LogCritical(ex, "{ServiceName} background task failed unexpectedly.", ServiceName); + } + }, _backgroundCts.Token); + + // And, ... sleep. + return ServiceStatus.Sleeping; + } + + /// + /// Triggered when the is starting (prior to initiating the timer). + /// + /// The . + protected virtual Task OnStartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Performs the asynchronous background loop. + /// + private async Task RunPeriodicAsync(CancellationToken cancellationToken) + { + // Initial staggered (pseudo jitter) delay. + var maxMs = Math.Max(1, (int)Math.Min(FirstInterval.TotalMilliseconds, (double)int.MaxValue)); + var nextInterval = TimeSpan.FromMilliseconds(Random.Shared.Next(1, maxMs + 1)); - /// - /// Gets the . - /// - protected IServiceProvider ServiceProvider; - - /// - /// Gets the . - /// - protected SettingsBase Settings { get; } - - /// - /// Gets the . - /// - protected ILogger Logger { get; } - - /// - /// Gets the service name (used for the likes of configuration and logging). - /// - /// Defaults to the . - public virtual string ServiceName => _name ??= GetType().Name; - - /// - /// Gets or sets the first timer start interval. - /// - /// Defaults to . This is used as a maximum, in that the actual start is determined using a random value up to this value to ensure staggering of execution where multiple hosts are triggered at the same time. - public virtual TimeSpan? FirstInterval { get; set; } - - /// - /// Gets or sets the timer interval . - /// - /// Defaults to one hour. - public virtual TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(60); - - /// - /// Gets the current . - /// - public TimerHostedServiceStatus Status + // Loop-de-loop until cancellation is requested. + while (!cancellationToken.IsCancellationRequested) { - get => _status; - private set => _status = ReportHealthStatus(value); + // Wait for either the interval to expire or an explicit signal. + try + { + if (nextInterval == Timeout.InfiniteTimeSpan) + await _signal.WaitAsync(cancellationToken).ConfigureAwait(false); + else + { + _delayCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var delayTask = Task.Delay(nextInterval, _delayCts.Token); + var signalTask = _signal.WaitAsync(cancellationToken); + await Task.WhenAny(delayTask, signalTask).ConfigureAwait(false); + _delayCts?.Dispose(); + _delayCts = null; + } + } + catch (Exception ex) when (ex.IsCanceled()) { break; } + + lock (SyncLock) + { + // Where not currently sleeping, then we need to wait for a status change. + if (!Status.IsAsleep) + { + nextInterval = Timeout.InfiniteTimeSpan; + continue; + } + + LastException = null; + Status = ServiceStatus.Running; + + if (Logger.IsEnabled(LogLevel.Debug)) + Logger.LogDebug("{ServiceName} execution triggered.", ServiceName); + } + + // Do the actual work! + try + { + await ScopedExecuteAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (ex.IsCanceled()) + break; + + ExceptionHandling(ex); + } + + // Confirm the status and determine next interval. + if (!ManageStatusAndGetNextInterval(out nextInterval)) + break; } + } - /// - /// Gets the last execution . - /// - public DateTime LastExecuted { get; private set; } = DateTime.MinValue; - - /// - /// Gets the last execution ; null indicates success. - /// - public Exception? LastException { get; private set; } - - /// - /// Indicates whether to bubble exceptions from the method. - /// - /// The default of false indicates that any is to log and then swallowed; i.e. will continue and re-execute on next timer. - public bool BubbleExceptionsFromExecuteAsync { get; protected set; } - - /// - /// Provides an opportunity to make a one-off change to the underlying timer to trigger using the specified . - /// - /// The one-off interval. - /// Indicates whether to not adjust the time where the time remaining is less than the one-off interval specified. - /// A negative will have no effect. - protected void OneOffIntervalAdjust(TimeSpan oneOffInterval, bool leaveWhereTimeRemainingIsLess = false) + /// + /// Orchestrates the scoped execution. + /// + /// The . + /// The that represents the long running operation. + private async Task ScopedExecuteAsync(CancellationToken cancellationToken) + { + var immediate = false; + int consecutiveExecutionCount = 0; + + do { - if (oneOffInterval < TimeSpan.Zero) - return; + // Where the cancellation token is requested, exit the loop and stop processing. + if (cancellationToken.IsCancellationRequested) + break; + + // Manage the status and confirm can keep running. + if (!ManageStatusAndGetNextInterval(out _, checkCanKeepRunningStatus: true)) + break; + + // Create a scope in which to perform the execution. + await using var scope = ServiceProvider.CreateAsyncScope(); + + // Instantiate and configure the execution context. + var ec = scope.ServiceProvider.GetRequiredService(); + ExecutionContextConfigure?.Invoke(ec); - lock (_lock) + await HostedServiceInvoker.InvokeAsync(this, async (_, cancellationToken) => { - if (Status == TimerHostedServiceStatus.Stopping || Status == TimerHostedServiceStatus.Stopped) - return; + // Enable logging scope data. + var sd = new Dictionary(); + AddLoggingScope(sd); - // Where already executing save the one-off value and use when ready. - if (_executeTask != null) - _oneOffInterval = oneOffInterval; - else + using (Logger.BeginScope(sd)) { - _oneOffInterval = null; - - // Where less time remaining than specified and requested to leave then do nothing. - if (leaveWhereTimeRemainingIsLess && (DateTime.UtcNow - LastExecuted) < oneOffInterval) - return; + // Execute the work! + try + { + immediate = await OnExecuteAsync(ec, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (ex.IsCanceled()) + return; - _timer?.Change(oneOffInterval, oneOffInterval); + immediate = false; + ExceptionHandling(ex); + } } + }, cancellationToken).ConfigureAwait(false); + + if (immediate && ++consecutiveExecutionCount > MaxConsecutiveExecutions) + { + immediate = false; + if (Logger.IsEnabled(LogLevel.Warning)) + Logger.LogWarning("{ServiceName} exceeded {Max} consecutive executions, forcing sleep.", ServiceName, MaxConsecutiveExecutions); } } + while (immediate); + } - /// - /// Provides an opportunity to explicitly trigger the service execution versus waiting for the next scheduled interval. - /// - /// The one-off interval before triggering; defaults to null which represents an immediate trigger. - /// Indicates whether to not adjust the time where the time remaining is less than the one-off interval specified. - /// Invokes the . - public void OneOffTrigger(TimeSpan? oneOffInterval = null, bool leaveWhereTimeRemainingIsLess = true) => OneOffIntervalAdjust(oneOffInterval ?? TimeSpan.Zero, leaveWhereTimeRemainingIsLess); - - /// - /// Triggered when the application host is ready to start the service. - /// - /// The . - async Task IHostedService.StartAsync(CancellationToken cancellationToken) + /// + /// Provides the common/standardized exception handling. + /// + private void ExceptionHandling(Exception ex) + { + lock (SyncLock) { - Status = TimerHostedServiceStatus.Starting; - Logger.LogInformation("{ServiceName} started. Timer first/interval {FirstInterval}/{Interval}.", ServiceName, FirstInterval ?? Interval, Interval); - _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - await StartingAsync(_cts.Token).ConfigureAwait(false); - _timer = new Timer(Execute, null, TimeSpan.FromMilliseconds(_random.Next(1, (int)(FirstInterval ?? Interval).TotalMilliseconds)), Interval); - } + LastException = ex; - /// - /// Triggered when the is starting (prior to initiating the timer). - /// - /// The . - protected virtual Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + // Stop where requested; otherwise, swallow and continue. + if (OnUnhandledException(ex)) + { + OneOffIntervalAdjustInternal(Timeout.InfiniteTimeSpan); + if (Logger.IsEnabled(LogLevel.Critical)) + Logger.LogCritical(ex, "{ServiceName} pausing due to failure: {Error}", ServiceName, ex.Message); + } + else + { + OneOffIntervalAdjustInternal(OnUnhandledInterval); + if (Logger.IsEnabled(LogLevel.Error)) + Logger.LogError(ex, "{ServiceName} failure; continuation in {NextInterval}: {Error}", ServiceName, _oneOffInterval ?? Interval, ex.Message); + } + } + } - /// - /// Performs the internal execution. - /// - private void Execute(object? state) + /// + /// Manage the status and report accordingly and advise whether can continue. + /// + private bool ManageStatusAndGetNextInterval(out TimeSpan nextInterval, bool checkCanKeepRunningStatus = false) + { + lock (SyncLock) { - // Stop the timer as no more work should be initiated until after complete. - lock (_lock) + if (Status.IsStop) { - _timer!.Change(Timeout.Infinite, Timeout.Infinite); - Status = TimerHostedServiceStatus.Running; - LastException = null; - Logger.LogDebug("{ServiceName} execution triggered by timer.", ServiceName); - - _executeTask = Task.Run(async () => await ScopedExecuteAsync(_cts!.Token).ConfigureAwait(false)); + nextInterval = Timeout.InfiniteTimeSpan; + return false; } - _executeTask.Wait(); + var interval = _oneOffInterval ?? Interval; + _oneOffInterval = null; + + if (interval == Timeout.InfiniteTimeSpan) + { + if (Status != ServiceStatus.Paused) + { + Status = ServiceStatus.Paused; + if (LastException is null && Logger.IsEnabled(LogLevel.Warning)) + Logger.LogWarning("{ServiceName} execution completed. Paused with no scheduled continuation.", ServiceName); + } - // Restart the timer. - lock (_lock) + nextInterval = Timeout.InfiniteTimeSpan; + } + else { - Status = TimerHostedServiceStatus.Sleeping; - _executeTask = null; - LastExecuted = DateTime.UtcNow; + if (!Status.IsAsleep) + { + LastExecuted = DateTimeOffset.UtcNow; - if (_cts!.IsCancellationRequested) - return; + if (Status.IsRunning && checkCanKeepRunningStatus) + { + Status = ServiceStatus.Running; // Forces report of health. + nextInterval = interval; + return true; + } - var interval = _oneOffInterval ?? Interval; - _oneOffInterval = null; + Status = ServiceStatus.Sleeping; + if (Logger.IsEnabled(LogLevel.Debug)) + Logger.LogDebug("{ServiceName} execution completed. Sleeping with continuation in {Interval}.", ServiceName, interval); - Logger.LogDebug("{ServiceName} execution completed. Retry in {interval}.", ServiceName, Interval); - _timer?.Change(interval, interval); + nextInterval = interval; + } + else + { + nextInterval = interval; + } } + + return true; } + } - /// - /// Orchestrates the scoped execution. - /// - /// The . - /// The that represents the long running operation. - private Task ScopedExecuteAsync(CancellationToken cancellationToken) => ServiceInvoker.Current.InvokeAsync(this, async (_, cancellationToken) => + /// + /// Triggered to perform the work within a scoped . + /// + /// The . + /// The . + /// indicates that the should be re-executed immediately (without an interval); otherwise, + /// to re-execute after the configured . + /// Each timer-based invocation of the will be managed within the context of a new Dependency Injection (DI) + /// scope and corresponding . + protected abstract Task OnExecuteAsync(ExecutionContext executionContext, CancellationToken cancellationToken); + + /// + protected override Task OnPauseAsync(CancellationToken cancellationToken) + { + lock (SyncLock) { - // Create a scope in which to perform the execution. - using var scope = ServiceProvider.CreateScope(); - ExecutionContext.Reset(); + _oneOffInterval = Timeout.InfiniteTimeSpan; + SignalWakeUp(); + } - try - { - await ExecuteAsync(scope.ServiceProvider, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) + return Task.CompletedTask; + } + + /// + protected override Task OnResumeAsync(CancellationToken cancellationToken) + { + lock (SyncLock) + { + if (Status == ServiceStatus.Resuming) { - if (ex is TaskCanceledException || (ex is AggregateException aex && aex.InnerException is TaskCanceledException)) - return; + _oneOffInterval = Interval; + Status = ServiceStatus.Sleeping; + LastException = null; - LastException = ex; - Logger.LogCritical(ex, "{ServiceName} failure as a result of an unexpected exception: {Error}", ServiceName, ex.Message); + if (Logger.IsEnabled(LogLevel.Information)) + Logger.LogInformation("{ServiceName} resumed. Status reset to Sleeping.", ServiceName); - // Only bubble where asked to do so; otherwise, swallow and continue. - if (BubbleExceptionsFromExecuteAsync) - throw; + SignalWakeUp(); } - }, cancellationToken); - - /// - /// Triggered to perform the work as a result of the . - /// - /// The scoped . - /// The . - /// Each timer-based invocation of the will be managed within the context of a new Dependency Injection (DI) - /// scope that is passed for direct usage. - protected abstract Task ExecuteAsync(IServiceProvider scopedServiceProvider, CancellationToken cancellationToken); - - /// - /// Triggered when the application host is performing a graceful shutdown. - /// - /// The - async Task IHostedService.StopAsync(CancellationToken cancellationToken) - { - Status = TimerHostedServiceStatus.Stopping; - Logger.LogInformation("{ServiceName} stop requested.", ServiceName); - _timer!.Change(Timeout.Infinite, Timeout.Infinite); + } + + return Task.CompletedTask; + } + /// + /// Triggered when the application host is performing a graceful shutdown. + /// + /// The + protected sealed override async Task OnStopAsync(CancellationToken cancellationToken) + { + try + { + _delayCts?.Cancel(); + _backgroundCts?.Cancel(); + } + finally + { try { - _cts!.Cancel(); + if (_backgroundTask != null) + await _backgroundTask.WaitAsync(cancellationToken).ConfigureAwait(false); } - finally + catch (Exception ex) when (ex.IsCanceled()) { - await Task.WhenAny(_executeTask ?? Task.CompletedTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false); + // Graceful shutdown timeout exceeded - this is acceptable. } + } - await StoppingAsync(cancellationToken).ConfigureAwait(false); + await OnStoppingAsync(cancellationToken).ConfigureAwait(false); + } - Status = TimerHostedServiceStatus.Stopped; - Logger.LogInformation("{ServiceName} stopped.", ServiceName); - } + /// + /// Triggered when the is stopping (after has stopped). + /// + /// The . + protected virtual Task OnStoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - /// - /// Triggered when the is stopping (after has stopped). - /// - /// The . - protected virtual Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - /// - /// Reports the on status change. - /// - /// The . - /// The . - private TimerHostedServiceStatus ReportHealthStatus(TimerHostedServiceStatus status) - { - if (_healthCheck is not null) - { - var data = new Dictionary - { - { "service", ServiceName }, - { "status", status.ToString() }, - { "lastExecuted", LastExecuted }, - { "interval", Interval.ToString() }, - { "firstInterval", FirstInterval?.ToString() ?? Interval.ToString() } - }; - - _healthCheck.Result = OnReportHealthStatus(data); - } + /// + protected override HealthCheckResult OnReportHealthStatus(Dictionary data) + { + data.Add("interval", Interval.ToString()); + data.Add("firstInterval", FirstInterval.ToString()); + data.Add("onUnhandledInterval", OnUnhandledInterval.ToString()); + data.Add("pauseOnUnhandledException", PauseOnUnhandledException); + data.Add("maxConsecutiveExecutions", MaxConsecutiveExecutions); + data.Add("lastExecuted", LastExecuted); + + return Status.IsPause + ? HealthCheckResult.Degraded("Service is in a paused state.", null, data) + : (LastException is null ? HealthCheckResult.Healthy(null, data) : HealthCheckResult.Degraded(null, LastException, data)); + } + + /// + /// Provides an opportunity to add additional logging scope data for the during the execution of the method. + /// + /// The to add scope data to. + protected virtual void AddLoggingScope(IDictionary data) { } - return status; + /// + /// Provides an opportunity to make a one-off change to the underlying timer to trigger using the specified . + /// + /// The one-off interval. + /// A negative will result in leading to a status change to paused. + protected void OneOffIntervalAdjust(TimeSpan oneOffInterval) + { + lock (SyncLock) + { + OneOffIntervalAdjustInternal(oneOffInterval); } + } - /// - /// Provides an opportunity to override the health status reporting. - /// - /// The status data. - /// The . - /// Returns where the is null; otherwise, . - protected virtual HealthCheckResult OnReportHealthStatus(Dictionary data) => LastException is null ? HealthCheckResult.Healthy(null, data) : HealthCheckResult.Unhealthy(null, LastException, data); - - /// - /// Dispose of resources. - /// - public void Dispose() + /// + /// Adjusts the interval for the next scheduled execution to a one-off value, overriding the regular interval for a single occurrence. + /// + private void OneOffIntervalAdjustInternal(TimeSpan oneOffInterval) + { + if (Status.IsStop) + return; + + if (oneOffInterval < TimeSpan.Zero) + oneOffInterval = Timeout.InfiniteTimeSpan; + + _oneOffInterval = oneOffInterval; + SignalWakeUp(); + } + + /// + /// Provides an opportunity to explicitly trigger the service execution versus waiting for the next scheduled interval. + /// + /// The one-off interval before triggering; defaults to null which represents an immediate trigger. + /// A negative will result in leading to a status change to paused. + /// Invokes the internally to perform. + public void OneOffTrigger(TimeSpan? oneOffInterval = null) => OneOffIntervalAdjust(oneOffInterval ?? TimeSpan.Zero); + + /// + /// Signals the background loop to wake up immediately by canceling any active delay and releasing the semaphore. + /// + private void SignalWakeUp() + { + _delayCts?.Cancel(); + if (_signal.CurrentCount == 0) + _signal.Release(); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) { - if (!_disposed) + try { _backgroundCts?.Cancel(); } catch { } + try { _delayCts?.Cancel(); } catch { } + + try { - lock (_lock) - { - if (!_disposed) - { - Status = TimerHostedServiceStatus.Stopped; - _timer?.Dispose(); - _cts?.Cancel(); - _cts?.Dispose(); - _timer = null; - _cts = null; - _disposed = true; - } - } + _signal?.Dispose(); + _delayCts?.Dispose(); + _backgroundCts?.Dispose(); + } + catch { } + finally + { + _delayCts = null; + _backgroundTask = null; + _backgroundCts = null; } - - Dispose(true); - GC.SuppressFinalize(this); } - /// - /// Releases the unmanaged resources used by the and optionally releases the managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) { } + base.Dispose(disposing); } + + /// + /// Performs a one-off of the service work outside of the timer-based schedule. + /// + /// The . + /// The . + /// indicates that the should be re-executed immediately (without an interval); otherwise, + /// to re-execute after the configured . + /// Warning: this is intended for advanced scenarios, such as testing, and improper usage may result in unexpected behavior. + public async Task OneOffExecuteAsync(ExecutionContext executionContext, CancellationToken cancellationToken) => await OnExecuteAsync(executionContext, cancellationToken).ConfigureAwait(false); } \ No newline at end of file diff --git a/src/CoreEx/Hosting/TimerHostedServiceStatus.cs b/src/CoreEx/Hosting/TimerHostedServiceStatus.cs deleted file mode 100644 index 210049f8..00000000 --- a/src/CoreEx/Hosting/TimerHostedServiceStatus.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Hosting; - -namespace CoreEx.Hosting -{ - /// - /// Represents the status of a . - /// - public enum TimerHostedServiceStatus - { - /// - /// Initialized, but not started. - /// - Initialized, - - /// - /// Starting; i.e. has been called. - /// - Starting, - - /// - /// Sleeping; i.e. the timer is waiting for the next interval. - /// - Sleeping, - - /// - /// Running; i.e. the timer is executing the method. - /// - Running, - - /// - /// Stopping; i.e. has been called. - /// - Stopping, - - /// - /// Stopped; the service has been stopped. - /// - Stopped - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/FileWorkStatePersistence.cs b/src/CoreEx/Hosting/Work/FileWorkStatePersistence.cs deleted file mode 100644 index fb733fca..00000000 --- a/src/CoreEx/Hosting/Work/FileWorkStatePersistence.cs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using CoreEx.Json; -using System; -using System.IO; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Hosting.Work -{ - /// - /// An that persists the (as JSON) to a file and the related to a separate file. - /// - public class FileWorkStatePersistence : IWorkStatePersistence - { - private static readonly Regex _invalidFileNameChars = new($"[{Regex.Escape(new string(Path.GetInvalidFileNameChars()))}]", RegexOptions.Compiled); - - private readonly string _path; - private readonly IJsonSerializer _jsonSerializer; - - /// - /// Gets the configuration key that defines the directory path for the work persistence files. - /// - public const string ConfigKey = "FileWorkPersistencePath"; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . Defaults to . - public FileWorkStatePersistence(SettingsBase settings, IJsonSerializer? jsonSerializer = null) - { - _path = settings.ThrowIfNull(nameof(settings)).GetCoreExValue(ConfigKey); - - if (string.IsNullOrEmpty(_path)) - throw new ArgumentException($"Configuration setting '{ConfigKey}' either does not exist or has no value.", nameof(settings)); - - if (!Directory.Exists(_path)) - throw new ArgumentException($"Configuration setting '{ConfigKey}' path does not exist: {_path}"); - - _jsonSerializer = jsonSerializer ?? JsonSerializer.Default; - } - - /// - /// Gets the . - /// - /// The . - /// The . - public FileInfo GetStateFileInfo(string id) => new(Path.Combine(_path, $"{GetSanitizedName(id)}.json")); - - /// - /// Gets the data . - /// - /// The . - /// The . - public FileInfo GetDataFileInfo(string id) => new(Path.Combine(_path, $"{GetSanitizedName(id)}.data")); - - /// - /// Gets a sanitized file name by replacing any invalid characters with an underscore. - /// - private static string GetSanitizedName(string name) => _invalidFileNameChars.Replace(name, "_"); - - /// - public async Task GetAsync(string id, CancellationToken cancellationToken = default) - { - var fi = GetStateFileInfo(id); - if (!fi.Exists) - return default!; - - using var stream = fi.OpenRead(); - var json = await BinaryData.FromStreamAsync(stream, cancellationToken).ConfigureAwait(false); - return _jsonSerializer.Deserialize(json); - } - - /// - public Task CreateAsync(WorkState state, CancellationToken cancellationToken = default) => ReplaceAsync(state, true, cancellationToken); - - /// - public Task UpdateAsync(WorkState state, CancellationToken cancellationToken = default) => ReplaceAsync(state, false, cancellationToken); - - /// - /// Replace the file. - /// - private async Task ReplaceAsync(WorkState state, bool isCreate, CancellationToken cancellationToken = default) - { - var fi = GetStateFileInfo(state.Id.ThrowIfNull()); - if (isCreate && fi.Exists) - throw new ArgumentException("Create can not be performed as the WorkState already exists; the type and identifier combination should be unique.", nameof(state)); - - if (fi.Directory is not null && !fi.Directory.Exists) - fi.Directory.Create(); - - using var stream = fi.Open(fi.Exists ? FileMode.Truncate : FileMode.Create); - var json = _jsonSerializer.SerializeToBinaryData(state); - await stream.WriteAsync(json, cancellationToken).ConfigureAwait(false); - } - - /// - public Task DeleteAsync(string id, CancellationToken cancellationToken = default) - { - var fi = GetStateFileInfo(id); - if (fi.Exists) - fi.Delete(); - - fi = GetDataFileInfo(id); - if (fi.Exists) - fi.Delete(); - - return Task.CompletedTask; - } - - /// - public async Task GetDataAsync(string id, CancellationToken cancellationToken) - { - var fi = GetDataFileInfo(id); - if (!fi.Exists) - return null; - - using var fs = fi.OpenRead(); - return await BinaryData.FromStreamAsync(fs, cancellationToken).ConfigureAwait(false); - } - - /// - public async Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken) - { - var fi = GetDataFileInfo(id); - using var fs = fi.Open(fi.Exists ? FileMode.Truncate : FileMode.Create); - await data.ToStream().CopyToAsync(fs, cancellationToken).ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/HybridCacheWorkProvider.cs b/src/CoreEx/Hosting/Work/HybridCacheWorkProvider.cs new file mode 100644 index 00000000..9c726d0f --- /dev/null +++ b/src/CoreEx/Hosting/Work/HybridCacheWorkProvider.cs @@ -0,0 +1,117 @@ +namespace CoreEx.Hosting.Work; + +/// +/// Provides the implementation using an underlying . +/// +/// The . +public class HybridCacheWorkProvider(IHybridCache hybridCache) : IWorkProvider +{ + /// + /// Gets the underlying . + /// + public IHybridCache HybridCache { get; } = hybridCache.ThrowIfNull(); + + /// + /// Gets or sets the . + /// + public HybridCacheEntryOptions? CacheEntryOptions { get; set; } + + /// + /// Gets or sets the maximum cached data size in bytes. + /// + /// The default is 512 * 1024 (512 KB). + public int MaxCachedDataSize { get; set => field = value.ThrowWhen(value => value <= 0); } = 512 * 1024; + + /// + /// Gets the and defaults where not specified. + /// + private HybridCacheEntryOptions GetCacheEntryOptions() + { + if (CacheEntryOptions is not null) + return CacheEntryOptions; + + // Create default with double the WorkState expiry time span. + var expiry = WorkOrchestrator.DefaultExpiryTimeSpan * 2; + return HybridCacheEntryOptions.CreateFor(expiry, expiry, CacheStrategy.Hybrid).WithTags(nameof(WorkState)); + } + + /// + public async Task GetAsync(string id, CancellationToken cancellationToken) + { + var (Exists, Value) = await HybridCache.TryGetAsync(id, GetCacheEntryOptions(), cancellationToken); + return Exists ? Value!.State : null; + } + + /// + public async Task CreateAsync(WorkState state, CancellationToken cancellationToken) + { + var exists = true; + await HybridCache.GetOrCreateAsync(state.ThrowIfNull().Id.ThrowIfNullOrEmpty(), _ => + { + exists = false; + return Task.FromResult(new WorkStateCacheItem { Id = state.Id!, State = state }); + }, GetCacheEntryOptions(), cancellationToken); + + if (exists) + throw new InvalidOperationException($"A work state with the identifier '{state.Id}' already exists."); + } + + /// + public async Task UpdateAsync(WorkState state, CancellationToken cancellationToken) + { + var (Exists, Value) = await HybridCache.TryGetAsync(state.ThrowIfNull().Id.ThrowIfNullOrEmpty(), GetCacheEntryOptions(), cancellationToken).ConfigureAwait(false); + if (!Exists || Value is null) + throw new NotFoundException(); + + Value.State = state; + await HybridCache.SetAsync(Value, GetCacheEntryOptions(), cancellationToken); + } + + /// + public Task DeleteAsync(string id, CancellationToken cancellationToken) => HybridCache.RemoveAsync(id, cancellationToken); + + /// + public async Task GetDataAsync(string id, CancellationToken cancellationToken) + { + var (Exists, Value) = await HybridCache.TryGetAsync(id.ThrowIfNullOrEmpty(), GetCacheEntryOptions(), cancellationToken).ConfigureAwait(false); + if (!Exists || Value is null) + return null; + + return Value.Data; + } + + /// + public async Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken) + { + var (Exists, Value) = await HybridCache.TryGetAsync(id.ThrowIfNullOrEmpty(), GetCacheEntryOptions(), cancellationToken).ConfigureAwait(false); + if (!Exists || Value is null) + throw new NotFoundException(); + + if (data.Length > MaxCachedDataSize) + throw new InvalidOperationException($"The data size of {data.Length} bytes exceeds the maximum allowed size of {MaxCachedDataSize} bytes."); + + Value.Data = data; + await HybridCache.SetAsync(Value, GetCacheEntryOptions(), cancellationToken); + } + + /// + /// Provides the underlying work state cache item. + /// + private record class WorkStateCacheItem : IReadOnlyIdentifier + { + /// + /// Gets the identifier. + /// + public required string Id { get; init; } + + /// + /// Gets or sets the . + /// + public required WorkState State { get; set; } + + /// + /// Gets or sets the associated data. + /// + public BinaryData? Data { get; set; } + } +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/IWorkProvider.cs b/src/CoreEx/Hosting/Work/IWorkProvider.cs new file mode 100644 index 00000000..b7ac5b0d --- /dev/null +++ b/src/CoreEx/Hosting/Work/IWorkProvider.cs @@ -0,0 +1,57 @@ +namespace CoreEx.Hosting.Work; + +/// +/// Provides the underlying implementation services. +/// +/// The contains all orchestration logic, including validation, etc. which is performed prior to invoking the . Therefore, there is no need +/// to repeat within the provider implementation; i.e. simply provide the persistence as requested. +/// The and enable simple (smallish) result data persistence; large data should be persisted independently with the result data here representing a link +/// to a file/blob for example. +public interface IWorkProvider +{ + /// + /// Gets (reads) the from the persistence store using the specified . + /// + /// The . + /// The where found; otherwise, null. + /// The . + Task GetAsync(string id, CancellationToken cancellationToken); + + /// + /// Creates (saves) the to the persistence store as new. + /// + /// The . + /// The . + Task CreateAsync(WorkState state, CancellationToken cancellationToken); + + /// + /// Updates (saves) the to the persistence store replacing existing. + /// + /// The . + /// The . + Task UpdateAsync(WorkState state, CancellationToken cancellationToken); + + /// + /// Deletes the from the persistence store using the specified . + /// + /// The . + /// The . + /// A delete should be considered idempotent and therefore should not fail where not found. + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// + /// Gets the using the specified . + /// + /// The . + /// The . + /// The where found; otherwise, null. + Task GetDataAsync(string id, CancellationToken cancellationToken); + + /// + /// Gets the for the specified . + /// + /// The . + /// The to persist. + /// The . + Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/IWorkStatePersistence.cs b/src/CoreEx/Hosting/Work/IWorkStatePersistence.cs deleted file mode 100644 index 8dd8586a..00000000 --- a/src/CoreEx/Hosting/Work/IWorkStatePersistence.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Hosting.Work -{ - /// - /// Provides the persistence capabilities for the - /// - /// The contains all orchestration logic, including validation, etc. which is performed prior to invoking the . Therefore, there is no need to repeat within the persistence implementation; - /// i.e. simply perist as requested.The and enable simple (smallish) result data persistence; large data should be persisted independently with the result data here representing a link to a file/blob for example. - public interface IWorkStatePersistence - { - /// - /// Gets (reads) the from the persistence store using the specified . - /// - /// The . - /// The where found; otherwise, null. - /// The . - Task GetAsync(string id, CancellationToken cancellationToken); - - /// - /// Creates (saves) the to the persistence store as new. - /// - /// The . - /// The . - Task CreateAsync(WorkState state, CancellationToken cancellationToken); - - /// - /// Updates (saves) the to the persistence store replacing existinf. - /// - /// The . - /// The . - Task UpdateAsync(WorkState state, CancellationToken cancellationToken); - - /// - /// Deletes the from the persistence store using the specified . - /// - /// The . - /// The . - /// A delete should be considered idempotent and therefore should not fail where not found. - Task DeleteAsync(string id, CancellationToken cancellationToken); - - /// - /// Gets the using the specified . - /// - /// The . - /// The . - /// The where found; otherwise, null. - Task GetDataAsync(string id, CancellationToken cancellationToken); - - /// - /// Gets the for the specified . - /// - /// The . - /// The to persist. - /// The . - Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/InMemoryWorkStatePersistence.cs b/src/CoreEx/Hosting/Work/InMemoryWorkStatePersistence.cs deleted file mode 100644 index c8397c45..00000000 --- a/src/CoreEx/Hosting/Work/InMemoryWorkStatePersistence.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Hosting.Work -{ - /// - /// An that persists the in-memory which can be used for the likes of testing. - /// - /// The . - public class InMemoryWorkStatePersistence(ILogger? logger = null) : IWorkStatePersistence - { - private readonly Dictionary _workStates = []; - private readonly Dictionary _workData = []; - private readonly ILogger? _logger = logger; - - /// - /// Gets all the entries. - /// - public WorkState[] GetWorkStates() => [.. _workStates.Values]; - - /// - public Task GetAsync(string id, CancellationToken cancellationToken) - => Task.FromResult(_workStates.TryGetValue(id, out var state) ? state : null); - - /// - public Task CreateAsync(WorkState state, CancellationToken cancellationToken) - { - if (_workStates.ContainsKey(state.Id.ThrowIfNull())) - throw new ArgumentException("Create can not be performed as the WorkState already exists; the type and identifier combination should be unique.", nameof(state)); - - _logger?.LogDebug("Creating WorkState: {Id}", state.Id); - _workStates.Add(state.Id, state); - return Task.CompletedTask; - } - - /// - public Task UpdateAsync(WorkState state, CancellationToken cancellationToken) - { - _logger?.LogDebug("Updating WorkState: {Id}", state.Id); - _workStates[state.Id.ThrowIfNull()] = state; - return Task.CompletedTask; - } - - /// - public Task DeleteAsync(string id, CancellationToken cancellationToken) - { - _logger?.LogDebug("Deleting WorkState: {Id}", id); - _workStates.Remove(id); - _workData.Remove(id); - return Task.CompletedTask; - } - - /// - public Task GetDataAsync(string id, CancellationToken cancellationToken) - { - _logger?.LogDebug("Getting WorkState data for: {Id}", id); - return Task.FromResult(_workData.TryGetValue(id, out var data) ? data : null); - } - - /// - public Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken) - { - _logger?.LogDebug("Setting WorkState data for: {Id}", id); - _workData[id] = data; - return Task.CompletedTask; - } - - /// - /// Clears the in-memory persistence. - /// - public void Clear() - { - _logger?.LogDebug("Clearing WorkState persistence."); - _workStates.Clear(); - _workData.Clear(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/WorkArgs.cs b/src/CoreEx/Hosting/Work/WorkArgs.cs new file mode 100644 index 00000000..286b28ca --- /dev/null +++ b/src/CoreEx/Hosting/Work/WorkArgs.cs @@ -0,0 +1,64 @@ +namespace CoreEx.Hosting.Work; + +/// +/// Represents the arguments. +/// +public record class WorkArgs : IReadOnlyIdentifier +{ + /// + /// Gets the underlying name for the specified . + /// + /// The to infer the enabling state separation. + /// The . + public static string GetTypeName() => typeof(T).FullName ?? typeof(T).Name; + + /// + /// Creates the using the as the . + /// + /// The to infer the enabling state separation. + /// The work identifier. + /// The newly instantiated . + public static WorkArgs Create(string? id = null) => new(GetTypeName(), id); + + /// + /// Initializes a new instance of the class. + /// + /// The type name. + /// The identifier. + public WorkArgs(string typeName, string? id = null) + { + TypeName = typeName.ThrowIfNullOrEmpty(); + Id = id.ThrowIfEmpty() ?? Runtime.NewId(); + } + + /// + /// Gets or sets the type name. + /// + /// Enables separation between one or more types; see to minimize cross-type access challenges. + public string TypeName { get; } + + /// + public string Id { get; } + + /// + /// Gets or sets the expiry . + /// + /// The will default to the where not specified. + public TimeSpan? Expiry { get; set; } + + /// + /// Gets or sets the trace parent (defaults to see ). + /// + public string? TraceParent { get; set; } + + /// + /// Gets or sets the trace state (defaults to see ). + /// + public string? TraceState { get; set; } + + /// + /// Gets or sets the owning . + /// + /// This provides a basic authorization-style opportunity by verifying only the initiating user has ongoing access. + public AuthenticationUser? User { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/WorkOrchestrator.cs b/src/CoreEx/Hosting/Work/WorkOrchestrator.cs new file mode 100644 index 00000000..2afbf7d5 --- /dev/null +++ b/src/CoreEx/Hosting/Work/WorkOrchestrator.cs @@ -0,0 +1,385 @@ +namespace CoreEx.Hosting.Work; + +/// +/// Represents the long-running work (see ) tracking orchestrator. +/// +/// The . +/// The optional . +/// There are some basic consistency checks that occur for the methods to ensure largely correct usage (sequence of execution). The onus is primarily on the developer to ensure correct usage. +public class WorkOrchestrator(IWorkProvider provider, JsonSerializerOptions? jsonSerializerOptions = null) +{ + private readonly WorkOrchestratorInvoker _invoker = WorkOrchestratorInvoker.Default; + private TimeSpan? _expiryTimeSpan; + + /// + /// Gets the default . + /// + internal static TimeSpan DefaultExpiryTimeSpan => Internal.GetConfigurationValue("CoreEx:Hosting:Work:Expiry", TimeSpan.FromDays(1)); + + /// + /// Gets the . + /// + public IWorkProvider Provider = provider.ThrowIfNull(nameof(provider)); + + /// + /// Gets the . + /// + /// Defaults to . + public JsonSerializerOptions JsonSerializerOptions = jsonSerializerOptions ?? JsonDefaults.SerializerOptions; + + /// + /// Gets or sets the work expiry . + /// + /// Defaults to configuration setting 'CoreEx:Hosting:Work:Expiry'; otherwise, one (1) day. + public TimeSpan ExpiryTimeSpan + { + get => _expiryTimeSpan ??= DefaultExpiryTimeSpan; + set => _expiryTimeSpan = value; + } + + /// + /// Indicates whether to check the where not where performing a or . + /// + public bool CheckUser { get; set; } = true; + + /// + /// Gets the for the specified . + /// + /// The work identifier. + /// The . + /// The where found; otherwise, . + /// Will automatically set the to when the work is not and has expired (see ). + public Task GetAsync(string id, CancellationToken cancellationToken = default) => _invoker.InvokeAsync(this, async (_, cancellationToken) => + { + var ws = await Provider.GetAsync(id.ThrowIfNullOrEmpty(nameof(id)), cancellationToken).ConfigureAwait(false); + if (ws is null) + return null; + + // Automatically expire where it has been hanging around unfinished for too long. + if (!WorkStatus.Finished.HasFlag(ws.Status) && Runtime.UtcNow >= ws.Expiry) + { + var wsr = await ExpireAsync(id, "The work has not finished within the expiry timeframe and is assumed to have expired.", cancellationToken).ConfigureAwait(false); + return wsr.Value; + } + + return ws; + }, cancellationToken); + + /// + /// Creates and persists a new with a status. + /// + /// The . + /// The . + /// The . + public Task CreateAsync(WorkArgs args, CancellationToken cancellationToken = default) => _invoker.InvokeAsync(this, async (_, cancellationToken) => + { + args.ThrowIfNull(); + var now = Runtime.UtcNow; + + var ws = new WorkState() + { + Id = args.Id, + TypeName = args.TypeName.ThrowIfNullOrEmpty(), + Status = WorkStatus.Created, + Created = now, + Expiry = now.Add(args.Expiry ?? ExpiryTimeSpan), + User = args.User, + TraceParent = args.TraceParent, + TraceState = args.TraceState + }; + + if (ws.User is null && ExecutionContext.TryGetCurrent(out var ec)) + ws.User = ec.User; + + if (string.IsNullOrEmpty(args.TraceParent) && Activity.Current is not null) + { + args.TraceParent = Activity.Current.Id; + args.TraceState = Activity.Current.TraceStateString; + } + + await Provider.CreateAsync(ws, cancellationToken).ConfigureAwait(false); + + return ws; + }, cancellationToken); + + /// + /// Starts a previously . + /// + /// The work identifier. + /// The . + /// The updated . + /// A may also be returned. Starting work that has already will not update the . + public Task> StartAsync(string id, CancellationToken cancellationToken = default) => _invoker.InvokeAsync>(this, async (_, cancellationToken) => + { + var ws = await Provider.GetAsync(id.ThrowIfNullOrEmpty(), cancellationToken).ConfigureAwait(false); + if (ws is null) + return Result.NotFoundError(); + + if (WorkStatus.Finished.HasFlag(ws.Status)) + return ws; + + if (ws.Status != WorkStatus.Created) + throw new InvalidOperationException($"Work '{id}' cannot be started due to current status of '{ws.Status}'."); + + ws.Status = WorkStatus.Started; + ws.Started = Runtime.UtcNow; + + await Provider.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); + return ws; + }, cancellationToken); + + /// + /// Sets a previously to . + /// + /// The work identifier. + /// The indeterminate reason. + /// The . + /// The updated . + /// A may also be returned. Setting work to indeterminate that has already will not update the . + public Task> IndeterminateAsync(string id, string reason, CancellationToken cancellationToken = default) => _invoker.InvokeAsync>(this, async (_, cancellationToken) => + { + var ws = await GetAsync(id.ThrowIfNullOrEmpty(), cancellationToken).ConfigureAwait(false); + if (ws is null) + return Result.NotFoundError(); + + if (WorkStatus.Finished.HasFlag(ws.Status)) + return ws; + + if (!WorkStatus.InProgress.HasFlag(ws.Status)) + throw new InvalidOperationException($"Work '{id}' cannot be set to indeterminate due to current status of '{ws.Status}'."); + + ws.Status = WorkStatus.Indeterminate; + ws.Indeterminate = Runtime.UtcNow; + ws.Reason = reason.ThrowIfNullOrEmpty(); + + await Provider.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); + return ws; + }, cancellationToken); + + /// + /// Completes a previously . + /// + /// The work identifier. + /// The . + /// The updated . + /// A may also be returned. Completing work that has already will not update the . + public Task> CompleteAsync(string id, CancellationToken cancellationToken = default) => _invoker.InvokeAsync>(this, async (_, cancellationToken) => + { + var ws = await GetAsync(id.ThrowIfNullOrEmpty(), cancellationToken).ConfigureAwait(false); + if (ws is null) + return Result.NotFoundError(); + + if (WorkStatus.Finished.HasFlag(ws.Status)) + return ws; + + if (!WorkStatus.InProgress.HasFlag(ws.Status)) + throw new InvalidOperationException($"Work '{id}' cannot be completed due to current status of '{ws.Status}'."); + + ws.Status = WorkStatus.Completed; + ws.Finished = Runtime.UtcNow; + ws.Reason = null; + + await Provider.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); + return ws; + }, cancellationToken); + + /// + /// Fails a previously . + /// + /// The work identifier. + /// The failure reason. + /// The . + /// The updated . + /// A may also be returned. Failing work that has already will not update the . + public Task> FailAsync(string id, string reason, CancellationToken cancellationToken = default) => _invoker.InvokeAsync>(this, async (_, cancellationToken) => + { + var ws = await GetAsync(id.ThrowIfNullOrEmpty(), cancellationToken).ConfigureAwait(false); + if (ws is null) + return Result.NotFoundError(); + + if (WorkStatus.Finished.HasFlag(ws.Status)) + return ws; + + if (!WorkStatus.InProgress.HasFlag(ws.Status)) + throw new InvalidOperationException($"Work '{id}' cannot be failed due to current status of '{ws.Status}'."); + + ws.Status = WorkStatus.Failed; + ws.Finished = Runtime.UtcNow; + ws.Reason = reason.ThrowIfNullOrEmpty(); + + await Provider.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); + return ws; + }, cancellationToken); + + /// + /// Fails a previously . + /// + /// The work identifier. + /// The unhandled . + /// The . + /// The updated . + /// A may also be returned. Failing work that has already will not update the . + public Task> FailAsync(string id, Exception exception, CancellationToken cancellationToken = default) + => FailAsync(id, $"Work failed due to an unexpected error: {exception.ThrowIfNull(nameof(exception)).Message}", cancellationToken); + + /// + /// Expires a . + /// + /// The work identifier. + /// The cancellation reason. + /// The . + /// The updated . + /// A may also be returned. Expiring work that has already will not update the . + public Task> ExpireAsync(string id, string reason, CancellationToken cancellationToken = default) => _invoker.InvokeAsync>(this, async (_, cancellationToken) => + { + var ws = await Provider.GetAsync(id.ThrowIfNullOrEmpty(), cancellationToken).ConfigureAwait(false); + if (ws is null) + return Result.NotFoundError(); + + if (WorkStatus.Finished.HasFlag(ws.Status)) + return ws; + + ws.Status = WorkStatus.Expired; + ws.Finished = Runtime.UtcNow; + ws.Reason = reason.ThrowIfNullOrEmpty(); + + await Provider.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); + return ws; + }, cancellationToken); + + /// + /// Cancels a . + /// + /// The work identifier. + /// The cancellation reason. + /// The . + /// The updated . + /// A may also be returned. Cancelling work that has already will not update the . + public Task> CancelAsync(string id, string reason, CancellationToken cancellationToken = default) => _invoker.InvokeAsync>(this, async (_, cancellationToken) => + { + var ws = await Provider.GetAsync(id.ThrowIfNullOrEmpty(), cancellationToken).ConfigureAwait(false); + if (ws is null) + return Result.NotFoundError(); + + if (WorkStatus.Finished.HasFlag(ws.Status)) + return ws; + + ws.Status = WorkStatus.Canceled; + ws.Finished = Runtime.UtcNow; + ws.Reason = reason.ThrowIfNullOrEmpty(nameof(reason)); + + await Provider.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); + return ws; + }, cancellationToken); + + /// + /// Deletes a . + /// + /// The work identifier. + /// The . + /// A will be returned where attempting to delete work that is not . + public Task DeleteAsync(string id, CancellationToken cancellationToken = default) => _invoker.InvokeAsync(this, async (_, cancellationToken) => + { + var ws = await GetAsync(id.ThrowIfNullOrEmpty(), cancellationToken).ConfigureAwait(false); + if (ws is null) + return Result.Success; + + if (!WorkStatus.Finished.HasFlag(ws.Status)) + return Result.ConflictError($"Work '{id}' can not be deleted due to current status of '{ws.Status}'; must be considered 'Finished'."); + + await Provider.DeleteAsync(id, cancellationToken).ConfigureAwait(false); + return Result.Success; + }, cancellationToken); + + /// + /// Gets the result data as and then JSON deserializes to the specified . + /// + /// The value . + /// The work identifier. + /// The . + /// The deserialized value where found; otherwise, . + public async Task GetDataValueAsync(string id, CancellationToken cancellationToken = default) + { + var data = await GetDataAsync(id, cancellationToken).ConfigureAwait(false); + if (data is null) + return default; + + return JsonSerializer.Deserialize(data, JsonSerializerOptions); + } + + /// + /// Gets the result data as a . + /// + /// The work identifier. + /// The . + /// The where found; otherwise, null. + public Task GetDataAsync(string id, CancellationToken cancellationToken = default) => _invoker.InvokeAsync(this, (_, cancellationToken) => + { + return Provider.GetDataAsync(id.ThrowIfNullOrEmpty(), cancellationToken); + }); + + /// + /// Sets the result data as the specified serialized as JSON. + /// + /// The . + /// The work identifier. + /// The value to JSON serialize as the result data. + /// The . + public async Task SetDataValueAsync(string id, TValue value, CancellationToken cancellationToken = default) + { + var bd = BinaryData.FromObjectAsJson(value, JsonSerializerOptions); + await SetDataAsync(id, bd, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sets the result data as the specified . + /// + /// The work identifier. + /// The . + /// The . + public async Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken = default) + { + if (await GetAsync(id, cancellationToken).ConfigureAwait(false) is null) + throw new ArgumentException($"Work '{id}' does not exist.", nameof(id)); + + await _invoker.InvokeAsync(this, async (_, cancellationToken) => + { + await Provider.SetDataAsync(id.ThrowIfNullOrEmpty(), data.ThrowIfNull(), cancellationToken).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + } + + #region WithType + + /// + /// Gets the for the specified and . + /// + /// The . + /// The work identifier. + /// The . + /// The where found; otherwise, null. + /// Will automatically set the to when the work is not and has expired (see ). + /// Additionally, the must equal the ; and, if is then the must equal the + /// ensuring that the initiating user can only interact with their . Where the aforementioned does not equal then a will be returned. + public async Task GetWithTypeAsync(string typeName, string id, CancellationToken cancellationToken = default) + { + var ws = await GetAsync(id, cancellationToken).ConfigureAwait(false); + if (ws is null || ws.TypeName != typeName) + return null; + + return CheckUser && ws.User is not null && ExecutionContext.TryGetCurrent(out var ec) && ec.User is not null && ws.User != ec.User ? null : ws; + } + + /// + /// Gets the for the specified . + /// + /// The to infer the enabling state separation. + /// The work identifier. + /// The . + /// The where found; otherwise, null. + /// Will automatically set the to when the work is not and has expired (see ). + /// Additionally, the must equal the ; and, if is then the must equal the + /// ensuring that the initiating user can only interact with their . Where the aforementioned does not equal then a will be returned. + public Task GetWithTypeAsync(string id, CancellationToken cancellationToken = default) => GetWithTypeAsync(WorkArgs.GetTypeName(), id, cancellationToken); + + #endregion +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/WorkOrchestratorInvoker.cs b/src/CoreEx/Hosting/Work/WorkOrchestratorInvoker.cs new file mode 100644 index 00000000..fce66793 --- /dev/null +++ b/src/CoreEx/Hosting/Work/WorkOrchestratorInvoker.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Hosting.Work; + +/// +/// Provides the invoker. +/// +[InvokerName("CoreEx.Hosting.Work.WorkOrchestrator")] +public class WorkOrchestratorInvoker : InvokerBase +{ + private static WorkOrchestratorInvoker? _default; + + /// + /// Gets the default instance. + /// + public static WorkOrchestratorInvoker Default => ExecutionContext.GetService() ?? (_default ??= new WorkOrchestratorInvoker()); +} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/WorkState.cs b/src/CoreEx/Hosting/Work/WorkState.cs index be25a377..4b6231b7 100644 --- a/src/CoreEx/Hosting/Work/WorkState.cs +++ b/src/CoreEx/Hosting/Work/WorkState.cs @@ -1,77 +1,75 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Hosting.Work; -using CoreEx.Entities; -using System; -using System.Text.Json.Serialization; - -namespace CoreEx.Hosting.Work +/// +/// Represents the status and result of a long-running -tracked work instance. +/// +public class WorkState : IIdentifier { + /// + /// The identifier must be globally unique across all work types. + public string? Id { get; set; } + /// - /// Represents the status and result of a long-running -tracked work instance. + /// Gets or sets the type name. /// - public class WorkState : IIdentifier - { - /// - /// The identifier must be globally unique across all work types. - public string? Id { get; set; } + /// Enables separation between one or more types. + [JsonPropertyName("type")] + public string? TypeName { get; set; } - /// - /// Gets or sets the type name. - /// - /// Enables separation between one or more types. - [JsonPropertyName("type")] - public string? TypeName { get; set; } + /// + /// Gets or sets the related entity key where applicable. + /// + public string? Key { get; set; } - /// - /// Gets or sets the related entity key where applicable. - /// - public string? Key { get; set; } + /// + /// Gets or sets the trace parent (defaults to see ). + /// + public string? TraceParent { get; set; } - /// - /// Gets or sets the correlation identifier. - /// - public string? CorrelationId { get; set; } + /// + /// Gets or sets the trace state (defaults to see ). + /// + public string? TraceState { get; set; } - /// - /// Gets or sets the . - /// - public WorkStatus Status { get; set; } + /// + /// Gets or sets the . + /// + public WorkStatus Status { get; set; } - /// - /// Gets or sets the owning user name. - /// - /// This provides a basic authorization-style opportunity by verifying only the initiating user has ongoing access. - public string? UserName { get; set; } + /// + /// Gets or sets the owning . + /// + /// This provides a basic authorization-style opportunity by verifying only the initiating user has ongoing access. + public AuthenticationUser? User { get; set; } - /// - /// Gets or sets the . - /// - public DateTimeOffset Created { get; set; } + /// + /// Gets or sets the . + /// + public DateTimeOffset Created { get; set; } - /// - /// Gets or sets the expiry . - /// - /// Where the work has not by the expiry it will be automatically . - public DateTimeOffset Expiry { get; set; } + /// + /// Gets or sets the expiry . + /// + /// Where the work has not by the expiry it will be automatically . + public DateTimeOffset Expiry { get; set; } - /// - /// Gets or sets the . - /// - public DateTimeOffset? Started { get; set; } + /// + /// Gets or sets the . + /// + public DateTimeOffset? Started { get; set; } - /// - /// Gets or sets the . - /// - public DateTimeOffset? Indeterminate { get; set; } + /// + /// Gets or sets the . + /// + public DateTimeOffset? Indeterminate { get; set; } - /// - /// Gets or sets the . - /// - public DateTimeOffset? Finished { get; set; } + /// + /// Gets or sets the . + /// + public DateTimeOffset? Finished { get; set; } - /// - /// Gets or sets the or reason. - /// - public string? Reason { get; set; } - } + /// + /// Gets or sets the or reason. + /// + public string? Reason { get; set; } } \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/WorkStateArgs.cs b/src/CoreEx/Hosting/Work/WorkStateArgs.cs deleted file mode 100644 index f955689a..00000000 --- a/src/CoreEx/Hosting/Work/WorkStateArgs.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; - -namespace CoreEx.Hosting.Work -{ - /// - /// Represents the arguments. - /// - public class WorkStateArgs(string typeName, string? id = null) : IIdentifier - { - /// - /// Gets the underlying name for the specified . - /// - /// The to infer the enabling state separation. - /// The . - public static string GetTypeName() => typeof(T).FullName ?? typeof(T).Name; - - /// - /// Creates the using the as the . - /// - /// The to infer the enabling state separation. - /// The work identifier. - /// The newly instantiated . - public static WorkStateArgs Create(string? id = null) => new(GetTypeName(), id); - - /// - /// Gets or sets the type name. - /// - /// Enables separation between one or more types; see to minimize cross-type access challenges. - public string TypeName { get; } = typeName.ThrowIfNullOrEmpty(nameof(typeName)); - - /// - /// The will default to the value. - public string? Id { get; set; } = id; - - /// - /// Gets or sets the related entity key where applicable. - /// - public string? Key { get; set; } - - /// - /// Gets or sets the correlation identifier. - /// - /// The will default to the where not specified. - public string? CorrelationId { get; set; } - - /// - /// Gets or sets the expiry . - /// - /// The will default to the where not specified. - public TimeSpan? Expiry { get; set; } - - /// - /// Gets or sets the owning user name. - /// - /// This provides a basic authorization opportunity by verifying only the initiating user has ongoing access. This will default to ; otherwise, null. - public string? UserName { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/WorkStateOrchestrator.cs b/src/CoreEx/Hosting/Work/WorkStateOrchestrator.cs deleted file mode 100644 index 9eb1ee64..00000000 --- a/src/CoreEx/Hosting/Work/WorkStateOrchestrator.cs +++ /dev/null @@ -1,381 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using CoreEx.Entities; -using CoreEx.Json; -using CoreEx.Results; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Hosting.Work -{ - /// - /// Represents the long-running work (see ) tracking orchestrator. - /// - /// The . - /// The . - /// The . - /// The . - /// There are some basic consistency checks that occur for the methods to ensure largely correct usage (sequence of execution). - public class WorkStateOrchestrator(IWorkStatePersistence persistence, SettingsBase? settings = null, IJsonSerializer? jsonSerializer = null, IIdentifierGenerator? identifierGenerator = null) - { - private TimeSpan? _expiryTimeSpan; - - /// - /// Gets the . - /// - public IWorkStatePersistence Persistence = persistence.ThrowIfNull(nameof(persistence)); - - /// - /// Gets the . - /// - /// Defaults to - public IIdentifierGenerator IdentifierGenerator = identifierGenerator ?? new IdentifierGenerator(); - - /// - /// Gets the . - /// - /// Defaults to . - public SettingsBase Settings = settings ?? new DefaultSettings(); - - /// - /// Gets the . - /// - /// Defaults to . - public IJsonSerializer JsonSerializer = jsonSerializer ?? Json.JsonSerializer.Default; - - /// - /// Gets or sets the work expiry . - /// - /// Defaults to . - public TimeSpan ExpiryTimeSpan - { - get => _expiryTimeSpan ??= Settings.WorkerExpiryTimeSpan; - set => _expiryTimeSpan = value; - } - - /// - /// Indicates whether to check the where not null where performing a or . - /// - public bool CheckUserName { get; set; } = true; - - /// - /// Gets the for the specified . - /// - /// The work identifier. - /// The . - /// The where found; otherwise, null. - /// Will automatically set the to when the work is not and has expired (see ). - public async Task GetAsync(string id, CancellationToken cancellationToken = default) - { - var ws = await Persistence.GetAsync(id.ThrowIfNullOrEmpty(nameof(id)), cancellationToken).ConfigureAwait(false); - if (ws is null) - return null; - - // Automatically cancel where expired. - if (ws.Status != WorkStatus.Finished && DateTimeOffset.UtcNow >= ws.Expiry) - { - var wsr = await ExpireAsync(id, "The work has not finished within the expiry timeframe and is assumed to have expired.", cancellationToken).ConfigureAwait(false); - return wsr.Value; - } - - return ws; - } - - /// - /// Creates and persists a new with a status. - /// - /// The . - /// The . - /// The . - /// The will default to the where not specified. - public async Task CreateAsync(WorkStateArgs args, CancellationToken cancellationToken = default) - { - args.ThrowIfNull(nameof(args)); - var now = DateTimeOffset.UtcNow; - var ws = new WorkState() - { - Id = string.IsNullOrEmpty(args.Id) ? await IdentifierGenerator.GenerateIdentifierAsync().ConfigureAwait(false) : args.Id, - TypeName = args.TypeName, - Key = args?.Key, - CorrelationId = args?.CorrelationId ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current.CorrelationId : Guid.NewGuid().ToString()), - Status = WorkStatus.Created, - Created = now, - Expiry = now.Add(args?.Expiry ?? ExpiryTimeSpan), - UserName = args?.UserName ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current.UserName : null) - }; - - await Persistence.CreateAsync(ws, cancellationToken).ConfigureAwait(false); - - return ws; - } - - /// - /// Starts a previously . - /// - /// The work identifier. - /// The . - /// The updated . - /// A or may also be returned. - public async Task> StartAsync(string id, CancellationToken cancellationToken = default) - { - var ws = await Persistence.GetAsync(id.ThrowIfNullOrEmpty(nameof(id)), cancellationToken).ConfigureAwait(false); - if (ws is null) - return Result.NotFoundError(); - - if (ws.Status != WorkStatus.Created) - return Result.ConflictError($"Work '{id}' can not be started due to current status of '{ws.Status}'."); - - ws.Status = WorkStatus.Started; - ws.Started = DateTimeOffset.UtcNow; - - await Persistence.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); - return ws; - } - - /// - /// Sets a previously to . - /// - /// The work identifier. - /// The indeterminate reason. - /// The . - /// The updated . - /// A or may also be returned. - public async Task> IndeterminateAsync(string id, string reason, CancellationToken cancellationToken = default) - { - var ws = await GetAsync(id.ThrowIfNullOrEmpty(nameof(id)), cancellationToken).ConfigureAwait(false); - if (ws is null) - return Result.NotFoundError(); - - if (!WorkStatus.InProgress.HasFlag(ws.Status)) - return Result.ConflictError($"Work '{id}' can not be set to indeterminate due to current status of '{ws.Status}'."); - - ws.Status = WorkStatus.Indeterminate; - ws.Indeterminate = DateTimeOffset.UtcNow; - ws.Reason = reason.ThrowIfNullOrEmpty(nameof(reason)); - - await Persistence.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); - return ws; - } - - /// - /// Completes a previously . - /// - /// The work identifier. - /// The . - /// The updated . - /// A or may also be returned. - public async Task> CompleteAsync(string id, CancellationToken cancellationToken = default) - { - var ws = await GetAsync(id.ThrowIfNullOrEmpty(nameof(id)), cancellationToken).ConfigureAwait(false); - if (ws is null) - return Result.NotFoundError(); - - if (!WorkStatus.InProgress.HasFlag(ws.Status)) - return Result.ConflictError($"Work '{id}' can not be completed due to current status of '{ws.Status}'."); - - ws.Status = WorkStatus.Completed; - ws.Finished = DateTimeOffset.UtcNow; - ws.Reason = null; - - await Persistence.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); - return ws; - } - - /// - /// Fails a previously . - /// - /// The work identifier. - /// The failure reason. - /// The . - /// The updated . - /// A or may also be returned. - public async Task> FailAsync(string id, string reason, CancellationToken cancellationToken = default) - { - var ws = await GetAsync(id.ThrowIfNullOrEmpty(nameof(id)), cancellationToken).ConfigureAwait(false); - if (ws is null) - return Result.NotFoundError(); - - if (!WorkStatus.InProgress.HasFlag(ws.Status)) - return Result.ConflictError($"Work '{id}' can not be failed due to current status of '{ws.Status}'."); - - ws.Status = WorkStatus.Failed; - ws.Finished = DateTimeOffset.UtcNow; - ws.Reason = reason.ThrowIfNullOrEmpty(nameof(reason)); - - await Persistence.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); - return ws; - } - - /// - /// Fails a previously . - /// - /// The work identifier. - /// The unhandled . - /// The . - /// The updated . - /// A or may also be returned. - public Task> FailAsync(string id, Exception exception, CancellationToken cancellationToken = default) - => FailAsync(id, $"Work failed due to an error: {exception.ThrowIfNull(nameof(exception)).Message}", cancellationToken); - - /// - /// Expires a . - /// - /// The work identifier. - /// The cancellation reason. - /// The . - /// The updated . - /// A may also be returned. Expiring work that has already will not update the . - public async Task> ExpireAsync(string id, string reason, CancellationToken cancellationToken = default) - { - var ws = await GetAsync(id.ThrowIfNullOrEmpty(nameof(id)), cancellationToken).ConfigureAwait(false); - if (ws is null) - return Result.NotFoundError(); - - if (!WorkStatus.Finished.HasFlag(ws.Status)) - return ws; - - ws.Status = WorkStatus.Expired; - ws.Finished = DateTimeOffset.UtcNow; - ws.Reason = reason.ThrowIfNullOrEmpty(nameof(reason)); - - await Persistence.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); - return ws; - } - - /// - /// Cancels a . - /// - /// The work identifier. - /// The cancellation reason. - /// The . - /// The updated . - /// A may also be returned. Cancelling work that has already will not update the . - public async Task> CancelAsync(string id, string reason, CancellationToken cancellationToken = default) - { - var ws = await GetAsync(id.ThrowIfNullOrEmpty(nameof(id)), cancellationToken).ConfigureAwait(false); - if (ws is null) - return Result.NotFoundError(); - - if (WorkStatus.Finished.HasFlag(ws.Status)) - return Result.Fail($"A cancellation can not be performed when the current status is {ws.Status}."); - - ws.Status = WorkStatus.Canceled; - ws.Finished = DateTimeOffset.UtcNow; - ws.Reason = reason.ThrowIfNullOrEmpty(nameof(reason)); - - await Persistence.UpdateAsync(ws, cancellationToken).ConfigureAwait(false); - return ws; - } - - /// - /// Deletes a . - /// - /// The work identifier. - /// The . - /// A will be returned where attempting to delete work that is not . - public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) - { - var ws = await GetAsync(id.ThrowIfNullOrEmpty(nameof(id)), cancellationToken).ConfigureAwait(false); - if (ws is null) - return Result.Success; - - if (!WorkStatus.Finished.HasFlag(ws.Status)) - return Result.ConflictError($"Work '{id}' can not be deleted due to current status of '{ws.Status}'; must be considered 'Finished'."); - - await Persistence.DeleteAsync(id, cancellationToken).ConfigureAwait(false); - return Result.Success; - } - - /// - /// Gets the result data as and then JSON deserializes to the specified . - /// - /// The value . - /// The work identifier. - /// The . - /// The deserialized value where found; otherwise, default. - public async Task GetDataAsync(string id, CancellationToken cancellationToken = default) - { - var data = await GetDataAsync(id, cancellationToken).ConfigureAwait(false); - if (data is null) - return default; - - return JsonSerializer.Deserialize(data); - } - - /// - /// Gets the result data as a . - /// - /// The work identifier. - /// The . - /// The where found; otherwise, null. - public Task GetDataAsync(string id, CancellationToken cancellationToken = default) - => Persistence.GetDataAsync(id.ThrowIfNullOrEmpty(nameof(id)), cancellationToken); - - /// - /// Sets the result data as the specified serialized as JSON. - /// - /// The . - /// The work identifier. - /// The value to JSON serialize as the result data. - /// The . - public Task SetDataAsync(string id, TValue value, CancellationToken cancellationToken = default) - { - var json = JsonSerializer.SerializeToBinaryData(value.ThrowIfNull(nameof(value))); - return SetDataAsync(id, json, cancellationToken); - } - - /// - /// Sets the result data as the specified . - /// - /// The work identifier. - /// The . - /// The . - public async Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken = default) - { - if (await GetAsync(id, cancellationToken).ConfigureAwait(false) is null) - throw new ArgumentException($"Work '{id}' does not exist.", nameof(id)); - - await Persistence.SetDataAsync(id.ThrowIfNullOrEmpty(nameof(id)), data.ThrowIfNull(nameof(data)), cancellationToken).ConfigureAwait(false); - } - - #region WithType - - /// - /// Gets the for the specified and . - /// - /// The . - /// The work identifier. - /// The . - /// The where found; otherwise, null. - /// Will automatically set the to when the work is not and has expired (see ). - /// Additionally, the must equal the ; and, if is true then the must equal the - /// ensuring that the initiating user can only interact with their . Where the aforementioned does not equal then a null will be returned. - public async Task GetAsync(string type, string id, CancellationToken cancellationToken = default) - { - var ws = await GetAsync(id, cancellationToken).ConfigureAwait(false); - if (ws is null || ws.TypeName != type) - return null; - - if (CheckUserName && ws.UserName is not null && ws.UserName != (ExecutionContext.HasCurrent ? ExecutionContext.Current.UserName : null)) - return null; - - return ws; - } - - /// - /// Gets the for the specified . - /// - /// The to infer the enabling state separation. - /// The work identifier. - /// The . - /// The where found; otherwise, null. - /// Will automatically set the to when the work is not and has expired (see ). - /// Additionally, the must equal the ; and, if is true then the must equal the - /// ensuring that the initiating user can only interact with their . Where the aforementioned does not equal then a null will be returned. - public Task GetAsync(string id, CancellationToken cancellationToken = default) => GetAsync(WorkStateArgs.GetTypeName(), id, cancellationToken); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Hosting/Work/WorkStatus.cs b/src/CoreEx/Hosting/Work/WorkStatus.cs index 6300378b..169d61f0 100644 --- a/src/CoreEx/Hosting/Work/WorkStatus.cs +++ b/src/CoreEx/Hosting/Work/WorkStatus.cs @@ -1,66 +1,63 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Hosting.Work; -namespace CoreEx.Hosting.Work +/// +/// Represents the long-running work status. +/// +public enum WorkStatus { /// - /// Represents the long-running work status. + /// Indicates that the work as been created; however, it is not yet . /// - public enum WorkStatus - { - /// - /// Indicates that the work as been created; however, it is not yet . - /// - Created = 1, + Created = 1, - /// - /// Indicates that the underlying work has been started and is in progress. - /// - Started = 2, + /// + /// Indicates that the underlying work has been started and is in progress. + /// + Started = 2, - /// - /// Indicates that the underlying work is in progress; however, the progress is indeterminate, see associated for details. - /// - /// This may occur as a result of a possible retry of processing and as such may or may not be completed; will eventually automatically expire without explicit completion. - Indeterminate = 4, + /// + /// Indicates that the underlying work is in progress; however, the progress is indeterminate, see associated for details. + /// + /// This may occur as a result of a possible retry of processing and as such may or may not be completed; will eventually automatically expire without explicit completion. + Indeterminate = 4, - /// - /// Indicates that the underlying work has been completed successfully. - /// - Completed = 8, + /// + /// Indicates that the underlying work has been completed successfully. + /// + Completed = 8, - /// - /// Indicates that the underlying work has failed. - /// - Failed = 32, + /// + /// Indicates that the underlying work has failed. + /// + Failed = 32, - /// - /// Indicates that the underlying work has expired. - /// - Expired = 64, + /// + /// Indicates that the underlying work has expired. + /// + Expired = 64, - /// - /// Indicates that the underlying work has been cancelled. - /// - Canceled = 128, + /// + /// Indicates that the underlying work has been canceled. + /// + Canceled = 128, - /// - /// Indicates that the underlying work is in progress; either started or indeterminate. - /// - InProgress = Started | Indeterminate, + /// + /// Indicates that the underlying work is in progress; either or . + /// + InProgress = Started | Indeterminate, - /// - /// Indicates that the underlying work is executing; either created or in progress. - /// - Executing = Created | InProgress, + /// + /// Indicates that the underlying work is executing; either or . + /// + Executing = Created | InProgress, - /// - /// Indicates the the underlying work has been terminated; either expired, failed or cancelled. - /// - Terminated = Expired | Failed | Canceled, + /// + /// Indicates the the underlying work has been terminated; either , or . + /// + Terminated = Expired | Failed | Canceled, - /// - /// Indicates that the underlying work has been completed, expired, failed or cancelled. - /// - Finished = Completed | Expired | Failed | Canceled - } -} \ No newline at end of file + /// + /// Indicates that the underlying work has been , , or . + /// + Finished = Completed | Expired | Failed | Canceled +} \ No newline at end of file diff --git a/src/CoreEx/Http/Abstractions/ProblemDetails.cs b/src/CoreEx/Http/Abstractions/ProblemDetails.cs new file mode 100644 index 00000000..8ae7f6a7 --- /dev/null +++ b/src/CoreEx/Http/Abstractions/ProblemDetails.cs @@ -0,0 +1,74 @@ +namespace CoreEx.Http.Abstractions; + +/// +/// Represents a problem details for HTTP APIs based on . +/// +/// This is required for scenarios where there is no explicit ASP.NET Core dependencies to access the out-of-the-box ProblemDetails. +public class ProblemDetails +{ + /// + /// Gets the default key used for validation errors in the dictionary. + /// + /// See . + public const string ErrorsKey = "errors"; + + /// + /// Get or sets a URI reference [RFC3986] that identifies the problem type. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Gets or sets a short, human-readable summary of the problem type. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the status code representing the current state of the operation. + /// + [JsonPropertyName("status")] + public int? Status { get; set; } + + /// + /// Gets or sets the detailed description or additional information associated with the object. + /// + [JsonPropertyName("detail")] + public string? Detail { get; set; } + + /// + /// Gets or sets a URI reference that identifies the specific occurrence of the problem. + /// + [JsonPropertyName("instance")] + public string? Instance { get; set; } + + /// + /// Gets a collection of validation errors, grouped by field name. + /// + /// The key to use to retrieve the validation errors. + /// Each key in the dictionary represents a field or property name, and the associated value is an array of related error messages for that field. + public IDictionary? GetValidationErrors(string? extensionsKey = ErrorsKey) + => Extensions is not null && Extensions.TryGetValue(extensionsKey ?? ErrorsKey, out var errorsObj) && errorsObj is IDictionary errorsDict + ? errorsDict.ToDictionary(kvp => kvp.Key, kvp => (kvp.Value as IEnumerable)?.ToArray() ?? []) + : null; + + /// + /// Gets or sets the extensions for the problem details. + /// + [JsonExtensionData] + public IDictionary? Extensions { get; set; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Gets the error type associated with the problem details; see . + /// + /// This is a -specific extension to provide additional error context. + [JsonPropertyName("errorType")] + public string? ErrorType { get; set; } + + /// + /// Gets the error code associated with the problem details; see . + /// + /// This is a -specific extension to provide additional error context. + [JsonPropertyName("errorCode")] + public string? ErrorCode { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx/Http/Extended/ITypedHttpClientOptions.cs b/src/CoreEx/Http/Extended/ITypedHttpClientOptions.cs deleted file mode 100644 index d2a71fe5..00000000 --- a/src/CoreEx/Http/Extended/ITypedHttpClientOptions.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Http.Extended -{ - /// - /// Provides access to the and . - /// - internal interface ITypedHttpClientOptions - { - /// - /// Gets the default used by all invocations. - /// - TypedHttpClientOptions DefaultOptions { get; } - - /// - /// Gets the used per invocation. - /// - TypedHttpClientOptions SendOptions { get; } - - /// - /// Indicates whether the are being configured. - /// - bool HasSendOptions { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/Extended/ITypedMappedHttpClient.cs b/src/CoreEx/Http/Extended/ITypedMappedHttpClient.cs deleted file mode 100644 index e182be5e..00000000 --- a/src/CoreEx/Http/Extended/ITypedMappedHttpClient.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; -using System; -using System.Net.Http; - -namespace CoreEx.Http.Extended -{ - /// - /// Provides support for a typed . - /// - public interface ITypedMappedHttpClient - { - /// - /// Gets the . - /// - IMapper Mapper { get; } - - /// - /// Maps the value to the . - /// - /// The response . - /// The response HTTP . - /// The . - /// The mapped . - public HttpResult MapResponse(HttpResult httpResult) - => httpResult.ThrowIfNull().IsSuccess - ? new(httpResult.Response, httpResult.BinaryContent, Mapper.Map(httpResult.Value, OperationTypes.Get)!) - : new(httpResult.Response, httpResult.BinaryContent, httpResult.Exception); - - /// - /// Maps the to the . - /// - /// The request . - /// The request HTTP . - /// The request value. - /// The singluar CRUD value being performed. - /// The mapped value. - public TRequestHttp MapRequest(TRequest value, OperationTypes operationType) => value is null ? default! : Mapper.Map(value, operationType); - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs b/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs deleted file mode 100644 index bdae307e..00000000 --- a/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http.Extended -{ - /// - /// Represents the options. - /// - public sealed class TypedHttpClientOptions - { - private readonly TypedHttpClientOptions? _defaultOptions; - private readonly ITypedHttpClientOptions? _owner; - private List? _ensureStatusCodes; - - /// - /// Initializes a new instance of the class. - /// - /// Optional default to copy from; also copied as a result of a . - public TypedHttpClientOptions(TypedHttpClientOptions? defaultOptions = null) - { - _defaultOptions = defaultOptions; - if (_defaultOptions is not null) - Reset(); - } - - /// - /// Initializes a new instance of the Default class. - /// - /// The . - internal TypedHttpClientOptions(ITypedHttpClientOptions owner) - { - _owner = owner; - CheckDefaultNotBeingUpdatedInSendMode(); - } - - /// - /// Indicates whether to ensure success; see . - /// - public bool ShouldEnsureSuccess { get; private set; } - - /// - /// Gets the list of expected status codes; see . - /// - public ReadOnlyCollection? ExpectedStatusCodes => _ensureStatusCodes == null ? null : new(_ensureStatusCodes); - - /// - /// Indicates whether a should be thrown; see . - /// - public bool ShouldThrowTransientException { get; private set; } - - /// - /// Gets the predicate that determines whether is transient; see . - /// - public Func IsTransientPredicate { get; private set; } = TypedHttpClientBase.IsTransient; - - /// - /// Indicates whether a known exception is thrown; see . - /// - public bool ShouldThrowKnownException { get; private set; } - - /// - /// Indicates whether the content should be thrown in the known exception; see . - /// - public bool ShouldThrowKnownUseContentAsMessage { get; private set; } - - /// - /// Indicates that a null/default is to be returned where the response has a of (on only). - /// - public bool ShouldNullOnNotFound { get; private set; } - - /// - /// Gets the function to update the before the request is sent; see . - /// - public Func? BeforeRequest { get; private set; } - - /// - /// Checks whether the default is being updated when in send mode which is not allowed. - /// - private void CheckDefaultNotBeingUpdatedInSendMode() - { - if (_owner is not null && _owner.HasSendOptions) - throw new InvalidOperationException($"The {nameof(ITypedHttpClientOptions.DefaultOptions)} can not be updated where individual {nameof(ITypedHttpClientOptions.SendOptions)} have been configured; must first perform a TypedHttpClientBase.SendAsync or TypedHttpClientBase.Reset to update."); - } - - /// - /// Indicates whether to check the and where considered a transient error then a will be thrown. - /// - /// An optional predicate to determine whether the error is considered transient. Defaults to where not specified. - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - public TypedHttpClientOptions ThrowTransientException(Func? predicate = null) - { - CheckDefaultNotBeingUpdatedInSendMode(); - ShouldThrowTransientException = true; - IsTransientPredicate = predicate ?? TypedHttpClientBase.IsTransient; - return this; - } - - /// - /// Indicates whether to check the and where it matches one of the known values then that will be thrown. - /// - /// Indicates whether to use the as the resulting exception message. - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - public TypedHttpClientOptions ThrowKnownException(bool useContentAsErrorMessage = false) - { - CheckDefaultNotBeingUpdatedInSendMode(); - ShouldThrowKnownException = true; - ShouldThrowKnownUseContentAsMessage = useContentAsErrorMessage; - return this; - } - - /// - /// Indicates whether to automatically perform an . - /// - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - public TypedHttpClientOptions EnsureSuccess() - { - CheckDefaultNotBeingUpdatedInSendMode(); - ShouldEnsureSuccess = true; - return this; - } - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - /// Will result in a where condition is not met. - public TypedHttpClientOptions EnsureOK() => Ensure(HttpStatusCode.OK); - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - /// Will result in a where condition is not met. - public TypedHttpClientOptions EnsureNoContent() => Ensure(HttpStatusCode.NoContent); - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - /// Will result in a where condition is not met. - public TypedHttpClientOptions EnsureAccepted() => Ensure(HttpStatusCode.Accepted); - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - /// Will result in a where condition is not met. - public TypedHttpClientOptions EnsureCreated() => Ensure(HttpStatusCode.Created); - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - /// Will result in a where condition is not met. - public TypedHttpClientOptions EnsureNotFound() => Ensure(HttpStatusCode.NotFound); - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// One or more status codes to be verified. - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - /// Will result in a where condition is not met. - public TypedHttpClientOptions Ensure(params HttpStatusCode[] statusCodes) - { - CheckDefaultNotBeingUpdatedInSendMode(); - if (statusCodes != null && statusCodes.Length > 0) - { - if (_ensureStatusCodes == null) - _ensureStatusCodes = new List(statusCodes); - else - _ensureStatusCodes.AddRange(statusCodes); - } - - return this; - } - - /// - /// Indicates that a null/default is to be returned where the response has a of (on only). - /// - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - public TypedHttpClientOptions NullOnNotFound() - { - CheckDefaultNotBeingUpdatedInSendMode(); - ShouldNullOnNotFound = true; - return this; - } - - /// - /// Sets the function to update the before the request is sent. - /// - /// The function to update the . - /// This instance to support fluent-style method-chaining. - /// This is after each invocation; see . - public TypedHttpClientOptions OnBeforeRequest(Func? beforeRequest) - { - CheckDefaultNotBeingUpdatedInSendMode(); - BeforeRequest = beforeRequest; - return this; - } - - /// - /// Resets the to its default state. - /// - public void Reset() - { - CheckDefaultNotBeingUpdatedInSendMode(); - if (_defaultOptions is null) - { - ShouldThrowTransientException = false; - IsTransientPredicate = TypedHttpClientBase.IsTransient; - ShouldThrowKnownException = false; - ShouldThrowKnownUseContentAsMessage = false; - ShouldEnsureSuccess = false; - _ensureStatusCodes = null; - ShouldNullOnNotFound = false; - BeforeRequest = null; - } - else - { - ShouldThrowTransientException = _defaultOptions.ShouldThrowTransientException; - IsTransientPredicate = _defaultOptions.IsTransientPredicate; - ShouldThrowKnownException = _defaultOptions.ShouldThrowKnownException; - ShouldThrowKnownUseContentAsMessage = _defaultOptions.ShouldThrowKnownUseContentAsMessage; - ShouldEnsureSuccess = _defaultOptions.ShouldEnsureSuccess; - _ensureStatusCodes = _defaultOptions.ExpectedStatusCodes == null ? null : new(_defaultOptions.ExpectedStatusCodes); - ShouldNullOnNotFound = _defaultOptions.ShouldNullOnNotFound; - BeforeRequest = _defaultOptions.BeforeRequest; - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/Extended/TypedMappedHttpClient.cs b/src/CoreEx/Http/Extended/TypedMappedHttpClient.cs deleted file mode 100644 index 9890d86a..00000000 --- a/src/CoreEx/Http/Extended/TypedMappedHttpClient.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using CoreEx.Mapping; -using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http.Extended -{ - /// - /// Provides a basic typed implementation (see ) that supports , , , , and . - /// - public sealed class TypedMappedHttpClient : TypedMappedHttpClientCore - { - /// - /// Initializes a new instance of the . - /// - /// The underlying . - /// The optional . - /// The optional . Defaults to . - /// The optional . Defaults to a new instance. - /// The optional function. Defaults to null. - /// is used to default each parameter to a configured service where present before final described defaults. - public TypedMappedHttpClient(HttpClient client, IMapper? mapper = null, IJsonSerializer? jsonSerializer = null, ExecutionContext? executionContext = null, Func? onBeforeRequest = null) - : base(client, mapper, jsonSerializer, executionContext) - => DefaultOptions.OnBeforeRequest(onBeforeRequest); - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/Extended/TypedMappedHttpClientBase.cs b/src/CoreEx/Http/Extended/TypedMappedHttpClientBase.cs deleted file mode 100644 index 77db1126..00000000 --- a/src/CoreEx/Http/Extended/TypedMappedHttpClientBase.cs +++ /dev/null @@ -1,245 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using CoreEx.Mapping; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http.Extended -{ - /// - /// Represents a typed base wrapper with request/response support. - /// - /// The self for support fluent-style method-chaining. - /// The underlying . - /// The optional . - /// The optional . Defaults to . - /// The optional . Defaults to a new instance. - /// is used to default each parameter to a configured service where present before final described defaults. - public abstract class TypedMappedHttpClientBase(HttpClient client, IMapper? mapper = null, IJsonSerializer? jsonSerializer = null, ExecutionContext? executionContext = null) - : TypedHttpClientBase(client, jsonSerializer, executionContext), ITypedMappedHttpClient where TSelf : TypedMappedHttpClientBase - { - /// - /// Gets the . - /// - public IMapper Mapper { get; } = mapper ?? ExecutionContext.GetService() ?? throw new ArgumentNullException(nameof(mapper)); - - /// - /// Maps the value to the . - /// - /// The response . - /// The response HTTP . - /// The . - /// The mapped . - protected HttpResult MapResponse(HttpResult httpResult) => (this as ITypedMappedHttpClient).MapResponse(httpResult); - - /// - /// Maps the to the . - /// - /// The request . - /// The request HTTP . - /// The request value. - /// The singluar CRUD value being performed. - /// The mapped value. - protected TRequestHttp MapRequest(TRequest value, OperationTypes operationType) => (this as ITypedMappedHttpClient).MapRequest(value, operationType); - - /// - /// Send a request to the specified Uri as an asynchronous operation and deserialize the JSON to the specified (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - protected async Task> GetMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> GetMappedAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await GetAsync(requestUri, requestOptions, args, cancellationToken).ConfigureAwait(false)); - - #region PostMappedAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified (mapped to ). - /// - /// The request . - /// The request HTTP . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected Task PostMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected Task PostMappedAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => PostAsync(requestUri, MapRequest(value, OperationTypes.Create), requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation and deserialize the response JSON to (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - protected async Task> PostMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PostMappedAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PostAsync(requestUri, requestOptions, args, cancellationToken).ConfigureAwait(false)); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - protected async Task> PostMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PostMappedAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PostAsync(requestUri, content, requestOptions, args, cancellationToken).ConfigureAwait(false)); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified (mapped to ) and deserialize the response JSON to (mapped from ). - /// - /// The request . - /// The request HTTP . - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - protected async Task> PostMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PostMappedAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PostAsync(requestUri, MapRequest(value, OperationTypes.Create), requestOptions, args, cancellationToken).ConfigureAwait(false)); - - #endregion - - #region PutMappedAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - protected async Task> PutMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PutMappedAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PutAsync(requestUri, content, requestOptions, args, cancellationToken).ConfigureAwait(false)); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified (mapped to ). - /// - /// The request . - /// The request HTTP . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected Task PutMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected Task PutMappedAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => PutAsync(requestUri, MapRequest(value, OperationTypes.Create), requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified (mapped to ) and deserialize the response JSON to (mapped from ). - /// - /// The request . - /// The request HTTP . - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - protected async Task> PutMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PutMappedAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PutAsync(requestUri, MapRequest(value, OperationTypes.Create), requestOptions, args, cancellationToken).ConfigureAwait(false)); - - #endregion - - #region PatchMappedAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - protected async Task> PatchMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PatchMappedAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PatchAsync(requestUri, content, requestOptions, args, cancellationToken)); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The . - /// The JSON formatted as per the selected . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - protected async Task> PatchMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpPatchOption patchOption, [StringSyntax(StringSyntaxAttribute.Json)] string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PatchMappedAsync(string requestUri, HttpPatchOption patchOption, string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PatchAsync(requestUri, patchOption, json, requestOptions, args, cancellationToken)); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/Extended/TypedMappedHttpClientCore.cs b/src/CoreEx/Http/Extended/TypedMappedHttpClientCore.cs deleted file mode 100644 index cdde42e6..00000000 --- a/src/CoreEx/Http/Extended/TypedMappedHttpClientCore.cs +++ /dev/null @@ -1,245 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using CoreEx.Mapping; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http.Extended -{ - /// - /// Represents a typed base wrapper that supports , , , , and . - /// - /// The self for support fluent-style method-chaining. - /// The underlying . - /// The optional . - /// The optional . Defaults to . - /// The optional . Defaults to a new instance. - /// is used to default each parameter to a configured service where present before final described defaults. - public abstract class TypedMappedHttpClientCore(HttpClient client, IMapper? mapper = null, IJsonSerializer? jsonSerializer = null, ExecutionContext? executionContext = null) - : TypedHttpClientCore(client, jsonSerializer, executionContext), ITypedMappedHttpClient where TSelf : TypedMappedHttpClientCore - { - /// - /// Gets the . - /// - public IMapper Mapper { get; } = mapper ?? ExecutionContext.GetService() ?? throw new ArgumentNullException(nameof(mapper)); - - /// - /// Maps the value to the . - /// - /// The response . - /// The response HTTP . - /// The . - /// The mapped . - protected HttpResult MapResponse(HttpResult httpResult) => (this as ITypedMappedHttpClient).MapResponse(httpResult); - - /// - /// Maps the to the . - /// - /// The request . - /// The request HTTP . - /// The request value. - /// The singluar CRUD value being performed. - /// The mapped value. - protected TRequestHttp MapRequest(TRequest value, OperationTypes operationType) => (this as ITypedMappedHttpClient).MapRequest(value, operationType); - - /// - /// Send a request to the specified Uri as an asynchronous operation and deserialize the JSON to the specified (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - public async Task> GetMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public async Task> GetMappedAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await GetAsync(requestUri, requestOptions, args, cancellationToken).ConfigureAwait(false)); - - #region PostMappedAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified (mapped to ). - /// - /// The request . - /// The request HTTP . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public Task PostMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public Task PostMappedAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => PostAsync(requestUri, MapRequest(value, OperationTypes.Create), requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation and deserialize the response JSON to (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - public async Task> PostMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public async Task> PostMappedAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PostAsync(requestUri, requestOptions, args, cancellationToken).ConfigureAwait(false)); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - public async Task> PostMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public async Task> PostMappedAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PostAsync(requestUri, content, requestOptions, args, cancellationToken).ConfigureAwait(false)); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified (mapped to ) and deserialize the response JSON to (mapped from ). - /// - /// The request . - /// The request HTTP . - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - public async Task> PostMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public async Task> PostMappedAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PostAsync(requestUri, MapRequest(value, OperationTypes.Create), requestOptions, args, cancellationToken).ConfigureAwait(false)); - - #endregion - - #region PutMappedAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - public async Task> PutMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public async Task> PutMappedAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PutAsync(requestUri, content, requestOptions, args, cancellationToken).ConfigureAwait(false)); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified (mapped to ). - /// - /// The request . - /// The request HTTP . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public Task PutMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public Task PutMappedAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => PutAsync(requestUri, MapRequest(value, OperationTypes.Create), requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified (mapped to ) and deserialize the response JSON to (mapped from ). - /// - /// The request . - /// The request HTTP . - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - public async Task> PutMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public async Task> PutMappedAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PutAsync(requestUri, MapRequest(value, OperationTypes.Create), requestOptions, args, cancellationToken).ConfigureAwait(false)); - - #endregion - - #region PatchMappedAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - public async Task> PatchMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public async Task> PatchMappedAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PatchAsync(requestUri, content, requestOptions, args, cancellationToken)); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to (mapped from ). - /// - /// The response . - /// The response HTTP . - /// The Uri the request is sent to. - /// The . - /// The JSON formatted as per the selected . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The mapped . -#if NET7_0_OR_GREATER - public async Task> PatchMappedAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpPatchOption patchOption, [StringSyntax(StringSyntaxAttribute.Json)] string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public async Task> PatchMappedAsync(string requestUri, HttpPatchOption patchOption, string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => MapResponse(await PatchAsync(requestUri, patchOption, json, requestOptions, args, cancellationToken)); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HealthChecks/TypedHttpClientCoreHealthCheck.cs b/src/CoreEx/Http/HealthChecks/TypedHttpClientCoreHealthCheck.cs deleted file mode 100644 index 01a72555..00000000 --- a/src/CoreEx/Http/HealthChecks/TypedHttpClientCoreHealthCheck.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace CoreEx.Http.HealthChecks -{ - /// - /// Health check for typed inheriting from . - /// - public class TypedHttpClientCoreHealthCheck : IHealthCheck where T : TypedHttpClientCore - { - private readonly T? _client; - private readonly IReadOnlyDictionary _data; - - /// - /// Initializes a new instance of the class. - /// - public TypedHttpClientCoreHealthCheck(T client) - { - _client = client; - _data = new Dictionary - { - { "host", _client?.BaseAddress?.Host ?? "unknown" } - }; - } - - /// - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - if (_client == null) - return new HealthCheckResult(context.Registration.FailureStatus, $"Typed Http client dependency for '{typeof(T)}' not resolved.", data: _data); - - try - { - var result = await _client.HealthCheckAsync(cancellationToken); - result.Response.EnsureSuccessStatusCode(); - return HealthCheckResult.Healthy(data: _data); - } - catch (Exception ex) - { - return new HealthCheckResult(context.Registration.FailureStatus, exception: ex, data: _data); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HealthChecks/TypedHttpClientHealthCheck.cs b/src/CoreEx/Http/HealthChecks/TypedHttpClientHealthCheck.cs deleted file mode 100644 index 53659d16..00000000 --- a/src/CoreEx/Http/HealthChecks/TypedHttpClientHealthCheck.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace CoreEx.Http.HealthChecks -{ - /// - /// Health check for typed inheriting from . - /// - public class TypedHttpClientHealthCheck : IHealthCheck where T : TypedHttpClientBase - { - private readonly T? _client; - private readonly IReadOnlyDictionary _data; - - /// - /// Initializes a new instance of the class. - /// - public TypedHttpClientHealthCheck(T client) - { - _client = client; - _data = new Dictionary - { - { "host", _client?.BaseAddress?.Host ?? "unknown" } - }; - } - - /// - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - if (_client == null) - return new HealthCheckResult(context.Registration.FailureStatus, $"Typed Http client dependency for '{typeof(T)}' not resolved.", data: _data); - - try - { - var result = await _client.HealthCheckAsync(cancellationToken); - result.Response.EnsureSuccessStatusCode(); - return HealthCheckResult.Healthy(data: _data); - } - catch (Exception ex) - { - return new HealthCheckResult(context.Registration.FailureStatus, exception: ex, data: _data); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpArg.cs b/src/CoreEx/Http/HttpArg.cs deleted file mode 100644 index a5c377f2..00000000 --- a/src/CoreEx/Http/HttpArg.cs +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Json; -using CoreEx.RefData; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Globalization; -using System.Net.Http; -using System.Net.Mime; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http -{ - /// - /// Represents an argument for an where updating or (body). - /// - /// The . - /// The argument . - /// The argument . - /// The . - public class HttpArg(string name, T value, HttpArgType argType = HttpArgType.FromUri) : IHttpArgTypeArg - { - private bool _isUsed = false; - - /// - /// Gets the argument name. - /// - public string Name { get; } = name.ThrowIfNull(nameof(name)); - - /// - /// Gets the that determines how the argument is applied. - /// - public HttpArgType ArgType { get; } = argType; - - /// - /// Gets the argument value. - /// - public T Value { get; } = value; - - /// - public string? ToEscapeDataString() - { - if (ArgType != HttpArgType.FromUri) - return null; - - _isUsed = true; - string? str; - if (Value == null) - return null; - else if (Value is IReferenceData rd) - str = rd?.Code; - else if (Value is DateTime dt) - str = dt.ToString("o", CultureInfo.InvariantCulture); - else if (Value is DateTimeOffset dto) - str = dto.ToString("o", CultureInfo.InvariantCulture); - else if (Value is bool b) - str = b.ToString().ToLowerInvariant(); - else if (Value is IFormattable fmt) - str = fmt.ToString(null, CultureInfo.InvariantCulture); - else - str = Value.ToString(); - - return str == null ? null : Uri.EscapeDataString(str); - } - - /// - public void AddToQueryString(NameValueCollection queryString, IJsonSerializer? jsonSerializer = null) - { - if (_isUsed || ArgType == HttpArgType.FromBody || Comparer.Default.Compare(Value, default!) == 0) - return; - - if (AddNameValue(queryString, Name, Value)) - return; - - if (Value is IEnumerable enumerable) - { - foreach (var v in enumerable) - { - if (v != null) - { - if (!AddNameValue(queryString, Name, v) && (ArgType == HttpArgType.FromUriUseProperties || ArgType == HttpArgType.FromUriUsePropertiesAndPrefix)) - AddComplexType(queryString, Value, jsonSerializer); - } - } - - return; - } - - if (ArgType == HttpArgType.FromUriUseProperties || ArgType == HttpArgType.FromUriUsePropertiesAndPrefix) - AddComplexType(queryString, Value, jsonSerializer); - } - - /// - /// Adds the named value. - /// - private static bool AddNameValue(NameValueCollection queryString, string name, object? value) - { - if (value == null) - return true; - - if (value is string str) - return AddNameValue(queryString, name, str); - - if (value is char ch) - return AddNameValue(queryString, name, ch.ToString()); - - if (value is DateTime dt) - return AddNameValue(queryString, name, dt.ToString("o", CultureInfo.InvariantCulture)); - - if (value is DateTimeOffset dto) - return AddNameValue(queryString, name, dto.ToString("o", CultureInfo.InvariantCulture)); - - if (value is IReferenceData rd && rd.Code is not null) - return AddNameValue(queryString, name, rd.Code); - - if (value is Enum en) - return AddNameValue(queryString, name, en.ToString()); - - if (value is bool bo) - return AddNameValue(queryString, name, bo.ToString().ToLowerInvariant()); - - if (value is IFormattable fmt) - return AddNameValue(queryString, name, fmt.ToString(null, CultureInfo.InvariantCulture)); - - return false; - } - - /// - /// Adds the name and value - /// - private static bool AddNameValue(NameValueCollection queryString, string name, string value) - { - queryString.Add(name, value); - return true; - } - - /// - /// Adds the complex type to the query string. - /// - private void AddComplexType(NameValueCollection queryString, object? value, IJsonSerializer? jsonSerializer) - { - if (value == null) - return; - - var tr = TypeReflector.GetReflector(new TypeReflectorArgs(jsonSerializer), value.GetType()); - foreach (var pr in tr.GetProperties()) - { - var pv = pr.PropertyInfo.GetValue(value, null); - var name = $"{(ArgType == HttpArgType.FromUriUsePropertiesAndPrefix ? $"{Name}." : "")}{pr.JsonName ?? pr.Name}"; - if (pv is not string && pv is IEnumerable ie) - { - foreach (var iv in ie) - { - if (!AddNameValue(queryString, name, iv)) - throw new InvalidOperationException($"Type '{tr.Type.Name}' cannot be serialized to a URI; Type should be passed using Request Body [FromBody] given complexity."); - } - } - else - { - if (!AddNameValue(queryString, name, pv)) - throw new InvalidOperationException($"Type '{tr.Type.Name}' cannot be serialized to a URI; Type should be passed using Request Body [FromBody] given complexity."); - } - } - } - - /// - public Task ModifyHttpRequestAsync(HttpRequestMessage request, IJsonSerializer jsonSerializer, CancellationToken cancellationToken = default) - { - if (request.Content != null || ArgType != HttpArgType.FromBody || Value == null) - return Task.CompletedTask; - - request.Content = new StringContent(jsonSerializer.Serialize(Value, JsonWriteFormat.None), Encoding.UTF8, MediaTypeNames.Application.Json); - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpArgType.cs b/src/CoreEx/Http/HttpArgType.cs deleted file mode 100644 index 5ade5982..00000000 --- a/src/CoreEx/Http/HttpArgType.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Http -{ - /// - /// Defines the . - /// - public enum HttpArgType - { - /// - /// Indicates the argument should be passed in the body. - /// - FromBody, - - /// - /// Indicates the argument should be passed as part of the URI query string. - /// - FromUri, - - /// - /// Indicates the properties of the argument should be passed as part of the URI query string with no prefix. - /// - FromUriUseProperties, - - /// - /// Indicates the properties of the argument should be passed as part of the URI query string with a prefix. - /// - FromUriUsePropertiesAndPrefix - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpArgs.cs b/src/CoreEx/Http/HttpArgs.cs deleted file mode 100644 index c9ae5736..00000000 --- a/src/CoreEx/Http/HttpArgs.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System.Collections.Generic; -using System.Linq; - -namespace CoreEx.Http -{ - /// - /// Provides helpers. - /// - public static class HttpArgs - { - /// - /// Creates an from the passed . - /// - /// The arguments. - /// The . - public static IEnumerable Create(params IHttpArg[] args) => args.AsEnumerable(); - - /// - /// Includes by updating . - /// - /// The (can be null). - /// The . - /// The . - /// Will create a new where the is null and the corresponding is not null; otherwise, overrides the - /// existing . - public static HttpRequestOptions? IncludePaging(this HttpRequestOptions? requestOptions, PagingArgs? paging) - { - if (requestOptions == null && paging == null) - return requestOptions; - - requestOptions ??= new HttpRequestOptions(); - requestOptions.WithPaging(paging); - return requestOptions; - } - - /// - /// Includes the by updating . - /// - /// The (can be null). - /// The . - /// The . - /// Will create a new where the is null and the corresponding is not null; otherwise, overrides the - /// existing . - public static HttpRequestOptions? IncludeQuery(this HttpRequestOptions? requestOptions, QueryArgs? query) - { - if (requestOptions == null && query == null) - return requestOptions; - - requestOptions ??= new HttpRequestOptions(); - requestOptions.WithQuery(query); - return requestOptions; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpConsts.cs b/src/CoreEx/Http/HttpConsts.cs deleted file mode 100644 index 2950cbdd..00000000 --- a/src/CoreEx/Http/HttpConsts.cs +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System.Collections.Generic; - -namespace CoreEx.Http -{ - /// - /// Provides HTTP configurable consts for Headers and QueryString. - /// - public static class HttpConsts - { - #region HeaderName - - /// - /// Gets or sets the header name for the exception error type value. - /// - public static string ErrorTypeHeaderName { get; set; } = "x-error-type"; - - /// - /// Gets or sets the header name for the exception error code value. - /// - public static string ErrorCodeHeaderName { get; set; } = "x-error-code"; - - /// - /// Gets or sets the header name for the . - /// - public static string PagingPageNumberHeaderName { get; set; } = "x-paging-page-number"; - - /// - /// Gets or sets the header name for the . - /// - public static string PagingPageSizeHeaderName { get; set; } = "x-paging-page-size"; - - /// - /// Gets or sets the header name for the . - /// - public static string PagingSkipHeaderName { get; set; } = "x-paging-skip"; - - /// - /// Gets or sets the header name for the . - /// - public static string PagingTakeHeaderName { get; set; } = "x-paging-take"; - - /// - /// Gets or sets the header name for the . - /// - public static string PagingTokenHeaderName { get; set; } = "x-paging-token"; - - /// - /// Gets or sets the header name for the . - /// - public static string PagingTotalCountHeaderName { get; set; } = "x-paging-total-count"; - - /// - /// Gets or sets the header name for the . - /// - public static string PagingTotalPagesHeaderName { get; set; } = "x-paging-total-pages"; - - /// - /// Gets or sets the header name for the messages. - /// - public static string MessagesHeaderName { get; set; } = "x-messages"; - - /// - /// Gets or sets the header name for the . - /// - public static string CorrelationIdHeaderName { get; set; } = "x-correlation-id"; - - #endregion - - #region QueryStringName - - /// - /// Gets or sets the query string name. - /// - public static string IncludeFieldsQueryStringName { get; set; } = "$fields"; - - /// - /// Gets or sets the query string name. - /// - public static string ExcludeFieldsQueryStringName { get; set; } = "$exclude"; - - /// - /// Gets or sets the query string name. - /// - public static string PagingArgsPageQueryStringName { get; set; } = "$page"; - - /// - /// Gets or sets the query string name. - /// - public static string PagingArgsSizeQueryStringName { get; set; } = "$size"; - - /// - /// Gets or sets the query string name. - /// - public static string PagingArgsSkipQueryStringName { get; set; } = "$skip"; - - /// - /// Gets or sets the query string name. - /// - public static string PagingArgsTakeQueryStringName { get; set; } = "$take"; - - /// - /// Gets or sets the query string name. - /// - public static string PagingArgsTokenQueryStringName { get; set; } = "$token"; - - /// - /// Gets or sets the query string name. - /// - public static string PagingArgsCountQueryStringName { get; set; } = "$count"; - - /// - /// Gets or sets the query string name. - /// - public static string QueryArgsFilterQueryStringName { get; set; } = "$filter"; - - /// - /// Gets or sets the query string name. - /// - public static string QueryArgsOrderByQueryStringName { get; set; } = "$orderby"; - - /// - /// Gets or sets the query string name. - /// - /// See . - public static string IncludeTextQueryStringName { get; set; } = "$text"; - - /// - /// Gets or sets the query string name. - /// - public static string IncludeInactiveQueryStringName { get; set; } = "$inactive"; - - #endregion - - #region QueryStringNames - - /// - /// Gets or sets the list of possible query string names. - /// - public static List PagingArgsPageQueryStringNames { get; set; } = new List(["$page", "$pageNumber", "paging-page"]); - - /// - /// Gets or sets the list of possible query string names. - /// - public static List PagingArgsSkipQueryStringNames { get; set; } = new List(["$skip", "$offset", "paging-skip"]); - - /// - /// Gets or sets the list of possible query string names. - /// - public static List PagingArgsTakeQueryStringNames { get; set; } = new List(["$take", "$top", "$size", "$pageSize", "$limit", "paging-take", "paging-size", "paging-limit"]); - - /// - /// Gets or sets the list of possible query string names. - /// - public static List PagingArgsTokenQueryStringNames { get; set; } = new List(["$token", "$after", "$cursor", "paging-token", "paging-after", "paging-cursor"]); - - /// - /// Gets or sets the list of possible query string names. - /// - public static List PagingArgsCountQueryStringNames { get; set; } = new List(["$count", "$totalCount", "paging-count"]); - - /// - /// Gets or sets the list of possible query string names. - /// - public static List QueryArgsFilterQueryStringNames { get; set; } = new List(["$filter"]); - - /// - /// Gets or sets the list of possible query string names. - /// - public static List QueryArgsOrderByQueryStringNames { get; set; } = new List(["$orderby", "$order-by"]); - - /// - /// Gets or sets the list of possible query string names. - /// - public static List IncludeFieldsQueryStringNames { get; set; } = new List(["$fields", "$includeFields", "$include", "include-fields"]); - - /// - /// Gets or sets the list of possible query string names. - /// - public static List ExcludeFieldsQueryStringNames { get; set; } = new List(["$excludeFields", "$exclude", "exclude-fields"]); - - /// - /// Gets or sets the list of possible query string names. - /// - public static List IncludeTextQueryStringNames { get; set; } = new List(["$text", "$includeText", "include-text"]); - - /// - /// Gets or sets the list of possible query string names. - /// - public static List IncludeInactiveQueryStringNames { get; set; } = new List(["$inactive", "$includeInactive", "include-inactive"]); - - #endregion - - #region MediaTypeName - - /// - /// Gets the media type name. - /// - public const string JsonPatchMediaTypeName = "application/json-patch+json"; - - /// - /// Gets the media type name. - /// - public const string MergePatchMediaTypeName = "application/merge-patch+json"; - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpExtensions.cs b/src/CoreEx/Http/HttpExtensions.cs deleted file mode 100644 index 26ebbae8..00000000 --- a/src/CoreEx/Http/HttpExtensions.cs +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using CoreEx.Http.Extended; -using CoreEx.Json; -using CoreEx.Mapping; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http -{ - /// - /// HTTP-related Extension methods. - /// - public static class HttpExtensions - { - /// - /// Creates a for the . - /// - /// The underlying . - /// The optional . Defaults to . - /// The optional . Defaults to a new instance. - /// The optional function. Defaults to null. - /// is used to default each parameter to a configured service where present before final described defauls. - /// The . - public static TypedHttpClient CreateTypedClient(this HttpClient httpClient, IJsonSerializer? jsonSerializer = null, ExecutionContext? executionContext = null, Func? onBeforeRequest = null) - => new(httpClient, jsonSerializer, executionContext, onBeforeRequest); - - /// - /// Creates a for the . - /// - /// The underlying . - /// The . - /// The optional . Defaults to . - /// The optional . Defaults to a new instance. - /// The optional function. Defaults to null. - /// is used to default each parameter to a configured service where present before final described defauls. - /// The . - public static TypedMappedHttpClient CreateTypedMappedClient(this HttpClient httpClient, IMapper? mapper = null, IJsonSerializer? jsonSerializer = null, ExecutionContext? executionContext = null, Func? onBeforeRequest = null) - => new(httpClient, mapper, jsonSerializer, executionContext, onBeforeRequest); - - /// - /// Applies the to the . - /// - /// The . - /// The . - /// The to support fluent-style method-chaining. - /// This will automatically invoke where there is an value. - public static HttpRequestMessage ApplyRequestOptions(this HttpRequestMessage httpRequest, HttpRequestOptions? requestOptions) - { - httpRequest.ThrowIfNull(nameof(httpRequest)); - - if (requestOptions == null) - return httpRequest; - - // Apply the ETag header. - ApplyETag(httpRequest, requestOptions.ETag); - - // Apply updates to the query string. - var qs = requestOptions.AddToQueryString(httpRequest.RequestUri?.Query); - var ub = httpRequest.RequestUri == null ? new UriBuilder() : new UriBuilder(httpRequest.RequestUri); - if (qs is not null) - ub.Query = qs; - - httpRequest.RequestUri = ub.Uri; - return httpRequest; - } - - /// - /// Applies the ETag to the as an (where is - /// or ); otherwise, an . - /// - /// The . - /// The ETag value. - /// The to support fluent-style method-chaining. - /// Automatically adds quoting to be ETag format compliant and sets the ETag as weak ('W/'). - public static HttpRequestMessage ApplyETag(this HttpRequestMessage httpRequest, string? etag) - { - // Apply the ETag header. - if (!string.IsNullOrEmpty(etag)) - { - if (httpRequest.Method == HttpMethod.Get || httpRequest.Method == HttpMethod.Head) - httpRequest.Headers.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(ETagGenerator.FormatETag(etag)!, true)); - else - httpRequest.Headers.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(ETagGenerator.FormatETag(etag)!, true)); - } - - return httpRequest; - } - - /// - /// Trys to get the from the headers. - /// - /// The . - /// The where found. - /// true where the is found; otherwise, false. - public static bool TryGetPagingResult(this HttpResponseMessage response, [NotNullWhen(true)] out PagingResult? result) - { - var skip = ParseLongValue(TryGetHeaderValue(response, HttpConsts.PagingSkipHeaderName, out var vs) ? vs : null); - var page = skip.HasValue ? null : ParseLongValue(TryGetHeaderValue(response, HttpConsts.PagingPageNumberHeaderName, out var vpn) ? vpn : null); - var token = TryGetHeaderValue(response, HttpConsts.PagingTokenHeaderName, out var vtk) ? vtk : null; - - if (!string.IsNullOrEmpty(token)) - result = new PagingResult(PagingArgs.CreateTokenAndTake(token, ParseLongValue(TryGetHeaderValue(response, HttpConsts.PagingTakeHeaderName, out var vt) ? vt : null))); - else if (skip.HasValue) - result = new PagingResult(PagingArgs.CreateSkipAndTake(skip.Value, ParseLongValue(TryGetHeaderValue(response, HttpConsts.PagingTakeHeaderName, out var vt) ? vt : null))); - else if (page.HasValue) - result = new PagingResult(PagingArgs.CreatePageAndSize(page.Value, ParseLongValue(TryGetHeaderValue(response, HttpConsts.PagingPageSizeHeaderName, out var vps) ? vps : null))); - else - { - result = null; - return false; - } - - result.TotalCount = ParseLongValue(TryGetHeaderValue(response, HttpConsts.PagingTotalCountHeaderName, out var vtc) ? vtc : null); - return true; - } - - /// - /// Trys to get the first named value from the . - /// - /// The . - /// The header name. - /// The header value where found. - /// true where the header value is found; otherwise, false. - public static bool TryGetHeaderValue(this HttpResponseMessage response, string name, [NotNullWhen(true)] out string? value) - { - value = null; - if (response == null || response.Headers == null || string.IsNullOrEmpty(name)) - return false; - - if (response.Headers.TryGetValues(name, out IEnumerable? values)) - { - value = values.First(); - return true; - } - - return false; - } - - /// - /// Converts the to the equivalent based on the . - /// - /// The . - /// Indicates whether to use the as the resulting exception message. - /// The . - /// The corresponding where applicable; otherwise, null. - public async static Task ToExtendedExceptionAsync(this HttpResponseMessage response, bool useContentAsErrorMessage = true, CancellationToken cancellationToken = default) - { - if (response == null || response.IsSuccessStatusCode) - return null; - -#if NETSTANDARD2_1 - var content = response.Content == null ? null : await response.Content.ReadAsStringAsync().ConfigureAwait(false); -#else - var content = response.Content == null ? null : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); -#endif - if (string.IsNullOrEmpty(content)) - content = $"Response status code does not indicate success: {(int)response.StatusCode} ({(string.IsNullOrEmpty(response.ReasonPhrase) ? response.StatusCode : response.ReasonPhrase)})."; - - return HttpResultBase.CreateExtendedException(response, content, useContentAsErrorMessage); - } - - /// - /// Parses the value as a . - /// - public static long? ParseLongValue(string? value) - { - if (value == null) - return null; - - if (!long.TryParse(value, out long val)) - return null; - - return val; - } - - /// - /// Parses the value as a . - /// - public static bool ParseBoolValue(string? value) - { - if (value == null) - return false; - - if (!bool.TryParse(value, out bool val)) - return false; - - return val; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpNames.cs b/src/CoreEx/Http/HttpNames.cs new file mode 100644 index 00000000..f4b86a37 --- /dev/null +++ b/src/CoreEx/Http/HttpNames.cs @@ -0,0 +1,102 @@ +namespace CoreEx.Http; + +/// +/// Provides the standard names for HTTP headers and query strings. +/// +public static class HttpNames +{ + /// + /// Gets or sets the name. + /// + public static string PagingSkipQueryStringName { get; set; } = "$skip"; + + /// + /// Gets or sets the name. + /// + public static string PagingTakeQueryStringName { get; set; } = "$take"; + + /// + /// Gets or sets the name. + /// + public static string PagingCountQueryStringName { get; set; } = "$count"; + + /// + /// Gets or sets the name. + /// + public static string PagingSkipHeaderName { get; set; } = "X-Paging-Skip"; + + /// + /// Gets or sets the name. + /// + public static string PagingTakeHeaderName { get; set; } = "X-Paging-Take"; + + /// + /// Gets or sets the name. + /// + public static string PagingTotalCountHeaderName { get; set; } = "X-Paging-Total-Count"; + + /// + /// Gets or sets the name. + /// + public static string QueryFilterQueryStringName { get; set; } = "$filter"; + + /// + /// Gets or sets the name. + /// + public static string QueryOrderByQueryStringName { get; set; } = "$orderby"; + + /// + /// Gets or sets the name. + /// + public static string IncludeFieldsQueryStringName { get; set; } = "$fields"; + + /// + /// Gets or sets the name. + /// + public static string ExcludeFieldsQueryStringName { get; set; } = "$exclude"; + + /// + /// Gets or sets the name. + /// + public static string IncludeTextQueryStringName { get; set; } = "$text"; + + /// + /// Gets or sets the name. + /// + public static string IncludeInactiveQueryStringName { get; set; } = "$inactive"; + + /// + /// Gets or sets the problem extensions name. + /// + public static string ErrorTypeName { get; set; } = "errorType"; + + /// + /// Gets or sets the problem extensions name. + /// + public static string ErrorCodeName { get; set; } = "errorCode"; + + /// + /// Gets or sets the tracing problem extensions name. + /// + public static string TraceIdName { get; set; } = "traceId"; + + /// + /// Gets or sets the (s) name. + /// + public static string WarningMessagesHeaderName { get; set; } = "X-Warning-Messages"; + + /// + /// Getsor sets the (s) name. + /// + public static string InfoMessagesHeaderName { get; set; } = "X-Info-Messages"; + + /// + /// Gets the JSON Merge Patch name as per . + /// + public const string MergePatchJsonMediaTypeName = "application/merge-patch+json"; + + /// + /// Gets the Idempotency Key name. + /// + public const string IdempotencyKeyHeaderName = "Idempotency-Key"; +} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpPatchOption.cs b/src/CoreEx/Http/HttpPatchOption.cs deleted file mode 100644 index fa8d0d84..00000000 --- a/src/CoreEx/Http/HttpPatchOption.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Net.Http; - -namespace CoreEx.Http -{ - /// - /// Specifies the option. - /// - public enum HttpPatchOption - { - /// - /// Indicates that no valid patch option has been specified. - /// - NotSpecified, - - /// - /// Indicates a json-patch. Requires a Content-Type of 'application/json-patch+json'. See https://tools.ietf.org/html/rfc6902 for more details. - /// - JsonPatch, - - /// - /// Indicates a merge-patch. Requires a Content-Type of 'application/merge-patch+json'. See https://tools.ietf.org/html/rfc7396 for more details. - /// - MergePatch - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpRequestOptions.cs b/src/CoreEx/Http/HttpRequestOptions.cs deleted file mode 100644 index 6cd811bd..00000000 --- a/src/CoreEx/Http/HttpRequestOptions.cs +++ /dev/null @@ -1,330 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.RefData; -using System; -using System.Collections.Specialized; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Web; - -namespace CoreEx.Http -{ - /// - /// Represents additional (optional) request options for an . - /// - /// Usage assumes that the HTTP endpoint supports and actions accordingly; i.e. by sending there is no guarantee that the desired outcome will occur as selected. - public class HttpRequestOptions - { - /// - /// Creates a new instance of the class. - /// - /// The optional . - /// The . - public static HttpRequestOptions Create(PagingArgs? paging = null) => new() { Paging = paging }; - - /// - /// Gets or sets the query string name. - /// - /// Defaults to . - public string QueryStringNameIncludeFields { get; set; } = HttpConsts.IncludeFieldsQueryStringName; - - /// - /// Gets or sets the query string name. - /// - /// Defaults to . - public string QueryStringNameExcludeFields { get; set; } = HttpConsts.ExcludeFieldsQueryStringName; - - /// - /// Gets or sets the query string name. - /// - /// Defaults to . - public string QueryStringNamePagingArgsPage { get; set; } = HttpConsts.PagingArgsPageQueryStringName; - - /// - /// Gets or sets the query string name. - /// - /// Defaults to . - public string QueryStringNamePagingArgsSize { get; set; } = HttpConsts.PagingArgsSizeQueryStringName; - - /// - /// Gets or sets the query string name. - /// - /// Defaults to . - public string QueryStringNamePagingArgsSkip { get; set; } = HttpConsts.PagingArgsSkipQueryStringName; - - /// - /// Gets or sets the query string name. - /// - /// Defaults to . - public string QueryStringNamePagingArgsTake { get; set; } = HttpConsts.PagingArgsTakeQueryStringName; - - /// - /// Gets or sets the query string name. - /// - /// Defaults to . - public string QueryStringNamePagingArgsToken { get; set; } = HttpConsts.PagingArgsTokenQueryStringName; - - /// - /// Gets or sets the query string name. - /// - /// Defaults to . - public string QueryStringNamePagingArgsCount { get; set; } = HttpConsts.PagingArgsCountQueryStringName; - - /// - /// Gets or sets the query string name. - /// - /// Defaults to . - public string QueryStringNameIncludeText { get; set; } = HttpConsts.IncludeTextQueryStringName; - - /// - /// Gets or sets the query string name. - /// - /// Defaults to . - public string QueryStringNameIncludeInactive { get; set; } = HttpConsts.IncludeInactiveQueryStringName; - - /// - /// Gets or sets the entity tag that will be passed as either a If-None-Match header where ; otherwise, an If-Match header. - /// - public string? ETag { get; set; } - - /// - /// Appends the to the . - /// - /// The fields to append. - /// The current instance to support fluent-style method-chaining. - public HttpRequestOptions Include(params string[] fields) - { - Query ??= new QueryArgs(); - Query.Include(fields); - return this; - } - - /// - /// Appends the to the . - /// - /// The fields to append. - /// The current instance to support fluent-style method-chaining. - public HttpRequestOptions Exclude(params string[] fields) - { - Query ??= new QueryArgs(); - Query.Exclude(fields); - return this; - } - - /// - /// Updates (overrides) the using a basic dynamic OData-like $filter statement. - /// - /// The filter. - /// The current instance to support fluent-style method-chaining. - public HttpRequestOptions Filter(string? filter) - { - Query ??= new QueryArgs(); - Query.Filter = filter; - return this; - } - - /// - /// Updates (overrides) the using a basic dynamic OData-like $orderby statement. - /// - /// The order by. - /// The current instance to support fluent-style method-chaining. - public HttpRequestOptions OrderBy(string orderby) - { - Query ??= new QueryArgs(); - Query.OrderBy = orderby; - return this; - } - - /// - /// Updates (overrides) the . - /// - /// The . - /// The current instance to support fluent-style method-chaining. - /// Any existing and/or fields will be integrated into the . - public HttpRequestOptions WithQuery(QueryArgs? query) - { - if (Query is not null && query is not null) - { - if (Query.IncludeFields is not null) - query.Include([.. Query.IncludeFields]); - - if (Query.ExcludeFields is not null) - query.Exclude([.. Query.ExcludeFields]); - - if (Query.IsTextIncluded) - query.IsTextIncluded = true; - } - - Query = query; - return this; - } - - /// - /// Updates (overrides) the . - /// - /// The . - /// The current instance to support fluent-style method-chaining. - public HttpRequestOptions WithPaging(PagingArgs? paging) - { - Paging = paging; - return this; - } - - /// - /// Gets the . - /// - public PagingArgs? Paging { get; private set; } - - /// - /// Gets the dynamic . - /// - public QueryArgs? Query { get; private set; } - - /// - /// Gets or sets the optional query string value to include within the . - /// - /// It is assumed that the contents of these are valid as no encoding will be employed; i.e. will be used as-is. The specification of any leading '&' and '?' characters is not required. - public string? UrlQueryString { get; set; } - - /// - /// Indicates whether to include any related texts for the item(s). - /// - /// For example, include corresponding for any ReferenceData values returned in the JSON response payload. - public bool IncludeText { get; set; } - - /// - /// Indicates whether to include any inactive item(s); - /// - /// For example, include item(s) where is false. - public bool IncludeInactive { get; set; } - - /// - /// Adds the to a . - /// - /// The query string. - /// The updated query string. - public string? AddToQueryString(string? queryString) => AddToQueryString(string.IsNullOrEmpty(queryString) ? null : HttpUtility.ParseQueryString(queryString)); - - /// - /// Adds the to a . - /// - /// The . - /// The updated query string. - public string? AddToQueryString(NameValueCollection? queryString) - { - var sb = new StringBuilder(); - if (queryString is not null) - AddNameValueCollection(sb, queryString); - - if (Paging is not null) - { - switch (Paging.Option) - { - case PagingOption.SkipAndTake: - AddNameValuePair(sb, QueryStringNamePagingArgsSkip, Paging.Skip?.ToString(), false); - AddNameValuePair(sb, QueryStringNamePagingArgsTake, Paging.Take.ToString(), false); - break; - - case PagingOption.PageAndSize: - AddNameValuePair(sb, QueryStringNamePagingArgsPage, Paging.Page?.ToString() ?? 1.ToString(), false); - AddNameValuePair(sb, QueryStringNamePagingArgsSize, Paging.Size.ToString(), false); - break; - - default: - AddNameValuePair(sb, QueryStringNamePagingArgsToken, Paging.Token, false); - AddNameValuePair(sb, QueryStringNamePagingArgsTake, Paging.Take.ToString(), false); - break; - - } - - if (Paging.IsGetCount) - AddNameValuePair(sb, QueryStringNamePagingArgsCount, "true", false); - } - - if (Query is not null) - { - if (!string.IsNullOrEmpty(Query.Filter)) - AddNameValuePair(sb, HttpConsts.QueryArgsFilterQueryStringName, Query.Filter, true); - - if (!string.IsNullOrEmpty(Query.OrderBy)) - AddNameValuePair(sb, HttpConsts.QueryArgsOrderByQueryStringName, Query.OrderBy, true); - - if (Query.IncludeFields != null && Query.IncludeFields.Count > 0) - AddNameValuePairs(sb, QueryStringNameIncludeFields, Query.IncludeFields.Where(x => !string.IsNullOrEmpty(x)).Select(x => HttpUtility.UrlEncode(x)).ToArray(), false, true); - - if (Query.ExcludeFields != null && Query.ExcludeFields.Count > 0) - AddNameValuePairs(sb, QueryStringNameExcludeFields, Query.ExcludeFields.Where(x => !string.IsNullOrEmpty(x)).Select(x => HttpUtility.UrlEncode(x)).ToArray(), false, true); - } - - if (IncludeText || (Query is not null && Query.IsTextIncluded)) - AddNameValuePair(sb, QueryStringNameIncludeText, "true", false); - - if (IncludeInactive) - AddNameValuePair(sb, QueryStringNameIncludeInactive, "true", false); - - var qs = sb.Length == 0 ? null : sb.ToString(); - if (!string.IsNullOrEmpty(UrlQueryString)) - { - if (qs is null) - return UrlQueryString.StartsWith('?') ? UrlQueryString : $"?{(UrlQueryString.StartsWith('&') ? UrlQueryString[1..] : UrlQueryString)}"; - else - return $"{qs}{(UrlQueryString.StartsWith('&') ? UrlQueryString : $"&{UrlQueryString}")}"; - } - else - return qs; - } - - /// - /// Add the name/value(s) pair(s) to the string builder. - /// - private static void AddNameValueCollection(StringBuilder sb, NameValueCollection nvc) - { - foreach (var name in nvc.AllKeys) - { - AddNameValuePairs(sb, name, nvc.GetValues(name), true, false); - } - } - - /// - /// Add the name/value(s) pair to the string builder. - /// - private static void AddNameValuePairs(StringBuilder sb, string? name, string[]? values, bool encode = false, bool concatenateValues = false) - { - if (values is null || values.Length == 0) - return; - else if (concatenateValues) - AddNameValuePair(sb, name, string.Join(",", values), encode); - else - { - foreach (var value in values) - { - AddNameValuePair(sb, name, value, encode); - } - } - } - - /// - /// Add the name/value pair to the string builder. - /// - private static void AddNameValuePair(StringBuilder sb, string? name, string? value, bool encode = false) - { - var nne = string.IsNullOrEmpty(name); - var vne = string.IsNullOrEmpty(value); - - if (nne && vne) - return; - - sb.Append(sb.Length == 0 ? '?' : '&'); - if (!nne) - sb.Append(name); - - if (!vne) - { - sb.Append('='); - sb.Append(encode ? HttpUtility.UrlEncode(value) : value); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpResult.cs b/src/CoreEx/Http/HttpResult.cs deleted file mode 100644 index e5eaf602..00000000 --- a/src/CoreEx/Http/HttpResult.cs +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using CoreEx.Json; -using CoreEx.Results; -using System; -using System.Globalization; -using System.Net.Http; -using System.Net.Mime; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http -{ - /// - /// Provides the result with no value. - /// - public class HttpResult : HttpResultBase, IToResult - { - /// - /// Creates a new with no value. - /// - /// The . - /// The . - /// The . - public static async Task CreateAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) -#if NETSTANDARD2_1 - => new HttpResult(response.ThrowIfNull(nameof(response)), - response.Content == null || response.Content.Headers.ContentLength == 0 ? null : new BinaryData(await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false))); -#else - => new HttpResult(response.ThrowIfNull(nameof(response)), - response.Content == null || response.Content.Headers.ContentLength == 0 ? null : new BinaryData(await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false))); -#endif - - /// - /// Creates a new with a . - /// - /// The . - /// The for deserializing the . - /// The . - /// The . - public static async Task> CreateAsync(HttpResponseMessage response, IJsonSerializer? jsonSerializer = default, CancellationToken cancellationToken = default) - { -#if NETSTANDARD2_1 - var content = (response.ThrowIfNull(nameof(response))).Content == null || response.Content.Headers.ContentLength == 0 - ? null : new BinaryData(await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false)); -#else - var content = (response.ThrowIfNull(nameof(response))).Content == null || response.Content.Headers.ContentLength == 0 - ? null : new BinaryData(await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false)); -#endif - - if (!response.IsSuccessStatusCode || content == BinaryData.Empty) - return new HttpResult(response, content, default(T)!); - - if (typeof(T) == typeof(string) && StringComparer.OrdinalIgnoreCase.Compare(response.Content.Headers?.ContentType?.MediaType, MediaTypeNames.Text.Plain) == 0) - { - try - { - return content == null - ? new HttpResult(response, content, default(T)!) - : new HttpResult(response, content, (T)Convert.ChangeType(content.ToString(), typeof(T), CultureInfo.CurrentCulture)); - } - catch (Exception ex) - { - return new HttpResult(response, content, new InvalidOperationException($"Unable to convert the content [{MediaTypeNames.Text.Plain}] content to Type {typeof(T).Name}.", ex)); - } - } - - try - { - var value = content == null ? default! : (jsonSerializer ?? ExecutionContext.GetService() ?? JsonSerializer.Default).Deserialize(content); - if (value != null && value is IETag etag && etag.ETag == null && response.Headers.ETag != null) - etag.ETag = response.Headers.ETag.Tag; - - // Where the value is an ICollectionResult then update the Paging property from the corresponding response headers. - if (value is ICollectionResult cr && cr != null) - { - if (response.TryGetPagingResult(out var paging)) - cr.Paging = paging; - } - - return new HttpResult(response, content, value!); - } - catch (Exception ex) - { - return new HttpResult(response, content, new InvalidOperationException($"Unable to deserialize the JSON [{response.Content.Headers?.ContentType?.MediaType ?? "not specified"}] content to Type {typeof(T).FullName}.", ex)); - } - } - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The as (see ). - internal HttpResult(HttpResponseMessage response, BinaryData? content) : base(response, content) { } - - /// - /// Throws an exception if the request was not successful (see ). - /// - /// Indicates whether to check the and where it matches one of the known values then that will be thrown. - /// Indicates whether to use the as the resulting exception message. - /// The instance to support fluent-style method-chaining. - public HttpResult ThrowOnError(bool throwKnownException = true, bool useContentAsErrorMessage = true) - { - if (IsSuccess) - return this; - - if (throwKnownException) - { - var eex = CreateExtendedException(Response, Content, useContentAsErrorMessage); - if (eex != null) - throw (Exception)eex; - } - - Response.EnsureSuccessStatusCode(); - return this; - } - - /// - public Result ToResult() => ToResult(true); - - /// - /// Converts the into an equivalent . - /// - /// Indicates whether to check the and where it matches one of the known values then that will be used. - /// Indicates whether to use the as the resulting exception message. - /// The resulting . - public Result ToResult(bool convertToKnownException, bool useContentAsErrorMessage = true) - { - if (IsSuccess) - return Result.Success; - - if (convertToKnownException) - { - var eex = CreateExtendedException(Response, Content, useContentAsErrorMessage); - if (eex != null) - return new Result((Exception)eex); - } - - return new Result(new HttpRequestException(Content)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpResultBase.cs b/src/CoreEx/Http/HttpResultBase.cs deleted file mode 100644 index f65fe5e1..00000000 --- a/src/CoreEx/Http/HttpResultBase.cs +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; - -namespace CoreEx.Http -{ - /// - /// Provides the result base capabilities. - /// - public abstract class HttpResultBase - { - private readonly Lazy _errorType; - private readonly Lazy _errorCode; - private readonly Lazy _messages; - private string? _content; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The as . - protected HttpResultBase(HttpResponseMessage response, BinaryData? content) - { - Response = response.ThrowIfNull(nameof(response)); - BinaryContent = content; - - _errorType = new Lazy(() => - { - if (Response.TryGetHeaderValue(HttpConsts.ErrorTypeHeaderName, out var et)) - return et; - else - return null; - }); - - _errorCode = new Lazy(() => - { - if (Response.TryGetHeaderValue(HttpConsts.ErrorCodeHeaderName, out var ec) && int.TryParse(ec, out var code)) - return code; - else - return null; - }); - - _messages = new Lazy(() => - { - if (!Response.TryGetHeaderValue(HttpConsts.MessagesHeaderName, out var mic) || string.IsNullOrEmpty(mic)) - return null; - - try - { - return Json.JsonSerializer.Default.Deserialize(mic); - } - catch - { - return null; // Swallow any deserialization errors. - } - }); - } - - /// - /// Gets the . - /// - public HttpResponseMessage Response { get; } - - /// - /// Gets the as . - /// - public BinaryData? BinaryContent { get; } - - /// - /// Gets the as a . - /// - public string? Content { get => WillResultInNullAsNotFound ? null : _content ??= BinaryContent?.ToString(); } - - /// - /// Gets the underlying . - /// - public HttpRequestMessage? Request => Response.RequestMessage; - - /// - /// Gets the . - /// - public virtual HttpStatusCode StatusCode => WillResultInNullAsNotFound ? HttpStatusCode.NoContent : Response.StatusCode; - - /// - /// Indicates whether the request was successful. - /// - public virtual bool IsSuccess => WillResultInNullAsNotFound || Response.IsSuccessStatusCode; - - /// - /// Gets the . - /// - public MessageItemCollection? Messages => _messages.Value; - - /// - /// Gets the error type using the . - /// - public string? ErrorType => WillResultInNullAsNotFound ? null : _errorType.Value; - - /// - /// Gets the error code using the - /// - public int? ErrorCode => WillResultInNullAsNotFound ? null : _errorCode.Value; - - /// - /// Indicates whether a null/default is to be returned where the response has a of ; i.e. it acts as . - /// - /// When set to true and the corresponding has a is , then will return true and will return null. - public bool NullOnNotFoundResponse { get; set; } - - /// - /// Indicates whether the will result in a null response where the response has a of - /// - /// See . - public bool WillResultInNullAsNotFound => NullOnNotFoundResponse && Response.StatusCode == HttpStatusCode.NotFound; - - /// - /// Creates an from the based on the . - /// - /// The . - /// The as a (see ). - /// Indicates whether to use the as the resulting exception message. - /// The corresponding where applicable; otherwise, null. - internal static IExtendedException? CreateExtendedException(HttpResponseMessage response, string? content, bool useContentAsErrorMessage = true) - { - if (response == null || response.IsSuccessStatusCode) - return null; - - if (!(response.TryGetHeaderValue(HttpConsts.ErrorTypeHeaderName, out var et) && Enum.TryParse(et, out ErrorType errorType))) - errorType = Abstractions.ErrorType.UnhandledError; - - var message = useContentAsErrorMessage ? content : null; - - switch (response.StatusCode) - { - case HttpStatusCode.BadRequest: - if (errorType == Abstractions.ErrorType.BusinessError) - return new BusinessException(message, new HttpRequestException(content)); - else - { - var mic = CreateMessageItems(content); - if (mic == null) - return new ValidationException(message, new HttpRequestException(content)); - else - return new ValidationException(mic); - } - - case HttpStatusCode.Unauthorized: return new AuthenticationException(message, new HttpRequestException(content)); - case HttpStatusCode.Forbidden: return new AuthorizationException(message, new HttpRequestException(content)); - case HttpStatusCode.PreconditionFailed: return new ConcurrencyException(message, new HttpRequestException(content)); - case HttpStatusCode.NotFound: return new NotFoundException(message, new HttpRequestException(content)); - case HttpStatusCode.ServiceUnavailable: return new TransientException(message, new HttpRequestException(content)); - - case HttpStatusCode.Conflict: - return errorType switch - { - Abstractions.ErrorType.DuplicateError => new DuplicateException(message, new HttpRequestException(content)), - Abstractions.ErrorType.DataConsistencyError => new DataConsistencyException(message, new HttpRequestException(content)), - _ => new ConflictException(message, new HttpRequestException(content)), - }; - - default: return null; - } - } - - /// - /// Create from . - /// - /// The as a (see ). - /// The where successfully deserialized; otherwise, null. - internal static MessageItemCollection? CreateMessageItems(string? content) - { - MessageItemCollection? mic = null; - - if (content != null) - { - try - { - var errors = System.Text.Json.JsonSerializer.Deserialize>(content); - if (errors != null) - { - foreach (var kvp in errors.Where(x => !string.IsNullOrEmpty(x.Key))) - { - foreach (var error in kvp.Value.Where(x => !string.IsNullOrEmpty(x))) - { - (mic ??= []).AddPropertyError(kvp.Key, error); - } - } - } - } - catch { } // Swallow any deserialization errors. - } - - return mic; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/HttpResultT.cs b/src/CoreEx/Http/HttpResultT.cs deleted file mode 100644 index 96058fd5..00000000 --- a/src/CoreEx/Http/HttpResultT.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Results; -using System; -using System.Net; -using System.Net.Http; - -namespace CoreEx.Http -{ - /// - /// Provides the result with a . - /// - public class HttpResult : HttpResultBase, IToResult - { - private readonly T _value; - private readonly Exception? _internalException = null; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The as (see ). - /// The deserialized value where ; otherwise, default. - internal HttpResult(HttpResponseMessage response, BinaryData? content, T value) : base(response, content) => _value = value; - - /// - /// Initializes a new instance of the class that has an exception. - /// - /// The . - /// The as (see ). - /// The internal . - internal HttpResult(HttpResponseMessage response, BinaryData? content, Exception? internalException) : this(response, content, default(T)!) => _internalException = internalException; - - /// - /// Gets the response value. - /// - /// Performs a before returning the resulting deserialized value. - public T Value - { - get - { - ThrowOnError(); - return _value; - } - } - - /// - /// Gets the internal exception where the request/response handling was not successful; i.e. JSON deserialization error. - /// - public Exception? Exception => _internalException; - - /// - public override bool IsSuccess => _internalException is null && base.IsSuccess; - - /// - public override HttpStatusCode StatusCode => _internalException is not null ? HttpStatusCode.InternalServerError : base.StatusCode; - - /// - /// Throws an exception if the request was not successful (see ). - /// - /// Indicates whether to check the and where it matches one of the known values then that will be thrown. - /// Indicates whether to use the as the resulting exception message. - /// The instance to support fluent-style method-chaining. - public HttpResult ThrowOnError(bool throwKnownException = true, bool useContentAsErrorMessage = true) - { - if (IsSuccess) - return this; - - if (_internalException is not null) - throw _internalException; - - if (throwKnownException) - { - var eex = CreateExtendedException(Response, Content, useContentAsErrorMessage); - if (eex != null) - throw (Exception)eex; - } - - Response.EnsureSuccessStatusCode(); - return this; - } - - /// - public Result ToResult() => ToResult(true); - - /// - /// Converts the into an equivalent . - /// - /// Indicates whether to check the and where it matches one of the known values then that will be used. - /// Indicates whether to use the as the resulting exception message. - /// The resulting . - public Result ToResult(bool convertToKnownException, bool useContentAsErrorMessage = true) - { - if (_internalException is not null) - return new Result(_internalException); - - if (IsSuccess) - return Result.Ok(Value); - - if (convertToKnownException) - { - var eex = CreateExtendedException(Response, Content, useContentAsErrorMessage); - if (eex != null) - return new Result((Exception)eex); - } - - return new Result(new HttpRequestException(Content)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/IHttpArg.cs b/src/CoreEx/Http/IHttpArg.cs deleted file mode 100644 index 9e0818e6..00000000 --- a/src/CoreEx/Http/IHttpArg.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using System.Collections.Specialized; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http -{ - /// - /// Enables an HTTP argument that may and/or . - /// - public interface IHttpArg - { - /// - /// Adds the to the . - /// - /// The . - /// The . - void AddToQueryString(NameValueCollection queryString, IJsonSerializer jsonSerializer); - - /// - /// Modifies the from the . - /// - /// The . - /// The . - /// The . - Task ModifyHttpRequestAsync(HttpRequestMessage request, IJsonSerializer jsonSerializer, CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/IHttpArgTypeArg.cs b/src/CoreEx/Http/IHttpArgTypeArg.cs deleted file mode 100644 index fc6f3103..00000000 --- a/src/CoreEx/Http/IHttpArgTypeArg.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Http -{ - /// - /// Enables an argument with an and that also supports request URI template replacement via . - /// - public interface IHttpArgTypeArg : IHttpArg - { - /// - /// Gets the argument name. - /// - public string Name { get; } - - /// - /// Gets the that determines how the argument is applied. - /// - public HttpArgType ArgType { get; } - - /// - /// Returns a representation of the value for URI template replacement use. - /// - /// The escaped data string. - string? ToEscapeDataString(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/IdempotencyKeyHandler.cs b/src/CoreEx/Http/IdempotencyKeyHandler.cs new file mode 100644 index 00000000..e5268cbe --- /dev/null +++ b/src/CoreEx/Http/IdempotencyKeyHandler.cs @@ -0,0 +1,44 @@ +namespace CoreEx.Http; + +/// +/// Provides an that adds an idempotency-key (see ) to outgoing HTTP requests. +/// +/// Only requests are supported by default; see . +/// It is expected that the and endpoints being consumed support ETag's (optimistic concurrency) and as such this functionality will ensure idempotency. +/// Otherwise, the and are assumed to be idempotent and are excluded. Adjust the to support the given use-case. +/// Where an already has the idempotency-key () defined this will be respected (i.e. will not be overridden). +public sealed class IdempotencyKeyHandler : DelegatingHandler +{ + /// + /// Gets or sets the supported HTTP methods for adding an idempotency key. + /// + /// Defaults to only. + public HttpMethod[] SupportedMethods { get; set; } = [HttpMethod.Post]; + + /// + /// Gets or sets the header name to use for the idempotency key. + /// + /// Defaults to . + public string HeaderName { get; set => field = value.ThrowIfNullOrEmpty(); } = HttpNames.IdempotencyKeyHeaderName; + + /// + /// Gets or sets the key generator function to use for generating the idempotency key value. + /// + /// Defaults to generating a new string. + public Func KeyGenerator { get; set => field = value.ThrowIfNull(); } = () => Guid.NewGuid().ToString(); + + /// + protected async override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Only applicable for supported methods. + if (SupportedMethods.Contains(request.Method)) + { + // Respect existing idempotency key value. + if (!request.Headers.Contains(HeaderName)) + request.Headers.Add(HeaderName, KeyGenerator()); + } + + // Continue processing; send it baby! + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/CoreEx/Http/ProblemDetailsException.cs b/src/CoreEx/Http/ProblemDetailsException.cs new file mode 100644 index 00000000..b7116b4d --- /dev/null +++ b/src/CoreEx/Http/ProblemDetailsException.cs @@ -0,0 +1,91 @@ +namespace CoreEx.Http; + +/// +/// Represents a . +/// +/// The associated with the exception. +/// The exception that is the cause of the current exception. +/// This exception does not implement by design; this is effectively an internal exception and behaves as such. To enable such behavior then the +/// method can be used and orchestrated accordingly. +public class ProblemDetailsException(ProblemDetails problemDetails, Exception? innerException) : Exception(problemDetails.ThrowIfNull().Title ?? problemDetails.Detail, innerException) +{ + /// + /// Gets the associated with the exception. + /// + public ProblemDetails ProblemDetails { get; } = problemDetails; + + /// + /// Maps the underlying to an exception of type . + /// + /// THe . + /// The corresponding . + /// This is a best effort mapping as there is no means to preserve all information from the originating exception as some fidelity will be lost. + public TException ToException() where TException : ExtendedException + { + var exception = (TException)Activator.CreateInstance(typeof(TException), (LText)Message, this)!; + + exception.Detail = ProblemDetails.Detail; + + if (ProblemDetails.Status is not null) + exception.StatusCode = (HttpStatusCode)ProblemDetails.Status.Value; + + exception.ErrorType = ProblemDetails.ErrorType; + exception.ErrorCode = ProblemDetails.ErrorCode; + exception.Detail = ProblemDetails.Detail; + + if (ProblemDetails.Extensions is not null) + { + foreach (var (key, value) in ProblemDetails.Extensions) + { + if (key == HttpNames.ErrorCodeName && value is string errorCode) + { + exception.ErrorCode = errorCode; + continue; + } + + if (key == HttpNames.ErrorTypeName && value is string errorType) + { + exception.ErrorType = errorType; + continue; + } + + exception.Extensions[key] = value; + } + } + + return exception; + } + + /// + /// Tries to get the from the underlying . + /// + /// The resulting . + /// true indicates that the problem details has been determined as a valid ; otherwise, false. + /// A is considered valid where its is . + public bool TryGetBusinessException([NotNullWhen(true)] out BusinessException? exception) + { + // Check that the error type is the expected business error type; this is required as the problem details may be representing some other exception type and we want to ensure that we only attempt + // to map to a business exception where appropriate. + if (BusinessException.BusinessErrorType.Equals(ProblemDetails.ErrorType, StringComparison.OrdinalIgnoreCase)) + { + exception = ToException(); + return true; + } + + exception = null; + return false; + } + + /// + /// Determines if the underlying represents a and if so, throws it. + /// + /// The to support fluent-style method-chaining. + /// See . + public ProblemDetailsException ThrowOnBusinessException() + { + if (TryGetBusinessException(out var exception)) + throw exception; + + return this; + } +} \ No newline at end of file diff --git a/src/CoreEx/Http/README.md b/src/CoreEx/Http/README.md deleted file mode 100644 index a7a89899..00000000 --- a/src/CoreEx/Http/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# CoreEx.Http - -The `CoreEx.Http` namespace provides additional HTTP capabilities. - -
- -## Motivation - -To encapsulate and enrich the `HttpClient` experience simplifying advanced scenarios, such as retries for the likes of transient errors, and supporting timeouts, etc. - -
- -## Typed HttpClient - -Provides capabilities to enable extended [typed](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/http-requests#typed-clients) [`HttpClient`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient) functionality providing a fluent-style method-chaining to enable the likes of `WithRetry`, `EnsureSuccess`, `Timeout`, and `ThrowTransientException`, etc. to improve the per invocation experience. - -Class | Description --|- -[`TypedHttpClientBase`](./TypedHttpClientBase.cs) | Provides the base foundational abstract capabilities. -[`TypedHttpClientBase`](./TypedHttpClientBaseT.cs) | Extends `TypedHttpClientBase` adding the abstract fluent-style method-chaining capabilities and supporting `SendAsync` logic. -[`TypedHttpClientCore`](./TypedHttpClientCore.cs) | Extends `TypedHttpClientBase` adding abstract support for `Head`, `Get`, `Post`, `Put`, `Patch` and `Delete` HTTP methods. -[`TypedHttpClient`](./TypedHttpClient.cs) | Provides `TypedHttpClientCore` implementation encapsulating an `HttpClient`. - -
- -### Options - -The [`TypedHttpClientOptions`](./Extended/TypedHttpClientOptions.cs) houses the fluent-style method-chaining options for a [`TypedHttpClientBase`](./TypedHttpClientBaseT.cs) via an underlying `DefaultOptions` property. This enables the standardized configuration that will be used for each request, versus configuring per-request directly. Once a request has completed the `TypedHttpClientBase` will reset to the `DefaultOptions`. - -
- -### Fluent-style method-chaining - -The fluent-style method-chaining capabilities are as follows. - -Method | Description --|- -`Ensure` | Adds the [`HttpStatusCode`](https://learn.microsoft.com/en-us/dotnet/api/system.net.httpstatuscode)(s) to the accepted list to be verified against the resulting [`StatusCode`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpresponsemessage.statuscode). -`EnsureAccepted` | Adds the [`HttpStatusCode.Accepted`](https://learn.microsoft.com/en-us/dotnet/api/system.net.httpstatuscode#system-net-httpstatuscode-accepted) to the accepted list to be verified against the resulting [`StatusCode`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpresponsemessage.statuscode). -`EnsureCreated` | Adds the [`HttpStatusCode.Created`](https://learn.microsoft.com/en-us/dotnet/api/system.net.httpstatuscode#system-net-httpstatuscode-created) to the accepted list to be verified against the resulting [`StatusCode`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpresponsemessage.statuscode). -`EnsureNoContent` | Adds the [`HttpStatusCode.NoContent`](https://learn.microsoft.com/en-us/dotnet/api/system.net.httpstatuscode#system-net-httpstatuscode-nocontent) to the accepted list to be verified against the resulting [`StatusCode`](https://learn.microsoft.com/noconetnten-us/dotnet/api/system.net.http.httpresponsemessage.statuscode). -`EnsureNotFound` | Adds the [`HttpStatusCode.NotFound`](https://learn.microsoft.com/en-us/dotnet/api/system.net.httpstatuscode#system-net-httpstatuscode-notfound) to the accepted list to be verified against the resulting [`StatusCode`](https://learn.microsoft.com/noconetnten-us/dotnet/api/system.net.http.httpresponsemessage.statuscode). -`EnsureOK` | Adds the [`HttpStatusCode.OK`](https://learn.microsoft.com/en-us/dotnet/api/system.net.httpstatuscode#system-net-httpstatuscode-ok) to the accepted list to be verified against the resulting [`StatusCode`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpresponsemessage.statuscode). -`EnsureSuccess` | Specifies whether to automatically perform a [`HttpResponseMessage.EnsureSuccessStatusCode`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpresponsemessage.ensuresuccessstatuscode). -`NullOnNotFound` | Specifies that a `null`/`default` value is returned where the _response_ has a `HttpStatusCode.NotFound` (applicable to an HTTP `GET` only). -`OnBeforeRequest` | Specifies the [function](https://learn.microsoft.com/en-us/dotnet/api/system.func-3) to update the [`HttpRequestMessage`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httprequestmessage) before the request is sent. -`ThrowKnownException` | Specifies that the [`IExtendedException`](../Abstractions/IExtendedException.cs) exception implementation for the HTTP status code is thrown when encountered. -`ThrowTransientException` | Specifies that a [`TransientException`](../TransientException.cs) is thrown when a transient error is encountered. -`WithMaxRetryDelay` | Specifies the max retry delay that polly retries will be capped with. -`WithCustomRetryPolicy` | Specifies a custom retry policy; overridding the default. -`WithRetry` | Specifies a retry, including count and delay seconds (exponential), where a transient error is encountered. -`WithTimeout` | Specifies the timeout for a request. - -
- -### Request options - -The [`HttpRequestOptions`](./HttpRequestOptions.cs) enable additional standardized options to be specified per request where applicable. - -
- -### Result - -The [`HttpResult`](./HttpResult.cs) and [`HttpResult`](./HttpResultT.cs) provide a standardized result that encapsulates the underlying [`HttpResponseMessage`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpresponsemessage); including the underlying JSON deserialization of underlying value. - -
- -### Example - -The following demonstrates usage. - -``` csharp -public class XxxAgent : TypedHttpClientCore -{ - public XxxwAgent(HttpClient client, IJsonSerializer jsonSerializer, CoreEx.ExecutionContext executionContext, SettingsBase settings, ILogger logger) - : base(client, jsonSerializer, executionContext, settings, logger) - { - DefaultOptions.WithRetry(); - } -} - -... - -var hr = await _xxxAgent.EnsureOK().EnsureNotFound().PostAsync("foo/bar", new { trackerId = id }).ConfigureAwait(false); -if (hr.StatusCode == HttpStatusCode.NotFound) - return -1; - -return hr.Value; -``` - -
- -## Extended - -The [`CoreEx.Http.Extended`](./Extended) namespace enables extended Typed HttpClient types where request and response [mapping](../Mapping) is also included. diff --git a/src/CoreEx/Http/TypedHttpClient.cs b/src/CoreEx/Http/TypedHttpClient.cs deleted file mode 100644 index 8b5f56a3..00000000 --- a/src/CoreEx/Http/TypedHttpClient.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http -{ - /// - /// Provides a basic typed implementation (see ) that supports , , , , and . - /// - public sealed class TypedHttpClient : TypedHttpClientCore - { - /// - /// Initializes a new instance of the . - /// - /// The underlying . - /// The optional . Defaults to . - /// The optional . Defaults to a new instance. - /// The optional function. Defaults to null. - /// is used to default each parameter to a configured service where present before final described defaults. - public TypedHttpClient(HttpClient client, IJsonSerializer? jsonSerializer = null, ExecutionContext? executionContext = null, Func? onBeforeRequest = null) - : base(client, jsonSerializer, executionContext) - => DefaultOptions.OnBeforeRequest(onBeforeRequest); - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/TypedHttpClientBase.cs b/src/CoreEx/Http/TypedHttpClientBase.cs deleted file mode 100644 index a0704c8f..00000000 --- a/src/CoreEx/Http/TypedHttpClientBase.cs +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Mime; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Web; -using CoreEx.Entities; -using CoreEx.Json; - -namespace CoreEx.Http -{ - /// - /// Represents a typed foundation wrapper. - /// - /// The underlying . - /// The optional . Defaults to . - /// is used to default each parameter to a configured service where present before final described defaults. - public abstract class TypedHttpClientBase(HttpClient client, IJsonSerializer? jsonSerializer = null) - { - /// - /// Gets the underlying . - /// - protected HttpClient Client { get; } = client.ThrowIfNull(nameof(client)); - - /// - /// Gets the Base Address of the client - /// - public Uri? BaseAddress => Client?.BaseAddress; - - /// - /// Gets the . - /// - protected IJsonSerializer JsonSerializer { get; } = jsonSerializer ?? ExecutionContext.GetService() ?? Json.JsonSerializer.Default; - - /// - /// Create an with no specified content. - /// - /// The . - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected Task CreateRequestAsync(HttpMethod method, [StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected Task CreateRequestAsync(HttpMethod method, string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => CreateRequestInternalAsync(method, requestUri, null, requestOptions, args, cancellationToken); - - /// - /// Create an with the specified . - /// - /// The . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected Task CreateContentRequestAsync(HttpMethod method, [StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected Task CreateContentRequestAsync(HttpMethod method, string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => CreateRequestInternalAsync(method, requestUri, content, requestOptions, args, cancellationToken); - - /// - /// Create an serializing the as JSON content. - /// - /// The request . - /// The . - /// The Uri the request is sent to. - /// The request value to be serialized to JSON. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected Task CreateJsonRequestAsync(HttpMethod method, [StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TReq value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected Task CreateJsonRequestAsync(HttpMethod method, string requestUri, TReq value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => CreateContentRequestAsync(method, requestUri, new StringContent(JsonSerializer.Serialize(value), Encoding.UTF8, MediaTypeNames.Application.Json), ApplyValueETagToRequestOptions(value, requestOptions), args, cancellationToken); - - /// - /// Applies the to the where not already specified. - /// - private static HttpRequestOptions? ApplyValueETagToRequestOptions(TReq value, HttpRequestOptions? requestOptions = null) - { - if (value == null || (requestOptions != null && requestOptions.ETag != null)) - return requestOptions; - - if (value is IETag et && et.ETag != null) - { - if (requestOptions == null) - return new HttpRequestOptions { ETag = et.ETag }; - - requestOptions.ETag = et.ETag; - } - - return requestOptions; - } - - /// - /// Create the request applying the specified options and args. - /// - private async Task CreateRequestInternalAsync(HttpMethod method, string requestUri, HttpContent? content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) - { - // Replace any format placeholders within request uri. - requestUri = FormatReplacement(requestUri, args); - - // Access the query string. - var uri = new Uri(requestUri, UriKind.RelativeOrAbsolute); - var ub = new UriBuilder(uri.IsAbsoluteUri ? uri : new Uri(new Uri("https://coreex"), requestUri)); - var qs = HttpUtility.ParseQueryString(ub.Query); - - // Extend the query string from the IHttpArgs. - foreach (var arg in (args ??= []).Where(x => x != null)) - { - arg.AddToQueryString(qs, JsonSerializer); - } - - // Extend the query string to include additional options. - ub.Query = (requestOptions ?? new HttpRequestOptions()).AddToQueryString(qs); - - // Create the request and include ETag if any. - var request = new HttpRequestMessage(method, uri.IsAbsoluteUri ? ub.Uri.ToString() : ub.Uri.PathAndQuery).ApplyETag(requestOptions?.ETag); - if (content != null) - request.Content = content; - - // Apply the body/content IHttpArg. - foreach (var arg in args.Where(x => x != null)) - { - await arg.ModifyHttpRequestAsync(request, JsonSerializer, cancellationToken).ConfigureAwait(false); - } - - return request; - } - - /// - /// Format replacement inspired by: https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs - /// - private static string FormatReplacement(string requestUri, IEnumerable? args) - { - var sb = new StringBuilder(); - var scanIndex = 0; - var endIndex = requestUri.Length; - - while (scanIndex < endIndex) - { - var openBraceIndex = FindBraceIndex(requestUri, '{', scanIndex, endIndex); - if (scanIndex == 0 && openBraceIndex == endIndex) - return requestUri; // No holes found. - - var closeBraceIndex = FindBraceIndex(requestUri, '}', openBraceIndex, endIndex); - if (closeBraceIndex == endIndex) - { - sb.Append(requestUri, scanIndex, endIndex - scanIndex); - scanIndex = endIndex; - } - else - { - sb.Append(requestUri, scanIndex, openBraceIndex - scanIndex); - - if (args != null) - { - var arg = args.OfType().Where(x => x != null && MemoryExtensions.Equals(requestUri.AsSpan(openBraceIndex + 1, closeBraceIndex - openBraceIndex - 1), x.Name, StringComparison.Ordinal)).FirstOrDefault(); - if (arg != null) - sb.Append(arg.ToEscapeDataString()); - } - - scanIndex = closeBraceIndex + 1; - } - } - - return sb.ToString(); - } - - /// - /// Find the brace index within specified range. - /// - private static int FindBraceIndex(string format, char brace, int startIndex, int endIndex) - { - // Example: {{prefix{{{Argument}}}suffix}}. - var braceIndex = endIndex; - var scanIndex = startIndex; - var braceOccurenceCount = 0; - - while (scanIndex < endIndex) - { - if (braceOccurenceCount > 0 && format[scanIndex] != brace) - { - if (braceOccurenceCount % 2 == 0) - { - // Even number of '{' or '}' found. Proceed search with next occurence of '{' or '}'. - braceOccurenceCount = 0; - braceIndex = endIndex; - } - else - { - // An unescaped '{' or '}' found. - break; - } - } - else if (format[scanIndex] == brace) - { - if (brace == '}') - { - if (braceOccurenceCount == 0) - { - // For '}' pick the first occurence. - braceIndex = scanIndex; - } - } - else - { - // For '{' pick the last occurence. - braceIndex = scanIndex; - } - - braceOccurenceCount++; - } - - scanIndex++; - } - - return braceIndex; - } - - /// - /// Deserialize the JSON into of . - /// - /// The response . - /// The . - /// The . - /// The deserialized response value. - protected async Task ReadAsJsonAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) - { - response.EnsureSuccessStatusCode(); - if (response.Content == null) - return default!; - -#if NETSTANDARD2_1 - var data = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); -#else - var data = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); -#endif - return JsonSerializer.Deserialize(new BinaryData(data))!; - } - - /// - /// Sends the returning the . - /// - /// The . - /// The . - /// The . - protected abstract Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken); - - /// - /// Determines whether the or result is transient in nature, and as such is a candidate for a retry. - /// - /// The . - /// The . - /// true indicates transient; otherwise, false. - public static (bool result, string error) IsTransient(HttpResponseMessage? response = null, Exception? exception = null) - { - if (exception != null) - { - if (exception is HttpRequestException) - return (true, $"Http Request Exception occurred: {exception.Message}"); - - if (exception is TaskCanceledException) - return (true, "Task was canceled."); - } - - if (response == null) - return (false, string.Empty); - - if ((int)response.StatusCode >= 500) - return (true, $"Response status code was {response.StatusCode} >= 500."); - - if (response.StatusCode == HttpStatusCode.RequestTimeout) - return (true, $"Response status code was {HttpStatusCode.RequestTimeout} ({(int)HttpStatusCode.RequestTimeout})."); - - if (response.StatusCode == HttpStatusCode.TooManyRequests) - return (true, $"Response status code was {HttpStatusCode.TooManyRequests} ({(int)HttpStatusCode.TooManyRequests})."); - - return (false, string.Empty); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/TypedHttpClientBaseT.cs b/src/CoreEx/Http/TypedHttpClientBaseT.cs deleted file mode 100644 index 6bbe1671..00000000 --- a/src/CoreEx/Http/TypedHttpClientBaseT.cs +++ /dev/null @@ -1,667 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Http.Extended; -using CoreEx.Json; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Sockets; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http -{ - /// - /// Represents a typed base wrapper. - /// - /// The self for support fluent-style method-chaining. - public abstract class TypedHttpClientBase : TypedHttpClientBase, ITypedHttpClientOptions where TSelf : TypedHttpClientBase - { - private TypedHttpClientOptions? _defaultOptions; - private TypedHttpClientOptions? _sendOptions; - - /// - /// Gets or sets an action that is invoked during instance instantiation of the to enable additional runtime configuration of the to be performed on the instance itself. - /// - /// This is required as there is currently no means to configure prior to usage when using Dependency Injection (DI) leveraging the out-of-the-box AddHttpClient. - public static Action? OnDefaultOptionsConfiguration { get; set; } - - /// - /// Initializes a new instance of the . - /// - /// The underlying . - /// The optional . Defaults to . - /// The optional . Defaults to a new instance. - /// is used to default each parameter to a configured service where present before final described defaults. - public TypedHttpClientBase(HttpClient client, IJsonSerializer? jsonSerializer = null, ExecutionContext? executionContext = null) : base(client, jsonSerializer) - { - ExecutionContext = executionContext ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current : new ExecutionContext()); - OnDefaultOptionsConfiguration?.Invoke(DefaultOptions); - } - - /// - /// Gets the . - /// - protected ExecutionContext ExecutionContext { get; } - - /// - /// Gets the default used by all invocations ( after each ). - /// - /// It is recommended that a is performed after setting (unless set within the constructor) to ensure latest values are used. - public TypedHttpClientOptions DefaultOptions => _defaultOptions ??= new TypedHttpClientOptions(this); - - /// - /// Gets the used per invocation then is immediately . - /// - /// This is automatically after each invocation; see . The will updated to the pre-configured . - public TypedHttpClientOptions SendOptions => _sendOptions ??= new TypedHttpClientOptions(_defaultOptions); - - /// - bool ITypedHttpClientOptions.HasSendOptions => _sendOptions is not null; - - /// - /// Gets the list of correlation header names. - /// - /// Defaults to and 'x-ms-client-tracking-id'. - protected virtual IEnumerable CorrelationHeaderNames { get; } = [HttpConsts.CorrelationIdHeaderName, "x-ms-client-tracking-id"]; - - /// - /// Indicates whether to check the and where considered a transient error then a will be thrown. - /// - /// An optional predicate to determine whether the error is considered transient. Defaults to where not specified. - /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . - public TSelf ThrowTransientException(Func? predicate = null) - { - SendOptions.ThrowTransientException(predicate); - return (TSelf)this; - } - - /// - /// Indicates whether to check the and where it matches one of the known values then that will be thrown. - /// - /// Indicates whether to use the as the resulting exception message. - /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . - public TSelf ThrowKnownException(bool useContentAsErrorMessage = false) - { - SendOptions.ThrowKnownException(useContentAsErrorMessage); - return (TSelf)this; - } - - /// - /// Indicates whether to automatically perform an . - /// - /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . - public TSelf EnsureSuccess() - { - SendOptions.EnsureSuccess(); - return (TSelf)this; - } - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . - /// Will result in a where condition is not met. - public TSelf EnsureOK() => Ensure(HttpStatusCode.OK); - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . - /// Will result in a where condition is not met. - public TSelf EnsureNoContent() => Ensure(HttpStatusCode.NoContent); - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . - /// Will result in a where condition is not met. - public TSelf EnsureAccepted() => Ensure(HttpStatusCode.Accepted); - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . - /// Will result in a where condition is not met. - public TSelf EnsureCreated() => Ensure(HttpStatusCode.Created); - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . - /// Will result in a where condition is not met. - public TSelf EnsureNotFound() => Ensure(HttpStatusCode.NotFound); - - /// - /// Adds the to the accepted list to be verified against the resulting . - /// - /// One or more status codes to be verified. - /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . - /// Will result in a where condition is not met. - public TSelf Ensure(params HttpStatusCode[] statusCodes) - { - SendOptions.Ensure(statusCodes); - return (TSelf)this; - } - - /// - /// Indicates that a null/default is to be returned where the response has a of (on only). - /// - /// - /// This references the equivalent method within the . This is after each invocation; see . - /// Results in the corresponding being set to get the desired outcome. - public TSelf NullOnNotFound() - { - SendOptions.NullOnNotFound(); - return (TSelf)this; - } - - /// - /// Sets the function to update the before the request is sent. - /// - /// The function to update the . - /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . - public TSelf OnBeforeRequest(Func? beforeRequest) - { - SendOptions.OnBeforeRequest(beforeRequest); - return (TSelf)this; - } - - /// - /// Resets the to its initial state. - /// - /// This instance to support fluent-style method-chaining. - public virtual TSelf Reset() - { - _sendOptions = null; - return (TSelf)this; - } - - #region SendAsync - - /// - /// Sends the returning the . - /// - /// The . - /// The . - /// The . - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - try - { - return await TypedHttpClientInvoker.Current.InvokeAsync(this, async (_, cancellationToken) => await SendInternalAsync(request, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - } - finally - { - Reset(); - } - } - - /// - /// Sends the returning the internal. - /// - private async Task SendInternalAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - HttpResponseMessage? response = null; - var options = SendOptions; - - try - { - CorrelationHeaderNames.ForEach(n => request.Headers.TryAddWithoutValidation(n, ExecutionContext.CorrelationId)); - - if (options.BeforeRequest != null) - await options.BeforeRequest(request, cancellationToken).ConfigureAwait(false); - - await OnBeforeRequest(request, cancellationToken).ConfigureAwait(false); - - response = await Client.SendAsync(request, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is TimeoutException || ex is SocketException) - { - // Both TimeoutException and SocketException are transient and indicate a connection was terminated. - if (options.ShouldThrowTransientException) - throw new TransientException(ex.Message, ex); - - throw; - } - catch (HttpRequestException hrex) - { - (bool isTransient, string error) = options.IsTransientPredicate(null, hrex); - if (options.ShouldThrowTransientException && isTransient) - throw new TransientException(error, hrex); - - throw; - } - - // Further check if transient and throw accordingly. - (bool wasTransient, string errorMsg) = options.IsTransientPredicate(response, null); - if (options.ShouldThrowTransientException && wasTransient) - throw new TransientException(errorMsg); - - if (options.ShouldThrowKnownException) - { - var eex = await response.ToExtendedExceptionAsync(options.ShouldThrowKnownUseContentAsMessage, cancellationToken).ConfigureAwait(false); - if (eex != null) - throw (Exception)eex; - } - - if (options.ShouldEnsureSuccess) - response.EnsureSuccessStatusCode(); - - if (options.ExpectedStatusCodes != null && !options.ExpectedStatusCodes.Contains(response.StatusCode)) - throw new HttpRequestException($"Response status code {response.StatusCode}; expected one of the following: {string.Join(", ", options.ExpectedStatusCodes)}."); - - return response; - } - - /// - /// Provides an opportunity to update the before sending. - /// - /// The . - /// The - protected virtual Task OnBeforeRequest(HttpRequestMessage request, CancellationToken cancellationToken) => Task.CompletedTask; - - #endregion - - #region HeadAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation. - /// - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public async Task HeadAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public async Task HeadAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => await HttpResult.CreateAsync(await SendAsync(await CreateRequestAsync(HttpMethod.Head, requestUri, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - - #endregion - - #region GetAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation. - /// - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task GetAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task GetAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - var nullOnNotFoundResponse = _sendOptions is not null && _sendOptions.ShouldNullOnNotFound; - var hr = await HttpResult.CreateAsync(await SendAsync(await CreateRequestAsync(HttpMethod.Get, requestUri, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - hr.NullOnNotFoundResponse = nullOnNotFoundResponse; - return hr; - } - - /// - /// Send a request to the specified Uri as an asynchronous operation and deserialize the JSON to the specified .NET object . - /// - /// The response . - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task> GetAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> GetAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - var nullOnNotFoundResponse = _sendOptions is not null && _sendOptions.ShouldNullOnNotFound; - var response = await SendAsync(await CreateRequestAsync(HttpMethod.Get, requestUri, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - var hr = await HttpResult.CreateAsync(response, JsonSerializer, cancellationToken).ConfigureAwait(false); - hr.NullOnNotFoundResponse = nullOnNotFoundResponse; - return hr; - } - - #endregion - - #region PostAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation. - /// - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task PostAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => await HttpResult.CreateAsync(await SendAsync(await CreateRequestAsync(HttpMethod.Post, requestUri, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task PostAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => await HttpResult.CreateAsync(await SendAsync(await CreateContentRequestAsync(HttpMethod.Post, requestUri, content, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The request . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task PostAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - var response = (value is HttpContent content) - ? await SendAsync(await CreateContentRequestAsync(HttpMethod.Post, requestUri, content, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false) - : await SendAsync(await CreateJsonRequestAsync(HttpMethod.Post, requestUri, value, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - - return await HttpResult.CreateAsync(response, cancellationToken).ConfigureAwait(false); - } - - /// - /// Send a request to the specified Uri as an asynchronous operation and deserializes the response JSON to . - /// - /// The response . - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task> PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PostAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - var response = await SendAsync(await CreateRequestAsync(HttpMethod.Post, requestUri, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - return await HttpResult.CreateAsync(response, JsonSerializer, cancellationToken); - } - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The response . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task> PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PostAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - var response = await SendAsync(await CreateContentRequestAsync(HttpMethod.Post, requestUri, content, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - return await HttpResult.CreateAsync(response, JsonSerializer, cancellationToken); - } - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The request . - /// The response . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task> PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PostAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - var response = await SendAsync(await CreateJsonRequestAsync(HttpMethod.Post, requestUri, value, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - return await HttpResult.CreateAsync(response, JsonSerializer, cancellationToken); - } - - #endregion - - #region PutAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task PutAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task PutAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => await HttpResult.CreateAsync(await SendAsync(await CreateContentRequestAsync(HttpMethod.Put, requestUri, content, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The request . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task PutAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task PutAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - var response = (value is HttpContent content) - ? await SendAsync(await CreateContentRequestAsync(HttpMethod.Put, requestUri, content, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false) - : await SendAsync(await CreateJsonRequestAsync(HttpMethod.Put, requestUri, value, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - - return await HttpResult.CreateAsync(response, cancellationToken).ConfigureAwait(false); - } - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The response . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task> PutAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PutAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - var response = await SendAsync(await CreateContentRequestAsync(HttpMethod.Put, requestUri, content, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - return await HttpResult.CreateAsync(response, JsonSerializer, cancellationToken); - } - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The request . - /// The response . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task> PutAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PutAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - var response = await SendAsync(await CreateJsonRequestAsync(HttpMethod.Put, requestUri, value, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - return await HttpResult.CreateAsync(response, JsonSerializer, cancellationToken); - } - - #endregion - - #region PatchAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task PatchAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task PatchAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => await HttpResult.CreateAsync(await SendAsync(await CreateContentRequestAsync(HttpMethod.Patch, requestUri, content, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The Uri the request is sent to. - /// The . - /// The JSON formatted as per the selected . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task PatchAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpPatchOption patchOption, [StringSyntax(StringSyntaxAttribute.Json)] string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task PatchAsync(string requestUri, HttpPatchOption patchOption, string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - if (patchOption == HttpPatchOption.NotSpecified) - throw new ArgumentException("A valid patch option must be specified.", nameof(patchOption)); - - var content = new StringContent(json, Encoding.UTF8, patchOption == HttpPatchOption.JsonPatch ? HttpConsts.JsonPatchMediaTypeName : HttpConsts.MergePatchMediaTypeName); - return await HttpResult.CreateAsync(await SendAsync(await CreateContentRequestAsync(HttpMethod.Patch, requestUri, content, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - } - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The response . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task> PatchAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PatchAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - var response = await SendAsync(await CreateContentRequestAsync(HttpMethod.Patch, requestUri, content, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - return await HttpResult.CreateAsync(response, JsonSerializer, cancellationToken).ConfigureAwait(false); - } - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The response . - /// The Uri the request is sent to. - /// The . - /// The JSON formatted as per the selected . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task> PatchAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpPatchOption patchOption, [StringSyntax(StringSyntaxAttribute.Json)] string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task> PatchAsync(string requestUri, HttpPatchOption patchOption, string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - { - if (patchOption == HttpPatchOption.NotSpecified) - throw new ArgumentException("A valid patch option must be specified.", nameof(patchOption)); - - var content = new StringContent(json, Encoding.UTF8, patchOption == HttpPatchOption.JsonPatch ? HttpConsts.JsonPatchMediaTypeName : HttpConsts.MergePatchMediaTypeName); - var response = await SendAsync(await CreateContentRequestAsync(HttpMethod.Patch, requestUri, content, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - return await HttpResult.CreateAsync(response, JsonSerializer, cancellationToken).ConfigureAwait(false); - } - - #endregion - - #region Delete - - /// - /// Send a request to the specified Uri as an asynchronous operation. - /// - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - protected async Task DeleteAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - protected async Task DeleteAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => await HttpResult.CreateAsync(await SendAsync(await CreateRequestAsync(HttpMethod.Delete, requestUri, requestOptions, args?.ToArray()!, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - - #endregion - - #region Healthcheck - - /// - /// Performs a health check by sending a request to base Uri as an asynchronous operation. - /// - /// The . - /// The . - public virtual Task HealthCheckAsync(CancellationToken cancellationToken = default) - => HeadAsync(string.Empty, null, null, cancellationToken); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/TypedHttpClientCore.cs b/src/CoreEx/Http/TypedHttpClientCore.cs deleted file mode 100644 index 0362b548..00000000 --- a/src/CoreEx/Http/TypedHttpClientCore.cs +++ /dev/null @@ -1,343 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Http -{ - /// - /// Represents a typed base wrapper that supports , , , , and . - /// - /// The self for support fluent-style method-chaining. - /// The underlying . - /// The optional . Defaults to . - /// The optional . Defaults to a new instance. - /// is used to default each parameter to a configured service where present before final described defaults. - public abstract class TypedHttpClientCore(HttpClient client, IJsonSerializer? jsonSerializer = null, ExecutionContext? executionContext = null) : TypedHttpClientBase(client, jsonSerializer, executionContext) where TSelf : TypedHttpClientCore - { - #region HeadAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation. - /// - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task HeadAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task HeadAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.HeadAsync(requestUri, requestOptions, args, cancellationToken); - - #endregion - - #region GetAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation. - /// - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task GetAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task GetAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.GetAsync(requestUri, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation and deserialize the JSON to the specified .NET object . - /// - /// The response . - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task> GetAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task> GetAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.GetAsync(requestUri, requestOptions, args, cancellationToken); - - #endregion - - #region PostAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation. - /// - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task PostAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PostAsync(requestUri, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task PostAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PostAsync(requestUri, content, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The request . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task PostAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PostAsync(requestUri, value, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation and deserialize the response JSON to . - /// - /// The response . - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task> PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task> PostAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PostAsync(requestUri, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The response . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task> PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task> PostAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PostAsync(requestUri, content, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The request . - /// The response . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task> PostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task> PostAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PostAsync(requestUri, value, requestOptions, args, cancellationToken); - - #endregion - - #region PutAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task PutAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task PutAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PutAsync(requestUri, content, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The request . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task PutAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task PutAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PutAsync(requestUri, value, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The response . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task> PutAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task> PutAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PutAsync(requestUri, content, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The request . - /// The response . - /// The Uri the request is sent to. - /// The request value. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task> PutAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task> PutAsync(string requestUri, TRequest value, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PutAsync(requestUri, value, requestOptions, args, cancellationToken); - - #endregion - - #region PatchAsync - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task PatchAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task PatchAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PatchAsync(requestUri, content, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified . - /// - /// The Uri the request is sent to. - /// The . - /// The JSON formatted as per the selected . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task PatchAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpPatchOption patchOption, [StringSyntax(StringSyntaxAttribute.Json)] string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task PatchAsync(string requestUri, HttpPatchOption patchOption, string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PatchAsync(requestUri, patchOption, json, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The response . - /// The Uri the request is sent to. - /// The . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task> PatchAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task> PatchAsync(string requestUri, HttpContent content, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PatchAsync(requestUri, content, requestOptions, args, cancellationToken); - - /// - /// Send a request to the specified Uri as an asynchronous operation with the specified and deserializes the response JSON to . - /// - /// The response . - /// The Uri the request is sent to. - /// The . - /// The JSON formatted as per the selected . - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task> PatchAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpPatchOption patchOption, [StringSyntax(StringSyntaxAttribute.Json)] string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task> PatchAsync(string requestUri, HttpPatchOption patchOption, string json, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.PatchAsync(requestUri, patchOption, json, requestOptions, args, cancellationToken); - - #endregion - - #region Delete - - /// - /// Send a request to the specified Uri as an asynchronous operation. - /// - /// The Uri the request is sent to. - /// The optional . - /// Zero or more objects for templating, query string additions, and content body specification. - /// The . - /// The . -#if NET7_0_OR_GREATER - public new Task DeleteAsync([StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#else - public new Task DeleteAsync(string requestUri, HttpRequestOptions? requestOptions = null, IEnumerable? args = null, CancellationToken cancellationToken = default) -#endif - => base.DeleteAsync(requestUri, requestOptions, args, cancellationToken); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Http/TypedHttpClientInvoker.cs b/src/CoreEx/Http/TypedHttpClientInvoker.cs deleted file mode 100644 index 84e3b236..00000000 --- a/src/CoreEx/Http/TypedHttpClientInvoker.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; - -namespace CoreEx.Http -{ - /// - /// Provides the invocation wrapping for the instances. - /// - public class TypedHttpClientInvoker : InvokerBase - { - private static TypedHttpClientInvoker? _default; - - /// - /// Gets the current configured instance (see ). - /// - public static TypedHttpClientInvoker Current => CoreEx.ExecutionContext.GetService() ?? (_default ??= new TypedHttpClientInvoker()); - } -} \ No newline at end of file diff --git a/src/CoreEx/ISystemTime.cs b/src/CoreEx/ISystemTime.cs deleted file mode 100644 index 109ee193..00000000 --- a/src/CoreEx/ISystemTime.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx -{ - /// - /// Enables the specification/overrinding of the system time. - /// - public interface ISystemTime - { - /// - /// Gets the current system time in UTC. - /// - DateTime UtcNow { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Invokers/DataInvoker.cs b/src/CoreEx/Invokers/DataInvoker.cs deleted file mode 100644 index 42154e99..00000000 --- a/src/CoreEx/Invokers/DataInvoker.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Invokers -{ - /// - /// Wraps a Data invoke enabling standard business tier functionality to be added to all invocations. - /// - [System.Diagnostics.DebuggerStepThrough] - public class DataInvoker : InvokerBase - { - private static DataInvoker? _default; - - /// - /// Gets the current configured instance (see ). - /// - public static DataInvoker Current => CoreEx.ExecutionContext.GetService() ?? (_default ??= new DataInvoker()); - } -} \ No newline at end of file diff --git a/src/CoreEx/Invokers/DataSvcInvoker.cs b/src/CoreEx/Invokers/DataSvcInvoker.cs deleted file mode 100644 index 6bb1110f..00000000 --- a/src/CoreEx/Invokers/DataSvcInvoker.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Invokers -{ - /// - /// Wraps a Data Service invoke enabling standard business tier functionality to be added to all invocations. - /// - [System.Diagnostics.DebuggerStepThrough] - public class DataSvcInvoker : InvokerBase - { - private static DataSvcInvoker? _default; - - /// - /// Gets the current configured instance (see ). - /// - public static DataSvcInvoker Current => CoreEx.ExecutionContext.GetService() ?? (_default ??= new DataSvcInvoker()); - } -} \ No newline at end of file diff --git a/src/CoreEx/Invokers/IInvoker.cs b/src/CoreEx/Invokers/IInvoker.cs index 126ac5e2..60e2764b 100644 --- a/src/CoreEx/Invokers/IInvoker.cs +++ b/src/CoreEx/Invokers/IInvoker.cs @@ -1,34 +1,65 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Invokers; -using System.Diagnostics; -using System; -using Microsoft.Extensions.Logging; - -namespace CoreEx.Invokers +/// +/// Enables standardized invocation capabilities. +/// +public interface IInvoker { /// - /// Enables the standardized invoker capabilities. - /// - public interface IInvoker - { - /// - /// Gets the start of an action. - /// - Action? OnActivityStart { get; } - - /// - /// Gets the action. - /// - Action? OnActivityException { get; } - - /// - /// Gets the completion of an action. - /// - Action? OnActivityComplete { get; } - - /// - /// Get the caller information formatter. - /// - Func CallerLoggerFormatter { get; } - } + /// Gets the invoker . + /// + Type Type { get; } + + /// + /// Gets the invoker name. + /// + string Name { get; } + + /// + /// Gets the optional . + /// + public IServiceProvider? ServiceProvider { get; } + + /// + /// Gets the optional . + /// + ILogger? Logger { get; } + + /// + /// Gets the optional . + /// + IConfiguration? Configuration { get; } + + /// + /// Gets the associated with the invoker. + /// + ActivityKind ActivityKind { get; } + + /// + /// Indicates whether the tracing is explicitly disabled for the invoker; i.e. will never happen regardless of configuration or other factors. + /// + bool IsTracingDisabled { get; } + + /// + /// Indicates whether the logging is explicitly disabled for the invoker; i.e. will never happen regardless of configuration or other factors. + /// + bool IsLoggingDisabled { get; } + + /// + /// Invoked on start. + /// + /// The . + void OnActivityStart(InvokerTracer tracer); + + /// + /// Invoked where the invocation resulted in an for an . + /// + /// and are mutually exclusive. + void OnActivityException(InvokerTracer tracer, Exception exception); + + /// + /// Invoked where the invocation completes successfully for an . + /// + /// and are mutually exclusive. + void OnActivityComplete(InvokerTracer tracer); } \ No newline at end of file diff --git a/src/CoreEx/Invokers/InvokeArgs.cs b/src/CoreEx/Invokers/InvokeArgs.cs deleted file mode 100644 index c93a2c7f..00000000 --- a/src/CoreEx/Invokers/InvokeArgs.cs +++ /dev/null @@ -1,284 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Configuration; -using CoreEx.Results; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Concurrent; -using System.Diagnostics; - -namespace CoreEx.Invokers -{ - /// - /// Represents the runtime arguments for an or invocation. - /// - public struct InvokeArgs - { - private static readonly ConcurrentDictionary _activitySources = new(); - - private const string NullName = "null"; - private const string InvokerTypeName = "invoker.type"; - private const string InvokerOwnerName = "invoker.owner"; - private const string InvokerMemberName = "invoker.member"; - private const string InvokerResultName = "invoker.result"; - private const string InvokerFailureName = "invoker.failure"; - private const string CompleteStateText = "Complete"; - private const string SuccessStateText = "Success"; - private const string FailureStateText = "Failure"; - private const string ExceptionStateText = "Exception"; - - private Type? _ownerType; - private bool _isComplete; - - /// - /// Determines whether tracing is enabled for the . - /// - private static bool IsTracingEnabled(Type invokerType) - { - var settings = ExecutionContext.GetService() ?? new DefaultSettings(ExecutionContext.GetService()); - if (settings.Configuration is null) - return true; - - return settings.GetCoreExValue($"Invokers:{invokerType.FullName}:TracingEnabled") ?? settings.GetCoreExValue("Invokers:Default:TracingEnabled") ?? true; - } - - /// - /// Determines whether logging is enabled for the . - /// - private static bool IsLoggingEnabled(Type invokerType) - { - var settings = ExecutionContext.GetService() ?? new DefaultSettings(ExecutionContext.GetService()); - if (settings.Configuration is null) - return true; - - return settings.GetCoreExValue($"Invokers:{invokerType.FullName}:LoggingEnabled") ?? settings.GetCoreExValue("Invokers:Default:LoggingEnabled") ?? true; - } - - /// - /// Provides the default implementation. - /// - /// The . - /// The caller information to be included in the log output. - public static string DefaultCallerLogFormatter(InvokeArgs args) => args.OwnerType is null ? args.MemberName ?? NullName : $"{args.OwnerType}->{args.MemberName ?? NullName}"; - - /// - /// Gets or sets the for tracing and logging enablement determination. - /// - /// These are cached to avoid the overhead of repeated configuration lookups and allow for dynamic configuration changes. - public static TimeSpan AbsoluteExpirationTimeSpan { get; set; } = TimeSpan.FromMinutes(5); - - /// - /// Initializes a new instance of the struct. - /// - /// This will throw a . - public InvokeArgs() => throw new NotSupportedException($"The {nameof(InvokeArgs)} default constructor is not supported; please use other(s)."); - - /// - /// Initializes a new instance of the struct. - /// - /// The initiating . - /// The invoking (owner) value. - /// The calling member name. - /// The optional parent . - /// Creates the tracing by concatenating the invoking () and separated by ' -> '. This is not - /// meant to represent the fully-qualified member/method name. - public InvokeArgs(IInvoker invoker, object? owner, string? memberName, InvokeArgs? invokeArgs) - { - Invoker = invoker ?? throw new ArgumentNullException(nameof(invoker)); - InvokerType = invoker.GetType(); - Owner = owner; - MemberName = memberName; - - try - { - var enabled = Internal.MemoryCache.GetOrCreate<(bool IsTracingEnabled, bool IsLoggingEnabled)>(InvokerType, e => - { - // These are cached to avoid the overhead of repeated configuration lookups and allow for dynamic configuration changes. - var type = (Type)e.Key; - e.SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); - return (IsTracingEnabled(type), IsLoggingEnabled(type)); - }); - - if (enabled.IsTracingEnabled) - { - var activitySource = _activitySources.GetOrAdd(InvokerType, type => new ActivitySource(type.FullName ?? NullName)); - Activity = activitySource.CreateActivity(OwnerType is null ? memberName ?? NullName : $"{OwnerType}->{memberName ?? NullName}", ActivityKind.Internal); - if (Activity is not null) - { - if (invokeArgs.HasValue && invokeArgs.Value.Activity is not null) - Activity.SetParentId(invokeArgs.Value.Activity!.TraceId, invokeArgs.Value.Activity.SpanId, invokeArgs.Value.Activity.ActivityTraceFlags); - - Activity.SetTag(InvokerTypeName, activitySource.Name); - Activity.SetTag(InvokerOwnerName, OwnerType?.FullName); - Activity.SetTag(InvokerMemberName, memberName); - Invoker.OnActivityStart?.Invoke(this); - Activity.Start(); - } - } - - if (enabled.IsLoggingEnabled) - { - Logger = ExecutionContext.GetService>(); - if (Logger is null || !Logger.IsEnabled(LogLevel.Debug)) - Logger = null; - else - { - Logger.LogDebug("{InvokerType}: Start {InvokerCaller}.", InvokerType.ToString(), Invoker.CallerLoggerFormatter(this)); - Stopwatch = Stopwatch.StartNew(); - } - } - } - catch - { - // Continue; do not allow tracing/logging to impact the execution. - Activity = null; - Logger = null; - } - } - - /// - /// Gets the initiating . - /// - public IInvoker Invoker { get; } - - /// - /// Gets the . - /// - public Type InvokerType { get; } - - /// - /// Gets the owning invocation value. - /// - public object? Owner { get; } - - /// - /// Gets the owning invocation - /// - public Type? OwnerType => _ownerType ??= Owner?.GetType(); - - /// - /// Gets the calling member name. - /// - public string? MemberName { get; } - - /// - /// Gets the leveraged for standardized (open-telemetry) tracing. - /// - /// Will be null where tracing is not enabled. - public Activity? Activity { get; } - - /// - /// Gets the leveraged for standardized invoker logging. - /// - public ILogger? Logger { get; } - - /// - /// Gets the leveraged for standardized invoker timing. - /// - public Stopwatch? Stopwatch { get; } - - /// - /// Adds the result outcome to the (where started). - /// - /// The result . - /// The result value. - /// The . - /// Where the is a then the underlying or will be recorded accordingly. - public TResult TraceResult(TResult result) - { - if (Activity is not null) - { - var ir = result as IResult; - Activity.SetTag(InvokerResultName, ir is null ? CompleteStateText : (ir.IsSuccess ? SuccessStateText : FailureStateText)); - if (ir is not null && ir.IsFailure) - Activity.SetTag(InvokerFailureName, $"{ir.Error.Message} ({ir.Error.GetType()})"); - - Invoker.OnActivityComplete?.Invoke(this); - _isComplete = true; - } - - if (Logger is not null) - { - Stopwatch!.Stop(); - var ir = result as IResult; - Logger.LogDebug("{InvokerType}: {InvokerResult} {InvokerCaller}{InvokerFailure} [{Elapsed}ms].", - InvokerType.ToString(), ir is null ? CompleteStateText : (ir.IsSuccess ? SuccessStateText : FailureStateText), Invoker.CallerLoggerFormatter(this), (ir is not null && ir.IsFailure) ? $" {ir.Error.Message} ({ir.Error.GetType()})" : string.Empty, Stopwatch.Elapsed.TotalMilliseconds); - - _isComplete = true; - } - - return result; - } - - /// - /// Completes the tracing (where started) recording the with the and capturing the corresponding . - /// - /// The . - public void TraceException(Exception ex) - { - if (Activity is not null && ex is not null) - { - Activity.SetTag(InvokerResultName, ExceptionStateText); - Activity.SetTag(InvokerFailureName, $"{ex.Message} ({ex.GetType()})"); - Invoker.OnActivityException?.Invoke(this, ex); - _isComplete = true; - } - - if (Logger is not null && ex is not null) - { - Stopwatch!.Stop(); - Logger.LogDebug("{InvokerType}: {InvokerResult} {InvokerCaller}{InvokerFailure} [{Elapsed}ms].", InvokerType.ToString(), ExceptionStateText, Invoker.CallerLoggerFormatter(this), $" {ex.Message} ({ex.GetType()})", Stopwatch.Elapsed.TotalMilliseconds); - _isComplete = true; - } - } - - /// - /// Completes (stops) the tracing (where started). - /// - /// Where not previously recorded as complete will set the to . - public readonly void TraceComplete() - { - if (Activity is not null) - { - // Where no result then it can only be as a result of an exception. - if (!_isComplete) - { - Activity.SetTag(InvokerResultName, ExceptionStateText); - Invoker.OnActivityException?.Invoke(this, new InvalidOperationException("The invocation was not completed successfully.")); - } - - Activity.Stop(); - } - - if (Logger is not null && !_isComplete) - { - Stopwatch!.Stop(); - Logger.LogDebug("{InvokerType}: {InvokerResult} {InvokerCaller} [{Elapsed}ms].", InvokerType.ToString(), ExceptionStateText, Invoker.CallerLoggerFormatter(this), Stopwatch.Elapsed.TotalMilliseconds); - } - } - - /// - /// Creates (and started) a new instance for a related invocation. - /// - /// The invoker used to manage the activity sources. - /// The invoking (owner) value. - /// The calling member name. - /// The . - public readonly InvokeArgs StartNewRelated(IInvoker invoker, object? owner, string? memberName) => new(invoker, owner, memberName, this); - - /// - /// Releases (disposes) all instances. - /// - public static void ReleaseAll() - { - foreach (var item in _activitySources.ToArray()) - { - if (_activitySources.TryRemove(item.Key, out var activitySource)) - activitySource?.Dispose(); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Invokers/Invoker.cs b/src/CoreEx/Invokers/Invoker.cs index 8db4b485..7fc21b8a 100644 --- a/src/CoreEx/Invokers/Invoker.cs +++ b/src/CoreEx/Invokers/Invoker.cs @@ -1,53 +1,50 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Invokers; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Invokers +/// +/// Provides a generic invoker. +/// +[InvokerName("CoreEx.Invokers.Invoker")] +public sealed class Invoker : InvokerBase { /// - /// Provides invoking capabilities including and to execute an async synchronously. + /// Gets the default for general-purpose use. /// - public class Invoker - { - private static readonly TaskFactory _taskFactory = new(CancellationToken.None, TaskCreationOptions.None, TaskContinuationOptions.None, TaskScheduler.Default); + /// Note that both and are ; i.e. they are disabled. + public static Invoker Default { get; } = new Invoker(); - /// - /// Returns the where not null; otherwise, a . - /// - /// The . - /// The where not null; otherwise, a . - public static Task InvokeAsync(Task? task) => task ?? Task.CompletedTask; + /// + /// Executes an async synchronously. + /// + /// The async function. + /// The general guidance is to avoid sync over async as this may result in deadlock, so please consider all options before using. There are many articles + /// written discussing this subject; however, if sync over async is needed this method provides a consistent approach to perform. + public static void RunSync(Func func) + { + var task = func(); + if (!task.IsCompleted) + task.GetAwaiter().GetResult(); + } - /// - /// Returns the where not null; otherwise, a with a default value. - /// - /// The . - /// The where not null; otherwise, a with a default value. - public static Task InvokeAsync(Task? task) => task ?? Task.FromResult(default!); + /// + /// Executes an async synchronously with a result. + /// + /// The result . + /// The async function. + /// The resulting value. + /// The general guidance is to avoid sync over async as this may result in deadlock, so please consider all options before using. There are many articles + /// written discussing this subject; however, if sync over async is needed this method provides a consistent approach to perform. + public static T RunSync(Func> func) + { + var task = func(); + if (task.IsCompleted) + return task.Result; - /// - /// Executes an async sychronously. - /// - /// The async . - /// The general guidance is to avoid sync over async as this may result in deadlock, so please consider all options before using. There are many articles - /// written discussing this subject; however, if sync over async is needed this method provides a consistent approach to perform. This implementation has been inspired by . - public static void RunSync(Func task) => _taskFactory.StartNew(task.ThrowIfNull(nameof(task))).Unwrap().GetAwaiter().GetResult(); + return task.GetAwaiter().GetResult(); + } - /// - /// Executes an async sychronously with a result. - /// - /// The result . - /// The async . - /// The resulting value. - /// The general guidance is to avoid sync over async as this may result in deadlock, so please consider all options before using. There are many articles - /// written discussing this subject; however, if sync over async is needed this method provides a consistent approach to perform. This implementation has been inspired by . - public static T RunSync(Func> task) => _taskFactory.StartNew(task.ThrowIfNull(nameof(task))).Unwrap().GetAwaiter().GetResult(); + /// > + public override bool IsLoggingDisabled => true; - /// - /// Private constructor. - /// - private Invoker() { } - } + /// > + public override bool IsTracingDisabled => true; } \ No newline at end of file diff --git a/src/CoreEx/Invokers/InvokerArgs.cs b/src/CoreEx/Invokers/InvokerArgs.cs deleted file mode 100644 index 82be1b64..00000000 --- a/src/CoreEx/Invokers/InvokerArgs.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using System; -using System.Transactions; - -namespace CoreEx.Invokers -{ - /// - /// Provides arguments for the to manage the likes of transactions and event sending.. - /// - public struct InvokerArgs - { - /// - /// Gets or sets the default where is false and is null. - /// - public static InvokerArgs Default { get; set; } = new InvokerArgs(); - - /// - /// Gets the where is and is false. - /// - public static InvokerArgs Read { get; } = new InvokerArgs { OperationType = CoreEx.OperationType.Read }; - - /// - /// Gets the where is and is false. - /// - public static InvokerArgs Create { get; } = new InvokerArgs { OperationType = CoreEx.OperationType.Create }; - - /// - /// Gets the where is and is false. - /// - public static InvokerArgs Update { get; } = new InvokerArgs { OperationType = CoreEx.OperationType.Update }; - - /// - /// Gets the where is and is false. - /// - public static InvokerArgs Delete { get; } = new InvokerArgs { OperationType = CoreEx.OperationType.Delete }; - - /// - /// Gets the where is and is false. - /// - public static InvokerArgs Unspecified { get; } = new InvokerArgs { OperationType = CoreEx.OperationType.Unspecified }; - - /// - /// Gets the where is true and is . - /// - public static InvokerArgs TransactionSuppress { get; } = new InvokerArgs { IncludeTransactionScope = true, TransactionScopeOption = TransactionScopeOption.Suppress }; - - /// - /// Gets the where is true and is . - /// - public static InvokerArgs TransactionRequiresNew { get; } = new InvokerArgs { IncludeTransactionScope = true, TransactionScopeOption = TransactionScopeOption.RequiresNew }; - - /// - /// Initialises a new instance of the struct. - /// - public InvokerArgs() { } - - /// - /// Indicates whether to wrap the invocation with a (see ). Defaults to false. - /// - public bool IncludeTransactionScope { get; set; } = false; - - /// - /// Gets or sets the (see ). Defaults to . - /// - public TransactionScopeOption TransactionScopeOption { get; set; } = TransactionScopeOption.Required; - - /// - /// Gets or sets the to automatically perform an on success. - /// - /// Where set will automatically perform an . This will be initiated before the corresponding - /// is completed; to ensure success before the final commit, otherwise a transaction rollback/cancel will occur. - public IEventPublisher? EventPublisher { get; set; } - - /// - /// Gets or sets the to override the . - /// - /// Note that this is not thread-safe in the sense that where set across multiple concurrent tasks the order in which they execute will update the shared . It is recommended that - /// this is set at the top of the call stack before any further concurrent tasks are performed. - public CoreEx.OperationType? OperationType { get; set; } - - /// - /// Gets or sets the unhandled handler. - /// - public Action? ExceptionHandler { get; set; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Invokers/InvokerBase.cs b/src/CoreEx/Invokers/InvokerBase.cs index 1bfe7af5..52467139 100644 --- a/src/CoreEx/Invokers/InvokerBase.cs +++ b/src/CoreEx/Invokers/InvokerBase.cs @@ -1,102 +1,77 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Invokers; -using CoreEx.Results; -using System; -using System.Threading; -using System.Threading.Tasks; -using System.Transactions; - -namespace CoreEx.Invokers +/// +/// Enables standard functionality to be added to an invocation including tracing (and logging). +/// +public abstract class InvokerBase : IInvoker { /// - /// Adds capabilities (wraps) an enabling standard functionality to be added to all business services tier (backend) invocations using a to configure the - /// supporting capabilities (for example, transactions and event publishing). + /// Initializes a new instance of the class. /// - [System.Diagnostics.DebuggerStepThrough] - public abstract class InvokerBase : InvokerBase + /// The optional root needed where there is no . + public InvokerBase(IServiceProvider? serviceProvider = null) { - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, object owner, Func func, InvokerArgs args) - { - InvokerArgs bia = args; - TransactionScope? txn = null; - var ot = CoreEx.ExecutionContext.Current.OperationType; - if (bia.OperationType.HasValue) - CoreEx.ExecutionContext.Current.OperationType = bia.OperationType.Value; + Type = GetType(); + Name = InvokerNameAttribute.GetName(Type); + ServiceProvider = serviceProvider; + Logger = serviceProvider?.GetService()?.CreateLogger(Type); + Configuration = serviceProvider?.GetService(); + } - try - { - // Initiate a transaction where requested. - if (bia.IncludeTransactionScope) - txn = new TransactionScope(bia.TransactionScopeOption, TransactionScopeAsyncFlowOption.Enabled); + /// + public Type Type { get; } - // Invoke the underlying logic. - var result = func(invokeArgs); + /// + public string Name { get; } - // Where using Railway-oriented programming, rollback the transaction where a failure has occurred. - if (result is IResult r && r.IsFailure) - return result; + /// + /// Gets the optional . + /// + public IServiceProvider? ServiceProvider { get; } - // Send any published events where applicable. - if (bia.EventPublisher != null) - Invoker.RunSync(() => bia.EventPublisher.SendAsync(default)); + /// + public ILogger? Logger { get; } - // Complete the transaction where requested to orchestrate one. - txn?.Complete(); - return result; - } - catch (Exception ex) - { - bia.ExceptionHandler?.Invoke(ex); - throw; - } - finally - { - txn?.Dispose(); - CoreEx.ExecutionContext.Current.OperationType = ot; - } - } + /// + public IConfiguration? Configuration { get; } - /// - protected async override Task OnInvokeAsync(InvokeArgs invokeArgs, object owner, Func> func, InvokerArgs args, CancellationToken cancellationToken) - { - InvokerArgs bia = args; - TransactionScope? txn = null; - var ot = CoreEx.ExecutionContext.Current.OperationType; - if (bia.OperationType.HasValue) - CoreEx.ExecutionContext.Current.OperationType = bia.OperationType.Value; + /// + /// Defaults to . + public virtual ActivityKind ActivityKind => ActivityKind.Internal; - try - { - // Initiate a transaction where requested. - if (bia.IncludeTransactionScope) - txn = new TransactionScope(bia.TransactionScopeOption, TransactionScopeAsyncFlowOption.Enabled); + /// + public virtual bool IsTracingDisabled => false; - // Invoke the underlying logic. - var result = await func(invokeArgs, cancellationToken).ConfigureAwait(false); + /// + public virtual bool IsLoggingDisabled => false; - // Where using Railway-oriented programming, rollback the transaction where a failure has occurred. - if (result is IResult r && r.IsFailure) - return result; + /// + void IInvoker.OnActivityStart(InvokerTracer args) => OnActivityStart(args); - // Send any published events where applicable. - if (bia.EventPublisher != null) - await bia.EventPublisher.SendAsync(cancellationToken).ConfigureAwait(false); + /// + void IInvoker.OnActivityException(InvokerTracer args, Exception exception) => OnActivityException(args, exception); - // Complete the transaction where requested to orchestrate one. - txn?.Complete(); - return result; - } - catch (Exception ex) - { - bia.ExceptionHandler?.Invoke(ex); - throw; - } - finally - { - txn?.Dispose(); - CoreEx.ExecutionContext.Current.OperationType = ot; - } - } - } + /// + void IInvoker.OnActivityComplete(InvokerTracer args) => OnActivityComplete(args); + + /// + /// Invoked on start. + /// + /// The . + /// Where overriding the base must be invoked. + protected virtual void OnActivityStart(InvokerTracer args) { } + + /// + /// Invoked where the invocation resulted in an for an . + /// + /// Where overriding the base must be invoked. + /// and are mutually exclusive. + protected virtual void OnActivityException(InvokerTracer args, Exception exception) { } + + /// + /// Invoked where the invocation completes successfully for an . + /// + /// Where overriding the base must be invoked. + /// and are mutually exclusive. + protected virtual void OnActivityComplete(InvokerTracer args) { } } \ No newline at end of file diff --git a/src/CoreEx/Invokers/InvokerBaseT.cs b/src/CoreEx/Invokers/InvokerBaseT.cs index 93a5930d..c31726cd 100644 --- a/src/CoreEx/Invokers/InvokerBaseT.cs +++ b/src/CoreEx/Invokers/InvokerBaseT.cs @@ -1,350 +1,86 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Invokers +namespace CoreEx.Invokers; + +/// +/// Wraps an Invoke enabling standard functionality (tracing and logging) to be added to an invocation. +/// +/// The calling (invoking) . +/// The optional root needed where there is no . +public abstract class InvokerBase(IServiceProvider? serviceProvider = null) : InvokerBase(serviceProvider) { /// - /// Wraps an Invoke enabling standard functionality to be added to all invocations. + /// Invokes a with a asynchronously. /// - /// The owner (invoking) . - /// All public methods result in either the synchronous or asynchronous virtual methods being called to manage the underlying invocation; therefore, where overridding each should - /// be overridden with the same logic. Where no result is specified this defaults to 'object?' for the purposes of execution. - public abstract class InvokerBase : IInvoker - { - /// - public Action? OnActivityStart { get; protected set; } - - /// - public Action? OnActivityException { get; protected set; } - - /// - public Action? OnActivityComplete { get; protected set; } - - /// - public Func CallerLoggerFormatter { get; protected set; } = InvokeArgs.DefaultCallerLogFormatter; + /// The result . + /// The . + /// The caller (invoker). + /// The function to invoke. + /// The . + /// The result. + /// Where overriding the base must be invoked; do not invoke the directly. + protected virtual Task OnInvokeAsync(InvokerTracer tracer, TCaller caller, Func> func, CancellationToken cancellationToken) + => func(tracer, cancellationToken); - /// - /// Invokes a with a synchronously. - /// - /// The result . - /// The . - /// The invoker (owner). - /// The function to invoke. - /// The result. - protected virtual TResult OnInvoke(InvokeArgs invokeArgs, TInvoker invoker, Func func) => func(invokeArgs); - - /// - /// Invokes a with a asynchronously. - /// - /// The result . - /// The . - /// The invoker (owner). - /// The function to invoke. - /// The . - /// The result. - protected virtual Task OnInvokeAsync(InvokeArgs invokeArgs, TInvoker invoker, Func> func, CancellationToken cancellationToken) => func(invokeArgs, cancellationToken); + /// + /// Invoke the with tracing and logging. + /// + private async Task TraceOnInvokeAsync(TCaller caller, Func> func, string? memberName, CancellationToken cancellationToken) + { + var isSuccess = true; + using var tracer = new InvokerTracer(this, caller, memberName, null); - /// - /// Invoke the with tracing. - /// - private TResult TraceOnInvoke(TInvoker invoker, Func func, string? memberName) + try { - var ia = new InvokeArgs(this, invoker, memberName, null); - try - { - return ia.TraceResult(OnInvoke(ia, invoker, func)); - } - catch (Exception ex) - { - ia.TraceException(ex); - throw; - } - finally - { - ia.TraceComplete(); - } + return await OnInvokeAsync(tracer, caller, func, cancellationToken).ConfigureAwait(false); } - - /// - /// Invoke the with tracing. - /// - private async Task TraceOnInvokeAsync(TInvoker invoker, Func> func, string? memberName, CancellationToken cancellationToken) + catch (Exception ex) { - var ia = new InvokeArgs(this, invoker, memberName, null); - try - { - return ia.TraceResult(await OnInvokeAsync(ia, invoker, func, cancellationToken).ConfigureAwait(false)); - } - catch (Exception ex) - { - ia.TraceException(ex); - throw; - } - finally - { - ia.TraceComplete(); - } + isSuccess = false; + tracer.TraceException(ex); + throw; } + finally + { + tracer.TraceComplete(isSuccess); + } + } - #region Sync/NoResult - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// The action to invoke. - /// The calling member name (uses to default). - public void Invoke(TInvoker invoker, Action action, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => { (action.ThrowIfNull(nameof(action))).Invoke(ia); return null!; }, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the action. - /// The action to invoke. - /// The calling member name (uses to default). - public void Invoke(TInvoker invoker, T1 p1, Action action, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => { (action.ThrowIfNull(nameof(action))).Invoke(ia, p1); return null!; }, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the action. - /// Parameter 2 to pass through to the action. - /// The action to invoke. - /// The calling member name (uses to default). - public void Invoke(TInvoker invoker, T1 p1, T2 p2, Action action, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => { (action.ThrowIfNull(nameof(action))).Invoke(ia, p1, p2); return null!; }, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the action. - /// Parameter 2 to pass through to the action. - /// Parameter 3 to pass through to the action. - /// The action to invoke. - /// The calling member name (uses to default). - public void Invoke(TInvoker invoker, T1 p1, T2 p2, T3 p3, Action action, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => { (action.ThrowIfNull(nameof(action))).Invoke(ia, p1, p2, p3); return null!; }, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the action. - /// Parameter 2 to pass through to the action. - /// Parameter 3 to pass through to the action. - /// Parameter 4 to pass through to the action. - /// The action to invoke. - /// The calling member name (uses to default). - public void Invoke(TInvoker invoker, T1 p1, T2 p2, T3 p3, T4 p4, Action action, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => { (action.ThrowIfNull(nameof(action))).Invoke(ia, p1, p2, p3, p4); return null!; }, memberName); - - #endregion - - #region Sync/Result - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// The function to invoke. - /// The calling member name (uses to default). - /// The result. - public TResult Invoke(TInvoker invoker, Func func, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => func(ia), memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// TParameter 1 to pass through to the function. - /// The function to invoke. - /// The calling member name (uses to default). - /// The result. - public TResult Invoke(TInvoker invoker, T1 p1, Func func, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => func(ia, p1), memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// The function to invoke. - /// The calling member name (uses to default). - /// The result. - public TResult Invoke(TInvoker invoker, T1 p1, T2 p2, Func func, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => func(ia, p1, p2), memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// The function to invoke. - /// The calling member name (uses to default). - /// The result. - public TResult Invoke(TInvoker invoker, T1 p1, T2 p2, T3 p3, Func func, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => func(ia, p1, p2, p3), memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// Parameter 4 to pass through to the function. - /// The function to invoke. - /// The calling member name (uses to default). - /// The result. - public TResult Invoke(TInvoker invoker, T1 p1, T2 p2, T3 p3, T4 p4, Func func, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => func(ia, p1, p2, p3, p4), memberName); - - #endregion - - #region Async/NoResult - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, Func func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, ct).ConfigureAwait(false); return (object?)null!; }, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// TParameter 1 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, Func func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, ct).ConfigureAwait(false); return (object?)null!; }, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, Func func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, p2, ct).ConfigureAwait(false); return (object?)null!; }, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, Func func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, p2, p3, ct).ConfigureAwait(false); return (object?)null!; }, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// Parameter 4 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, T4 p4, Func func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, p2, p3, p4, ct).ConfigureAwait(false); return (object?)null!; }, memberName, cancellationToken); - - #endregion - - #region Async/Result - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, ct), memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// TParameter 1 to pass through to the function. - /// The function to invoke. - /// The . - /// The result. - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, ct), memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// The function to invoke. - /// The . - /// The result. - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, p2, ct), memberName, cancellationToken); + /// + /// Invokes an asynchronously. + /// + /// The caller (invoker). + /// The function to invoke. + /// The calling member name (uses to default). + public Task InvokeAsync(TCaller caller, Func func, [CallerMemberName] string? memberName = null) + => TraceOnInvokeAsync(caller.ThrowIfNull(), async (tracer, _) => { await func(tracer).ConfigureAwait(false); return (object?)null!; }, memberName, default); - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// The function to invoke. - /// The . - /// The result. - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, p2, p3, ct), memberName, cancellationToken); + /// + /// Invokes an asynchronously. + /// + /// The caller (invoker). + /// The function to invoke. + /// The . + /// The calling member name (uses to default). + public Task InvokeAsync(TCaller caller, Func func, CancellationToken cancellationToken, [CallerMemberName] string? memberName = null) + => TraceOnInvokeAsync(caller.ThrowIfNull(), async (tracer, ct) => { await func(tracer, ct).ConfigureAwait(false); return (object?)null!; }, memberName, cancellationToken); - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// Parameter 4 to pass through to the function. - /// The function to invoke. - /// The . - /// The result. - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, T4 p4, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, p2, p3, p4, ct), memberName, cancellationToken); + /// + /// Invokes an asynchronously. + /// + /// The caller (invoker). + /// The function to invoke. + /// The calling member name (uses to default). + /// The result. + public Task InvokeAsync(TCaller caller, Func> func, [CallerMemberName] string? memberName = null) + => TraceOnInvokeAsync(caller.ThrowIfNull(), (tracer, _) => func(tracer), memberName, default); - #endregion - } + /// + /// Invokes an asynchronously. + /// + /// The caller (invoker). + /// The function to invoke. + /// The . + /// The calling member name (uses to default). + /// The result. + public Task InvokeAsync(TCaller caller, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) + => TraceOnInvokeAsync(caller.ThrowIfNull(), (tracer, ct) => func(tracer, ct), memberName, cancellationToken); } \ No newline at end of file diff --git a/src/CoreEx/Invokers/InvokerBaseT2.cs b/src/CoreEx/Invokers/InvokerBaseT2.cs index 49c25fb4..21eb4ddc 100644 --- a/src/CoreEx/Invokers/InvokerBaseT2.cs +++ b/src/CoreEx/Invokers/InvokerBaseT2.cs @@ -1,498 +1,92 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Invokers +namespace CoreEx.Invokers; + +/// +/// Wraps an Invoke enabling standard functionality (tracing and logging) to be added to an invocation including . +/// +/// The calling (invoking) . +/// The arguments . +/// The optional root needed where there is no . +public abstract class InvokerBase(IServiceProvider? serviceProvider = null) : InvokerBase(serviceProvider) { /// - /// Wraps an Invoke enabling standard functionality to be added to all invocations. + /// Invokes a with a asynchronously. /// - /// The owner (invoking) . - /// The arguments . - /// All public methods result in either the synchronous or asynchronous virtual methods being called to manage the underlying invocation; therefore, where overridding each should - /// be overridden with the same logic. Where no result is specified this defaults to 'object?' for the purposes of execution. - public abstract class InvokerBase : IInvoker - { - /// - public Action? OnActivityStart { get; protected set; } - - /// - public Action? OnActivityException { get; protected set; } + /// The result . + /// The . + /// The caller (invoker). + /// The caller arguments. + /// The function to invoke. + /// The . + /// The result. + /// Where overriding the base must be invoked; do not invoke the directly. + protected virtual Task OnInvokeAsync(InvokerTracer tracer, TCaller caller, TArgs args, Func> func, CancellationToken cancellationToken) + => func(tracer, args, cancellationToken); - /// - public Action? OnActivityComplete { get; protected set; } - - /// - public Func CallerLoggerFormatter { get; protected set; } = InvokeArgs.DefaultCallerLogFormatter; - - /// - /// Invokes a with a synchronously. - /// - /// The result . - /// The . - /// The invoker (owner). - /// The function to invoke. - /// The arguments passed to the invoke. - /// The result. - protected virtual TResult OnInvoke(InvokeArgs invokeArgs, TInvoker invoker, Func func, TArgs? args) => func(invokeArgs); - - /// - /// Invokes a with a asynchronously. - /// - /// The result . - /// The . - /// The invoker (owner). - /// The function to invoke. - /// The arguments passed to the invoke. - /// The . - /// The result. - protected virtual Task OnInvokeAsync(InvokeArgs invokeArgs, TInvoker invoker, Func> func, TArgs? args, CancellationToken cancellationToken) => func(invokeArgs, cancellationToken); + /// + /// Invoke the with tracing. + /// + private async Task TraceOnInvokeAsync(TCaller caller, TArgs args, Func> func, string? memberName, CancellationToken cancellationToken) + { + var isSuccess = true; + using var tracer = new InvokerTracer(this, caller, memberName, null); - /// - /// Invoke the with tracing. - /// - private TResult TraceOnInvoke(TInvoker invoker, Func func, TArgs? args, string? memberName) + try { - var ia = new InvokeArgs(this, invoker, memberName, null); - try - { - return ia.TraceResult(OnInvoke(ia, invoker, func, args)); - } - catch (Exception ex) - { - ia.TraceException(ex); - throw; - } - finally - { - ia.TraceComplete(); - } + return await OnInvokeAsync(tracer, caller, args, func, cancellationToken).ConfigureAwait(false); } - - /// - /// Invoke the with tracing. - /// - private async Task TraceOnInvokeAsync(TInvoker invoker, Func> func, TArgs? args, string? memberName, CancellationToken cancellationToken) + catch (Exception ex) { - var ia = new InvokeArgs(this, invoker, memberName, null); - try - { - return ia.TraceResult(await OnInvokeAsync(ia, invoker, func, args, cancellationToken).ConfigureAwait(false)); - } - catch (Exception ex) - { - ia.TraceException(ex); - throw; - } - finally - { - ia.TraceComplete(); - } + isSuccess = false; + tracer.TraceException(ex); + throw; } + finally + { + tracer.TraceComplete(isSuccess); + } + } - #region Sync/NoResult - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// The action to invoke. - /// The arguments passed to the invoke. - /// The calling member name (uses to default). - public void Invoke(TInvoker invoker, Action action, TArgs? args = default, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => { (action.ThrowIfNull(nameof(action))).Invoke(ia); return null!; }, args, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// TParameter 1 to pass through to the action. - /// The action to invoke. - /// The arguments passed to the invoke. - /// The calling member name (uses to default). - public void Invoke(TInvoker invoker, T1 p1, Action action, TArgs? args = default, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => { (action.ThrowIfNull(nameof(action))).Invoke(ia, p1); return null!; }, args, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the action. - /// Parameter 2 to pass through to the action. - /// The action to invoke. - /// The arguments passed to the invoke. - /// The calling member name (uses to default). - public void Invoke(TInvoker invoker, T1 p1, T2 p2, Action action, TArgs? args = default, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => { (action.ThrowIfNull(nameof(action))).Invoke(ia, p1, p2); return null!; }, args, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the action. - /// Parameter 2 to pass through to the action. - /// Parameter 3 to pass through to the action. - /// The action to invoke. - /// The arguments passed to the invoke. - /// The calling member name (uses to default). - public void Invoke(TInvoker invoker, T1 p1, T2 p2, T3 p3, Action action, TArgs? args = default, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => { (action.ThrowIfNull(nameof(action))).Invoke(ia, p1, p2, p3); return null!; }, args, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the action. - /// Parameter 2 to pass through to the action. - /// Parameter 3 to pass through to the action. - /// Parameter 4 to pass through to the action. - /// The action to invoke. - /// The arguments passed to the invoke. - /// The calling member name (uses to default). - public void Invoke(TInvoker invoker, T1 p1, T2 p2, T3 p3, T4 p4, Action action, TArgs? args = default, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => { (action.ThrowIfNull(nameof(action))).Invoke(ia, p1, p2, p3, p4); return null!; }, args, memberName); - - #endregion - - #region Sync/Result - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// The function to invoke. - /// The value. - /// The calling member name (uses to default). - /// The result. - public TResult Invoke(TInvoker invoker, Func func, TArgs? args = default, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => func(ia), args, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// TParameter 1 to pass through to the function. - /// The function to invoke. - /// The value. - /// The calling member name (uses to default). - /// The result. - public TResult Invoke(TInvoker invoker, T1 p1, Func func, TArgs? args = default, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => func(ia, p1), args, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// The function to invoke. - /// The value. - /// The calling member name (uses to default). - /// The result. - public TResult Invoke(TInvoker invoker, T1 p1, T2 p2, Func func, TArgs? args = default, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => func(ia, p1, p2), args, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// The function to invoke. - /// The value. - /// The calling member name (uses to default). - /// The result. - public TResult Invoke(TInvoker invoker, T1 p1, T2 p2, T3 p3, Func func, TArgs? args = default, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => func(ia, p1, p2, p3), args, memberName); - - /// - /// Invokes an synchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// Parameter 4 to pass through to the function. - /// The function to invoke. - /// The value. - /// The calling member name (uses to default). - /// The result. - public TResult Invoke(TInvoker invoker, T1 p1, T2 p2, T3 p3, T4 p4, Func func, TArgs? args = default, [CallerMemberName] string? memberName = null) - => TraceOnInvoke(invoker.ThrowIfNull(nameof(invoker)), ia => func(ia, p1, p2, p3, p4), args, memberName); - - #endregion - - #region Async/NoResult - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// The function to invoke. - /// The value. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, Func func, TArgs? args, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, ct).ConfigureAwait(false); return (object?)null!; }, args, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// TParameter 1 to pass through to the function. - /// The function to invoke. - /// The value. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, Func func, TArgs? args, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, ct).ConfigureAwait(false); return (object?)null!; }, args, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// The function to invoke. - /// The value. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, Func func, TArgs? args, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, p2, ct).ConfigureAwait(false); return (object?)null!; }, args, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// The function to invoke. - /// The value. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, Func func, TArgs? args, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, p2, p3, ct).ConfigureAwait(false); return (object?)null!; }, args, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// Parameter 4 to pass through to the function. - /// The function to invoke. - /// The value. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, T4 p4, Func func, TArgs? args, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, p2, p3, p4, ct).ConfigureAwait(false); return (object?)null!; }, args, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, Func func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, ct).ConfigureAwait(false); return (object?)null!; }, default, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// TParameter 1 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, Func func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, ct).ConfigureAwait(false); return (object?)null!; }, default, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, Func func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, p2, ct).ConfigureAwait(false); return (object?)null!; }, default, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, Func func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, p2, p3, ct).ConfigureAwait(false); return (object?)null!; }, default, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// Parameter 4 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, T4 p4, Func func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), async (ia, ct) => { await func(ia, p1, p2, p3, p4, ct).ConfigureAwait(false); return (object?)null!; }, default, memberName, cancellationToken); - - #endregion - - #region Async/Result - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// The function to invoke. - /// The . - /// The value. - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, Func> func, TArgs? args, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, ct), args, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// TParameter 1 to pass through to the function. - /// The function to invoke. - /// The value. - /// The . - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, T1 p1, Func> func, TArgs? args, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, ct), args, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// The function to invoke. - /// The value. - /// The . - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, Func> func, TArgs? args, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, p2, ct), args, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// The function to invoke. - /// The value. - /// The . - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, Func> func, TArgs? args, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, p2, p3, ct), args, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// Parameter 4 to pass through to the function. - /// The function to invoke. - /// The value. - /// The . - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, T4 p4, Func> func, TArgs? args, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, p2, p3, p4, ct), args, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, ct), default, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// TParameter 1 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, T1 p1, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, ct), default, memberName, cancellationToken); - - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, p2, ct), default, memberName, cancellationToken); + /// + /// Invokes an asynchronously. + /// + /// The caller (invoker). + /// The caller arguments. + /// The function to invoke. + /// The calling member name (uses to default). + public Task InvokeAsync(TCaller caller, TArgs args, Func func, [CallerMemberName] string? memberName = null) + => TraceOnInvokeAsync(caller.ThrowIfNull(), args, async (tracer, args, _) => { await func(tracer, args).ConfigureAwait(false); return (object?)null!; }, memberName, default); - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, p2, p3, ct), default, memberName, cancellationToken); + /// + /// Invokes an asynchronously. + /// + /// The caller (invoker). + /// The caller arguments. + /// The function to invoke. + /// The . + /// The calling member name (uses to default). + public Task InvokeAsync(TCaller caller, TArgs args, Func func, CancellationToken cancellationToken, [CallerMemberName] string? memberName = null) + => TraceOnInvokeAsync(caller.ThrowIfNull(), args, async (tracer, args, cancellationToken) => { await func(tracer, args, cancellationToken).ConfigureAwait(false); return (object?)null!; }, memberName, cancellationToken); - /// - /// Invokes an asynchronously. - /// - /// The invoker (owner). - /// Parameter 1 to pass through to the function. - /// Parameter 2 to pass through to the function. - /// Parameter 3 to pass through to the function. - /// Parameter 4 to pass through to the function. - /// The function to invoke. - /// The . - /// The calling member name (uses to default). - /// The result. - public Task InvokeAsync(TInvoker invoker, T1 p1, T2 p2, T3 p3, T4 p4, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) - => TraceOnInvokeAsync(invoker.ThrowIfNull(nameof(invoker)), (ia, ct) => func(ia, p1, p2, p3, p4, ct), default, memberName, cancellationToken); + /// + /// Invokes an asynchronously. + /// + /// The caller (invoker). + /// The caller arguments. + /// The function to invoke. + /// The calling member name (uses to default). + /// The result. + public Task InvokeAsync(TCaller caller, TArgs args, Func> func, [CallerMemberName] string? memberName = null) + => TraceOnInvokeAsync(caller.ThrowIfNull(), args, (tracer, args, _) => func(tracer, args), memberName, default); - #endregion - } + /// + /// Invokes an asynchronously. + /// + /// The caller (invoker). + /// The caller arguments. + /// The function to invoke. + /// The . + /// The calling member name (uses to default). + /// The result. + public Task InvokeAsync(TCaller caller, TArgs args, Func> func, CancellationToken cancellationToken = default, [CallerMemberName] string? memberName = null) + => TraceOnInvokeAsync(caller.ThrowIfNull(), args, (tracer, args, cancellationToken) => func(tracer, args, cancellationToken), memberName, cancellationToken); } \ No newline at end of file diff --git a/src/CoreEx/Invokers/InvokerLogger.cs b/src/CoreEx/Invokers/InvokerLogger.cs new file mode 100644 index 00000000..c513c770 --- /dev/null +++ b/src/CoreEx/Invokers/InvokerLogger.cs @@ -0,0 +1,22 @@ +namespace CoreEx.Invokers; + +/// +/// Provides high-performance logging for invokers. +/// +internal static partial class InvokerLogger +{ + [LoggerMessage(Message = "{invoker}: {operation} {status}.")] + public static partial void InvokeStart(this ILogger logger, LogLevel logLevel, string invoker, string operation, string status); + + [LoggerMessage(Message = "{invoker}: {operation} {status} - {error}. [{elapsed}ms]")] + public static partial void InvokeError(this ILogger logger, LogLevel logLevel, string invoker, string operation, string status, string? error, double elapsed); + + [LoggerMessage(Message = "{invoker}: {operation} {status} - {error}: {exception} [{elapsed}ms]")] + public static partial void InvokeException(this ILogger logger, LogLevel logLevel, string invoker, string operation, string status, string? error, string exception, double elapsed); + + [LoggerMessage(Message = "{invoker}: {operation} {status}. [{elapsed}ms]")] + public static partial void InvokeComplete(this ILogger logger, LogLevel logLevel, string invoker, string operation, string status, double elapsed); + + [LoggerMessage(Message = "{invoker}: {operation} {status} - {context}")] + public static partial void InvokeContext(this ILogger logger, LogLevel logLevel, string invoker, string operation, string status, string context); +} \ No newline at end of file diff --git a/src/CoreEx/Invokers/InvokerNameAttribute.cs b/src/CoreEx/Invokers/InvokerNameAttribute.cs new file mode 100644 index 00000000..a0e6afcc --- /dev/null +++ b/src/CoreEx/Invokers/InvokerNameAttribute.cs @@ -0,0 +1,29 @@ +namespace CoreEx.Invokers; + +/// +/// Provides an attribute to override the name. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public class InvokerNameAttribute(string name) : Attribute +{ + private static readonly ConcurrentDictionary _cache = []; + + /// + /// Gets the name. + /// + public string Name { get; } = name.ThrowIfNullOrEmpty(); + + /// + /// Gets the name for the specified using the where defined; otherwise, uses the formatted name from the . + /// + /// The . + /// The name. + public static string GetName() => GetName(typeof(T)); + + /// + /// Gets the name for the specified using the where defined; otherwise, uses the formatted name from the . + /// + /// The . + /// The name. + public static string GetName(Type type) => _cache.GetOrAdd(type.ThrowIfNull(), t => t.GetCustomAttribute(true) ?? new InvokerNameAttribute(Internal.GetNamespaceFormattedName(t))).Name; +} \ No newline at end of file diff --git a/src/CoreEx/Invokers/InvokerTracer.cs b/src/CoreEx/Invokers/InvokerTracer.cs new file mode 100644 index 00000000..7a715d5b --- /dev/null +++ b/src/CoreEx/Invokers/InvokerTracer.cs @@ -0,0 +1,235 @@ +namespace CoreEx.Invokers; + +/// +/// Provides the standardized invocation runtime tracing (and logging) capabilities. +/// +/// This encapsulates the to ensure consistency of implementation/usage. +public readonly struct InvokerTracer : IDisposable +{ + private const string NullName = "null"; + private const string InvokerResultName = "operation.result"; + private const string InvokerErrorName = "error.type"; + private const string InvokerErrorCodeName = "error.code"; + private const string InvokerErrorMessageName = "error.message"; + private const string InvokerTenantName = "tenant.id"; + private const string StartStateText = "Start"; + private const string CompleteStateText = "Complete"; + private const string ErrorStateText = "Error"; + private const string ExceptionStateText = "Exception"; + private const string ContextStateText = "Context"; + + private static readonly ConcurrentDictionary _activitySources = new(); + + /// + /// Determines whether tracing is enabled for the . + /// + private static bool IsTracingEnabled(Type invokerType, IConfiguration? configuration) + => Internal.GetConfigurationValueWithFallback($"CoreEx:Invokers:{invokerType.FullName}:TracingEnabled", () => "CoreEx:Invokers:TracingEnabled", true, configuration); + + /// + /// Determines whether logging is enabled for the . + /// + private static bool IsLoggingEnabled(Type invokerType, IConfiguration? configuration) + => Internal.GetConfigurationValueWithFallback($"CoreEx:Invokers:{invokerType.FullName}:LoggingEnabled", () => "CoreEx:Invokers:LoggingEnabled", true, configuration); + + /// + /// Gets or sets the for tracing and logging enablement determination. + /// + /// These are cached to avoid the overhead of repeated configuration lookups and allow for dynamic configuration changes. + public static TimeSpan SlidingExpirationTimeSpan { get; set; } = TimeSpan.FromMinutes(15); + + /// + /// Initializes a new instance of the struct as a no-op; not intended for general use. + /// + /// This constructor leverages the which is essentially a no-op; as in no tracing and/or logging is ever performed. + /// This is not intended for general use. + [Obsolete("Parameterless constructor is not supported.", true)] + public InvokerTracer() => throw new NotSupportedException(); + + /// + /// Initializes a new instance of the struct. + /// + /// The initiating . + /// The caller (owner) value. + /// The calling member name. + /// The optional parent . + /// Creates the tracing by concatenating the invoking () and separated by ' -> '. This is not + /// meant to represent the fully-qualified member/method name. + internal InvokerTracer(IInvoker invoker, object? caller, string? memberName, InvokerTracer? parent) + { + Invoker = invoker.ThrowIfNull(); + Caller = caller; + CallerName = Caller is null ? NullName : InvokerNameAttribute.GetName(Caller.GetType()); + MemberName = memberName ?? NullName; + OperationName = $"{CallerName}->{MemberName}"; + + try + { + var enabled = Internal.MemoryCache.GetOrCreate<(bool IsTracingEnabled, bool IsLoggingEnabled)>(Invoker.Type, e => + { + // These are cached to avoid the overhead of repeated configuration lookups. + var type = (Type)e.Key; + e.SlidingExpiration = SlidingExpirationTimeSpan; + return (!invoker.IsTracingDisabled && IsTracingEnabled(type, invoker.Configuration), !invoker.IsLoggingDisabled && IsLoggingEnabled(type, invoker.Configuration)); + }); + + if (enabled.IsTracingEnabled) + { + var activitySource = _activitySources.GetOrAdd(Invoker.Name, name => new ActivitySource(name)); + Activity = activitySource.CreateActivity(OperationName, Invoker.ActivityKind); + if (Activity is not null) + { + if (parent.HasValue && parent.Value.Activity is not null) + Activity.SetParentId(parent.Value.Activity!.TraceId, parent.Value.Activity.SpanId, parent.Value.Activity.ActivityTraceFlags); + + if (ExecutionContext.TryGetCurrent(out var ec) && ec.TenantId is not null) + Activity.SetTag(InvokerTenantName, ec.TenantId); + + Invoker.OnActivityStart(this); + Activity.Start(); + } + } + + if (enabled.IsLoggingEnabled) + { + Logger = invoker.Logger ?? ExecutionContext.GetService>(); + if (Logger is null || !Logger.IsEnabled(LogLevel.Debug)) + Logger = null; + else + { + InvokerLogger.InvokeStart(Logger, LogLevel.Debug, Invoker.Name, OperationName, StartStateText); + Stopwatch = Stopwatch.StartNew(); + } + } + } + catch + { + // Continue; do not allow tracing/logging to impact the execution! + Activity?.Dispose(); + Activity = null; + Logger = null; + } + } + + /// + /// Gets the initiating . + /// + public readonly IInvoker Invoker { get; } + + /// + /// Gets the caller instance. + /// + public readonly object? Caller { get; } + + /// + /// Gets the name. + /// + public readonly string CallerName { get; } + + /// + /// Gets the calling member name. + /// + public readonly string MemberName { get; } + + /// + /// Gets the operation name. + /// + public readonly string OperationName { get; } + + /// + /// Gets the leveraged for standardized (open-telemetry) tracing. + /// + /// Will be where tracing is not enabled. + public Activity? Activity { get; } + + /// + /// Gets the leveraged for standardized invoker logging. + /// + public ILogger? Logger { get; } + + /// + /// Gets the leveraged for standardized invoker timing. + /// + public Stopwatch? Stopwatch { get; } + + /// + public override readonly string ToString() => OperationName; + + /// + /// Adds the result outcome to the (where successful) then performs an . + /// + internal readonly void TraceComplete(bool isSuccess) + { + if (isSuccess) + { + if (Activity is not null) + { + Activity.SetTag(InvokerResultName, CompleteStateText); + Invoker.OnActivityComplete(this); + Activity.SetStatus(ActivityStatusCode.Ok); + Activity.Stop(); + } + + if (Logger?.IsEnabled(LogLevel.Debug) ?? false) + { + Stopwatch!.Stop(); + InvokerLogger.InvokeComplete(Logger, LogLevel.Debug, Invoker.Name, OperationName, CompleteStateText, Stopwatch.Elapsed.TotalMilliseconds); + } + } + + Activity?.Stop(); + } + + /// + /// Completes the tracing (where started) recording the with the and capturing the corresponding . + /// + /// The . + internal readonly void TraceException(Exception ex) + { + Stopwatch?.Stop(); + + if (ex is IExtendedException eex && eex.IsError) + { + Activity?.SetTag(InvokerResultName, ErrorStateText).SetTag(InvokerErrorName, eex.ErrorType).SetTag(InvokerErrorMessageName, eex.Message).SetTag(InvokerErrorCodeName, eex.ErrorCode); + Activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + if (Logger?.IsEnabled(LogLevel.Debug) ?? false) + InvokerLogger.InvokeError(Logger, LogLevel.Debug, Invoker.Name, OperationName, ErrorStateText, eex.ErrorType, Stopwatch!.Elapsed.TotalMilliseconds); + } + else + { + Activity?.SetTag(InvokerResultName, ExceptionStateText).SetTag(InvokerErrorName, ex.GetType().Name).SetTag(InvokerErrorMessageName, ex.Message); + Activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + if (Logger?.IsEnabled(LogLevel.Debug) ?? false) + InvokerLogger.InvokeException(Logger, LogLevel.Debug, Invoker.Name, OperationName, ExceptionStateText, ex.GetType().Name, ex.Message, Stopwatch!.Elapsed.TotalMilliseconds); + } + + if (Activity is not null) + Invoker.OnActivityException(this, ex); + } + + /// + /// Logs additional where the is enabled. + /// + /// The context message. + /// This is intended to provide additional in a log message structured similarly to those automatically output. + public readonly void LogContext(string context) + { + if (Logger?.IsEnabled(LogLevel.Debug) ?? false) + InvokerLogger.InvokeContext(Logger, LogLevel.Debug, Invoker.Name, OperationName, ContextStateText, context ?? NullName); + } + + /// + public readonly void Dispose() => Activity?.Dispose(); + + /// + /// Releases (disposes) all instances. + /// + public static void ReleaseAll() + { + foreach (var item in _activitySources.ToArray()) + { + if (_activitySources.TryRemove(item.Key, out var activitySource)) + activitySource?.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/CoreEx/Invokers/ManagerInvoker.cs b/src/CoreEx/Invokers/ManagerInvoker.cs deleted file mode 100644 index eccdab58..00000000 --- a/src/CoreEx/Invokers/ManagerInvoker.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Invokers -{ - /// - /// Wraps a Manager invoke enabling standard business tier functionality to be added to all invocations. - /// - [System.Diagnostics.DebuggerStepThrough] - public class ManagerInvoker : InvokerBase - { - private static ManagerInvoker? _default; - - /// - /// Gets the current configured instance (see ). - /// - public static ManagerInvoker Current => CoreEx.ExecutionContext.GetService() ?? (_default ??= new ManagerInvoker()); - } -} \ No newline at end of file diff --git a/src/CoreEx/Invokers/README.md b/src/CoreEx/Invokers/README.md deleted file mode 100644 index a0079151..00000000 --- a/src/CoreEx/Invokers/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# CoreEx.Invokers - -The `CoreEx.Invokers` namespace provides invocation capabilities. - -
- -## Motivation - -To enable a standardized approach to the invocation of logic enabling a means to add surrounding runtime execution logic decoupled from the initial implementation; enabling additional functionality to be added separately where desired (i.e. logging). - -By default the invoke represents a standardized [tracing/instrumentation](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/distributed-tracing-instrumentation-walkthroughs#add-basic-instrumentation) boundary. - -
- -## Base capabilities - -The [`InvokerBase`](./InvokerBaseT.cs) and [`InvokerBase`](./InvokerBaseT2.cs) provide the base functionality to invoke an underlying `Action` or `Func` either synchronously or asynchronously. The virtual `OnInvoke` (synchronous) and `OnInvokeAsync` (asynchronous) methods must be overridden to extend the functionality. - -
- -### Instrumentation - -Internally an [`InvokeArgs`](./InvokeArgs.cs) is created to provide the context for the invocation. This is passed to the `Invoke` or `InvokeAsync` methods and provides access to an underlying [`Activity`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activity) property. - -By default where tracing is enable the following standard properties are recorded: - -Property | Description --|- -`invoker.type` | The .NET type name of the invoker. -`invoker.owner` | The .NET type name of the invoker owner/caller. -`invoker.member` | The .NET member name of the invoker. -`invoker.result` | The result of the invocation. Where the result is an [`IResult`](../Results/IResult.cs) (see [Railway-oriented programming](../Results/README.md)) will be either `Success` or `Failure`; otherwise `Complete`. An unhandled exception will be `Exception` (not that the underlying exception details are not recorded). -`invoker.failure` | Where the result is an `IResult` and its state is `IsFailure` then corresponding `Error.Message` is recorded. - -Additional tracing properties may be included where these specifically have been added. - -
- -### Usage - -The general usage pattern is to provide a concrete implementation, for example [`DatabaseInvoker`](../../CoreEx.Database/DatabaseInvoker.cs) and then leverage within application to invoke (wrap) key logic. To then enable runtime overridding, the owning class would allow an instance to be provided within the constructor (i.e. Dependency Injection), for example [`Database`](../../CoreEx.Database/Database.cs); or provide a static instance (i.e. Singleton), for example [`ValidationInvoker`](../Validation/ValidationInvoker.cs) via the `Current` property. - -
- -## Business logic invoker - -The [`InvokerBase`](./InvokerBase.cs) and corresponding [`InvokerArgs`](./InvokerArgs.cs) provides standardized (common) business services logic (leveraged where applicable). - -1. Copy the [`OperationType`](../OperationType.cs) from `ExecutionContext.Current`; and reset after execution. -2. Override the `ExecutionContext.Current` when `InvokerArgs.OperationType` is specified. -3. Initiate a tranasction ([TransactionScope](https://learn.microsoft.com/en-us/dotnet/api/system.transactions.transactionscope)) when `InvokerArgs.IncludeTransactionScope` is specified. -4. **Invoke** the underlying `Action` or `Func` (overrides virtual `OnInvokeAsync` method). -5. Send any events (see [`IEventPublisher.SendAsync`](../Events/IEventPublisher.cs)) when `InvokerArgs.EventPublisher` is specified. -6. Complete the tranasction ([TransactionScope](https://learn.microsoft.com/en-us/dotnet/api/system.transactions.transactionscope)) where previously initiated. -7. On **`Exception`** invoke the `InvokerArgs.ExceptionHandler` where specified. - -The [`ManagerInvoker`](./ManagerInvoker.cs), [`DataSvcInvoker`](./DataSvcInvoker.cs) and [`DataInvoker`](./DataInvoker.cs) provide implementations of the `InvokerBase` that enable usage within these named common business services logic layers where applicable. - -
- -## Invoker - -The [`Invoker`](./Invoker.cs) provides the following common functions. - -Method | Description --|- -`InvokeAsync` | Invokes the passed `Task` where not `null`; otherwise returns `Task.CompletedTask`. This is useful to invoke a `Task` where it is not known until runtime whether it is `null` or not, encapsulating the conditional logic. -`RunSync` | Runs (invokes) the passed `Task` synchronously. The general guidance is to avoid sync over async as this may result in deadlock, so please consider all options before using. There are many [articles]("https://stackoverflow.com/questions/5095183/how-would-i-run-an-async-taskt-method-synchronously") written discussing this subject; however, if sync over async is needed this method provides a consistent approach to perform. This implementation has been inspired by https://www.ryadel.com/en/asyncutil-c-helper-class-async-method-sync-result-wait/". - - diff --git a/src/CoreEx/Invokers/ResultInvokerWith.cs b/src/CoreEx/Invokers/ResultInvokerWith.cs deleted file mode 100644 index 8311f529..00000000 --- a/src/CoreEx/Invokers/ResultInvokerWith.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Results; -using System; -using System.Threading.Tasks; - -namespace CoreEx.Invokers -{ - /// - /// Represents the with capability. - /// - /// The . - /// - /// Initializes a new instance of the . - /// - /// The originating . - /// The . - /// The owner/invoker. - /// The . - public readonly struct ResultInvokerWith(T result, InvokerBase invoker, object owner, InvokerArgs? args = null) where T : IResult - { - /// - /// Gets the originating result. - /// - public T Result { get; } = result ?? throw new System.ArgumentNullException(nameof(result)); - - /// - /// Gets the . - /// - public InvokerBase Invoker { get; } = invoker ?? throw new System.ArgumentNullException(nameof(invoker)); - - /// - /// Gets the owner/invoker. - /// - public object Owner { get; } = owner ?? throw new System.ArgumentNullException(nameof(owner)); - - /// - /// Gets the . - /// - public InvokerArgs Args { get; } = args ?? InvokerArgs.Default; - - /// - /// Executes the where the is . - /// - /// The to invoke. - /// The resulting . - public T With(Func func) - { - var result = Result; - return result.IsSuccess ? Invoker.Invoke(Owner, _ => func(result), Args) : result; - } - - /// - /// Executes the where the is . - /// - /// The to invoke. - /// The resulting . - public Task WithAsync(Func> func) - { - var result = Result; - return result.IsSuccess ? Invoker.InvokeAsync(Owner, (_, __) => func(result), Args, default) : Task.FromResult(result); - } - - /// - /// Executes the where the is (as new ). - /// - /// The to invoke. - /// The resulting . - public U WithAs(Func func) where U : IResult - { - var result = Result; - return result.IsSuccess ? Invoker.Invoke(Owner, _ => func(result), Args) : default!; - } - - /// - /// Executes the where the is (as new ). - /// - /// The to invoke. - /// The resulting . - public async Task WithAsAsync(Func> func) where U : IResult - { - var result = Result; - return result.IsSuccess ? await Invoker.InvokeAsync(Owner, (_, __) => func(result), Args, default).ConfigureAwait(false) : default!; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Invokers/ResultInvokerWithExtensions.cs b/src/CoreEx/Invokers/ResultInvokerWithExtensions.cs deleted file mode 100644 index ecce8f79..00000000 --- a/src/CoreEx/Invokers/ResultInvokerWithExtensions.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Results; -using System; - -namespace CoreEx.Invokers -{ - /// - /// Provides the extension methods. - /// - public static class ResultInvokerWithExtensions - { - /// - /// Initiates a -with operation (see ) where is . - /// - /// The . - /// The originating . - /// The owner/invoker. - /// The . - /// The . - public static ResultInvokerWith Manager(this T result, object owner, InvokerArgs? args = null) where T : IResult => new(result, ManagerInvoker.Current, owner, args); - - /// - /// Initiates a -with operation (see ) where is . - /// - /// The . - /// The originating . - /// The owner/invoker. - /// The . - /// The . - public static ResultInvokerWith DataSvc(this T result, object owner, InvokerArgs? args = null) where T : IResult => new(result, DataSvcInvoker.Current, owner, args); - - /// - /// Initiates a -with operation (see ) where is . - /// - /// The . - /// The originating . - /// The owner/invoker. - /// The . - /// The . - public static ResultInvokerWith Data(this T result, object owner, InvokerArgs? args = null) where T : IResult => new(result, DataInvoker.Current, owner, args); - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Compare/JsonElementComparer.cs b/src/CoreEx/Json/Compare/JsonElementComparer.cs deleted file mode 100644 index 9dac85ec..00000000 --- a/src/CoreEx/Json/Compare/JsonElementComparer.cs +++ /dev/null @@ -1,461 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Text.Json; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text.Json; - -namespace CoreEx.Json.Compare -{ - /// - /// Provides a comparer where property order is not significant. - /// - /// Influenced by . - /// The ; defaults to . - public sealed class JsonElementComparer(JsonElementComparerOptions? options = null) : IEqualityComparer, IEqualityComparer - { - /// - /// Gets the . - /// - public JsonElementComparerOptions Options { get; } = options ?? JsonElementComparerOptions.Default; - - /// - /// Compare two object values for equality; each value is JSON-serialized (uses ) and then compared. - /// - /// The left value. - /// The right value. - /// Optional list of paths to exclude from the comparison. Qualified paths, that include indexing, are also supported. - /// The . - public JsonElementComparerResult CompareValue(TLeft left, TRight right, params string[] pathsToIgnore) - => Compare(SerializeValue(Options.JsonSerializer ??= JsonSerializer.Default, left, nameof(left)), SerializeValue(Options.JsonSerializer, right, nameof(right)), pathsToIgnore); - - /// - /// Serialize the value to JSON. - /// - private static string SerializeValue(IJsonSerializer jsonSerializer, T value, string name) - { - if (value is JsonElement je) - return je.ToString(); - - try - { - return jsonSerializer.Serialize(value); - } - catch (Exception ex) - { - throw new ArgumentException($"Failed to serialize value '{value?.GetType().FullName ?? "null"}' to JSON.", name, ex); - } - } - - /// - /// Compare two JSON strings for equality. - /// - /// The left JSON . - /// The right JSON . - /// Optional list of paths to exclude from the comparison. Qualified paths, that include indexing, are also supported. - /// The . -#if NET7_0_OR_GREATER - public JsonElementComparerResult Compare([StringSyntax(StringSyntaxAttribute.Json)] string left, [StringSyntax(StringSyntaxAttribute.Json)] string right, params string[] pathsToIgnore) -#else - public JsonElementComparerResult Compare(string left, string right, params string[] pathsToIgnore) -#endif - { - var ljr = new Utf8JsonReader(new BinaryData(left)); - if (!JsonElement.TryParseValue(ref ljr, out JsonElement? lje)) - throw new ArgumentException("JSON is not considered valid.", nameof(left)); - - var rjr = new Utf8JsonReader(new BinaryData(right)); - if (!JsonElement.TryParseValue(ref rjr, out JsonElement? rje)) - throw new ArgumentException("JSON is not considered valid.", nameof(right)); - - return Compare(lje.Value, rje.Value, pathsToIgnore); - } - - /// - /// Compare two values for equality. - /// - /// The left . - /// The right . - /// Optional list of paths to exclude from the comparison. Qualified paths, that include indexing, are also supported. - /// The . - public JsonElementComparerResult Compare(JsonElement left, JsonElement right, params string[] pathsToIgnore) - { - var result = new JsonElementComparerResult(left, right, Options.MaxDifferences, Options.ReplaceAllArrayItemsOnMerge); - Compare(left, right, new CompareState(result, Options.PathComparer, pathsToIgnore)); - return result; - } - - /// - /// Perform the comparison. - /// - private void Compare(JsonElement left, JsonElement right, CompareState state) - { - if (left.ValueKind != right.ValueKind) - { - state.AddDifference(left, right, JsonElementDifferenceType.Kind); - return; - } - - switch (left.ValueKind) - { - case JsonValueKind.Null: - case JsonValueKind.True: - case JsonValueKind.False: - // These are the same by kind, so carry on! - break; - - case JsonValueKind.String: - switch (Options.ValueComparison) - { - case JsonElementComparison.Exact: - if (left.GetRawText() != right.GetRawText()) - state.AddDifference(left, right, JsonElementDifferenceType.Value); - - break; - - default: - if (left.GetRawText() == right.GetRawText()) - break; - - if (left.TryGetDateTimeOffset(out var ldto) && right.TryGetDateTimeOffset(out var rdto)) - { - if (ldto != rdto) - state.AddDifference(left, right, JsonElementDifferenceType.Value); - } - else if (left.TryGetDateTime(out var ldt) && right.TryGetDateTime(out var rdt)) - { - if (ldt != rdt) - state.AddDifference(left, right, JsonElementDifferenceType.Value); - } - else if (left.TryGetGuid(out var lg) && right.TryGetGuid(out var rg)) - { - if (lg != rg) - state.AddDifference(left, right, JsonElementDifferenceType.Value); - } - else if (left.GetString() != right.GetString()) - state.AddDifference(left, right, JsonElementDifferenceType.Value); - - break; - } - - break; - - case JsonValueKind.Number: - switch (Options.ValueComparison) - { - case JsonElementComparison.Exact: - if (left.GetRawText() != right.GetRawText()) - state.AddDifference(left, right, JsonElementDifferenceType.Value); - - break; - - default: - if (left.GetRawText() == right.GetRawText()) - break; - - if (left.TryGetDecimal(out var ldec) && right.TryGetDecimal(out var rdec)) - { - if (ldec != rdec) - state.AddDifference(left, right, JsonElementDifferenceType.Value); - } - else if (left.TryGetDouble(out var ldbl) && right.TryGetDouble(out var rdbl)) - { - if (ldbl != rdbl) - state.AddDifference(left, right, JsonElementDifferenceType.Value); - } - else - state.AddDifference(left, right, JsonElementDifferenceType.Value); - - break; - } - - break; - - case JsonValueKind.Object: - foreach (var l in left.EnumerateObject()) - { - state.Compare(l.Name, () => - { - if (TryGetProperty(right, l.Name, out var r)) - Compare(l.Value, r, state); - else - { - if (l.Value.ValueKind == JsonValueKind.Null && Options.NullComparison == JsonElementComparison.Semantic) - return; - - state.AddDifference(left, right, JsonElementDifferenceType.RightNone); - } - }); - - if (state.MaxDifferencesFound) - break; - } - - foreach (var r in right.EnumerateObject()) - { - state.Compare(r.Name, () => - { - if (!TryGetProperty(left, r.Name, out var _)) - { - if (r.Value.ValueKind == JsonValueKind.Null && Options.NullComparison == JsonElementComparison.Semantic) - return; - - state.AddDifference(left, right, JsonElementDifferenceType.LeftNone); - } - }); - - if (state.MaxDifferencesFound) - break; - } - - break; - - case JsonValueKind.Array: - if (left.GetArrayLength() != right.GetArrayLength()) - { - state.AddDifference(left, right, JsonElementDifferenceType.ArrayLength); - break; - } - - var i = 0; - var rja = right.EnumerateArray(); - foreach (var lje in left.EnumerateArray()) - { - rja.MoveNext(); - state.Compare(i++, () => Compare(lje, rja.Current, state)); - if (state.MaxDifferencesFound) - break; - } - - break; - - case JsonValueKind.Undefined: - // Ignore Undefined, assume irrelevant (i.e. not included in comparison). - break; - - default: - throw new InvalidOperationException($"Unexpected JsonValueKind {left.ValueKind}."); - } - } - - /// - /// Performs the configured TryGetProperty; using the comparer. - /// - private bool TryGetProperty(JsonElement json, string propertyName, out JsonElement value) - => Options.PropertyNameComparer is null ? json.TryGetProperty(propertyName, out value) : json.TryGetProperty(propertyName, Options.PropertyNameComparer, out value); - - /// -#if NET7_0_OR_GREATER - public bool Equals([StringSyntax(StringSyntaxAttribute.Json)] string? x, [StringSyntax(StringSyntaxAttribute.Json)] string? y) -#else - public bool Equals(string? x, string? y) -#endif - { - if (x == null && y == null) - return true; - else if (x == null || y == null) - return false; - - var ljr = new Utf8JsonReader(new BinaryData(x)); - if (!JsonElement.TryParseValue(ref ljr, out JsonElement? lje)) - throw new ArgumentException("JSON is not considered valid.", nameof(x)); - - var rjr = new Utf8JsonReader(new BinaryData(y)); - if (!JsonElement.TryParseValue(ref rjr, out JsonElement? rje)) - throw new ArgumentException("JSON is not considered valid.", nameof(y)); - - var state = new CompareState(new JsonElementComparerResult(lje.Value, rje.Value, 1), Options.PathComparer); - Compare(lje.Value, rje.Value, state); - return !state.MaxDifferencesFound; - } - - /// - public bool Equals(JsonElement x, JsonElement y) - { - var state = new CompareState(new JsonElementComparerResult(x, y, 1), Options.PathComparer); - Compare(x, y, state); - return !state.MaxDifferencesFound; - } - - /// -#if NET7_0_OR_GREATER - public int GetHashCode([StringSyntax(StringSyntaxAttribute.Json)] string json) -#else - public int GetHashCode(string json) -#endif - { - if (json == null) - return 0; - - var jr = new Utf8JsonReader(new BinaryData(json)); - if (!JsonElement.TryParseValue(ref jr, out JsonElement? je)) - throw new ArgumentException("JSON is not considered valid.", nameof(json)); - - return GetHashCode(je.Value); - } - - /// - public int GetHashCode(JsonElement json) - { - var hash = new HashCode(); - ComputeHashCode(json, ref hash); - return hash.ToHashCode(); - } - - /// - /// Computes the hash code. - /// - private static void ComputeHashCode(JsonElement json, ref HashCode hash) - { - hash.Add(json.ValueKind); - - switch (json.ValueKind) - { - case JsonValueKind.Null: - break; - - case JsonValueKind.True: - hash.Add(true.GetHashCode()); - break; - - case JsonValueKind.False: - hash.Add(false.GetHashCode()); - break; - - case JsonValueKind.Number: - hash.Add(json.GetDecimal().GetHashCode()); - break; - - case JsonValueKind.String: - hash.Add(json.GetString()); - break; - - case JsonValueKind.Array: - foreach (var item in json.EnumerateArray()) - { - ComputeHashCode(item, ref hash); - } - - break; - - case JsonValueKind.Object: - foreach (var property in json.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal)) - { - hash.Add(property.Name); - ComputeHashCode(property.Value, ref hash); - } - - break; - - case JsonValueKind.Undefined: - break; - - default: - throw new JsonException(string.Format("Unknown JsonValueKind {0}", json.ValueKind)); - } - } - - /// - /// Provides internal state needed to support the comparison. - /// - private sealed class CompareState - { - private readonly Stack _unqualifiedPaths = new(["$"]); - private readonly Stack _paths = new(["$"]); - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The to use for comparing JSON paths. - /// The paths to ignore from the comparison. - public CompareState(JsonElementComparerResult result, IEqualityComparer? pathComparer, params string[] pathsToIgnore) - { - Result = result; - PathComparer = pathComparer ?? StringComparer.InvariantCultureIgnoreCase; - var maxDepth = 0; - PathsToIgnore = new(JsonFilterer.CreateDictionary(pathsToIgnore, JsonPropertyFilter.Exclude, StringComparison.Ordinal, ref maxDepth, true).Keys); - } - - /// - /// Gets the . - /// - public JsonElementComparerResult Result { get; } - - /// - /// Gets or sets the to use for comparing JSON paths. - /// - /// Defaults to . - public IEqualityComparer PathComparer { get; } - - /// - /// Indicates whether the maximum number of differences specified to detect has been found. - /// - public bool MaxDifferencesFound => Result.IsMaxDifferencesFound; - - /// - /// Get paths to exclude. - /// - public HashSet PathsToIgnore { get; } - - /// - /// Gets the unqualified path (excludes indexing). - /// - public string UnqualifiedPath => _unqualifiedPaths.Peek(); - - /// - /// Gets the path. - /// - public string Path => _paths.Peek(); - - /// - /// Encapsulates a path comparison action. - /// - /// The path name. - /// The action to execute. - public void Compare(string name, Action action) - { - var unqualifiedPath = $"{UnqualifiedPath}.{name}"; - if (PathsToIgnore.Contains(unqualifiedPath, PathComparer)) - return; - - var path = $"{Path}.{name}"; - if (PathsToIgnore.Contains(path, PathComparer)) - return; - - _unqualifiedPaths.Push(unqualifiedPath); - _paths.Push(path); - - action.Invoke(); - - _unqualifiedPaths.Pop(); - _paths.Pop(); - } - - /// - /// Encapsulates an array item comparison. - /// - /// The array index. - /// The action to execute. - public void Compare(int index, Action action) - { - _paths.Push($"{Path}[{index}]"); - - action.Invoke(); - - _paths.Pop(); - } - - /// - /// Adds a difference to the result. - /// - /// The left . - /// The right . - /// The . - public void AddDifference(JsonElement left, JsonElement right, JsonElementDifferenceType type) - => Result.AddDifference(new JsonElementDifference(Path, left, right, type)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Compare/JsonElementComparerOptions.cs b/src/CoreEx/Json/Compare/JsonElementComparerOptions.cs deleted file mode 100644 index a55c8e49..00000000 --- a/src/CoreEx/Json/Compare/JsonElementComparerOptions.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Text.Json; -using System; -using System.Collections.Generic; -using System.Text.Json; - -namespace CoreEx.Json.Compare -{ - /// - /// Provides the options for . - /// - public sealed class JsonElementComparerOptions - { - private static JsonElementComparerOptions? _default; - - /// - /// Gets or sets the default instance. - /// - public static JsonElementComparerOptions Default - { - get => _default ??= new(); - set => _default = value.ThrowIfNull(nameof(value)); - } - - /// - /// Gets or sets the to use for comparing JSON paths. - /// - /// Defaults to . - public IEqualityComparer PathComparer { get; set; } = StringComparer.OrdinalIgnoreCase; - - /// - /// Gets or sets the to use for comparing property names. - /// - /// Where not specified will use the native (fast) exact comparison; otherwise, will use - /// which is less performant (however, enables semantic comparison where applicable). - public IEqualityComparer? PropertyNameComparer { get; set; } - - /// - /// Gets or sets the maximum number of differences to detect where performing a comparison. - /// - /// Defaults to . - public int MaxDifferences { get; set; } = int.MaxValue; - - /// - /// Gets or sets the used for property value comparisons. - /// - /// When : a the comparison will be performed using , , and - /// (in order specified) until match found; otherwise, for a the comparison will be performed using and (in order specified) until a match - /// found.Defaults to . - public JsonElementComparison ValueComparison { get; set; } = JsonElementComparison.Semantic; - - /// - /// Gets or sets the used for null value comparisons. - /// - /// When : a where the other property does not exist assumes is equivalent null by default. - public JsonElementComparison NullComparison { get; set; } = JsonElementComparison.Exact; - - /// - /// Gets or sets the . - /// - /// Defaults to where not specified. - public IJsonSerializer? JsonSerializer { get; set; } - - /// - /// Indicates whether to always replace all array items where at least one item has changed when performing a corresponding . - /// - /// The formal specification explictly states that an is to be a replacement operation. - /// Where set to false and there is an array length difference this will always result in a replace (i.e. all); no means to reliably determine what has been added, deleted, modified, resequenced, etc. - public bool ReplaceAllArrayItemsOnMerge { get; set; } = true; - - /// - /// Clones the . - /// - /// A new (cloned) instance. - public JsonElementComparerOptions Clone() => new() - { - PathComparer = PathComparer, - PropertyNameComparer = PropertyNameComparer, - MaxDifferences = MaxDifferences, - ValueComparison = ValueComparison, - NullComparison = NullComparison, - JsonSerializer = JsonSerializer, - ReplaceAllArrayItemsOnMerge = ReplaceAllArrayItemsOnMerge - }; - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Compare/JsonElementComparerResult.cs b/src/CoreEx/Json/Compare/JsonElementComparerResult.cs deleted file mode 100644 index c7fd4a1b..00000000 --- a/src/CoreEx/Json/Compare/JsonElementComparerResult.cs +++ /dev/null @@ -1,306 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Text.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace CoreEx.Json.Compare -{ - /// - /// Represents the result of a . - /// - public sealed class JsonElementComparerResult - { - private List? _differences; - - /// - /// Initializes a new instance of the class. - /// - /// The left . - /// The right . - /// The maximum number of differences to detect. - /// Indicates whether to always replace all array items where at least one item has changed when performing a corresponding . - internal JsonElementComparerResult(JsonElement left, JsonElement right, int maxDifferences, bool replaceAllArrayItemsOnMerge = true) - { - Left = left; - Right = right; - MaxDifferences = maxDifferences; - ReplaceAllArrayItemsOnMerge = replaceAllArrayItemsOnMerge; - } - - /// - /// Gets the left . - /// - public JsonElement Left { get; } - - /// - /// Gets the right . - /// - public JsonElement Right { get; } - - /// - /// Gets the maximum number of differences to detect. - /// - public int MaxDifferences { get; } - - /// - /// Indicates whether the two JSON elements are considered equal. - /// - public bool AreEqual => DifferenceCount == 0; - - /// - /// Indicates whether there are any differences between the two JSON elements based on the specified criteria. - /// - public bool HasDifferences => DifferenceCount != 0; - - /// - /// Gets the current number of differences detected. - /// - public int DifferenceCount => _differences?.Count ?? 0; - - /// - /// Indicates whether the maximum number of differences specified to detect has been found. - /// - public bool IsMaxDifferencesFound => DifferenceCount >= MaxDifferences; - - /// - /// Indicates whether to always replace all array items where at least one item has changed when performing a corresponding . - /// - /// The formal specification explictly states that an is to be a replacement operation. - /// Where set to false and there is an array length difference this will always result in a replace (i.e. all); no means to reliably determine what has been added, deleted, modified, resequenced, etc. - public bool ReplaceAllArrayItemsOnMerge { get; } - - /// - /// Gets the array. - /// - /// The differences found up to the specified. - public JsonElementDifference[] GetDifferences() => _differences is null ? [] : [.. _differences]; - - /// - /// Adds a . - /// - /// The . - internal void AddDifference(JsonElementDifference difference) => (_differences ??= []).Add(difference); - - /// - public override string ToString() - { - if (AreEqual) - return "No differences detected."; - - var sb = new StringBuilder(); - foreach (var d in _differences!) - { - if (sb.Length > 0) - sb.AppendLine(); - - sb.Append(d.ToString()); - } - - if (IsMaxDifferencesFound) - { - sb.AppendLine(); - sb.Append($"Maximum difference count of '{MaxDifferences}' found; comparison stopped."); - } - - return sb.ToString(); - } - - /// - /// Creates a JSON Merge Patch (application/merge-patch+json) from the (used within the comparison) based on the differences () - /// found and the optional . - /// - /// Optional list of paths to additionally include. Qualified paths, that include indexing, are also supported. - /// A JSON Merge Patch (application/merge-patch+json) originally sourced as the . - /// The enables additional paths to always be included regardless of whether any differences were found for those paths; i.e those paths will always be included. Additionally, - /// to further include or exclude consider the which is conveniently enabled as extensions methods and - /// . - public JsonNode ToMergePatch(params string[] pathsToInclude) - { - var maxDepth = 0; - var state = new MergePatchState( - _differences is null ? null : JsonFilterer.CreateDictionary(_differences.Select(x => x.Path), JsonPropertyFilter.Include, StringComparison.Ordinal, ref maxDepth, true), - pathsToInclude.Length == 0 ? null : JsonFilterer.CreateDictionary(pathsToInclude, JsonPropertyFilter.Include, StringComparison.Ordinal, ref maxDepth, true)); - - switch (Right.ValueKind) - { - case JsonValueKind.Object: - var jo = JsonObject.Create(Right)!; - MergePatch(jo, state); - return jo; - - case JsonValueKind.Array: - var ja = JsonArray.Create(Right)!; - MergePatch(ja, state); - return ja; - - case JsonValueKind.Undefined: - throw new InvalidOperationException("Cannot create a JSON Merge Patch from an undefined JSON element."); - - default: - return JsonValue.Create(Right)!; - } - } - - /// - /// Merge patch based on node type. - /// - private PathMatch MergePatch(JsonNode? jn, MergePatchState state) - { - if (jn == null) - return PathMatch.None; - - var match = state.GetMatch(jn); - if (match != PathMatch.Partial) - return match; - - return jn switch - { - JsonValue => PathMatch.Partial, - JsonObject jo => MergePatch(jo, state), - JsonArray ja => MergePatch(ja, state), - _ => throw new NotSupportedException($"Unsupported JSON node type '{jn.GetType().Name}'.") - }; - } - - /// - /// Merge patch an object. - /// - private PathMatch MergePatch(JsonObject jo, MergePatchState state) - { - PathMatch match; - var overall = PathMatch.None; - - foreach (var jn in jo.ToArray()) - { - if (jn.Value is null) - { - var path = $"{jo.GetPath()}.{jn.Key}"; - match = state.GetMatch(path); - } - else - match = MergePatch(jn.Value, state); - - overall = MergePatchState.ConsolidateMatch(overall, match); - if (match != PathMatch.Full) - jo.Remove(jn.Key); - } - - return overall; - } - - /// - /// Merge patch an array. - /// - private PathMatch MergePatch(JsonArray ja, MergePatchState state) - { - PathMatch match; - var overall = PathMatch.None; - - if (ReplaceAllArrayItemsOnMerge && state.GetMatch(ja) != PathMatch.None) - return PathMatch.Full; - - for (var i = ja.Count - 1; i >= 0; i--) - { - var jn = ja[i]; - match = jn is null ? state.GetMatch($"{ja.GetPath()}[{i}]") : MergePatch(ja[i], state); - overall = MergePatchState.ConsolidateMatch(overall, match); - if (match != PathMatch.Full) - ja.RemoveAt(i); - } - - return overall; - } - - /// - /// Provides internal state needed to support the merge patch. - /// - /// The differences paths for inclusion. - /// The additional paths for inclusion. - private sealed class MergePatchState(Dictionary? differencePaths, Dictionary? includePaths) - { - /// - /// Gets the differences paths for inclusion. - /// - public Dictionary? DifferencePaths { get; } = differencePaths; - - /// - /// Gets the additional paths for inclusion. - /// - public Dictionary? IncludePaths { get; } = includePaths; - - /// - /// Gets the difference and include match for the specified . - /// - public PathMatch GetMatch(JsonNode jsonNode) => GetMatch(jsonNode.GetPath()); - - /// - /// Gets the difference and include match for the specified path. - /// - public PathMatch GetMatch(string path) - { - var match = GetMatchAsIs(path); - return match != PathMatch.Full && JsonFilterer.TryRemovePathIndexes(path, out var unindexed) ? ConsolidateMatch(GetMatchAsIs(unindexed), match) : match; - } - - /// - /// Gets the difference and include match for the specified path as-is. - /// - private PathMatch GetMatchAsIs(string path) - { - var dm = GetDifferenceMatch(path); - if (dm == PathMatch.Full) - return PathMatch.Full; - - var im = GetIncludeMatch(path); - if (im == PathMatch.Full) - return PathMatch.Full; - else if (dm == PathMatch.Partial || im == PathMatch.Partial) - return PathMatch.Partial; - else - return PathMatch.None; - } - - /// - /// Gets the difference match for the specified path. - /// - public PathMatch GetDifferenceMatch(string path) => (DifferencePaths is not null && DifferencePaths.TryGetValue(path, out var match)) ? (match ? PathMatch.Full : PathMatch.Partial) : PathMatch.None; - - /// - /// Gets the include match for the specified path. - /// - public PathMatch GetIncludeMatch(string path) => (IncludePaths is not null && IncludePaths.TryGetValue(path, out var match)) ? (match ? PathMatch.Full : PathMatch.Partial) : PathMatch.None; - - /// - /// Consolidate the existing and matched and return the mostest (sic) match. - /// - public static PathMatch ConsolidateMatch(PathMatch existing, PathMatch matched) - => existing == PathMatch.Full || matched == PathMatch.Full ? PathMatch.Full : (existing == PathMatch.Partial || matched == PathMatch.Partial ? PathMatch.Partial : PathMatch.None); - } - - /// - /// Represents the result of path matching. - /// - private enum PathMatch - { - /// - /// Indicates no match. - /// - None, - - /// - /// Indicates a partial match. - /// - Partial, - - /// - /// Indicates a full match. - /// - Full - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Compare/JsonElementComparison.cs b/src/CoreEx/Json/Compare/JsonElementComparison.cs deleted file mode 100644 index 33480323..00000000 --- a/src/CoreEx/Json/Compare/JsonElementComparison.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Text.Json; - -namespace CoreEx.Json.Compare -{ - /// - /// Defines the comparison option where is either a , or . - /// - public enum JsonElementComparison - { - /// - /// Indicates that a semantic match is to used for the comparison. - /// - Semantic, - - /// - /// Indicates that an exact match is to used for the comparison. - /// - /// Uses the for the value comparison. - Exact - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Compare/JsonElementDifference.cs b/src/CoreEx/Json/Compare/JsonElementDifference.cs deleted file mode 100644 index 233f2307..00000000 --- a/src/CoreEx/Json/Compare/JsonElementDifference.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Text.Json; - -namespace CoreEx.Json.Compare -{ - /// - /// Represents a comparison difference. - /// - public readonly struct JsonElementDifference - { - /// - /// Initializes a the struct. - /// - /// The JSON path. - /// The left where applicable. - /// The right where applicable. - /// The . - internal JsonElementDifference(string path, JsonElement? left, JsonElement? right, JsonElementDifferenceType type) - { - Path = path; - Left = left; - Right = right; - Type = type; - } - - /// - /// Gets the JSON path. - /// - public string Path { get; } - - /// - /// Gets the left where applicable. - /// - public JsonElement? Left { get; } - - /// - /// Gets the right where applicable. - /// - public JsonElement? Right { get; } - - /// - /// Gets the . - /// - public JsonElementDifferenceType Type { get; } - - /// - public override string ToString() => $"Path '{Path}': {Type switch - { - JsonElementDifferenceType.LeftNone => "Does not exist in left JSON.", - JsonElementDifferenceType.RightNone => "Does not exist in right JSON.", - JsonElementDifferenceType.ArrayLength => $"Array lengths are not equal: {Left?.GetArrayLength()} != {Right?.GetArrayLength()}.", - JsonElementDifferenceType.Kind => $"Kind is not equal: {Left?.ValueKind} != {Right?.ValueKind}.", - _ => $"Value is not equal: {Left?.GetRawText()} != {Right?.GetRawText()}.", - }}"; - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Compare/JsonElementDifferenceType.cs b/src/CoreEx/Json/Compare/JsonElementDifferenceType.cs deleted file mode 100644 index 3f560cc0..00000000 --- a/src/CoreEx/Json/Compare/JsonElementDifferenceType.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Text.Json; - -namespace CoreEx.Json.Compare -{ - /// - /// Defines the type of difference identified. - /// - public enum JsonElementDifferenceType - { - /// - /// Indicates that the left and right is different. - /// - Kind, - - /// - /// Indicates that the left and right values are different. - /// - Value, - - /// - /// Indicates that the corresponding path does not exist in the left . - /// - LeftNone, - - /// - /// Indicates that the corresponding path does not exist in the right . - /// - RightNone, - - /// - /// Indicates that the left and right array lengths are different. - /// - ArrayLength - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Data/JsonDataReader.cs b/src/CoreEx/Json/Data/JsonDataReader.cs deleted file mode 100644 index 27547a4a..00000000 --- a/src/CoreEx/Json/Data/JsonDataReader.cs +++ /dev/null @@ -1,515 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; -using CoreEx.RefData; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text.Json; -using System.Threading.Tasks; -using YamlDotNet.Core.Events; -using YamlDotNet.Serialization; - -namespace CoreEx.Json.Data -{ - /// - /// Reads JSON or YAML data and converts into a corresponding typed collection. - /// - public sealed class JsonDataReader : IDisposable - { - private readonly JsonDocument _jsonDocument; - private readonly JsonProperty _json; - private readonly JsonDataReaderArgs _args; - private readonly bool _disposeDocument; - private readonly ExecutionContext _executionContext; - private readonly TypeReflectorArgs _typeReflectorArgs; - - private class YamlNodeTypeResolver : INodeTypeResolver - { - private static readonly string[] boolValues = ["true", "false"]; - - /// - bool INodeTypeResolver.Resolve(NodeEvent? nodeEvent, ref Type currentType) - { - if (nodeEvent is Scalar scalar && scalar.Style == YamlDotNet.Core.ScalarStyle.Plain) - { - if (decimal.TryParse(scalar.Value, out _)) - { - if (scalar.Value.Length > 1 && scalar.Value.StartsWith('0')) // Valid JSON does not support a number that starts with a zero. - currentType = typeof(string); - else - currentType = typeof(decimal); - - return true; - } - - if (boolValues.Contains(scalar.Value)) - { - currentType = typeof(bool); - return true; - } - } - - return false; - } - } - - /// - /// Reads and parses the YAML from the named embedded resource within the inferred from the . - /// - /// The to infer the to find manifest resources (see ). - /// The embedded resource name (matches to the end of the fully qualifed resource name). - /// The optional . - /// The . - public static JsonDataReader ParseYaml(string resourceName, JsonDataReaderArgs? args = null) => ParseYaml(Resource.GetStream(resourceName), args); - - /// - /// Reads and parses the YAML . - /// - /// The YAML . - /// The optional . - /// The . - public static JsonDataReader ParseYaml(string yaml, JsonDataReaderArgs? args = null) - { - using var sr = new StringReader(yaml); - return ParseYaml(sr, args); - } - - /// - /// Reads and parses the YAML . - /// - /// The YAML . - /// The optional . - /// The . - public static JsonDataReader ParseYaml(Stream s, JsonDataReaderArgs? args = null) => ParseYaml(new StreamReader(s), args); - - /// - /// Reads and parses the YAML . - /// - /// The YAML . - /// The optional . - /// The . - public static JsonDataReader ParseYaml(TextReader tr, JsonDataReaderArgs? args = null) - { - var yaml = new DeserializerBuilder().WithNodeTypeResolver(new YamlNodeTypeResolver()).Build().Deserialize(tr); - var json = new SerializerBuilder().JsonCompatible().Build().Serialize(yaml!); - return new(JsonDocument.Parse(json) ?? throw new InvalidOperationException("JsonNode.Parse resulted in a null."), args, true); - } - - /// - /// Reads and parses the JSON from the named embedded resource within the inferred from the . - /// - /// The to infer the to find manifest resources (see ). - /// The embedded resource name (matches to the end of the fully qualifed resource name). - /// The optional . - /// The . - public static JsonDataReader ParseJson(string resourceName, JsonDataReaderArgs? args = null) => ParseJson(Resource.GetStream(resourceName), args); - - /// - /// Reads and parses the JSON . - /// - /// The JSON . - /// The optional . - /// The . -#if NET7_0_OR_GREATER - public static JsonDataReader ParseJson([StringSyntax(StringSyntaxAttribute.Json)] string json, JsonDataReaderArgs? args = null) -#else - public static JsonDataReader ParseJson(string json, JsonDataReaderArgs? args = null) -#endif - => new(JsonDocument.Parse(json) ?? throw new InvalidOperationException("JsonNode.Parse resulted in a null."), args, true); - - /// - /// Reads and parses the JSON . - /// - /// The JSON . - /// The optional . - /// The . - public static JsonDataReader ParseJson(Stream s, JsonDataReaderArgs? args = null) => new(JsonDocument.Parse(s) ?? throw new InvalidOperationException("JsonNode.Parse resulted in a null."), args, true); - - /// - /// Reads and parses the . - /// - /// The . - /// The optional . - /// The . - /// A is only used to read and navigate the JSON, any serialization operation will use the specified . - public static JsonDataReader ParseJson(JsonDocument json, JsonDataReaderArgs? args = null) => new(json, args, false); - - /// - /// Initializes a new instance of the class. - /// - private JsonDataReader(JsonDocument json, JsonDataReaderArgs? args, bool disposeDocument) - { - _jsonDocument = json.ThrowIfNull(nameof(json)); - try - { - if (_jsonDocument.RootElement.ValueKind != JsonValueKind.Object) - throw new ArgumentException("JSON root element must be an Object.", nameof(json)); - - _json = json.RootElement.EnumerateObject().FirstOrDefault(); - if (_json.Value.ValueKind != JsonValueKind.Array) - throw new ArgumentException("JSON root element must be an Object with an underlying array.", nameof(json)); - } - catch - { - if (disposeDocument) - _jsonDocument.Dispose(); - - throw; - } - - _args = args ?? new JsonDataReaderArgs(); - _disposeDocument = disposeDocument; - _executionContext = new ExecutionContext { UserName = (string)_args.Parameters[JsonDataReaderArgs.UserNameKey]!, Timestamp = (DateTime)_args.Parameters[JsonDataReaderArgs.DateTimeNowKey]! }; - _typeReflectorArgs = new(_args.JsonSerializer); - } - - /// - /// Deserializes the contents of the named element into a collection of the specified . - /// - /// The to deserialize to. - /// The element name where the array of items to deserialize are housed. Defaults to the name. - /// The resulting collection of items. - /// true indicates that one or more items were deserialized; otherwise, false for none found. - public bool TryDeserialize(string? name, [NotNullWhen(true)] out List? items) - { - items = null; - - // Find the named object and deserialize corresponding items. - foreach (var ji in _json.Value.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Object)) - { - foreach (var jo in ji.EnumerateObject().Where(x => x.Name == (name ?? typeof(T).Name) && x.Value.ValueKind == JsonValueKind.Array)) - { - foreach (var jd in jo.Value.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Object)) - { - var item = Deserialize(jd); - if (item != null) - { - (items ??= []).Add(item); - _args.IdentifierGenerator?.AssignIdentifierAsync(item); - ChangeLog.PrepareCreated(item, _executionContext); - - // Only reset tenant id where not explicitly set. - if (item is ITenantId tenantId && tenantId is null) - Cleaner.ResetTenantId(item, _executionContext); - - PrepareReferenceData(typeof(T), item, jd, items.Count - 1); - } - } - } - } - - return items != null; - } - - /// - /// Deserializes the contents of the named element into a collection of the specified . - /// - /// The to deserialize to. - /// The element name where the array of items to deserialize are housed. Defaults to the name. - /// The resulting collection of items. - /// true indicates that one or more items were deserialized; otherwise, false for none found. - public bool TryDeserialize(Type type, string? name, [NotNullWhen(true)] out List? items) - { - items = null; - - // Find the named object and deserialize corresponding items. - foreach (var ji in _json.Value.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Object)) - { - foreach (var jo in ji.EnumerateObject().Where(x => x.Name == (name ?? type.Name) && x.Value.ValueKind == JsonValueKind.Array)) - { - foreach (var jd in jo.Value.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Object)) - { - var item = Deserialize(type, jd); - if (item != null) - { - (items ??= []).Add(item); - _args.IdentifierGenerator?.AssignIdentifierAsync(item); - ChangeLog.PrepareCreated(item, _executionContext); - - // Only reset tenant id where not explicitly set. - if (item is ITenantId tenantId && tenantId is null) - Cleaner.ResetTenantId(item, _executionContext); - - PrepareReferenceData(type, item, jd, items.Count - 1); - } - } - } - } - - return items != null; - } - - /// - /// Enumerate the named element and invoke the action per item. - /// - /// The element name where the array of items to invoke are housed. - /// The resulting item function. - /// true indicates that one or more items were invoked; otherwise, false for none found. - public async Task EnumerateJsonAsync(string name, Func json) - { - name.ThrowIfNullOrEmpty(nameof(name)); - json.ThrowIfNull(nameof(json)); - bool any = false; - - // Find the named object and action. - foreach (var ji in _json.Value.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Object)) - { - foreach (var jo in ji.EnumerateObject().Where(x => x.Name == name && x.Value.ValueKind == JsonValueKind.Array)) - { - foreach (var jd in jo.Value.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Object)) - { - await json(jd).ConfigureAwait(false); - any = true; - } - } - } - - return any; - } - - /// - /// Deserialize the JSON replacing any dynamic parameters. - /// - private T? Deserialize(JsonElement json) - { - using var ms = new MemoryStream(); - using var jw = new Utf8JsonWriter(ms); - - // Copy and replace JSON. - CopyAndReplace(json, jw); - jw.Flush(); - - // Deserialize the new JSON. - return _args.JsonSerializer.Deserialize(new BinaryData(ms.ToArray())); - } - - /// - /// Deserialize the JSON replacing any dynamic parameters. - /// - private object? Deserialize(Type type, JsonElement json) - { - using var ms = new MemoryStream(); - using var jw = new Utf8JsonWriter(ms); - - // Copy and replace JSON. - CopyAndReplace(json, jw); - jw.Flush(); - - // Deserialize the new JSON. - return _args.JsonSerializer.Deserialize(new BinaryData(ms.ToArray()), type); - } - - /// - /// Copy existing and replace any dynamic parameters. - /// - private void CopyAndReplace(JsonElement je, Utf8JsonWriter jw) - { - switch (je.ValueKind) - { - case JsonValueKind.Array: - jw.WriteStartArray(); - je.EnumerateArray().ForEach(j => CopyAndReplace(j, jw)); - jw.WriteEndArray(); - break; - - case JsonValueKind.Object: - jw.WriteStartObject(); - je.EnumerateObject().ForEach(j => - { - jw.WritePropertyName(j.Name); - CopyAndReplace(j.Value, jw); - }); - - jw.WriteEndObject(); - break; - - case JsonValueKind.String: - ReplaceDynamicParameter(je, jw); - break; - - default: - je.WriteTo(jw); - break; - } - } - - /// - /// Replace any '^' placholders. - /// - private void ReplaceDynamicParameter(JsonElement je, Utf8JsonWriter jw) - { - var str = je.GetString(); - if (!string.IsNullOrEmpty(str) && str.Length > 1 && str[0] == '^') - { - if (str.StartsWith("^(") && str.EndsWith(')')) - { - var val = GetRuntimeValue(_args.Parameters, str[2..^1]); - if (val == null) - { - jw.WriteNullValue(); - return; - } - - switch (val) - { - case string sv: jw.WriteStringValue(sv); break; - case Guid gv: jw.WriteStringValue(gv); break; - case DateTime dv: jw.WriteStringValue(dv); break; - case DateTimeOffset ov: jw.WriteStringValue(ov); break; -#if NET7_0_OR_GREATER - case DateOnly dv: jw.WriteStringValue(dv.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)); break; - case TimeOnly tv: jw.WriteStringValue(tv.ToString("HH:mm:ss.FFFFFFF", System.Globalization.CultureInfo.InvariantCulture)); break; -#endif - case bool bv: jw.WriteBooleanValue(bv); break; - case short nsv: jw.WriteNumberValue(nsv); break; - case int niv: jw.WriteNumberValue(niv); break; - case long nlv: jw.WriteNumberValue(nlv); break; - case ushort nusv: jw.WriteNumberValue(nusv); break; - case uint nuiv: jw.WriteNumberValue(nuiv); break; - case ulong nulv: jw.WriteNumberValue(nulv); break; - case decimal ndv: jw.WriteNumberValue(ndv); break; - case double n2v: jw.WriteNumberValue(n2v); break; - case float nfv: jw.WriteNumberValue(nfv); break; - default: jw.WriteStringValue(val.ToString()); break; - } - - return; - } - else if (_args.ReplaceShorthandGuids && int.TryParse(str[1..], out var i)) - { - jw.WriteStringValue(new Guid(i, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)); - return; - } - } - - je.WriteTo(jw); - } - - /// - /// Gets the runtime value for the specified key. - /// - public static object? GetRuntimeValue(IDictionary parameters, string key) - { - // Check against known values and runtime parameters. - if ((parameters.ThrowIfNull(nameof(parameters))).TryGetValue(key.ThrowIfNull(nameof(key)), out object? dval)) - return dval; - - // Try instantiating as defined. - var (val, msg) = GetSystemRuntimeValue(key); - if (msg == null) - return val; - - // Try again adding the System namespace. - (val, msg) = GetSystemRuntimeValue("System." + key); - if (msg == null) - return val; - - throw new ArgumentException(msg, nameof(key)); - } - - /// - /// Get the system runtime value. - /// - private static (object? value, string? message) GetSystemRuntimeValue(string param) - { - var ns = param.Split(","); - if (ns.Length > 2) - return (null, $"Runtime value parameter '{param}' is invalid; incorrect format."); - - var parts = ns[0].Split("."); - if (parts.Length <= 1) - return (null, $"Runtime value parameter '{param}' is invalid; incorrect format."); - - Type? type = null; - int i = parts.Length; - for (; i >= 0; i--) - { - if (ns.Length == 1) - type = Type.GetType(string.Join('.', parts[0..^(parts.Length - i)])); - else - type = Type.GetType(string.Join('.', parts[0..^(parts.Length - i)]) + "," + ns[1]); - - if (type != null) - break; - } - - if (type == null) - return (null, $"Runtime value parameter '{param}' is invalid; no Type can be found."); - - return GetSystemPropertyValue(param, type, null, parts[i..]); - } - - /// - /// Recursively navigates the properties and values to discern the value. - /// - private static (object? value, string? message) GetSystemPropertyValue(string param, Type type, object? obj, string[] parts) - { - if (parts == null || parts.Length == 0) - return (obj, null); - - var part = parts[0]; - if (part.EndsWith("()")) - { - var mi = type.GetMethod(part[0..^2], []); - if (mi == null || mi.GetParameters().Length != 0) - return (null, $"Runtime value parameter '{param}' is invalid; specified method '{part}' is invalid."); - - return GetSystemPropertyValue(param, mi.ReturnType, mi.Invoke(obj, null), parts[1..]); - } - else - { - var pi = type.GetProperty(part); - if (pi == null || !pi.CanRead) - return (null, $"Runtime value parameter '{param}' is invalid; specified property '{part}' is invalid."); - - return GetSystemPropertyValue(param, pi.PropertyType, pi.GetValue(obj, null), parts[1..]); - } - } - - /// - /// Prepare the value. - /// - private void PrepareReferenceData(Type type, object item, JsonElement json, int index) - { - if (item is not IReferenceData rd) - return; - - if (rd.Code == null && rd.Text == null && json.EnumerateObject().Count() == 1) - { - var jp = json.EnumerateObject().Single(); - if (jp.Value.ValueKind == JsonValueKind.String) - { - rd.Code = jp.Name; - rd.Text = jp.Value.GetString(); - } - } - - if (_args.RefDataColumnDefaults.Count == 0) - return; - - var tr = TypeReflector.GetReflector(_typeReflectorArgs, type); - foreach (var rp in _args.RefDataColumnDefaults) - { - // Check json name and property name overrides. - var pr = tr.GetProperty(rp.Key); - if ((pr.JsonName != null && !json.TryGetProperty(pr.JsonName, out _)) && !json.TryGetProperty(pr.Name, out _)) - { - pr.PropertyExpression.SetValue(item, rp.Value(index)); - } - } - } - - /// - public void Dispose() - { - if (_disposeDocument) - _jsonDocument.Dispose(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Data/JsonDataReaderArgs.cs b/src/CoreEx/Json/Data/JsonDataReaderArgs.cs deleted file mode 100644 index f49a524c..00000000 --- a/src/CoreEx/Json/Data/JsonDataReaderArgs.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.RefData; -using System; -using System.Collections.Generic; - -namespace CoreEx.Json.Data -{ - /// - /// Represents the arguments. - /// - public class JsonDataReaderArgs - { - /// - /// Gets the UserName key. - /// - public const string UserNameKey = "UserName"; - - /// - /// Gets the DateTimeNow key. - /// - public const string DateTimeNowKey = "DateTimeNow"; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The user name. Defaults to '\'. - /// The current . Defaults to . - /// The defaults to a new instance with a added to the default - /// to support numbers specified as strings which YAML is more permissive with. - public JsonDataReaderArgs(IJsonSerializer? jsonSerializer = null, string? username = null, DateTime? dateTimeNow = null) - { - Parameters.Add(UserNameKey, username ?? (Environment.UserDomainName == null ? Environment.UserName : $"{Environment.UserDomainName}\\{Environment.UserName}")); - Parameters.Add(DateTimeNowKey, dateTimeNow ?? SystemTime.Timestamp); - - RefDataColumnDefaults.Add(nameof(IReferenceData.IsActive), _ => true); - RefDataColumnDefaults.Add(nameof(IReferenceData.SortOrder), i => i + 1); - - if (jsonSerializer is null) - { - var jo = new System.Text.Json.JsonSerializerOptions(Text.Json.JsonSerializer.DefaultOptions); - jo.Converters.Add(new Text.Json.NumberToStringConverter()); - JsonSerializer = new Text.Json.JsonSerializer(jo); - } - else - JsonSerializer = jsonSerializer; - } - - /// - /// Gets or sets the . - /// - public IJsonSerializer JsonSerializer { get; } - - /// - /// Indiates whether to replace '^n' values where 'n' is an integer with a equivalent; e.g. '^1' will be '00000001-0000-0000-0000-000000000000' - /// - public bool ReplaceShorthandGuids { get; set; } = true; - - /// - /// Gets or sets the . - /// - /// Defaults to . - public IIdentifierGenerator? IdentifierGenerator { get; set; } = new IdentifierGenerator(); - - /// - /// Gets or sets the reference data property defaults dictionary. - /// - /// The dictionary should contain the property name and corresponding function that returns the default value; the input to the function is the item index (zero-based). - /// Defaults following properties: - /// - /// with function '_ => true' (always true). - /// with function 'i => i + 1' (increment by 1 from 1). - /// - /// - public Dictionary> RefDataColumnDefaults { get; } = []; - - /// - /// Gets the runtime parameters. - /// - public Dictionary Parameters { get; } = []; - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/IJsonPreFilterInspector.cs b/src/CoreEx/Json/IJsonPreFilterInspector.cs deleted file mode 100644 index 6342ea8b..00000000 --- a/src/CoreEx/Json/IJsonPreFilterInspector.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Json -{ - /// - /// Defines pre (prior) to filtering JSON inspector. - /// - /// Enables access to the serialized value before the filtering occurs. - public interface IJsonPreFilterInspector - { - /// - /// Gets the underlying JSON object (as per the underlying implementation). - /// - object Json { get; } - - /// - /// Returns the JSON string. - /// - /// The JSON string. - string? ToJsonString(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/IJsonSerializer.cs b/src/CoreEx/Json/IJsonSerializer.cs deleted file mode 100644 index 2edd92e1..00000000 --- a/src/CoreEx/Json/IJsonSerializer.cs +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; - -namespace CoreEx.Json -{ - /// - /// Provides the core (common) JSON Serialize and Deserialize capabilities. - /// - public interface IJsonSerializer - { - /// - /// Gets the underlying serializer configuration settings/options. - /// - object Options { get; } - - /// - /// Serialize the to a JSON . - /// - /// The . - /// The value to serialize. - /// Where specified overrides the serialization write formatting. - /// The JSON . - string Serialize(T value, JsonWriteFormat? format = null); - - /// - /// Serialize the to a JSON . - /// - /// The . - /// The value to serialize. - /// Where specified overrides the serialization write formatting. - /// The JSON . - BinaryData SerializeToBinaryData(T value, JsonWriteFormat? format = null); - - /// - /// Deserialize the JSON to an underlying JSON object. - /// - /// The JSON . - /// The JSON object (as per the underlying implementation). -#if NET7_0_OR_GREATER - object? Deserialize([StringSyntax(StringSyntaxAttribute.Json)] string json); -#else - object? Deserialize(string json); -#endif - - /// - /// Deserialize the JSON to the specified . - /// - /// The JSON . - /// The to convert to. - /// The corresponding typed value. -#if NET7_0_OR_GREATER - object? Deserialize([StringSyntax(StringSyntaxAttribute.Json)] string json, Type type); -#else - object? Deserialize(string json, Type type); -#endif - - /// - /// Deserialize the JSON to the of . - /// - /// The to convert to. - /// The JSON . - /// The corresponding typed value. -#if NET7_0_OR_GREATER - T? Deserialize([StringSyntax(StringSyntaxAttribute.Json)] string json); -#else - T? Deserialize(string json); -#endif - - /// - /// Deserialize the JSON to an underlying JSON object. - /// - /// The JSON . - /// The JSON object (as per the underlying implementation). - object? Deserialize(BinaryData json); - - /// - /// Deserialize the JSON to the specified . - /// - /// The JSON . - /// The to convert to. - /// The corresponding typed value. - object? Deserialize(BinaryData json, Type type); - - /// - /// Deserialize the JSON to the of . - /// - /// The to convert to. - /// The JSON . - /// The corresponding typed value. - T? Deserialize(BinaryData json); - - /// - /// Trys to apply the JSON property (using JSON ) to a resulting in the corresponding . - /// - /// The value . - /// The value. - /// The list of JSON property names to . - /// The corresponding JSON with the filtering applied. - /// The ; defaults to . - /// The names ; defaults to . - /// The action. - /// true indicates that at least one JSON node was filtered (removed); otherwise, false for no changes. - bool TryApplyFilter(T value, IEnumerable? names, out string json, JsonPropertyFilter filter = JsonPropertyFilter.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null); - - /// - /// Trys to apply the JSON property (using JSON ) to a resulting in the corresponding . - /// - /// The value . - /// The value. - /// The list of JSON property names to . - /// The corresponding JSON object (as per the underlying implementation) with the filtering applied. - /// The ; defaults to . - /// The names ; defaults to . - /// The action. - /// true indicates that at least one JSON node was filtered (removed); otherwise, false for no changes. - bool TryApplyFilter(T value, IEnumerable? names, out object json, JsonPropertyFilter filter = JsonPropertyFilter.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null); - - /// - /// Trys and gets the corresponding JSON name for the . - /// - /// The - /// The JSON name where underlying JSON attribute is defined or not; null where not serializable. - /// true indicates that the property is considered serializable; otherwise, false. - bool TryGetJsonName(MemberInfo memberInfo, [NotNullWhen(true)] out string? jsonName); - - /// - /// Serialize the to a JSON using the specified property filter (). - /// - /// The . - /// The value to serialize. - /// The list of JSON property names to . - /// The JSON . - /// This is a wrapper for . - public string SerializeWithIncludeFilter(T value, params string[] names) - { - TryApplyFilter(value, names, out string json, JsonPropertyFilter.Include, StringComparison.OrdinalIgnoreCase); - return json; - } - - /// - /// Serialize the to a JSON using the specified property filter (). - /// - /// The . - /// The value to serialize. - /// The list of JSON property names to . - /// The JSON . - /// This is a wrapper for . - public string SerializeWithExcludeFilter(T value, params string[] names) - { - TryApplyFilter(value, names, out string json, JsonPropertyFilter.Exclude, StringComparison.OrdinalIgnoreCase); - return json; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/IReferenceDataContentJsonSerializer.cs b/src/CoreEx/Json/IReferenceDataContentJsonSerializer.cs deleted file mode 100644 index 1c184324..00000000 --- a/src/CoreEx/Json/IReferenceDataContentJsonSerializer.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; - -namespace CoreEx.Json -{ - /// - /// Provides the JSON Serialize and Deserialize capabilities to allow types to serialize contents. - /// - /// Generally, types will serialize the as the value; this allows for full contents to be serialized. - public interface IReferenceDataContentJsonSerializer : IJsonSerializer { } -} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonDataMapConverterFactory.cs b/src/CoreEx/Json/JsonDataMapConverterFactory.cs new file mode 100644 index 00000000..f8d79005 --- /dev/null +++ b/src/CoreEx/Json/JsonDataMapConverterFactory.cs @@ -0,0 +1,58 @@ +namespace CoreEx.Json; + +/// +/// Provides a generic JSON converter factory for types that preserves the original casing of the underlying keys. +/// +public class JsonDataMapConverterFactory : JsonConverterFactory +{ + /// + public override bool CanConvert(Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + var genericType = typeToConvert.GetGenericTypeDefinition(); + return genericType == typeof(DataMap<>); + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(JsonDataMapConverter<>).MakeGenericType(valueType); + return (JsonConverter?)Activator.CreateInstance(converterType); + } + + /// + /// Provides the JSON converter for types, which handles the serialization and deserialization of the dictionary while preserving the original casing of the keys. + /// + private sealed class JsonDataMapConverter : JsonConverter> + { + /// + public override DataMap? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var dict = JsonSerializer.Deserialize>(ref reader, options); + return dict is null ? null : new(dict); + } + + /// + public override void Write(Utf8JsonWriter writer, DataMap value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartObject(); + + foreach (var kvp in value) + { + writer.WritePropertyName(kvp.Key); + JsonSerializer.Serialize(writer, kvp.Value, options); + } + + writer.WriteEndObject(); + } + } +} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonDefaults.cs b/src/CoreEx/Json/JsonDefaults.cs new file mode 100644 index 00000000..06ea7ba9 --- /dev/null +++ b/src/CoreEx/Json/JsonDefaults.cs @@ -0,0 +1,49 @@ +namespace CoreEx.Json; + +/// +/// Provides defaults; such as the primary and runtime accessor. +/// +public class JsonDefaults +{ + /// + /// Gets the default . + /// + /// Use this property to customize the default behaviors for JSON serialization throughout the application. + public static JsonDefaultConfiguration Configuration { get; } = new(); + + /// + /// Gets the current from the where found; otherwise, the references the instance. + /// + /// Do not make changes to the underlying via this method as the instance may vary at runtime; changes should be made via + /// or during service registration at application startup. + public static JsonSerializerOptions SerializerOptions => ExecutionContext.GetService() ?? Configuration.SerializerOptions; + + /// + /// Provides the default JSON configuration settings for the application, which can be customized as needed. + /// + /// This encapsulates the default and allows for centralized management of JSON serialization settings. + public sealed class JsonDefaultConfiguration + { + private readonly JsonSubstituteNamingPolicy _jsonSubstituteNamingPolicy = new(); + + /// + /// Initializes a new instance of the class with default settings. + /// + public JsonDefaultConfiguration() + { + SerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + WriteIndented = false, + PropertyNamingPolicy = _jsonSubstituteNamingPolicy, + DictionaryKeyPolicy = _jsonSubstituteNamingPolicy, + Converters = { new JsonStringEnumConverter(), new JsonReferenceDataConverter(), new JsonDataMapConverterFactory() } + }; + } + + /// + /// Gets or sets the default configuration. + /// + public JsonSerializerOptions SerializerOptions { get; set => field = value.ThrowIfNull(); } + } +} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonExceptionConverterFactory.cs b/src/CoreEx/Json/JsonExceptionConverterFactory.cs new file mode 100644 index 00000000..8dea68aa --- /dev/null +++ b/src/CoreEx/Json/JsonExceptionConverterFactory.cs @@ -0,0 +1,53 @@ +namespace CoreEx.Json; + +/// +/// Provides a generic JSON converter factory for types. +/// +public class JsonExceptionConverterFactory : JsonConverterFactory +{ + /// + public override bool CanConvert(Type typeToConvert) => typeof(Exception).IsAssignableFrom(typeToConvert); + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + => (JsonConverter)Activator.CreateInstance(typeof(JsonExceptionConverter<>).MakeGenericType(typeToConvert))!; + + /// + /// Provides a reflection-based JSON converter for types. + /// + /// The . + private sealed class JsonExceptionConverter : JsonConverter where TException : Exception + { + /// + public override TException? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotSupportedException(); + + /// + public override void Write(Utf8JsonWriter writer, TException value, JsonSerializerOptions options) + { + var serializableProperties = value.GetType().GetProperties() + .Select(uu => new + { + uu.Name, + Value = uu.GetValue(value), + Ignore = uu.GetCustomAttribute(), + JsonName = uu.GetCustomAttribute()?.Name + }) + .Where(uu => uu.Ignore is not null && uu.Name != nameof(Exception.TargetSite)); + + if (options?.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull) + serializableProperties = serializableProperties.Where(uu => uu.Value is not null); + + if (serializableProperties.Any()) + { + writer.WriteStartObject(); + foreach (var prop in serializableProperties) + { + writer.WritePropertyName(prop.JsonName ?? options?.PropertyNamingPolicy?.ConvertName(prop.Name) ?? prop.Name); + JsonSerializer.Serialize(writer, prop.Value, options); + } + + writer.WriteEndObject(); + } + } + } +} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonFilter.cs b/src/CoreEx/Json/JsonFilter.cs new file mode 100644 index 00000000..20593b6c --- /dev/null +++ b/src/CoreEx/Json/JsonFilter.cs @@ -0,0 +1,361 @@ +using System.Text.Json.Nodes; +using static System.Net.WebRequestMethods; + +namespace CoreEx.Json; + +/// +/// Provides a means to apply a filter to include or exclude JSON properties (in effect removing the unwanted properties). +/// +/// The JSON path matching is exact (other than specified ) in that the path matches with no indexing or fully indexed; i.e. no mixing is supported. For example, a JSON path of +/// '$.projects[0].technologies[1]' will only match based on a filter of either '$.projects[0].technologies[1]' (fully indexed) or '$.projects.technologies' (no indexing); not on +/// '$.projects.technologies[1]' (mixed). Note that the '$.' JSON path prefix for the filter is optional. +public static partial class JsonFilter +{ + private static readonly Regex _regex = IndexesRegex(); + + /// + /// Gets the standard JSON root path. + /// + public const string JsonRootPath = "$"; + + /// + /// Prepends the JSON with the where not already present. + /// + /// The JSON path. + /// The resulting JSON path. + public static string PrependRootPath(string path) => string.IsNullOrEmpty(path) ? JsonRootPath : (!path.StartsWith(JsonRootPath) ? (path.StartsWith('[') ? $"{JsonRootPath}{path}" : $"{JsonRootPath}.{path}") : path); + + /// + /// Removes all indexes from the specified JSON path. + /// + /// The input JSON path. + /// The resulting JSON path. + /// indicates indexes were removed; otherwise, . + public static bool TryRemovePathIndexes(string input, out string path) + { + if (string.IsNullOrEmpty(input)) + { + path = input; + return false; + } + + path = _regex.Replace(input, string.Empty); + return path.Length != input.Length; + } + + /// + /// Tries to apply the JSON (using JSON ) to a JSON resulting in the corresponding . + /// + /// The JSON value. + /// The list of JSON paths to . + /// The corresponding JSON with the filtering applied. + /// The ; defaults to . + /// The optional . + /// The paths ; defaults to . + /// indicates that at least one JSON node was filtered (removed); otherwise, for no changes. + public static bool TryJsonFilter([StringSyntax(StringSyntaxAttribute.Json)] string value, IEnumerable? paths, out string json, JsonFilterOption filter = JsonFilterOption.Include, JsonSerializerOptions? jsonSerializerOptions = null, StringComparison comparison = StringComparison.OrdinalIgnoreCase) + { + var j = JsonNode.Parse(value.ThrowIfNull())!; + var r = Filter(j, paths, filter, comparison); + json = j?.ToJsonString(jsonSerializerOptions ?? JsonDefaults.SerializerOptions) ?? "null"; + return r; + } + + /// + /// Tries to apply the JSON (using JSON ) to a resulting in the corresponding . + /// + /// The value . + /// The value. + /// The list of JSON paths to . + /// The corresponding JSON with the filtering applied. + /// The ; defaults to . + /// The optional . + /// The paths ; defaults to . + /// indicates that at least one JSON node was filtered (removed); otherwise, for no changes. + public static bool TryFilter(T value, IEnumerable? paths, out string json, JsonFilterOption filter = JsonFilterOption.Include, JsonSerializerOptions? jsonSerializerOptions = null, StringComparison comparison = StringComparison.OrdinalIgnoreCase) + { + var r = TryFilter(value, paths, out JsonNode node, filter, jsonSerializerOptions, comparison); + json = node?.ToJsonString(jsonSerializerOptions ?? JsonDefaults.SerializerOptions) ?? "null"; + return r; + } + + /// + /// Tries to apply the JSON (using JSON ) to a resulting in the corresponding . + /// + /// The value . + /// The value. + /// The list of JSON paths to . + /// The corresponding with the filtering applied. + /// The ; defaults to . + /// The optional . + /// The paths ; defaults to . + /// indicates that at least one JSON node was filtered (removed); otherwise, for no changes. + public static bool TryFilter(T value, IEnumerable? paths, out JsonNode json, JsonFilterOption filter = JsonFilterOption.Include, JsonSerializerOptions? jsonSerializerOptions = null, StringComparison comparison = StringComparison.OrdinalIgnoreCase) + { + json = JsonSerializer.SerializeToNode(value, jsonSerializerOptions ?? JsonDefaults.SerializerOptions)!; + return Filter(json, paths, filter, comparison); + } + + /// + /// Applies the JSON (using JSON ) to a specified . + /// + /// The value. + /// The list of JSON paths to . + /// The ; defaults to . + /// The paths ; defaults to . + /// indicates that at least one JSON node was filtered (removed); otherwise, for no changes. + public static bool Filter(JsonNode json, IEnumerable? paths, JsonFilterOption filter = JsonFilterOption.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase) + { + if (json is null) + return false; + + var maxDepth = 0; + var dict = CreateDictionary(paths, filter, comparison, ref maxDepth, true); + var args = new JsonFilterArgs { MaxDepth = maxDepth, Paths = dict }; + + if (filter == JsonFilterOption.Include) + FilterInclude(json, args); + else if (maxDepth > 0) + FilterExclude(json, args, 1); + + return args.IsFiltered; + } + + /// + /// Gets the first that matches the JSON from within the specified . + /// + /// The value. + /// The JSON path to match. + /// The paths ; defaults to . + /// The first matched where found; otherwise, . + public static JsonNode? GetMatched(JsonNode json, string path, StringComparison comparison = StringComparison.OrdinalIgnoreCase) + { + var maxDepth = 0; + var dict = CreateDictionary([path.ThrowIfNullOrEmpty()], JsonFilterOption.Include, comparison, ref maxDepth, true); + var args = new JsonFilterArgs { MaxDepth = maxDepth, Paths = dict }; + + FilterInclude(json, args); + return args.MatchedNode; + } + + /// + /// Create a from the and expands list with intermediary paths where is . + /// + /// The list of JSON paths. + /// The . + /// The paths . + /// The maximum hierarchy depth for all specified . + /// The . + /// Where the is this indicates the specified path; versus, that indicates an intermediary path. + public static Dictionary CreateDictionary(IEnumerable? paths, JsonFilterOption filter, StringComparison comparison, ref int maxDepth) + => CreateDictionary(paths, filter, comparison, ref maxDepth, false); + + /// + /// Create a from the and expands list with intermediary paths where is . + /// + /// The list of JSON paths. + /// The . + /// The paths . + /// The maximum hierarchy depth for all specified . + /// Indicates whether to prepend the to each path. + /// The . + /// Where the is this indicates the specified path; versus, that indicates an intermediary path. + private static Dictionary CreateDictionary(IEnumerable? paths, JsonFilterOption filter, StringComparison comparison, ref int maxDepth, bool prependRootPath) + { + var dict = new Dictionary(StringComparer.FromComparison(comparison)); + paths ??= []; + + // Add each 'specified' path. + foreach (var path in paths) + dict.TryAdd(prependRootPath ? PrependRootPath(path) : path, true); + + // Add each 'intermediary' path where applicable. + if (filter == JsonFilterOption.Include) + { + var sb = new StringBuilder(); + foreach (var kvp in dict.ToArray()) + { + sb.Clear(); + var parts = kvp.Key.Split('.'); + for (int i = 0; i < parts.Length; i++) + { + if (i > 0) + sb.Append('.'); + + sb.Append(parts[i]); + dict.TryAdd(sb.ToString(), false); + + maxDepth = Math.Max(maxDepth, i + 1); + } + + if (TryRemovePathIndexes(kvp.Key, out var indexless)) + { + sb.Clear(); + parts = indexless.Split('.'); + for (int i = 0; i < parts.Length; i++) + { + if (i > 0) + sb.Append('.'); + + sb.Append(parts[i]); + dict.TryAdd(sb.ToString(), false); + } + } + } + + foreach (var kvp in dict.ToArray()) + { + if (dict.Keys.Any(x => !x.Equals(kvp.Key, comparison) && x.StartsWith(kvp.Key, comparison))) + dict[kvp.Key] = false; + } + } + else + maxDepth = Math.Max(maxDepth, dict.Count == 0 ? 0 : dict.Max(x => x.Key.Count(c => c == '.') + 1)); + + return dict; + } + + /// + /// Recursively filters the JSON based on the specified and results in true where should be excluded (removed). + /// This is used for the option. + /// + private static bool FilterInclude(JsonNode json, JsonFilterArgs args) + { + var path = json.GetPath(); + if (args.Paths.TryGetValue(path, out var isSpecifiedPath)) + { + if (isSpecifiedPath) + { + args.MatchedNode = json; + return false; + } + } + else + { + if (TryRemovePathIndexes(path, out var pathWithoutIndexes)) + { + if (args.Paths.TryGetValue(pathWithoutIndexes, out isSpecifiedPath) && isSpecifiedPath) + { + args.MatchedNode = json; + return false; + } + } + else + return true; + } + + if (json is JsonObject jo) + { + foreach (var jn in jo.ToArray()) + { + if (FilterInclude(jn.Value ?? throw new InvalidOperationException(), args)) + { + jo.Remove(jn.Key); + args.IsFiltered = true; + } + else + isSpecifiedPath = true; + } + } + else if (json is JsonArray ja) + { + for (var i = ja.Count - 1; i >= 0; i--) + { + var jn = ja[i]!; + if (FilterInclude(jn, args)) + { + ja.RemoveAt(i); + args.IsFiltered = true; + } + else + isSpecifiedPath = true; + } + } + + return !isSpecifiedPath; + } + + /// + /// Recursively filters the JSON based on the specified and results in true where should be excluded (removed). + /// This is used for the option. + /// + private static bool FilterExclude(JsonNode json, JsonFilterArgs args, int depth) + { + if (depth > args.MaxDepth) + return false; + + var path = json.GetPath(); + if (args.Paths.TryGetValue(path, out var isSpecifiedPath)) + { + if (isSpecifiedPath) + return true; + } + else + { + if (TryRemovePathIndexes(path, out var pathWithoutIndexes)) + { + if (args.Paths.TryGetValue(pathWithoutIndexes, out isSpecifiedPath) && isSpecifiedPath) + return true; + } + } + + if (json is JsonObject jo) + { + depth++; + foreach (var jn in jo.ToArray()) + { + if (FilterExclude(jn.Value ?? throw new InvalidOperationException(), args, depth)) + { + jo.Remove(jn.Key); + args.IsFiltered = true; + } + } + } + else if (json is JsonArray ja) + { + for (var i = ja.Count - 1; i >= 0; i--) + { + var jn = ja[i]!; + if (FilterExclude(jn, args, depth)) + { + ja.RemoveAt(i); + args.IsFiltered = true; + } + } + } + + return false; + } + + /// + /// Represents the internal arguments for the JSON filter state. + /// + private sealed class JsonFilterArgs + { + /// + /// Gets the selected JSON paths to include/exclude. + /// + public required Dictionary Paths { get; init; } + + /// + /// Gets the maximum depth of the JSON hierarchy of the specified. + /// + public int MaxDepth { get; init; } = 0; + + /// + /// Indicates whether a filter took place; i.e. there was at least one JSON node removed. + /// + public bool IsFiltered { get; set; } + + /// + /// Gets or sets the last fully matched JSON node for am . + /// + public JsonNode? MatchedNode { get; set; } + } + + /// + /// Provides the generated for . + /// + [GeneratedRegex(@"\[(.*?)\]", RegexOptions.Compiled)] + private static partial Regex IndexesRegex(); +} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonFilterOption.cs b/src/CoreEx/Json/JsonFilterOption.cs new file mode 100644 index 00000000..c40e4cf6 --- /dev/null +++ b/src/CoreEx/Json/JsonFilterOption.cs @@ -0,0 +1,17 @@ +namespace CoreEx.Json; + +/// +/// Defines the JSON filter option (either to or the specified paths) for the . +/// +public enum JsonFilterOption +{ + /// + /// Indicates whether to include only those property paths that have been specified. + /// + Include, + + /// + /// Indicates whether to exclude those property paths that have been specified. + /// + Exclude +} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonMergePatch.cs b/src/CoreEx/Json/JsonMergePatch.cs new file mode 100644 index 00000000..d7bbb595 --- /dev/null +++ b/src/CoreEx/Json/JsonMergePatch.cs @@ -0,0 +1,385 @@ +namespace CoreEx.Json; + +/// +/// Provides a JSON Merge Patch ('application/merge-patch+json') whereby the contents of a JSON document are merged into an existing JSON document resulting in a new merged JSON document as per . +/// +/// The optional . +/// Note: The formal specification explicitly states that an is to be a replacement operation. Additionally, +/// values in the merge patch are given special meaning to indicate the removal of existing values in the target. +public sealed class JsonMergePatch(JsonMergePatchOptions? options = null) +{ + /// + /// Gets the . + /// + public JsonMergePatchOptions Options { get; } = options ?? new JsonMergePatchOptions(); + + /// + /// Merges the content into the . + /// + /// The value . + /// The JSON to merge patch. + /// The target to merge into. + /// The . + public Result> Merge([StringSyntax(StringSyntaxAttribute.Json)] string patch, T target) => Merge(new BinaryData(patch.ThrowIfNull()), target); + + /// + /// Merges the content into the . + /// + /// The value . + /// The JSON to merge patch. + /// The target to merge into. + /// The . + public Result> Merge(BinaryData patch, T target) + { + // Parse ensuring the JSON is valid for the type and can be navigated before attempting merge. + if (!TryParseJson(patch.ThrowIfNull(), out var r)) + return r; + + // No merge will occur when the target is null. + if (target is null) + return new JsonMergePatchResult(); + + // Perform the merge patch. + return MergePatch(patch, target); + } + + /// + /// Merges the content into the value returned by the function. + /// + /// The value . + /// The JSON to merge patch. + /// The function to get the target to merge into. + /// The . + /// The . + /// Provides the opportunity to validate the JSON before getting the value where this execution order is important; i.e. get operation is expensive (latency). A that returns + /// will immediately exit without performing any merge. + public Task>> MergeAsync(BinaryData patch, Func> getTarget, CancellationToken cancellationToken = default) + => MergeWithResultAsync(patch, async ct => Result.Ok(await getTarget.ThrowIfNull()(ct).ConfigureAwait(false)), cancellationToken); + + /// + /// Merges the content into the value returned by the function. + /// + /// The value . + /// The JSON to merge patch. + /// The function to get the target to merge into. + /// The . + /// The . + /// Provides the opportunity to validate the JSON before getting the value where this execution order is important; i.e. get operation is expensive (latency). A that returns + /// will immediately exit without performing any merge. + public async Task>> MergeWithResultAsync(BinaryData patch, Func>> getTarget, CancellationToken cancellationToken = default) + { + // Parse ensuring the JSON is valid for the type and can be navigated. + if (!TryParseJson(patch.ThrowIfNull(), out var r)) + return r; + + // Get the value and exit where nothing to merge into. + var target = await getTarget.ThrowIfNull().Invoke(cancellationToken).ConfigureAwait(false); + if (target.IsFailure) + return target.Error; + + if (target.Value is null) + return new JsonMergePatchResult(); + + // Perform the merge patch. + return MergePatch(patch, target.Value); + } + + /// + /// Merges the content into the . + /// + private Result> MergePatch(BinaryData patch, T target) + { + // Serialize the merge into value as will be using JsonElements to perform. + var vje = JsonSerializer.SerializeToElement(target, Options.JsonSerializerOptions); + + // Perform the root merge patch and return the corresponding result. + var jd = JsonDocument.Parse(patch.ToMemory()); + if (!TryMerge(jd.RootElement, vje, out ArrayBufferWriter? merged)) + return Result.Ok(new JsonMergePatchResult { HasChanges = false, Merged = target }); + + var reader = new Utf8JsonReader(merged.WrittenSpan); + return Result.Ok(new JsonMergePatchResult { HasChanges = true, Merged = JsonSerializer.Deserialize(ref reader, Options.JsonSerializerOptions) }); + } + + /// + /// Tries to parse the JSON outputting a failed result where unsuccessful. + /// + private bool TryParseJson(BinaryData patch, out Result> result) + { + try + { + patch.ToObjectFromJson(Options.JsonSerializerOptions); + result = new(); + return true; + } + catch (JsonException jex) + { + result = new Result>(jex); + return false; + } + } + + /// + /// Merges the with the with a merged result. + /// + /// The JSON to merge patch. + /// The JSON target to merge with (into). + /// The resulting merged JSON. + public string Merge([StringSyntax(StringSyntaxAttribute.Json)] string patch, [StringSyntax(StringSyntaxAttribute.Json)] string target) + => Merge(JsonDocument.Parse(patch.ThrowIfNullOrEmpty()).RootElement, JsonDocument.Parse(target.ThrowIfNullOrEmpty()).RootElement).GetRawText(); + + /// + /// Merges the with the . + /// + /// The JSON to merge patch. + /// The JSON target to merge with (into). + /// The resulting merged . + public JsonElement Merge(JsonElement json, JsonElement target) + { + TryMerge(json, target, out JsonElement merged); + return merged; + } + + /// + /// Merges the with the resulting in merged JSON where changes were made. + /// + /// The JSON to merge patch. + /// The JSON to merge with (into). + /// The resulting merged JSON. + /// indicates that changes were made as a result of the merge (see resulting ); otherwise, for no changes. + public bool TryMerge([StringSyntax(StringSyntaxAttribute.Json)] string patch, [StringSyntax(StringSyntaxAttribute.Json)] string target, [NotNullWhen(true)] out string? merged) + { + if (!TryMerge(JsonDocument.Parse(patch.ThrowIfNullOrEmpty()).RootElement, JsonDocument.Parse(target.ThrowIfNullOrEmpty()).RootElement, out ArrayBufferWriter? buffer)) + { + merged = null; + return false; + } + + // Read the merged writer and parse. + var reader = new Utf8JsonReader(buffer.WrittenSpan); + merged = JsonElement.ParseValue(ref reader).Clone().GetRawText(); + return true; + } + + /// + /// Merges the with the resulting in the where changes were made. + /// + /// The JSON to merge patch. + /// The JSON to merge with (into). + /// The resulting merged JSON. + /// indicates that changes were made as a result of the merge (see resulting ); otherwise, for no changes. + public bool TryMerge(JsonElement patch, JsonElement target, out JsonElement merged) + { + // Merge and where no changes then return the target as-is. + if (!TryMerge(patch, target, out ArrayBufferWriter? buffer)) + { + merged = target; + return false; + } + + // Read the merged writer and parse. + var reader = new Utf8JsonReader(buffer.WrittenSpan); + merged = JsonElement.ParseValue(ref reader).Clone(); + return true; + } + + /// + /// Merge the JSON elements (root). + /// + private bool TryMerge(JsonElement patch, JsonElement target, [NotNullWhen(true)] out ArrayBufferWriter? merged) + { + // Create writer for the merged output. + var buffer = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer); + + // Merge for real! + var changed = TryMerge(patch, target, writer, false); + + // Where no changes then return the target as-is. + if (!changed) + { + merged = null; + return false; + } + + // Read the merged writer and parse. + writer.Flush(); + merged = buffer; + return true; + } + + /// + /// Merge the JSON elements (recursive). + /// + private bool TryMerge(JsonElement patch, JsonElement target, Utf8JsonWriter writer, bool changed) + { + // Where the kinds are different then simply accept the merge and acknowledge as changed. + if (patch.ValueKind != target.ValueKind) + { + patch.WriteTo(writer); + return true; + } + + // Where the kinds are the same then process accordingly. + switch (patch.ValueKind) + { + case JsonValueKind.Object: + // An object is a property-by-property merge. + return TryObjectMerge(patch, target, writer, changed); + + case JsonValueKind.Array: + // An array is always a replacement. + patch.WriteTo(writer); +#if NET8_0 + if (patch.GetArrayLength() != target.GetArrayLength() || !DeepEquals(patch, target)) +#else + if (patch.GetArrayLength() != target.GetArrayLength() || !JsonElement.DeepEquals(patch, target)) +#endif + changed = true; + + break; + + default: + // Accept merge as-is. + patch.WriteTo(writer); +#if NET8_0 + if (!DeepEquals(patch, target)) +#else + if (!JsonElement.DeepEquals(patch, target)) +#endif + changed = true; + + break; + } + + return changed; + } + + /// + /// Merge the JSON object and properties and record changed. + /// + private bool TryObjectMerge(JsonElement patch, JsonElement target, Utf8JsonWriter writer, bool changed) + { + writer.WriteStartObject(); + + // Apply merge add/override. + foreach (var j in patch.EnumerateObject()) + { + // Where the property is new then add. + if (!TryGetProperty(target, j.Name, out var t)) + { + if (j.Value.ValueKind != JsonValueKind.Null) + j.WriteTo(writer); + + changed = true; + continue; + } + + // Null is a remove; otherwise, override. + if (j.Value.ValueKind != JsonValueKind.Null) + { + writer.WritePropertyName(j.Name); + changed = TryMerge(j.Value, t, writer, changed); + } + else + changed = true; + } + + // Add existing target properties not being merged. + foreach (var t in target.EnumerateObject()) + { + // Where found then consider as handled above and ignore! + if (TryGetProperty(patch, t.Name, out _)) + continue; + + t.WriteTo(writer); + } + + writer.WriteEndObject(); + return changed; + } + + /// + /// Performs the TryGetProperty using the configured comparer. + /// + private bool TryGetProperty(JsonElement json, string propertyName, out JsonElement value) + => Options.PropertyNameComparer is null ? json.TryGetProperty(propertyName, out value) : TryGetPropertyWithComparer(json, propertyName, Options.PropertyNameComparer, out value); + + /// + /// Performs the TryGetProperty using the specified comparer (this will impact performance). + /// + private static bool TryGetPropertyWithComparer(JsonElement json, string propertyName, StringComparer comparer, out JsonElement value) + { + foreach (var j in json.EnumerateObject()) + { + if (comparer.Equals(j.Name, propertyName)) + { + value = j.Value; + return true; + } + } + + value = default; + return false; + } + +#if NET8_0 + /// + /// Provides a deep equals for two instances. + /// + /// The left . + /// The right . + internal static bool DeepEquals(JsonElement left, JsonElement right) + { + if (left.ValueKind != right.ValueKind) + { + return false; + } + + switch (left.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.False: + case JsonValueKind.True: + // These are the same by kind, so carry on! + return true; + + case JsonValueKind.Number: + case JsonValueKind.String: + return left.GetRawText() == right.GetRawText(); + + case JsonValueKind.Array: + if (left.GetArrayLength() != right.GetArrayLength()) + return false; + + var rja = right.EnumerateArray(); + foreach (var lje in left.EnumerateArray()) + { + rja.MoveNext(); + if (!DeepEquals(lje, rja.Current)) + return false; + } + + return true; + + default: + foreach (var l in left.EnumerateObject()) + { + if (!right.TryGetProperty(l.Name, out var r)) + { + if (!DeepEquals(l.Value, r)) + return false; + } + } + + foreach (var r in right.EnumerateObject()) + { + if (!left.TryGetProperty(r.Name, out var _)) + return false; + } + + return true; + } + } +#endif +} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonMergePatchOptions.cs b/src/CoreEx/Json/JsonMergePatchOptions.cs new file mode 100644 index 00000000..adc4f6da --- /dev/null +++ b/src/CoreEx/Json/JsonMergePatchOptions.cs @@ -0,0 +1,18 @@ +namespace CoreEx.Json; + +/// +/// The options. +/// +/// The optional . +public class JsonMergePatchOptions(JsonSerializerOptions? jsonSerializerOptions = null) +{ + /// + /// Gets the . + /// + public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions ?? JsonDefaults.SerializerOptions; + + /// + /// Gets or sets the for matching the JSON name (defaults to ). + /// + public StringComparer PropertyNameComparer { get; set; } = StringComparer.OrdinalIgnoreCase; +} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonMergePatchResult.cs b/src/CoreEx/Json/JsonMergePatchResult.cs new file mode 100644 index 00000000..d6875be4 --- /dev/null +++ b/src/CoreEx/Json/JsonMergePatchResult.cs @@ -0,0 +1,17 @@ +namespace CoreEx.Json; + +/// +/// The result. +/// +public class JsonMergePatchResult +{ + /// + /// Indicates whether changes were identified whilst merging into the value. + /// + public bool HasChanges { get; internal set; } + + /// + /// Gets the resulting value. + /// + public T? Merged { get; internal set; } +} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonPropertyFilter.cs b/src/CoreEx/Json/JsonPropertyFilter.cs deleted file mode 100644 index 7cd1597f..00000000 --- a/src/CoreEx/Json/JsonPropertyFilter.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Collections.Generic; - -namespace CoreEx.Json -{ - /// - /// Defines the JSON property filter ( or ) for the - /// and . - /// - public enum JsonPropertyFilter - { - /// - /// Indicates whether to include only those properties that have been specified. - /// - Include, - - /// - /// Indicates whether to exclude those properties that have been specified. - /// - Exclude - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonReferenceDataConverter.cs b/src/CoreEx/Json/JsonReferenceDataConverter.cs new file mode 100644 index 00000000..9bac08f0 --- /dev/null +++ b/src/CoreEx/Json/JsonReferenceDataConverter.cs @@ -0,0 +1,16 @@ +namespace CoreEx.Json; + +/// +/// Provides an converter to ensure the correct type is used to serialize. +/// +/// This converter only supports serialization and not deserialization due to the lack of native polymorphic support for . +public class JsonReferenceDataConverter : JsonConverter +{ + /// + public override IReferenceData? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotSupportedException($"Deserialization is not supported for an {nameof(IReferenceData)} value as there is no native polymorphic support."); + + /// + public override void Write(Utf8JsonWriter writer, IReferenceData value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value, value.GetType(), options); +} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonSerializer.cs b/src/CoreEx/Json/JsonSerializer.cs deleted file mode 100644 index 061f3eb8..00000000 --- a/src/CoreEx/Json/JsonSerializer.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; - -namespace CoreEx.Json -{ - /// - /// Provides the instance. - /// - public static class JsonSerializer - { - private static IJsonSerializer? _jsonSerializer = new CoreEx.Text.Json.JsonSerializer(); - - /// - /// Gets or sets the default instance. - /// - /// Defaults to . - public static IJsonSerializer Default - { - get => _jsonSerializer ?? throw new InvalidOperationException($"No default {nameof(IJsonSerializer)} has been defined; this must be set prior to access."); - set => _jsonSerializer = value.ThrowIfNull(nameof(value)); - } - - /// - /// Gets the dictionary of JSON name substitutions that will be used during serialization to rename the .NET property to the specified JSON name. - /// - /// The dictionary key is the .NET property name and the value is the corresponding JSON name. - public static Dictionary NameSubstitutions { get; } = new Dictionary { { "ETag", "etag" } }; - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonSubstituteNamingPolicy.cs b/src/CoreEx/Json/JsonSubstituteNamingPolicy.cs new file mode 100644 index 00000000..112ef0f2 --- /dev/null +++ b/src/CoreEx/Json/JsonSubstituteNamingPolicy.cs @@ -0,0 +1,32 @@ +namespace CoreEx.Json; + +/// +/// Provides JSON naming substitution. +/// +/// Defaults the following substitutions: 'ETag' and 'eTag', to 'etag'. +/// Additional can be added. +public class JsonSubstituteNamingPolicy : JsonNamingPolicy +{ + /// + /// Initializes a new instance of the class. + /// + public JsonSubstituteNamingPolicy() + { + Substitutions.TryAdd("ETag", "etag"); + Substitutions.TryAdd("eTag", "etag"); + } + + /// + /// Gets or sets the fallback policy where no found. + /// + public JsonNamingPolicy FallbackPolicy { get; set; } = CamelCase; + + /// + /// Gets or sets the substitutions. + /// + public ConcurrentDictionary Substitutions { get; } = []; + + /// + /// Converts using the then the . + public override string ConvertName(string name) => Substitutions.TryGetValue(name, out var substitution) ? substitution : CamelCase.ConvertName(name); +} \ No newline at end of file diff --git a/src/CoreEx/Json/JsonWriteFormat.cs b/src/CoreEx/Json/JsonWriteFormat.cs deleted file mode 100644 index b0866c49..00000000 --- a/src/CoreEx/Json/JsonWriteFormat.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Json -{ - /// - /// Defines the JSON serialization write format. - /// - public enum JsonWriteFormat - { - /// - /// Indicates that no formatting is to occur. - /// - None, - - /// - /// Indicates that pretty-printing indented formatting is to occur. - /// - Indented - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Mapping/IJsonObjectMapper.cs b/src/CoreEx/Json/Mapping/IJsonObjectMapper.cs deleted file mode 100644 index ff79269b..00000000 --- a/src/CoreEx/Json/Mapping/IJsonObjectMapper.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; -using System; -using System.Text.Json.Nodes; - -namespace CoreEx.Json.Mapping -{ - /// - /// Defines a mapper. - /// - public interface IJsonObjectMapper - { - /// - /// Gets the . - /// - Text.Json.JsonSerializer JsonSerializer { get; } - - /// - /// Gets the source being mapped from/to a . - /// - Type SourceType { get; } - - /// - /// Maps from an creating a corresponding instance of the . - /// - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The corresponding instance of the . - object? MapFromJson(JsonObject json, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToJson(object? value, JsonObject json, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Mapping/IJsonObjectMapperMappings.cs b/src/CoreEx/Json/Mapping/IJsonObjectMapperMappings.cs deleted file mode 100644 index c883e846..00000000 --- a/src/CoreEx/Json/Mapping/IJsonObjectMapperMappings.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.Json.Mapping -{ - /// - /// Gets the mappings. - /// - public interface IJsonObjectMapperMappings - { - /// - /// Gets the mappings. - /// - IEnumerable Mappings { get; } - - /// - /// Gets the from the for the specified . - /// - /// The property name. - /// The where found. - /// Thrown when the property does not exist. - IPropertyJsonMapper this[string propertyName] { get; } - - /// - /// Attempts to get the for the specified . - /// - /// The source property name. - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - bool TryGetProperty(string propertyName, [NotNullWhen(true)] out IPropertyJsonMapper? propertyColumnMapper); - - /// - /// Gets the . - /// - /// The source property name. - /// The where found. - /// Thrown when the property does not exist. - string GetJsonName(string propertyName); - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Mapping/IJsonObjectMapperT.cs b/src/CoreEx/Json/Mapping/IJsonObjectMapperT.cs deleted file mode 100644 index eed7c996..00000000 --- a/src/CoreEx/Json/Mapping/IJsonObjectMapperT.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Mapping; -using System; -using System.Text.Json.Nodes; - -namespace CoreEx.Json.Mapping -{ - /// - /// Defines a typed mapper. - /// - public interface IJsonObjectMapper : IJsonObjectMapper - { - /// - Type IJsonObjectMapper.SourceType => typeof(TSource); - - /// - object? IJsonObjectMapper.MapFromJson(JsonObject json, OperationTypes operationType) => MapFromJson(json, operationType)!; - - /// - void IJsonObjectMapper.MapToJson(object? value, JsonObject json, OperationTypes operationType) => MapToJson((TSource?)value, json, operationType); - - /// - /// Maps from a creating a corresponding instance of . - /// - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The corresponding instance of . - new TSource? MapFromJson(JsonObject json, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToJson(TSource? value, JsonObject json, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Mapping/IPropertyJsonMapper.cs b/src/CoreEx/Json/Mapping/IPropertyJsonMapper.cs deleted file mode 100644 index 15d01392..00000000 --- a/src/CoreEx/Json/Mapping/IPropertyJsonMapper.cs +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Mapping.Converters; -using CoreEx.Mapping; -using System; -using System.Text.Json.Nodes; - -namespace CoreEx.Json.Mapping -{ - /// - /// Enables bi-directional property and property mapping. - /// - public interface IPropertyJsonMapper - { - /// - /// Gets the . - /// - IPropertyExpression PropertyExpression { get; } - - /// - /// Gets the source property name. - /// - string PropertyName { get; } - - /// - /// Gets the source property . - /// - Type PropertyType { get; } - - /// - /// Indicates whether the underlying source property is a complex type. - /// - bool IsSrcePropertyComplex { get; } - - /// - /// Gets the destination property name. - /// - string JsonName { get; } - - /// - /// Gets the selection to enable inclusion or exclusion of property (default to ). - /// - OperationTypes OperationTypes { get; } - - /// - /// Indicates whether the property forms part of the primary key. - /// - bool IsPrimaryKey { get; } - - /// - /// Sets the primary key (). - /// - void SetPrimaryKey(); - - /// - /// Gets the (used where a specific source and destination type conversion is required). - /// - IConverter? Converter { get; } - - /// - /// Sets the . - /// - /// The . - /// The and are mutually exclusive. - void SetConverter(IConverter converter); - - /// - /// Gets the to map complex types. - /// - IJsonObjectMapper? Mapper { get; } - - /// - /// Set the to map complex types. - /// - /// The . - /// The and are mutually exclusive. - void SetMapper(IJsonObjectMapper mapper); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - void MapToJson(object value, JsonObject json, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps from a updating the . - /// - /// The . - /// The value. - /// The single value being performed to enable conditional execution where appropriate. - void MapFromJson(JsonObject json, object value, OperationTypes operationType = OperationTypes.Unspecified); - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Mapping/JsonObjectMapper.cs b/src/CoreEx/Json/Mapping/JsonObjectMapper.cs deleted file mode 100644 index aa7ee004..00000000 --- a/src/CoreEx/Json/Mapping/JsonObjectMapper.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Json.Mapping -{ - /// - /// Enables or of a . - /// - public class JsonObjectMapper - { - /// - /// Creates a where properties are added manually. - /// - /// The ; defaults where not specified. - /// A . - public static JsonObjectMapper Create(Text.Json.JsonSerializer? jsonSerializer = null) where TSource : class, new() => new(jsonSerializer, false); - - /// - /// Creates a where properties are added automatically (assumes the property and column names share the same name). - /// - /// An array of source property names to ignore. - /// A . - public static JsonObjectMapper CreateAuto(params string[] ignoreSrceProperties) where TSource : class, new() => new(null, true, ignoreSrceProperties); - - /// - /// Creates a where properties are added automatically (assumes the property and column names share the same name). - /// - /// The ; defaults where not specified. - /// An array of source property names to ignore. - /// A . - public static JsonObjectMapper CreateAuto(Text.Json.JsonSerializer? jsonSerializer, params string[] ignoreSrceProperties) where TSource : class, new() => new(jsonSerializer, true, ignoreSrceProperties); - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Mapping/JsonObjectMapperT.cs b/src/CoreEx/Json/Mapping/JsonObjectMapperT.cs deleted file mode 100644 index 449d2fb3..00000000 --- a/src/CoreEx/Json/Mapping/JsonObjectMapperT.cs +++ /dev/null @@ -1,265 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; -using CoreEx.Mapping; -using System; -using System.Collections.Generic; -using System.Data; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using System.Text.Json.Nodes; - -namespace CoreEx.Json.Mapping -{ - /// - /// Provides mapping from a and . - /// - /// The source . - public class JsonObjectMapper : IJsonObjectMapper, IJsonObjectMapperMappings where TSource : class, new() - { - private readonly List _mappings = []; - private readonly bool _implementsIIdentifier = typeof(IIdentifier).IsAssignableFrom(typeof(TSource)); - - /// - /// Initializes a new instance of the class. - /// - /// The ; defaults where not specified. - /// Indicates whether the entity should automatically map all public get/set properties, where the property and column names are all assumed to share the same name. - /// An array of source property names to ignore. - public JsonObjectMapper(Text.Json.JsonSerializer? jsonSerializer = null, bool autoMap = false, params string[] ignoreSrceProperties) - { - if (typeof(TSource) == typeof(string)) throw new InvalidOperationException("TSource must not be a String."); - - JsonSerializer = jsonSerializer ?? (CoreEx.Json.JsonSerializer.Default is Text.Json.JsonSerializer js ? js : new Text.Json.JsonSerializer()); - if (autoMap) - AutomagicallyMap(ignoreSrceProperties); - } - - /// - /// Gets the . - /// - public Text.Json.JsonSerializer JsonSerializer { get; } - - /// - IEnumerable IJsonObjectMapperMappings.Mappings => _mappings.AsEnumerable(); - - /// - public IPropertyJsonMapper this[string propertyName] => TryGetProperty(propertyName, out var pcm) ? pcm : throw new ArgumentException($"Property '{propertyName}' does not exist.", nameof(propertyName)); - - /// - /// Gets the for the specified source . - /// - /// The to reference the source property. - /// The where found. - /// Thrown when the property does not exist. - public IPropertyJsonMapper this[Expression> propertyExpression] - { - get - { - MemberExpression? me = null; - if (propertyExpression.ThrowIfNull(nameof(propertyExpression)).Body.NodeType == ExpressionType.MemberAccess) - me = propertyExpression.Body as MemberExpression; - else if (propertyExpression.Body.NodeType == ExpressionType.Convert) - { - if (propertyExpression.Body is UnaryExpression ue) - me = ue.Operand as MemberExpression; - } - - if (me == null) - throw new InvalidOperationException("Only Member access expressions are supported."); - - return this[me.Member.Name]; - } - } - - /// - public bool TryGetProperty(string propertyName, [NotNullWhen(true)] out IPropertyJsonMapper? propertyColumnMapper) - { - propertyColumnMapper = _mappings.Where(x => x.PropertyName == propertyName).FirstOrDefault(); - return propertyColumnMapper != null; - } - - /// - public string GetJsonName(string propertyName) => this[propertyName].JsonName; - - /// - /// Automatically add each public get/set property. - /// - private void AutomagicallyMap(string[] ignoreSrceProperties) - { - foreach (var sp in TypeReflector.GetProperties(typeof(TSource))) - { - // Do not auto-map where ignore has been specified. - if (ignoreSrceProperties.Contains(sp.Name)) - continue; - - // Create the lambda expression for the property and add to the mapper. - var spe = Expression.Parameter(typeof(TSource), "x"); - var sex = Expression.Lambda(Expression.Property(spe, sp), spe); - typeof(JsonObjectMapper) - .GetMethod(nameof(AutoProperty), BindingFlags.NonPublic | BindingFlags.Instance)! - .MakeGenericMethod([sp.PropertyType]) - .Invoke(this, [sex, null, OperationTypes.Any]); - } - } - - /// - /// Adds a to the mapper with additiional auto-logic. - /// - private PropertyJsonMapper AutoProperty(Expression> propertyExpression, string? jsonName = null, OperationTypes operationTypes = OperationTypes.Any) - { - var pcm = Property(propertyExpression, jsonName, operationTypes); - - // Automatically set primary key where IIdentifier. - if (_implementsIIdentifier && pcm.PropertyName == nameof(IIdentifier.Id)) - pcm.SetPrimaryKey(); - - return pcm; - } - - /// - /// Adds a to the mapper. - /// - /// The source property . - /// The to reference the source property. - /// The Dataverse column name. Defaults to name. - /// The selection to enable inclusion or exclusion of property. - /// The . - public PropertyJsonMapper Property(Expression> propertyExpression, string? columnName = null, OperationTypes operationTypes = OperationTypes.Any) - { - var pcm = new PropertyJsonMapper(this, propertyExpression, columnName, operationTypes); - AddMapping(pcm); - return pcm; - } - - /// - /// Validates and adds a new IPropertyJsonMapper. - /// - private void AddMapping(PropertyJsonMapper propertyJsonMapper) - { - if (_mappings.Any(x => x.PropertyName == propertyJsonMapper.PropertyName)) - throw new ArgumentException($"Source property '{propertyJsonMapper.PropertyName}' must not be specified more than once.", nameof(propertyJsonMapper)); - - if (_mappings.Any(x => x.JsonName == propertyJsonMapper.JsonName)) - throw new ArgumentException($"Column '{propertyJsonMapper.JsonName}' must not be specified more than once.", nameof(propertyJsonMapper)); - - _mappings.Add(propertyJsonMapper); - } - - /// - /// Adds or updates to the mapper. - /// - /// The source property . - /// The to reference the source property. - /// The JSON property name. Defaults to name. - /// The selection to enable inclusion or exclusion of property. - /// An enabling access to the created . - /// - /// Where updating an existing the and where specified will override the previous values. - public JsonObjectMapper HasProperty(Expression> propertyExpression, string? jsonName = null, OperationTypes? operationTypes = null, Action>? property = null) - { - var tmp = new PropertyJsonMapper(this, propertyExpression, jsonName, operationTypes ?? OperationTypes.Any); - var pcm = _mappings.Where(x => x.PropertyName == tmp.PropertyName).OfType>().SingleOrDefault(); - if (pcm == null) - AddMapping(pcm = tmp); - else - { - if (jsonName != null && tmp.JsonName != pcm.JsonName) - { - if (_mappings.Any(x => x.JsonName == pcm.JsonName)) - throw new ArgumentException($"JSON property '{pcm.JsonName}' must not be specified more than once.", nameof(jsonName)); - else - pcm.JsonName = tmp.JsonName; - } - - if (operationTypes != null) - pcm.OperationTypes = operationTypes.Value; - } - - property?.Invoke(pcm); - return this; - } - - /// - /// Inherits the property mappings from the selected . - /// - /// The source . Must inherit from . - /// The to inherit from. Must also implement . - public void InheritPropertiesFrom(IJsonObjectMapper inheritMapper) where T : class, new() - { - inheritMapper.ThrowIfNull(nameof(inheritMapper)); - if (!typeof(TSource).IsSubclassOf(typeof(T))) throw new ArgumentException($"Type {typeof(TSource).Name} must inherit from {typeof(T).Name}.", nameof(inheritMapper)); - if (inheritMapper is not IJsonObjectMapperMappings inheritMappings) throw new ArgumentException($"Type {typeof(T).Name} must implement {typeof(IJsonObjectMapperMappings).Name} to copy the mappings.", nameof(inheritMapper)); - - var pe = Expression.Parameter(typeof(TSource), "x"); - var type = typeof(JsonObjectMapper<>).MakeGenericType(typeof(TSource)); - - foreach (var p in inheritMappings.Mappings) - { - var lex = Expression.Lambda(Expression.Property(pe, p.PropertyName), pe); - var pmap = (IPropertyJsonMapper)type - .GetMethod("Property", BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)! - .MakeGenericMethod(p.PropertyType) - .Invoke(this, [lex, p.JsonName, p.OperationTypes])!; - - if (p.IsPrimaryKey) - pmap.SetPrimaryKey(); - - if (p.Converter != null) - pmap.SetConverter(p.Converter); - - if (p.Mapper != null) - pmap.SetMapper(p.Mapper); - } - } - - /// - public void MapToJson(TSource? value, JsonObject json, OperationTypes operationType = OperationTypes.Unspecified) - { - json.ThrowIfNull(nameof(json)); - if (value == null) return; - - foreach (var p in _mappings) - { - p.MapToJson(value, json, operationType); - } - - OnMapToJson(value, json, operationType); - } - - /// - /// Extension opportunity when performing a . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - protected virtual void OnMapToJson(TSource value, JsonObject json, OperationTypes operationType) { } - - /// - public TSource? MapFromJson(JsonObject json, OperationTypes operationType = OperationTypes.Unspecified) - { - json.ThrowIfNull(nameof(json)); - var value = new TSource(); - - foreach (var p in _mappings) - { - p.MapFromJson(json, value, operationType); - } - - value = OnMapFromJson(value, json, operationType); - return (value != null && value is IInitial ii && ii.IsInitial) ? null : value; - } - - /// - /// Extension opportunity when performing a . - /// - /// The source value. - /// The . - /// The single value being performed to enable conditional execution where appropriate. - /// The source value. - protected virtual TSource? OnMapFromJson(TSource value, JsonObject json, OperationTypes operationType) => value; - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Mapping/PropertyJsonMapper.cs b/src/CoreEx/Json/Mapping/PropertyJsonMapper.cs deleted file mode 100644 index 6b69dcff..00000000 --- a/src/CoreEx/Json/Mapping/PropertyJsonMapper.cs +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Mapping; -using CoreEx.Mapping.Converters; -using System; -using System.ComponentModel; -using System.Linq.Expressions; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace CoreEx.Json.Mapping -{ - /// - /// Provides bi-directional property and property mapping. - /// - /// The source entity . - /// The corresponding source property . - public class PropertyJsonMapper : IPropertyJsonMapper where TSource : class, new() - { - private readonly PropertyExpression _propertyExpression; - - /// - /// Initializes a new instance of the class. - /// - /// The owning . - /// The to reference the source property. - /// The property name. Defaults to the JSON name inferred by the . - /// The selection to enable inclusion or exclusion of property. - internal PropertyJsonMapper(IJsonObjectMapper owner, Expression> propertyExpression, string? jsonName = null, OperationTypes operationTypes = OperationTypes.Any) - { - Owner = owner.ThrowIfNull(nameof(owner)); - _propertyExpression = Abstractions.Reflection.PropertyExpression.Create(propertyExpression, Owner.JsonSerializer); - JsonName = jsonName ?? _propertyExpression.JsonName ?? PropertyName; - OperationTypes = operationTypes; - } - - /// - /// Gets the owning . - /// - /// Required to provide the underlying . - public IJsonObjectMapper Owner { get; } - - /// - public IPropertyExpression PropertyExpression => _propertyExpression; - - /// - public string PropertyName => _propertyExpression.Name; - - /// - public Type PropertyType => typeof(TSourceProperty); - - /// - public bool IsSrcePropertyComplex => throw new NotImplementedException(); - - /// - public string JsonName { get; internal set; } - - /// - public OperationTypes OperationTypes { get; internal set; } - - /// - public bool IsPrimaryKey { get; private set; } - - /// - public bool IsPrimaryKeyUseEntityIdentifier { get; private set; } - - /// - public IConverter? Converter { get; private set; } - - /// - public IJsonObjectMapper? Mapper { get; private set; } - - /// - void IPropertyJsonMapper.SetConverter(IConverter converter) - { - converter.ThrowIfNull(nameof(converter)); - - if (Mapper != null) - throw new InvalidOperationException("The Mapper and Converter cannot be both set; only one is permissible."); - - if (converter.SourceType != typeof(TSourceProperty)) - throw new InvalidOperationException($"The PropertyType '{PropertyType.Name}' and IConverter.SourceType '{converter.SourceType.Name}' must match."); - - Converter = converter; - } - - /// - /// Sets the . - /// - /// The . - /// The to support fluent-style method-chaining. - /// The and are mutually exclusive. - public PropertyJsonMapper SetConverter(IConverter converter) - { - ((IPropertyJsonMapper)this).SetConverter(converter); - return this; - } - - /// - void IPropertyJsonMapper.SetMapper(IJsonObjectMapper mapper) - { - mapper.ThrowIfNull(nameof(mapper)); - - if (Converter != null) - throw new InvalidOperationException("The Mapper and Converter cannot be both set; only one is permissible."); - - if (!_propertyExpression.IsClass) - throw new InvalidOperationException($"The PropertyType '{PropertyType.Name}' must be a class to set a Mapper."); - - if (mapper.SourceType != typeof(TSourceProperty)) - throw new ArgumentException($"The PropertyType '{PropertyType.Name}' and IDataverseMapper.SourceType '{mapper.SourceType.Name}' must match.", nameof(mapper)); - - if (IsPrimaryKey) - throw new InvalidOperationException("A Mapper can not be set for a primary key."); - - Mapper = mapper; - } - - /// - /// Sets the . - /// - /// The . - /// The to support fluent-style method-chaining. - /// The and are mutually exclusive. - public PropertyJsonMapper SetMapper(IJsonObjectMapper mapper) - { - ((IPropertyJsonMapper)this).SetMapper(mapper); - return this; - } - - /// - void IPropertyJsonMapper.SetPrimaryKey() - { - if (Mapper != null) throw new InvalidOperationException("A primary key must not contain a Mapper."); - - IsPrimaryKey = true; - } - - /// - /// Sets the primary key (). - /// - /// The to support fluent-style method-chaining. - public PropertyJsonMapper SetPrimaryKey() - { - ((IPropertyJsonMapper)this).SetPrimaryKey(); - return this; - } - - /// - void IPropertyJsonMapper.MapToJson(object? value, JsonObject json, OperationTypes operationType) => MapToJson((TSource?)value, json, operationType); - - /// - /// Maps from a updating the . - /// - /// The value. - /// The to update from the . - /// The single value being performed to enable conditional execution where appropriate. - private void MapToJson(TSource? value, JsonObject json, OperationTypes operationType) - { - if (value == null || !OperationTypes.HasFlag(operationType)) - return; - - var val = _propertyExpression.GetValue(value); - if (Mapper != null) - { - if (val != null) - Mapper.MapToJson(val, json, operationType); - } - else - { - var jv = Converter == null ? val : Converter.ConvertToDestination(val); - json[JsonName] = System.Text.Json.JsonSerializer.SerializeToNode(jv, Owner.JsonSerializer.Options); - } - } - - /// - void IPropertyJsonMapper.MapFromJson(JsonObject json, object value, OperationTypes operationType) => MapFromJson(json, (TSource)value, operationType); - - /// - /// Maps from a updating the . - /// - /// The . - /// The value. - /// The single value being performed to enable conditional execution where appropriate. - private void MapFromJson(JsonObject json, TSource value, OperationTypes operationType) - { - if (!OperationTypes.HasFlag(operationType)) - return; - - TSourceProperty? pval; - if (Mapper != null) - pval = (TSourceProperty?)Mapper.MapFromJson(json, operationType); - else - { - // Where no json property found then set to default. - if (json.TryGetPropertyValue(JsonName, out var jn) || jn == null) - { - if (Converter is null) - pval = (TSourceProperty)jn.Deserialize(PropertyType, Owner.JsonSerializer.Options)!; - else - pval = (TSourceProperty)Converter.ConvertToSource(jn.Deserialize(Converter.DestinationType, Owner.JsonSerializer.Options))!; - } - else - pval = default; - } - - _propertyExpression.SetValue(value, pval); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/DictionaryMergeApproach.cs b/src/CoreEx/Json/Merge/DictionaryMergeApproach.cs deleted file mode 100644 index bad47ec3..00000000 --- a/src/CoreEx/Json/Merge/DictionaryMergeApproach.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Collections; -using System.Collections.Generic; - -namespace CoreEx.Json.Merge -{ - /// - /// Defines the approach for a dictionary (see ) merge; being either or . - /// - /// The formal specification does not explictly state how to treat a dictionary; however, the approach appears to most closely align as the dictionary - /// is represented as JSON properties (the being the property name and the being the corresponding value) versus an - /// . - public enum DictionaryMergeApproach - { - /// - /// Indicates that the dictionary (see ) merge will act similar to a property merge, in that each key is a property (which is how they present within JSON). Therefore, to remove the item the value must be set - /// to to explicitly remove. Where the merging key already exists then a standard value merge will result; otherwise, where the key does not exist, a resulting add will result. - /// - Merge, - - /// - /// Indicates that the dictionary (see ) merge will be treated the same as any where the result is a replacement (overwrite) operation. - /// - Replace - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/EntityKeyCollectionMergeApproach.cs b/src/CoreEx/Json/Merge/EntityKeyCollectionMergeApproach.cs deleted file mode 100644 index 6fecda2f..00000000 --- a/src/CoreEx/Json/Merge/EntityKeyCollectionMergeApproach.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System.Collections; - -namespace CoreEx.Json.Merge -{ - /// - /// Defines the approach for a collection (see ) that implements ; being either or . - /// - /// The formal specification explictly states that an is to be a replacement operation. However, a approach - /// can be advantageous as it simplifies the manipulation of an without having to provide all content within. - public enum EntityKeyCollectionMergeApproach - { - /// - /// Indicates that the collection (see ) merge will be treated the same as any where the result is a replacement (overwrite) operation. - /// - Replace, - - /// - /// Indicates that the collection (see ) merge will be managed by the checking of the for all items. Where the merge JSON does not provide a matching - /// it will be removed from the resulting collection. Where the merging already exists then a standard value merge will result (not a replacement); otherwise, - /// where the key does not exist, a resulting add will result. - /// - /// Note: this is unique to CoreEx and not part of the formal specification . However, this approach can be advantageous as it simplifies the manipulation of an - /// without having to provide all content within. - Merge - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/Extended/JsonMergePatchEx.cs b/src/CoreEx/Json/Merge/Extended/JsonMergePatchEx.cs deleted file mode 100644 index 3a750d41..00000000 --- a/src/CoreEx/Json/Merge/Extended/JsonMergePatchEx.cs +++ /dev/null @@ -1,376 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Collections; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Json.Merge.Extended -{ - /// - /// Provides a JSON Merge Patch (application/merge-patch+json) whereby the contents of a JSON document are merged into an existing object value (instance) as per - /// using .NET Reflection (see ). - /// - /// This object should be reused where possible as it caches the JSON serialization semantics internally to improve performance. It is also thread-safe. - /// Additional logic has been added to the merge patch enabled by and . Note: these capabilities are - /// unique to CoreEx and not part of the formal specification . - public class JsonMergePatchEx : IJsonMergePatch - { - private const string EKCollectionName = "EKColl"; - private readonly TypeReflectorArgs _trArgs; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// This object should be reused where possible as it caches the JSON serialization semantics internally to improve performance. It is also thread-safe. - public JsonMergePatchEx(JsonMergePatchExOptions? options = null) - { - Options = options ?? new JsonMergePatchExOptions(); - - _trArgs = new TypeReflectorArgs(Options.JsonSerializer) - { - AutoPopulateProperties = true, - NameComparer = Options.PropertyNameComparer, - TypeBuilder = tr => - { - // Determine if type implements IEntityKeyCollection. - if (tr.Type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICompositeKeyCollection<>))) - tr.Data.Add(EKCollectionName, true); - }, - PropertyBuilder = pr => pr.PropertyExpression.IsJsonSerializable // Only interested in properties that are considered serializable. - }; - } - - /// - /// Gets the . - /// - public JsonMergePatchExOptions Options { get; } - - /// - public bool Merge(BinaryData json, ref T? value) - { - // Parse the JSON. - var j = ParseJson(json.ThrowIfNull(nameof(json))); - - // Perform the root merge patch. - return MergeRoot(j.JsonElement, j.Value, ref value); - } - - /// - public async Task<(bool HasChanges, T? Value)> MergeAsync(BinaryData json, Func> getValue, CancellationToken cancellationToken = default) - { - getValue.ThrowIfNull(nameof(getValue)); - - // Parse the JSON. - var j = ParseJson(json.ThrowIfNull(nameof(json))); - - // Get the value. - T? value = await getValue(j.Value, cancellationToken).ConfigureAwait(false); - if (value == null) - return (false, default!); - - // Perform the root merge patch. - return (MergeRoot(j.JsonElement, j.Value, ref value), value); - } - - /// - public async Task> MergeWithResultAsync(BinaryData json, Func>> getValue, CancellationToken cancellationToken = default) - { - getValue.ThrowIfNull(nameof(getValue)); - - // Parse the JSON. - var j = ParseJson(json.ThrowIfNull(nameof(json))); - - // Get the value. - var result = await getValue(j.Value!, cancellationToken).ConfigureAwait(false); - return result.ThenAs(value => - { - if (value == null) - return Result<(bool, T?)>.Ok((false, default)); - - // Perform the root merge patch. - return Result.Ok((MergeRoot(j.JsonElement, j.Value, ref value), value)); - }); - } - - /// - /// Performs the merge patch. - /// - private bool MergeRoot(JsonElement json, T? srce, ref T? dest) - { - bool hasChanged = false; - - var tr = TypeReflector.GetReflector(_trArgs); - - switch (json.ValueKind) - { - case JsonValueKind.Null: - case JsonValueKind.True: - case JsonValueKind.False: - case JsonValueKind.String: - case JsonValueKind.Number: - hasChanged = !tr.Compare(srce, dest); - dest = srce; - break; - - case JsonValueKind.Object: - if (tr.TypeCode == TypeReflectorTypeCode.IDictionary) - { - // Where merging into a dictionary this can be a replace or per item merge. - if (Options.DictionaryMergeApproach == DictionaryMergeApproach.Replace) - { - hasChanged = !tr.Compare(dest, srce); - if (hasChanged) - dest = srce; - } - else - dest = (T)MergeDictionary(tr, "$", json, (IDictionary)srce!, (IDictionary)dest!, ref hasChanged)!; - } - else - { - if (srce == null || dest == null) - { - hasChanged = !tr.Compare(dest, srce); - if (hasChanged) - dest = srce; - } - else - MergeObject(tr, "$", json, srce, dest!, ref hasChanged); - } - - break; - - case JsonValueKind.Array: - // Unless explicitly requested an array is a full replacement only (source copy); otherwise, perform key collection item merge. - if (Options.EntityKeyCollectionMergeApproach != EntityKeyCollectionMergeApproach.Replace && tr.Data.ContainsKey(EKCollectionName)) - dest = (T)MergeKeyedCollection(tr, "$", json, (ICompositeKeyCollection)srce!, (ICompositeKeyCollection)dest!, ref hasChanged); - else - { - hasChanged = !tr.Compare(dest, srce); - if (hasChanged) - dest = srce; - } - - break; - - default: - throw new InvalidOperationException($"A JSON element of '{json.ValueKind}' is invalid where merging the root."); - } - - return hasChanged; - } - - /// - /// Parses the JSON. - /// - private (JsonElement JsonElement, T? Value) ParseJson(BinaryData json) - { - try - { - // Deserialize into a temporary value which will be used as the merge source. - var value = Options.JsonSerializer.Deserialize(json); - - // Parse the JSON into a JsonElement which will be used to navigate the merge. - var jr = new Utf8JsonReader(json); - var je = JsonElement.ParseValue(ref jr); - return (je, value); - } - catch (JsonException jex) - { - throw new JsonMergePatchException(jex.Message, jex); - } - } - - /// - /// Merge the object. - /// - private void MergeObject(ITypeReflector tr, string root, JsonElement json, object? srce, object dest, ref bool hasChanged) - { - foreach (var jp in json.EnumerateObject()) - { - // Find the named property; skip when not found. - var pr = tr.GetJsonProperty(jp.Name); - if (pr == null) - continue; - - MergeProperty(pr, $"{root}.{jp.Name}", jp, srce, dest, ref hasChanged); - } - } - - /// - /// Merge the property. - /// - private void MergeProperty(IPropertyReflector pr, string path, JsonProperty json, object? srce, object dest, ref bool hasChanged) - { - // Update according to the value kind. - switch (json.Value.ValueKind) - { - case JsonValueKind.Null: - case JsonValueKind.True: - case JsonValueKind.False: - case JsonValueKind.String: - case JsonValueKind.Number: - // Update the value directly from the source. - SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); - break; - - case JsonValueKind.Object: - // Where existing is null, copy source as-is; otherwise, merge object property-by-property. - var current = pr.PropertyExpression.GetValue(dest); - if (current == null) - SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); - else - { - if (pr.TypeCode == TypeReflectorTypeCode.IDictionary) - { - // Where the merging into a dictionary this can be a replace or per item merge. - if (Options.DictionaryMergeApproach == DictionaryMergeApproach.Replace) - SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); - else - { - var dict = MergeDictionary(pr.GetTypeReflector()!, path, json.Value, (IDictionary)pr.PropertyExpression.GetValue(srce)!, (IDictionary)pr.PropertyExpression.GetValue(dest)!, ref hasChanged); - SetPropertyValue(pr, dict, dest, ref hasChanged); - } - } - else - MergeObject(pr.GetTypeReflector()!, path, json.Value, pr.PropertyExpression.GetValue(srce), current, ref hasChanged); - } - - break; - - case JsonValueKind.Array: - // Unless explicitly requested an array is a full replacement only (source copy); otherwise, perform key collection item merge. - var tr = pr.GetTypeReflector()!; - if (Options.EntityKeyCollectionMergeApproach != EntityKeyCollectionMergeApproach.Replace && tr.Data.ContainsKey(EKCollectionName)) - { - var coll = MergeKeyedCollection(tr, path, json.Value, (ICompositeKeyCollection)pr.PropertyExpression.GetValue(srce)!, (ICompositeKeyCollection)pr.PropertyExpression.GetValue(dest)!, ref hasChanged); - SetPropertyValue(pr, coll, dest, ref hasChanged); - } - else - SetPropertyValue(pr, pr.PropertyExpression.GetValue(srce)!, dest, ref hasChanged); - - break; - } - } - - /// - /// Sets the property value. - /// - private static void SetPropertyValue(IPropertyReflector pr, object? srce, object dest, ref bool hasChanged) - { - var curr = pr.PropertyExpression.GetValue(dest); - if (pr.Compare(curr, srce)) - return; - - pr.PropertyExpression.SetValue(dest, srce); - hasChanged = true; - } - - /// - /// Merge an . - /// - private IDictionary? MergeDictionary(ITypeReflector tr, string root, JsonElement json, IDictionary srce, IDictionary? dest, ref bool hasChanged) - { - var dict = dest; - - // Iterate through the properties, each is an item that will be added to the new dictionary. - foreach (var jp in json.EnumerateObject()) - { - var path = $"{root}.{jp.Name}"; - var srceitem = srce[jp.Name]; - - if (srceitem == null) - { - // A null value results in a remove operation. - if (dict != null && dict.Contains(jp.Name)) - { - dict.Remove(jp.Name); - hasChanged = true; - } - - continue; - } - - // Create new destination dictionary where it does not exist already. - dict ??= (IDictionary)tr.CreateInstance(); - - // Find the existing and merge; otherwise, add as-is. - if (dict.Contains(jp.Name)) - { - var destitem = dict[jp.Name]!; - switch (tr.ItemTypeCode) - { - case TypeReflectorTypeCode.Simple: - if (!hasChanged && !tr.GetItemTypeReflector()!.Compare(dict[jp.Name], destitem)) - hasChanged = true; - - dict[jp.Name] = srceitem; - continue; - - case TypeReflectorTypeCode.Complex: - MergeObject(tr.GetItemTypeReflector()!, path, jp.Value, srceitem, destitem, ref hasChanged); - dict[jp.Name] = destitem; - continue; - - default: - throw new NotSupportedException("A merge where a dictionary value is an array or other collection type is not supported."); - } - } - else - { - // Represents an add. - hasChanged = true; - dict[jp.Name] = srceitem; - } - } - - return dict; - } - - /// - /// Merge a . - /// - private ICompositeKeyCollection MergeKeyedCollection(ITypeReflector tr, string root, JsonElement json, ICompositeKeyCollection srce, ICompositeKeyCollection? dest, ref bool hasChanged) - { - if (srce!.IsAnyDuplicates()) - throw new JsonMergePatchException($"The JSON array must not contain items with duplicate '{nameof(IEntityKey)}' keys. Path: {root}"); - - if (dest != null && dest.IsAnyDuplicates()) - throw new JsonMergePatchException($"The JSON array destination collection must not contain items with duplicate '{nameof(IEntityKey)}' keys prior to merge. Path: {root}"); - - // Create new destination collection; add each to maintain sent order as this may be important to the consuming application. - var coll = (ICompositeKeyCollection)tr.CreateInstance(); - - // Iterate through the items and add to the new collection. - var i = 0; - ITypeReflector ier = tr.GetItemTypeReflector()!; - - foreach (var ji in json.EnumerateArray()) - { - var path = $"{root}[{i}]"; - if (ji.ValueKind != JsonValueKind.Object && ji.ValueKind != JsonValueKind.Null) - throw new JsonMergePatchException($"The JSON array item must be an Object where the destination collection supports '{nameof(IEntityKey)}' keys. Path: {path}"); - - var srceitem = (IEntityKey)srce[i++]!; - - // Find the existing and merge; otherwise, add as-is. - var destitem = srceitem == null ? null : dest?.GetByKey(srceitem.EntityKey); - if (destitem != null) - { - MergeObject(ier, path, ji, srceitem, destitem, ref hasChanged); - coll.Add(destitem); - } - else - coll.Add(srceitem!); - } - - return coll; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/Extended/JsonMergePatchExOptions.cs b/src/CoreEx/Json/Merge/Extended/JsonMergePatchExOptions.cs deleted file mode 100644 index d45e3b9d..00000000 --- a/src/CoreEx/Json/Merge/Extended/JsonMergePatchExOptions.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Json.Merge.Extended -{ - /// - /// The options. - /// - /// The . Defaults to . - public class JsonMergePatchExOptions(IJsonSerializer? jsonSerializer = null) - { - /// - /// Gets the . - /// - public IJsonSerializer JsonSerializer { get; } = jsonSerializer ?? Json.JsonSerializer.Default; - - /// - /// Gets or sets the for matching the JSON name (defaults to ). - /// - public StringComparer PropertyNameComparer { get; set; } = StringComparer.OrdinalIgnoreCase; - - /// - /// Gets or sets the . Defaults to . - /// - public DictionaryMergeApproach DictionaryMergeApproach { get; set; } = DictionaryMergeApproach.Merge; - - /// - /// Gets or sets the . Defaults to . - /// - public EntityKeyCollectionMergeApproach EntityKeyCollectionMergeApproach { get; set; } = EntityKeyCollectionMergeApproach.Replace; - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/IJsonMergePatch.cs b/src/CoreEx/Json/Merge/IJsonMergePatch.cs deleted file mode 100644 index d8596e18..00000000 --- a/src/CoreEx/Json/Merge/IJsonMergePatch.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Results; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Json.Merge -{ - /// - /// Enables a JSON Merge Patch (application/merge-patch+json) whereby the contents of a JSON document are merged into an existing object value as per . - /// - public interface IJsonMergePatch - { - /// - /// Merges the content into the . - /// - /// The value . - /// The JSON to merge. - /// The value to merge into. - /// true indicates that changes were made to the as a result of the merge; otherwise, false for no changes. - bool Merge(BinaryData json, ref T? value); - - /// - /// Merges the content into the value returned by the function. - /// - /// The value . - /// The JSON to merge. - /// The function to get the value to merge into. The function is passed in the deserialized value. - /// The . - /// true indicates that changes were made to the entity value as a result of the merge; otherwise, false for no changes. The merged value is also returned. - /// Provides the opportunity to validate the JSON before getting the value where this execution order is important; i.e. get operation is expensive (latency). - Task<(bool HasChanges, T? Value)> MergeAsync(BinaryData json, Func> getValue, CancellationToken cancellationToken = default); - - /// - /// Merges the content into the value returned by the function (with a ). - /// - /// The value . - /// The JSON to merge. - /// The function to get the value to merge into. The function is passed in the deserialized value. - /// The . - /// true indicates that changes were made to the entity value as a result of the merge; otherwise, false for no changes. The merged value is also returned. - /// Provides the opportunity to validate the JSON before getting the value where this execution order is important; i.e. get operation is expensive (latency). - Task> MergeWithResultAsync(BinaryData json, Func>> getValue, CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/JsonMergePatch.cs b/src/CoreEx/Json/Merge/JsonMergePatch.cs deleted file mode 100644 index 3acac61f..00000000 --- a/src/CoreEx/Json/Merge/JsonMergePatch.cs +++ /dev/null @@ -1,281 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json.Compare; -using CoreEx.Results; -using CoreEx.Text.Json; -using System; -using System.Buffers; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Json.Merge -{ - /// - /// Provides a JSON Merge Patch (application/merge-patch+json) whereby the contents of a JSON document are merged into an existing JSON document resulting in a new merged JSON document as per . - /// - /// The . - public class JsonMergePatch(JsonMergePatchOptions? options = null) : IJsonMergePatch - { - /// - /// Gets the . - /// - public JsonMergePatchOptions Options { get; } = options ?? new JsonMergePatchOptions(); - - /// - public bool Merge(BinaryData json, ref T? value) - { - // Parse the JSON. - var j = ParseJson(json.ThrowIfNull(nameof(json))); - var t = SerializeToJsonElement(value); - - // Perform the root merge patch. - if (!TryMerge(j.JsonElement, t, out var merged)) - return false; - - // Deserialize the merged JSON. - value = DeserializeFromJsonElement(merged); - return true; - } - - /// - public async Task<(bool HasChanges, T? Value)> MergeAsync(BinaryData json, Func> getValue, CancellationToken cancellationToken = default) - { - getValue.ThrowIfNull(nameof(getValue)); - - // Parse the JSON. - var j = ParseJson(json.ThrowIfNull(nameof(json))); - - // Get the value. - var value = await getValue(j.Value, cancellationToken).ConfigureAwait(false); - if (value == null) - return (false, default!); - - // Perform the merge patch. - var t = SerializeToJsonElement(value); - if (!TryMerge(j.JsonElement, t, out var merged)) - return (false, value); - - // Deserialize the merged JSON. - return (true, DeserializeFromJsonElement(merged)); - } - - /// - public async Task> MergeWithResultAsync(BinaryData json, Func>> getValue, CancellationToken cancellationToken = default) - { - getValue.ThrowIfNull(nameof(getValue)); - - // Parse the JSON. - var j = ParseJson(json.ThrowIfNull(nameof(json))); - - // Get the value. - var result = await getValue(j.Value!, cancellationToken).ConfigureAwait(false); - return result.ThenAs(value => - { - if (value == null) - return (false, default); - - // Perform the merge patch. - var t = SerializeToJsonElement(value); - if (!TryMerge(true, j.JsonElement, t, out var merged)) - return (false, value); - - // Deserialize the merged JSON. - return (true, DeserializeFromJsonElement(merged)); - }); - } - - /// - /// Serialize the to a . - /// - private JsonElement SerializeToJsonElement(T value) - { - // Fast path where using System.Text.Json. - if (Options.JsonSerializer is CoreEx.Text.Json.JsonSerializer js) - return System.Text.Json.JsonSerializer.SerializeToElement(value, js.Options); - - // Otherwise, serialize and then parse as two separate operations (slower path). - var bd = Options.JsonSerializer.SerializeToBinaryData(value); - var jr = new Utf8JsonReader(bd); - return JsonElement.ParseValue(ref jr).Clone(); - } - - /// - /// Deserialize the to a . - /// - private T? DeserializeFromJsonElement(JsonElement json) - { - // Fast path where using System.Text.Json. - if (Options.JsonSerializer is CoreEx.Text.Json.JsonSerializer js) - return json.Deserialize(js.Options); - - // Otherwise, deserialize using the specified serializer. - return Options.JsonSerializer.Deserialize(json.GetRawText()); - } - - /// - /// Parses the JSON. - /// - private (JsonElement JsonElement, T? Value) ParseJson(BinaryData json) - { - try - { - // Deserialize into a temporary value which will be used as the merge source. - var value = Options.JsonSerializer.Deserialize(json); - - // Parse the JSON into a JsonElement which will be used to navigate the merge. - var jr = new Utf8JsonReader(json); - var je = JsonElement.ParseValue(ref jr).Clone(); - return (je, value); - } - catch (JsonException jex) - { - throw new JsonMergePatchException(jex.Message, jex); - } - } - - /// - /// Merges the with (into) the . - /// - /// The JSON to merge. - /// The JSON target to merge with (into). - /// The resulting merged . - public JsonElement Merge(JsonElement json, JsonElement target) - { - TryMerge(false, json, target, out var merged); - return merged; - } - - /// - /// Merges the with (into) the resulting in the where changes were made. - /// - /// The JSON to merge. - /// The JSON to merge with (into). - /// The resulting JSON where changes were made. - /// true indicates that changes were made as a result of the merge (see resulting ); otherwise, false for no changes. - public bool TryMerge(JsonElement json, JsonElement target, out JsonElement merged) - { - if (TryMerge(true, json, target, out var m)) - { - merged = m; - return true; - } - - merged = target; - return false; - } - - /// - /// Orchestrates the 'actual' merge processing. The `checkForChanges` is used to determine whether to check for changes after the completed merge only where necessary; therefore, the boolean result is not always guaranteed to be accurate :-) - /// - private bool TryMerge(bool checkForChanges, JsonElement json, JsonElement target, out JsonElement merged) - { - // Create writer for the merged output. - var buffer = new ArrayBufferWriter(); - using var writer = new Utf8JsonWriter(buffer); - - var changed = TryMerge(json, target, writer, false); - - writer.Flush(); - - // Read the merged output and parse. - var reader = new Utf8JsonReader(buffer.WrittenSpan); - merged = JsonElement.ParseValue(ref reader).Clone(); - - // Where check for changes is enabled then compare the target and merged JSON if not previously identified. - if (checkForChanges && !changed) - { - var comparer = new JsonElementComparer(new JsonElementComparerOptions { JsonSerializer = Options.JsonSerializer, PropertyNameComparer = Options.PropertyNameComparer, MaxDifferences = 1 }); - changed = comparer.Compare(target, merged).HasDifferences; - } - - return changed; - } - - /// - /// Merge the JSON element (record 'change' where no additional cost to do so). - /// - private bool TryMerge(JsonElement json, JsonElement target, Utf8JsonWriter writer, bool changed) - { - // Where the kinds are different then simply accept the merge and acknowledge as changed. - if (json.ValueKind != target.ValueKind) - { - json.WriteTo(writer); - return true; - } - - // Where the kinds are the same then process accordingly. - switch (json.ValueKind) - { - case JsonValueKind.Object: - // An object is a property-by-property merge. - return TryObjectMerge(json, target, writer, changed); - - case JsonValueKind.Array: - // An array is always a replacement. - json.WriteTo(writer); - if (json.GetArrayLength() != target.GetArrayLength()) - changed = true; - - break; - - default: - // Accept merge as-is. - json.WriteTo(writer); - break; - } - - return changed; - } - - /// - /// Merge the JSON object and properties (record 'change' where no cost to do so). - /// - private bool TryObjectMerge(JsonElement json, JsonElement target, Utf8JsonWriter writer, bool changed) - { - writer.WriteStartObject(); - - // Apply merge add/override. - foreach (var j in json.EnumerateObject()) - { - // Where the property is new then add. - if (!TryGetProperty(target, j.Name, out var t)) - { - if (j.Value.ValueKind != JsonValueKind.Null) - j.WriteTo(writer); - - changed = true; - continue; - } - - // Null is a remove; otherwise, override. - if (j.Value.ValueKind != JsonValueKind.Null) - { - writer.WritePropertyName(j.Name); - changed = TryMerge(j.Value, t, writer, changed); - } - else - changed = true; - } - - // Add existing target properties not being merged. - foreach (var t in target.EnumerateObject()) - { - // Where found then consider as handled above! - if (TryGetProperty(json, t.Name, out _)) - continue; - - t.WriteTo(writer); - } - - writer.WriteEndObject(); - return changed; - } - - /// - /// Performs the TryGetProperty using the configured comparer. - /// - private bool TryGetProperty(JsonElement json, string propertyName, out JsonElement value) - => Options.PropertyNameComparer is null ? json.TryGetProperty(propertyName, out value) : json.TryGetProperty(propertyName, Options.PropertyNameComparer, out value); - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/JsonMergePatchException.cs b/src/CoreEx/Json/Merge/JsonMergePatchException.cs deleted file mode 100644 index 5d311975..00000000 --- a/src/CoreEx/Json/Merge/JsonMergePatchException.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Json.Merge -{ - /// - /// Represents a . - /// - /// Inherits from as the and related handling are the same. - public class JsonMergePatchException : ValidationException - { - /// - /// Initializes a new instance of the class. - /// - public JsonMergePatchException() : base() { } - - /// - /// Initializes a new instance of the class with the specified . - /// - /// The exception message. - public JsonMergePatchException(string message) : base(message) { } - - /// - /// Initializes a new instance of the class with the specified and . - /// - /// The exception message. - /// The inner . - public JsonMergePatchException(string message, Exception innerException) : base(message, innerException) { } - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/Merge/JsonMergePatchOptions.cs b/src/CoreEx/Json/Merge/JsonMergePatchOptions.cs deleted file mode 100644 index ccca9a4e..00000000 --- a/src/CoreEx/Json/Merge/JsonMergePatchOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Json.Merge -{ - /// - /// The options. - /// - /// The . Defaults to . - public class JsonMergePatchOptions(IJsonSerializer? jsonSerializer = null) - { - /// - /// Gets the . - /// - public IJsonSerializer JsonSerializer { get; } = jsonSerializer ?? ExecutionContext.GetService() ?? Json.JsonSerializer.Default; - - /// - /// Gets or sets the for matching the JSON name (defaults to ). - /// - public StringComparer PropertyNameComparer { get; set; } = StringComparer.OrdinalIgnoreCase; - } -} \ No newline at end of file diff --git a/src/CoreEx/Json/README.md b/src/CoreEx/Json/README.md deleted file mode 100644 index c6894ec7..00000000 --- a/src/CoreEx/Json/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# CoreEx.Json - -The `CoreEx.Json` namespace provides additional [JSON](https://en.wikipedia.org/wiki/JSON)-related capabilities. - -
- -## Motivation - -.NET recently added [`System.Text.Json`](https://docs.microsoft.com/en-us/dotnet/api/system.text.json); however, there is still extensive usage of [`Newtonsoft.Json`](https://www.newtonsoft.com/json), and there can be [challenges migrating](https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to) from the latter to the former depending on capabilties required. As such `CoreEx` is largely JSON serializer agnostic and provides implementations for each; whilst also providing additional JSON-related capabilities. - -
- -## JSON Serializer - -To be JSON serializer agnostic, _CoreEX_ introduces [`IJsonSerializer`](./IJsonSerializer.cs) which is used almost exclusively within _CoreEx_ to encapsulate common capabilities; primarily `Serialize`, `Deserialize` and `TryApplyFilter` (enables include/exclude property filtering). - -The following implementations are provided. - - - [`CoreEx.Text.Json.JsonSerializer`](../Text/Json/JsonSerializer.cs) - leverages [`System.Text.Json`](https://docs.microsoft.com/en-us/dotnet/api/system.text.json) (see [`CoreEx.Text.Json`](../Text/Json)). - - [`CoreEx.Newtonsoft.Json.JsonSerializer`](../../CoreEx.Newtonsoft/Json/JsonSerializer.cs) - leverages [`Newtonsoft.Json`](https://www.newtonsoft.com/json) (see [`CoreEx.Newtonsoft.Json`](../../CoreEx.Newtonsoft/Json)). - -
- -## JSON Merge - -Provides a JSON Merge Patch (`application/merge-patch+json`) whereby the contents of a JSON document are merged into an existing object value as per [RFC7396](https://tools.ietf.org/html/rfc7396). This is used to achieve the exclusive _CoreEx_ HTTP `PATCH` functionality within [`CoreEx.WebApis`](../WebApis). - -The [`IJsonMergePatch`](./Merge/IJsonMergePatch.cs) interface and [`JsonMergePatch`](./Merge/JsonMergePatch.cs) implementation enable this functionality. The [`JsonMergePatchOptions`](./Merge/JsonMergePatchOptions.cs) specifies the options to support alternate merge approaches; for example [`DictionaryMergeApproach`](./Merge/DictionaryMergeApproach.cs) and [`EntityKeyCollectionMergeApproach`](./Merge/EntityKeyCollectionMergeApproach.cs). - -
- -## JSON Data - -The [`JsonDataReader`](./Data/JsonDataReader.cs) reads JSON (or YAML) data and converts into a corresponding typed collection; this is primarily intended to enable data loading scenarios. - - diff --git a/src/CoreEx/Localization/ITextProvider.cs b/src/CoreEx/Localization/ITextProvider.cs index 4c9d444c..61385372 100644 --- a/src/CoreEx/Localization/ITextProvider.cs +++ b/src/CoreEx/Localization/ITextProvider.cs @@ -1,17 +1,14 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Localization; -namespace CoreEx.Localization +/// +/// Enables the text localization for an . +/// +public interface ITextProvider { /// - /// Enables the localized text for a passed key. + /// Gets the localized text for the specified . /// - public interface ITextProvider - { - /// - /// Gets the text for the passed . - /// - /// The . - /// The corresponding text where found; otherwise, the where specified. Where nothing found or specified then the key itself will be returned. - string? GetText(LText key); - } + /// The . + /// The corresponding text where found; otherwise, the where specified. Where nothing found or specified then the key itself will be returned. + string? GetText(LText text); } \ No newline at end of file diff --git a/src/CoreEx/Localization/LText.cs b/src/CoreEx/Localization/LText.cs index bac2c64c..769f6f28 100644 --- a/src/CoreEx/Localization/LText.cs +++ b/src/CoreEx/Localization/LText.cs @@ -1,108 +1,199 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Localization; -using System; - -namespace CoreEx.Localization +/// +/// Represents a localization agnostic text. +/// +public readonly struct LText : IEquatable { /// - /// Represents the localization text key/identifier to be used by the . + /// Gets the empty . + /// + /// The and are both . + public static readonly LText Empty = new(); + + /// + /// Initializes a new instance of the with a . /// - public struct LText : IEquatable + /// The key and/or text. + public LText(string? keyAndOrText) => KeyAndOrText = keyAndOrText; + + /// + /// Initializes a new instance of the with a and optional . + /// + /// The key and/or text. + /// The fallback text to be used when the is not found by the . + /// The object array that contains zero or more objects to format. + /// Where the is explicitly set to this will set as the explicit fallback versus using the (see ). + public LText(string? keyAndOrText, string? fallbackText, params IEnumerable args) { - /// - /// Gets the empty . - /// - /// The and are both null. - public static readonly LText Empty = new(); - - /// - /// Gets or sets the numeric () key/identifier format to convert to a standardized . - /// - public static string NumericKeyFormat { get; set; } = "000000"; - - /// - /// Initializes a new instance of the with null and . - /// - public LText() - { - KeyAndOrText = null; - FallbackText = null; - } + KeyAndOrText = keyAndOrText; + FallbackText = fallbackText; + if (fallbackText is null) + WasFallBackTextSetToNull = true; - /// - /// Initializes a new instance of the with a and optional . - /// - /// The key and/or text. - /// The fallback text to be used when the is not found by the . - /// At least one of the arguments must be specified. - public LText(string? keyAndOrText, string? fallbackText = null) - { - KeyAndOrText = keyAndOrText; - FallbackText = fallbackText; - } + Args = [.. args]; + } + + /// + /// Initializes a new instance of the copying the existing . + /// + /// The existing . + /// The object array that contains zero or more objects to format. + private LText(LText ltext, params IEnumerable? args) + { + KeyAndOrText = ltext.KeyAndOrText; + FallbackText = ltext.FallbackText; + WasFallBackTextSetToNull = ltext.WasFallBackTextSetToNull; + Args = args is null ? null : [ ..args]; + } - /// - /// Initializes a new instance of the with an key and optional . - /// - /// The key. - /// The fallback text to be used when not found by the . - public LText(long key, string? fallbackText = null) + /// + /// Gets the key and/or text (where the key is not found, it will used as the text; unless a is specified. + /// + public string? KeyAndOrText { get; } + + /// + /// Gets the optional fallback text to be used when the is not found; where not specified the becomes the fallback text. + /// + public string? FallbackText { get; } + + /// + /// Indicates whether the was explicitly set to (i.e. the fallback is not the ). + /// + public bool WasFallBackTextSetToNull { get; } = false; + + /// + /// Gets the object array that contains zero or more objects to format. + /// + /// Also consider using to extend. + public object?[]? Args { get; } + + /// + /// Indicates whether the has . + /// + public bool HasArgs => Args is not null && Args.Length > 0; + + /// + /// Indicates whether the is empty; i.e. the and are both . + /// + public readonly bool IsEmpty => KeyAndOrText is null && FallbackText is null; + + /// + /// Creates a new extending (adds to) the with the specified . + /// + /// The object array that contains zero or more objects to format. + /// The new . + public LText WithArgs(params IEnumerable args) + { + if (args is null || !args.Any()) + return this; + + return new LText(this, Args is null ? args : [.. Args, .. args]); + } + + /// + /// Ensures that the has no ; otherwise, will throw an . + /// + /// The to support fluent-style method-chaining. + public readonly LText EnsureNoArgs() + { + if (Args is not null && Args.Length > 0) + throw new InvalidOperationException($"The {nameof(LText)} must have no {nameof(Args)} to be used in this capacity."); + + return this; + } + + /// + /// Ensures that the has no when have been specified; otherwise, will throw an . + /// + /// The object array that contains zero or more objects to format. + /// The to support fluent-style method-chaining. + public readonly LText EnsureNoArgsWhen(params IEnumerable args) + { + if (args is null || !args.Any()) + return this; + + return EnsureNoArgs(); + } + + /// + /// Returns the as a (see ). + /// + /// The string value. + public override readonly string? ToString() => (string?)this; + + /// + public override readonly bool Equals(object? obj) => obj is LText r && Equals(r); + + /// + public readonly bool Equals(LText other) => KeyAndOrText == other.KeyAndOrText && FallbackText == other.FallbackText && WasFallBackTextSetToNull == other.WasFallBackTextSetToNull && ArrayEquals(Args, other.Args); + + /// + /// Compare the two arrays for equality. + /// + private static bool ArrayEquals(object?[]? a, object?[]? b) + { + if (a is null && b is null) + return true; + + if (a is null || b is null) + return false; + + if (a.Length != b.Length) + return false; + + return Enumerable.SequenceEqual(a, b); + } + + /// + public override readonly int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(KeyAndOrText); + hashCode.Add(FallbackText); + hashCode.Add(WasFallBackTextSetToNull); + if (Args is not null) { - KeyAndOrText = key <= 0 ? throw new ArgumentException("Key must be a positive integer.", nameof(key)) : key.ToString(NumericKeyFormat, System.Globalization.CultureInfo.InvariantCulture); - FallbackText = fallbackText; + foreach (var arg in Args) + hashCode.Add(arg); } - /// - /// Gets or sets the key and/or text (where the key is not found, it will used as the text; unless a is specified. - /// - public string? KeyAndOrText { get; } - - /// - /// Gets or sets the optional fallback text to be used when the is not found by the (where not specified the becomes the fallback text). - /// - public string? FallbackText { get; set; } - - /// - /// Indicates whether the is empty; i.e. the and are both null. - /// - public readonly bool IsEmpty => KeyAndOrText is null && FallbackText is null; - - /// - /// Returns the as a (see ). - /// - /// The string value. - public override readonly string ToString() => this!; - - /// - public override readonly bool Equals(object? obj) => obj is LText r && Equals(r); - - /// - public readonly bool Equals(LText other) => KeyAndOrText == other.KeyAndOrText && FallbackText == other.FallbackText; - - /// - public override readonly int GetHashCode() => HashCode.Combine(KeyAndOrText, FallbackText); - - /// - /// Indicates whether the current is equal to another . - /// - public static bool operator ==(LText left, LText right) => left.Equals(right); - - /// - /// Indicates whether the current is not equal to another . - /// - public static bool operator !=(LText left, LText right) => !(left == right); - - /// - /// An implicit cast from an to a (see ). - /// - /// The . - /// The corresponding text where found; otherwise, the where specified. Where nothing found or specified then the key itself will be returned. - public static implicit operator string(LText text) => TextProvider.Current.GetText(text)!; - - /// - /// An implicit cast from a text to an value updating the . - /// - /// The key and/or text. - public static implicit operator LText(string keyAndOrText) => keyAndOrText is null ? Empty : new(keyAndOrText); + return hashCode.ToHashCode(); } + + /// + /// Indicates whether the current is equal to another . + /// + public static bool operator ==(LText left, LText right) => left.Equals(right); + + /// + /// Indicates whether the current is not equal to another . + /// + public static bool operator !=(LText left, LText right) => !(left == right); + + /// + /// An implicit cast from an to a (see ). + /// + /// The . + /// The corresponding text where found; otherwise, the where specified. Where nothing found or specified then the key itself will be returned. + public static implicit operator string?(LText text) => TextProvider.Current.GetText(text)!; + + /// + /// An implicit cast from an to a (see ). + /// + /// The . + /// The corresponding text where found; otherwise, the where specified. Where nothing found or specified then the key itself will be returned. + public static implicit operator string?(LText? text) => text is null ? null : TextProvider.Current.GetText(text.Value); + + /// + /// An implicit cast from a text to an value updating the . + /// + /// The key and/or text. + public static implicit operator LText(string? keyAndOrText) => keyAndOrText is null ? Empty : new(keyAndOrText); + + /// + /// An implicit cast from a text to an value updating the . + /// + /// The key and/or text. + public static implicit operator LText?(string? keyAndOrText) => keyAndOrText is null ? null : new(keyAndOrText); } \ No newline at end of file diff --git a/src/CoreEx/Localization/LocalizationAttribute.cs b/src/CoreEx/Localization/LocalizationAttribute.cs new file mode 100644 index 00000000..50edb6d2 --- /dev/null +++ b/src/CoreEx/Localization/LocalizationAttribute.cs @@ -0,0 +1,26 @@ +namespace CoreEx.Localization; + +/// +/// An attribute to define the localization for a property. +/// +/// The key and/or text. +/// The optional fallback text to be used when the is not found. +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class LocalizationAttribute(string keyAndOrText, string? fallbackText = null) : Attribute +{ + /// + /// Gets the key and/or text (where the key is not found, it will used as the text; unless a is specified. + /// + public string? KeyAndOrText { get; } = keyAndOrText.ThrowIfNullOrEmpty(); + + /// + /// Gets the optional fallback text to be used when the is not found; where not specified the becomes the fallback text. + /// + public string? FallbackText { get; } = fallbackText; + + /// + /// Creates a new instance based on the attribute values and any optional arguments. + /// + /// The new instance. + public LText ToLText() => FallbackText is null ? new LText(KeyAndOrText) : new LText(KeyAndOrText, FallbackText); +} \ No newline at end of file diff --git a/src/CoreEx/Localization/NullTextProvider.cs b/src/CoreEx/Localization/NullTextProvider.cs index 20c85df0..f027d343 100644 --- a/src/CoreEx/Localization/NullTextProvider.cs +++ b/src/CoreEx/Localization/NullTextProvider.cs @@ -1,17 +1,10 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Localization; -namespace CoreEx.Localization +/// +/// Provides a implementation; the will always return . +/// +public class NullTextProvider : TextProviderBase { - /// - /// Provides a null implementation; the will return null. - /// - public class NullTextProvider : TextProviderBase - { - /// - /// Gets the text for the passed . - /// - /// The . - /// The corresponding text where found; otherwise null. - protected override string? GetTextForKey(LText key) => null; - } + /// + protected override string? GetFormattedText(LText text) => null; } \ No newline at end of file diff --git a/src/CoreEx/Localization/README.md b/src/CoreEx/Localization/README.md deleted file mode 100644 index 3a9e26e5..00000000 --- a/src/CoreEx/Localization/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# CoreEx.Localization - -The `CoreEx.Localization` namespace provides additional localization capabilities. - -
- -## Motivation - -To enable extended (additional) localization capabilities. - -
- -## Text localization - -To simplify the localization of strings a localized text struct, [`LText`](./LText.cs), has been introduced. - -The `LText` supports a constructor that takes a `keyAndOrText` being the key and/or text used to lookup the localized value, and an optional `fallbackText` to be used where the lookup fails. Where no text is found, then the originating `keyAndOrText` will be used. - -Additionally, the `LText` supports an implicit operator to and from a `string`, which enables the casting thereof providing a natural development experience. - -The casting to a `string` is the action that performs the lookup. This invokes the static [`TextProvider.Current`](./TextProvider.cs) property, which is an instance of the [`ITextProvider`](./ITextProvider.cs) interface. - -
- -## Text provider - -To support the `LText` lookup functionality an [`ITextProvider`](./ITextProvider.cs) implementation is required to enable. It is the responsibility of the `GetText` method to perform using the following logic, use: a) the corresponding text where found, b) the fallback text, and finally c) the key itself. - -An implementation is provided within [`CoreEx.Validation`](../../CoreEx.Validation), being [`ValidationTextProvider`](../../CoreEx.Validation/ValidationTextProvider.cs) that uses embedded resources for the strings. - - diff --git a/src/CoreEx/Localization/TextProvider.cs b/src/CoreEx/Localization/TextProvider.cs index 512ca450..7cc33a80 100644 --- a/src/CoreEx/Localization/TextProvider.cs +++ b/src/CoreEx/Localization/TextProvider.cs @@ -1,39 +1,53 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Localization; -using System; - -namespace CoreEx.Localization +/// +/// Provides access to the global/static and , etc. +/// +public static class TextProvider { + private static ITextProvider? _textProvider; + private static ITextProvider? _backupTextProvider; + /// - /// Provides access to the global/static instance. + /// Sets the instance explicitly. /// - public static class TextProvider + /// The concrete instance. + public static void SetTextProvider(ITextProvider? textProvider) => _textProvider = textProvider; + + /// + /// Gets the current instance using in the following order: , the explicit , otherwise, . + /// + public static ITextProvider Current { - private static ITextProvider? _textProvider; - private static ITextProvider? _backupTextProvider; - - /// - /// Sets the instance explicitly. - /// - /// The concrete instance. - public static void SetTextProvider(ITextProvider? textProvider) => _textProvider = textProvider; - - /// - /// Gets the current instance using in the following order: , the explicit , otherwise, . - /// - public static ITextProvider Current + get { - get - { - var tp = ExecutionContext.GetService(); - if (tp != null) - return tp; + var tp = ExecutionContext.GetService(); + if (tp is not null) + return tp; - if (_textProvider != null) - return _textProvider; + if (_textProvider is not null) + return _textProvider; - return _backupTextProvider ??= new NullTextProvider(); - } + return _backupTextProvider ??= new NullTextProvider(); } } + + /// + /// Gets the current UI (i.e. or ). + /// + public static CultureInfo GetUICulture() => ExecutionContext.TryGetCurrent(out var ec) ? ec.UICulture : CultureInfo.CurrentUICulture; + + /// + /// Replaces the format items in a string with the string representations of the corresponding objects in the specified array. + /// + /// The format string. + /// The optional arguments. + /// The is used with the as the . + public static string? Format(string? format, object?[]? args) + { + if (format is null || args is null || args.Length == 0) + return format; + + return string.Format(GetUICulture(), format, args); + } } \ No newline at end of file diff --git a/src/CoreEx/Localization/TextProviderBase.cs b/src/CoreEx/Localization/TextProviderBase.cs index fb2c8d9d..85631851 100644 --- a/src/CoreEx/Localization/TextProviderBase.cs +++ b/src/CoreEx/Localization/TextProviderBase.cs @@ -1,30 +1,24 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Localization; -namespace CoreEx.Localization +/// +/// Provides the base text localization functionality for an . +/// +public abstract class TextProviderBase : ITextProvider { - /// - /// Provides the localized text for a passed key. - /// - public abstract class TextProviderBase : ITextProvider + /// + public string? GetText(LText text) { - /// - /// Gets the text for the passed . - /// - /// The . - /// The corresponding text where found; otherwise, the where specified. Where nothing found or specified then the key itself will be returned. - public string? GetText(LText key) - { - if (key.KeyAndOrText == null) - return key.FallbackText; - - return GetTextForKey(key) ?? key.FallbackText ?? key.KeyAndOrText; - } + if (text.KeyAndOrText is null) + return TextProvider.Format(text.FallbackText, text.Args); - /// - /// Gets the text for the passed . - /// - /// The . - /// The corresponding text where found; otherwise, null. - protected abstract string? GetTextForKey(LText key); + return GetFormattedText(text) ?? TextProvider.Format(text.FallbackText ?? (text.WasFallBackTextSetToNull ? null : text.KeyAndOrText), text.Args); } + + /// + /// Gets the final formatted localized text for the specified . + /// + /// The . + /// The corresponding localized text where found; otherwise, (will use the where specified). + /// This should leverage the to ensure intended outcome with any included . + protected abstract string? GetFormattedText(LText text); } \ No newline at end of file diff --git a/src/CoreEx/Mapping/BiDirectionMapperT2.cs b/src/CoreEx/Mapping/BiDirectionMapperT2.cs new file mode 100644 index 00000000..e3fe78a1 --- /dev/null +++ b/src/CoreEx/Mapping/BiDirectionMapperT2.cs @@ -0,0 +1,66 @@ +namespace CoreEx.Mapping; + +/// +/// Provides a bi-direction mapper between and . +/// +/// The source . +/// The destination . +/// The is a left-to-right , with the being a right-to-left . +public abstract class BiDirectionMapper : IBiDirectionMapper where TSource : class where TDestination : class +{ + /// + /// Initializes a new instance of the class. + /// + public BiDirectionMapper() + { + if (typeof(TSource) == typeof(TDestination)) + throw new InvalidOperationException($"{nameof(BiDirectionMapper<,>)} source and destination types cannot be the same."); + + To = new SourceToDestinationMapper(OnMap); + From = new DestinationToSourceMapper(OnMap); + } + + /// + public IMapper To { get; } + + /// + public IMapper From { get; } + + /// + /// Maps the () value to a new destination () value. + /// + /// The source value. + /// The destination value. + /// This represents the left-to-right mapping direction. + protected abstract TDestination OnMap(TSource source); + + /// + /// Maps the () value to a new destination () value. + /// + /// The source value. + /// The destination value. + /// This represents the right-to-left mapping direction. + protected abstract TSource OnMap(TDestination source); + + /// + /// Provides the underlying to mapping. + /// + public sealed class SourceToDestinationMapper(Func map) : Mapper + { + private readonly Func _map = map; + + /// + protected override TDestination OnMap(TSource source) => _map(source); + } + + /// + /// Provides the underlying to mapping. + /// + public sealed class DestinationToSourceMapper(Func map) : Mapper + { + private readonly Func _map = map; + + /// + protected override TSource OnMap(TDestination source) => _map(source); + } +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/BiDirectionMapperT3.cs b/src/CoreEx/Mapping/BiDirectionMapperT3.cs new file mode 100644 index 00000000..0f89e978 --- /dev/null +++ b/src/CoreEx/Mapping/BiDirectionMapperT3.cs @@ -0,0 +1,80 @@ +namespace CoreEx.Mapping; + +/// +/// Provides a bi-directional singleton mapper between and . +/// +/// The source . +/// The destination . +/// The . +/// The is a left-to-right , with the being a right-to-left +public abstract class BiDirectionMapper : IBiDirectionMapper where TSource : class where TDestination : class where TSelf : BiDirectionMapper, new() +{ + /// + /// Static constructor. + /// + static BiDirectionMapper() + { + Default = new TSelf(); + To = new SourceToDestinationMapper(Default.OnMap); + From = new DestinationToSourceMapper(Default.OnMap); + } + + /// + /// Gets the default instance. + /// + public static TSelf Default { get; } + + /// + IMapper IBiDirectionMapper.To => To; + + /// + IMapper IBiDirectionMapper.From => From; + + /// + /// Gets the default underlying singleton instance. + /// + public static SourceToDestinationMapper To { get; } + + /// + /// Gets the default underlying singleton instance. + /// + public static DestinationToSourceMapper From { get; } + + /// + /// Maps the () value to a new destination () value. + /// + /// The source value. + /// The destination value. + /// This represents the left-to-right mapping direction. + protected abstract TDestination OnMap(TSource source); + + /// + /// Maps the () value to a new destination () value. + /// + /// The source value. + /// The destination value. + /// This represents the right-to-left mapping direction. + protected abstract TSource OnMap(TDestination source); + + /// + /// Provides the underlying to mapping. + /// + public sealed class SourceToDestinationMapper(Func map) : Mapper + { + private readonly Func _map = map; + + /// + protected override TDestination OnMap(TSource source) => _map(source); + } + + /// + /// Provides the underlying to mapping. + /// + public sealed class DestinationToSourceMapper(Func map) : Mapper + { + private readonly Func _map = map; + + /// + protected override TSource OnMap(TDestination source) => _map(source); + } +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/BidirectionalMapper.cs b/src/CoreEx/Mapping/BidirectionalMapper.cs deleted file mode 100644 index 6e322eda..00000000 --- a/src/CoreEx/Mapping/BidirectionalMapper.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.Mapping; - -/// -/// Provides a bidirectional mapper that encapsulates two ; one for each direction. -/// -/// The from . -/// The to . -/// The from/to mapper. -/// The to/from mapper. -/// Indicates whether to map null source value to a corresponding null destination automatically. -public class BidirectionalMapper(IMapper mapperFromTo, IMapper mapperToFrom, bool mapNullIfNull = false) : IBidirectionalMapper where TFrom : class, new() where TTo : class, new() -{ - /// - /// Initializes a new instance of the class. - /// - /// The from/to mapping. - /// The to/from mapping. - /// Indicates whether to map null source value to a corresponding null destination automatically. - public BidirectionalMapper(Func mapFromTo, Func mapToFrom, bool mapNullIfNull = false) : this(new Mapper(mapFromTo), new Mapper(mapToFrom), mapNullIfNull) { } - - /// - public IMapper MapperFromTo { get; } = mapperFromTo.ThrowIfNull(nameof(mapperFromTo)); - - /// - public IMapper MapperToFrom { get; } = mapperToFrom.ThrowIfNull(nameof(mapperToFrom)); - - /// - /// Indicates whether to map null source value to a corresponding null destination automatically. - /// - public bool IsMapNullIfNull { get; } = mapNullIfNull; - - /// - /// Maps the value to a new value. - /// - /// The source value. - /// The singluar CRUD value being performed. - /// The destination value. - [return: NotNullIfNotNull(nameof(source))] - public TTo? Map(TFrom? source, OperationTypes operationType = OperationTypes.Unspecified) => IsMapNullIfNull && source == default ? default : MapperFromTo.Map(source, operationType); - - /// - /// Maps the value into the existing value. - /// - /// The source value. - /// The destination value. - /// The singluar CRUD value being performed. - /// The value. - [return: NotNullIfNotNull(nameof(source))] - public TTo? Map(TFrom? source, TTo? destination, OperationTypes operationType = OperationTypes.Unspecified) => IsMapNullIfNull && source == default && destination == default ? default : MapperFromTo.Map(source, destination, operationType); - - /// - /// Maps the value to a new value. - /// - /// The source value. - /// The singluar CRUD value being performed. - /// The destination value. - [return: NotNullIfNotNull(nameof(source))] - public TFrom? Map(TTo? source, OperationTypes operationType = OperationTypes.Unspecified) => IsMapNullIfNull && source == default ? default : MapperToFrom.Map(source, operationType); - - /// - /// Maps the value into the existing value. - /// - /// The source value. - /// The destination value. - /// The singluar CRUD value being performed. - /// The value. - [return: NotNullIfNotNull(nameof(source))] - public TFrom? Map(TTo? source, TFrom? destination, OperationTypes operationType = OperationTypes.Unspecified) => IsMapNullIfNull && source == default && destination == default ? default : MapperToFrom.Map(source, destination, operationType); -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/CollectionMapper.cs b/src/CoreEx/Mapping/CollectionMapper.cs deleted file mode 100644 index a4e18963..00000000 --- a/src/CoreEx/Mapping/CollectionMapper.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CoreEx.Mapping -{ - /// - /// Provides a simple (explicit) value mapper. - /// - /// The source collection . - /// The source . - /// The destination . - /// The destination collection . - /// Note that collection mapping results in a replacement; there is no merging of content. - public class CollectionMapper : IMapper - where TSourceColl : class, ICollection, new() where TSource : class, new() - where TDestinationColl : class, ICollection, new() where TDestination : class, new() - { - private Mapper? _mapper; - - /// - public Mapper Owner { get => _mapper ?? throw new InvalidOperationException("Mapper has not been set to a non-null value; this is automatically performed when registered."); set => _mapper = value; } - - /// - public TDestinationColl CreateSource() => new(); - - /// - public TDestinationColl CreateDestination() => new(); - - /// - bool IMapper.IsSourceInitial(TSourceColl source) => false; - - /// - bool IMapper.InitializeDestination(TDestinationColl destination) => false; - - /// - object? IMapperBase.Map(object? source, OperationTypes operationType) => Map((TSourceColl?)source, null, operationType); - - /// - object? IMapperBase.Map(object? source, object? destination, OperationTypes operationType) => Map((TSourceColl?)source, (TDestinationColl?)destination, operationType); - - /// - TDestinationColl? IMapper.Map(TSourceColl? source, OperationTypes operationType) => Map(source, null, operationType); - - /// - TDestinationColl? IMapper.Map(TSourceColl? source, TDestinationColl? destination, OperationTypes operationType) => Map(source, destination, operationType); - - /// - /// Performs the mapping. - /// - /// The source. - /// The destination. - /// The singular . - /// The destination. - internal TDestinationColl? Map(TSourceColl? source, TDestinationColl? destination, OperationTypes operationType = OperationTypes.Unspecified) - { - if (source is null && destination is null) - return destination; - - if ((source == null || source.Count == 0) && Owner.ConvertEmptyCollectionsToNull) - { - destination = default; - return destination; - } - - // Clear/empty destination as collection mapping is "replacement" only. - destination?.Clear(); - if (source is null) - return destination; - - destination ??= new(); - var itemMapper = Owner.GetMapper(); - source.ForEach(x => destination.Add(itemMapper.Map(x, operationType)!)); - return destination; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/Abstractions/IDestinationConverter.cs b/src/CoreEx/Mapping/Converters/Abstractions/IDestinationConverter.cs new file mode 100644 index 00000000..849ee427 --- /dev/null +++ b/src/CoreEx/Mapping/Converters/Abstractions/IDestinationConverter.cs @@ -0,0 +1,25 @@ +namespace CoreEx.Mapping.Converters.Abstractions; + +/// +/// Defines the with typed . +/// +/// The destination . +public interface IDestinationConverter : IConverter +{ + /// + Type IConverter.DestinationType => typeof(TDestination); + + /// + /// Converts the source to the destination value (converts to). + /// + /// The source value to convert. + /// The converted destination value. + new TDestination ConvertToDestination(object? source); + + /// + /// Converts the destination to the source value (converts back from). + /// + /// The destination value to convert. + /// The converted source value. + object? ConvertToSource(TDestination destination); +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/Abstractions/ISourceConverter.cs b/src/CoreEx/Mapping/Converters/Abstractions/ISourceConverter.cs new file mode 100644 index 00000000..89b4b62b --- /dev/null +++ b/src/CoreEx/Mapping/Converters/Abstractions/ISourceConverter.cs @@ -0,0 +1,25 @@ +namespace CoreEx.Mapping.Converters.Abstractions; + +/// +/// Defines the with typed . +/// +/// The source . +public interface ISourceConverter : IConverter +{ + /// + Type IConverter.SourceType => typeof(TSource); + + /// + /// Converts the source to the destination value (converts to). + /// + /// The source value to convert. + /// The converted destination value. + object? ConvertToDestination(TSource source); + + /// + /// Converts the destination to the source value (converts back from). + /// + /// The destination value to convert. + /// The converted source value. + new TSource ConvertToSource(object? destination); +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/Converter.cs b/src/CoreEx/Mapping/Converters/Converter.cs deleted file mode 100644 index 7fc2e4bb..00000000 --- a/src/CoreEx/Mapping/Converters/Converter.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Enables the of a instance. - /// - public static class Converter - { - /// - /// Provides a generic means to create a one-off instance. - /// - /// The source . - /// The destination . - /// The to conversion logic. - /// The to conversion logic. - /// The . - public static Converter Create(Func toDestination, Func toSource) => new(toDestination, toSource); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/ConverterT.cs b/src/CoreEx/Mapping/Converters/ConverterT.cs deleted file mode 100644 index bcb89a4e..00000000 --- a/src/CoreEx/Mapping/Converters/ConverterT.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Provides a generic means to create a one-off instance. - /// - /// The source . - /// The destination . - /// The to conversion logic. - /// The to conversion logic. - public readonly struct Converter(Func toDestination, Func toSource) : IConverter - { - private readonly ValueConverter _convertToDestination = new(toDestination); - private readonly ValueConverter _convertToSource = new(toSource); - - /// - /// Gets the source to destination . - /// - public readonly IValueConverter ToDestination => _convertToDestination; - - /// - /// Gets the destination to source . - /// - public readonly IValueConverter ToSource => _convertToSource; - - /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((TSource)source!); - - /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((TDestination)destination!); - - /// - public readonly TDestination ConvertToDestination(TSource source) => ToDestination.Convert(source); - - /// - public readonly TSource ConvertToSource(TDestination destination) => ToSource.Convert(destination); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/DateTimeToStringConverter.cs b/src/CoreEx/Mapping/Converters/DateTimeToStringConverter.cs deleted file mode 100644 index f2443d0b..00000000 --- a/src/CoreEx/Mapping/Converters/DateTimeToStringConverter.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Globalization; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Represents a to converter. - /// - /// The default format is 'G' (see ) - /// with an of . - public readonly struct DateTimeToStringConverter : IConverter - { - private readonly ValueConverter _convertToDestination = new(s => s?.ToString("G", CultureInfo.InvariantCulture.DateTimeFormat)); - private readonly ValueConverter _convertToSource = new(d => d == null ? null : DateTime.ParseExact(d, "G", CultureInfo.InvariantCulture.DateTimeFormat, DateTimeStyles.None)); - - /// - /// Gets or sets the default (singleton) instance. - /// - public static DateTimeToStringConverter Default { get; set; } = new(); - - /// - /// Initializes a new instance of the struct. - /// - public DateTimeToStringConverter() { } - - /// - /// Initializes a new instance of the struct. - /// - /// The format specifier. - /// The ; defaults to . - /// The ; defaults to . - /// The optional to be performed on the . - public DateTimeToStringConverter(string format, IFormatProvider? formatProvider = null, DateTimeStyles style = DateTimeStyles.None, DateTimeTransform transform = DateTimeTransform.None) - { - format = format.ThrowIfNull(nameof(format)); - formatProvider ??= CultureInfo.InvariantCulture.DateTimeFormat; - _convertToDestination = new(s => Cleaner.Clean(s, transform)?.ToString(format, formatProvider)); - _convertToSource = new(d => d == null ? null : Cleaner.Clean(DateTime.ParseExact(d, format, formatProvider, style), transform)); - } - - /// - /// Gets the source to destination . - /// - public IValueConverter ToDestination => _convertToDestination; - - /// - /// Gets the destination to source . - /// - public IValueConverter ToSource => _convertToSource; - - /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((DateTime?)source); - - /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((string?)destination); - - /// - public readonly string? ConvertToDestination(DateTime? source) => ToDestination.Convert(source); - - /// - public readonly DateTime? ConvertToSource(string? destination) => ToSource.Convert(destination); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/EncodedStringToDateTimeConverter.cs b/src/CoreEx/Mapping/Converters/EncodedStringToDateTimeConverter.cs deleted file mode 100644 index 5f2e8cda..00000000 --- a/src/CoreEx/Mapping/Converters/EncodedStringToDateTimeConverter.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Represents an encoded to converter. - /// - public readonly struct EncodedStringToDateTimeConverter : IConverter - { - private static readonly ValueConverter _convertToDestination = new(s => s == null ? null : DateTime.FromBinary(BitConverter.ToInt64(Convert.FromBase64String(s)))); - private static readonly ValueConverter _convertToSource = new(d => d == null ? null : Convert.ToBase64String(BitConverter.GetBytes(d.Value.ToBinary()))); - - /// - /// Gets or sets the default (singleton) instance. - /// - public static EncodedStringToDateTimeConverter Default { get; set; } = new(); - - /// - /// Initializes a new instance of the struct. - /// - public EncodedStringToDateTimeConverter() { } - - /// - /// Gets the source to destination . - /// - public IValueConverter ToDestination => _convertToDestination; - - /// - /// Gets the destination to source . - /// - public IValueConverter ToSource => _convertToSource; - - /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); - - /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((DateTime?)destination); - - /// - public readonly DateTime? ConvertToDestination(string? source) => ToDestination.Convert(source); - - /// - public readonly string? ConvertToSource(DateTime? destination) => ToSource.Convert(destination); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs b/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs deleted file mode 100644 index 80466dc9..00000000 --- a/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Represents an encoded to converter. - /// - public readonly struct EncodedStringToUInt32Converter : IConverter - { - private static readonly ValueConverter _convertToDestination = new(s => s == null ? 0 : BitConverter.ToUInt32(Convert.FromBase64String(s))); - private static readonly ValueConverter _convertToSource = new(d => d == 0 ? null : Convert.ToBase64String(BitConverter.GetBytes(d))); - - /// - /// Gets or sets the default (singleton) instance. - /// - public static EncodedStringToUInt32Converter Default { get; set; } = new(); - - /// - /// Initializes a new instance of the struct. - /// - public EncodedStringToUInt32Converter() { } - - /// - /// Gets the source to destination . - /// - public IValueConverter ToDestination => _convertToDestination; - - /// - /// Gets the destination to source . - /// - public IValueConverter ToSource => _convertToSource; - - /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); - - /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((uint)destination!); - - /// - public readonly uint ConvertToDestination(string? source) => ToDestination.Convert(source); - - /// - public readonly string? ConvertToSource(uint destination) => ToSource.Convert(destination); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/IConverter.cs b/src/CoreEx/Mapping/Converters/IConverter.cs index ff9eff32..41f410c3 100644 --- a/src/CoreEx/Mapping/Converters/IConverter.cs +++ b/src/CoreEx/Mapping/Converters/IConverter.cs @@ -1,36 +1,31 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Mapping.Converters; -using System; - -namespace CoreEx.Mapping.Converters +/// +/// Enables bi-directional conversion from a source to a destination value and vice-versa. +/// +public interface IConverter { /// - /// Enables bi-directional conversion from a source to a destination value and vice-versa. + /// Gets the source . /// - public interface IConverter - { - /// - /// Gets the source . - /// - Type SourceType { get; } + Type SourceType { get; } - /// - /// Gets the destination . - /// - Type DestinationType { get; } + /// + /// Gets the destination . + /// + Type DestinationType { get; } - /// - /// Converts the source to the destination value. - /// - /// The source value. - /// The destination value. - object? ConvertToDestination(object? source); + /// + /// Converts the source to the destination value. + /// + /// The source value. + /// The destination value. + object? ConvertToDestination(object? source); - /// - /// Converts the destination to the source value. - /// - /// The destination value. - /// The source value. - object? ConvertToSource(object? destination); - } + /// + /// Converts the destination to the source value. + /// + /// The destination value. + /// The source value. + object? ConvertToSource(object? destination); } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/IConverterT.cs b/src/CoreEx/Mapping/Converters/IConverterT.cs index c24e4728..f633e42a 100644 --- a/src/CoreEx/Mapping/Converters/IConverterT.cs +++ b/src/CoreEx/Mapping/Converters/IConverterT.cs @@ -1,44 +1,45 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Mapping.Converters; -using System; - -namespace CoreEx.Mapping.Converters +/// +/// Enables bi-directional conversion from a source to a destination value and vice-versa. +/// +/// The source . +/// The destination . +public interface IConverter : ISourceConverter, IDestinationConverter { + /// + object? ISourceConverter.ConvertToDestination(TSource source) => ConvertToDestination((TSource)source!); + + /// + TSource ISourceConverter.ConvertToSource(object? destination) => ConvertToSource((TDestination)destination!); + + /// + TDestination IDestinationConverter.ConvertToDestination(object? source) => ConvertToDestination((TSource)source!); + + /// + object? IDestinationConverter.ConvertToSource(TDestination destination) => ConvertToSource((TDestination)destination!); + + /// + /// Gets the source to destination . + /// + IValueConverter ToDestination { get; } + + /// + /// Gets the destination to source . + /// + IValueConverter ToSource { get; } + + /// + /// Converts the source to the destination value (converts to). + /// + /// The source value to convert. + /// The converted destination value. + new TDestination ConvertToDestination(TSource source); + /// - /// Enables bi-directional conversion from a source to a destination value and vice-versa. + /// Converts the destination to the source value (converts back from). /// - /// The source . - /// The destination . - public interface IConverter : IConverter - { - /// - Type IConverter.SourceType => typeof(TSource); - - /// - Type IConverter.DestinationType => typeof(TDestination); - - /// - /// Gets the source to destination . - /// - IValueConverter ToDestination { get; } - - /// - /// Gets the destination to source . - /// - IValueConverter ToSource { get; } - - /// - /// Converts the source to the destination value (converts to). - /// - /// The source value to convert. - /// The converted destination value. - TDestination ConvertToDestination(TSource source); - - /// - /// Converts the destination to the source value (converts back from). - /// - /// The destination value to convert. - /// The converted source value. - TSource ConvertToSource(TDestination destination); - } + /// The destination value to convert. + /// The converted source value. + new TSource ConvertToSource(TDestination destination); } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/IValueConverter.cs b/src/CoreEx/Mapping/Converters/IValueConverter.cs index fd29cdcf..aef20ebd 100644 --- a/src/CoreEx/Mapping/Converters/IValueConverter.cs +++ b/src/CoreEx/Mapping/Converters/IValueConverter.cs @@ -1,19 +1,14 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Mapping.Converters; -using System; - -namespace CoreEx.Mapping.Converters +/// +/// Enables conversion from a source to a destination value. +/// +public interface IValueConverter { /// - /// Enables conversion from a source to a destination value. + /// Convert value to destination equivalent. /// - public interface IValueConverter - { - /// - /// Convert value to destination equivalent. - /// - /// The source value. - /// The destination equivalent. - object? Convert(object? source); - } + /// The source value. + /// The destination equivalent. + object? Convert(object? source); } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/IValueConverterT.cs b/src/CoreEx/Mapping/Converters/IValueConverterT.cs index ed50f260..c4fb6174 100644 --- a/src/CoreEx/Mapping/Converters/IValueConverterT.cs +++ b/src/CoreEx/Mapping/Converters/IValueConverterT.cs @@ -1,24 +1,19 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Mapping.Converters; -using System; - -namespace CoreEx.Mapping.Converters +/// +/// Enables conversion from a source to a destination value. +/// +/// The source . +/// The destination . +public interface IValueConverter : IValueConverter { + /// + object? IValueConverter.Convert(object? source) => Convert(source is null ? default! : (TSource)source!); + /// - /// Enables conversion from a source to a destination value. + /// Convert value to destination equivalent. /// - /// The source . - /// The destination . - public interface IValueConverter : IValueConverter - { - /// - object? IValueConverter.Convert(object? source) => Convert(source == null ? default! : (TSource)source!); - - /// - /// Convert value to destination equivalent. - /// - /// The source value. - /// The destination equivalent. - TDestination Convert(TSource source); - } + /// The source value. + /// The destination equivalent. + TDestination Convert(TSource source); } \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/JsonElementStringConverter.cs b/src/CoreEx/Mapping/Converters/JsonElementStringConverter.cs new file mode 100644 index 00000000..c4bc2365 --- /dev/null +++ b/src/CoreEx/Mapping/Converters/JsonElementStringConverter.cs @@ -0,0 +1,49 @@ +namespace CoreEx.Mapping.Converters; + +/// +/// Represents a to converter. +/// +public readonly struct JsonElementStringConverter : IConverter +{ + private static readonly ValueConverter _convertToDestination = new(s => s?.ToString()); + private static readonly ValueConverter _convertToSource = new(d => + { + if (d is null) + return null; + + using var doc = JsonDocument.Parse(d); + return doc.RootElement.Clone(); + }); + + /// + /// Gets or sets the default (singleton) instance. + /// + public static JsonElementStringConverter Default { get; set; } = new(); + + /// + /// Initializes a new instance of the struct. + /// + public JsonElementStringConverter() { } + + /// + /// Gets the source to destination . + /// + public IValueConverter ToDestination => _convertToDestination; + + /// + /// Gets the destination to source . + /// + public IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((JsonElement?)destination); + + /// + public readonly string? ConvertToDestination(JsonElement? source) => ToDestination.Convert(source); + + /// + public readonly JsonElement? ConvertToSource(string? destination) => ToSource.Convert(destination); +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/ObjectToJsonConverter.cs b/src/CoreEx/Mapping/Converters/ObjectToJsonConverter.cs deleted file mode 100644 index 1cbf524a..00000000 --- a/src/CoreEx/Mapping/Converters/ObjectToJsonConverter.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using System; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Represents an to JSON converter. - /// - /// The . - public readonly struct ObjectToJsonConverter : IConverter - { - private readonly ValueConverter _convertToDestination; - private readonly ValueConverter _convertToSource; - - /// - /// Gets or sets the default (singleton) instance. - /// - public static ObjectToJsonConverter Default { get; set; } = new(); - - /// - /// Initializes a new instance of the struct using the default . - /// - public ObjectToJsonConverter() : this(null) { } - - /// - /// Initializes a new instance of the struct. - /// - /// The ; will default where not specified. - public ObjectToJsonConverter(IJsonSerializer? jsonSerializer) - { - var js = jsonSerializer ?? ExecutionContext.GetService() ?? JsonSerializer.Default; - _convertToDestination = new(s => s == null ? null : js.Serialize(s)); - _convertToSource = new(d => d == null ? default : js.Deserialize(d)); - } - - /// - /// Gets the source to destination . - /// - public IValueConverter ToDestination => _convertToDestination; - - /// - /// Gets the destination to source . - /// - public IValueConverter ToSource => _convertToSource; - - /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((T?)source); - - /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((string?)destination); - - /// - public readonly string? ConvertToDestination(T? source) => ToDestination.Convert(source); - - /// - public readonly T? ConvertToSource(string? destination) => ToSource.Convert(destination); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/ReferenceDataCodeConverter.cs b/src/CoreEx/Mapping/Converters/ReferenceDataCodeConverter.cs deleted file mode 100644 index 6a904755..00000000 --- a/src/CoreEx/Mapping/Converters/ReferenceDataCodeConverter.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using System; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Represents an to () converter. - /// - /// The . - public readonly struct ReferenceDataCodeConverter : IConverter where TRef : IReferenceData, new() - { - private readonly ValueConverter _convertToDestination = new(s => s?.Code); - private readonly ValueConverter _convertToSource = new(d => ReferenceDataOrchestrator.ConvertFromCode(d)!); - - /// - /// Gets or sets the default (singleton) instance. - /// - public static ReferenceDataCodeConverter Default { get; set; } = new(); - - /// - /// Initializes a new instance of the struct. - /// - public ReferenceDataCodeConverter() { } - - /// - /// Gets the source to destination . - /// - public IValueConverter ToDestination => _convertToDestination; - - /// - /// Gets the destination to source . - /// - public IValueConverter ToSource => _convertToSource; - - /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((TRef?)source); - - /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((string?)destination); - - /// - public readonly string? ConvertToDestination(TRef? source) => ToDestination.Convert(source); - - /// - public readonly TRef? ConvertToSource(string? destination) => ToSource.Convert(destination); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/ReferenceDataIdConverter.cs b/src/CoreEx/Mapping/Converters/ReferenceDataIdConverter.cs deleted file mode 100644 index 44d596d8..00000000 --- a/src/CoreEx/Mapping/Converters/ReferenceDataIdConverter.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.RefData; -using System; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Represents an to converter. - /// - /// The . - /// The . - public readonly struct ReferenceDataIdConverter : IConverter where TRef : IReferenceData, new() - { - private readonly ValueConverter _convertToDestination = new(s => s == null ? default! : (TId)s.Id!); - private readonly ValueConverter _convertToSource = new(d => ReferenceDataOrchestrator.ConvertFromId(d)); - - /// - /// Gets or sets the default (singleton) instance. - /// - public static ReferenceDataIdConverter Default { get; set; } = new(); - - /// - /// Initializes a new instance of the struct. - /// - public ReferenceDataIdConverter() { } - - /// - /// Gets the source to destination . - /// - public IValueConverter ToDestination => _convertToDestination; - - /// - /// Gets the destination to source . - /// - public IValueConverter ToSource => _convertToSource; - - /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((TRef?)source); - - /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((TId)destination!); - - /// - public readonly TId ConvertToDestination(TRef? source) => ToDestination.Convert(source); - - /// - public readonly TRef? ConvertToSource(TId destination) => ToSource.Convert(destination); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/StringBase64Converter.cs b/src/CoreEx/Mapping/Converters/StringBase64Converter.cs new file mode 100644 index 00000000..2538a098 --- /dev/null +++ b/src/CoreEx/Mapping/Converters/StringBase64Converter.cs @@ -0,0 +1,42 @@ +namespace CoreEx.Mapping.Converters; + +/// +/// Represents a to converter (uses and ). +/// +public readonly struct StringBase64Converter : IConverter +{ + private static readonly ValueConverter _convertToDestination = new(s => s is null ? null : Convert.FromBase64String(s)); + private static readonly ValueConverter _convertToSource = new(d => d is null ? null : Convert.ToBase64String(d)); + + /// + /// Gets or sets the default (singleton) instance. + /// + public static StringBase64Converter Default { get; set; } = new(); + + /// + /// Initializes a new instance of the struct. + /// + public StringBase64Converter() { } + + /// + /// Gets the source to destination . + /// + public IValueConverter ToDestination => _convertToDestination; + + /// + /// Gets the destination to source . + /// + public IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((byte[]?)destination); + + /// + public readonly byte[]? ConvertToDestination(string? source) => ToDestination.Convert(source); + + /// + public readonly string? ConvertToSource(byte[]? destination) => ToSource.Convert(destination); +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/StringToBase64Converter.cs b/src/CoreEx/Mapping/Converters/StringToBase64Converter.cs deleted file mode 100644 index 9201d427..00000000 --- a/src/CoreEx/Mapping/Converters/StringToBase64Converter.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Represents a to converter (uses and ). - /// - public readonly struct StringToBase64Converter : IConverter - { - private static readonly ValueConverter _convertToDestination = new(s => s == null ? null : Convert.FromBase64String(s)); - private static readonly ValueConverter _convertToSource = new(d => d == null ? null : Convert.ToBase64String(d)); - - /// - /// Gets or sets the default (singleton) instance. - /// - public static StringToBase64Converter Default { get; set; } = new(); - - /// - /// Initializes a new instance of the struct. - /// - public StringToBase64Converter() { } - - /// - /// Gets the source to destination . - /// - public IValueConverter ToDestination => _convertToDestination; - - /// - /// Gets the destination to source . - /// - public IValueConverter ToSource => _convertToSource; - - /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); - - /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((byte[]?)destination); - - /// - public readonly byte[]? ConvertToDestination(string? source) => ToDestination.Convert(source); - - /// - public readonly string? ConvertToSource(byte[]? destination) => ToSource.Convert(destination); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/StringToTypeConverter.cs b/src/CoreEx/Mapping/Converters/StringToTypeConverter.cs deleted file mode 100644 index 8d0dbba9..00000000 --- a/src/CoreEx/Mapping/Converters/StringToTypeConverter.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.ComponentModel; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Represents a to to conversion using a . - /// - /// The to convert. - /// See also . - public readonly struct StringToTypeConverter : IConverter - { - private static readonly TypeConverter _typeConverter = TypeDescriptor.GetConverter(typeof(T)); - private static readonly ValueConverter _convertToDestination = new(d => d == null ? default! : (T)_typeConverter.ConvertFromInvariantString(d)!); - private static readonly ValueConverter _convertToSource = new(s => s == null ? default! : _typeConverter.ConvertToInvariantString(s)); - - /// - /// Gets or sets the default (singleton) instance. - /// - public static StringToTypeConverter Default { get; set; } = new(); - - /// - /// Initializes a new instance of the struct. - /// - public StringToTypeConverter() { } - - /// - public readonly IValueConverter ToDestination => _convertToDestination; - - /// - public readonly IValueConverter ToSource => _convertToSource; - - /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); - - /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((T)destination!); - - /// - public readonly T ConvertToDestination(string? source) => ToDestination.Convert(source); - - /// - public readonly string? ConvertToSource(T destination) => ToSource.Convert(destination); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/TypeToStringConverter.cs b/src/CoreEx/Mapping/Converters/TypeToStringConverter.cs deleted file mode 100644 index 65a36514..00000000 --- a/src/CoreEx/Mapping/Converters/TypeToStringConverter.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.ComponentModel; - -namespace CoreEx.Mapping.Converters -{ - /// - /// Represents a to conversion using a . - /// - /// The to convert. - /// See also . - public readonly struct TypeToStringConverter : IConverter - { - private static readonly TypeConverter _typeConverter = TypeDescriptor.GetConverter(typeof(T)); - private static readonly ValueConverter _convertToDestination = new(s => s == null ? default! : _typeConverter.ConvertToInvariantString(s)); - private static readonly ValueConverter _convertToSource = new(d => d == null ? default! : (T)_typeConverter.ConvertFromInvariantString(d)!); - - /// - /// Gets or sets the default (singleton) instance. - /// - public static TypeToStringConverter Default { get; set; } = new(); - - /// - /// Initializes a new instance of the struct. - /// - public TypeToStringConverter() { } - - /// - public readonly IValueConverter ToDestination => _convertToDestination; - - /// - public readonly IValueConverter ToSource => _convertToSource; - - /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((T)source!); - - /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((string?)destination); - - /// - public readonly string? ConvertToDestination(T source) => ToDestination.Convert(source); - - /// - public readonly T ConvertToSource(string? destination) => ToSource.Convert(destination); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Converters/ValueConverter.cs b/src/CoreEx/Mapping/Converters/ValueConverter.cs index 29f43b0a..0f4e43bf 100644 --- a/src/CoreEx/Mapping/Converters/ValueConverter.cs +++ b/src/CoreEx/Mapping/Converters/ValueConverter.cs @@ -1,20 +1,15 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Mapping.Converters; -using System; - -namespace CoreEx.Mapping.Converters +/// +/// Provides conversion from a source to a destination value. +/// +/// The source . +/// The destination . +/// The function to convert a to a . +public readonly struct ValueConverter(Func converter) : IValueConverter { - /// - /// Provides conversion from a source to a destination value. - /// - /// The source . - /// The destination . - /// The function to convert a to a . - public readonly struct ValueConverter(Func converter) : IValueConverter - { - private readonly Func _converter = converter.ThrowIfNull(nameof(converter)); + private readonly Func _converter = converter.ThrowIfNull(); - /// - public TDestination Convert(TSource source) => _converter is null ? throw new InvalidOperationException($"The {nameof(ValueConverter)} has not been initialized with an underlying converter correctly.") : _converter(source); - } + /// + public TDestination Convert(TSource source) => _converter is null ? throw new InvalidOperationException($"The {nameof(ValueConverter<,>)} has not been initialized with an underlying converter correctly.") : _converter(source); } \ No newline at end of file diff --git a/src/CoreEx/Mapping/IBiDirectionMapper.cs b/src/CoreEx/Mapping/IBiDirectionMapper.cs new file mode 100644 index 00000000..e30dcfe1 --- /dev/null +++ b/src/CoreEx/Mapping/IBiDirectionMapper.cs @@ -0,0 +1,20 @@ +namespace CoreEx.Mapping; + +/// +/// Enables the bi-directional mapping between a value and a value. +/// +/// The source . +/// The destination . +/// The is a left-to-right , with the being a right-to-left . +public interface IBiDirectionMapper where TSource : class where TDestination : class +{ + /// + /// Gets the to mapper (left-to-right). + /// + IMapper To { get; } + + /// + /// Gets the to mapper (right-to-left). + /// + IMapper From { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/IBidirectionalMapperBase.cs b/src/CoreEx/Mapping/IBidirectionalMapperBase.cs deleted file mode 100644 index 302b505b..00000000 --- a/src/CoreEx/Mapping/IBidirectionalMapperBase.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Mapping -{ - /// - /// Enables a bidirectional mapper that encapsulates two 's; one for each direction. - /// - public interface IBidirectionalMapperBase - { - /// - /// Gets the from to to . - /// - IMapperBase MapperFromTo { get; } - - /// - /// Gets the to to from . - /// - IMapperBase MapperToFrom { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/IBidirectionalMapperT.cs b/src/CoreEx/Mapping/IBidirectionalMapperT.cs deleted file mode 100644 index 48ddfbe0..00000000 --- a/src/CoreEx/Mapping/IBidirectionalMapperT.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Mapping -{ - /// - /// Enables a bidirectional mapper that encapsulates two ; one for each direction. - /// - /// The from . - /// The to . - public interface IBidirectionalMapper : IBidirectionalMapperBase - { - /// - IMapperBase IBidirectionalMapperBase.MapperFromTo => MapperFromTo; - - /// - IMapperBase IBidirectionalMapperBase.MapperToFrom => MapperToFrom; - - /// - /// Gets the for to - /// - new IMapper MapperFromTo { get; } - - /// - /// Gets the for to "/> - /// - new IMapper MapperToFrom { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/IIntoMapper.cs b/src/CoreEx/Mapping/IIntoMapper.cs new file mode 100644 index 00000000..2f10c494 --- /dev/null +++ b/src/CoreEx/Mapping/IIntoMapper.cs @@ -0,0 +1,14 @@ +namespace CoreEx.Mapping; + +/// +/// Enables mapping from a source value into an existing destination value. +/// +public interface IIntoMapper : IMapperBase +{ + /// + /// Maps (merges) the value into an existing value. + /// + /// The source value. + /// The destination value. + void MapInto(object source, object destination); +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/IIntoMapperT.cs b/src/CoreEx/Mapping/IIntoMapperT.cs new file mode 100644 index 00000000..a432ee25 --- /dev/null +++ b/src/CoreEx/Mapping/IIntoMapperT.cs @@ -0,0 +1,29 @@ +namespace CoreEx.Mapping; + +/// +/// Enables mapping from a value into an existing value. +/// +/// The source . +/// The destination . +public interface IIntoMapper : IIntoMapper where TSource : class where TDestination : class +{ + /// + /// Gets the source . + /// + Type IMapperBase.SourceType => typeof(TSource); + + /// + /// Gets the destination . + /// + Type IMapperBase.DestinationType => typeof(TDestination); + + /// + void IIntoMapper.MapInto(object source, object destination) => MapInto((TSource)source.ThrowIfNull(), (TDestination)destination.ThrowIfNull()); + + /// + /// Maps (merges) the value into an existing value. + /// + /// The source value. + /// The destination value. + void MapInto(TSource source, TDestination destination); +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/IMapper.cs b/src/CoreEx/Mapping/IMapper.cs index baaa332d..d7fcf180 100644 --- a/src/CoreEx/Mapping/IMapper.cs +++ b/src/CoreEx/Mapping/IMapper.cs @@ -1,47 +1,15 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Mapping; -using System; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.Mapping +/// +/// Enables mapping from a source value to a new destination value. +/// +public interface IMapper : IMapperBase { /// - /// Provides mapping between source and destination values. + /// Maps the value to a new destination value. /// - /// Decouples CoreEx from any specific implementation. - public interface IMapper - { - /// - /// Maps the (inferring ) value to a new value. - /// - /// The destination . - /// The source value. - /// The singluar CRUD value being performed. - /// The destination value. - [return: NotNullIfNotNull(nameof(source))] - TDestination? Map(object? source, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps the value to a new value. - /// - /// The source . - /// The destination . - /// The source value. - /// The singluar CRUD value being performed. - /// The destination value. - [return: NotNullIfNotNull(nameof(source))] - TDestination? Map(TSource? source, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps the value into the existing value. - /// - /// The source . - /// The destination . - /// The source value. - /// The destination value. - /// The singluar CRUD value being performed. - /// The value. - [return: NotNullIfNotNull(nameof(source))] - TDestination? Map(TSource? source, TDestination? destination, OperationTypes operationType = OperationTypes.Unspecified); - } + /// The source value. + /// The destination value. + [return: NotNullIfNotNull(nameof(source))] + object? Map(object? source); } \ No newline at end of file diff --git a/src/CoreEx/Mapping/IMapperBase.cs b/src/CoreEx/Mapping/IMapperBase.cs index 4201c9f3..6dfbe300 100644 --- a/src/CoreEx/Mapping/IMapperBase.cs +++ b/src/CoreEx/Mapping/IMapperBase.cs @@ -1,46 +1,17 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Mapping; -using System; - -namespace CoreEx.Mapping +/// +/// Enables base capabilities to support mapping from a to a value. +/// +public interface IMapperBase { /// - /// Enables the base source and destination mapping capability. + /// Gets the source . /// - public interface IMapperBase - { - /// - /// Gets or sets the owning . - /// - /// This enables a mapper to map an underlying property to another. - /// This is automatically set during the . - Mapper Owner { get; set; } - - /// - /// Gets the source . - /// - Type SourceType { get; } - - /// - /// Gets the destination . - /// - Type DestinationType { get; } + Type SourceType { get; } - /// - /// Maps the value to a new destination value. - /// - /// The source value. - /// The singluar CRUD value being performed. - /// The destination value. - object? Map(object? source, OperationTypes operationType = OperationTypes.Unspecified); - - /// - /// Maps the value to a new destination value. - /// - /// The source value. - /// The destination value. - /// The singluar CRUD value being performed. - /// The destination value. - object? Map(object? source, object? destination, OperationTypes operationType = OperationTypes.Unspecified); - } + /// + /// Gets the destination . + /// + Type DestinationType { get; } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/IMapperT.cs b/src/CoreEx/Mapping/IMapperT.cs index ef45b231..db16406d 100644 --- a/src/CoreEx/Mapping/IMapperT.cs +++ b/src/CoreEx/Mapping/IMapperT.cs @@ -1,63 +1,30 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Mapping +namespace CoreEx.Mapping; + +/// +/// Enables mapping from a value to a new value. +/// +/// The source . +/// The destination . +public interface IMapper : IMapper where TSource : class where TDestination : class { /// - /// Enables mapping from the to the . + /// Gets the source . /// - /// The source . - /// The destination . - public interface IMapper : IMapperBase - { - /// - Type IMapperBase.SourceType => typeof(TSource); - - /// - Type IMapperBase.DestinationType => typeof(TDestination); - - /// - /// Creates a instance. - /// - /// A instance. - TDestination CreateSource(); - - /// - /// Creates a instance. - /// - /// A instance. - TDestination CreateDestination(); + Type IMapperBase.SourceType => typeof(TSource); - /// - /// Indicates whether the is considered initial; i.e. all mapped property values are their default. - /// - /// The source value. - /// true where considered initial; otherwise, false. - bool IsSourceInitial(TSource source); - - /// - /// Initializes the destination properties to their default values during a Flatten where the source value is null. - /// - /// The destination value. - /// true where initialization occured; otherwise, false. - bool InitializeDestination(TDestination destination); + /// + /// Gets the destination . + /// + Type IMapperBase.DestinationType => typeof(TDestination); - /// - /// Maps the value to a new value. - /// - /// The source value. - /// The singluar CRUD value being performed. - /// The destination value. - TDestination? Map(TSource? source, OperationTypes operationType = OperationTypes.Unspecified); + /// + object? IMapper.Map(object? source) => Map((TSource?)source)!; - /// - /// Maps the value into the existing value. - /// - /// The source value. - /// The destination value. - /// The singluar CRUD value being performed. - /// The value. - TDestination? Map(TSource? source, TDestination? destination, OperationTypes operationType = OperationTypes.Unspecified); - } + /// + /// Maps the value to a new destination value. + /// + /// The source value . + /// The destination value. + [return: NotNullIfNotNull(nameof(source))] + TDestination? Map(TSource? source); } \ No newline at end of file diff --git a/src/CoreEx/Mapping/IntoMapperT2.cs b/src/CoreEx/Mapping/IntoMapperT2.cs new file mode 100644 index 00000000..9dc6c977 --- /dev/null +++ b/src/CoreEx/Mapping/IntoMapperT2.cs @@ -0,0 +1,31 @@ +namespace CoreEx.Mapping; + +/// +/// Provides mapping () from a into an existing value. +/// +/// The source . +/// The destination . +/// Automatically leverages the (where ) to map standard properties where applicable. +public abstract class IntoMapper : IIntoMapper where TSource : class where TDestination : class +{ + /// + /// Indicates whether to use the automatically. + /// + /// This occurs after the . + protected virtual bool UseMapStandardInto => true; + + /// + public void MapInto(TSource source, TDestination destination) + { + OnMapInto(source.ThrowIfNull(), destination.ThrowIfNull()); + if (UseMapStandardInto) + Mapper.MapStandardInto(source, destination); + } + + /// + /// Maps the value into an existing destination value. + /// + /// The source value . + /// The destination value. + protected abstract void OnMapInto(TSource source, TDestination destination); +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/IntoMapperT3.cs b/src/CoreEx/Mapping/IntoMapperT3.cs new file mode 100644 index 00000000..5c0bb42c --- /dev/null +++ b/src/CoreEx/Mapping/IntoMapperT3.cs @@ -0,0 +1,30 @@ +namespace CoreEx.Mapping; + +/// +/// Provides mapping () from a value into an existing value. +/// +/// The source . +/// The destination . +/// The mapper itself. +/// The method provides a convenient way to map a source value into an existing destination value using the singleton instance of the underlying mapper. +/// Automatically leverages the (where ) to map standard properties where applicable. +public abstract class IntoMapper : IntoMapper where TSource : class where TDestination : class where TSelf : IntoMapper, new() +{ + /// + /// Gets the default singleton instance of the mapper. + /// + public static TSelf Default { get; } = new TSelf(); + + /// + /// Maps (merges) the value into an existing value. + /// + /// The source value. + /// The destination value. + [return: NotNullIfNotNull(nameof(source))] + public static new void MapInto(TSource source, TDestination destination) + { + Default.OnMapInto(source.ThrowIfNull(), destination.ThrowIfNull()); + if (Default.UseMapStandardInto) + Mapper.MapStandardInto(source, destination); + } +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/Mapper.cs b/src/CoreEx/Mapping/Mapper.cs index 50d62bca..bc0f6a5d 100644 --- a/src/CoreEx/Mapping/Mapper.cs +++ b/src/CoreEx/Mapping/Mapper.cs @@ -1,383 +1,191 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Mapping; -using CoreEx.Abstractions.Reflection; -using System; -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reflection; - -namespace CoreEx.Mapping +/// +/// Provides utility capabilities for mapping. +/// +public static class Mapper { /// - /// Provides the simple (explicit) value capability. + /// Maps the standard properties from a value into a new value. /// - public class Mapper : IMapper + /// The source . + /// The destination . + /// The destination value. + /// The source value. + /// Indicates whether to map the change log properties. + /// The to support fluent-style method-chaining. + /// Standard properties are mapped based on whether both the and implement the following respectively: + /// + /// -> + /// -> + /// -> + /// -> + /// -> + /// -> + /// -> or + /// -> or + /// + /// See also . + /// + public static TDestination MapStandardFrom(this TDestination destination, TSource source, bool mapChangeLog = true) where TSource : class where TDestination : class { - private readonly ConcurrentDictionary<(Type, Type), IMapperBase> _mappers = new(); + MapStandardInto(source, destination, mapChangeLog); + return destination; + } - /// - /// Gets an empty ; i.e. one that does not perform any mapping and will always throw a where a Map operation is invoked. - /// - public static EmptyMapper Empty { get; } = new EmptyMapper(); + /// + /// Maps the standard properties from a value into an existing value. + /// + /// The source . + /// The destination . + /// The source value. + /// The destination value. + /// Indicates whether to map the change log properties. + /// Standard properties are mapped based on whether both the and implement the following respectively: + /// + /// -> + /// -> + /// -> + /// -> + /// -> + /// -> + /// -> or + /// -> or + /// + /// See also . + /// + public static void MapStandardInto(TSource source, TDestination destination, bool mapChangeLog = true) where TSource : class where TDestination : class + { + if (ReferenceEquals(source.ThrowIfNull(), destination.ThrowIfNull())) + return; - /// - /// Initializes a new instance of the class. - /// - /// Also, automatically registers the mapping Cartesian product between and (i.e. all combinations thereof). - public Mapper() - { - Register(new Mapper() - .Map((s, d) => d.CreatedBy = s.CreatedBy, OperationTypes.AnyExceptUpdate) - .Map((s, d) => d.CreatedDate = s.CreatedDate, OperationTypes.AnyExceptUpdate) - .Map((s, d) => d.UpdatedBy = s.UpdatedBy, OperationTypes.AnyExceptCreate) - .Map((s, d) => d.UpdatedDate = s.UpdatedDate, OperationTypes.AnyExceptCreate)); + if (source is IReadOnlyIdentifier si && destination is IIdentifier di && si.IdType == di.IdType) + di.Id = si.Id; - Register(new Mapper() - .Map((s, d) => d.CreatedBy = s.CreatedBy, OperationTypes.AnyExceptUpdate) - .Map((s, d) => d.CreatedDate = s.CreatedDate, OperationTypes.AnyExceptUpdate) - .Map((s, d) => d.UpdatedBy = s.UpdatedBy, OperationTypes.AnyExceptCreate) - .Map((s, d) => d.UpdatedDate = s.UpdatedDate, OperationTypes.AnyExceptCreate)); + if (source is IReadOnlyETag setag && destination is IETag detag) + detag.ETag = setag.ETag; - Register(new Mapper() - .Map((s, d) => d.CreatedBy = s.CreatedBy, OperationTypes.AnyExceptUpdate) - .Map((s, d) => d.CreatedDate = s.CreatedDate, OperationTypes.AnyExceptUpdate) - .Map((s, d) => d.UpdatedBy = s.UpdatedBy, OperationTypes.AnyExceptCreate) - .Map((s, d) => d.UpdatedDate = s.UpdatedDate, OperationTypes.AnyExceptCreate)); + if (source is IReadOnlyTenantId sti && destination is ITenantId dti) + dti.TenantId = sti.TenantId; - Register(new Mapper() - .Map((s, d) => d.CreatedBy = s.CreatedBy, OperationTypes.AnyExceptUpdate) - .Map((s, d) => d.CreatedDate = s.CreatedDate, OperationTypes.AnyExceptUpdate) - .Map((s, d) => d.UpdatedBy = s.UpdatedBy, OperationTypes.AnyExceptCreate) - .Map((s, d) => d.UpdatedDate = s.UpdatedDate, OperationTypes.AnyExceptCreate)); - } + if (source is IReadOnlyPartitionKey spk && destination is IPartitionKey dpk) + dpk.PartitionKey = spk.PartitionKey; - /// - /// Indicates whether to convert empty collections to null where supported. - /// - /// Defaults to true. - public bool ConvertEmptyCollectionsToNull { get; set; } = true; + if (source is IReadOnlyLogicallyDeleted sld && destination is ILogicallyDeleted dld) + dld.IsDeleted = sld.IsDeleted; - /// - /// Indicates whether to allow same to same type mapping (where explicitly not registered) as always returning the source value. - /// - /// Defaults to true. - /// Creates and registers an instance of the on first use. - public bool MapSameTypeWithSourceValue { get; set; } = true; + if (source is IReadOnlyTypeDiscriminator std && destination is ITypeDiscriminator dtd) + dtd.TypeDiscriminator = std.TypeDiscriminator; - /// - /// Register (adds) all the and types (instances) from the from the specified . - /// - /// The . - public void Register() => Register(typeof(TAssembly).Assembly); + if (mapChangeLog) + MapChangeLogInto(source, destination); + } - /// - /// Register (adds) all and types (instances) from the specified . - /// - /// The assemblies. - public void Register(params Assembly[] assemblies) - { - foreach (var assembly in assemblies.Distinct()) - { - foreach (var match in from type in assembly.GetTypes() - where !type.IsAbstract && !type.IsGenericTypeDefinition - let interfaces = type.GetInterfaces() - let genericInterfaces = interfaces.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IMapper<,>)) - let @interface = genericInterfaces.FirstOrDefault() - let sourceType = @interface?.GetGenericArguments().Length == 2 ? @interface?.GetGenericArguments()[0] : null - let destinationType = @interface?.GetGenericArguments().Length == 2 ? @interface?.GetGenericArguments()[1] : null - where @interface != null - select new { type, sourceType, destinationType }) - { - Register(match.sourceType, match.destinationType, (IMapperBase)Activator.CreateInstance(match.type)!); - } + /// + /// Maps the standard change log properties from a value into an existing changing shape as required. + /// + /// The source . + /// The destination . + /// The source value. + /// The destination value. + public static void MapChangeLogInto(TSource source, TDestination destination) where TSource : class where TDestination : class + { + if (ReferenceEquals(source.ThrowIfNull(), destination.ThrowIfNull())) + return; - foreach (var match in from type in assembly.GetTypes() - where !type.IsAbstract && !type.IsGenericTypeDefinition - let interfaces = type.GetInterfaces() - let genericInterfaces = interfaces.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IBidirectionalMapper<,>)) - let @interface = genericInterfaces.FirstOrDefault() - where @interface != null - select new { type }) - { - var bimapper = (IBidirectionalMapperBase)Activator.CreateInstance(match.type)!; - _mappers.TryAdd((bimapper.MapperFromTo.SourceType, bimapper.MapperFromTo.DestinationType), bimapper.MapperFromTo); - _mappers.TryAdd((bimapper.MapperToFrom.SourceType, bimapper.MapperToFrom.DestinationType), bimapper.MapperToFrom); - } - } - } + if (destination is IChangeLog dcl) + MapChangeLogInto(source, dcl); - /// - /// Perform the actual mapper registration and linking. - /// - private void Register(Type s, Type d, IMapperBase mapper) - { - mapper.Owner = this; - _mappers.TryAdd((s, d), mapper); - } + if (destination is IChangeLogEx dclex) + MapChangeLogInto(source, dclex); + } - /// - /// Registers (adds) an individual . - /// - /// The source . - /// The destination . - /// The . - /// Where an attempt is made to add a mapper for a and more than once only the first will succeed; no exception will be thrown for subsequent adds. - public void Register(IMapper mapper) - => _mappers.TryAdd(((mapper.ThrowIfNull(nameof(mapper))).SourceType, mapper.DestinationType), mapper.Adjust(x => x.Owner = this)); + /// + /// Maps the standard change log properties from a value into an existing () value (where applicable). + /// + private static void MapChangeLogInto(TSource source, IChangeLog destination) where TSource : class + { + if (source is IReadOnlyChangeLog scl) + destination.ChangeLog = new ChangeLog(scl); + else if (source is IReadOnlyChangeLogEx sclex) + destination.ChangeLog = new ChangeLog(sclex); - /// - /// Registers (adds) an individual . - /// - /// The source . - /// The destination . - /// The see cref="IBidirectionalMapper{TSource, TDestination}"/>. - /// Where an attempt is made to add a mapper for a and more than once only the first will succeed; no exception will be thrown for subsequent adds. - public void Register(IBidirectionalMapper bidirectionalMapper) - { - var mapperFromTo = bidirectionalMapper.ThrowIfNull(nameof(bidirectionalMapper)).MapperFromTo.Adjust(x => x.Owner = this); - var mapperToFrom = bidirectionalMapper.MapperToFrom.Adjust(x => x.Owner = this); - _mappers.TryAdd((mapperFromTo.SourceType, mapperFromTo.DestinationType), mapperFromTo); - _mappers.TryAdd((mapperToFrom.SourceType, mapperToFrom.DestinationType), mapperToFrom); - } + if (destination.ChangeLog?.IsDefault() ?? false) + destination.ChangeLog = null; + } - /// - [return: NotNullIfNotNull(nameof(source))] - public TDestination? Map(object? source, OperationTypes operationType = OperationTypes.Unspecified) + /// + /// Maps the standard change log properties from a value into an existing ( value (where applicable). + /// + private static void MapChangeLogInto(TSource source, IChangeLogEx destination) where TSource : class + { + static void MapInto(IReadOnlyChangeLogEx scl, IChangeLogEx dcl) { - if (source is null) - return default!; - - return (TDestination)GetMapper(source.GetType(), typeof(TDestination)).Map(source, operationType)!; + dcl.CreatedBy = scl.CreatedBy; + dcl.CreatedOn = scl.CreatedOn; + dcl.UpdatedBy = scl.UpdatedBy; + dcl.UpdatedOn = scl.UpdatedOn; } - /// - /// Gets the for the specified and types as previously registered. - /// - /// The source . - /// The destination . - /// The previously registered . - /// Thrown where not previously registered. - public IMapperBase GetMapper(Type source, Type destination) - { - if (TryGetMapper(source, destination, out var mapper)) - return mapper; - - throw new InvalidOperationException($"No mapper has been registered for source '{source.FullName}' and destination '{destination.FullName}' types."); - } - - /// - /// Gets the for the specified and types as previously registered. - /// - /// The source . - /// The destination . - /// The previously registered . - /// Thrown where not previously registered. - public IMapper GetMapper() => (IMapper)GetMapper(typeof(TSource), typeof(TDestination)); - - /// - /// Try and get the mapper for the and types. - /// - /// The source . - /// The destination . - /// The previously registered . - /// true where found; otherwise, false. - public bool TryGetMapper(Type source, Type destination, [NotNullWhen(true)] out IMapperBase? mapper) - { - if (_mappers.TryGetValue((source, destination), out mapper)) - return true; - - // Check if the types are collection and automatically create where possible. - var si = TypeReflector.GetCollectionItemType(source); - if (si.TypeCode == TypeReflectorTypeCode.ICollection) - { - var di = TypeReflector.GetCollectionItemType(destination); - if (di.TypeCode == TypeReflectorTypeCode.ICollection) - { - mapper = _mappers.GetOrAdd((source, destination), _ => - { - var t = typeof(CollectionMapper<,,,>).MakeGenericType(source, si.ItemType!, destination, di.ItemType!); - var mapper = (IMapperBase)Activator.CreateInstance(t)!; - mapper.Owner = this; - return mapper; - }); - - return true; - } - } - - // Check if the types are the same and automatically create where configured to do so. - if (MapSameTypeWithSourceValue && si.TypeCode == TypeReflectorTypeCode.Complex && source == destination) - { - mapper = _mappers.GetOrAdd((source, destination), _ => - { - var t = typeof(SameTypeMapper<>).MakeGenericType(source); - var mapper = (IMapperBase)Activator.CreateInstance(t)!; - mapper.Owner = this; - return mapper; - }); + if (source is IReadOnlyChangeLogEx sclex) + MapInto(sclex, destination); + else if (source is IReadOnlyChangeLog scl) + MapInto(scl?.ChangeLog is null ? ChangeLog.Empty : scl.ChangeLog, destination); + } - return true; - } + /// + /// Creates a using the specified function. + /// + /// + /// + /// The mapping function. + /// The . + public static OneOffMapper Create(Func map) where TSource : class where TDestination : class => new(map); - return false; - } + /// + /// Provides a one-off runtime instantiated . + /// + /// The source . + /// The destination . + public sealed class OneOffMapper : Mapper where TSource : class where TDestination : class + { + private readonly Func _map; /// - /// Try and get the mapper for the and types. + /// Initializes a new instance of the class. /// - /// The source . - /// The destination . - /// The previously registered . - /// true where found; otherwise, false. - public bool TryGetMapper([NotNullWhen(true)] out IMapper? mapper) - { - if (TryGetMapper(typeof(TSource), typeof(TDestination), out var m)) - { - mapper = (IMapper)m; - return true; - } - - mapper = default; - return false; - } + /// The mapping function. + internal OneOffMapper(Func map) => _map = map.ThrowIfNull(); /// - [return: NotNullIfNotNull(nameof(source))] - public TDestination? Map(TSource? source, OperationTypes operationType = OperationTypes.Unspecified) - => GetMapper().Map(source, operationType)!; - - /// - [return: NotNullIfNotNull(nameof(source))] - public TDestination? Map(TSource? source, TDestination? destination, OperationTypes operationType = OperationTypes.Unspecified) - => GetMapper().Map(source, destination, operationType)!; - - /// - /// Represents an empty ; i.e. one that does not perform any mapping and will always throw a . - /// - public class EmptyMapper : IMapper - { - /// - /// Initializes a new instance of the class. - /// - internal EmptyMapper() { } - - /// - [return: NotNullIfNotNull(nameof(source))] - public TDestination? Map(object? source, OperationTypes operationType = OperationTypes.Unspecified) => throw new NotImplementedException(); - - /// - [return: NotNullIfNotNull(nameof(source))] - public TDestination? Map(TSource? source, OperationTypes operationType = OperationTypes.Unspecified) => throw new NotImplementedException(); - - /// - [return: NotNullIfNotNull(nameof(source))] - public TDestination? Map(TSource? source, TDestination? destination, OperationTypes operationType = OperationTypes.Unspecified) => throw new NotImplementedException(); - } - - /// - /// Represents a same ; i.e. where both the source and destination are the same. - /// - /// The source and destination . - public class SameTypeMapper : IMapper - { - private Mapper? _mapper; - - /// - public Mapper Owner - { - get => _mapper ?? throw new InvalidOperationException("Owner has not been set to a non-null value; this is automatically performed when registered."); - set => _mapper = _mapper is null ? value : throw new InvalidOperationException("Owner can not be changed once set."); - } - - /// - /// Throws a . - public TSame CreateDestination() => throw new NotSupportedException(); - - /// - /// Throws a . - public TSame CreateSource() => throw new NotSupportedException(); - - /// - public bool InitializeDestination(TSame destination) => false; - - /// - /// Returns where implemented; otherwise, false. - public bool IsSourceInitial(TSame source) => source is Entities.IInitial ii && ii.IsInitial; - - /// - /// Always returns the as-is. - public TSame? Map(TSame? source, OperationTypes operationType = OperationTypes.Unspecified) => source; - - /// - /// Always returns the as-is. - public TSame? Map(TSame? source, TSame? destination, OperationTypes operationType = OperationTypes.Unspecified) => source; - - /// - /// Always returns the as-is. - object? IMapperBase.Map(object? source, OperationTypes operationType) => source; - - /// - /// Always returns the as-is. - object? IMapperBase.Map(object? source, object? destination, OperationTypes operationType) => destination; - } - - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenGet(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Get, operationType, action); - - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenCreate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Create, operationType, action); - - /// - /// When is an then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenUpdate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Update, operationType, action); - - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenDelete(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.Delete, operationType, action); + protected override TDestination OnMap(TSource source) => _map(source); + } - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenAnyExceptGet(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.AnyExceptGet, operationType, action); + /// + /// Creates a using the specified action. + /// + /// + /// + /// The mapping action. + /// The . + public static OneOffIntoMapper CreateInto(Action map) where TSource : class where TDestination : class => new(map); - /// - /// When is a then the action is invoked. - /// - /// The singular . - /// The action to invoke. - public static void WhenAnyExceptCreate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.AnyExceptCreate, operationType, action); + /// + /// Provides a one-off runtime instantiated . + /// + /// The source . + /// The destination . + public sealed class OneOffIntoMapper : IntoMapper where TSource : class where TDestination : class + { + private readonly Action _map; /// - /// When is a then the action is invoked. + /// Initializes a new instance of the class. /// - /// The singular . - /// The action to invoke. - public static void WhenAnyExceptUpdate(OperationTypes operationType, Action action) => WhenOperationType(OperationTypes.AnyExceptUpdate, operationType, action); + /// The mapping action. + internal OneOffIntoMapper(Action map) => _map = map.ThrowIfNull(); - /// - /// When the matches the then the is invoked. - /// - private static void WhenOperationType(OperationTypes expectedOperationTypes, OperationTypes operationType, Action action) - { - if (expectedOperationTypes.HasFlag(operationType)) - action?.Invoke(); - } + /// + protected override void OnMapInto(TSource source, TDestination destination) => _map(source, destination); } } \ No newline at end of file diff --git a/src/CoreEx/Mapping/MapperExtensions.cs b/src/CoreEx/Mapping/MapperExtensions.cs new file mode 100644 index 00000000..a5d9e6b8 --- /dev/null +++ b/src/CoreEx/Mapping/MapperExtensions.cs @@ -0,0 +1,45 @@ +namespace CoreEx.Mapping; + +/// +/// Provides extension methods for capabilities. +/// +public static class MapperExtensions +{ + /// + /// Maps the value to a new value. + /// + /// The source . + /// The destination . + /// The . + /// The source value to map from. + /// The value that was created and mapped into. + public static TDestination MapNew(this IIntoMapper mapper, TSource source) + where TSource : class + where TDestination : class, new() + { + var destination = new TDestination(); + mapper.MapInto(source, destination); + return destination; + } + + /// + /// Maps the value to a new value or returns if the value is . + /// + /// The source . + /// The destination . + /// The . + /// The source value to map from. + /// The value that was created and mapped into, or where the source was also . + [return: NotNullIfNotNull(nameof(source))] + public static TDestination? MapNewOrNull(this IIntoMapper mapper, TSource? source) + where TSource : class + where TDestination : class, new() + { + if (source is null) + return null; + + var destination = new TDestination(); + mapper.MapInto(source, destination); + return destination; + } +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/MapperOptions.cs b/src/CoreEx/Mapping/MapperOptions.cs deleted file mode 100644 index c0885844..00000000 --- a/src/CoreEx/Mapping/MapperOptions.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Mapping -{ - /// - /// Represents the options. - /// - /// The owning . - /// The singluar CRUD value being performed. - public class MapperOptions(Mapper mapper, OperationTypes operationType) - { - /// - /// Gets the owning . - /// - public Mapper Owner { get; } = mapper.ThrowIfNull(nameof(mapper)); - - /// - /// Gets the singluar CRUD value being performed. - /// - public OperationTypes OperationType { get; } = operationType; - - /// - /// Maps the (inferring ) value to a new value. - /// - /// The destination . - /// The source value. - /// The destination value. - public TDestination? Map(object? source) - => Owner.Map(source, OperationType); - - /// - /// Maps the value to a new value. - /// - /// The source . - /// The destination . - /// The source value. - /// The destination value. - public TDestination? Map(TSource? source) - => Owner.Map(source, OperationType); - - /// - /// Maps the value into the existing value. - /// - /// The source . - /// The destination . - /// The source value. - /// The destination value. - /// The value. - public TDestination? Map(TSource? source, TDestination? destination) - => Owner.Map(source, destination, OperationType); - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/MapperT.cs b/src/CoreEx/Mapping/MapperT.cs deleted file mode 100644 index f9d6d72e..00000000 --- a/src/CoreEx/Mapping/MapperT.cs +++ /dev/null @@ -1,321 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CoreEx.Mapping -{ - /// - /// Provides a simple (explicit) value mapper. - /// - /// The source . - /// The destination . - public class Mapper : IMapper where TSource : class, new() where TDestination : class, new() - { - private readonly List<(Action action, OperationTypes types, Func? isSourceInitial, Action? initializeDestination)> _mappings = []; - private readonly Func? _onMap; - private Mapper? _mapper; - private Func? _isSourceInitial; - private Func? _initializeDestination; - - /// - /// Initializes a new instance of the class. - /// - public Mapper() { } - - /// - /// Initializes a new instance of the class with an function. - /// - /// The mapping function. - /// Provides a simple means to create an instance with logic specified within the constructor; versus, having to inherit to implement. - public Mapper(Func onMap) => _onMap = onMap.ThrowIfNull(nameof(onMap)); - - /// - public Mapper Owner - { - get => _mapper ?? throw new InvalidOperationException("Owner has not been set to a non-null value; this is automatically performed when registered."); - set - { - _mapper = _mapper is null ? value : throw new InvalidOperationException("Owner can not be changed once set."); - if (_mapper != null) - OnRegister(this); - } - } - - /// - /// Adds a mapping. - /// - /// The action that performs the mapping. - /// The (condition) when the mapping should occur. Defaults to . - /// The function to determine whether the source is initial (has default value). - /// The action to initialize the destination as a result of a where the source property is null. - public Mapper Map(Action map, OperationTypes operationTypes = OperationTypes.Any, Func? isSourceInitial = null, Action? initializeDestination = null) - { - map.ThrowIfNull(nameof(map)); - - void action(MapperOptions o, TSource s, TDestination d) => map(s, d); - - _mappings.Add((action, operationTypes, isSourceInitial, initializeDestination)); - return this; - } - - /// - /// Adds a mapping. - /// - /// The action that performs the mapping. - /// The (condition) when the mapping should occur. Defaults to . - /// The function to determine whether the source is initial (has default value). - /// The action to initialize the destination as a result of a where the source property is null. - public Mapper Map(Action map, OperationTypes operationTypes = OperationTypes.Any, Func? isSourceInitial = null, Action? initializeDestination = null) - { - _mappings.Add((map.ThrowIfNull(nameof(map)), operationTypes, isSourceInitial, initializeDestination)); - return this; - } - - /// - /// Adds a flatten-mapping of a nested property. - /// - /// The . - /// The function to get the source value. - /// The (condition) when the mapping should occur. Defaults to . - /// The function to determine whether the source is initial (has default value). - /// Flattening is the updating of the from a property that is a nested class, that in turn contains the actual corresponding source properties. - /// Where the property is null the corresponding properties are still updated as a temporary source property instance is instantiated and used. - /// Expanding () is the opposite of flattening. - public Mapper Flatten(Func source, OperationTypes operationTypes = OperationTypes.Any, Func? isSourceInitial = null) where T : class, new() - => Map((o, s, d) => - { - var sv = source(s); - if (sv is null && o.Owner.GetMapper().InitializeDestination(d)) - return; - - o.Map(sv ?? new(), d); - }, operationTypes, isSourceInitial: isSourceInitial); - - /// - /// Adds an expand-mapping to a nested property. - /// - /// The property . - /// The action that performs the mapping. - /// The (condition) when the mapping should occur. Defaults to . - /// The action to initialize the destination as a result of a where the source property is null. - public Mapper Expand(Action map, OperationTypes operationTypes = OperationTypes.Any, Action? initializeDestination = null) where T : class, new() - => Expand(map, null, operationTypes, initializeDestination); - - /// - /// Adds an expand-mapping to a nested property. - /// - /// The property . - /// The action that performs the mapping. - /// The (condition) when the mapping should occur. Defaults to . - /// The condition that must be met for the to be invoked. - /// The action to initialize the destination as a result of a where the source property is null. - /// Where is null then the (from the expanding mapper) is used to determine whether mapping occurs (i.e. where result is false being not initial). - public Mapper Expand(Action map, Func? condition, OperationTypes operationTypes = OperationTypes.Any, Action? initializeDestination = null) where T : class, new() - => Map((o, s, d) => - { - if (condition is not null && !condition(s, d)) - return; - - var em = o.Owner.GetMapper(); - if (condition is null && em.IsSourceInitial(s)) - return; - - map(d, em.Map(s)!); - }, operationTypes, initializeDestination: initializeDestination); - - /// - /// Adds a base mapping. - /// - /// The base source . - /// The base destination . - public Mapper Base() where TBaseSource : class, new() where TBaseDestination : class, new() - { - VerifyBaseInstanceOf(); - return Map((o, s, d) => o.Map((TBaseSource)(object)s, (TBaseDestination)(object)d)); - } - - /// - /// Adds a base mapping using the specified . - /// - /// The base source . - /// The base destination . - /// The base . - public Mapper Base(Mapper baseMapper) where TBaseSource : class, new() where TBaseDestination : class, new() - { - VerifyBaseInstanceOf(); - return Map((o, s, d) => baseMapper.Map((TBaseSource)(object)s, (TBaseDestination)(object)d, o.OperationType)); - } - - /// - /// Adds a base mapping. - /// - /// The . - public Mapper Base() where TMapper : IMapperBase, new() - { - var mapper = new TMapper(); - VerifyBaseInstanceOf(mapper.SourceType, mapper.DestinationType); - return Map((o, s, d) => o.Owner.GetMapper(mapper.SourceType, mapper.DestinationType).Map((object)s, (object)d)); - } - - /// - /// Verify instance of base. - /// - private static void VerifyBaseInstanceOf() => VerifyBaseInstanceOf(typeof(TBaseSource), typeof(TBaseDestination)); - - /// - /// Verify instance of base. - /// - private static void VerifyBaseInstanceOf(Type baseSourceType, Type baseDestinationType) - { - if (!baseSourceType.IsAssignableFrom(typeof(TSource))) - throw new ArgumentException($"Source Type '{baseSourceType.FullName}' must be assignable from '{typeof(TSource).FullName}'."); - - if (!baseDestinationType.IsAssignableFrom(typeof(TDestination))) - throw new ArgumentException($"Destination Type '{baseDestinationType.FullName}' must be assignable from '{typeof(TDestination).FullName}'."); - } - - /// - public virtual TDestination CreateSource() => new(); - - /// - public virtual TDestination CreateDestination() => new(); - - /// - /// Defaults to false where no Map, Flatten, Expand or is performed; otherwise, returns result of the aforementioned explicit checks. - public virtual bool IsSourceInitial(TSource source) - { - bool somethingConfigured = false; - foreach (var (_, _, isSourceInitial, _) in _mappings) - { - if (isSourceInitial is not null) - { - if (!isSourceInitial(source)) - return false; - - somethingConfigured = true; - } - } - - if (somethingConfigured) - return _isSourceInitial is null || _isSourceInitial(source); - - return _isSourceInitial?.Invoke(source) ?? false; - } - - /// - /// Sets the underlying function to determine whether extending the per Map, Flatten and Expand specifications (provides the base functionality). - /// - /// The function to determine whether the source is initial. - /// The function must return true where the source is initial; otherwise, false. - public Mapper IsSourceInitial(Func isSourceInitial) - { - if (_isSourceInitial != null) - throw new ArgumentException($"{nameof(IsSourceInitial)} cannot be invoked more than once.", nameof(isSourceInitial)); - - _isSourceInitial = isSourceInitial.ThrowIfNull(nameof(isSourceInitial)); - return this; - } - - /// - /// Defaults to true where explicit initialization logic is performed (per Map, Flatten, Expand and/or ); otherwise, - /// then a newly instantiated is mapped to achieve initialization. - public virtual bool InitializeDestination(TDestination destination) - { - bool somethingConfigured = false; - foreach (var (_, _, _, initializeDestination) in _mappings) - { - if (initializeDestination is not null) - { - initializeDestination(destination); - somethingConfigured = true; - } - } - - var wasInitialized = _initializeDestination?.Invoke(destination) ?? false; - return somethingConfigured || wasInitialized; - } - - /// - /// Sets the underlying function to initialize the destination properties as a result of a (provides the base functionality). - /// - /// The function to initialize the destination properties. - /// The function must return true where initialization occured; otherwise, false. - public Mapper InitializeDestination(Func initializeDestination) - { - if (_initializeDestination != null) - throw new ArgumentException($"{nameof(InitializeDestination)} cannot be invoked more than once.", nameof(initializeDestination)); - - _initializeDestination = initializeDestination.ThrowIfNull(nameof(initializeDestination)); - return this; - } - - /// - object? IMapperBase.Map(object? source, OperationTypes operationType) => source is null ? default : Map((TSource)source, null, operationType); - - /// - object? IMapperBase.Map(object? source, object? destination, OperationTypes operationType) => source is null ? default : Map((TSource)source, (TDestination?)destination, operationType); - - /// - TDestination? IMapper.Map(TSource? source, OperationTypes operationType) => Map(source, null, operationType); - - /// - TDestination? IMapper.Map(TSource? source, TDestination? destination, OperationTypes operationType) => Map(source, destination, operationType); - - /// - /// Performs the mapping by iterating over the configuration. - /// - /// The source. - /// The destination. - /// The singular . - public virtual TDestination? Map(TSource? source, TDestination? destination, OperationTypes operationType) - { - if (source is null && destination is null) - return OnMapInternal(source, destination, operationType); - - if (source is null && destination is not null) - { - destination = default; - return OnMapInternal(source, destination, operationType); - } - - if (destination is not null) - source ??= new(); - - destination ??= new(); - foreach (var (action, _, _, _) in _mappings.Where(m => m.types.HasFlag(operationType))) - { - action(new MapperOptions(Owner, operationType), source!, destination); - } - - return OnMapInternal(source, destination, operationType); - } - - /// - /// Performs the internal mapping logic. - /// - private TDestination? OnMapInternal(TSource? source, TDestination? destination, OperationTypes operationType) - { - if (_onMap is not null) - destination = _onMap(source, destination, operationType); - - return OnMap(source, destination, operationType); - } - - /// - /// Invoked after the internal mapping is completed (where configured). - /// - /// The source value. - /// The destination value. - /// The singular . - /// The destination value. - protected virtual TDestination? OnMap(TSource? source, TDestination? destination, OperationTypes operationType) => destination; - - /// - /// Invoked when the mapper is registered. - /// - /// The mapper instance being registered. - protected virtual void OnRegister(Mapper mapper) { } - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/MapperT2.cs b/src/CoreEx/Mapping/MapperT2.cs new file mode 100644 index 00000000..b8395ed9 --- /dev/null +++ b/src/CoreEx/Mapping/MapperT2.cs @@ -0,0 +1,28 @@ +namespace CoreEx.Mapping; + +/// +/// Provides mapping () from a value to a new value. +/// +/// The source . +/// The destination . +/// Automatically leverages the (where ) to map standard properties where applicable. +public abstract class Mapper : IMapper where TSource : class where TDestination : class +{ + /// + /// Indicates whether to use the automatically. + /// + /// This occurs after the . + protected virtual bool UseMapStandardFrom => true; + + /// + [return: NotNullIfNotNull(nameof(source))] + public TDestination? Map(TSource? source) => source is null ? null : OnMap(source).AdjustWhen(_ => UseMapStandardFrom, d => Mapper.MapStandardFrom(d, source)); + + /// + /// Maps the value to a new destination value. + /// + /// The source value . + /// The destination value. + /// The source will never be ; i.e. this is only invoked where not . + protected abstract TDestination OnMap(TSource source); +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/MapperT3.cs b/src/CoreEx/Mapping/MapperT3.cs new file mode 100644 index 00000000..fbd63e8b --- /dev/null +++ b/src/CoreEx/Mapping/MapperT3.cs @@ -0,0 +1,25 @@ +namespace CoreEx.Mapping; + +/// +/// Provides mapping () from a value to a new value. +/// +/// The source . +/// The destination . +/// The mapper itself. +/// The method provides a convenient way to map a source value to a new destination value using the singleton instance of the underlying mapper. +/// Automatically leverages the (where ) to map standard properties where applicable. +public abstract class Mapper : Mapper where TSource : class where TDestination : class where TSelf : Mapper, new() +{ + /// + /// Gets the default singleton instance of the mapper. + /// + public static TSelf Default { get; } = new TSelf(); + + /// + /// Maps the value to a new destination value. + /// + /// The source value . + /// The destination value. + [return: NotNullIfNotNull(nameof(source))] + public static new TDestination? Map(TSource? source) => source is null ? null : Default.OnMap(source).AdjustWhen(_ => Default.UseMapStandardFrom, d => Mapper.MapStandardFrom(d, source)); +} \ No newline at end of file diff --git a/src/CoreEx/Mapping/OperationTypes.cs b/src/CoreEx/Mapping/OperationTypes.cs deleted file mode 100644 index f7914cd5..00000000 --- a/src/CoreEx/Mapping/OperationTypes.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Mapping -{ - /// - /// Represents the mapping CRUD operation types (Create, Read, Update and Delete). - /// - [Flags] - public enum OperationTypes - { - /// - /// Unspecified operation. - /// - Unspecified = 1, - - /// - /// A Get (Read) operation. - /// - Get = 2, - - /// - /// A Create operation. - /// - Create = 4, - - /// - /// An update operation. - /// - Update = 8, - - /// - /// A delete operation. - /// - Delete = 16, - - /// - /// Any operation. - /// - Any = Unspecified | Get | Create | Update | Delete, - - /// - /// Any operation except . - /// - AnyExceptGet = Unspecified | Create | Update | Delete, - - /// - /// Any operation except . - /// - AnyExceptCreate = Unspecified | Get | Update | Delete, - - /// - /// Any operation except . - /// - AnyExceptUpdate = Unspecified | Get | Create | Delete - } -} \ No newline at end of file diff --git a/src/CoreEx/Mapping/README.md b/src/CoreEx/Mapping/README.md deleted file mode 100644 index 8952a03f..00000000 --- a/src/CoreEx/Mapping/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# CoreEx.Mapping - -The `CoreEx.Mapping` namespace provides extended mapping capabilities. - -
- -## Motivation - -To support a generic, implementation agnostic, means to support object property mappings that can be leveraged (integrated) in a consistent manner within _CoreEx_. Whereby enabling a developer to leverage their respective framework of choice; for example [AutoMapper](#AutoMapper-implementation). - -
- -## Implementation agnostic - -The [`IMapper`](./IMapper.cs) interface provides the standard implementation agnostic `Map` operations that can be, and are, leveraged within _CoreEx_ to provide object property mapping functionality. - -
- -## Mapper implementation - -The [`Mapper`](./Mapper.cs) class provides a simple (explicit) mapping implementation of the `IMapper`. It for the most part does _not_ employ reflection and/or run-time compilation; therefore, it is very simple and fast. - -The source and destination types and corresponding property mappings are configured using the [`Mapper`](./MapperT.cs) class which are registered with the owning `Mapper` using the `Register` method. Collection mappings are automatically enabled. - -Mappings are configured using the following methods. - -Method | Description --|- -`Map` | Adds a value-based mapping, generally property to property, including an [`IConverter`](./Converters/IConverter.cs) where applicable. -`Flatten` | Adds a flatten-mapping of a nested property. Flattening is the updating of the destination from a source property that is a nested class, that in turn contains the actual corresponding source properties. Where the source property is `null` the corresponding destination properties are still updated as a temporary source property instance is instantiated and used. -`Expand` | Adds an expand-mapping to a nested property. Expanding is the reverse of flattening (`Flatten`). -`Base` | Adds a base-mapping; where the source and destination type configuration is inherited from the base. -`IsSourceInitital` | Determines whether the source is initial; i.e. all properties have their respective default values. Defaults to `false` where not specified/overridden. -`InitializeDestination` | Specifies.overrides the initialization of the destination properties as a result of a `Flatten` where the corresponding source property is `null`. - - -The `Map`, `Flatten` and `Expand` methods support `isSourceInitital` and `initializeDestination` parameters to enable specification more easily per property. - -The unit [`MapperTest`](../../../tests/CoreEx.Test/Framework/Mapping/MapperTest.cs) demonstrates usage; as does the _Beef_ [`EmployeeBaseData`](https://github.com/Avanade/Beef/blob/master/samples/MyEf.Hr/MyEf.Hr.Business/Data/Generated/EmployeeBaseData.cs) and [`TerminationDetailData`](https://github.com/Avanade/Beef/blob/master/samples/MyEf.Hr/MyEf.Hr.Business/Data/Generated/TerminationDetailData.cs) samples. - -
- -## Explicit implementation - -To explicity define the mapping logic the `OnMap` should be overridden, see example below: - -``` csharp -public class ProductMapper : Mapper -{ - protected override BackendProduct? OnMap(Product? s, BackendProduct? d, OperationTypes operationType) - { - if (s is null || d is null) - return d; - - d.Code = s.Id!; - d.Description = s.Name; - d.RetailPrice = s.Price; - return d; - } -} -``` - -
- -## Converters - -The [`IConverter`](./Converters/IConverter.cs) interface provides a standardized approach to value conversion. A number of [converters](./Converters) are provided for common conversion requirements. - -
- -## AutoMapper implementation - -[AutoMapper](https://github.com/AutoMapper/AutoMapper) is a popular .NET mapper; as such [CoreEx.AutoMapper](../../CoreEx.AutoMapper) is provided to implement. The underlying [`AutoMapperWrapper`](../../CoreEx.AutoMapper/AutoMapperWrapper.cs) enables. diff --git a/src/CoreEx/Metadata/IPropertyRuntimeMetadata.cs b/src/CoreEx/Metadata/IPropertyRuntimeMetadata.cs new file mode 100644 index 00000000..a4297af8 --- /dev/null +++ b/src/CoreEx/Metadata/IPropertyRuntimeMetadata.cs @@ -0,0 +1,103 @@ +namespace CoreEx.Metadata; + +/// +/// Enables the runtime metadata definition for a property within an entity. +/// +public interface IPropertyRuntimeMetadata +{ + /// + /// Gets the owning entity . + /// + Type Owner { get; } + + /// + /// Gets the property . + /// + Type Type { get; } + + /// + /// Gets the property name. + /// + string Name { get; } + + /// + /// Gets the property text. + /// + LText Text { get; } + + /// + /// Gets the JSON property name. + /// + string? JsonName { get; } + + /// + /// Gets the default value. + /// + object? DefaultValue { get; } + + /// + /// Gets the . + /// + CleanOption CleanOption { get; } + + /// + /// Indicates whether the property is read-only (i.e. does not have a setter). + /// + bool IsReadOnly { get; } + + /// + /// Gets the format string used when formatting the property value as a . + /// + /// See and composite formatting. + string? Format { get; } + + /// + /// Indicates whether the property value is considered in its default state. + /// + /// The entity value. + bool IsDefault(object entity); + + /// + /// Cleans the property value based on the . + /// + /// The entity value. + void Clean(object entity); + + /// + /// Gets the property value. + /// + /// The entity value. + /// The property value. + object? GetValue(object entity); + + /// + /// Get the property value. + /// + /// + /// The entity value. + /// The property value. + T GetValue(object entity); + + /// + /// Sets (overrides) the property value with the specified . + /// + /// The entity value. + /// The overriding property value. + void SetValue(object entity, object? value); + + /// + /// Sets (overrides) the property value with the specified . + /// + /// + /// The entity value. + /// The overriding property value. + void SetValue(object entity, T value); + + /// + /// Gets the JSON property name. + /// + /// The optional . + /// The JSON property name. + /// Uses the where not ; otherwise, uses the property passed through the optional . + string GetJsonName(JsonSerializerOptions? options = null); +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/IRuntimeMetadata.cs b/src/CoreEx/Metadata/IRuntimeMetadata.cs new file mode 100644 index 00000000..2c352bfe --- /dev/null +++ b/src/CoreEx/Metadata/IRuntimeMetadata.cs @@ -0,0 +1,13 @@ +namespace CoreEx.Metadata; + +/// +/// Enables access to the for each property via the static . +/// +/// See also . +public interface IRuntimeMetadata : IRuntimeMetadataCore +{ + /// + /// Gets the for each property. + /// + static abstract IEnumerable GetStaticPropertyRuntimeMetadata(); +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/IRuntimeMetadataCore.cs b/src/CoreEx/Metadata/IRuntimeMetadataCore.cs new file mode 100644 index 00000000..79e3fe8a --- /dev/null +++ b/src/CoreEx/Metadata/IRuntimeMetadataCore.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Metadata; + +/// +/// Enables access to the for each property. +/// +public interface IRuntimeMetadataCore +{ + /// + /// Gets the for each property. + /// + IEnumerable GetPropertyRuntimeMetadata(); +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/PropertyRuntimeMetadata.cs b/src/CoreEx/Metadata/PropertyRuntimeMetadata.cs new file mode 100644 index 00000000..a0db149a --- /dev/null +++ b/src/CoreEx/Metadata/PropertyRuntimeMetadata.cs @@ -0,0 +1,136 @@ +namespace CoreEx.Metadata; + +/// +/// Provides the runtime metadata definition for a property within an entity. +/// +/// The owning entity . +/// The property . +/// The property name. +/// The function to get the value. +/// The action to set the value. +/// The optional . +/// The optional default value. +/// The used for cleaning. +/// The optional explicit JSON name. +/// The optional format string. +/// The underlying implementation does not store mutable state for an entity property; therefore, an instance can be cached and reused where applicable to improve performance, etc. +public readonly struct PropertyRuntimeMetadata(string name, Func getValue, Action? setValue = null, Func? text = null, TProperty defaultValue = default!, CleanOption clean = CleanOption.UseDefault, string? jsonName = null, string? format = null) : IPropertyRuntimeMetadata +{ + private static readonly string? _nullObject = null; + + private readonly Func _getValue = getValue.ThrowIfNull(); + private readonly Action? _setValue = setValue; + private readonly Lazy _text = new(() => text?.Invoke() ?? new LText(name, name.ToSentenceCase())); + + /// + /// Initializes a new instance of the struct. + /// + /// The parameterless constructor is not supported; and as such, throws a . + [Obsolete("Parameterless constructor is not supported.", true)] + public PropertyRuntimeMetadata() : this(_nullObject ?? throw new NotSupportedException("The parameterless constructor is not supported."), null!) { } + + /// + public Type Owner => typeof(TEntity); + + /// + public Type Type => typeof(TProperty); + + /// + public string Name { get; } = name.ThrowIfNullOrEmpty(); + + /// + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + public LText Text => _text.Value; + + /// + public string? JsonName { get; } = jsonName; + + /// + object? IPropertyRuntimeMetadata.DefaultValue => DefaultValue; + + /// + /// Gets the default value. + /// + public TProperty? DefaultValue { get; } = defaultValue; + + /// + public CleanOption CleanOption { get; } = clean; + + /// + public bool IsReadOnly => _setValue is null; + + /// + public string? Format { get; } = format; + + /// + bool IPropertyRuntimeMetadata.IsDefault(object entity) => IsDefault((TEntity)entity.ThrowIfNull()); + + /// + /// Indicates whether the property value is considered in its default state. + /// + /// The entity value. + public bool IsDefault(TEntity entity) => RuntimeMetadata.IsDefault(GetValue(entity), DefaultValue); + + /// + void IPropertyRuntimeMetadata.Clean(object entity) + { + if (entity is not null) + Clean((TEntity)entity); + } + + /// + /// Cleans the property. + /// + /// The entity value. + public void Clean(TEntity entity) + { + if (entity is null) + return; + + var clean = CleanOption == CleanOption.UseDefault ? Cleaner.DefaultCleanOption : CleanOption; + if (clean == CleanOption.None) + return; + + var val = Cleaner.Clean(GetValue(entity)); + if (IsReadOnly) + return; + + if (clean == CleanOption.CleanAndDefault && RuntimeMetadata.AreEqual(val, DefaultValue)) + SetValue(entity, DefaultValue!); + } + + /// + object? IPropertyRuntimeMetadata.GetValue(object entity) => GetValue((TEntity)entity); + + /// + T IPropertyRuntimeMetadata.GetValue(object entity) => Internal.Cast(GetValue((TEntity)entity)); + + /// + /// Gets the property value. + /// + public TProperty GetValue(TEntity entity) => _getValue(entity.ThrowIfNull()); + + /// + void IPropertyRuntimeMetadata.SetValue(object entity, object? value) => SetValue((TEntity)entity.ThrowIfNull(), (TProperty)value!); + + /// + void IPropertyRuntimeMetadata.SetValue(object entity, T value) => SetValue((TEntity)entity, Internal.Cast(value)); + + /// + /// Sets (overrides) the property value. + /// + /// The entity value. + /// The property value. + public void SetValue(TEntity entity, TProperty value) + { + if (_setValue is null) + throw new InvalidOperationException($"Property '{Name}' is read-only and cannot be set."); + + _setValue(entity.ThrowIfNull(), value); + } + + /// + public string GetJsonName(JsonSerializerOptions? options = null) => JsonName is not null + ? JsonName + : (options ?? JsonDefaults.SerializerOptions).PropertyNamingPolicy?.ConvertName(Name) ?? Name; +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/PropertyRuntimeMetadataReflector.cs b/src/CoreEx/Metadata/PropertyRuntimeMetadataReflector.cs new file mode 100644 index 00000000..67ea8469 --- /dev/null +++ b/src/CoreEx/Metadata/PropertyRuntimeMetadataReflector.cs @@ -0,0 +1,36 @@ +namespace CoreEx.Metadata; + +/// +/// Provides functionality to create runtime metadata using reflection. +/// +internal class PropertyRuntimeMetadataReflector +{ + /// + /// Creates the from the supplied . + /// + /// The corresponding . + /// The . + public static IPropertyRuntimeMetadata CreatePropertyRuntimeMetadata(PropertyInfo pi) where TEntity : class + { + pi.ThrowIfNull(); + + Func getValue = (Func)Delegate.CreateDelegate(typeof(Func), null, pi.GetGetMethod()!); + + var mi = pi.GetSetMethod(); + Action? setValue = mi is null ? null : (Action)Delegate.CreateDelegate(typeof(Action), null, mi); + + var text = pi.GetCustomAttribute()?.ToLText(); + if (text is null) + { + var dn = pi.GetCustomAttribute()?.GetName(); + if (!string.IsNullOrEmpty(dn)) + text = new LText(pi.Name, dn); + } + + Func? textFunc = text.HasValue ? () => text.Value : null; + + return new PropertyRuntimeMetadata(pi.Name, getValue, setValue, textFunc, + jsonName: pi.GetCustomAttribute()?.Name, + format: pi.GetCustomAttribute()?.DataFormatString); + } +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs b/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs new file mode 100644 index 00000000..2b33e669 --- /dev/null +++ b/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs @@ -0,0 +1,145 @@ +namespace CoreEx.Metadata; + +public static partial class RuntimeMetadata +{ + /// + /// Compare two values for equality. + /// + /// The value . + /// The left-side value. + /// The right-side value. + /// indicates they are equal; otherwise, . + /// This improves upon the standard which for a generally only performs a reference equality. The following additional checks + /// are performed: comparison, comparison, per item and comparisons, + /// item comparisons, and nested and comparisons. This is to achieve a best attempt deep-equals where a contract-style + /// class (such as a ) constrains itself to simple and known types such as those described above, and/or overrides accordingly. + public static bool AreEqual(T? left, T? right) + { + if (ReferenceEquals(left, right)) return true; + if (left is null || right is null) return false; + + // Fast-path string comparison. + if (left is string ls && right is string rs) + return ls == rs; + + // Where metadata, then matchy-matchy each property one-by-one. + if (left is IRuntimeMetadataCore lrm) + { + var epl = lrm.GetPropertyRuntimeMetadata().GetEnumerator(); + var epr = ((IRuntimeMetadataCore)right).GetPropertyRuntimeMetadata().GetEnumerator(); + while (epl.MoveNext()) + { + if (!epr.MoveNext() || !AreEqual(epl.Current.GetValue(left), epr.Current.GetValue(right))) + return false; + } + + return true; + } + + // Fast-path explicit equality implementation. + if (left is IEquatable leq) + return leq.Equals(right); + + // Short circuit arrays, collections, lists, and dictionaries based on count difference. + if (left is ICollection lc) + { + if (lc.Count != ((ICollection)right).Count) + return false; + } + + // Per item-based comparisons. + if (left is IDictionary ld) + return IDictionaryAreEqual(ld, (IDictionary)right); + + if (left is IEnumerable le) + return IEnumerableAreEqual(le, (IEnumerable)right); + + // Special handling for JsonElement comparison. + if (left is JsonElement lje && right is JsonElement rje) +#if NET8_0 + return CoreEx.Json.JsonMergePatch.DeepEquals(lje, rje); +#else + return JsonElement.DeepEquals(lje, rje); +#endif + + // Default value type comparison. + var type = left.GetType(); + if (type.IsValueType) + return Equals(left, right); + + // Must be a class so use reflection-based runtime-metadata to compare each property. + foreach (var p in GetCachedProperties(type).Values) + { + if (!AreEqual(p.GetValue(left), p.GetValue(right))) + return false; + } + + // Well, if we got this far, then they must be equal - good job (https://www.youtube.com/watch?v=BSmliwh7D30). + return true; + } + + /// + /// Perform an equality comparison of two values. + /// + private static bool IEnumerableAreEqual(IEnumerable left, IEnumerable right) + { + // Slow path, possibly with boxing + static bool EnumerateObjectAreEqual(IEnumerable l, IEnumerable r) + { + // Determine the element type and use cached dispatcher where possible. + var elementType = GetEnumerableElementType(l); + if (elementType is not null && elementType.IsValueType) + { + var dispatcher = GetEnumerableDispatcher(elementType); + return dispatcher(l, r); + } + + // Fallback to object comparison; which is ok where there is no boxing involved. + var el = l.GetEnumerator(); + var er = r.GetEnumerator(); + while (el.MoveNext()) + { + if (!(er.MoveNext() && AreEqual(el.Current, er.Current))) + return false; + } + + return true; + } + + return (left, right) switch + { + // Fast paths for common/hot types. + (IEnumerable ls, IEnumerable rs) => TypedEnumerateAreEqual(ls, rs), + (IEnumerable lg, IEnumerable rg) => TypedEnumerateAreEqual(lg, rg), + (IEnumerable lgn, IEnumerable rgn) => TypedEnumerateAreEqual(lgn, rgn), + (IEnumerable li, IEnumerable ri) => TypedEnumerateAreEqual(li, ri), + (IEnumerable lin, IEnumerable rin) => TypedEnumerateAreEqual(lin, rin), + (IEnumerable ll, IEnumerable rl) => TypedEnumerateAreEqual(ll, rl), + (IEnumerable ll, IEnumerable rl) => TypedEnumerateAreEqual(ll, rl), + (IEnumerable ld, IEnumerable rd) => TypedEnumerateAreEqual(ld, rd), + (IEnumerable ldg, IEnumerable rdg) => TypedEnumerateAreEqual(ldg, rdg), + + // Use cached dispatcher for other types. + _ => EnumerateObjectAreEqual(left, right) + }; + } + + /// + /// Perform an equality comparison of two values. + /// + private static bool IDictionaryAreEqual(IDictionary left, IDictionary right) + { + var el = left.GetEnumerator(); + + while (el.MoveNext()) + { + if (!right.Contains(el.Key)) + return false; + + if (!AreEqual(el.Value, right[el.Key])) + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs b/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs new file mode 100644 index 00000000..c2027720 --- /dev/null +++ b/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs @@ -0,0 +1,82 @@ +namespace CoreEx.Metadata; + +public static partial class RuntimeMetadata +{ + /// + /// Cleans (deep) the mutable properties of the . + /// + /// The value . + /// The value. + /// The cleaned . + /// This will walk the fully object graph, including arrays, collections, and dictionaries cleaning all mutable properties. Note that where the entry for an array, collection, or dictionary is a value type + /// this is unable to be cleaned/replaced. An empty array, collection, or dictionary will be set to . + public static T? Clean(T value) + { + if (value is string str) + return Internal.Cast(Cleaner.Clean(str, Cleaner.DefaultStringTrim, Cleaner.DefaultStringTransform, Cleaner.DefaultStringCase)!); + + if (value is null) + return value; + + if (value is DateTime dt) + return Internal.Cast(Cleaner.Clean(dt, Cleaner.DefaultDateTimeTransform)); + + if (value is IRuntimeMetadataCore rm) + { + foreach (var p in rm.GetPropertyRuntimeMetadata().Where(x => !x.IsReadOnly)) + { + p.Clean(value); + } + + return RuntimeMetadata.IsDefault(value) ? default : value; + } + + // Zero-length collections are nulled out. + if (value is ICollection ic && ic.Count == 0) + return default; + + // Clean each dictionary item (does not replace/null entry, only contents thereof); key remains unchanged. + if (value is IDictionary d) + { + foreach (DictionaryEntry de in d) + { + Clean(de.Value); + } + + return value; + } + + // Clean each enumerable item (does not replace/null entry, only contents thereof). + if (value is IEnumerable e) + { + // Fast-path common/hot types to avoid boxing - can't clean anyway! + if (value is ICollection || value is ICollection || value is ICollection + || value is ICollection || value is ICollection || value is ICollection || value is ICollection) + return value; + + // Get the element type to determine if boxing will occur and bail if so - can't clean anyway! + var elementType = GetEnumerableElementType(value); + if (elementType is not null && elementType.IsValueType) + return value; + + foreach (var item in e) + { + Clean(item); + } + + return value; + } + + // Handle value or class types. + var type = value.GetType(); + if (type.IsValueType) + return Cleaner.Clean(value); + + foreach (var p in GetCachedProperties(value.GetType()).Values.Where(x => x.IsReadOnly)) + { + p.Clean(value); + } + + return RuntimeMetadata.IsDefault(value) ? default : value; + } +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/RuntimeMetadata.CopyInto.cs b/src/CoreEx/Metadata/RuntimeMetadata.CopyInto.cs new file mode 100644 index 00000000..7b6bab9e --- /dev/null +++ b/src/CoreEx/Metadata/RuntimeMetadata.CopyInto.cs @@ -0,0 +1,139 @@ +namespace CoreEx.Metadata; + +public static partial class RuntimeMetadata +{ + /// + /// Copies (shallow) a value another value where they share mutable properties. + /// + /// The from . + /// The into . + /// The from value. + /// The into value. + /// Only mutable (set-based) properties will be copied; i.e. read-only (get-based) properties will remain unchanged. This performs a shallow copy only, in that only the root property values are copied (replace existing). Where + /// these values are complex types (i.e. class types) these are not further copied (cloned); they are updated by reference. + /// This method ignores and values, and the value must be the same type or is same assignable/subclass as the value. + /// In these instances no copying can or will be performed; i.e. is a no-op. + /// This will leverage either the underlying implementation or reflection () depending on the types. + public static void CopyInto(TFrom from, TInto into) where TFrom : class where TInto : class + { + from.ThrowIfNull(); + into.ThrowIfNull(); + + if (from is string || into is string || from is ICollection || into is ICollection) + return; + + if (!(from is TInto || into is TFrom)) + return; + + var dict = into is IRuntimeMetadataCore irm + ? irm.GetPropertyRuntimeMetadata().ToDictionary(p => p.Name) + : GetCachedProperties(); + + if (from is IRuntimeMetadataCore frm) + { + foreach (var fp in frm.GetPropertyRuntimeMetadata().Where(p => !p.IsReadOnly)) + { + if (dict.TryGetValue(fp.Name, out var im) && !im.IsReadOnly && fp.Type == im.Type) + { + im.SetValue(into, fp.GetValue(from)); + } + } + } + else + { + foreach (var fp in GetCachedProperties().Where(p => !p.Value.IsReadOnly)) + { + if (dict.TryGetValue(fp.Value.Name, out var im) && !im.IsReadOnly && fp.Value.Type == im.Type) + { + im.SetValue(into, fp.Value.GetValue(from)); + } + } + } + } + + /// + /// Copies (shallow) a value another value where they share mutable properties and returns a value indicating whether changes where made. + /// + /// The from . + /// The into . + /// The from value. + /// The into value. + /// true where changes were made; otherwise, false. + /// Only mutable (set-based) properties will be copied; i.e. read-only (get-based) properties will remain unchanged. This performs a shallow copy only, in that only the root property values are copied (replace existing). Where + /// these values are complex types (i.e. class types) these are not further copied (cloned); they are updated by reference. + /// This method ignores and values, and the value must be the same type or is same assignable/subclass as the value. + /// In these instances no copying can or will be performed; i.e. is a no-op. + /// This will leverage either the underlying implementation or reflection () depending on the types. + public static bool TryCopyInto(TFrom from, TInto into) where TFrom : class where TInto : class + { + from.ThrowIfNull(); + into.ThrowIfNull(); + + if (from is string || into is string || from is ICollection || into is ICollection) + return false; + + if (!(from is TInto || into is TFrom)) + return false; + + var changed = false; + var dict = into is IRuntimeMetadataCore irm + ? irm.GetPropertyRuntimeMetadata().ToDictionary(p => p.Name) + : GetCachedProperties(); + + if (from is IRuntimeMetadataCore frm) + { + foreach (var fp in frm.GetPropertyRuntimeMetadata().Where(p => !p.IsReadOnly)) + { + if (dict.TryGetValue(fp.Name, out var im) && !im.IsReadOnly && fp.Type == im.Type) + { + var gv = fp.GetValue(from); + if (gv is not string && gv is ICollection) + continue; + + if (AreEqual(im.GetValue(into), gv)) + continue; + + im.SetValue(into, gv); + changed = true; + } + } + } + else + { + foreach (var fp in GetCachedProperties().Where(p => !p.Value.IsReadOnly)) + { + if (dict.TryGetValue(fp.Value.Name, out var im) && !im.IsReadOnly && fp.Value.Type == im.Type) + { + var gv = fp.Value.GetValue(from); + if (gv is not string && gv is ICollection) + continue; + + if (AreEqual(im.GetValue(into), gv)) + continue; + + im.SetValue(into, gv); + changed = true; + } + } + } + + return changed; + } + + /// + /// Creates a shallow copy (clone) of the specified . + /// + /// The value . + /// The source value. + /// The cloned . + /// The is the key enabler for this capability. + public static T Clone(T value) where T : class, new() + { + if (value.ThrowIfNull() is string str) + return Internal.Cast(str); + + var clone = new T(); + CopyInto(clone, value.ThrowIfNull()); + return clone; + } +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs b/src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs new file mode 100644 index 00000000..7d611151 --- /dev/null +++ b/src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs @@ -0,0 +1,71 @@ +namespace CoreEx.Metadata; + +public static partial class RuntimeMetadata +{ + /// + /// Gets the hash code for the . + /// + /// The value . + /// The value. + /// A 32-bit signed integer hash code. + public static int GetHashCode(T? value) + { + switch (value) + { + case null: + return 0; + + case string s: + return s.GetHashCode(); + + case IRuntimeMetadataCore rm: + { + var hash = new HashCode(); + foreach (var p in rm.GetPropertyRuntimeMetadata()) + { + hash.Add(GetHashCode(p.GetValue(value))); + } + + return hash.ToHashCode(); + } + + case IDictionary d: + { + var hash = new HashCode(); + var e = d.GetEnumerator(); + while (e.MoveNext()) + { + hash.Add(GetHashCode(e.Key)); + hash.Add(GetHashCode(e.Value)); + } + + return hash.ToHashCode(); + } + + case IEnumerable e: + { + var hash = new HashCode(); + foreach (var item in e) + hash.Add(GetHashCode(item)); + + return hash.ToHashCode(); + } + + default: + { + var type = value.GetType(); + if (type.IsValueType) + return value.GetHashCode(); + + // Must be a class so use reflection-based runtime-metadata. + var hash = new HashCode(); + foreach (var p in GetCachedProperties(type).Values) + { + hash.Add(GetHashCode(p.GetValue(value))); + } + + return hash.ToHashCode(); + } + } + } +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/RuntimeMetadata.Internal.cs b/src/CoreEx/Metadata/RuntimeMetadata.Internal.cs new file mode 100644 index 00000000..2bb2df28 --- /dev/null +++ b/src/CoreEx/Metadata/RuntimeMetadata.Internal.cs @@ -0,0 +1,76 @@ +namespace CoreEx.Metadata; + +public static partial class RuntimeMetadata +{ + /// + /// Gets the element type. + /// + private static Type? GetEnumerableElementType(object o) => GetEnumerableElementType(o.GetType()); + + /// + /// Gets the element type. + /// + private static Type? GetEnumerableElementType(Type type) + { + // Arrays are easy! + if (type.IsArray) + return type.GetElementType(); + + // Ok, now we need some reflection magic - must cache. + return Internal.MemoryCache.GetOrCreate($"RuntimeMetadata_ElementType_{type.FullName}", entry => + { + entry.SlidingExpiration = SlidingExpirationTimespan; + + // The type itself might be IEnumerable. + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + return type.GetGenericArguments()[0]; + + // Or it implements IEnumerable. + var enumIface = type.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + return enumIface?.GetGenericArguments()[0]; + }); + } + + /// + /// Gets (or creates) the cached delegate that compares two sequences of the specified element type for equality. + /// + private static Func GetEnumerableDispatcher(Type elementType) => Internal.MemoryCache.GetOrCreate>($"RuntimeMetadata_Dispatcher_{elementType.FullName}", entry => + { + // Create the generic: TypedEnumerateAreEqual(IEnumerable, IEnumerable) + var method = typeof(RuntimeMetadata).GetMethod(nameof(TypedEnumerateAreEqual), BindingFlags.NonPublic | BindingFlags.Static)!; + var generic = method.MakeGenericMethod(elementType); + + // Build a delegate: (object left, object right) => TypedEnumerateAreEqual((IEnumerable)left, (IEnumerable)right) + var leftParam = Expression.Parameter(typeof(object), "left"); + var rightParam = Expression.Parameter(typeof(object), "right"); + + var ienumerable = typeof(IEnumerable<>).MakeGenericType(elementType); + var leftCast = Expression.Convert(leftParam, ienumerable); + var rightCast = Expression.Convert(rightParam, ienumerable); + + var call = Expression.Call(generic, leftCast, rightCast); + var lambda = Expression.Lambda>(call, leftParam, rightParam); + + // Paid once, then cache. + entry.SlidingExpiration = SlidingExpirationTimespan; + return lambda.Compile(); + })!; + + /// + /// Perform an equality comparison of two typed values. + /// + private static bool TypedEnumerateAreEqual(IEnumerable l, IEnumerable r) + { + var el = l.GetEnumerator(); + var er = r.GetEnumerator(); + + while (el.MoveNext()) + { + if (!(er.MoveNext() && AreEqual(el.Current, er.Current))) + return false; + } + + return true; + } + +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs b/src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs new file mode 100644 index 00000000..914974f3 --- /dev/null +++ b/src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs @@ -0,0 +1,42 @@ +namespace CoreEx.Metadata; + +public static partial class RuntimeMetadata +{ + /// + /// Indicates whether the is in its default state. + /// + /// The value . + /// The value. + /// indicates that the value is considered default; otherwise, . + /// See also . + /// This will leverage either the underlying implementation or reflection () depending on the types. + public static bool IsDefault(T value) => IsDefault(value, default!); + + /// + /// Indicates whether the is in its default state. + /// + /// The value . + /// The value. + /// The default value (used where applicable). + /// indicates that the value is considered default; otherwise, . + internal static bool IsDefault(T value, T @default) + { + if (value == null) + return true; + + if (value is string str) + return AreEqual(str, Internal.Cast(@default)); + + if (value is IRuntimeMetadataCore rm) + return !rm.GetPropertyRuntimeMetadata().Any(x => !x.IsDefault(value)); + + if (value is ICollection ic && ic.Count == 0) + return true; + + var type = value.GetType(); + if (type.IsValueType) + return AreEqual(value, @default); + + return !GetPropertyRuntimeMetadata(value.GetType()).Any(x => !x.IsDefault(value)); + } +} \ No newline at end of file diff --git a/src/CoreEx/Metadata/RuntimeMetadata.cs b/src/CoreEx/Metadata/RuntimeMetadata.cs new file mode 100644 index 00000000..6fa521e2 --- /dev/null +++ b/src/CoreEx/Metadata/RuntimeMetadata.cs @@ -0,0 +1,154 @@ +namespace CoreEx.Metadata; + +/// +/// Provides the underlying capabilities. +/// +/// +/// The capabilities within are provided to primarily target common contract patterns in an CoreEx-opinionated manner. As such, there are limitations to the functionality included. +/// This provides both compile-based () and reflection-based implementations enabling a mix of usage enabling greater flexibility and consistency. +public static partial class RuntimeMetadata +{ + /// + /// Gets the underlying caching sliding expiration . + /// + /// Defaults to 30 minutes. + public static TimeSpan SlidingExpirationTimespan => Internal.GetConfigurationValue("CoreEx:Runtime:Metadata:SlidingExpirationTimespan", TimeSpan.FromMinutes(30)); + + /// + /// Gets the for the specified using . + /// + /// The entity . + /// The property . + /// The property expression. + /// The corresponding . + public static IPropertyRuntimeMetadata GetForExpression(Expression> propertyExpression) where TEntity : IContract + { + if (propertyExpression.ThrowIfNull().Body.NodeType != ExpressionType.MemberAccess) + throw new ArgumentException("Only member access expressions are supported.", nameof(propertyExpression)); + + var me = (MemberExpression)propertyExpression.Body; + if (GetCachedProperties().TryGetValue(me.Member.Name, out var m)) + return m; + + throw new InvalidOperationException("The underlying property metadata cannot be retrieved for the property expression."); + } + + /// + /// Gets the for the specified using reflection on a cache miss. + /// + /// The entity . + /// The property . + /// The property expression. + /// An unused parameter needed for the compiler to differentiate same named method. + /// The corresponding . +#pragma warning disable IDE0060 // Remove unused parameter; unused and needed to differentiate same named methods. + public static IPropertyRuntimeMetadata GetForExpression(Expression> propertyExpression, object? unused = null) where TEntity : class +#pragma warning restore IDE0060 + { + if (propertyExpression.ThrowIfNull().Body.NodeType != ExpressionType.MemberAccess) + throw new ArgumentException("Only member access expressions are supported.", nameof(propertyExpression)); + + var me = (MemberExpression)propertyExpression.Body; + if (GetCachedProperties().TryGetValue(me.Member.Name, out var m)) + return m; + + throw new InvalidOperationException("The underlying property metadata can not be retrieved for the property expression."); + } + + /// + /// Gets the cached dictionary for the entity using . + /// + /// The . + /// The cached dictionary. + public static IDictionary GetCachedProperties() where T : IContract + { + var cacheKey = $"RuntimeMetadata_{typeof(T).FullName}"; + + return Internal.MemoryCache.GetOrCreate>(cacheKey, entry => + { + var dict = new Dictionary(); + foreach (var p in GetPropertyRuntimeMetadata()) + dict[p.Name] = p; + + entry.SlidingExpiration = SlidingExpirationTimespan; + return dict; + })!; + } + + /// + /// Gets the cached dictionary for the entity using reflection on a cache miss. + /// + /// The entity . + /// The list of properties to ignore. + /// This implementation leverages System.Reflection; as such, consider implementing type as and use where performance is a premium. + public static IDictionary GetCachedProperties(params IEnumerable ignoreProperties) => GetCachedProperties(typeof(T), ignoreProperties); + + /// + /// Gets the cached dictionary for the entity using reflection on a cache miss. + /// + /// The . + /// The list of properties to ignore. + /// This implementation leverages System.Reflection; as such, consider implementing type as and use where performance is a premium. + public static IDictionary GetCachedProperties(Type type, params IEnumerable ignoreProperties) + { + var cacheKey = $"RuntimeMetadata_{type.FullName}"; + + if (type.IsValueType) + return new Dictionary(); + + var dict = Internal.MemoryCache.GetOrCreate>(cacheKey, entry => + { + var dict = new Dictionary(); + foreach (var p in GetPropertyRuntimeMetadata(type)) + dict[p.Name] = p; + + entry.SlidingExpiration = SlidingExpirationTimespan; + return dict; + })!; + + return ignoreProperties.Any() ? dict.Where(kvp => !ignoreProperties.Contains(kvp.Key)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value) : dict; + } + + /// + /// Gets the for each property of the entity using . + /// + /// The . + /// Also, consider the equivalent where applicable. Note, the types do not require reflection. + public static IEnumerable GetPropertyRuntimeMetadata() where T : IContract + { + foreach (var p in T.GetStaticPropertyRuntimeMetadata()) + yield return p; + } + + /// + /// Gets the for each property of the entity using reflection. + /// + /// The entity . + /// The list of properties to ignore. + /// This implementation leverages System.Reflection; as such, consider implementing type as and use where performance is a premium. + /// This method bypasses the internal cache; therefore, consider to minimize repeated reflection costs. + public static IEnumerable GetPropertyRuntimeMetadata(params IEnumerable ignoreProperties) => GetPropertyRuntimeMetadata(typeof(T), ignoreProperties); + + /// + /// Gets the for each property of the entity using reflection. + /// + /// The entity + /// The list of properties to ignore. + /// This implementation leverages System.Reflection; as such, consider implementing type as and use where performance is a premium. + /// This method bypasses the internal cache; therefore, consider to minimize repeated reflection costs. + public static IEnumerable GetPropertyRuntimeMetadata(Type type, params IEnumerable ignoreProperties) + { + if (!type.ThrowIfNull().IsClass || type == typeof(string)) + throw new ArgumentException($"The type '{type.FullName}' must be a class.", nameof(type)); + + foreach (var pi in type.GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(p => p.GetMethod is not null && p.GetMethod.IsPublic)) + { + if (ignoreProperties.Contains(pi.Name)) + continue; + + var method = typeof(PropertyRuntimeMetadataReflector).GetMethod(nameof(PropertyRuntimeMetadataReflector.CreatePropertyRuntimeMetadata), BindingFlags.Static | BindingFlags.Public)!; + var genericMethod = method.MakeGenericMethod(type, pi.PropertyType); + yield return (IPropertyRuntimeMetadata)genericMethod.Invoke(null, [pi])!; + } + } +} \ No newline at end of file diff --git a/src/CoreEx/NotFoundException.cs b/src/CoreEx/NotFoundException.cs index ffa45c3f..c43c0e16 100644 --- a/src/CoreEx/NotFoundException.cs +++ b/src/CoreEx/NotFoundException.cs @@ -1,72 +1,47 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Localization; -using System; -using System.Net; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents a Not Found exception. +/// +/// The defaults to: Requested data was not found. +/// The error message. +/// The inner . +public class NotFoundException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(NotFoundException).FullName, _message), innerException) { + private const string _message = "Requested data was not found."; + /// - /// Represents a Not Found exception. + /// Initializes a new instance of the class. /// - /// The defaults to: Requested data was not found. - public class NotFoundException : Exception, IExtendedException - { - private const string _message = "Requested data was not found."; - private static bool? _shouldExceptionBeLogged; - - /// - /// Get or sets the value. - /// - public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } - - /// - /// Initializes a new instance of the class. - /// - public NotFoundException() : this(null!) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - public NotFoundException(string? message) : base(message ?? new LText(typeof(NotFoundException).FullName, _message)) { } - - /// - /// Initializes a new instance of the class using the specified and . - /// - /// The error message. - /// The inner . - public NotFoundException(string? message, Exception innerException) : base(message ?? new LText(typeof(NotFoundException).FullName, _message), innerException) { } + public NotFoundException() : this(null) { } - /// - /// - /// - /// The value as a . - public string ErrorType => Abstractions.ErrorType.NotFoundError.ToString(); - - /// - /// - /// - /// The value as a . - public int ErrorCode => (int)Abstractions.ErrorType.NotFoundError; + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public NotFoundException(LText? message) : this(message, null) { } - /// - /// - /// - /// The value. - public HttpStatusCode StatusCode => HttpStatusCode.NotFound; + /// + protected override void OnInitialize() + { + ErrorType = "not-found"; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.NotFound); + } - /// - /// - /// - /// false; is not considered transient. - public bool IsTransient => false; + /// + /// Throws a if the is . + /// + /// The . + /// The value to validate as non-default. + /// The optional message . + /// The to support fluent-style method-chaining. + [return: NotNull] + public static T? ThrowIfDefault([NotNull] T? value, LText? message = null) + { + if (value is null || EqualityComparer.Default.Equals(value, default!)) + throw new NotFoundException(message); - /// - /// - /// - /// The value. - public bool ShouldBeLogged => ShouldExceptionBeLogged; + return value; } } \ No newline at end of file diff --git a/src/CoreEx/OperationType.cs b/src/CoreEx/OperationType.cs index 89550fea..62f1d0cf 100644 --- a/src/CoreEx/OperationType.cs +++ b/src/CoreEx/OperationType.cs @@ -1,36 +1,37 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx; -namespace CoreEx +/// +/// Represents the CRUD operation types (Create, Read, Update and Delete). +/// +public enum OperationType { /// - /// Represents the possible operations types. + /// An Unspecified operation. /// - /// Based on the standard CRUD operations: , , and . - public enum OperationType - { - /// - /// An unknown/unspecified operation type. - /// - Unspecified, + Unspecified = 0, - /// - /// A create operation. - /// - Create, + /// + /// A Get (keyed) operation. + /// + Get = 1, - /// - /// A read operation. - /// - Read, + /// + /// A Create operation. + /// + Create = 2, - /// - /// An update operation. - /// - Update, + /// + /// An Update operation. + /// + Update = 4, - /// - /// A delete operation. - /// - Delete - } + /// + /// A Delete operation. + /// + Delete = 8, + + /// + /// A Query operation (as distinct from a ). + /// + Query = 16, } \ No newline at end of file diff --git a/src/CoreEx/README.md b/src/CoreEx/README.md deleted file mode 100644 index f974b47b..00000000 --- a/src/CoreEx/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# CoreEx - -The `CoreEx` namespace provides the key root level capabilities. However, the majority of the capabilities are housed in their own respective namespaces. - -
- -## Motivation - -The motivation for _CoreEx_ is to primarily identify key back-end business services patterns and provide additional capabilities to standardize and simplify the development of these. The intent is that _CoreEx_ is less opinionated about usage and enables opt-in where benefits can be derived. As well as being able to co-exist within a solution that leverages other frameworks, etc. - -
- -## Namespaces - -The following key namespaces are provided; additional documentation is provided for each via their respective links: - -Namespace | Description --|- -[`Abstractions`](./Abstractions) | Provides key abstractions or other largely internal capabilities. -[`Caching`](./Caching) | Provides addition caching capabilities. -[`Configuration`](./Configuration) | Extends [`IConfiguration`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.configuration.iconfiguration) to enable a more flexible means to get and override configuration values. -[`Entities`](./Entities) | Provides standardized and enriched capabilities for entities and data models. -[`Events`](./Events) | Provides standardized and enriched capabilities for event (message) declaration, publishing and subscribing. -[`Globalization`](./Globalization) | Provides extended globalization capabilities. -[`HealthChecks`](./HealthChecks) | Provides extended health checks capabilities. -[`Hosting`](./Hosting) | Provides extended [hosted service (worker)](https://learn.microsoft.com/en-us/dotnet/core/extensions/workers) capabilities. -[`Http`](./Http) | Provides extended [`HttpClient`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient) capabilities. -[`Invokers`](./Invokers) | Provides extended invocation capabilities. -[`Json`](./Json) | Provides implementation agnostic [JSON](https://en.wikipedia.org/wiki/JSON)-related capabilities. -[`Localization`](./Localization) | Provided extended localization capabilities. -[`Mapping`](./Mapping) | Provides implementation agnostic mapping capabilities. -[`RefData`](./RefData) | Provides standardized and enriched capabilities for reference data. -[`Results`](./Results) | Provides [monadic](https://en.wikipedia.org/wiki/Monad_(functional_programming)) error-handling, often referred to as [Railway-oriented programming](https://swlaschin.gitbooks.io/fsharpforfunandprofit/content/posts/recipe-part2.html) via `Result` and `Result` types. -[`Security`](./Security) | Provides extended security capabilities. -[`Serialization`](./Serialization) | Provides implementation agnostic serialization capabilities. -[`Text.Json`](./Text/Json) | Provides [`System.Text.Json`](https://docs.microsoft.com/en-us/dotnet/api/system.text.json) implementation of the [`IJsonSerializer`](./Json/IJsonSerializer.cs). -[`Validation`](./Validation) | Provides implementation agnostic validation capabilities. -[`Wildcards`](./Wildcards) | Provides standardized approach to parsing and validating [`Wildcard`](./Wildcards/Wildcard.cs) text.` - -
- -## Execution context - -The [`ExecutionContext`](./ExecutionContext.cs) is a foundational class that is integral to the underlying execution within _CoreEx_. It represents a thread-bound (request) execution context - enabling the availability of the likes of `Username` at at runtime via `ExecutionContext.Current`. Additionally, the context is passed between executing threads for the owning request (see [`AsyncLocal`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.asynclocal-1)). - -An implementor may choose to inherit from this class and add additional capabilities as required. - -
- -## Exceptions - -There are a number of key exceptions that have a specific built in behaviour; these all implement [`IExtendedException`](./Abstractions/IExtendedException.cs). - -Exception | Description | HTTP Status | [`ErrorType`](./Abstractions/ErrorType.cs) --|-|-|- -[`AuthenticationException`](./AuthenticationException.cs) | Represents an **Authentication** exception. | 401 Unauthorized | 8 AuthenticationError -[`AuthorizationException`](./AuthorizationException.cs) | Represents an **Authorization** exception. | 403 Forbidden | 3 AuthorizationError -[`BusinessException`](./BusinessException.cs) | Represents a **Business** exception whereby the message returned should be displayed directly to the consumer. | 400 BadRequest | 2 BusinessError -[`ConcurrencyException`](./ConcurrencyException.cs) | Represents a data **Concurrency** exception; generally as a result of an errant [ETag](./Entities/IETag.cs). | 412 PreconditionFailed | 4 ConcurrencyError -[`ConflictException`](./ConflictException.cs) | Represents a data **Conflict** exception; for example creating an entity that already exists. | 409 Conflict | 6 ConflictError -[`DataConsistencyException`](./DataConsistencyException.cs) | Represents a **Data Consistency** exception; for example where the operation would result in data consistency error; i.e. possible data corruption may occur. | 409 Conflict | 10 DataConsistencyError -[`DuplicateException`](./DuplicateException.cs) | Represents a **Duplicate** exception; for example updating a code on an entity where the value is already used. | 409 Conflict | 7 DuplicateError -[`NotFoundException`](./NotFoundException.cs) | Represents a **NotFound** exception; for example getting an entity that does not exist. | 404 NotFound | 5 NotFoundError -[`TransientException`](./TransientException.cs) | Represents a **Transient** exception; failed but is a candidate for a retry. | 503 ServiceUnavailable | 9 TransientError -[`ValidationException`](./ValidationException.cs) | Represents a **Validation** exception with a corresponding `Messages` [collection](./Entities/MessageItemCollection.cs). | 400 BadRequest | 1 ValidationError diff --git a/src/CoreEx/RefData/Abstractions/IReferenceData.cs b/src/CoreEx/RefData/Abstractions/IReferenceData.cs new file mode 100644 index 00000000..be6cb684 --- /dev/null +++ b/src/CoreEx/RefData/Abstractions/IReferenceData.cs @@ -0,0 +1,110 @@ +namespace CoreEx.RefData.Abstractions; + +/// +/// Enables the core Reference Data properties. +/// +/// A reference data instance should be considered largely immutable. +public interface IReferenceData : IReadOnlyIdentifier, IReadOnlyETag +{ + /// + /// Gets or initializes the unique identifier. + /// + new object? Id { get; init; } + + /// + /// Gets or initializes the unique code. + /// + string? Code { get; init; } + + /// + /// Gets or initializes the text. + /// + /// The get should return a localized text where possible; see also . + string? Text { get; init; } + + /// + /// Gets or initializes the description. + /// + /// The get should return a localized description where possible; see also . + string? Description { get; init; } + + /// + /// Gets or initializes the sort order. + /// + int SortOrder { get; init; } + + /// + /// Indicates whether the is inactive. + /// + /// where inactive; otherwise, is active. + bool IsInactive { get; init; } + + /// + /// Indicates whether the is active (opposite of ). + /// + [JsonIgnore] + bool IsActive { get; } + + /// + /// Gets or initializes the validity start . + /// + DateTimeOffset? StartsOn { get; init; } + + /// + /// Gets or initializes the validity end . + /// + DateTimeOffset? EndsOn { get; init; } + + /// + /// Indicates whether the is known and in a valid state (it may be active or inactive depending). + /// + /// This is typically a result of casting a to an that is not known by the . + [JsonIgnore] + bool IsValid { get; } + + /// + /// Overrides the standard check and flags the as Invalid. + /// + /// Will result in set to . Once set to invalid it can not be changed; i.e. there is not a means to set back to valid. + void SetInvalid(); + + /// + /// Gets the original (non-localized) value. + /// + string? GetText(); + + /// + /// Gets the original (non-localized) value. + /// + string? GetDescription(); + + /// + /// Indicates whether any mapping values have been configured. + /// + [JsonIgnore] + bool HasMappings { get; } + + /// + /// Gets the underlying mapping dictionary as read-only. + /// + /// The is intended to be the means in which the mappings are mutated. + IReadOnlyDictionary? Mappings { get; } + + /// + /// Sets the mapping for the specified . + /// + /// The value . + /// The mapping name. + /// The mapping value. + /// A with the default value will not be set; assumed in this case that no mapping exists. + void SetMapping(string name, T? value) where T : IComparable, IEquatable; + + /// + /// Gets the mapping for the specified . + /// + /// The value . + /// The mapping name. + /// The mapping value. + /// indicates that the name exists; otherwise, . + bool TryGetMapping(string name, [NotNullWhen(true)] out T? value) where T : IComparable, IEquatable; +} \ No newline at end of file diff --git a/src/CoreEx/RefData/Abstractions/IReferenceDataCollection.cs b/src/CoreEx/RefData/Abstractions/IReferenceDataCollection.cs new file mode 100644 index 00000000..84499dd5 --- /dev/null +++ b/src/CoreEx/RefData/Abstractions/IReferenceDataCollection.cs @@ -0,0 +1,111 @@ +namespace CoreEx.RefData.Abstractions; + +/// +/// Enables and functionality for an collection. +/// +public interface IReferenceDataCollection : ICollection +{ + /// + /// Gets the underlying item . + /// + [JsonIgnore] + Type ItemType { get; } + + /// + /// Adds the to the . + /// + /// The . + void Add(IReferenceData item); + + /// + /// Adds the of items to the . + /// + /// The collection containing the items to add. + void AddRange(IEnumerable collection); + + /// + /// Determines whether the specified exists within the collection. + /// + /// The . + /// if it exists; otherwise, . + bool ContainsId(object? id); + + /// + /// Determines whether the specified exists within the collection. + /// + /// The . + /// if it exists; otherwise, . + bool ContainsCode(string code); + + /// + /// Attempts to get the with the specified . + /// + /// The . + /// The corresponding item where found; otherwise, . + /// where found; otherwise, . + bool TryGetById(object? id, [NotNullWhen(true)] out IReferenceData? item); + + /// + /// Attempts to get the with the specified . + /// + /// The . + /// The corresponding item where found; otherwise, . + /// where found; otherwise, . + bool TryGetByCode(string code, [NotNullWhen(true)] out IReferenceData? item); + + /// + /// Gets the for the specified . + /// + /// The specified reference data . + /// The where found; otherwise, . + IReferenceData? GetById(object? id); + + /// + /// Gets the for the specified . + /// + /// The specified . + /// The where found; otherwise, . + IReferenceData? GetByCode(string code); + + /// + /// Determines whether the specified value exists within the collection. + /// + /// The mapping value . + /// The mapping name. + /// The mapping value. + /// if it exists; otherwise, . + bool ContainsMapping(string name, T value) where T : IComparable, IEquatable; + + /// + /// Attempts to get the with the specified value. + /// + /// The mapping value . + /// The mapping name. + /// The mapping value. + /// The corresponding item where found; otherwise, . + /// where found; otherwise, . + bool TryGetByMapping(string name, T value, [NotNullWhen(true)] out IReferenceData? item) where T : IComparable, IEquatable; + + /// + /// Gets the for the specified value. + /// + /// The mapping value . + /// The mapping name. + /// The mapping value. + /// The where found; otherwise, . + IReferenceData? GetByMapping(string name, T value) where T : IComparable, IEquatable; + + /// + /// Gets all items (excluding invalid only) sorted by the value. + /// + /// An containing the selected items. + [JsonIgnore] + IEnumerable AllItems { get; } + + /// + /// Gets all active (excluding ) items sorted by the value. + /// + /// An containing the selected items. + [JsonIgnore] + IEnumerable ActiveItems { get; } +} \ No newline at end of file diff --git a/src/CoreEx/RefData/Caching/FixedExpirationCacheEntry.cs b/src/CoreEx/RefData/Caching/FixedExpirationCacheEntry.cs deleted file mode 100644 index 31b8957f..00000000 --- a/src/CoreEx/RefData/Caching/FixedExpirationCacheEntry.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Caching.Memory; -using System; - -namespace CoreEx.RefData.Caching -{ - /// - /// Enables fixed expiration configuration capabilities. - /// - /// Provides a consistent fixed expiration for all cache entries. - /// The value. - /// The value. - public sealed class FixedExpirationCacheEntry(TimeSpan? absoluteExpirationRelativeToNow = null, TimeSpan? slidingExpiration = null) : ICacheEntryConfig - { - private readonly TimeSpan? _absoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow; - private readonly TimeSpan? _slidingExpiration = slidingExpiration; - - /// - /// Provides an opportunity to the maintain the data prior to the cache create function being invoked (as a result of ). - /// - /// The . - /// The . - public void CreateCacheEntry(Type type, ICacheEntry entry) - { - entry.AbsoluteExpirationRelativeToNow = _absoluteExpirationRelativeToNow; - entry.SlidingExpiration = _slidingExpiration; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/Caching/ICacheEntryConfig.cs b/src/CoreEx/RefData/Caching/ICacheEntryConfig.cs deleted file mode 100644 index e084a887..00000000 --- a/src/CoreEx/RefData/Caching/ICacheEntryConfig.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Caching.Memory; -using System; - -namespace CoreEx.RefData.Caching -{ - /// - /// Provides the configuration capabilities. - /// - public interface ICacheEntryConfig - { - /// - /// Gets the cache key to be used (defaults to ). - /// - /// The . - /// The cache key. - /// To support the likes of multi-tenancy caching then the resulting cache key should be overridden to include the both the and . - public object GetCacheKey(Type type) => type; - - /// - /// Provides an opportunity to the maintain the data prior to the cache create function being invoked (as a result of ). - /// - /// The . - /// The . - void CreateCacheEntry(Type type, ICacheEntry entry); - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/Caching/SettingsBasedCacheEntry.cs b/src/CoreEx/RefData/Caching/SettingsBasedCacheEntry.cs deleted file mode 100644 index 339559a8..00000000 --- a/src/CoreEx/RefData/Caching/SettingsBasedCacheEntry.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using Microsoft.Extensions.Caching.Memory; -using System; - -namespace CoreEx.RefData.Caching -{ - /// - /// Enables the -based configuration capabilities. - /// - /// See cache policy configuration for more information. - /// Where the has not been configured, then the default behaviour sets the following: = 2 hours, and = 30 minutes. - /// The . - public class SettingsBasedCacheEntry(SettingsBase? settings) : ICacheEntryConfig - { - /// - /// Gets the optional . - /// - public SettingsBase? Settings { get; } = settings; - - /// - /// Gets the cache key to be used (defaults to ). - /// - /// The . - /// The cache key. - /// To support the likes of multi-tenancy caching then the resulting cache key should be overridden to include the both the and . - public virtual object GetCacheKey(Type type) => type; - - /// - /// Provides an opportunity to the maintain the data prior to the cache create function being invoked (as a result of ). - /// - /// The . - /// The . - /// The default behaviour sets the following: = 2 hours, and = 30 minutes unless overidden by optional . - /// This should be overridden where more advanced behaviour is required. - public virtual void CreateCacheEntry(Type type, ICacheEntry entry) - { - entry.AbsoluteExpirationRelativeToNow = Settings?.GetCoreExValue($"RefDataCache:{type.Name}:{nameof(ICacheEntry.AbsoluteExpirationRelativeToNow)}", Settings.RefDataCacheAbsoluteExpirationRelativeToNow) ?? TimeSpan.FromHours(2); - entry.SlidingExpiration = Settings?.GetCoreExValue($"RefDataCache:{type.Name}:{nameof(ICacheEntry.SlidingExpiration)}", Settings.RefDataCacheSlidingExpiration) ?? TimeSpan.FromMinutes(30); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs b/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs deleted file mode 100644 index f1e13951..00000000 --- a/src/CoreEx/RefData/Extended/ReferenceDataBaseEx.cs +++ /dev/null @@ -1,309 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Entities.Extended; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text.Json.Serialization; - -namespace CoreEx.RefData.Extended -{ - /// - /// Represents the extended base implementation. - /// - /// The can only be of type , , and . - /// This implementation is fully-featured and is generally intended for usage within backend services. - [DebuggerDisplay("Id = {Id}, Code = {Code}, Text = {Text}, IsActive = {IsActive}")] - public class ReferenceDataBaseEx : EntityBase, IReferenceData where TId : IComparable, IEquatable where TSelf : ReferenceDataBaseEx, new() - { - private TId? _id; - private string? _code; - private string? _text; - private string? _description; - private int _sortOrder; - private bool _isActive = true; - private bool _isInvalid; - private DateTime? _startDate; - private DateTime? _endDate; - private string? _etag; - private MappingsDictionary? _mappings; - - /// - /// Initializes a new instance of the class. - /// - public ReferenceDataBaseEx() - { - if (_id != null && _id is not int && _id is not long && _id is not string && _id is not Guid) - throw new InvalidOperationException($"A Reference Data {nameof(Id)} can only be of type {nameof(Int32)}, {nameof(Int64)}, {nameof(String)} or {nameof(Guid)}."); - } - - /// - object? IIdentifier.Id { get => Id; set => Id = (TId)value!; } - - /// - public TId? Id { get => _id; set => SetValue(ref _id, value, immutable: true); } - - /// - public string? Code { get => _code; set => SetValue(ref _code, value, StringTrim.Both, immutable: true); } - - /// - public string? Text { get => _text; set => SetValue(ref _text, value); } - - /// - public string? Description { get => _description; set => SetValue(ref _description, value); } - - /// - public int SortOrder { get => _sortOrder; set => SetValue(ref _sortOrder, value); } - - /// - /// Note to classes that override: the base should be called as it verifies , and that the and are not outside of the - /// where configured (otherwise ). This is accessed via - /// where . The will always return false when not . - public virtual bool IsActive - { - get - { - if (!IsValid || !_isActive) - return false; - - if (StartDate != null || EndDate != null) - { - var date = Cleaner.Clean(ExecutionContext.HasCurrent ? ExecutionContext.Current.ReferenceDataContext[GetType()] : SystemTime.Timestamp, DateTimeTransform.DateOnly); - if (StartDate != null && date < StartDate) - return false; - - if (EndDate != null && date > EndDate) - return false; - } - - return _isActive; - } - - set => SetValue(ref _isActive, value); - } - - /// - public DateTime? StartDate { get => _startDate; set => SetValue(ref _startDate, value, DateTimeTransform.DateOnly); } - - /// - public DateTime? EndDate { get => _endDate; set => SetValue(ref _endDate, value, DateTimeTransform.DateOnly); } - - /// - public string? ETag { get => _etag; set => SetValue(ref _etag, value); } - - /// - [JsonIgnore] - public bool IsValid => !_isInvalid; - - /// - void IReferenceData.SetInvalid() => _isInvalid = true; - - /// - [JsonIgnore] - public bool HasMappings { get => _mappings != null && _mappings.Count > 0; } - - /// - Dictionary? IReferenceData.Mappings => _mappings; - - /// - public override string ToString() => Text ?? Code ?? Id?.ToString() ?? base.ToString(); - - /// - public void SetMapping(string name, T? value) where T : IComparable, IEquatable - { - if (Comparer.Default.Compare(value, default!) == 0) - return; - - if ((_mappings ??= new()).ContainsKey(name)) - throw new InvalidOperationException(EntityConsts.ValueIsImmutableMessage); - - if (IsReadOnly) - throw new InvalidOperationException(EntityConsts.EntityIsReadOnlyMessage); - - _mappings.Add(name, value); - } - - /// - public T? GetMapping(string name) where T : IComparable, IEquatable - { - if (!HasMappings || !_mappings!.TryGetValue(name, out var value)) - return default!; - - return (T?)value!; - } - - /// - public bool TryGetMapping(string name, [NotNullWhen(true)] out T? value) where T : IComparable, IEquatable - { - value = default!; - if (!HasMappings || !_mappings!.TryGetValue(name, out var val)) - return false; - - value = (T?)val!; - return true; - } - - /// - protected override IEnumerable GetPropertyValues() - { - yield return CreateProperty(nameof(Id), Id, v => Id = v); - yield return CreateProperty(nameof(Code), Code, v => Code = v); - yield return CreateProperty(nameof(Text), Text, v => Text = v); - yield return CreateProperty(nameof(Description), Description, v => Description = v); - yield return CreateProperty(nameof(SortOrder), SortOrder, v => SortOrder = v); - yield return CreateProperty(nameof(IsActive), IsActive, v => IsActive = v, true); - yield return CreateProperty(nameof(StartDate), StartDate, v => StartDate = v); - yield return CreateProperty(nameof(EndDate), EndDate, v => EndDate = v); - yield return CreateProperty(nameof(_mappings), _mappings, v => _mappings = v); - } - - /// - /// An implicit cast from a to an where the is of . - /// - /// The value. - public static implicit operator int(ReferenceDataBaseEx? item) => item != null && item.Id is int id ? id : 0; - - /// - /// An implicit cast from a to a where the is of . - /// - /// The value. - public static implicit operator int?(ReferenceDataBaseEx? item) => item != null && item.Id is int id ? id : null; - - /// - /// An implicit cast from a to a where the is of . - /// - /// The value. - public static implicit operator long(ReferenceDataBaseEx? item) => item != null && item.Id is long id ? id : 0; - - /// - /// An implicit cast from a to a where the is of . - /// - /// The value. - public static implicit operator long?(ReferenceDataBaseEx? item) => item != null && item.Id is long id ? id : null; - - /// - /// An implicit cast from a to a where the is of . - /// - /// The value. - public static implicit operator Guid(ReferenceDataBaseEx? item) => item != null && item.Id is Guid id ? id : Guid.Empty; - - /// - /// An implicit cast from a to a where the is of . - /// - /// The value. - public static implicit operator Guid?(ReferenceDataBaseEx? item) => item != null && item.Id is Guid id ? id : null; - - /// - /// An implicit cast from a to the . - /// - /// The value. - public static implicit operator string?(ReferenceDataBaseEx? item) => item?.Code; - - /// - /// Performs a conversion from an to an instance of . - /// - /// The . - /// The instance. - /// Where the item () is not found it will be created and will be invoked. - [return: NotNullIfNotNull(nameof(id))] - public static TSelf? ConvertFromId(TId? id) => ReferenceDataOrchestrator.ConvertFromId(id); - - /// - /// Performs a conversion from a to an instance of . - /// - /// The . - /// The instance. - /// Where the item () is not found it will be created and will be invoked. - [return: NotNullIfNotNull(nameof(code))] - public static TSelf? ConvertFromCode(string? code) => ReferenceDataOrchestrator.ConvertFromCode(code); - - /// - /// Performs a conversion from a mapping value to an instance of . - /// - /// The mapping value . - /// The mapping name. - /// The mapping value. - /// Where the item () is not found it will be created and will be invoked. - public static TSelf ConvertFromMapping(string name, T? value) where T : IComparable, IEquatable => ReferenceDataOrchestrator.ConvertFromMapping(name, value); - - /// - /// Gets the corresponding for the specified where is true. - /// - /// The . - /// The . - /// This is intended to be consumed by classes that wish to provide an opt-in serialization of corresponding . - public static string? GetRefDataText(string? code) => code != null && ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled ? ConvertFromCode(code)?.Text : null; - - /// - /// Gets the corresponding for the specified where is true. - /// - /// The . - /// The . - /// This is intended to be consumed by classes that wish to provide an opt-in serialization of corresponding . - public static string? GetRefDataText(object? id) => id != null && ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled ? ConvertFromId((TId)id)?.Text : null; - - #region MappingsDictionary - - /// - /// Provides the - /// - private class MappingsDictionary : Dictionary, IEquatable, IEntityBaseCollection - { - /// - public bool IsInitial => false; - - /// - public void CleanUp() { } - - /// - public object Clone() - { - var md = new MappingsDictionary(); - this.ForEach(item => md.Add(item.Key, item.Value)); - return md; - } - - /// - public bool Equals(ReferenceDataBaseEx.MappingsDictionary? other) - { - if (other == null || Count != other.Count) - return false; - - var el = ((IDictionary)this!).GetEnumerator(); - var er = ((IDictionary)other!).GetEnumerator(); - while (el.MoveNext()) - { - if (!er.MoveNext() || !el.Current.Key.Equals(er.Current.Key)) - return false; - - if (el.Current.Value == null && er.Current.Value == null) - continue; - - if (el.Current.Value != null && !el.Current.Value.Equals(er.Current.Value)) - return false; - - if (!er.Current.Value!.Equals(el.Current.Value)) - return false; - } - - return true; - } - - /// - public override bool Equals(object? obj) => Equals(obj! as ReferenceDataBaseEx.MappingsDictionary); - - /// - public override int GetHashCode() - { - var hash = new HashCode(); - this.ForEach(item => hash.Add(item.GetHashCode())); - return hash.ToHashCode(); - } - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/HealthChecks/ReferenceDataOrchestratorHealthCheck.cs b/src/CoreEx/RefData/HealthChecks/ReferenceDataOrchestratorHealthCheck.cs deleted file mode 100644 index fe5a3b81..00000000 --- a/src/CoreEx/RefData/HealthChecks/ReferenceDataOrchestratorHealthCheck.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.RefData.HealthChecks -{ - /// - /// Provides a to report the statistics. - /// - /// The . - public class ReferenceDataOrchestratorHealthCheck(ReferenceDataOrchestrator orchestrator) : IHealthCheck - { - private readonly ReferenceDataOrchestrator _orchestrator = orchestrator.ThrowIfNull(); - - /// - /// Will always return . - public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - var data = new Dictionary { { "types", _orchestrator.GetAllTypes().Count() } }; - -#if NET7_0_OR_GREATER - data.Add("statistics", _orchestrator.Cache.GetCurrentStatistics() ?? new()); -#endif - - return Task.FromResult(HealthCheckResult.Healthy(null, data)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/IReferenceData.cs b/src/CoreEx/RefData/IReferenceData.cs deleted file mode 100644 index 838da9d9..00000000 --- a/src/CoreEx/RefData/IReferenceData.cs +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.RefData.Extended; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace CoreEx.RefData -{ - /// - /// Provides the core Reference Data properties. - /// - public interface IReferenceData : IIdentifier, IETag - { - /// - /// Gets or sets the unique code. - /// - string? Code { get; set; } - - /// - /// Gets or sets the text. - /// - string? Text { get; set; } - - /// - /// Gets or sets the description. - /// - string? Description { get; set; } - - /// - /// Gets or sets the sort order. - /// - int SortOrder { get; set; } - - /// - /// Indicates whether the is active. - /// - /// true where active; otherwise, false. - bool IsActive { get; set; } - - /// - /// Gets of sets the validity start date. - /// - DateTime? StartDate { get; set; } - - /// - /// Gets of sets the validity end date. - /// - DateTime? EndDate { get; set; } - - /// - /// Indicates whether the is known and in a valid state. - /// - [JsonIgnore] - public bool IsValid => true; - - /// - /// Overrides the standard check and flags the as Invalid. - /// - /// Will result in set to false. Once set to invalid it can not be changed; i.e. there is not an means to set back to valid. - void SetInvalid() => throw new NotImplementedException("Either explicity override this functionality or leverage the ReferenceDataBaseEx class that enables."); - - /// - /// Gets the underlying mapping dictionary. - /// - /// The mapping dictionary property is intended for internal use only; generally speaking use , and - /// to access. - [JsonIgnore] - Dictionary? Mappings => null!; - - /// - /// Indicates whether any mapping values have been configured. - /// - [JsonIgnore] - public bool HasMappings => false; - - /// - /// Sets the mapping for the specified . - /// - /// The value . - /// The mapping name. - /// The mapping value. - /// A with the default value will not be set; assumed in this case that no mapping exists. - public void SetMapping(string name, T? value) where T : IComparable, IEquatable => throw new NotImplementedException("Either explicity override this functionality or leverage the ReferenceDataBaseEx class that enables."); - - /// - /// Gets a mapping value for the for the specified . - /// - /// The value . - /// The mapping name. - /// The mapping value where found; otherwise, the corresponding default value. - public T? GetMapping(string name) where T : IComparable, IEquatable => default!; - - /// - /// Gets a mapping value for the for the specified . - /// - /// The value . - /// The mapping name. - /// The mapping value. - /// true indicates that the name exists; otherwise, false. - public bool TryGetMapping(string name, [NotNullWhen(true)] out T? value) where T : IComparable, IEquatable { value = default!; return false; } - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/IReferenceDataCache.cs b/src/CoreEx/RefData/IReferenceDataCache.cs new file mode 100644 index 00000000..7b2d159d --- /dev/null +++ b/src/CoreEx/RefData/IReferenceDataCache.cs @@ -0,0 +1,16 @@ +namespace CoreEx.RefData; + +/// +/// Enables caching of reference data in a implementation-agnostic manner. +/// +public interface IReferenceDataCache +{ + /// + /// Gets the for the specified where it exists; otherwise, invokes the to create. + /// + /// The . + /// The factory to create where the is not in the cache. + /// The . + /// The from the cache. + Task GetOrCreateAsync(Type type, Func> factory, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/CoreEx/RefData/IReferenceDataCodeCollection.cs b/src/CoreEx/RefData/IReferenceDataCodeCollection.cs new file mode 100644 index 00000000..8abce386 --- /dev/null +++ b/src/CoreEx/RefData/IReferenceDataCodeCollection.cs @@ -0,0 +1,36 @@ +namespace CoreEx.RefData; + +/// +/// Enables a special purpose collection specifically for managing a referenced list of serialization identifiers, being the underlying . +/// +public interface IReferenceDataCodeCollection +{ + /// + /// Indicates whether the collection contains invalid items (i.e. not ). + /// + /// indicates that invalid items exist; otherwise, . + bool HasInvalidItems { get; } + + /// + /// Indicates whether the collection contains inactive items (i.e. ). + /// + /// indicates that inactive items exist; otherwise, . + bool HasInactiveItems { get; } + + /// + /// Gets the number of items in the underlying collection. + /// + int Count { get; } + + /// + /// Gets the underlying entries. + /// + /// The underlying entries. + IEnumerable ToRefDataList(); + + /// + /// Gets the underlying collection. + /// + /// The underlying entries as a . + List ToCodeList(); +} \ No newline at end of file diff --git a/src/CoreEx/RefData/IReferenceDataCodeList.cs b/src/CoreEx/RefData/IReferenceDataCodeList.cs deleted file mode 100644 index 8edcefe1..00000000 --- a/src/CoreEx/RefData/IReferenceDataCodeList.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System.Collections.Generic; - -namespace CoreEx.RefData -{ - /// - /// Enables the base capabilities for a special purpose collection specifically for managing a referenced list of serialization identifiers being the underlying . - /// - public interface IReferenceDataCodeList - { - /// - /// Indicates whether the collection contains invalid items (i.e. not ). - /// - /// true indicates that invalid items exist; otherwise, false. - bool HasInvalidItems { get; } - - /// - /// Gets the number of items in the collection. - /// - int Count { get; } - - /// - /// Gets the underlying list. - /// - /// The underlying list. - List ToRefDataList(); - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/IReferenceDataCollection.cs b/src/CoreEx/RefData/IReferenceDataCollection.cs deleted file mode 100644 index cb854c30..00000000 --- a/src/CoreEx/RefData/IReferenceDataCollection.cs +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.RefData -{ - /// - /// Provides and functionality for an collection. - /// - public interface IReferenceDataCollection - { - /// - /// Gets the underlying item . - /// - Type ItemType { get; } - - /// - /// Gets the collection item count. - /// - int Count { get; } - - /// - /// Adds the to the . - /// - /// The . - void Add(IReferenceData item); - - /// - /// Adds the of items to the . - /// - /// The collection containing the items to add. - void AddRange(IEnumerable collection); - - /// - /// Determines whether the specified exists within the collection. - /// - /// The . - /// true if it exists; otherwise, false. - bool ContainsId(object id); - - /// - /// Determines whether the specified exists within the collection. - /// - /// The . - /// true if it exists; otherwise, false. - bool ContainsCode(string code); - - /// - /// Attempts to get the with the specifed . - /// - /// The . - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - bool TryGetById(object id, [NotNullWhen(true)] out IReferenceData? item); - - /// - /// Attempts to get the with the specifed . - /// - /// The . - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - bool TryGetByCode(string code, [NotNullWhen(true)] out IReferenceData? item); - - /// - /// Gets the for the specified . - /// - /// The specified reference data . - /// The where found; otherwise, null. - IReferenceData? GetById(object id); - - /// - /// Gets the for the specified . - /// - /// The specified . - /// The where found; otherwise, null. - IReferenceData? GetByCode(string code); - - /// - /// Determines whether the specified value exists within the collection. - /// - /// The mapping value . - /// The mapping name. - /// The mapping value. - /// true if it exists; otherwise, false. - bool ContainsMapping(string name, T value) where T : IComparable, IEquatable; - - /// - /// Attempts to get the with the specifed value. - /// - /// The mapping value . - /// The mapping name. - /// The mapping value. - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - bool TryGetByMapping(string name, T value, [NotNullWhen(true)] out IReferenceData? item) where T : IComparable, IEquatable; - - /// - /// Gets the for the specified value. - /// - /// The mapping value . - /// The mapping name. - /// The mapping value. - /// The where found; otherwise, null. - IReferenceData? GetByMapping(string name, T value) where T : IComparable, IEquatable; - - /// - /// Gets all items (excluding invalid only) sorted by the value. - /// - /// An containing the selected items. - IEnumerable AllItems { get; } - - /// - /// Gets all active (excluding invalid and not ) items sorted by the value. - /// - /// An containing the selected items. - IEnumerable ActiveItems { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/IReferenceDataCollectionT.cs b/src/CoreEx/RefData/IReferenceDataCollectionT.cs index 269c63c6..91726bfd 100644 --- a/src/CoreEx/RefData/IReferenceDataCollectionT.cs +++ b/src/CoreEx/RefData/IReferenceDataCollectionT.cs @@ -1,124 +1,117 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.RefData; -using CoreEx.Entities; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.RefData +/// +/// Enables functionality for an collection with a typed . +/// +public interface IReferenceDataCollection : IReferenceDataCollection, ICollection where TRef : class, IReferenceData { - /// - /// Provides functionality for an collection with a typed . - /// - public interface IReferenceDataCollection : IReferenceDataCollection, ICollection where TId : IComparable, IEquatable where TRef : class, IReferenceData - { - /// - Type IReferenceDataCollection.ItemType => typeof(TRef); + /// + [JsonIgnore] + Type IReferenceDataCollection.ItemType => typeof(TRef); + + /// + void IReferenceDataCollection.Add(IReferenceData item) => Add((TRef)item); - /// - void IReferenceDataCollection.Add(IReferenceData item) => Add((TRef)item); + /// + void IReferenceDataCollection.AddRange(IEnumerable collection) => AddRange((IEnumerable)collection); - /// - void IReferenceDataCollection.AddRange(IEnumerable collection) => AddRange((IEnumerable)collection); - - /// - bool IReferenceDataCollection.ContainsId(object id) => ContainsId((TId)id); + /// + bool IReferenceDataCollection.ContainsId(object? id) => ContainsId((TId)(id ?? default(TId)!)); - /// - bool IReferenceDataCollection.TryGetById(object id, [NotNullWhen(true)] out IReferenceData? item) + /// + bool IReferenceDataCollection.TryGetById(object? id, [NotNullWhen(true)] out IReferenceData? item) + { + if (TryGetById((TId)(id ?? default(TId)!), out TRef? item2)) { - if (TryGetById((TId)id, out TRef? item2)) - { - item = item2; - return true; - } - - item = null; - return false; + item = item2; + return true; } - /// - bool IReferenceDataCollection.TryGetByCode(string code, [NotNullWhen(true)] out IReferenceData? item) + item = null; + return false; + } + + /// + bool IReferenceDataCollection.TryGetByCode(string code, [NotNullWhen(true)] out IReferenceData? item) + { + if (TryGetByCode(code, out TRef? item2)) { - if (TryGetByCode(code, out TRef? item2)) - { - item = item2; - return true; - } - - item = null; - return false; + item = item2; + return true; } - /// - IReferenceData? IReferenceDataCollection.GetById(object id) => GetById((TId)id); - - /// - IReferenceData? IReferenceDataCollection.GetByCode(string code) => GetByCode(code); - - /// - IReferenceData? IReferenceDataCollection.GetByMapping(string name, T value) => GetByMapping(name, value); - - /// - /// Adds the to the . - /// - /// The collection containing the items to add. - public void AddRange(IEnumerable collection); - - /// - /// Determines whether the specified exists within the collection. - /// - /// The . - /// true if it exists; otherwise, false. - bool ContainsId(TId id); - - /// - /// Attempts to get the with the specifed . - /// - /// The . - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - bool TryGetById(TId id, [NotNullWhen(true)] out TRef? item); - - /// - /// Attempts to get the with the specifed . - /// - /// The . - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - bool TryGetByCode(string code, [NotNullWhen(true)] out TRef? item); - - /// - /// Gets the for the specified . - /// - /// The specified reference data . - /// The where found; otherwise, null. - TRef? GetById(TId id); - - /// - /// Gets the for the specified . - /// - /// The specified . - /// The where found; otherwise, null. - new TRef? GetByCode(string code); - - /// - /// Attempts to get the with the specifed value. - /// - /// The mapping value . - /// The mapping name. - /// The mapping value. - /// The corresponding item where found; otherwise, null. - /// true where found; otherwise, false. - bool TryGetByMapping(string name, T value, [NotNullWhen(true)] out TRef? item) where T : IComparable, IEquatable; - - /// - /// Gets the for the specified value. - /// - /// The mapping value . - /// The mapping name. - /// The mapping value. - /// The where found; otherwise, null. - new TRef? GetByMapping(string name, T value) where T : IComparable, IEquatable; + item = null; + return false; } + + /// + IReferenceData? IReferenceDataCollection.GetById(object? id) => GetById(id); + + /// + IReferenceData? IReferenceDataCollection.GetByCode(string code) => GetByCode(code); + + /// + IReferenceData? IReferenceDataCollection.GetByMapping(string name, T value) => GetByMapping(name, value); + + /// + /// Adds the to the . + /// + /// The collection containing the items to add. + public void AddRange(IEnumerable collection); + + /// + /// Determines whether the specified exists within the collection. + /// + /// The . + /// if it exists; otherwise, . + bool ContainsId(TId id); + + /// + /// Attempts to get the with the specified . + /// + /// The . + /// The corresponding item where found; otherwise, . + /// where found; otherwise, . + bool TryGetById(TId id, [NotNullWhen(true)] out TRef? item); + + /// + /// Attempts to get the with the specified . + /// + /// The . + /// The corresponding item where found; otherwise, . + /// where found; otherwise, . + bool TryGetByCode(string code, [NotNullWhen(true)] out TRef? item); + + /// + /// Gets the for the specified . + /// + /// The specified reference data . + /// The where found; otherwise, . + TRef? GetById(TId id); + + /// + /// Gets the for the specified . + /// + /// The specified . + /// The where found; otherwise, . + new TRef? GetByCode(string code); + + /// + /// Attempts to get the with the specified value. + /// + /// The mapping value . + /// The mapping name. + /// The mapping value. + /// The corresponding item where found; otherwise, . + /// where found; otherwise, . + bool TryGetByMapping(string name, T value, [NotNullWhen(true)] out TRef? item) where T : IComparable, IEquatable; + + /// + /// Gets the for the specified value. + /// + /// The mapping value . + /// The mapping name. + /// The mapping value. + /// The where found; otherwise, . + new TRef? GetByMapping(string name, T value) where T : IComparable, IEquatable; } \ No newline at end of file diff --git a/src/CoreEx/RefData/IReferenceDataContext.cs b/src/CoreEx/RefData/IReferenceDataContext.cs index 0f4974fe..a5366d54 100644 --- a/src/CoreEx/RefData/IReferenceDataContext.cs +++ b/src/CoreEx/RefData/IReferenceDataContext.cs @@ -1,33 +1,28 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.RefData; -using System; - -namespace CoreEx.RefData +/// +/// Enables the contextual validation for a and verification. +/// +/// This allows for the validatity dates to be adjusted, per requrest or on-demand, to validate or invalidate the reference date where a and/or +/// have been configured. As such, this should be a scoped service from an ASP.NET Core dependency injection (DI) perspective. +/// The is a master setting for all types. An individual +/// can be overridden where required, and all dates can be . +public interface IReferenceDataContext { /// - /// Enables the contextual validation for a and verification. + /// Gets or sets the and contextual validation date. /// - /// This allows for the validatity dates to be adjusted, per requrest or on-demand, to validate or invalidate the reference date where a and/or - /// have been configured. As such, this should be a scoped service from an ASP.NET Core dependency injection (DI) perspective. - /// The is a master setting for all types. An individual - /// can be overridden where required, and all dates can be . - public interface IReferenceDataContext - { - /// - /// Gets or sets the and contextual validation date. - /// - DateTime? Date { get; set; } + DateTimeOffset? Date { get; set; } - /// - /// Gets or sets a contextual validation date for a specific . - /// - /// The . - /// The contextual validation date where found. - DateTime? this[Type type] { get; set; } + /// + /// Gets or sets a contextual validation date for a specific . + /// + /// The . + /// The contextual validation date where found. + DateTimeOffset? this[Type type] { get; set; } - /// - /// Resets all dates. - /// - void Reset(); - } + /// + /// Resets all dates. + /// + void Reset(); } \ No newline at end of file diff --git a/src/CoreEx/RefData/IReferenceDataProvider.cs b/src/CoreEx/RefData/IReferenceDataProvider.cs index 2b3b0380..9dd9934c 100644 --- a/src/CoreEx/RefData/IReferenceDataProvider.cs +++ b/src/CoreEx/RefData/IReferenceDataProvider.cs @@ -1,29 +1,21 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.RefData; -using CoreEx.Results; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.RefData +/// +/// Enables a means to manage and group one or more for use by the centralized . +/// +public interface IReferenceDataProvider { /// - /// Provides a means to manage and group one or more entities for use by the centralised . + /// Gets all the underlying and corresponding pairs provided. /// - public interface IReferenceDataProvider - { - /// - /// Gets all the underlying types provided. - /// - /// The types provided. - Type[] Types { get; } + /// The and corresponding pairs provided. + IEnumerable<(Type, Type)> Types { get; } - /// - /// Gets the for the specified . - /// - /// The . - /// The . - /// The corresponding . - Task> GetAsync(Type type, CancellationToken cancellationToken = default); - } + /// + /// Gets the for the specified . + /// + /// The . + /// The . + /// The corresponding . + Task GetAsync(Type type, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/CoreEx/RefData/IReferenceDataT.cs b/src/CoreEx/RefData/IReferenceDataT.cs index 78fafc99..2a101fe1 100644 --- a/src/CoreEx/RefData/IReferenceDataT.cs +++ b/src/CoreEx/RefData/IReferenceDataT.cs @@ -1,13 +1,13 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.RefData; -using CoreEx.Entities; -using System; - -namespace CoreEx.RefData +/// +/// Enables the core Reference Data properties with a typed . +/// +/// The identifier . +public interface IReferenceData : IReadOnlyIdentifier, IReferenceData { /// - /// Provides the core Reference Data properties with a typed . + /// Gets or initializes the identifier. /// - /// The identifier . - public interface IReferenceData : IIdentifier, IReferenceData where TId : IComparable, IEquatable { } + new TId Id { get; init; } } \ No newline at end of file diff --git a/src/CoreEx/RefData/README.md b/src/CoreEx/RefData/README.md deleted file mode 100644 index 24b8a864..00000000 --- a/src/CoreEx/RefData/README.md +++ /dev/null @@ -1,157 +0,0 @@ -# CoreEx.RefData - -The `CoreEx.RefData` namespace provides a rich, first class, experience for _reference data_ given its key role within an application. - -
- -## Motivation - -To provide a consistent pattern to the treatment of _reference data_ within an application, simplifying usage whilst maintaining flexibility of implementation. - -
- -## Types of data - -At a high-level a typical application deals with different types of data: - -- **Reference Data** is data that is managed within an application primarily used to provide lists of valid values. These values provide contextual information that are generally used to trigger business processes, workflows and/or used for grouping / filtering. -This data has a low level of volatility, in that it remains largely static for significant periods of time. There are low volumes of this data within an application. It is a very good candidate for the likes of caching. Reference Data is generally never deleted; instead it may become inactive. Example: Country, Gender, Payment Type, etc. - -- **Master data** is data that is captured and continuously maintained to reflect a current known understanding; there is no historical context other than that provided by an audit process providing a version history over time. This data has a moderate level of volatility, in that changes generally occur infrequently. There are moderate volumes of this data within an application. -Master data can be deleted (or logically deleted) as required; typically the latter. Example: Customer, Vendor, Product, GL Account, etc. - -- **Transactional data** is data that is recorded to capture/manage an event or action, tied to specific business rules, at a point in time. The data will typically have a high level of volatility at inception decreasing significantly over time. Once the corresponding workflow has completed the data becomes immutable and serves the purpose of providing a historical context. Transactional data is generally never deleted as it provides an auditable recording; it may be archived. There are high volumes of this type of data within an application. Example: Purchase Order, Sales Invoice, GL Posting, etc. - -
- -## Base capabilities - -The [`IReferenceData`](./IReferenceData.cs) provides for the core (standard) properties. The [`ReferenceDataBase`](./ReferenceDataBaseT.cs) or [`ReferenceDataBaseEx`](./Extended/ReferenceDataBaseEx.cs) provide the base implementation depending on the level of functionality required out-of-the-box. The latter is targeted for internal usage only with additional capabilities included that are often useful. - -The *primary* properties are as follows. - -Property | Description --|- -`Id` | The internal unique identifier as either an `int`, `long`, `Guid` or `string`. -`Code` | The unique (immutable) code as a `string`. This is primarily the value that would be used by external parties (applications) to consume. Additionally, it could be used to store the reference in the underlying data source if the above `Id` is not suitable. -`Text` | The textual `string` used for display within an application; e.g. within a drop-down. -`SortOrder` | Defines the sort order (integer) within the underlying reference data collection. -`IsActive` | Indicates whether the value is active or not. It is up to the application what to do when a value is not considered active. - -Additional _secondary_ (largely optional) properties are as follows. - -Property | Description --|- -`Description` | A further more detailed textual `string` used for display within an application; e.g. help-style text. -`StartDate` | The `IsValid` validity start date (`null` indicates not defined) where [`IReferenceDataContext`](./IReferenceDataContext.cs) -`EndDate` | The `IsValid` validity end date (`null` indicates not defined). -`ETag` | The entity tag ([`IETag`](../Entities/IETag.cs)) for optimistic concurrency and [`IF-MATCH`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) checking. - -Finally, there is a feature to enable multiple code mappings; i.e. where two (or more) systems have a different codes for the same value. The `SetMapping`, `GetMapping` and `TryGetMapping`, etc. methods enable. - -Additional developer-defined properties can be, and should be, added where required extending on the base class. The _reference data_ framework will then make these available within the application to enable simple usage/access by a developer. - -The [`ReferenceDataCollection`](./ReferenceDataCollection.cs) provides the base capabilities for a reference data collection. Including the adding, ensuring uniqueness, sorting and additional filtering (e.g. `ActiveList`). - -
- -## Orchestration and caching - -The [`ReferenceDataOrchestrator`](./ReferenceDataOrchestrator.cs) provides the centralized reference data orchestration. Primarily responsible for the management of one or more `IReferenceDataProvider` instances. - -An [`IReferenceDataProvider`](./IReferenceDataProvider.cs) defines the list of _reference data_ `Types` that are provided/supported. The `GetAsync` method is then responsible for performing the load for the specified _reference data_ type. - -Each `IReferenceDataProvider` (typically only one) instance is registered via the orchestrator's `Register` method. The orchestrator then provides a number of utility methods to access the various _reference data_ types. The orchestrator will lazy-load each type (using provider's `GetAsync`) on first access and automatically cache using an [`IMemoryCache`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.caching.memory.imemorycache) to improve performance. Out-of-the-box `IMemoryCache` policies can be set to manage cache lifetimes. - -
- -### Cache policy configuration - -The default implementation for the `IMemoryCache` is that each _reference data_ type's collection [`ICacheEntry`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.caching.memory.icacheentry) is set in a standardized manner; being `AbsoluteExpirationRelativeToNow` and `SlidingExpiration` properties defaulted to `02:00:00` (2 hours) and `00:30:00` (30 minutes) respectively. - -These defaults can be overridden within the configuration [settings](../Configuration/SettingsBase.cs); as can specific _reference data_ types. The following is an example of setting the defaults and then specifically overridding the `Gender` policy (the `Type.Name` is used as the property name). - -``` json -{ - "RefDataCache": { - "AbsoluteExpirationRelativeToNow": "01:45:00", - "SlidingExpiration": "00:15:00", - "Gender": { - "AbsoluteExpirationRelativeToNow": "03:00:00", - "SlidingExpiration": "00:45:00" - } - } -} -``` - -This is enabled by the default [`SettingsBasedCacheEntry`](./Caching/SettingsBasedCacheEntry.cs) class. Where the above is not sufficient then a custom [`ICacheEntryConfig`](./Caching/ICacheEntryConfig.cs) can be leveraged, or the virtual `ReferencedDataOrchestrator.OnCreateCacheEntry` method can be overridden to fully customize (override) the behaviour. - -
- -### Distributed cache - -There may be times that a [distributed cache](https://learn.microsoft.com/en-us/dotnet/core/extensions/caching#distributed-caching) may be required to support higher scale-out and/or consistent multiple app server refreshing. In this scenario it is still recommended that an `IMemoryCache` is used, albeit with a nominal (small) expiration to ensure optimal performance given potential frequency of access, then implement the likes of an [`IDistribuedCache`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.caching.distributed.idistributedcache) within the corresponding [`IReferenceDataProvider`](./IReferenceDataProvider.cs) implementation. - -
- -## Reference data properties and serialization - -It is recommended that the rich _reference data_ types themselves where included within an entity are not JSON serialized/deserialized over the wire from the likes of an API; in these instances only the `Code` is generally used. This is to minimize the resulting payload size of the owning entity. - -For example a `Person` entity would be defined with the rich `Gender` _reference data_ type: - -``` csharp -public class Person -{ - public string? Name { get; set; } - public Gender? Gender { get; set; } -} -``` - -The resulting serialized JSON should be as follows. By default the [`JsonSerializer`](../Text/Json/JsonSerializer.cs) will automatically detect a property of `IReferenceData` and serialize the `Code` only, whereby ensuring the minimized serialization. Where full serialization is required use the alternate [`ReferenceDataContentJsonSerializer`](../Text/Json/ReferenceDataContentJsonSerializer.cs). - -``` json -{ "name": "Sarah", "gender": "F" } -``` - -
- -## Reference data APIs - -The [orchestrator](#Orchestration-and-caching) encapsulates all the requisite functionality to enables rich API endpoints. By default only the active (`IReferenceData.IsActive`) items will be returned. To get both the active and inactive the [`$inactive=true`](../Http/HttpConsts.cs) URL query string must be used. - -
- -### Per reference data endpoints - -Each reference data entity should have an API endpoint; similar to `/ref/Xxx`. This will by default return all of the active reference data entries. This can also be invoked passing additional URL query string parameters: - -Parameter | Description --|- -`code` | Zero or more codes can be passed; e.g: `?code=m,f` or `?code=m&code=f` (case insensitive). -`text` | A single text with wildcards can be passed; e.g: `?text=m*` (case insensitive). - -To expose per _reference data_ type, [code similar to](../../../samples/My.Hr/My.Hr.Api/Controllers/ReferenceDataController.cs) the following should be adopted. The [orchestrator](#Orchestration-and-caching) encapsulates all the requisite functionality to filter, etc. - -``` csharp -[HttpGet("genders")] -[ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] -public Task GenderGetAll([FromQuery] IEnumerable? codes = default, string? text = default) => - _webApi.GetAsync(Request, x => _orchestrator.GetWithFilterAsync(codes, text, x.RequestOptions.IncludeInactive)); -``` - -
- -### Root reference data endpoint - -Additionally, a root `/ref`-style endpoint can be used to return multiple reference data values in a single request; designed to reduce chattiness from a consuming channel to the above _per_ endpoints. This must be passed at least a single URL query string parameter to function. - -The parameter is either just the named reference data entity which will result in all corresponding entries being returned (e.g: `?gender` or `?gender&country`). Otherwise, specific codes can be specified (e.g" `?gender=m,f`, `?gender=m&gender=f`, `?gender=m,f&country=au,nz`). The options can be mixed and matched (e.g: `?gender&country=au,nz`). - -To expose, [code similar to](../../../samples/My.Hr/My.Hr.Api/Controllers/ReferenceDataController.cs) the following should be adopted. The [orchestrator](#Orchestration-and-caching) encapsulates all the requisite functionality to perform in a consistent manner. - -``` csharp -[HttpGet()] -[ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] -public Task GetNamed() => _webApi.GetAsync(Request, p => _orchestrator.GetNamedAsync(p.RequestOptions)); -``` \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataBase.cs b/src/CoreEx/RefData/ReferenceDataBase.cs deleted file mode 100644 index 7fc5725a..00000000 --- a/src/CoreEx/RefData/ReferenceDataBase.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Diagnostics; - -namespace CoreEx.RefData -{ - /// - /// Represents the basic implementation. - /// - /// For a fully-featured implementation see . - [DebuggerDisplay("Id = {Id}, Code = {Code}, Text = {Text}, IsActive = {IsActive}")] - public abstract class ReferenceDataBase : IReferenceData - { - private bool _isValid = true; - - /// - Type IIdentifier.IdType => throw new NotImplementedException(); - - /// - public object? Id { get; set; } - - /// - public string? Code { get; set; } - - /// - public string? Text { get; set; } - - /// - public string? Description { get; set; } - - /// - public int SortOrder { get; set; } - - /// - public bool IsActive { get; set; } = true; - - /// - public DateTime? StartDate { get; set; } - - /// - public DateTime? EndDate { get; set; } - - /// - public string? ETag { get; set; } - - /// - bool IReferenceData.IsValid => _isValid; - - /// - void IReferenceData.SetInvalid() => _isValid = false; - - /// - public override string ToString() => Text ?? Code ?? Id?.ToString() ?? base.ToString()!; - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataBaseT.cs b/src/CoreEx/RefData/ReferenceDataBaseT.cs deleted file mode 100644 index 28d29847..00000000 --- a/src/CoreEx/RefData/ReferenceDataBaseT.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Diagnostics; - -namespace CoreEx.RefData -{ - /// - /// Represents the implementation. - /// - /// For a fully-featured implementation see . - [DebuggerDisplay("Id = {Id}, Code = {Code}, Text = {Text}, IsActive = {IsActive}")] - public class ReferenceDataBase : ReferenceDataBase, IReferenceData where TId : IComparable, IEquatable - { - /// - /// Initializes a new instance of the class. - /// - /// The can only be of type , , and . - public ReferenceDataBase() - { - base.Id = default(TId); - if (Id != null && Id is not int && Id is not long && Id is not string && Id is not Guid) - throw new InvalidOperationException($"A Reference Data {nameof(Id)} can only be of type {nameof(Int32)}, {nameof(Int64)}, {nameof(String)} or {nameof(Guid)}."); - } - - /// - object? IIdentifier.Id { get => base.Id; set => base.Id = (TId)value!; } - - /// - Type IIdentifier.IdType => typeof(TId); - - /// - public new TId? Id { get => (TId?)base.Id; set => base.Id = value; } - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataCodeList.cs b/src/CoreEx/RefData/ReferenceDataCodeList.cs deleted file mode 100644 index 64c8308b..00000000 --- a/src/CoreEx/RefData/ReferenceDataCodeList.cs +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Linq; - -namespace CoreEx.RefData -{ - /// - /// Provides the capabilities for a special purpose collection specifically for managing a referenced list of serialization identifiers being the underlying . - /// - /// The . - public class ReferenceDataCodeList : IReferenceDataCodeList, IList, INotifyCollectionChanged where TRef : class, IReferenceData, new() - { - private readonly List _codes; - - /// - /// Initializes a new instance of the class. - /// - public ReferenceDataCodeList() => _codes = []; - - /// - /// Initializes a new instance of the class with a reference to an external list. - /// - /// A reference to the external list; it is this list that will be maintained by this collection. Changes made to the referenced list will bypass . - public ReferenceDataCodeList(ref List? codes) => _codes = codes ?? []; - - /// - /// Initializes a new instance of the class with a list of items. - /// - /// The list of items. - public ReferenceDataCodeList(IEnumerable items) => _codes = new((items ?? []).Select(x => x.Code)); - - /// - /// Initializes a new instance of the class with a array. - /// - /// The array. - public ReferenceDataCodeList(params string?[] codes) => _codes = new(codes); - - /// - /// Creates a new list from the underlying contents. - /// - /// A new list list. - public List ToCodeList() => new(_codes); - - /// - List IReferenceDataCodeList.ToRefDataList() => new(this); - - /// - /// Creates a new list from the underlying contents. - /// - /// A new list - public List ToRefDataList() => [.. this]; - - /// - /// Creates a new list from the underlying contents. - /// - /// The . - /// A new list - public List ToIdList() => this.Select(x => (TId?)x.Id).ToList(); - - /// - /// Indicates whether the collection contains invalid items (i.e. not ). - /// - /// true indicates that invalid items exist; otherwise, false. - public bool HasInvalidItems => this.Any(x => x == null || !x.IsValid); - - /// - /// Gets the item for the specified . - /// - private static TRef GetItem(string? code) - { - if (code != null) - return ReferenceDataOrchestrator.ConvertFromCode(code); - - var rdx = new TRef(); - rdx.SetInvalid(); - return rdx; - } - - #region IList - - /// - public TRef this[int index] - { - get => GetItem(_codes[index]); - - set - { - var e = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, value, GetItem(_codes[index]!)); - _codes[index] = value?.Code; - OnCollectionChanged(e); - } - } - - /// - public int Count => _codes.Count; - - /// - public bool IsReadOnly => ((IList)_codes).IsReadOnly; - - /// - public void Add(TRef item) - { - var e = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, _codes.Count); - _codes.Add(item?.Code); - OnCollectionChanged(e); - } - - /// - public void Clear() - { - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, this)); - _codes.Clear(); - } - - /// - public bool Contains(TRef item) => _codes.Contains(item?.Code); - - /// - public void CopyTo(TRef[] array, int arrayIndex) - { - if (array == null || array.Length == 0) - return; - - var codes = new string?[array.Length]; - for (int i = 0; i < array.Length; i++) - { - codes[i] = array[i]?.Code; - } - - _codes.CopyTo(codes, arrayIndex); - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, array, arrayIndex)); - } - - /// - public IEnumerator GetEnumerator() - { - foreach (string? code in _codes) - { - yield return GetItem(code!); - } - } - - /// - public int IndexOf(TRef item) => _codes.IndexOf(item?.Code); - - /// - public void Insert(int index, TRef item) - { - _codes.Insert(index, item?.Code); - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index)); - } - - /// - public bool Remove(TRef item) - { - var index = IndexOf(item); - if (index < 0) - return false; - - RemoveAt(index); - return true; - } - - /// - public void RemoveAt(int index) - { - var e = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, this[index], index); - _codes.RemoveAt(index); - OnCollectionChanged(e); - } - - /// - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - #endregion - - #region INotifyPropertyChanged - - /// - /// Raises the event with the provided arguments. - /// - /// Arguments of the event being raised. - protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs e) => CollectionChanged?.Invoke(this, e); - - /// - public event NotifyCollectionChangedEventHandler? CollectionChanged; - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataCollection.cs b/src/CoreEx/RefData/ReferenceDataCollection.cs deleted file mode 100644 index 471b0e2c..00000000 --- a/src/CoreEx/RefData/ReferenceDataCollection.cs +++ /dev/null @@ -1,297 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; - -namespace CoreEx.RefData -{ - /// - /// Represents a base collection. - /// - /// The . - /// The . - public class ReferenceDataCollection : IReferenceDataCollection, ICollection where TId : IComparable, IEquatable where TRef : class, IReferenceData - { -#if NET9_0_OR_GREATER - private readonly System.Threading.Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - private readonly ConcurrentDictionary _rdcId = new(); - private readonly ConcurrentDictionary _rdcCode; - private Dictionary<(string, object?), TRef>? _mappingsDict; - - /// - /// Initializes a new instance of the class. - /// - /// The . Defaults to . - /// The for comparisons. Defaults to . - public ReferenceDataCollection(ReferenceDataSortOrder sortOrder = ReferenceDataSortOrder.SortOrder, StringComparer? codeComparer = null) - { - SortOrder = sortOrder; - _rdcCode = new ConcurrentDictionary(codeComparer ?? StringComparer.OrdinalIgnoreCase); - OnInitialization(); - } - - /// - /// Provides an opportunity to extend initialization when the object is constructed. - /// - /// Added to support scenarios whether the class is defined using the likes of partial classes to provide a means to easily add functionality during the constructor process. - protected virtual void OnInitialization() { } - - /// - /// Gets or sets the used by . - /// - public ReferenceDataSortOrder SortOrder { get; set; } - - /// - /// Gets the item for the specified . - /// - /// The . - /// The item where found; otherwise, null. - public TRef? this[string code] => _rdcCode[code]; - - /// - public void Clear() - { - lock (_lock) - { - _rdcId.Clear(); - _rdcCode.Clear(); - _mappingsDict?.Clear(); - } - } - - /// - /// The underlying are included during add; if they are maintained (see ) after these will not be included. Also, where the item - /// implements then will be invoked during the add. - public void Add(TRef item) - { - item.ThrowIfNull(nameof(item)); - - if (item.Id == null) - throw new ArgumentException("Id must not be null.", nameof(item)); - - if (item.Code == null) - throw new ArgumentException("Code must not be null.", nameof(item)); - - lock (_lock) - { - if (_rdcId.Values.Contains(item)) - throw new ArgumentException($"Item already exists within the collection.", nameof(item)); - - if (_rdcId.ContainsKey(item.Id)) - throw new ArgumentException($"Item with Id '{item.Id}' already exists within the collection.", nameof(item)); - - if (_rdcCode.ContainsKey(item.Code)) - throw new ArgumentException($"Item with Code '{item.Code!}' already exists within the collection.", nameof(item)); - - if (item.HasMappings) - { - _mappingsDict ??= []; - - // Make sure there are no duplicates. - foreach (var map in item.Mappings!) - { - if (_mappingsDict.ContainsKey((map.Key, map.Value))) - throw new ArgumentException($"Item with Mapping Key '{map.Key}' and Value '{map.Value}' already exists within the collection."); - } - - // Now add 'em in. - foreach (var map in item.Mappings) - { - _mappingsDict.Add((map.Key, map.Value), item); - } - } - - // Add to the underlying dictionaries. - _rdcId.TryAdd(item.Id, item); - _rdcCode.TryAdd(item.Code, item); - - if (item is IReadOnly ro) - ro.MakeReadOnly(); - } - } - - /// - /// Adds the to the . - /// - /// The collection containing the items to add. - public void AddRange(IEnumerable collection) - { - if (collection == null) - return; - - foreach (var item in collection) - { - Add(item); - } - } - - /// - public bool ContainsId(TId id) => _rdcId.ContainsKey(id); - - /// - public bool TryGetById(TId id, [NotNullWhen(true)] out TRef? item) - { - if (id != null) - return _rdcId.TryGetValue(id, out item); - - item = default; - return false; - } - - /// - public TRef? GetById(TId id) => id == null ? default : _rdcId[id]; - - /// - public bool ContainsCode(string code) => _rdcCode.ContainsKey(code); - - /// - public bool TryGetByCode(string code, [NotNullWhen(true)] out TRef? item) - { - if (code != null) - return _rdcCode.TryGetValue(code, out item); - - item = default; - return false; - } - - /// - public TRef? GetByCode(string code) => code == null ? default : _rdcCode[code]; - - /// - public bool ContainsMapping(string name, T value) where T : IComparable, IEquatable => _mappingsDict != null && _mappingsDict.ContainsKey((name, value)); - - /// - bool IReferenceDataCollection.TryGetByMapping(string name, T value, [NotNullWhen(true)] out IReferenceData? item) - { - var r = TryGetByMapping(name, value, out TRef? itemx); - item = itemx; - return r; - } - - /// - public bool TryGetByMapping(string name, T value, [NotNullWhen(true)] out TRef? item) where T : IComparable, IEquatable - { - if (_mappingsDict != null) - return _mappingsDict.TryGetValue((name, value), out item); - - item = default; - return false; - } - - /// - public TRef? GetByMapping(string name, T value) where T : IComparable, IEquatable => TryGetByMapping(name, value, out var item) ? item : default; - - /// - IEnumerable IReferenceDataCollection.AllItems => AllList; - - /// - IEnumerable IReferenceDataCollection.ActiveItems => ActiveList; - - /// - /// Gets a list of all items (excluding where not ) sorted by the value. - /// - /// An containing the selected items. - /// This is provided as a property to more easily support binding; it encapsulates the following method invocation: (SortOrder, null, null); - public IList AllList => GetItems(SortOrder, null, true); - - /// - /// Gets a list of all active ( and ) items sorted by the value. - /// - /// An containing the selected items. - /// This is provided as a property to more easily support binding; it encapsulates the following method invocation: (SortOrder, true, true); - public IList ActiveList => GetItems(SortOrder, true, true); - - /// - /// Gets a list of items from the collection using the specified criteria. - /// - /// Defines the ; null indicates to use the defined . - /// Indicates whether the list should include values with the same value; otherwise, null indicates all. - /// Indicates whether the list should include values with the same value; otherwise, null indicates all. - /// This is leveraged by and . - public List GetItems(ReferenceDataSortOrder? sortOrder = null, bool? isActive = null, bool? isValid = null) - { - if (_rdcId.IsEmpty) - return []; - - var list = from rd in _rdcId.Values select rd; - if (isActive != null) - list = list.Where(x => IsItemActive(x)); - - if (isValid != null) - list = list.Where(x => IsItemValid(x)); - - list = (sortOrder ?? SortOrder) switch - { - ReferenceDataSortOrder.Id => list.OrderBy(x => x.Id), - ReferenceDataSortOrder.Code => list.OrderBy(x => x.Code), - ReferenceDataSortOrder.Text => list.OrderBy(x => x.Text).ThenBy(x => x.Code), - _ => list.OrderBy(x => x.SortOrder).ThenBy(x => x.Text).ThenBy(x => x.Code) - }; - - return list.ToList(); - } - - /// - /// Determines whether the is considered active and therefore accessible from within the collection. - /// - /// The item to validate. - /// true indicates active; otherwise, false. - /// By default checks . - protected virtual bool IsItemActive(TRef item) => item.IsActive; - - /// - /// Determines whether the is considered valid and therefore accessible from within the collection. - /// - /// The item to validate. - /// true indicates valid; otherwise, false. - /// By default checks . - protected virtual bool IsItemValid(TRef item) => item.IsValid; - - #region ICollection - - /// - public ICollection Keys => _rdcId.Keys; - - /// - public ICollection Values => _rdcId.Values; - - /// - public int Count => _rdcId.Count; - - /// - bool ICollection.IsReadOnly => ((ICollection>)_rdcId).IsReadOnly; - - /// - public bool Contains(TRef item) => _rdcId.Values.Contains(item); - - /// - void ICollection.CopyTo(TRef[] array, int arrayIndex) => throw new NotSupportedException(); - - /// - bool ICollection.Remove(TRef item) => throw new NotSupportedException(); - - /// - /// Only items that are are enumerated. There is no implied sort order; use for sorted lists. - public IEnumerator GetEnumerator() - { - foreach (TRef item in _rdcId.Values) - { - if (IsItemValid(item)) - yield return item; - } - } - - /// - /// Only items that are are enumerated. There is no implied sort order; use for sorted lists. - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataCollectionBase.cs b/src/CoreEx/RefData/ReferenceDataCollectionBase.cs deleted file mode 100644 index f4de88f2..00000000 --- a/src/CoreEx/RefData/ReferenceDataCollectionBase.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.RefData -{ - /// - /// Represents a base collection with to enable the . - /// - /// The . - /// The . - /// The itself. - /// The . Defaults to . - /// The for comparisons. Defaults to . - public abstract class ReferenceDataCollectionBase(ReferenceDataSortOrder sortOrder, StringComparer? codeComparer = null) : ReferenceDataCollection(sortOrder, codeComparer) where TId : IComparable, IEquatable where TRef : class, IReferenceData where TSelf : ReferenceDataCollectionBase, new() - { - /// - /// Initializes a new instance of the class. - /// - public ReferenceDataCollectionBase() : this(ReferenceDataSortOrder.SortOrder, null) { } - - /// - /// Creates an instance of and adds from the . - /// - /// The source items. - /// An instance of . - public static TSelf Create(IEnumerable? source = null) - { - var coll = new TSelf(); - if (source != null) - coll.AddRange(source); - - return coll; - } - - /// - /// Creates an instance of and adds from the asynchronously. - /// - /// The source items. - /// The . - /// An instance of . - public static Task CreateAsync(IQueryable source, CancellationToken cancellationToken = default) - => CreateAsync(source is IAsyncEnumerable ae ? ae : throw new ArgumentException("The source must implement IAsyncEnumerable.", nameof(source)), cancellationToken); - - /// - /// Creates an instance of and adds from the asynchronously. - /// - /// The source items. - /// The . - /// An instance of . - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Future proofing.")] - public static async Task CreateAsync(IAsyncEnumerable? source = null, CancellationToken cancellationToken = default) - { - var coll = new TSelf(); - - if (source != null) - { - await foreach (TRef i in source.ConfigureAwait(false)) - { - coll.Add(i); - } - } - - return coll; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataContext.cs b/src/CoreEx/RefData/ReferenceDataContext.cs deleted file mode 100644 index 49c88f14..00000000 --- a/src/CoreEx/RefData/ReferenceDataContext.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Collections.Concurrent; - -namespace CoreEx.RefData -{ - /// - /// Provides the contextual validation for a and verification. - /// - /// All dates when set go through with . - public class ReferenceDataContext : IReferenceDataContext - { - private DateTime? _date; - private readonly ConcurrentDictionary _coll = new(); - - /// - /// Gets or sets the and contextual validation date. - /// - /// Defaults to . - public DateTime? Date - { - get => _date ??= Cleaner.Clean(SystemTime.Timestamp, DateTimeTransform.DateOnly); - set => _date = Cleaner.Clean(value, DateTimeTransform.DateOnly); - } - - /// - /// Gets or sets a contextual validation date for a specific . - /// - /// The . - /// The contextual validation date. - public DateTime? this[Type type] - { - get => (_coll.TryGetValue(type.ThrowIfNull(nameof(type)), out var date) ? date : Date) ?? Date; - set => _coll.AddOrUpdate(type, Cleaner.Clean(value, DateTimeTransform.DateOnly), (_, __) => Cleaner.Clean(value, DateTimeTransform.DateOnly)); - } - - /// - /// Resets all dates. - /// - public void Reset() - { - _date = null; - _coll.Clear(); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataFilter.cs b/src/CoreEx/RefData/ReferenceDataFilter.cs deleted file mode 100644 index 28b9fa6b..00000000 --- a/src/CoreEx/RefData/ReferenceDataFilter.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Entities.Extended; -using System.Collections.Generic; - -namespace CoreEx.RefData -{ - /// - /// Provides the filter properties specifically for HTTP Agent usage; see - /// - public class ReferenceDataFilter : EntityBase - { - private IEnumerable? _codes; - private string? _text; - - /// - /// Gets or sets the list of codes. - /// - public IEnumerable? Codes { get => _codes; set => SetValue(ref _codes, value); } - - /// - /// Gets or sets the text (including wildcards). - /// - public string? Text { get => _text; set => SetValue(ref _text, value, StringTrim.Both ); } - - /// - protected override IEnumerable GetPropertyValues() - { - yield return CreateProperty(nameof(Codes), Codes, v => Codes = v); - yield return CreateProperty(nameof(Text), Text, v => Text = v); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataMultiDictionary.cs b/src/CoreEx/RefData/ReferenceDataMultiDictionary.cs index 2999054a..ab0c4b3b 100644 --- a/src/CoreEx/RefData/ReferenceDataMultiDictionary.cs +++ b/src/CoreEx/RefData/ReferenceDataMultiDictionary.cs @@ -1,19 +1,14 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.RefData; -using System; -using System.Collections.Generic; - -namespace CoreEx.RefData +/// +/// Represents a dictionary where the key is the and the value is the corresponding items. +/// +/// This is intended for the to enable the to enable correct serialization of types. +/// To enable the is used; however, note this only supports serialization and not deserialization. +public class ReferenceDataMultiDictionary : Dictionary> { /// - /// Represents a dictionary where the key is the and the value is the corresponding items. + /// Initializes a new instance of the class. /// - /// This is generally intended for the . - public class ReferenceDataMultiDictionary : Dictionary> - { - /// - /// Initializes a new instance of the class. - /// - public ReferenceDataMultiDictionary() : base(StringComparer.OrdinalIgnoreCase) { } - } + public ReferenceDataMultiDictionary() : base(StringComparer.OrdinalIgnoreCase) { } } \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataOrchestrator.cs b/src/CoreEx/RefData/ReferenceDataOrchestrator.cs index 4fc51340..aaf971a9 100644 --- a/src/CoreEx/RefData/ReferenceDataOrchestrator.cs +++ b/src/CoreEx/RefData/ReferenceDataOrchestrator.cs @@ -1,691 +1,581 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Configuration; -using CoreEx.Entities; -using CoreEx.Invokers; -using CoreEx.RefData.Caching; -using CoreEx.Wildcards; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.RefData +namespace CoreEx.RefData; + +/// +/// Provides the centralized reference data orchestration. Primarily responsible for the management of one or more instances. +/// +/// Provides cached access to the underlying reference data collections via the likes of , or . +/// To improve performance the reference data should be cached; this is enabled using the . The underlying reference data loading is executed in the context of a +/// to limit/minimize any impact on the processing of the current request by isolating all scoped services. +/// The needed to instantiate the registered providers, etc. +/// The . +public sealed class ReferenceDataOrchestrator(IServiceProvider serviceProvider, ILogger logger) { /// - /// Provides the centralized reference data orchestration. Primarily responsible for the management of one or more instances. + /// Gets the error message where the value is invalid. /// - /// Provides cached access to the underlying reference data collections via the likes of , or . - /// To improve performance the reference data is cached. The enables this via an implementation; default is where not explicitly specified. - /// The underlying reference data loading is executed in the context of a to limit/minimize any impact on the processing of the current request by isolating all scoped services. - public class ReferenceDataOrchestrator - { - /// - /// Gets the error message where the value is invalid. - /// - public const string TextWildcardErrorMessage = "Text contains invalid or unsupported wildcard selection."; + public const string TextWildcardErrorMessage = "Text contains invalid or unsupported wildcard selection."; - private const string InvokerCacheType = "refdata.cachetype"; - private const string InvokerCacheState = "refdata.cachestate"; - private const string InvokerCacheCount = "refdata.cachecount"; + private const string InvokerCacheType = "refdata.cachetype"; + private const string InvokerCacheState = "refdata.cachestate"; + private const string InvokerCacheCount = "refdata.cachecount"; - private static readonly AsyncLocal _asyncLocal = new(); + private static readonly AsyncLocal _asyncLocal = new(); + private readonly ReferenceDataOrchestratorInvoker _invoker = serviceProvider?.GetService() ?? new(); #if NET9_0_OR_GREATER - private readonly System.Threading.Lock _lock = new(); + private readonly Lock _lock = new(); #else - private readonly object _lock = new(); + private readonly object _lock = new(); #endif - private readonly ConcurrentDictionary _typeToProvider = new(); - private readonly ConcurrentDictionary _nameToType = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _semaphores = new(); - private readonly Lazy _logger; - private readonly Lazy _settings; - - /// - /// Gets or sets the current for the executing thread graph (see ) - /// - public static ReferenceDataOrchestrator Current + private readonly ConcurrentDictionary _typeToProvider = new(); + private readonly ConcurrentDictionary _nameToType = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _typeToCollType = new(); + + /// + /// Tries to get the current for the executing thread graph (see ), or if not set, the service is used. + /// + /// The where ; otherwise, . + /// when ; otherwise, . + public static bool TryGetCurrent([NotNullWhen(true)] out ReferenceDataOrchestrator? referenceDataOrchestrator) + { + referenceDataOrchestrator = _asyncLocal.Value ??= ExecutionContext.GetService(); + return referenceDataOrchestrator is not null; + } + + /// + /// Gets the current for the executing thread graph (see ), or if not set, the service is used. + /// + public static ReferenceDataOrchestrator Current => TryGetCurrent(out var rdo) ? rdo : throw new InvalidOperationException($"Unable to get an instance of the {nameof(ReferenceDataOrchestrator)} from the {nameof(ExecutionContext)}; the {nameof(ReferenceDataOrchestrator)} must be registered as a singleton service"); + + /// + /// Indicates whether the has a value. + /// + public static bool HasCurrent => TryGetCurrent(out var _); + + /// + /// Sets (or overrides) the instance. + /// + /// The . + public static void SetCurrent(ReferenceDataOrchestrator? orchestrator = null) => _asyncLocal.Value = orchestrator; + + /// + /// Gets the . + /// + public IServiceProvider ServiceProvider { get; } = serviceProvider.ThrowIfNull(); + + /// + /// Gets the . + /// + public ILogger Logger { get; } = logger.ThrowIfNull(); + + /// + /// Gets or sets the to use when performing a . + /// + /// Defaults to 2 to minimize potential impact + public int PrefetchMaxDegreeOfParallelism { get; set; } = 2; + + /// + /// Registers the . + /// + /// The to support fluent-style method-chaining. + /// Internally this builds the relationship between the and the owning to enable cached access to the underlying + /// using or . + public ReferenceDataOrchestrator Register() => Register(); + + /// + /// Registers an . + /// + /// The to register. + /// The to support fluent-style method-chaining. + /// Internally this builds the relationship between the and the owning to enable cached access to the underlying + /// using or . + public ReferenceDataOrchestrator Register() where TProvider : IReferenceDataProvider + { + using var scope = ServiceProvider.CreateScope(); + var provider = scope.ServiceProvider.GetRequiredService(); + + foreach (var (refType, collType) in provider.Types) { - get + // Lock to ensure that the two internal dictionaries (together) are updated in a thread-safe manner + lock (_lock) { - if (_asyncLocal.Value is not null) - return _asyncLocal.Value; + if (_nameToType.ContainsKey(refType.Name)) + throw new InvalidOperationException($"Type '{refType.FullName}' cannot be added as name '{refType.Name}' already associated with previously added Type '{_nameToType.GetValueOrDefault(refType.Name)?.FullName}'."); - if (ExecutionContext.HasCurrent) - { - try - { - var rdo = ExecutionContext.GetService(); - if (rdo is not null) - return rdo; - } - catch (Exception ex) - { - throw new InvalidOperationException($"Unable to get an instance of the {nameof(ReferenceDataOrchestrator)} from the {nameof(ExecutionContext)}. It is recommended that the {nameof(ReferenceDataOrchestrator)}.{nameof(SetCurrent)} is used to set globally; this can be performed using the IApplicationBuilder.UseReferenceDataOrchestrator method during start-up where applicable.", ex); - } - } + if (!_typeToProvider.TryAdd(refType, typeof(TProvider))) + throw new InvalidOperationException($"Type '{refType.FullName}' cannot be added as already associated with previously added Provider '{_typeToProvider.GetValueOrDefault(refType)?.GetType().FullName}'."); - throw new InvalidOperationException($"Unable to get an instance of the {nameof(ReferenceDataOrchestrator)} from the {nameof(ExecutionContext)}. It is recommended that the {nameof(ReferenceDataOrchestrator)}.{nameof(SetCurrent)} is used to set globally; this can be performed using the IApplicationBuilder.UseReferenceDataOrchestrator method during start-up where applicable."); + _nameToType.TryAdd(refType.Name, refType); + _typeToCollType.TryAdd(refType, collType); } } - /// - /// Indicates whether the has a value. - /// - public static bool HasCurrent => _asyncLocal != null; - - /// - /// Sets (or overriddes) the instance. - /// - /// The . - public static void SetCurrent(ReferenceDataOrchestrator? orchestrator = null) => _asyncLocal.Value = orchestrator; - - /// - /// Initializes a new instance of the class. - /// - /// The needed to instantiated the registered providers, etc. - /// The . Defaults to new instance. - /// The . Defaults to new instance. - public ReferenceDataOrchestrator(IServiceProvider serivceProvider, IMemoryCache? cache = null, ICacheEntryConfig? cacheEntryConfig = null) - { - ServiceProvider = serivceProvider.ThrowIfNull(nameof(serivceProvider)); -#if NET7_0_OR_GREATER - Cache = cache ?? new MemoryCache(new MemoryCacheOptions { TrackStatistics = true }); -#else - Cache = cache ?? new MemoryCache(new MemoryCacheOptions()); -#endif - CacheEntryConfig = cacheEntryConfig ?? new SettingsBasedCacheEntry(ServiceProvider.GetService()); - _logger = new Lazy(ServiceProvider.GetRequiredService>); - _settings = new Lazy(ServiceProvider.GetService); - } + return this; + } - /// - /// Gets the . - /// - public IServiceProvider ServiceProvider { get; } - - /// - /// Gets the underlying . - /// - public IMemoryCache Cache { get; } - - /// - /// Gets the underlying . - /// - public ICacheEntryConfig CacheEntryConfig { get; } - - /// - /// Gets the . - /// - [DebuggerBrowsable(DebuggerBrowsableState.Never)] - public ILogger Logger => _logger.Value; - - /// - /// Gets the . - /// - [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] - public SettingsBase Settings => _settings.Value!; - -#if NET6_0_OR_GREATER - /// - /// Gets or sets the to use when performing a . - /// - /// Defaults to -1. The uses and as such - /// these setting will equal the equivalent of the ; see this article. - public int PrefetchMaxDegreeOfParallelism { get; set; } = -1; -#endif + /// + /// Determines whether the contains the specified . + /// + /// The . + /// indicates that it exists; otherwise, . + public bool ContainsType() where TRef : IReferenceData => ContainsType(typeof(TRef)); - /// - /// Registers the . - /// - /// The to support fluent-style method-chaining. - /// Internally this builds the relationship between the and the owning to enable cached access to the underlying - /// using or . - public ReferenceDataOrchestrator Register() => Register(); - - /// - /// Registers an . - /// - /// The to register. - /// The to support fluent-style method-chaining. - /// Internally this builds the relationship between the and the owning to enable cached access to the underlying - /// using or . - public ReferenceDataOrchestrator Register() where TProvider : IReferenceDataProvider - { - using var scope = ServiceProvider.CreateScope(); - var provider = scope.ServiceProvider.GetRequiredService(); + /// + /// Determines whether the contains the specified . + /// + /// The . + /// indicates that it exists; otherwise, . + public bool ContainsType(Type type) => _typeToProvider.ContainsKey(type); - foreach (var type in provider.Types.Where(x => x != null).Distinct()) - { - lock (_lock) - { - if (_nameToType.ContainsKey(type.Name)) - throw new InvalidOperationException($"Type '{type.FullName}' cannot be added as name '{type.Name}' already associated with previously added Type '{_nameToType.GetValueOrDefault(type.Name)?.FullName}'."); + /// + /// Determines whether the contains the specified name (see ). + /// + /// The reference data name. + /// indicates that it exists; otherwise, . + public bool ContainsName(string name) => _nameToType.ContainsKey(name); - if (!_typeToProvider.TryAdd(type, typeof(TProvider))) - throw new InvalidOperationException($"Type '{type.FullName}' cannot be added as already associated with previously added Provider '{_typeToProvider.GetValueOrDefault(type)?.GetType().FullName}'."); + /// + /// Gets the for the specified synchronously. + /// + /// The . + /// The corresponding where found; otherwise, . + public IReferenceDataCollection? this[Type type] => GetByType(type); - _nameToType.TryAdd(type.Name, type); - } - } + /// + /// Gets the for the specified name (see ) synchronously. + /// + /// The reference data name. + /// The corresponding where found; otherwise, . + public IReferenceDataCollection? this[string name] => GetByName(name); - return this; - } + /// + /// Gets a list for all the registered types. + /// + /// The list. + public IEnumerable GetAllTypes() => _typeToProvider.Keys; - /// - /// Determines whether the contains the specified . - /// - /// The . - /// true indicates that it exists; otherwise, false. - public bool ContainsType() => ContainsType(typeof(TRef)); - - /// - /// Determines whether the contains the specified . - /// - /// The . - /// true indicates that it exists; otherwise, false. - public bool ContainsType(Type type) => _typeToProvider.ContainsKey(type); - - /// - /// Determines whether the contains the specified name (see ). - /// - /// The reference data name. - /// true indicates that it exists; otherwise, false. - public bool ContainsName(string name) => _nameToType.ContainsKey(name); - - /// - /// Gets the for the specified synchronously. - /// - /// The . - /// The corresponding where found; otherwise, null. - public IReferenceDataCollection? this[Type type] => GetByType(type); - - /// - /// Gets the for the specified name (see ) synchronously. - /// - /// The reference data name. - /// The corresponding where found; otherwise, null. - public IReferenceDataCollection? this[string name] => GetByName(name); - - /// - /// Gets a list for the registered types. - /// - /// The list. - public IEnumerable GetAllTypes() => _typeToProvider.Keys; - - /// - /// Gets the for the specified . - /// - /// The . - /// The corresponding where found; otherwise, null. - public IReferenceDataCollection? GetByType() => GetByType(typeof(TRef)); - - /// - /// Gets the for the specified . - /// - /// The . - /// The corresponding where found; otherwise, null. - public IReferenceDataCollection? GetByType(Type type) => Cache.TryGetValue(OnGetCacheKey(type), out IReferenceDataCollection? coll) ? coll! : Invoker.RunSync(() => GetByTypeAsync(type)); - - /// - /// Gets the for the specified (will throw where not found). - /// - /// The . - /// The corresponding where found; otherwise, will throw an . - public IReferenceDataCollection GetByTypeRequired() => GetByTypeRequired(typeof(TRef)); - - /// - /// Gets the for the specified (will throw where not found). - /// - /// The . - /// The corresponding where found; otherwise, will throw an . - public IReferenceDataCollection GetByTypeRequired(Type type) => Cache.TryGetValue(OnGetCacheKey(type), out IReferenceDataCollection? coll) ? coll! : Invoker.RunSync(() => GetByTypeRequiredAsync(type)); - - /// - /// Gets the for the specified . - /// - /// The . - /// The . - /// The corresponding where found; otherwise, null. - public Task GetByTypeAsync(CancellationToken cancellationToken = default) => GetByTypeAsync(typeof(TRef), cancellationToken); - - /// - /// Gets the for the specified . - /// - /// The . - /// The . - /// The corresponding where found; otherwise, null. - public async Task GetByTypeAsync(Type type, CancellationToken cancellationToken = default) - { - if (!_typeToProvider.TryGetValue(type.ThrowIfNull(nameof(type)), out var providerType)) - return null; + /// + /// Gets the for the specified . + /// + /// The . + /// The corresponding where found; otherwise, . + public IReferenceDataCollection? GetByType() where TRef : IReferenceData => GetByType(typeof(TRef)); + + /// + /// Gets the for the specified . + /// + /// The . + /// The corresponding where found; otherwise, . + public IReferenceDataCollection? GetByType(Type type) + { + // To ensure greatest flexibility of the reference data capabilities, it may (and often from class properties by-design), be called in contexts where an async execution context is not available, + // so we need to support a synchronous call pattern which will execute the underlying async code as it will need to get from cache or underlying repository. RunSync is also optimized to avoid + // unnecessary context switches where the underlying code is already completed. + return Invoker.RunSync(() => GetByTypeAsync(type)); + } + + /// + /// Gets the for the specified (will throw where not found). + /// + /// The . + /// The corresponding where found; otherwise, will throw an . + public IReferenceDataCollection GetByTypeRequired() where TRef : IReferenceData => GetByTypeRequired(typeof(TRef)); + + /// + /// Gets the for the specified (will throw where not found). + /// + /// The . + /// The corresponding where found; otherwise, will throw an . + public IReferenceDataCollection GetByTypeRequired(Type type) => Invoker.RunSync(() => GetByTypeRequiredAsync(type)); + + /// + /// Gets the for the specified . + /// + /// The . + /// The . + /// The corresponding where found; otherwise, . + public Task GetByTypeAsync(CancellationToken cancellationToken = default) where TRef : IReferenceData => GetByTypeAsync(typeof(TRef), cancellationToken); - var coll = await OnGetOrCreateAsync(type, (t, ct) => + /// + /// Gets the for the specified . + /// + /// The . + /// The . + /// The corresponding where found; otherwise, . + public async Task GetByTypeAsync(Type type, CancellationToken cancellationToken = default) + { + if (!_typeToProvider.TryGetValue(type.ThrowIfNull(), out var providerType)) + return null; + + if (!ExecutionContext.HasCurrent) + throw new InvalidOperationException($"The {nameof(ReferenceDataOrchestrator)} requires an active {nameof(ExecutionContext)} to support underlying scoped service resolution."); + + // Get the underlying scoped cache. + var cache = ExecutionContext.GetRequiredService(); + + // Get the corresponding reference data collection type. + var collType = _typeToCollType[type]; + + // Get or create the reference data collection. + var coll = await cache.GetOrCreateAsync(collType, (collType, cancellationToken) => + { + return _invoker.InvokeAsync(this, async (tracer, cancellationToken) => { - return ReferenceDataOrchestratorInvoker.Current.InvokeAsync(this, async (ia, ct) => + if (tracer.Activity is not null) { - if (ia.Activity is not null) - { - ia.Activity.AddTag(InvokerCacheType, type.ToString()); - ia.Activity.AddTag(InvokerCacheState, "TaskRun"); - } + tracer.Activity.AddTag(InvokerCacheType, type.ToString()); + tracer.Activity.AddTag(InvokerCacheState, "Task.Run"); + } + if (Logger?.IsEnabled(LogLevel.Debug) == true) Logger.LogDebug("Reference data type {RefDataType} cache load start: ServiceProvider.CreateScope and Threading.ExecutionContext.SuppressFlow to support underlying cache data get.", type.FullName); - using var ec = ExecutionContext.Current.CreateCopy(); - var rdo = this; - using var scope = ServiceProvider.CreateScope(); - Task task; - using (System.Threading.ExecutionContext.SuppressFlow()) + /* + * OK, a bit of complexity going on here. Why? As we do not know exactly when this method will be called, we need to: + * a) ensure that the underlying reference data provider is executed in a completely separate execution context to the caller to ensure that any scoped services used by the provider do not + * impact the caller's execution context and/or scoped services. + * b) when called, are we are retrieving data from a repository, and the caller in a transaction scope, we do not want to flow the transaction scope to the new thread as this may cause deadlocks; + * achieved by using Threading.ExecutionContext.SuppressFlow. + * c) a Task.Run is used to ensure that the provider execution occurs on a different thread to the caller, again to ensure complete execution context separation. Note that the Task.Run is executed + * within the context of the cache GetOrCreateAsync factory method, so will only be used when the cache item is not already populated. + */ + + using var ec = ExecutionContext.Current.CreateCopy(); + var rdo = this; + + await using var scope = ServiceProvider.CreateAsyncScope(); + Task task; + using (System.Threading.ExecutionContext.SuppressFlow()) + { + task = Task.Run(async () => { - task = Task.Run(async () => await GetByTypeInNewScopeAsync(rdo, ec, scope, t, providerType, ia, ct).ConfigureAwait(false)); - } + try + { + return await GetByTypeInNewScopeAsync(rdo, ec, scope, type, providerType, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (Logger?.IsEnabled(LogLevel.Error) == true) + { + Logger.LogError(ex, "Reference data type {RefDataType} cache load failed in worker task: {ex.Message}", type.FullName, ex.Message); + throw; // Re-throw to propagate + } + }); + } -#if NET6_0_OR_GREATER - return await task.WaitAsync(ct).ConfigureAwait(false); -#else - task.Wait(ct); - await Task.CompletedTask.ConfigureAwait(false); - return task.Result; -#endif - }, cancellationToken, nameof(GetByTypeAsync)); - }, cancellationToken).ConfigureAwait(false); + var coll = await task.WaitAsync(cancellationToken).ConfigureAwait(false); - return coll ?? throw new InvalidOperationException($"The {nameof(IReferenceDataCollection)} returned for Type '{type.FullName}' from Provider '{providerType.FullName}' must not be null."); - } + if (Logger?.IsEnabled(LogLevel.Information) == true) + Logger.LogInformation("Reference data type {RefDataType} cache load finish: {ItemCount} items cached.", type.ToString(), coll.Count); - /// - /// Performs the actual reference data load in a new thread context / scope. - /// - private async Task GetByTypeInNewScopeAsync(ReferenceDataOrchestrator rdo, ExecutionContext executionContext, IServiceScope scope, Type type, Type providerType, InvokeArgs invokeArgs, CancellationToken cancellationToken) - { - _asyncLocal.Value = rdo; + tracer.Activity?.AddTag(InvokerCacheCount, coll.Count); - executionContext.ServiceProvider = scope.ServiceProvider; - ExecutionContext.SetCurrent(executionContext); + return coll; + }, cancellationToken, nameof(GetByTypeAsync)); + }, cancellationToken).ConfigureAwait(false); - // Start related activity as this "work" is occuring on an unrelated different thread (by design to ensure complete separation). - var ria = invokeArgs.StartNewRelated(invokeArgs.Invoker, rdo, nameof(GetByTypeInNewScopeAsync)); - try - { - if (ria.Activity is not null) - { - ria.Activity.AddTag(InvokerCacheType, type.ToString()); - ria.Activity.AddTag(InvokerCacheState, "TaskWorker"); - } + return coll ?? throw new InvalidOperationException($"The {nameof(IReferenceDataCollection)} returned for Type '{type.FullName}' from Provider '{providerType.FullName}' must not be null."); + } - var sw = Stopwatch.StartNew(); - var provider = (IReferenceDataProvider)scope.ServiceProvider.GetRequiredService(providerType); - var coll = (await provider.GetAsync(type, cancellationToken).ConfigureAwait(false)).Value!; - sw.Stop(); + /// + /// Performs the actual reference data load in a new thread context / scope. + /// + private async Task GetByTypeInNewScopeAsync(ReferenceDataOrchestrator rdo, ExecutionContext executionContext, AsyncServiceScope scope, Type type, Type providerType, CancellationToken cancellationToken) + { + _asyncLocal.Value = rdo; - Logger.LogInformation("Reference data type {RefDataType} cache load finish: {ItemCount} items cached [{Elapsed}ms]", type.ToString(), coll.Count, sw.Elapsed.TotalMilliseconds); - ria.Activity?.AddTag(InvokerCacheCount, coll.Count); + executionContext.ServiceProvider = scope.ServiceProvider; + ExecutionContext.SetCurrent(executionContext); - return ria.TraceResult(coll); - } - catch (Exception ex) - { - ria.TraceException(ex); - throw; - } - finally + // Start related activity as this "work" is occurring on an unrelated different thread (by design to ensure complete execution separation). + return await _invoker.InvokeAsync(rdo, async (tracer, cancellationToken) => + { + if (tracer.Activity is not null) { - ria.TraceComplete(); + tracer.Activity.AddTag(InvokerCacheType, type.ToString()); + tracer.Activity.AddTag(InvokerCacheState, "Task.Worker"); } - } - /// - /// Gets the for the specified (will throw where not found). - /// - /// The . - /// The corresponding where found; otherwise, will throw an . - public Task GetByTypeRequiredAsync(CancellationToken cancellationToken = default) => GetByTypeRequiredAsync(typeof(TRef), cancellationToken); - - /// - /// Gets the for the specified (will throw where not found). - /// - /// The . - /// The . - /// The corresponding where found; otherwise, will throw an . - public async Task GetByTypeRequiredAsync(Type type, CancellationToken cancellationToken = default) - => (await GetByTypeAsync(type, cancellationToken).ConfigureAwait(false)) ?? throw new InvalidOperationException($"Reference data collection for type '{type.FullName}' does not exist."); - - /// - /// Gets where pre-existing and not expired, or (re-)creates, the for the specified . - /// - /// The . - /// The underlying function to invoke to get the when (re-)creating cache collection. - /// The . - /// The . - /// Invokes the prior to invoking on create. This should be overridden where the default capabilities are not - /// sufficient. The contains the logic to invoke the underlying ; this is executed in the context of a - /// to limit/minimize any impact on the processing of the current request by isolating all scoped services. Additionally, semaphore locks are used to - /// manage concurrency to ensure cache loading is thread-safe. - private async Task OnGetOrCreateAsync(Type type, Func> getCollAsync, CancellationToken cancellationToken = default) - { - // Try and get as most likely already in the cache; where exists then exit fast. - var key = OnGetCacheKey(type); - if (Cache.TryGetValue(key, out IReferenceDataCollection? coll)) - return coll!; + var provider = (IReferenceDataProvider)scope.ServiceProvider.GetRequiredService(providerType); + var coll = await provider.GetAsync(type, cancellationToken).ConfigureAwait(false); - // Get or add a new semaphore for the cache key so we can manage single concurrency for that key only. - SemaphoreSlim semaphore = _semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + tracer.Activity?.AddTag(InvokerCacheCount, coll.Count); - // Use the semaphore to manage a single thread to perform the "expensive" get operation. - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - // Does a get or create as it may have been added as we went to lock. - return (await Cache.GetOrCreateAsync(key, async entry => - { - OnCreateCacheEntry(type, entry); - return await getCollAsync(type, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The returned collection must not be null."); - }).ConfigureAwait(false))!; - } - finally - { - semaphore.Release(); - } - } + return coll; + }, cancellationToken).ConfigureAwait(false); + } - /// - /// Gets the cache key to be used (defaults to ). - /// - /// The . - /// The cache key. - /// Leverages the . - /// To support the likes of multi-tenancy caching then the resulting cache key should be overridden to include the both the and . - protected virtual object OnGetCacheKey(Type type) => CacheEntryConfig.GetCacheKey(type); - - /// - /// Provides an opportunity to the maintain the data prior to the cache create function being invoked (as a result of ). - /// - /// The . - /// The . - /// Leverages the . - protected virtual void OnCreateCacheEntry(Type type, ICacheEntry entry) => CacheEntryConfig.CreateCacheEntry(type, entry); - - /// - /// Gets the for the specified name (see ). - /// - /// The reference data name. - /// The corresponding where found; otherwise, null. - public IReferenceDataCollection? GetByName(string name) - => _nameToType.TryGetValue(name.ThrowIfNull(nameof(name)), out var type) ? GetByType(type) : null; - - /// - /// Gets the for the specified name (see ). - /// - /// The reference data name. - /// The corresponding where found; otherwise, null. - public IReferenceDataCollection GetByNameRequired(string name) - => _nameToType.TryGetValue(name.ThrowIfNull(nameof(name)), out var type) ? GetByTypeRequired(type) : throw new InvalidOperationException($"Reference data collection for name '{name}' does not exist."); - - /// - /// Gets the for the specified name (see ). - /// - /// The reference data name. - /// The . - /// The corresponding where found; otherwise, null. - public Task GetByNameAsync(string name, CancellationToken cancellationToken = default) - => _nameToType.TryGetValue(name.ThrowIfNull(nameof(name)), out var type) ? GetByTypeAsync(type, cancellationToken) : Task.FromResult(null); - - /// - /// Gets the for the specified name (see ). - /// - /// The reference data name. - /// The . - /// The corresponding where found; otherwise, null. - public Task GetByNameRequiredAsync(string name, CancellationToken cancellationToken = default) - => _nameToType.TryGetValue(name.ThrowIfNull(nameof(name)), out var type) ? GetByTypeRequiredAsync(type, cancellationToken) : throw new InvalidOperationException($"Reference data collection for name '{name}' does not exist."); - - /// - /// Gets the list for the specified applying the and filter. - /// - /// The . - /// The reference data code list. - /// The reference data text (including wildcards). - /// Indicates whether to include inactive ( equal false) entries. - /// The . - /// The filtered collection. - public async Task> GetWithFilterAsync(IEnumerable? codes = null, string? text = null, bool includeInactive = false, CancellationToken cancellationToken = default) where TRef : IReferenceData - => (await GetWithFilterAsync(typeof(TRef), codes, text, includeInactive, cancellationToken).ConfigureAwait(false)).OfType(); - - /// - /// Gets the list for the specified applying the and filter. - /// - /// The . - /// The reference data code list. - /// The reference data text (including wildcards). - /// Indicates whether to include inactive ( equal false) entries. - /// The . - /// The filtered collection. - public async Task> GetWithFilterAsync(Type type, IEnumerable? codes = null, string? text = null, bool includeInactive = false, CancellationToken cancellationToken = default) - => await GetWithFilterAsync(await GetByTypeAsync(type, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException($"Reference data collection for type '{type.FullName}' does not exist."), codes, text, includeInactive, cancellationToken).ConfigureAwait(false); - - /// - /// Gets the list for the specified name (see ) applying the and filter. - /// - /// The reference data name. - /// The reference data code list. - /// The reference data text (including wildcards). - /// Indicates whether to include inactive ( equal false) entries. - /// The . - /// The filtered collection. - public async Task> GetWithFilterAsync(string name, IEnumerable? codes = null, string? text = null, bool includeInactive = false, CancellationToken cancellationToken = default) - => await GetWithFilterAsync(await GetByNameAsync(name, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException($"Reference data collection for name '{name}' does not exist."), codes, text, includeInactive, cancellationToken).ConfigureAwait(false); - - /// - /// Applys the selected filter to the collection. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Future proofing.")] - private static Task> GetWithFilterAsync(IReferenceDataCollection coll, IEnumerable? codes = null, string? text = null, bool includeInactive = false, CancellationToken cancellationToken = default) - { - if ((codes == null || !codes.Any()) && string.IsNullOrEmpty(text) && !includeInactive) - return Task.FromResult(coll.ActiveItems); + /// + /// Gets the for the specified (will throw where not found). + /// + /// The . + /// The corresponding where found; otherwise, will throw an . + public Task GetByTypeRequiredAsync(CancellationToken cancellationToken = default) where TRef : IReferenceData + => GetByTypeRequiredAsync(typeof(TRef), cancellationToken); - // Validate the arguments. - if (text != null && !Wildcard.Default.Validate(text)) - throw new ValidationException(TextWildcardErrorMessage); + /// + /// Gets the for the specified (will throw where not found). + /// + /// The . + /// The . + /// The corresponding where found; otherwise, will throw an . + public async Task GetByTypeRequiredAsync(Type type, CancellationToken cancellationToken = default) + => (await GetByTypeAsync(type, cancellationToken).ConfigureAwait(false)) ?? throw new InvalidOperationException($"Reference data collection for type '{type.FullName}' does not exist."); - // Apply the filter. - var items = includeInactive ? coll.AllItems : coll.ActiveItems; - var result = items - .WhereWhen(x => codes!.Contains(x.Code, StringComparer.OrdinalIgnoreCase), codes != null && codes.Distinct().FirstOrDefault() != null) - .WhereWildcard(x => x.Text, text); + /// + /// Gets the for the specified name (see ). + /// + /// The reference data name. + /// The corresponding where found; otherwise, . + public IReferenceDataCollection? GetByName(string name) + => _nameToType.TryGetValue(name.ThrowIfNull(), out var type) ? GetByType(type) : null; - return Task.FromResult(result); - } + /// + /// Gets the for the specified name (see ). + /// + /// The reference data name. + /// The corresponding where found; otherwise, . + public IReferenceDataCollection GetByNameRequired(string name) + => _nameToType.TryGetValue(name.ThrowIfNull(), out var type) ? GetByTypeRequired(type) : throw new InvalidOperationException($"Reference data collection for name '{name}' does not exist."); - /// - /// Prefetches all of the named items. - /// - /// The list of names. - /// The . - /// As the reference data is a great candidate for caching this will force a prefetch of the cache items. - public Task PrefetchAsync(IEnumerable names, CancellationToken cancellationToken = default) - { -#if NET6_0_OR_GREATER - return Parallel.ForEachAsync(names, new ParallelOptions { MaxDegreeOfParallelism = PrefetchMaxDegreeOfParallelism, CancellationToken = cancellationToken }, - async (name, ct) => await GetByNameAsync(name, ct).ConfigureAwait(false)); -#else + /// + /// Gets the for the specified name (see ). + /// + /// The reference data name. + /// The . + /// The corresponding where found; otherwise, . + public Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + => _nameToType.TryGetValue(name.ThrowIfNull(), out var type) ? GetByTypeAsync(type, cancellationToken) : Task.FromResult(null); - var tasks = new List(); - if (names != null) - { - foreach (var name in names.Distinct(StringComparer.OrdinalIgnoreCase)) - { - tasks.Add(GetByNameAsync(name, cancellationToken)); - } - } + /// + /// Gets the for the specified name (see ). + /// + /// The reference data name. + /// The . + /// The corresponding where found; otherwise, . + public Task GetByNameRequiredAsync(string name, CancellationToken cancellationToken = default) + => _nameToType.TryGetValue(name.ThrowIfNull(), out var type) ? GetByTypeRequiredAsync(type, cancellationToken) : throw new InvalidOperationException($"Reference data collection for name '{name}' does not exist."); - return Task.WhenAll(tasks); -#endif - } + /// + /// Gets the list for the specified applying the and filter. + /// + /// The . + /// The reference data code list. + /// The reference data text (including wildcards). + /// Indicates whether to include inactive ( equal ) entries. + /// The . + /// The filtered collection. + public async Task> GetWithFilterAsync(IEnumerable? codes = null, string? textPattern = null, bool includeInactive = false, CancellationToken cancellationToken = default) where TRef : IReferenceData + => (await GetWithFilterAsync(typeof(TRef), codes, textPattern, includeInactive, cancellationToken).ConfigureAwait(false)).OfType(); - /// - /// Gets the reference data items for the specified . - /// - /// The reference data names. - /// Indicates whether to include inactive ( equal false) entries. - /// The . - /// The . - /// Will return an empty collection where no are specified. - public async Task GetNamedAsync(IEnumerable names, bool includeInactive = false, CancellationToken cancellationToken = default) - { - var mc = new ReferenceDataMultiDictionary(); + /// + /// Gets the list for the specified applying the and filter. + /// + /// The . + /// The reference data code list. + /// The reference data text (including wildcards). + /// Indicates whether to include inactive ( equal ) entries. + /// The . + /// The filtered collection. + public async Task> GetWithFilterAsync(Type type, IEnumerable? codes = null, string? textPattern = null, bool includeInactive = false, CancellationToken cancellationToken = default) + => GetWithFilterAsync(await GetByTypeAsync(type, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException($"Reference data collection for type '{type.FullName}' does not exist."), codes, textPattern, includeInactive); - if (names != null) - { - await PrefetchAsync(names, cancellationToken).ConfigureAwait(false); + /// + /// Gets the list for the specified name (see ) applying the and filter. + /// + /// The reference data name. + /// The reference data code list. + /// The reference data text (including wildcards). + /// Indicates whether to include inactive ( equal ) entries. + /// The . + /// The filtered collection. + public async Task> GetWithFilterAsync(string name, IEnumerable? codes = null, string? textPattern = null, bool includeInactive = false, CancellationToken cancellationToken = default) + => GetWithFilterAsync(await GetByNameAsync(name, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException($"Reference data collection for name '{name}' does not exist."), codes, textPattern, includeInactive); - foreach (var name in names.Where(ContainsName).Distinct(StringComparer.OrdinalIgnoreCase)) - { - mc.Add(_nameToType[name].Name, await GetWithFilterAsync(name, includeInactive: includeInactive, cancellationToken: cancellationToken).ConfigureAwait(false)); - } - } + /// + /// Apply the selected filter to the collection. + /// + private static IEnumerable GetWithFilterAsync(IReferenceDataCollection coll, IEnumerable? codes = null, string? textPattern = null, bool includeInactive = false) + { + if ((codes is null || !codes.Any()) && string.IsNullOrEmpty(textPattern) && !includeInactive) + return coll.ActiveItems; - return mc; - } + // Validate the arguments. + if (textPattern is not null && Wildcard.Default.Parse(textPattern).HasError) + throw new ValidationException(TextWildcardErrorMessage); - /// - /// Gets the reference data items for the specified names and related codes (see ). - /// - /// The reference data names and related codes. - /// Indicates whether to include inactive ( equal false) entries. - /// The . - /// The . - /// Will return an empty collection where no are specified. - public async Task GetNamedAsync(IEnumerable>> namesAndCodes, bool includeInactive = false, CancellationToken cancellationToken = default) - { - var mc = new ReferenceDataMultiDictionary(); + // Apply the filter. + var items = includeInactive ? coll.AllItems : coll.ActiveItems; + var result = items + .WhereWhen(codes is not null && codes.Any(), x => codes!.Contains(x.Code, StringComparer.OrdinalIgnoreCase)) + .WhereWildcard(x => x.Text, textPattern); - if (namesAndCodes != null) - { - await PrefetchAsync(namesAndCodes.Select(x => x.Key).AsEnumerable(), cancellationToken).ConfigureAwait(false); + return result; + } - foreach (var kvp in namesAndCodes.Where(x => ContainsName(x.Key))) - { - mc.Add(_nameToType[kvp.Key].Name, await GetWithFilterAsync(kvp.Key, codes: kvp.Value, includeInactive: includeInactive, cancellationToken: cancellationToken).ConfigureAwait(false)); - } - } + /// + /// Prefetches all of the named items. + /// + /// The list of names. + /// The . + /// The actual distinct list of names used. + /// As the reference data is a great candidate for caching this will force a prefetch of the cache items. + public async Task> PrefetchAsync(IEnumerable names, CancellationToken cancellationToken = default) + { + // Get the distinct list of known names to prefetch. + var list = names.Where(ContainsName).Distinct(StringComparer.OrdinalIgnoreCase); - return mc; - } + // Go get 'em all! + await Parallel.ForEachAsync(list, new ParallelOptions { MaxDegreeOfParallelism = PrefetchMaxDegreeOfParallelism, CancellationToken = cancellationToken }, + async (name, ct) => await GetByNameAsync(name, ct).ConfigureAwait(false)).ConfigureAwait(false); + + return list; + } + + /// + /// Gets the reference data items for the specified . + /// + /// The reference data names. + /// Indicates whether to include inactive ( equal ) entries. + /// The mapping of names to their replacement. + /// The . + /// The . + /// Will return an empty collection where no are specified. + public async Task GetNamedAsync(IEnumerable names, bool includeInactive = false, IDictionary? mapper = null, CancellationToken cancellationToken = default) + { + var mc = new ReferenceDataMultiDictionary(); - /// - /// Performs a conversion from a to an instance of . - /// - /// The . - /// The . - /// The instance. - /// Where the item () is not found it will be created and will be invoked. - [return: NotNullIfNotNull(nameof(code))] - public static TRef? ConvertFromCode(string? code) where TRef : IReferenceData, new() + if (names is not null) { - if (code == null) - return default; + var list = await PrefetchAsync(ReplaceNames(names, mapper), cancellationToken).ConfigureAwait(false); - if (ExecutionContext.HasCurrent) + foreach (var name in list) { - var rdc = Current.GetByType(); - if (rdc != null && rdc.TryGetByCode(code, out var rd)) - return (TRef)rd!; + mc.Add(mapper?.Where(x => x.Value == name).Select(x => x.Key).FirstOrDefault() ?? _nameToType[name].Name, + await GetWithFilterAsync(name, includeInactive: includeInactive, cancellationToken: cancellationToken).ConfigureAwait(false)); } - - var rdx = new TRef { Code = code }; - ((IReferenceData)rdx).SetInvalid(); - return rdx; } - /// - /// Performs a conversion from an to an instance of . - /// - /// The . - /// The . - /// The . - /// The instance. - /// Where the item () is not found it will be created and will be invoked. - [return: NotNullIfNotNull(nameof(id))] - public static TRef? ConvertFromId(TId? id) where TRef : IReferenceData, new() where TId : IComparable, IEquatable + return mc; + } + + /// + /// Replaces the specified based on the provided . + /// + private static IEnumerable ReplaceNames(IEnumerable names, IDictionary? mapper) + { + foreach (var name in names) { - if (id == null) - return default; + if (mapper is not null && mapper.TryGetValue(name, out var mappedName)) + yield return mappedName; + else + yield return name; + } + } - if (ExecutionContext.HasCurrent) + /// + /// Tries to get the item for the specified . + /// + /// The . + /// The . + /// The instance. + /// where found; otherwise, . + /// Where the item () is not found it will be created and will be invoked; unless where is + /// in which case a will also be returned. + public static bool TryGetByCode(string? code, out TRef item) where TRef : IReferenceData, new() + { + if (code is not null && HasCurrent) + { + var rdc = Current.GetByType(); + if (rdc is not null && rdc.TryGetByCode(code, out var rd)) { - var rdc = Current.GetByType(); - if (rdc != null && rdc.TryGetById(id, out var rd)) - return (TRef)rd!; + item = (TRef)rd!; + return true; } - - var rdx = new TRef { Id = id }; - ((IReferenceData)rdx).SetInvalid(); - return rdx; } - /// - /// Performs a conversion from an to an instance of . - /// - /// The . - /// The . - /// The instance. - /// Where the item () is not found it will be created and will be invoked. - [return: NotNullIfNotNull(nameof(id))] - public static TRef? ConvertFromId(object? id) where TRef : IReferenceData, new() - { - if (id == null) - return default; + item = new TRef { Code = code }; + ((IReferenceData)item).SetInvalid(); + return false; + } - if (ExecutionContext.HasCurrent) + /// + /// Tries to get the item for the specified . + /// + /// The . + /// The . + /// The . + /// The instance. + /// where found; otherwise, . + /// Where the item () is not found it will be created and will be invoked. + public static bool TryGetById(TId id, out TRef item) where TRef : IReferenceData, new() + { + if (id is not null && HasCurrent) + { + var rdc = Current.GetByType(); + if (rdc is not null && rdc.TryGetById(id, out var rd)) { - var rdc = Current.GetByType(); - if (rdc != null && rdc.TryGetById(id, out var rd)) - return (TRef)rd!; + item = (TRef)rd!; + return true; } - - var rdx = new TRef { Id = id }; - ((IReferenceData)rdx).SetInvalid(); - return rdx; } - /// - /// Performs a conversion from a mapping value to an instance of . - /// - /// The . - /// The mapping value . - /// The mapping name. - /// The mapping value. - /// Where the item () is not found it will be created and will be invoked. - public static TRef ConvertFromMapping(string name, T? value) where TRef : IReferenceData, new() where T : IComparable, IEquatable + item = new TRef { Id = id }; + ((IReferenceData)item).SetInvalid(); + return false; + } + + /// + /// Tries to get the item for the specified . + /// + /// The . + /// The . + /// The instance. + /// where found; otherwise, . + /// Where the item () is not found it will be created and will be invoked. + public static bool TryGetById(object id, out TRef item) where TRef : IReferenceData, new() + { + if (id is not null && HasCurrent) { - if (value != null && ExecutionContext.HasCurrent) + var rdc = Current.GetByType(); + if (rdc is not null && rdc.TryGetById(id, out var rd)) { - var rdc = Current.GetByType(); - if (rdc != null && rdc.TryGetByMapping(name, value, out var rd)) - return (TRef)rd!; + item = (TRef)rd!; + return true; } - - var rdx = new TRef(); - ((IReferenceData)rdx).SetInvalid(); - return rdx; } - /// - /// Gets all the types in the same namespace as . - /// - /// The to infer the namespace. - /// The list. - public static IEnumerable GetAllTypesInNamespace() => typeof(TNamespace).Assembly.GetTypes().Where(t => t.Namespace == typeof(TNamespace).Namespace && t.IsClass && !t.IsAbstract && typeof(IReferenceData).IsAssignableFrom(t)); + item = new TRef { Id = id }; + ((IReferenceData)item).SetInvalid(); + return false; + } + + + /// + /// Performs a conversion from a mapping value to an instance of . + /// + /// The . + /// The mapping value . + /// The mapping name. + /// The mapping value. + /// Where the item () is not found it will be created and will be invoked. + public static TRef ConvertFromMapping(string name, T? value) where TRef : IReferenceData, new() where T : IComparable, IEquatable + { + if (value is not null && HasCurrent) + { + var rdc = Current.GetByType(); + if (rdc is not null && rdc.TryGetByMapping(name, value, out var rd)) + return (TRef)rd!; + } + var rdx = new TRef(); + ((IReferenceData)rdx).SetInvalid(); + return rdx; } + + /// + /// Gets all the types in the same namespace as . + /// + /// The to infer the namespace. + /// The list. + public static IEnumerable GetAllTypesInNamespace() + => typeof(TNamespace).Assembly.GetTypes().Where(t => t.Namespace == typeof(TNamespace).Namespace && t.IsClass && !t.IsAbstract && typeof(IReferenceData).IsAssignableFrom(t)); } \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataOrchestratorInvoker.cs b/src/CoreEx/RefData/ReferenceDataOrchestratorInvoker.cs index c15ee57d..582bef1d 100644 --- a/src/CoreEx/RefData/ReferenceDataOrchestratorInvoker.cs +++ b/src/CoreEx/RefData/ReferenceDataOrchestratorInvoker.cs @@ -1,19 +1,7 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.RefData; -using CoreEx.Invokers; - -namespace CoreEx.RefData -{ - /// - /// Provides the invocation wrapping for the instances. - /// - public class ReferenceDataOrchestratorInvoker : InvokerBase - { - private static ReferenceDataOrchestratorInvoker? _default; - - /// - /// Gets the current configured instance (see ). - /// - public static ReferenceDataOrchestratorInvoker Current => CoreEx.ExecutionContext.GetService() ?? (_default ??= new ReferenceDataOrchestratorInvoker()); - } -} \ No newline at end of file +/// +/// Provides the invoker. +/// +[InvokerName("CoreEx.RefData.ReferenceDataOrchestrator")] +public sealed class ReferenceDataOrchestratorInvoker : InvokerBase { } \ No newline at end of file diff --git a/src/CoreEx/RefData/ReferenceDataSortOrder.cs b/src/CoreEx/RefData/ReferenceDataSortOrder.cs deleted file mode 100644 index 2bc31d91..00000000 --- a/src/CoreEx/RefData/ReferenceDataSortOrder.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; - -namespace CoreEx.RefData -{ - /// - /// Provides the sort order for the reference data. - /// - public enum ReferenceDataSortOrder - { - /// - /// Ordered by , then , and finally (default). - /// - SortOrder, - - /// - /// Ordered by . - /// - Id, - - /// - /// Ordered by . - /// - Code, - - /// - /// Ordered by and then . - /// - Text - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/Abstractions/IResult.cs b/src/CoreEx/Results/Abstractions/IResult.cs new file mode 100644 index 00000000..56940e83 --- /dev/null +++ b/src/CoreEx/Results/Abstractions/IResult.cs @@ -0,0 +1,42 @@ +namespace CoreEx.Results.Abstractions; + +/// +/// Enables the use of a Result type to represent the outcome of an operation. +/// +public interface IResult +{ + /// + /// Gets the underlying value where . + /// + object? Value { get; } + + /// + /// Gets the underlying error represented as an . + /// + Exception Error { get; } + + /// + /// Indicates whether the result is in a successful state. + /// + bool IsSuccess { get; } + + /// + /// Indicates whether the result is in a failure state. + /// + /// Where then the will contain a corresponding . + bool IsFailure { get; } + + /// + /// Creates an equivalent that is considered . + /// + /// The underlying error represented as an . + /// The resulting . + IResult ToFailure(Exception error); + + /// + /// Indicates whether the result is in a failure state and the underlying error is of the specified . + /// + /// The . + /// indicates that the result is in a failure state and the underlying error is of the specified . + bool IsFailureOfType() where TException : Exception; +} \ No newline at end of file diff --git a/src/CoreEx/Results/Abstractions/IResultT.cs b/src/CoreEx/Results/Abstractions/IResultT.cs new file mode 100644 index 00000000..46c2ea8a --- /dev/null +++ b/src/CoreEx/Results/Abstractions/IResultT.cs @@ -0,0 +1,14 @@ +namespace CoreEx.Results.Abstractions; + +/// +/// Enables the use of a Result type with a to represent the success outcome of an operation. +/// +/// The . +public interface IResult : IResult +{ + /// + /// Gets the underlying value where . + /// + /// Where then the corresponding will be thrown. + new T Value { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Results/Abstractions/IToResult.cs b/src/CoreEx/Results/Abstractions/IToResult.cs new file mode 100644 index 00000000..7100bdce --- /dev/null +++ b/src/CoreEx/Results/Abstractions/IToResult.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Results.Abstractions; + +/// +/// Enables the creation of a representing the current state. +/// +public interface IToResult +{ + /// + /// Creates a representing the current state. + /// + Result ToResult(); +} \ No newline at end of file diff --git a/src/CoreEx/Results/Abstractions/IToResultT.cs b/src/CoreEx/Results/Abstractions/IToResultT.cs new file mode 100644 index 00000000..4913bb6d --- /dev/null +++ b/src/CoreEx/Results/Abstractions/IToResultT.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Results.Abstractions; + +/// +/// Enables the creation of a representing the current state. +/// +public interface IToResult : IToResult +{ + /// + Result IToResult.ToResult() => ToResult().AsResult(); + + /// + /// Creates a representing the current state. + /// + new Result ToResult(); +} \ No newline at end of file diff --git a/src/CoreEx/Results/AnyExtensions.cs b/src/CoreEx/Results/AnyExtensions.cs deleted file mode 100644 index 26ac200e..00000000 --- a/src/CoreEx/Results/AnyExtensions.cs +++ /dev/null @@ -1,1076 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading.Tasks; - -namespace CoreEx.Results -{ - public static partial class ResultsExtensions - { - #region Synchronous - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result Any(this Result result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - action(); - return result; - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result Any(this Result result, Func func) - { - ThrowIfNull(result, func); - return func(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result Any(this Result result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - action(result.Value); - return result; - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result Any(this Result result, Func func) - { - ThrowIfNull(result, func); - return Result.Ok(func(result.Value)); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result Any(this Result result, Func> func) - { - ThrowIfNull(result, func); - return func(result.Value); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return Result.Ok(func()); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return func(); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - action(result.Value); - return result.Bind(); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return func(result.Value); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return Result.Ok(func(result.Value)); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return func(result.Value); - } - - /* IToResult */ - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result Any(this Result result, Func func) - { - ThrowIfNull(result, func); - return func().ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result Any(this Result result, Func func) - { - ThrowIfNull(result, func); - return func(result.Value).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result Any(this Result result, Func> func) - { - ThrowIfNull(result, func); - return func(result.Value).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return func(result.Value).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return func().ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return func().ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return func(result.Value).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result AnyAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return func(result.Value).ToResult(); - } - - #endregion - - #region AsyncResult - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task Any(this Task result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.Any(action); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task Any(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.Any(func); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> Any(this Task> result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.Any(action); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> Any(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.Any(func); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> Any(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.Any(func); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAs(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.AnyAs(func); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAs(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.AnyAs(func); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAs(this Task> result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.AnyAs(action); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.AnyAs(func); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.AnyAs(func); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAs(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.AnyAs(func); - } - - /* IToResult */ - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task Any(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.Any(func); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> Any(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.Any(func); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> Any(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.Any(func); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.AnyAs(func); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAs(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.AnyAs(func); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAs(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.AnyAs(func); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.AnyAs(func); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAs(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.AnyAs(func); - } - - #endregion - - #region AsyncFunc - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsync(this Result result, Func func) - { - ThrowIfNull(result, func); - if (result.IsSuccess) - await func().ConfigureAwait(false); - - return result; - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return await func().ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsync(this Result result, Func func) - { - ThrowIfNull(result, func); - if (result.IsSuccess) - await func(result.Value).ConfigureAwait(false); - - return result; - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return Result.Ok(await func(result.Value).ConfigureAwait(false)); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return await func(result.Value).ConfigureAwait(false); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return Result.Ok(await func().ConfigureAwait(false)); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return await func().ConfigureAwait(false); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsAsync(this Result result, Func func) - { - ThrowIfNull(result, func); - if (result.IsSuccess) - await func(result.Value).ConfigureAwait(false); - - return result.Bind(); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return await func(result.Value).ConfigureAwait(false); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return Result.Ok(await func(result.Value).ConfigureAwait(false)); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return await func(result.Value).ConfigureAwait(false); - } - - /* IToResult */ - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return (await func().ConfigureAwait(false)).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return (await func(result.Value).ConfigureAwait(false)).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return (await func(result.Value).ConfigureAwait(false)).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return (await func(result.Value).ConfigureAwait(false)).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return (await func().ConfigureAwait(false)).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return (await func().ConfigureAwait(false)).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return (await func(result.Value).ConfigureAwait(false)).ToResult(); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return (await func(result.Value).ConfigureAwait(false)).ToResult(); - } - - #endregion - - #region AsyncBoth - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsync(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsync(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Task result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsAsync(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the regardless of the state (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - /* IToResult */ - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task AnyAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Task result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the regardless of the state. - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> AnyAsAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.AnyAsAsync(func).ConfigureAwait(false); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/CoreExtensions.cs b/src/CoreEx/Results/CoreExtensions.cs deleted file mode 100644 index 86333e1c..00000000 --- a/src/CoreEx/Results/CoreExtensions.cs +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Diagnostics; - -namespace CoreEx.Results -{ - /// - /// Provides the and core extension methods. - /// - [DebuggerStepThrough] - public static class CoreExtensions - { - /// - /// Unwraps the and where invokes the and returns the resulting ; - /// otherwise, where returns a resulting instance with the corresponding . - /// - /// The . - /// The output (resulting) . - /// The . - /// The binding (mapping) function. - /// The resulting . - public static Result Bind(this Result result, Func> func) - { - func.ThrowIfNull(nameof(func)); - return result.IsSuccess ? func(result.Value) : new Result(result.Error!); - } - - /// - /// Binds/converts the to a corresponding defaulting to where losing the ; - /// otherwise, where returns a resulting instance with the corresponding . - /// - /// The . - /// The output (resulting) . - /// The . - /// The resulting . - public static Result Bind(this Result result) => result.IsSuccess ? (result.Value is U uv ? new Result(uv) : Result.None) : new Result(result.Error!); - - /// - /// Unwraps the and where invokes the and returns the resulting ; - /// otherwise, where returns a resulting instance with the corresponding . - /// - /// The output (resulting) . - /// The . - /// The binding (mapping) function. - /// The resulting . - public static Result Bind(this Result result, Func> func) - { - func.ThrowIfNull(nameof(func)); - return result.IsSuccess ? func() : new Result(result.Error!); - } - - /// - /// Binds/converts the to a corresponding defaulting to where ; - /// otherwise, where returns a resulting instance with the corresponding . - /// - /// The output (resulting) . - /// The . - /// The resulting . - public static Result Bind(this Result result) => result.IsSuccess ? Result.None : new Result(result.Error!); - - /// - /// Binds/converts the to a corresponding losing the . - /// - /// The . - /// The . - /// The resulting . - public static Result Bind(this Result result) => result.IsSuccess ? Result.Success : new Result(result.Error); - - /// - /// Maps a into a . - /// - /// The . - /// The value to map. - /// The resulting . - public static Result Map(T value) => new(value); - - /// - /// Combines the and into a single . - /// - /// The . - /// The other . - /// The resulting . - /// Where either or both are failures, a failure state will be returned with the corresponding ; where both contains errors these will be aggregated into an . - public static Result Combine(this Result result, Result other) - { - if (result.IsFailure && other.IsFailure) - return new Result(new AggregateException(result.Error, other.Error)); - - return result.IsFailure ? result : other; - } - - /// - /// Combines the and into a single ; on success the will be returned. - /// - /// The . - /// The . - /// The other . - /// The resulting . - /// Where either or both are failures, a failure state will be returned with the corresponding ; where both contains errors these will be aggregated into an . - public static Result Combine(this Result result, Result other) - { - if (result.IsFailure && other.IsFailure) - return new Result(new AggregateException(result.Error, other.Error)); - - if (result.IsFailure) - return new Result(result.Error!); - - return other; - } - - /// - /// Combines the and into a single . - /// - /// The . - /// The other and resulting . - /// The . - /// The other . - /// The resulting . - /// Where either or both are failures, a failure state will be returned with the corresponding ; where both contains errors these will be aggregated into an . - /// Where both are successful and, and are the same , the is used as the returned value (i.e is ignored). - public static Result Combine(this Result result, Result other) - { - if (result.IsFailure && other.IsFailure) - return new Result(new AggregateException(result.Error, other.Error)); - - if (result.IsFailure) - return new Result(result.Error!); - - if (other.IsFailure) - return other; - - return result.Value is U uv ? uv : other; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/IResult.cs b/src/CoreEx/Results/IResult.cs deleted file mode 100644 index 20c40b36..00000000 --- a/src/CoreEx/Results/IResult.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Results -{ - /// - /// Enables the use of a Result type to represent the outcome of an operation. - /// - public interface IResult - { - /// - /// Gets the underlying value where . - /// - object? Value { get; } - - /// - /// Gets the underlying error represented as an . - /// - Exception Error { get; } - - /// - /// Indicates whether the result is in a successful state. - /// - bool IsSuccess { get; } - - /// - /// Indicates whether the result is in a failure state. - /// - /// Where true then the will contain a corresponding . - bool IsFailure { get; } - - /// - /// Creates an equivalent that is considered . - /// - /// The underlying error represented as an . - /// The resulting . - IResult ToFailure(Exception error); - - /// - /// Indicates whether the result is in a failure state and the underlying error is of the specified . - /// - /// The . - /// true indicates that the result is in a failure state and the underlying error is of the specified . - bool IsFailureOfType() where TException : Exception; - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/IResultT.cs b/src/CoreEx/Results/IResultT.cs deleted file mode 100644 index 6d32946f..00000000 --- a/src/CoreEx/Results/IResultT.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; - -namespace CoreEx.Results -{ - /// - /// Enables the use of a Result type with a to represent the outcome of an operation. - /// - /// The . - public interface IResult : IResult - { - /// - /// Gets the underlying value where . - /// - new T Value { get; } - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/IToResult.cs b/src/CoreEx/Results/IToResult.cs deleted file mode 100644 index 036a6f13..00000000 --- a/src/CoreEx/Results/IToResult.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Results -{ - /// - /// Enables the to convert into a corresponding . - /// - public interface IToResult - { - /// - /// Converts into a corresponding . - /// - /// The resulting . - Result ToResult(); - } -} diff --git a/src/CoreEx/Results/IToResultT.cs b/src/CoreEx/Results/IToResultT.cs deleted file mode 100644 index 60f251e7..00000000 --- a/src/CoreEx/Results/IToResultT.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Results -{ - /// - /// Enables the to convert into a corresponding . - /// - /// The . - public interface IToResult - { - /// - /// Converts into a corresponding . - /// - /// The resulting . - Result ToResult(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/ITypedToResult.cs b/src/CoreEx/Results/ITypedToResult.cs deleted file mode 100644 index ad68ba59..00000000 --- a/src/CoreEx/Results/ITypedToResult.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -namespace CoreEx.Results -{ - /// - /// Enables the to convert into a corresponding . - /// - /// The resulting . - public interface ITypedToResult : IToResult - { - /// - /// Converts into a corresponding . - /// - /// The . - /// The resulting . - Result ToResult(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/MatchExtensions.cs b/src/CoreEx/Results/MatchExtensions.cs deleted file mode 100644 index b70df482..00000000 --- a/src/CoreEx/Results/MatchExtensions.cs +++ /dev/null @@ -1,438 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading.Tasks; - -namespace CoreEx.Results -{ - public static partial class ResultsExtensions - { - #region Synchronous - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Result Match(this Result result, Action ok, Action fail) - { - MatchThrowIfNull(result, ok, fail); - return result.Match(() => - { - ok(); - return result; - }, e => - { - fail(e); - return result; - }); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Result Match(this Result result, Func ok, Func fail) - { - MatchThrowIfNull(result, ok, fail); - return result.IsSuccess ? ok() : fail(result.Error); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Result MatchAs(this Result result, Func> ok, Func> fail) - { - MatchThrowIfNull(result, ok, fail); - return result.IsSuccess ? ok() : fail(result.Error); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Result Match(this Result result, Action ok, Action fail) - { - MatchThrowIfNull(result, ok, fail); - return result.Match(v => - { - ok(v); - return result; - }, e => - { - fail(e); - return result; - }); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Result Match(this Result result, Func> ok, Func> fail) - { - MatchThrowIfNull(result, ok, fail); - return result.IsSuccess ? ok(result.Value) : fail(result.Error); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The output (resulting) . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Result MatchAs(this Result result, Func> ok, Func> fail) - { - MatchThrowIfNull(result, ok, fail); - return result.IsSuccess ? ok(result.Value) : fail(result.Error); - } - - #endregion - - #region AsyncResult - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task Match(this Task result, Action ok, Action fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - return r.Match(() => - { - ok(); - return r; - }, e => - { - fail(e); - return r; - }); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task Match(this Task result, Func ok, Func fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - return r.IsSuccess ? ok() : fail(r.Error); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task> MatchAs(this Task result, Func> ok, Func> fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - return r.IsSuccess ? ok() : fail(r.Error); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task> Match(this Task> result, Action ok, Action fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - return r.Match(v => - { - ok(v); - return r; - }, e => - { - fail(e); - return r; - }); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task> Match(this Task> result, Func> ok, Func> fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - return r.IsSuccess ? ok(r.Value) : fail(r.Error); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The output (resulting) . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task> MatchAs(this Task> result, Func> ok, Func> fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - return r.IsSuccess ? ok(r.Value) : fail(r.Error); - } - - #endregion - - #region AsyncFunc - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task MatchAsync(this Result result, Func ok, Func fail) - { - MatchThrowIfNull(result, ok, fail); - return await result.MatchAsync(async () => - { - await ok().ConfigureAwait(false); - return result; - }, async e => - { - await fail(e).ConfigureAwait(false); - return result; - }).ConfigureAwait(false); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Task MatchAsync(this Result result, Func> ok, Func> fail) - { - MatchThrowIfNull(result, ok, fail); - return result.IsSuccess ? ok() : fail(result.Error); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Task> MatchAsAsync(this Result result, Func>> ok, Func>> fail) - { - MatchThrowIfNull(result, ok, fail); - return result.IsSuccess ? ok() : fail(result.Error); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Task> MatchAsync(this Result result, Func ok, Func fail) - { - MatchThrowIfNull(result, ok, fail); - return result.MatchAsync(async v => - { - await ok(v).ConfigureAwait(false); - return result; - }, async e => - { - await fail(e).ConfigureAwait(false); - return result; - }); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Task> MatchAsync(this Result result, Func>> ok, Func>> fail) - { - MatchThrowIfNull(result, ok, fail); - return result.IsSuccess ? ok(result.Value) : fail(result.Error); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The output (resulting) . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static Task> MatchAsAsync(this Result result, Func>> ok, Func>> fail) - { - MatchThrowIfNull(result, ok, fail); - return result.IsSuccess ? ok(result.Value) : fail(result.Error); - } - - #endregion - - #region AsyncBoth - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task MatchAsync(this Task result, Func ok, Func fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - if (r.IsSuccess) - await ok().ConfigureAwait(false); - else - await fail(r.Error).ConfigureAwait(false); - - return r; - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task MatchAsync(this Task result, Func> ok, Func> fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - return r.IsSuccess ? await ok().ConfigureAwait(false) : await fail(r.Error).ConfigureAwait(false); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task> MatchAsAsync(this Task result, Func>> ok, Func>> fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - return r.IsSuccess ? await ok().ConfigureAwait(false) : await fail(r.Error).ConfigureAwait(false); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task> MatchAsync(this Task> result, Func ok, Func fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - if (r.IsSuccess) - await ok(r.Value).ConfigureAwait(false); - else - await fail(r.Error).ConfigureAwait(false); - - return r; - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task> MatchAsAsync(this Task> result, Func>> ok, Func>> fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - return r.IsSuccess ? await ok(r.Value).ConfigureAwait(false) : await fail(r.Error).ConfigureAwait(false); - } - - /// - /// Invokes (matches) the function when the is ; otherwise, invokes the function. - /// - /// The . - /// The output (resulting) . - /// The . - /// The success function. - /// The failure function. - /// The resulting . - public static async Task> MatchAsAsync(this Task> result, Func>> ok, Func>> fail) - { - MatchThrowIfNull(result, ok, fail); - var r = await result.ConfigureAwait(false); - return r.IsSuccess ? await ok(r.Value).ConfigureAwait(false): await fail(r.Error).ConfigureAwait(false); - } - - /// - /// Check parameters and throw where null. - /// - private static void MatchThrowIfNull(object result, object ok, object fail) - { - result.ThrowIfNull(nameof(result)); - ok.ThrowIfNull(nameof(ok)); - fail.ThrowIfNull(nameof(fail)); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/OnFailureExtensions.cs b/src/CoreEx/Results/OnFailureExtensions.cs deleted file mode 100644 index 3d5ca7e9..00000000 --- a/src/CoreEx/Results/OnFailureExtensions.cs +++ /dev/null @@ -1,1082 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading.Tasks; - -namespace CoreEx.Results -{ - public static partial class ResultsExtensions - { - #region Synchronous - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailure(this Result result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - if (result.IsFailure) - action(result.Error); - - return result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailure(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailure(this Result result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - if (result.IsFailure) - action(result.Error); - - return result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailure(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsFailure ? Result.Ok(func(result.Error)) : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailure(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func(result.Error) : result; - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsFailure ? Result.Ok(func()) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func() : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - if (result.IsFailure) - action(result.Error); - - return result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func(result.Error) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsFailure ? Result.Ok(func(result.Error)) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func(result.Error) : result.Bind(); - } - - /* IToResult */ - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailure(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func().ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailure(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func(result.Error).ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailure(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func(result.Error).ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func(result.Error).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func().ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func().ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func(result.Error).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result OnFailureAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? func(result.Error).ToResult() : result.Bind(); - } - - #endregion - - #region AsyncResult - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailure(this Task result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.OnFailure(action); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailure(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailure(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailure(this Task> result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.OnFailure(action); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailure(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailure(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailure(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailure(func); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAs(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(func); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAs(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(func); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAs(this Task> result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(action); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(func); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(func); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAs(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(func); - } - - /* IToResult */ - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailure(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailure(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailure(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailure(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailure(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailure(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAs(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAs(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAs(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.OnFailureAs(func); - } - - #endregion - - #region AsyncFunc - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsync(this Result result, Func func) - { - ThrowIfNull(result, func); - if (result.IsFailure) - await func().ConfigureAwait(false); - - return result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? await func().ConfigureAwait(false) : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsync(this Result result, Func func) - { - ThrowIfNull(result, func); - if (result.IsFailure) - await func(result.Error).ConfigureAwait(false); - - return result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? Result.Ok(await func(result.Error).ConfigureAwait(false)) : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? await func(result.Error).ConfigureAwait(false) : result; - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? Result.Ok(await func().ConfigureAwait(false)) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? await func().ConfigureAwait(false) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsAsync(this Result result, Func func) - { - ThrowIfNull(result, func); - if (result.IsFailure) - await func(result.Error).ConfigureAwait(false); - - return result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? await func(result.Error).ConfigureAwait(false) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? Result.Ok(await func(result.Error).ConfigureAwait(false)) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? await func(result.Error).ConfigureAwait(false) : result.Bind(); - } - - /* IToResult */ - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? (await func().ConfigureAwait(false)).ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? (await func(result.Error).ConfigureAwait(false)).ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? (await func(result.Error).ConfigureAwait(false)).ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? (await func(result.Error).ConfigureAwait(false)).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? (await func().ConfigureAwait(false)).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? (await func().ConfigureAwait(false)).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? (await func(result.Error).ConfigureAwait(false)).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsFailure ? (await func(result.Error).ConfigureAwait(false)).ToResult() : result.Bind(); - } - - #endregion - - #region AsyncBoth - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsync(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsync(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Task result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsAsync(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - /* IToResult */ - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task OnFailureAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Task result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> OnFailureAsAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.OnFailureAsAsync(func).ConfigureAwait(false); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/README.md b/src/CoreEx/Results/README.md deleted file mode 100644 index f8908199..00000000 --- a/src/CoreEx/Results/README.md +++ /dev/null @@ -1,258 +0,0 @@ -# CoreEx.Results - -The `CoreEx.Result` namespace enables [monadic](https://en.wikipedia.org/wiki/Monad_(functional_programming)) error-handling, often referred to as [Railway-oriented programming](https://swlaschin.gitbooks.io/fsharpforfunandprofit/content/posts/recipe-part2.html), as illustrated below (from referenced article) - this is a **must** read for context and understanding of the benefits of this approach. - -![Railway-oriented](../../../images/Railway_Transparent.png) - -
- -## Motivation - -To provide a means to enable Railway-oriented programming within _CoreEx_, in a rich and consistent manner. The capabilities are for the most part completely optional and can be used as needed. Although C# is not a functional language, it does support a number of functional concepts (i.e. LINQ), and adding this capability to _CoreEx_ is keeping within the spirit of this. - -
- -### Cost of exceptions - -There are some logic and performance benefits for leveraging, especially where managing errors, as this can avoid the traditional throwing of exceptions; and instead, provides a means to manage and return errors in a more functional manner. - -There are a number of [articles](https://csharpplayersguide.com/blog/2022/03/12/exception-performance/) on the internet that provide concrete examples of the performance challenges that can come with the usage of exceptions, these clearly demonstrate the potential impact. - -This is not to say that exceptions are bad and should be avoided, they absolutely serve a purpose and should continue to be leveraged in those truly _exceptional_ cases. Microsoft provides [guidance](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/exceptions-and-performance) around this specifically; also review their [best practices](https://learn.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions) for exceptions. - -
- -### Exception vs error - -However, in some instances where returning a business functional error that is intended to be handled by the consumer then an exception, although convenient, is possibly not the best approach. - -For example, where exposing an API that supports the updating of an entity, and the entity data is not valid, then more typically some sort of validation exception would be thrown by the business logic, then re-caught by the API logic, and finally transformed in a corresponding HTTP 400 error. This validation error is intended and expected behaviour, i.e. it is _not_ exceptional, and therefore treating it as an explicit _error_ is the most appropriate course of action. - -However, in this example, where persisting the entity to a database, and the database is unavailable, then this is an unexpected _exceptional_ case, and throwing a corresponding exception as a result is appropriate. - -Finally, an exception contains additional context, such as the stack trace to assist with the likes of troubleshooting, which is generally not required for an explicit (expected) business error. - -
- -## Results - -_CoreEx_ enables using the following two types, which can be used to represent either a successful result or an error result. - -Class | Description --|- -[`Result`](./Result.cs) | Represents the outcome of an operation with _no_ value. It is intended to be a replacement for `void`-based methods, where the `void` is replaced with a `Result` that can be used to represent the outcome of the operation. The static `Result.Success` property represents the successful `Result` instance. -[`Result`](./ResultT.cs) | Represents the outcome of an operation _with_ a `Value` (of type `T`). It is intended to be a used where a method previously returns a value. The static `Result.None` property represents the successful `Result` instance with its [default](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/default) value. - -The results each additionally contain the following key properties as per [`IResult`](./IResult.cs). - -Property | Description --|- -`IsSuccess` | Indicates whether the result is in a successful state. -`IsFailure` | Indicates whether the result is in a failure state. -`Error` | The failure error, represented as an `Exception`. The .NET [`Exception`](https://learn.microsoft.com/en-us/dotnet/api/system.exception) is used for the error type as it already provides a rich set of capabilities, can easily be thrown where applicable, has support for the likes of an [`AggregateException`](https://learn.microsoft.com/en-us/dotnet/api/system.aggregateexception) for combining, and is well understood by developers; i.e. it was determined that there is limited benefit in creating an alternate error type. - -
- -### Success - -By default, each of the results has a default property to represent success, `Result.Success` and `Result.None` (default value). - -Additionally, the `Result.Ok(T value)` method enables the creation of a successful result with the specified value. The `Result` also supports [implicit](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/user-defined-conversion-operators) conversion from `T` to `Result` and vice-versa. - -
- -### Failures - -The following primary failure methods are provided. - -Method | Description --|- -`Fail()` | Creates a failure result with the specified error message (internally creates a [`BusinessException`](../BusinessException.cs)), or alternatively can be passed an `Exception` directly. -`ThrowOnError()` | Throws the `Error` if the result is a failure; otherwise, does nothing. - -The `Result` and `Result` also support [implicit](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/user-defined-conversion-operators) conversion from an `Exception`. - -The following secondary failure methods are provided that enable the creation of a failure result with a specific _CoreEx_ error type. These [exceptions](../README.md#exceptions) are used extensively within _CoreEx_ to both represent specific error types, and to enable related functionality as a result of the exception typically being thrown. - -Method | Description --|- -`AuthenticationError()` | Creates a failure result with the [`AuthenticationException`](../AuthenticationException.cs). -`AuthorizationError()` | Creates a failure result with the [`AuthorizationException`](../AuthorizationException.cs). -`ConcurrencyError()` | Creates a failure result with the [`ConcurrencyException`](../ConcurrencyException.cs). -`ConflictError()` | Creates a failure result with the [`ConflictException`](../ConflictException.cs). -`DataConsistencyError` | Creates a failure result with the [`DataConsistencyException`](../DataConsistencyException.cs). -`DuplicateError()` | Creates a failure result with the [`DuplicateException`](../DuplicateException.cs). -`NotFoundError()` | Creates a failure result with the [`NotFoundException`](../NotFoundException.cs). -`TransientError()` | Creates a failure result with the [`TransientException`](../TransientException.cs). -`ValidationError()` | Creates a failure result with the [`ValidationException`](../ValidationException.cs). - -All of the above methods support the passing of the error message, which leverages [`LText`](../Localization/LText.cs) to enable the localization of the error message. - -
- -### Binding (converting) - -It is a common requirement where leveraging results that the result type needs to be converted to another type, either from/to a `Result` and `Result`, or between different `Result` types. The [`Result.Bind`](./CoreExtensions.cs) extension methods and its various overloads enable. - -Where converting from a `Result` to a `Result` the `Value` will be lost, and where converting from a `Result` to a `Result` the `Result.None` value is used. - -Additionally, there is implicit conversion support from `Result` to `Result` as there is no data loss; and explict conversion (casting) support from `Result` to `Result` given the data loss (this is intended to minimize unintentional data loss). - -Finally, as a general rule the binding will be performed automatically by the framework (i.e. is used primarily internally). - -
- -### Combining - -The [`Result.Combine`](./CoreExtensions.cs) extension methods and its various overloads enable combining two results into a single result. Where combining and there are multiple failures, then these will be combined into a single `Error` leveraging an underlying [`AggregateException`](https://learn.microsoft.com/en-us/dotnet/api/system.aggregateexception). - -
- -## Composition - -To leverage the results beyond the basics of a response mechanism, the composition of multiple steps brings the real power of railway-oriented programming. This is implemented within .NET leveraging a fluent-style method-chaining approach. - -The following extension methods are provided to enable the composition of results; these are success and failure aware, as well as supporting binding (conversion) between different result types. - -Method | Description --|- -[`Go()`](./ResultGo.cs), `GoAsync()`, `GoFrom()`, `GoFromAsync()` | Begins (starts) a new result chain. -[`Then()`](./ThenExtensions.cs), `ThenAsync()`, `ThenAs()`, `ThenAsAsync()`, `ThenFrom()`, `ThenFromAsync()`, `ThenFromAs()`, `ThenFromAsAsync()` | Executes the specified function if the result is a success; otherwise, does nothing. -[`When()`](./WhenExtensions.cs), `WhenAsync`, `WhenAs()`, `WhenAsAsync()`, `WhenFrom()`, `WhenFromAsync()`, `WhenFromAs()`, `WhenFromAsAsync()` | Where the result is a success executes the specified function or optional otherwise function depending on corresponding condition evaluation (same as if/then/else); otherwise, does nothing. -[`OnFailure()`](./OnFailureExtensions.cs), `OnFailureAsync()`, `OnFailureAs()`, `OnFailureAsAsync()` | Executes the specified function if the result is a failure; otherwise, does nothing. -[`Match()`](./MatchExtensions.cs), `MatchAsync()`, `MatchAs()`, `MatchAsAsync()` | Executes (matches) the _ok_ function when the result is a success; otherwise, invokes the corresponding _fail_ function. -[`Any()`](./AnyExtensions.cs), `AnyAsync()`, `AnyAs()`, `AnyAsAsync()` | Executes the specified function regardless of the result state. -[`AsResult()`](./ResultsExtensions.cs), `AsResultAsync()` | Converts (binds) the `Result` to a `Result` (i.e. loses the `Value`); or a `Result` to a `Result`. - -By convention the methods above that are named with the following have the described characteristics. - -Convention | Description --|- -`As` | Supports _explicit_ [as-based](#as-based-conversion) conversion between types to minimize unintentional data loss and/or unexpected side-effects. -`Async` | Supports asynchronous execution (versus synchronous). Note that all internal asynchronous executions are invoked with a [`ConfigureAwait(false)`](https://devblogs.microsoft.com/dotnet/configureawait-faq/#what-does-configureawaitfalse-do). -`From` | Supports [`IToResult`](./IToResult.cs), [`IToResult`](./IToResultT.cs) and [`ITypedToResult`](./ITypedToResult.cs) result conversion. - -
- -### As-based conversion - -A key design decision was made that there _must_ be an _explicit_ conversion between types to minimize unintentional data loss and/or unexpected side-effects. Therefore the `As`-based convention was introduced to consistently support the requisite explicit conversion. - -
- -### ToResult - -The [`IToResult`](./IToResult.cs), [`IToResult`](./IToResultT.cs) and [`ITypedToResult`](./ITypedToResult.cs) interfaces enable the conversion of a type to a result. These have been added to the following types. - -Namespace | Type(s) --|- -[`CoreEx.Http`](../Http) | [`HttpResult`](../Http/HttpResult.cs) implements `IToResult`.
[`HttpResult`](../Http/HttpResultT.cs) implements `IToResult`. -[`CoreEx.Validation`](../Validation) | [`IValidationResult`](../Validation/IValidationResult.cs) implements `ITypedToResult`. - -
- -### Simple example - -The following [`DatabaseQuery`](../../CoreEx.Database/Extended/DatabaseQuery.cs) code demonstrates usage; with the key takeaway being that each step in the chain is only executed if the previous step was a success. - -``` csharp -private async Task> SelectWrapperWithResultAsync(Func>> func, CancellationToken cancellationToken) -{ - var rvp = Paging != null && Paging.IsGetCount ? Parameters.AddReturnValueParameter() : null; - var cmd = Command.Params(Parameters).PagingParams(Paging); - - return await Result.GoAsync(func(cmd, cancellationToken)) - .When(_ => rvp != null && rvp.Value != null, _ => { Paging!.TotalCount = (long)rvp!.Value; }) - .Then(res => QueryArgs.CleanUpResult ? Cleaner.Clean(res) : res); -} -``` - -
- -### Complex example - -The following [`CosmosDbValueContainer`](../../CoreEx.Cosmos/CosmosDbValueContainer.cs) code demonstrates usage; with the key takeaway being steps can contain complex logic, the type can be explicitly converted by the use of the `ThenAsAsyc()`, and errors can be returned where applicable as evidenced by the usage of the `Result.ConcurrencyError()`. - -``` csharp -return await Result - .Go(CheckAuthorized(resp)) - .When(() => v is IETag etag2 && etag2.ETag != null && ETagGenerator.FormatETag(etag2.ETag) != resp.ETag, () => Result.ConcurrencyError()) - .Then(() => - { - ro.SessionToken = resp.Headers?.Session; - ChangeLog.PrepareUpdated(v); - CosmosDb.Mapper.Map(v, resp.Resource, OperationTypes.Update); - Cleaner.ResetTenantId(resp.Resource); - - // Re-check auth to make sure not updating to something not allowed. - return CheckAuthorized(resp); - }) - .ThenAsAsync(async () => - { - resp = await Container.ReplaceItemAsync(resp.Resource, key, pk, ro, ct).ConfigureAwait(false); - return GetResponseValue(resp)!; - }); -``` - -
- -### Validation example - -The following [code](https://github.com/Avanade/Beef/blob/master/samples/MyEf.Hr/MyEf.Hr.Business/Generated/EmployeeManager.cs) demonstrates usage of the `Required`, `Requires` and `ValidateAsync` validation-oriented extension methods. - -``` csharp -return Result.Go(value).Required().Requires(id).Then(v => v.Id = id) - .ValidateAsync(v => v.Entity().With(), cancellationToken: ct) - .ThenAsAsync(v => _dataService.UpdateAsync(value)); -``` - -
- -### ToResult example - -The following [code](https://github.com/Avanade/Beef/blob/master/samples/MyEf.Hr/MyEf.Hr.Security.Subscriptions/OktaHttpClient.cs) demonstrates the usage of the [`IToResult`](./IToResult.cs) interface enabled by the `HttpResult`; with the key takeaway being that the `Result.GoFrom()` method is used. - -``` csharp -public async Task> GetUserAsync(Guid id, string email) - => Result.GoFrom(await GetAsync>($"/api/v1/users?search=profile.email eq \"{email}\"").ConfigureAwait(false)) - .ThenAs(coll => coll.Count switch - { - 0 => Result.NotFoundError($"Employee {id} with email {email} not found within OKTA."), - 1 => Result.Ok(coll[0]), - _ => Result.NotFoundError($"Employee {id} with email {email} has multiple entries within OKTA.") - }); -``` - -
- -### Additional - -The following additional composition extensions methods are available: - -Method | Description --|- -`ValidateAsync` | Validates the `Result.Value` using either the specified [`IValidator`](../Validation/IValidatorT.cs) or [`IPropertyRule, TEntity?>`](../../CoreEx.Validation/IPropertyRuleT2.cs) resulting in either success or failure (`Result.ValidationError`). Include `CoreEx.Validation` namespace to enable. -`ValidatesAsync` | Validates the specified _value_ using either the specified [`IValidator`](../Validation/IValidatorT.cs) or [`IPropertyRule, TEntity?>`](../../CoreEx.Validation/IPropertyRuleT2.cs) resulting in either success or failure (`Result.ValidationError`). Include `CoreEx.Validation` namespace to enable. -`Required` | Validates that the `Result.Value` is non-default (i.e. is required) resulting in either success or failure (`Result.ValidationError`). Include `CoreEx.Validation` namespace to enable. -`Requires` | Validates that the specified _value_ is non-default (i.e. is required) resulting in either success or failure (`Result.ValidationError`). Include `CoreEx.Validation` namespace to enable. -`ThrowIfNull` | Throws a [`NullReferenceException`](https://docs.microsoft.com/en-us/dotnet/api/system.nullreferenceexception) if the `Result.Value` is `null`. -`CacheGetOrAddAsync` | Gets the [`IRequestCache`](../Caching/IRequestCache.cs) cached value associated with the specified key where it exists; otherwise, adds and returns the value created by the corresponding add factory function. Include `CoreEx.Caching` namespace to enable. -`UserIsAuthorized` | Performs the equivalent `ExecutionContext.Current.UserIsAuthorized` resulting in either success or failure. -`UserIsInRole` | Performs the equivalent `ExecutionContext.Current.UserIsInRole` resulting in either success or failure. - -
- -## WithResult - -Within _CoreEx_ `Result` and `Result` support has been enabled; these are methods generally suffixed by `WithResult` or `WithResultAsync` by convention. These co-exist with the existing exception throwing methods. - -This allows these methods to be conveniently invoked without the need to explicitly catch the related exceptions as the `IResult.Error` will have been set accordingly. Note that only exceptions that correspond to the _CoreEx_ [secondary failures](#Failures) will be caught and converted; otherwise, the _exceptional_ exceptions will continue to be thrown. These would then need to be explicitly caught and handled where applicable. - -The `*WithResult` or `*WithResultAsync` methods have been added to the following types: - -Namespace | Type(s) --|- -[`CoreEx.Cosmos`](../../CoreEx.Cosmos) | [`CosmosDbContainerBase`](../../CoreEx.Cosmos/CosmosDbContainerBase.cs), [`CosmosDbContainer`](../../CoreEx.Cosmos/CosmosDbContainer.cs), [`CosmosDbValueContainer`](../../CoreEx.Cosmos/CosmosDbValueContainer.cs), [`CosmosDbQueryBase`](../../CoreEx.Cosmos/CosmosDbQueryBase.cs), [`CosmosDbQuery`](../../CoreEx.Cosmos/CosmosDbQuery.cs) and [`CosmosDbValueQuery`](../../CoreEx.Cosmos/CosmosDbValueQuery.cs). -[`CoreEx.Database`](../../CoreEx.Database) | [`DatabaseCommand`](../../CoreEx.Database/DatabaseCommand.cs), [`DatabaseQuery`](../../CoreEx.Database/Extended/DatabaseQuery.cs), [`RefDataLoadeder`](../../CoreEx.Database/Extended/RefDataLoader.cs) and [`DatabaseExtendedExtensions`](../../CoreEx.Database/Extended/DatabaseExtendedExtensions.cs). -[`CoreEx.EntityFrameworkCore`](../../CoreEx.EntityFrameworkCore) | [`IEfDb`](../../CoreEx.EntityFrameworkCore/IEfDb.cs), [`EfDb`](../../CoreEx.EntityFrameworkCore/EfDb.cs) and [`EfDbEntity`](../../CoreEx.EntityFrameworkCore/EfDbEntity.cs). -[`CoreEx.WebApis`](../WebApis) | [`WebApiBase`](../WebApis/WebApiBase.cs), [`WebApi`](../WebApis/WebApi.cs) and [`WebApiPublisher`](../WebApis/WebApiPublisher.cs). diff --git a/src/CoreEx/Results/Result.Error.cs b/src/CoreEx/Results/Result.Error.cs new file mode 100644 index 00000000..9c9f9b24 --- /dev/null +++ b/src/CoreEx/Results/Result.Error.cs @@ -0,0 +1,93 @@ +namespace CoreEx.Results; + +public readonly partial struct Result +{ + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result AuthenticationError(LText? message = default, Action? configure = null) => (new AuthenticationException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result AuthorizationError(LText? message = default, Action? configure = null) => (new AuthorizationException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result BusinessError(LText? message = default, Action? configure = null) => (new BusinessException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result ConcurrencyError(LText? message = default, Action? configure = null) => (new ConcurrencyException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + /// An example would be where the identifier provided for a Create operation already exists. + public static Result ConflictError(LText? message = default, Action? configure = null) => (new ConflictException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result DuplicateError(LText? message = default, Action? configure = null) => (new DuplicateException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result NotFoundError(LText? message = default, Action? configure = null) => (new NotFoundException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result TransientError(LText? message = default, Action? configure = null) => (new TransientException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result ValidationError(LText? message = default, Action? configure = null) => (new ValidationException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The list. + /// The optional configuration action. + /// The that has a state of . + public static Result ValidationError(IEnumerable? messages, Action? configure = null) => (messages is null ? new ValidationException() : new ValidationException(messages)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The . + /// The optional configuration action. + /// The that has a state of . + public static Result ValidationError(MessageItem message, Action? configure = null) => (new ValidationException(message)).Adjust(configure); +} \ No newline at end of file diff --git a/src/CoreEx/Results/Result.Go.cs b/src/CoreEx/Results/Result.Go.cs new file mode 100644 index 00000000..973c935b --- /dev/null +++ b/src/CoreEx/Results/Result.Go.cs @@ -0,0 +1,114 @@ +namespace CoreEx.Results; + +partial struct Result +{ + /// + /// Begins a new chain. + /// + /// The resulting . + public static Result Go() => Success; + + /// + /// Begins a new chain by executing the . + /// + /// The function to invoke. + /// The resulting . + public static Result Go(Action action) => Success.Then(action); + + /// + /// Begins a new chain by executing the . + /// + /// The function to invoke. + /// The resulting . + public static Result Go(Func func) => Success.Then(func); + + /// + /// Begins a new chain by starting with the . + /// + /// The starting . + /// The resulting . + public static Result Go(Result result) => result; + + /// + /// Begins a new chain by executing the . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static Result Go(Func func) => Result.Success.Then(_ => func()); + + /// + /// Begins a new chain by executing the . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static Result Go(Func> func) => Result.Success.Then(_ => func()); + + /// + /// Begins a new chain. + /// + /// The . + /// The resulting . + public static Result Go() => Result.Success; + + /// + /// Begins a new chain with the specified . + /// + /// The . + /// The starting value. + /// The resulting . + public static Result Go(T value) => Result.Ok(value); + + /// + /// Begins a new chain by starting with the . + /// + /// The starting . + /// The resulting . + public static Result Go(Result result) => result; + + /// + /// Begins a new chain by executing the . + /// + /// The function to invoke. + /// The resulting . + public static async Task GoAsync(Func func) => await Success.ThenAsync(func).ConfigureAwait(false); + + /// + /// Begins a new chain by executing the . + /// + /// The function to invoke. + /// The resulting . + public static async Task GoAsync(Func> func) => await Success.ThenAsync(func).ConfigureAwait(false); + + /// + /// Begins a new chain by starting with the . + /// + /// The starting . + /// The resulting . + public static async Task GoAsync(Task result) => await result.ConfigureAwait(false); + + /// + /// Begins a new chain by executing the . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> GoAsync(Func>> func) => await Result.Success.ThenAsync(async _ => await func().ConfigureAwait(false)).ConfigureAwait(false); + + /// + /// Begins a new chain by executing the . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> GoAsync(Func> func) => await Result.Success.ThenAsync(async _ => await func().ConfigureAwait(false)).ConfigureAwait(false); + + /// + /// Begins a new chain by starting with the . + /// + /// The . + /// The starting . + /// The resulting . + public static async Task> GoAsync(Task> result) => await result.ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/CoreEx/Results/Result.Static.cs b/src/CoreEx/Results/Result.Static.cs new file mode 100644 index 00000000..1f41cf2c --- /dev/null +++ b/src/CoreEx/Results/Result.Static.cs @@ -0,0 +1,59 @@ +namespace CoreEx.Results; + +public readonly partial struct Result +{ + /// + /// Gets the . + /// + public static Result Success { get; } = new(); + + /// + /// Gets the . + /// + public static Task SuccessTask => Task.FromResult(Success); + + /// + /// Gets the with a of . + /// + public static Result False { get; } = new Result(false); + + /// + /// Gets the with a of . + /// + public static Result True { get; } = new Result(true); + + /// + /// Executes the specified and returns . + /// + /// The action to execute. + /// The . + /// This is a helper method to simplify code where an should be invoked followed immediately by returning a corresponding to complete/conclude. + public static Result Done(Action action) + { + action.ThrowIfNull()(); + return Success; + } + + /// + /// Creates a with a that is considered . + /// + /// The . + /// The value. + /// The that is . + public static Result Ok(T value) => Result.Ok(value); + + /// + /// Creates a with an (see ). + /// + /// The error represented as an . + /// The that has a state of . + public static Result Fail(Exception error) => new(error); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result Fail(LText? message = null, Action? configure = null) => new((new ValidationException(message)).Adjust(configure)); +} \ No newline at end of file diff --git a/src/CoreEx/Results/Result.cs b/src/CoreEx/Results/Result.cs index c8727110..11143748 100644 --- a/src/CoreEx/Results/Result.cs +++ b/src/CoreEx/Results/Result.cs @@ -1,261 +1,128 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Localization; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; - -namespace CoreEx.Results +namespace CoreEx.Results; + +/// +/// Represents the outcome of an operation with no value. +/// +/// There are logic and performance benefits for leveraging a , especially where explicitly managing known/expected errors, as this can avoid the overhead of throwing exceptions. +/// This instead, provides a means to manage and return errors in a more functional manner (see monad-based error handling and +/// Railway Oriented Programming for more information). +/// This is not to say that exceptions are not valid and should be avoided, they absolutely serve a purpose and should continue to be leveraged where the outcome of an operation is unexpected. +/// See the error management guidance provided by Microsoft. +/// However, in some instances where returning a business functional error that is intended to be handled by the consumer then an , although convenient, is possibly not the best approach. +/// Finally, an exception contains additional context, such as the stack trace to assist with the likes of troubleshooting, which is generally not required for an explicit (expected) business error. +/// See also . +[DebuggerStepThrough] +[DebuggerDisplay("{ToDebuggerString()}")] +public readonly partial struct Result : IResult, IEquatable { + private readonly Exception? _error = default; + /// - /// Represents the outcome of an operation with no value. + /// Initializes a new instance of the that is considered . /// - [DebuggerStepThrough] - [DebuggerDisplay("{ToDebuggerString()}")] - public readonly partial struct Result : IResult, IEquatable - { - /// - /// Gets the . - /// - public static Result Success { get; } = new(); - - /// - /// Gets the . - /// - public static Task SuccessTask { get; } = Task.FromResult(Success); - - private readonly Exception? _error = default; - - /// - /// Initializes a new instance of the that is considered . - /// - public Result() { } - - /// - /// Initializes a new instance of the with an (see ). - /// - /// The error represented as an . - public Result(Exception error) => _error = error.ThrowIfNull(nameof(error)); - - /// - object? IResult.Value - { - get - { - ThrowOnError(); - return null; - } - } - - /// - public Exception Error { get => _error ?? throw new InvalidOperationException($"The {nameof(Error)} cannot be accessed as the {nameof(Result)} is in a successful state."); } - - /// - public bool IsSuccess => _error is null; - - /// - public bool IsFailure => _error is not null; - - /// - IResult IResult.ToFailure(Exception error) => new Result(error); + public Result() { } - /// - public bool IsFailureOfType() where TException : Exception => _error is not null && _error.GetType() == typeof(TException); - - /// - /// Converts the to a corresponding (of ) defaulting to where ; otherwise, where - /// returns a resulting instance with the corresponding . - /// - /// The (resulting) . - /// The corresponding . - /// This invokes internally to perform. - public Result ToResult() => this.Bind(); - - /// - /// Throws the where ; otherwise, does nothing. - /// - /// The where to enable further fluent-style method-chaining. - public Result ThrowOnError() - { - if (IsFailure) - ThrowErrorOrAggregateException(Error); - - return this; - } - - /// - /// Throws either the directly where not previously thrown; otherwise, throws a new which contains the originating . - /// - /// The originating . - [DoesNotReturn] - internal static void ThrowErrorOrAggregateException(Exception error) - { - if (error.StackTrace is null) - throw error; - - throw new AggregateException(error); - } + /// + /// Initializes a new instance of the with an (see ). + /// + /// The error represented as an . + public Result(Exception error) => _error = error.ThrowIfNull(); - /// - /// Executes the specified and returns . - /// - /// The action to execute. - /// The . - /// This is a helper method to simplify code where an should be invoked followed immediately by returning a corresponding to complete/conclude. - public static Result Done(Action action) + /// + object? IResult.Value + { + get { - action.ThrowIfNull(nameof(action))(); - return Success; + ThrowOnError(); + return null; } + } - /// - /// Creates a with a that is considered . - /// - /// The . - /// The value. - /// The that is . - public static Result Ok(T value) => Result.Ok(value); - - /// - /// Creates a with an (see ). - /// - /// The error represented as an . - /// The that has a state of . - public static Result Fail(Exception error) => new(error); - - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result Fail(LText? message = null) => new(new BusinessException(message)); - - /// - /// Converts the to a using . - /// - /// The completed . - public Task AsTask() => Task.FromResult(this); - - /// - public override string ToString() => IsSuccess ? "Success." : $"Failure: {Error.Message}"; - - /// - /// Get the representation of the for debugging purposes. - /// - private string ToDebuggerString() => IsSuccess ? "Success." : $"Failure: {Error.Message} [{Error.GetType().Name}]"; - - /// - /// Implicitly converts an to a that is considered . - /// - /// The underlying error represented as an . - public static implicit operator Result(Exception error) => new(error); - - #region Errors - - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result ValidationError(LText? message = default) => new ValidationException(message); + /// + public Exception Error { get => _error ?? throw new InvalidOperationException($"The {nameof(Error)} cannot be accessed as the {nameof(Result)} is in a successful state."); } - /// - /// Creates a with an (see ) of type . - /// - /// The list. - /// The that has a state of . - public static Result ValidationError(IEnumerable? messages) => messages is null ? new ValidationException() : new ValidationException(messages); + /// + public bool IsSuccess => _error is null; - /// - /// Creates a with an (see ) of type . - /// - /// The . - /// The that has a state of . - public static Result ValidationError(MessageItem message) => new ValidationException(message); + /// + public bool IsFailure => _error is not null; - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - /// An example would be where the identifier provided for a Create operation already exists. - public static Result ConflictError(LText? message = default) => new ConflictException(message); + /// + IResult IResult.ToFailure(Exception error) => new Result(error); - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result ConcurrencyError(LText? message = default) => new ConcurrencyException(message); + /// + public bool IsFailureOfType() where TException : Exception => _error is not null && _error is TException; - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result DataConsistencyError(LText? message = default) => new DataConsistencyException(message); + /// + /// Converts the to a corresponding (of ) defaulting to where ; otherwise, where + /// returns a resulting instance with the corresponding . + /// + /// The (resulting) . + /// The corresponding . + /// This invokes internally to perform. + public Result ToResult() => this.Bind(); - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result DuplicateError(LText? message = default) => new DuplicateException(message); + /// + /// Throws the where ; otherwise, does nothing. + /// + /// The where to enable further fluent-style method-chaining. + public Result ThrowOnError() + { + if (IsFailure) + ThrowErrorOrAggregateException(Error); - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result NotFoundError(LText? message = default) => new NotFoundException(message); + return this; + } - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result TransientError(LText? message = default) => new TransientException(message); + /// + /// Throws either the directly where not previously thrown; otherwise, throws a new which contains the originating . + /// + /// The originating . + [DoesNotReturn] + internal static void ThrowErrorOrAggregateException(Exception error) + { + if (error.StackTrace is null) + throw error; - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result AuthenticationError(LText? message = default) => new AuthenticationException(message); + throw new AggregateException(error); + } - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result AuthorizationError(LText? message = default) => new AuthorizationException(message); + /// + /// Converts the to a using . + /// + /// The completed . + public Task AsTask() => Task.FromResult(this); - #endregion + /// + public override string ToString() => IsSuccess ? "Success." : $"Failure: {Error.Message}"; - #region Equality + /// + /// Get the representation of the for debugging purposes. + /// + private string ToDebuggerString() => IsSuccess ? "Success." : $"Failure: {Error.Message} [{Error.GetType().Name}]"; - /// - public override bool Equals(object? obj) => obj is Result r && Equals(r); + /// + /// Implicitly converts an to a that is considered . + /// + /// The underlying error represented as an . + public static implicit operator Result(Exception error) => new(error); - /// - public bool Equals(Result other) => IsSuccess ? other.IsSuccess : (IsFailure == other.IsFailure && Error.GetType() == other.Error.GetType() && Error.ToString() == other.Error.ToString()); + /// + public override bool Equals(object? obj) => obj is Result r && Equals(r); - /// - /// Indicates whether the current is equal to another . - /// - public static bool operator ==(Result left, Result right) => left.Equals(right); + /// + public bool Equals(Result other) => IsSuccess ? other.IsSuccess : (IsFailure == other.IsFailure && Error.GetType() == other.Error.GetType() && Error.ToString() == other.Error.ToString()); - /// - /// Indicates whether the current is not equal to another . - /// - public static bool operator !=(Result left, Result right) => !(left == right); + /// + /// Indicates whether the current is equal to another . + /// + public static bool operator ==(Result left, Result right) => left.Equals(right); - /// - public override int GetHashCode() => HashCode.Combine(IsSuccess, IsFailure ? Error.GetHashCode() : 0); + /// + /// Indicates whether the current is not equal to another . + /// + public static bool operator !=(Result left, Result right) => !(left == right); - #endregion - } + /// + public override int GetHashCode() => HashCode.Combine(IsSuccess, IsFailure ? Error.GetHashCode() : 0); } \ No newline at end of file diff --git a/src/CoreEx/Results/ResultGo.cs b/src/CoreEx/Results/ResultGo.cs deleted file mode 100644 index f52a39a2..00000000 --- a/src/CoreEx/Results/ResultGo.cs +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading.Tasks; - -namespace CoreEx.Results -{ - public readonly partial struct Result - { - #region Go - - /// - /// Begins a new chain. - /// - /// The resulting . - public static Result Go() => Success; - - /// - /// Begins a new chain by executing the . - /// - /// The function to invoke. - /// The resulting . - public static Result Go(Action action) => Success.Then(action); - - /// - /// Begins a new chain by executing the . - /// - /// The function to invoke. - /// The resulting . - public static Result Go(Func func) => Success.Then(func); - - /// - /// Begins a new chain by starting with the . - /// - /// The starting . - /// The resulting . - public static Result Go(Result result) => result; - - /// - /// Begins a new chain by starting with the . - /// - /// The starting . - /// The resulting . - public static Result GoFrom(IToResult result) => Success.ThenFrom(() => result); - - /// - /// Begins a new chain by starting with the . - /// - /// The starting function to invoke. - /// The resulting . - public static Result GoFrom(Func func) => Success.ThenFrom(func); - - /// - /// Begins a new chain by executing the . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result Go(Func func) => Result.None.Then(_ => func()); - - /// - /// Begins a new chain by executing the . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result Go(Func> func) => Result.None.Then(_ => func()); - - /// - /// Begins a new chain. - /// - /// The . - /// The resulting . - public static Result Go() => Result.None; - - /// - /// Begins a new chain with the specified . - /// - /// The . - /// The starting value. - /// The resulting . - public static Result Go(T value) => Go(() => Result.Ok(value)); - - /// - /// Begins a new chain by starting with the . - /// - /// The starting . - /// The resulting . - public static Result Go(Result result) => result; - - /// - /// Begins a new chain by starting with the . - /// - /// The . - /// The starting . - /// The resulting . - public static Result GoFrom(IToResult result) => Result.None.ThenFrom(_ => result); - - /// - /// Begins a new chain by starting with the . - /// - /// The . - /// The starting function to invoke. - /// The resulting . - public static Result GoFrom(Func> func) => Result.None.ThenFrom(_ => func()); - - /// - /// Begins a new chain by starting with the . - /// - /// The . - /// The starting . - /// The resulting . - public static Result GoFrom(ITypedToResult result) => Result.None.ThenFrom(_ => result); - - /// - /// Begins a new chain by starting with the . - /// - /// The . - /// The starting function to invoke. - /// The resulting . - public static Result GoFrom(Func func) => Result.None.ThenFrom(_ => func()); - - #endregion - - #region GoAsync - - /// - /// Begins a new chain by executing the . - /// - /// The function to invoke. - /// The resulting . - public static Task GoAsync(Func func) => Success.ThenAsync(func); - - /// - /// Begins a new chain by executing the . - /// - /// The function to invoke. - /// The resulting . - public static Task GoAsync(Func> func) => Success.ThenAsync(func); - - /// - /// Begins a new chain by starting with the . - /// - /// The starting . - /// The resulting . - public static async Task GoAsync(Task result) => await result.ConfigureAwait(false); - - /// - /// Begins a new chain by starting with the . - /// - /// The starting . - /// The resulting . - public static Task GoFromAsync(Task result) => Success.ThenFromAsync(() => result); - - /// - /// Begins a new chain by starting with the . - /// - /// The starting function to invoke. - /// The resulting . - public static Task GoFromAsync(Func> func) => Success.ThenFromAsync(async () => await func()); - - /// - /// Begins a new chain by executing the . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Task> GoAsync(Func>> func) => Result.None.ThenAsync(async _ => await func().ConfigureAwait(false)); - - /// - /// Begins a new chain by executing the . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Task> GoAsync(Func> func) => Result.None.ThenAsync(async _ => await func().ConfigureAwait(false)); - - /// - /// Begins a new chain by starting with the . - /// - /// The . - /// The starting . - /// The resulting . - public static async Task> GoAsync(Task> result) => await result.ConfigureAwait(false); - - /// - /// Begins a new chain by starting with the . - /// - /// The . - /// The starting . - /// The resulting . - public static Task> GoFromAsync(Task> result) => Result.None.ThenFromAsync(_ => result); - - /// - /// Begins a new chain by starting with the . - /// - /// The . - /// The starting function to invoke. - /// The resulting . - public static Task> GoFromAsync(Func>> func) => Result.None.ThenFromAsync(async _ => await func().ConfigureAwait(false)); - - /// - /// Begins a new chain by starting with the . - /// - /// The . - /// The starting . - /// The resulting . - public static Task> GoFromAsync(Task result) => Result.None.ThenFromAsync(_ => result); - - /// - /// Begins a new chain by starting with the . - /// - /// The . - /// The starting function to invoke. - /// The resulting . - public static Task> GoFromAsync(Func> func) => Result.None.ThenFromAsync(async _ => await func().ConfigureAwait(false)); - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/ResultT.Error.cs b/src/CoreEx/Results/ResultT.Error.cs new file mode 100644 index 00000000..547f2aad --- /dev/null +++ b/src/CoreEx/Results/ResultT.Error.cs @@ -0,0 +1,93 @@ +namespace CoreEx.Results; + +public readonly partial struct Result +{ + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result AuthenticationError(LText? message = default, Action? configure = null) => (new AuthenticationException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result AuthorizationError(LText? message = default, Action? configure = null) => (new AuthorizationException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result BusinessError(LText? message = default, Action? configure = null) => (new BusinessException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result ConcurrencyError(LText? message = default, Action? configure = null) => (new ConcurrencyException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + /// An example would be where the identifier provided for a Create operation already exists. + public static Result ConflictError(LText? message = default, Action? configure = null) => (new ConflictException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result DuplicateError(LText? message = default, Action? configure = null) => (new DuplicateException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result NotFoundError(LText? message = default, Action? configure = null) => (new NotFoundException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result TransientError(LText? message = default, Action? configure = null) => (new TransientException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result ValidationError(LText? message = default, Action? configure = null) => (new ValidationException(message)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The list. + /// The optional configuration action. + /// The that has a state of . + public static Result ValidationError(IEnumerable? messages, Action? configure = null) => (messages is null ? new ValidationException() : new ValidationException(messages)).Adjust(configure); + + /// + /// Creates a with an (see ) of type . + /// + /// The . + /// The optional configuration action. + /// The that has a state of . + public static Result ValidationError(MessageItem message, Action? configure = null) => (new ValidationException(message)).Adjust(configure); +} \ No newline at end of file diff --git a/src/CoreEx/Results/ResultT.Static.cs b/src/CoreEx/Results/ResultT.Static.cs new file mode 100644 index 00000000..70087446 --- /dev/null +++ b/src/CoreEx/Results/ResultT.Static.cs @@ -0,0 +1,46 @@ +namespace CoreEx.Results; + +public readonly partial struct Result +{ + /// + /// Gets the with a default . + /// + public static Result Success { get; } = default; + + /// + /// Creates a with a default that is considered . + /// + /// The that is (see ). + public static Result Ok() => Success; + + /// + /// Creates a with a that is considered . + /// + /// The value. + /// The that is . + /// This is synonymous with . + public static Result Ok(T value) => Map(value); + + /// + /// Maps a into a that is considered . + /// + /// The value to map. + /// The resulting . + /// This is synonymous with . + public static Result Map(T value) => new(value); + + /// + /// Creates a with an (see ). + /// + /// The error represented as an . + /// The that has a state of . + public static Result Fail(Exception error) => new(error); + + /// + /// Creates a with an (see ) of type . + /// + /// The error message. + /// The optional configuration action. + /// The that has a state of . + public static Result Fail(LText message, Action? configure = null) => new((new ValidationException(message)).Adjust(configure)); +} \ No newline at end of file diff --git a/src/CoreEx/Results/ResultT.cs b/src/CoreEx/Results/ResultT.cs index e0adbd92..3950b3af 100644 --- a/src/CoreEx/Results/ResultT.cs +++ b/src/CoreEx/Results/ResultT.cs @@ -1,274 +1,161 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using CoreEx.Localization; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Threading.Tasks; - -namespace CoreEx.Results +namespace CoreEx.Results; + +/// +/// Represents the outcome of an operation with a . +/// +/// The . +/// There are logic and performance benefits for leveraging a , especially where explicitly managing known/expected errors, as this can avoid the overhead of throwing exceptions. +/// This instead, provides a means to manage and return errors in a more functional manner (see monad-based error handling and +/// Railway Oriented Programming for more information). +/// This is not to say that exceptions are not valid and should be avoided, they absolutely serve a purpose and should continue to be leveraged where the outcome of an operation is unexpected. +/// See the error management guidance provided by Microsoft. +/// However, in some instances where returning a business functional error that is intended to be handled by the consumer then an , although convenient, is possibly not the best approach. +/// Finally, an exception contains additional context, such as the stack trace to assist with the likes of troubleshooting, which is generally not required for an explicit (expected) business error. +/// See also . +[DebuggerStepThrough] +[DebuggerDisplay("{ToDebuggerString()}")] +public readonly partial struct Result : IResult, IEquatable> { + private readonly T _value = default!; + private readonly Exception? _error = default; + /// - /// Represents the outcome of an operation with a . + /// Initializes a new instance of the with a . /// - /// The . - [DebuggerStepThrough] - [DebuggerDisplay("{ToDebuggerString()}")] - public readonly struct Result : IResult, IEquatable> - { - /// - /// Gets the with a default . - /// - public static Result None { get; } = default; - - private readonly T _value = default!; - private readonly Exception? _error = default; - - /// - /// Initializes a new instance of the with a . - /// - /// The . - public Result(T value) => _value = value; - - /// - /// Initializes a new instance of the with a corresponding . - /// - /// The error represented as an . - public Result(Exception error) => _error = error.ThrowIfNull(nameof(error)); - - /// - object? IResult.Value => Value; - - /// - public T Value - { - get - { - if (IsSuccess) - return _value; - - Result.ThrowErrorOrAggregateException(Error); - return default; - } - } - - /// - public Exception Error { get => _error ?? throw new InvalidOperationException($"The {nameof(Error)} cannot be accessed as the {nameof(Result)} is in a successful state."); } - - /// - public bool IsSuccess => _error is null; - - /// - public bool IsFailure => _error is not null; - - /// - IResult IResult.ToFailure(Exception error) => new Result(error); + /// The . + public Result(T value) => _value = value; - /// - public bool IsFailureOfType() where TException : Exception => _error is not null && _error.GetType() == typeof(TException); - - /// - /// Throws the where ; otherwise, does nothing. - /// - /// The where to enable further fluent-style method-chaining. - public Result ThrowOnError() - { - if (IsFailure) - Result.ThrowErrorOrAggregateException(Error); - - return this; - } - - /// - public override string ToString() => IsSuccess ? $"Success: {(Value is null ? "null" : Value)}" : $"Failure: {Error.Message}"; - - /// - /// Get the representation of the for debugging purposes. - /// - private string ToDebuggerString() => IsSuccess ? $"Success: {(Value is null ? "null" : Value)}" : $"Failure: {Error.Message} [{Error.GetType().Name}]"; - - /// - /// Creates a with a default that is considered . - /// - /// The that is (see ). - public static Result Ok() => Result.None; - - /// - /// Creates a with a that is considered . - /// - /// The value. - /// The that is . - public static Result Ok(T value) => value is null || (value is IComparable && Comparer.Default.Compare(value, default!) == 0) ? Result.None : new Result(value); - - /// - /// Creates a with an (see ). - /// - /// The error represented as an . - /// The that has a state of . - public static Result Fail(Exception error) => new(error); + /// + /// Initializes a new instance of the with a corresponding . + /// + /// The error represented as an . + public Result(Exception error) => _error = error.ThrowIfNull(); - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result Fail(LText message) => new(new BusinessException(message)); + /// + object? IResult.Value => Value; - /// - /// Requires (validates) that the is non-default; otherwise, will result in a . - /// - /// The value name (defaults to ). - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// The resulting . - /// The format of the error messages is defined by . - public Result Required(string? name = null, LText? text = null) + /// + public T Value + { + get { - if (IsSuccess && Comparer.Default.Compare(Value, default!) == 0) - return ValidationError(MessageItem.CreateErrorMessage(name ?? Validation.Validation.ValueNameDefault, Validation.Validation.MandatoryFormat, text ?? ((name == null || name == Validation.Validation.ValueNameDefault) ? Validation.Validation.ValueTextDefault : name.ToSentenceCase()!))); + if (IsSuccess) + return _value; - return this; + Result.ThrowErrorOrAggregateException(Error); + return default; } + } - /// - /// Converts the to a using . - /// - /// The completed . - public Task> AsTask() => Task.FromResult(this); - - /// - /// Implicitly converts an to a that is considered . - /// - /// The underlying error represented as an . - public static implicit operator Result(Exception error) => new(error); - - /// - /// Implicitly converts a to a defaulting the where . - /// - /// The . - public static implicit operator Result(Result result) => result.Bind(() => new Result()); - - /// - /// Explicitly converts a to a losing the where . - /// - /// - public static explicit operator Result(Result result) => result.Bind(); + /// + /// Gets the underlying where ; otherwise, returns the default value for the type . + /// + public T? ValueOrDefault => IsSuccess ? _value : default; - /// - /// Implicitly converts a to a as . - /// - /// The underlying value. - public static implicit operator Result(T value) => Result.Ok(value); + /// + public Exception Error { get => _error ?? throw new InvalidOperationException($"The {nameof(Error)} cannot be accessed as the {nameof(Result)} is in a successful state."); } - /// - /// Implicitly converts a to a where . - /// - /// - public static implicit operator T(Result result) => result.Value; + /// + public bool IsSuccess => _error is null; - #region Errors + /// + public bool IsFailure => _error is not null; - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result ValidationError(LText? message = default) => Result.Fail(new ValidationException(message)); + /// + IResult IResult.ToFailure(Exception error) => new Result(error); - /// - /// Creates a with an (see ) of type . - /// - /// The list. - /// The that has a state of . - public static Result ValidationError(IEnumerable? messages) => Result.Fail(new ValidationException(messages!)); + /// + public bool IsFailureOfType() where TException : Exception => _error is not null && _error is TException; - /// - /// Creates a with an (see ) of type . - /// - /// The . - /// The that has a state of . - public static Result ValidationError(MessageItem message) => Result.Fail(new ValidationException(message)); + /// + /// Throws the where ; otherwise, does nothing. + /// + /// The where to enable further fluent-style method-chaining. + public Result ThrowOnError() + { + if (IsFailure) + Result.ThrowErrorOrAggregateException(Error); - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - /// An example would be where the identifier provided for a Create operation already exists. - public static Result ConflictError(LText? message = default) => Result.Fail(new ConflictException(message)); + return this; + } - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result ConcurrencyError(LText? message = default) => Result.Fail(new ConcurrencyException(message)); + /// + public override string ToString() => IsSuccess ? $"Success: {(Value is null ? "null" : Value)}" : $"Failure: {Error.Message}"; - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result DataConsistencyError(LText? message = default) => Result.Fail(new DataConsistencyException(message)); + /// + /// Get the representation of the for debugging purposes. + /// + private string ToDebuggerString() => IsSuccess ? $"Success: {(Value is null ? "null" : Value)}" : $"Failure: {Error.Message} [{Error.GetType().Name}]"; - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result DuplicateError(LText? message = default) => Result.Fail(new DuplicateException(message)); + /// + /// Requires (validates) that the is non-default; otherwise, will result in a . + /// + /// The value name (defaults to ). + /// The friendly text name used in validation messages (defaults to as sentence case where not specified). + /// The resulting . + /// The format of the error messages is defined by . + public Result Required(string? name = null, LText? text = null) + { + if (IsSuccess && EqualityComparer.Default.Equals(Value, default!)) + return Result.ValidationError(MessageItem.CreateErrorMessage(name + ?? Validation.Validation.ValueName, Validation.Validation.MandatoryFormat, text + ?? ((name is null || name == Validation.Validation.ValueName) ? Validation.Validation.ValueText : name.ToSentenceCase()!))); - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result NotFoundError(LText? message = default) => Result.Fail(new NotFoundException(message)); + return this; + } - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result TransientError(LText? message = default) => Result.Fail(new TransientException(message)); + /// + /// Converts the to a using . + /// + /// The completed . + public Task> AsTask() => Task.FromResult(this); - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result AuthenticationError(LText? message = default) => Result.Fail(new AuthenticationException(message)); + /// + /// Implicitly converts an to a that is considered . + /// + /// The underlying error represented as an . + public static implicit operator Result(Exception error) => new(error); - /// - /// Creates a with an (see ) of type . - /// - /// The error message. - /// The that has a state of . - public static Result AuthorizationError(LText? message = default) => Result.Fail(new AuthorizationException(message)); + /// + /// Implicitly converts a to a defaulting the where . + /// + /// The . + public static implicit operator Result(Result result) => result.Bind(() => new Result()); - #endregion + /// + /// Explicitly converts a to a losing the where . + /// + /// The + public static explicit operator Result(Result result) => result.Bind(); - #region Equality + /// + /// Implicitly converts a to a as . + /// + /// The underlying value. + public static implicit operator Result(T value) => Result.Ok(value); - /// - public override bool Equals(object? obj) => obj is Result r && Equals(r); + /// + /// Implicitly converts a to a where . + /// + /// The + public static implicit operator T(Result result) => result.Value; - /// - public bool Equals(Result other) => IsSuccess ? (other.IsSuccess && EqualityComparer.Default.Equals(Value, other.Value)) : (IsFailure == other.IsFailure && Error.GetType() == other.Error.GetType() && Error.ToString() == other.Error.ToString()); + /// + public override bool Equals(object? obj) => obj is Result r && Equals(r); - /// - /// Indicates whether the current is equal to another . - /// - public static bool operator ==(Result left, Result right) => left.Equals(right); + /// + public bool Equals(Result other) => IsSuccess ? (other.IsSuccess && EqualityComparer.Default.Equals(Value, other.Value)) : (IsFailure == other.IsFailure && Error.GetType() == other.Error.GetType() && Error.ToString() == other.Error.ToString()); - /// - /// Indicates whether the current is not equal to another . - /// - public static bool operator !=(Result left, Result right) => !(left == right); + /// + /// Indicates whether the current is equal to another . + /// + public static bool operator ==(Result left, Result right) => left.Equals(right); - /// - public override int GetHashCode() => HashCode.Combine(IsSuccess, IsSuccess ? (Value?.GetHashCode() ?? 0) : 0, IsFailure ? Error.GetHashCode() : 0); + /// + /// Indicates whether the current is not equal to another . + /// + public static bool operator !=(Result left, Result right) => !(left == right); - #endregion - } + /// + public override int GetHashCode() => HashCode.Combine(IsSuccess, IsSuccess ? (Value?.GetHashCode() ?? 0) : 0, IsFailure ? Error.GetHashCode() : 0); } \ No newline at end of file diff --git a/src/CoreEx/Results/ResultsExtensions.Any.cs b/src/CoreEx/Results/ResultsExtensions.Any.cs new file mode 100644 index 00000000..51705ae9 --- /dev/null +++ b/src/CoreEx/Results/ResultsExtensions.Any.cs @@ -0,0 +1,620 @@ +namespace CoreEx.Results; + +public static partial class ResultsExtensions +{ + #region Synchronous + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The function to invoke. + /// The resulting . + public static Result Any(this Result result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + action(); + return result; + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The function to invoke. + /// The resulting . + public static Result Any(this Result result, Func func) + { + ThrowIfNull(result, func); + return func(); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result Any(this Result result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + action(result.ValueOrDefault); + return result; + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result Any(this Result result, Func func) + { + ThrowIfNull(result, func); + return Result.Ok(func(result.ValueOrDefault)); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result Any(this Result result, Func> func) + { + ThrowIfNull(result, func); + return func(result.ValueOrDefault); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result AnyAs(this Result result, Func func) + { + ThrowIfNull(result, func); + return Result.Ok(func()); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result AnyAs(this Result result, Func> func) + { + ThrowIfNull(result, func); + return func(); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result AnyAs(this Result result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + action(result.ValueOrDefault); + return result.Bind(); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result AnyAs(this Result result, Func func) + { + ThrowIfNull(result, func); + return func(result.ValueOrDefault); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static Result AnyAs(this Result result, Func func) + { + ThrowIfNull(result, func); + return Result.Ok(func(result.ValueOrDefault)); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static Result AnyAs(this Result result, Func> func) + { + ThrowIfNull(result, func); + return func(result.ValueOrDefault); + } + + #endregion + + #region AsyncResult + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task Any(this Task result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.Any(action); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task Any(this Task result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.Any(func); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> Any(this Task> result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.Any(action); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> Any(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.Any(func); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> Any(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.Any(func); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAs(this Task result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.AnyAs(func); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAs(this Task result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.AnyAs(func); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task AnyAs(this Task> result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.AnyAs(action); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task AnyAs(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.AnyAs(func); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAs(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.AnyAs(func); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAs(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.AnyAs(func); + } + + #endregion + + #region AsyncFunc + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task AnyAsync(this Result result, Func func) + { + ThrowIfNull(result, func); + await func().ConfigureAwait(false); + return result; + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task AnyAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return await func().ConfigureAwait(false); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsync(this Result result, Func func) + { + ThrowIfNull(result, func); + await func(result.ValueOrDefault).ConfigureAwait(false); + return result; + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return Result.Ok(await func(result.ValueOrDefault).ConfigureAwait(false)); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsync(this Result result, Func>> func) + { + ThrowIfNull(result, func); + return await func(result.ValueOrDefault).ConfigureAwait(false); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return Result.Ok(await func().ConfigureAwait(false)); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsAsync(this Result result, Func>> func) + { + ThrowIfNull(result, func); + return await func().ConfigureAwait(false); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task AnyAsAsync(this Result result, Func func) + { + ThrowIfNull(result, func); + await func(result.ValueOrDefault).ConfigureAwait(false); + return result.Bind(); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task AnyAsAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return await func(result.ValueOrDefault).ConfigureAwait(false); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return Result.Ok(await func(result.ValueOrDefault).ConfigureAwait(false)); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsAsync(this Result result, Func>> func) + { + ThrowIfNull(result, func); + return await func(result.ValueOrDefault).ConfigureAwait(false); + } + + #endregion + + #region AsyncBoth + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task AnyAsync(this Task result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task AnyAsync(this Task result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsync(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsync(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the regardless of the state. + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsync(this Task> result, Func>> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsAsync(this Task result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsAsync(this Task result, Func>> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task AnyAsAsync(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task AnyAsAsync(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsAsync(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the regardless of the state (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> AnyAsAsync(this Task> result, Func>> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.AnyAsAsync(func).ConfigureAwait(false); + } + + #endregion +} \ No newline at end of file diff --git a/src/CoreEx/Results/ResultsExtensions.Error.cs b/src/CoreEx/Results/ResultsExtensions.Error.cs new file mode 100644 index 00000000..0ca6e280 --- /dev/null +++ b/src/CoreEx/Results/ResultsExtensions.Error.cs @@ -0,0 +1,67 @@ +namespace CoreEx.Results; + +public static partial class ResultsExtensions +{ + extension(IResult result) + { + /// + /// Indicates whether the and the is a . + /// + /// A result does not imply , just that it is not in the specified error state. + public bool IsNotFoundError => result.IsFailureOfType(); + + /// + /// Indicates whether the and the is a . + /// + /// A result does not imply , just that it is not in the specified error state. + public bool IsValidationError => result.IsFailureOfType(); + + /// + /// Indicates whether the and the is a . + /// + /// A result does not imply , just that it is not in the specified error state. + public bool IsBusinessError => result.IsFailureOfType(); + + /// + /// Indicates whether the and the is a . + /// + /// A result does not imply , just that it is not in the specified error state. + public bool IsConflictError => result.IsFailureOfType(); + + /// + /// Indicates whether the and the is a . + /// + /// A result does not imply , just that it is not in the specified error state. + public bool IsConcurrencyError => result.IsFailureOfType(); + + /// + /// Indicates whether the and the is a . + /// + /// A result does not imply , just that it is not in the specified error state. + public bool IsDataConsistencyError => result.IsFailureOfType(); + + /// + /// Indicates whether the and the is a . + /// + /// A result does not imply , just that it is not in the specified error state. + public bool IsDuplicateError => result.IsFailureOfType(); + + /// + /// Indicates whether the and the is a . + /// + /// A result does not imply , just that it is not in the specified error state. + public bool IsTransientError => result.IsFailureOfType(); + + /// + /// Indicates whether the and the is a . + /// + /// A result does not imply , just that it is not in the specified error state. + public bool IsAuthenticationError => result.IsFailureOfType(); + + /// + /// Indicates whether the and the is a . + /// + /// A result does not imply , just that it is not in the specified error state. + public bool IsAuthorizationError => result.IsFailureOfType(); + } +} \ No newline at end of file diff --git a/src/CoreEx/Results/ResultsExtensions.Match.cs b/src/CoreEx/Results/ResultsExtensions.Match.cs new file mode 100644 index 00000000..2de595cd --- /dev/null +++ b/src/CoreEx/Results/ResultsExtensions.Match.cs @@ -0,0 +1,432 @@ +namespace CoreEx.Results; + +public static partial class ResultsExtensions +{ + #region Synchronous + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Result Match(this Result result, Action ok, Action fail) + { + MatchThrowIfNull(result, ok, fail); + return result.Match(() => + { + ok(); + return result; + }, e => + { + fail(e); + return result; + }); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Result Match(this Result result, Func ok, Func fail) + { + MatchThrowIfNull(result, ok, fail); + return result.IsSuccess ? ok() : fail(result.Error); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Result MatchAs(this Result result, Func> ok, Func> fail) + { + MatchThrowIfNull(result, ok, fail); + return result.IsSuccess ? ok() : fail(result.Error); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Result Match(this Result result, Action ok, Action fail) + { + MatchThrowIfNull(result, ok, fail); + return result.Match(v => + { + ok(v); + return result; + }, e => + { + fail(e); + return result; + }); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Result Match(this Result result, Func> ok, Func> fail) + { + MatchThrowIfNull(result, ok, fail); + return result.IsSuccess ? ok(result.Value) : fail(result.Error); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The output (resulting) . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Result MatchAs(this Result result, Func> ok, Func> fail) + { + MatchThrowIfNull(result, ok, fail); + return result.IsSuccess ? ok(result.Value) : fail(result.Error); + } + + #endregion + + #region AsyncResult + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task Match(this Task result, Action ok, Action fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + return r.Match(() => + { + ok(); + return r; + }, e => + { + fail(e); + return r; + }); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task Match(this Task result, Func ok, Func fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + return r.IsSuccess ? ok() : fail(r.Error); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task> MatchAs(this Task result, Func> ok, Func> fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + return r.IsSuccess ? ok() : fail(r.Error); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task> Match(this Task> result, Action ok, Action fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + return r.Match(v => + { + ok(v); + return r; + }, e => + { + fail(e); + return r; + }); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task> Match(this Task> result, Func> ok, Func> fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + return r.IsSuccess ? ok(r.Value) : fail(r.Error); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The output (resulting) . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task> MatchAs(this Task> result, Func> ok, Func> fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + return r.IsSuccess ? ok(r.Value) : fail(r.Error); + } + + #endregion + + #region AsyncFunc + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task MatchAsync(this Result result, Func ok, Func fail) + { + MatchThrowIfNull(result, ok, fail); + return await result.MatchAsync(async () => + { + await ok().ConfigureAwait(false); + return result; + }, async e => + { + await fail(e).ConfigureAwait(false); + return result; + }).ConfigureAwait(false); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Task MatchAsync(this Result result, Func> ok, Func> fail) + { + MatchThrowIfNull(result, ok, fail); + return result.IsSuccess ? ok() : fail(result.Error); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Task> MatchAsAsync(this Result result, Func>> ok, Func>> fail) + { + MatchThrowIfNull(result, ok, fail); + return result.IsSuccess ? ok() : fail(result.Error); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Task> MatchAsync(this Result result, Func ok, Func fail) + { + MatchThrowIfNull(result, ok, fail); + return result.MatchAsync(async v => + { + await ok(v).ConfigureAwait(false); + return result; + }, async e => + { + await fail(e).ConfigureAwait(false); + return result; + }); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Task> MatchAsync(this Result result, Func>> ok, Func>> fail) + { + MatchThrowIfNull(result, ok, fail); + return result.IsSuccess ? ok(result.Value) : fail(result.Error); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The output (resulting) . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static Task> MatchAsAsync(this Result result, Func>> ok, Func>> fail) + { + MatchThrowIfNull(result, ok, fail); + return result.IsSuccess ? ok(result.Value) : fail(result.Error); + } + + #endregion + + #region AsyncBoth + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task MatchAsync(this Task result, Func ok, Func fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + if (r.IsSuccess) + await ok().ConfigureAwait(false); + else + await fail(r.Error).ConfigureAwait(false); + + return r; + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task MatchAsync(this Task result, Func> ok, Func> fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + return r.IsSuccess ? await ok().ConfigureAwait(false) : await fail(r.Error).ConfigureAwait(false); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task> MatchAsAsync(this Task result, Func>> ok, Func>> fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + return r.IsSuccess ? await ok().ConfigureAwait(false) : await fail(r.Error).ConfigureAwait(false); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task> MatchAsync(this Task> result, Func ok, Func fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + if (r.IsSuccess) + await ok(r.Value).ConfigureAwait(false); + else + await fail(r.Error).ConfigureAwait(false); + + return r; + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task> MatchAsync(this Task> result, Func>> ok, Func>> fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + return r.IsSuccess ? await ok(r.Value).ConfigureAwait(false) : await fail(r.Error).ConfigureAwait(false); + } + + /// + /// Invokes (matches) the function when the is ; otherwise, invokes the function. + /// + /// The . + /// The output (resulting) . + /// The . + /// The success function. + /// The failure function. + /// The resulting . + public static async Task> MatchAsAsync(this Task> result, Func>> ok, Func>> fail) + { + MatchThrowIfNull(result, ok, fail); + var r = await result.ConfigureAwait(false); + return r.IsSuccess ? await ok(r.Value).ConfigureAwait(false) : await fail(r.Error).ConfigureAwait(false); + } + + #endregion + + /// + /// Check parameters and throw where null. + /// + private static void MatchThrowIfNull(object result, object ok, object fail) + { + result.ThrowIfNull(); + ok.ThrowIfNull(); + fail.ThrowIfNull(); + } +} \ No newline at end of file diff --git a/src/CoreEx/Results/ResultsExtensions.OnFailure.cs b/src/CoreEx/Results/ResultsExtensions.OnFailure.cs new file mode 100644 index 00000000..e8b120ec --- /dev/null +++ b/src/CoreEx/Results/ResultsExtensions.OnFailure.cs @@ -0,0 +1,632 @@ +namespace CoreEx.Results; + +public static partial class ResultsExtensions +{ + #region Synchronous + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailure(this Result result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + if (result.IsFailure) + action(result); + + return result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailure(this Result result, Func func) + { + ThrowIfNull(result, func); + return result.IsFailure ? func(result) : result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailure(this Result result, Action> action) + { + ThrowIfNull(result, action, nameof(action)); + if (result.IsFailure) + action(result); + + return result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailure(this Result result, Func, T> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? Result.Ok(func(result)) : result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailure(this Result result, Func, Result> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? func(result) : result; + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailureAs(this Result result, Func func) + { + ThrowIfNull(result, func); + return result.IsFailure ? Result.Ok(func(result)) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailureAs(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? func(result) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailureAs(this Result result, Action> action) + { + ThrowIfNull(result, action, nameof(action)); + if (result.IsFailure) + action(result); + + return result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailureAs(this Result result, Func, Result> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? func(result) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailureAs(this Result result, Func, U> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? Result.Ok(func(result)) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static Result OnFailureAs(this Result result, Func, Result> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? func(result) : result.Bind(); + } + + #endregion + + #region AsyncResult + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailure(this Task result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.OnFailure(action); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailure(this Task result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.OnFailure(func); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailure(this Task> result, Action> action) + { + ThrowIfNull(result, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.OnFailure(action); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailure(this Task> result, Func, T> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.OnFailure(func); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailure(this Task> result, Func, Result> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.OnFailure(func); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAs(this Task result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.OnFailureAs(func); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAs(this Task result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.OnFailureAs(func); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailureAs(this Task> result, Action> action) + { + ThrowIfNull(result, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.OnFailureAs(action); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailureAs(this Task> result, Func, Result> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.OnFailureAs(func); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAs(this Task> result, Func, U> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.OnFailureAs(func); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAs(this Task> result, Func, Result> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.OnFailureAs(func); + } + + #endregion + + #region AsyncFunc + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailureAsync(this Result result, Func func) + { + ThrowIfNull(result, func); + if (result.IsFailure) + await func(result).ConfigureAwait(false); + + return result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailureAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? await func(result).ConfigureAwait(false) : result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsync(this Result result, Func, Task> func) + { + ThrowIfNull(result, func); + if (result.IsFailure) + await func(result).ConfigureAwait(false); + + return result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsync(this Result result, Func, Task> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? Result.Ok(await func(result).ConfigureAwait(false)) : result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsync(this Result result, Func, Task>> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? await func(result).ConfigureAwait(false) : result; + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? Result.Ok(await func(result).ConfigureAwait(false)) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsAsync(this Result result, Func>> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? await func(result).ConfigureAwait(false) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailureAsAsync(this Result result, Func, Task> func) + { + ThrowIfNull(result, func); + if (result.IsFailure) + await func(result).ConfigureAwait(false); + + return result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailureAsAsync(this Result result, Func, Task> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? await func(result).ConfigureAwait(false) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsAsync(this Result result, Func, Task> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? Result.Ok(await func(result).ConfigureAwait(false)) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsAsync(this Result result, Func, Task>> func) + { + ThrowIfNull(result, func); + return result.IsFailure ? await func(result).ConfigureAwait(false) : result.Bind(); + } + + #endregion + + #region AsyncBoth + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailureAsync(this Task result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailureAsync(this Task result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsync(this Task> result, Func, Task> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsync(this Task> result, Func, Task> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsync(this Task> result, Func, Task>> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsAsync(this Task result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsAsync(this Task result, Func>> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailureAsAsync(this Task> result, Func, Task> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task OnFailureAsAsync(this Task> result, Func, Task> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsAsync(this Task> result, Func, Task> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> OnFailureAsAsync(this Task> result, Func, Task>> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.OnFailureAsAsync(func).ConfigureAwait(false); + } + + #endregion +} \ No newline at end of file diff --git a/src/CoreEx/Results/ResultsExtensions.Then.cs b/src/CoreEx/Results/ResultsExtensions.Then.cs new file mode 100644 index 00000000..60f625cb --- /dev/null +++ b/src/CoreEx/Results/ResultsExtensions.Then.cs @@ -0,0 +1,686 @@ +namespace CoreEx.Results; + +public static partial class ResultsExtensions +{ + #region Synchronous + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static Result Then(this Result result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + if (result.IsSuccess) + action(); + + return result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static Result Then(this Result result, Func func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? func() : result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result Then(this Result result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + if (result.IsSuccess) + action(result.Value); + + return result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result Then(this Result result, Func func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? Result.Ok(func(result.Value)) : result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result Then(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? func(result.Value) : result; + } + + /// + /// Executes the where the is (the will not be lost). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result Then(this Result result, Func func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? func(result.Value).Combine(result) : result; + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result ThenAs(this Result result, Func func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? Result.Ok(func()) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result ThenAs(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? func() : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result ThenAs(this Result result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + if (result.IsSuccess) + action(result.Value); + + return result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static Result ThenAs(this Result result, Func func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? func(result.Value) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static Result ThenAs(this Result result, Func func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? Result.Ok(func(result.Value)) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static Result ThenAs(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? func(result.Value) : result.Bind(); + } + + #endregion + + #region AsyncResult + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task Then(this Task result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.Then(action); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task Then(this Task result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.Then(func); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> Then(this Task> result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.Then(action); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> Then(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.Then(func); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> Then(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.Then(func); + } + + /// + /// Executes the where the is (the will not be lost). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> Then(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.Then(func); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAs(this Task result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.ThenAs(func); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAs(this Task result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.ThenAs(func); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task ThenAs(this Task> result, Action action) + { + ThrowIfNull(result, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.ThenAs(action); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task ThenAs(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.ThenAs(func); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAs(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.ThenAs(func); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAs(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return r.ThenAs(func); + } + + #endregion + + #region AsyncFunc + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task ThenAsync(this Result result, Func func) + { + ThrowIfNull(result, func); + if (result.IsSuccess) + await func().ConfigureAwait(false); + + return result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task ThenAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? await func().ConfigureAwait(false) : result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsync(this Result result, Func func) + { + ThrowIfNull(result, func); + if (result.IsSuccess) + await func(result.Value).ConfigureAwait(false); + + return result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? Result.Ok(await func(result.Value).ConfigureAwait(false)) : result; + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsync(this Result result, Func>> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? await func(result.Value).ConfigureAwait(false) : result; + } + + /// + /// Executes the where the is (the will not be lost). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? (await func(result.Value).ConfigureAwait(false)).Combine(result) : result; + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? Result.Ok(await func().ConfigureAwait(false)) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsAsync(this Result result, Func>> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? await func().ConfigureAwait(false) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task ThenAsAsync(this Result result, Func func) + { + ThrowIfNull(result, func); + if (result.IsSuccess) + await func(result.Value).ConfigureAwait(false); + + return result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task ThenAsAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? await func(result.Value).ConfigureAwait(false) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsAsync(this Result result, Func> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? Result.Ok(await func(result.Value).ConfigureAwait(false)) : result.Bind(); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsAsync(this Result result, Func>> func) + { + ThrowIfNull(result, func); + return result.IsSuccess ? await func(result.Value).ConfigureAwait(false) : result.Bind(); + } + + #endregion + + #region AsyncBoth + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task ThenAsync(this Task result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The function to invoke. + /// The resulting . + public static async Task ThenAsync(this Task result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsync(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsync(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is . + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsync(this Task> result, Func>> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (the will not be lost). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsync(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsAsync(this Task result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsAsync(this Task result, Func>> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task ThenAsAsync(this Task> result, Func func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task ThenAsAsync(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsAsync(this Task> result, Func> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsAsync(func).ConfigureAwait(false); + } + + /// + /// Executes the where the is (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The function to invoke. + /// The resulting . + public static async Task> ThenAsAsync(this Task> result, Func>> func) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.ThenAsAsync(func).ConfigureAwait(false); + } + + #endregion +} \ No newline at end of file diff --git a/src/CoreEx/Results/ResultsExtensions.When.cs b/src/CoreEx/Results/ResultsExtensions.When.cs new file mode 100644 index 00000000..fec92d69 --- /dev/null +++ b/src/CoreEx/Results/ResultsExtensions.When.cs @@ -0,0 +1,939 @@ +namespace CoreEx.Results; + +public static partial class ResultsExtensions +{ + #region Synchronous + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result When(this Result result, Func condition, Action action, Action? otherwise = null) + { + ThrowIfNull(result, condition, action, nameof(action)); + if (result.IsSuccess) + { + if (condition()) + action(); + else + otherwise?.Invoke(); + } + + return result; + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result When(this Result result, Func condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result; + + if (condition()) + return func(); + else + return otherwise is null ? result : otherwise(); + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result When(this Result result, Predicate condition, Action action, Action? otherwise = null) + { + ThrowIfNull(result, condition, action, nameof(action)); + if (result.IsSuccess) + { + if (condition(result.Value)) + action(result.Value); + else + otherwise?.Invoke(result.Value); + } + + return result; + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result When(this Result result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result; + + if (condition(result.Value)) + return Result.Ok(func(result.Value)); + else + return otherwise is null ? result : Result.Ok(otherwise(result.Value)); + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result When(this Result result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result; + + if (condition(result.Value)) + return func(result.Value); + else + return otherwise is null ? result : otherwise(result.Value); + } + + /// + /// Executes the where the is and evaluates to (the will not be lost). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result When(this Result result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result; + + if (condition(result.Value)) + return func(result.Value).Combine(result); + else + return otherwise is null ? result : func(result.Value).Combine(result); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result WhenAs(this Result result, Func condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result.Bind(); + + if (condition()) + return Result.Ok(func()); + else + return otherwise is null ? result.Bind() : Result.Ok(otherwise()); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result WhenAs(this Result result, Func condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result.Bind(); + + if (condition()) + return func(); + else + return otherwise is null ? result.Bind() : otherwise(); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result WhenAs(this Result result, Predicate condition, Action action, Action? otherwise = null) + { + ThrowIfNull(result, condition, action, nameof(action)); + if (result.IsSuccess) + { + if (condition(result.Value)) + action(result.Value); + else + otherwise?.Invoke(result.Value); + } + + return result.Bind(); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result WhenAs(this Result result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result.Bind(); + + if (condition(result.Value)) + return func(result.Value); + else + return otherwise is null ? result.Bind() : otherwise(result.Value); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result WhenAs(this Result result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result.Bind(); + + if (condition(result.Value)) + return Result.Ok(func(result.Value)); + else + return otherwise is null ? result.Bind() : Result.Ok(otherwise(result.Value)); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static Result WhenAs(this Result result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result.Bind(); + + if (condition(result.Value)) + return func(result.Value); + else + return otherwise is null ? result.Bind() : otherwise(result.Value); + } + + #endregion + + #region AsyncResult + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task When(this Task result, Func condition, Action action, Action? otherwise = null) + { + ThrowIfNull(result, condition, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.When(condition, action, otherwise); + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task When(this Task result, Func condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return r.When(condition, func, otherwise); + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> When(this Task> result, Predicate condition, Action action, Action? otherwise = null) + { + ThrowIfNull(result, condition, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.When(condition, action, otherwise); + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> When(this Task> result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return r.When(condition, func, otherwise); + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> When(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return r.When(condition, func, otherwise); + } + + /// + /// Executes the where the is and evaluates to (the will not be lost). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> When(this Task> result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return r.When(condition, func, otherwise); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAs(this Task result, Func condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return r.WhenAs(condition, func, otherwise); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAs(this Task result, Func condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return r.WhenAs(condition, func, otherwise); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task WhenAs(this Task> result, Predicate condition, Action action, Action? otherwise = null) + { + ThrowIfNull(result, condition, action, nameof(action)); + var r = await result.ConfigureAwait(false); + return r.WhenAs(condition, action, otherwise); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task WhenAs(this Task> result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return r.WhenAs(condition, func, otherwise); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAs(this Task> result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return r.WhenAs(condition, func, otherwise); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The condition/predicate that must also be evaluated. + /// The to invoke. + /// The to invoke where condition/predicate is (where is also ). + /// The resulting . + public static async Task> WhenAs(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return r.WhenAs(condition, func, otherwise); + } + + #endregion + + #region AsyncFunc + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task WhenAsync(this Result result, Func condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsSuccess) + { + if (condition()) + await func().ConfigureAwait(false); + else + { + if (otherwise is not null) + await otherwise().ConfigureAwait(false); + } + } + + return result; + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task WhenAsync(this Result result, Func condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result; + + if (condition()) + return await func().ConfigureAwait(false); + else + return otherwise is not null ? await otherwise().ConfigureAwait(false) : result; + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsync(this Result result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsSuccess) + { + if (condition(result.Value)) + await func(result.Value).ConfigureAwait(false); + else + { + if (otherwise is not null) + await otherwise(result.Value).ConfigureAwait(false); + } + } + + return result; + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result; + + if (condition(result.Value)) + return Result.Ok(await func(result.Value).ConfigureAwait(false)); + else + return otherwise is not null ? Result.Ok(await otherwise(result.Value).ConfigureAwait(false)) : result; + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsync(this Result result, Predicate condition, Func>> func, Func>>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result; + + if (condition(result.Value)) + return await func(result.Value).ConfigureAwait(false); + else + return otherwise is not null ? await otherwise(result.Value).ConfigureAwait(false) : result; + } + + /// + /// Executes the where the is and evaluates to (the will not be lost). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, func); + if (result.IsFailure) + return result; + + if (condition(result.Value)) + return await func(result.Value).ConfigureAwait(false); + else + return otherwise is not null ? await otherwise(result.Value).ConfigureAwait(false) : result; + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsAsync(this Result result, Func condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result.Bind(); + + if (condition()) + return Result.Ok(await func().ConfigureAwait(false)); + else + return otherwise is not null ? Result.Ok(await otherwise().ConfigureAwait(false)) : result.Bind(); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsAsync(this Result result, Func condition, Func>> func, Func>>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result.Bind(); + + if (condition()) + return await func().ConfigureAwait(false); + else + return otherwise is not null ? await otherwise().ConfigureAwait(false) : result.Bind(); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task WhenAsAsync(this Result result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsSuccess) + { + if (condition(result.Value)) + await func(result.Value).ConfigureAwait(false); + else if (otherwise is not null) + await otherwise(result.Value).ConfigureAwait(false); + } + + return result.Bind(); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task WhenAsAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result.Bind(); + + if (condition(result.Value)) + return await func(result.Value).ConfigureAwait(false); + else + return otherwise is not null ? await otherwise(result.Value).ConfigureAwait(false) : result.Bind(); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsSuccess) + { + if (condition(result.Value)) + return Result.Ok(await func(result.Value).ConfigureAwait(false)); + else if (otherwise is not null) + return Result.Ok(await otherwise(result.Value).ConfigureAwait(false)); + } + + return result.Bind(); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsAsync(this Result result, Predicate condition, Func>> func, Func>>? otherwise = null) + { + ThrowIfNull(result, condition, func); + if (result.IsFailure) + return result.Bind(); + + if (condition(result.Value)) + return await func(result.Value).ConfigureAwait(false); + else + return otherwise is not null ? await otherwise(result.Value).ConfigureAwait(false) : result.Bind(); + } + + #endregion + + #region AsyncBoth + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task WhenAsync(this Task result, Func condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task WhenAsync(this Task result, Func condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsync(this Task> result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to . + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsync(this Task> result, Predicate condition, Func>> func, Func>>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to (the will not be lost). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsAsync(this Task result, Func condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsAsync(this Task result, Func condition, Func>> func, Func>>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task WhenAsAsync(this Task> result, Predicate condition, Func func, Func? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task WhenAsAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); + } + + /// + /// Executes the where the is and evaluates to (as new ). + /// + /// The . + /// The output (resulting) . + /// The . + /// The condition/predicate that must also be evaluated. + /// The function to invoke where is (where is ). + /// The function to invoke where is (where is ). + /// The resulting . + public static async Task> WhenAsAsync(this Task> result, Predicate condition, Func>> func, Func>>? otherwise = null) + { + ThrowIfNull(result, condition, func); + var r = await result.ConfigureAwait(false); + return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); + } + + + #endregion + + /// + /// Check parameters and throw where null. + /// + private static void ThrowIfNull(object result, object condition, object func, string? name = null) + { + result.ThrowIfNull(); + condition.ThrowIfNull(); + func.ThrowIfNull(name ?? nameof(func)); + } +} \ No newline at end of file diff --git a/src/CoreEx/Results/ResultsExtensions.cs b/src/CoreEx/Results/ResultsExtensions.cs index c1af2dec..5c6f6d65 100644 --- a/src/CoreEx/Results/ResultsExtensions.cs +++ b/src/CoreEx/Results/ResultsExtensions.cs @@ -1,225 +1,179 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Results; -using System; -using System.Diagnostics; -using System.Threading.Tasks; - -namespace CoreEx.Results +/// +/// Provides the and extension methods. +/// +public static partial class ResultsExtensions { /// - /// Provides the and extension methods. + /// Unwraps the and where invokes the and returns the resulting ; + /// otherwise, where returns a resulting instance with the corresponding . + /// + /// The . + /// The output (resulting) . + /// The . + /// The binding (mapping) function. + /// The resulting . + public static Result Bind(this Result result, Func> func) + { + func.ThrowIfNull(); + return result.IsSuccess ? func(result.Value) : new Result(result.Error!); + } + + /// + /// Binds/converts the to a corresponding defaulting to where losing the ; + /// otherwise, where returns a resulting instance with the corresponding . + /// + /// The . + /// The output (resulting) . + /// The . + /// The resulting . + public static Result Bind(this Result result) => result.IsSuccess ? (result.Value is U uv ? new Result(uv) : Result.Success) : new Result(result.Error!); + + /// + /// Unwraps the and where invokes the and returns the resulting ; + /// otherwise, where returns a resulting instance with the corresponding . + /// + /// The output (resulting) . + /// The . + /// The binding (mapping) function. + /// The resulting . + public static Result Bind(this Result result, Func> func) + { + func.ThrowIfNull(); + return result.IsSuccess ? func() : new Result(result.Error!); + } + + /// + /// Binds/converts the to a corresponding defaulting to where ; + /// otherwise, where returns a resulting instance with the corresponding . + /// + /// The output (resulting) . + /// The . + /// The resulting . + public static Result Bind(this Result result) => result.IsSuccess ? Result.Success : new Result(result.Error!); + + /// + /// Binds/converts the to a corresponding losing the . + /// + /// The . + /// The . + /// The resulting . + public static Result Bind(this Result result) => result.IsSuccess ? Result.Success : new Result(result.Error); + + /// + /// Combines the and into a single . + /// + /// The . + /// The other . + /// The resulting . + /// Where either or both are failures, a failure state will be returned with the corresponding ; where both contains errors these will be aggregated into an . + public static Result Combine(this Result result, Result other) + { + if (result.IsFailure && other.IsFailure) + return new Result(new AggregateException(result.Error, other.Error)); + + return result.IsFailure ? result : other; + } + + /// + /// Combines the and into a single ; on success the will be returned. + /// + /// The . + /// The . + /// The other . + /// The resulting . + /// Where either or both are failures, a failure state will be returned with the corresponding ; where both contains errors these will be aggregated into an . + public static Result Combine(this Result result, Result other) + { + if (result.IsFailure && other.IsFailure) + return new Result(new AggregateException(result.Error, other.Error)); + + if (result.IsFailure) + return new Result(result.Error!); + + return other; + } + + /// + /// Combines the and into a single . + /// + /// The . + /// The other and resulting . + /// The . + /// The other . + /// The resulting . + /// Where either or both are failures, a failure state will be returned with the corresponding . Where both contain errors these will be aggregated into an . + /// Where both are successful and, and are the same , the is used as the returned value (i.e is ignored). + public static Result Combine(this Result result, Result other) + { + if (result.IsFailure && other.IsFailure) + return new Result(new AggregateException(result.Error, other.Error)); + + if (result.IsFailure) + return new Result(result.Error!); + + if (other.IsFailure) + return other; + + return result.Value is U uv ? uv : other; + } + + /// + /// Converts the to a corresponding losing the . + /// + /// The . + /// The corresponding . + /// This invokes internally to perform. + public static Result AsResult(this Result result) => result.Bind(); + + /// + /// Converts the to a corresponding losing the . + /// + /// The . + /// The corresponding . + /// This invokes internally to perform. + public static async Task AsResultAsync(this Task> result) + { + var r = await result.ConfigureAwait(false); + return r.Bind(); + } + + /// + /// Converts the to a corresponding (of ) defaulting to where losing the + /// ; otherwise, where returns a resulting instance with the corresponding . + /// + /// The . + /// The output (resulting) . + /// The corresponding . + /// This invokes internally to perform. + public static Result AsResult(this Result result) => result.Bind(); + + /// + /// Converts the to a corresponding (of ) defaulting to where losing the + /// ; otherwise, where returns a resulting instance with the corresponding . + /// + /// The . + /// The output (resulting) . + /// The corresponding . + /// This invokes internally to perform. + public static async Task> AsResultAsync(this Task> result) + { + var r = await result.ConfigureAwait(false); + return r.Bind(); + } + + /// + /// Check parameters and throw where null. + /// + private static void ThrowIfNull(object result) => result.ThrowIfNull(); + + /// + /// Check parameters and throw where null. /// - [DebuggerStepThrough] - public static partial class ResultsExtensions + private static void ThrowIfNull(object result, object func, string? name = null) { - /// - /// Check parameters and throw where null. - /// - private static void ThrowIfNull(object result) - { - result.ThrowIfNull(nameof(result)); - } - - /// - /// Check parameters and throw where null. - /// - private static void ThrowIfNull(object result, object func, string? name = null) - { - ThrowIfNull(result); - func.ThrowIfNull(name ?? nameof(func)); - } - - /// - /// Converts the to a corresponding losing the . - /// - /// The . - /// The corresponding . - /// This invokes internally to perform. - public static Result AsResult(this Result result) => result.Bind(); - - /// - /// Converts the to a corresponding losing the . - /// - /// The . - /// The corresponding . - /// This invokes internally to perform. - public static async Task AsResult(this Task> result) - { - var r = await result.ConfigureAwait(false); - return r.Bind(); - } - - /// - /// Converts the to a corresponding (of ) defaulting to where losing the - /// ; otherwise, where returns a resulting instance with the corresponding . - /// - /// The . - /// The output (resulting) . - /// The corresponding . - /// This invokes internally to perform. - public static Result AsResult(this Result result) => result.Bind(); - - /// - /// Converts the to a corresponding (of ) defaulting to where losing the - /// ; otherwise, where returns a resulting instance with the corresponding . - /// - /// The . - /// The output (resulting) . - /// The corresponding . - /// This invokes internally to perform. - public static async Task> AsResult(this Task> result) - { - var r = await result.ConfigureAwait(false); - return r.Bind(); - } - - /// - /// Verifies that the is not null where the is and throws a corresponding . - /// - /// The . - /// The . - /// The value name (defaults to ). - /// The resulting . - public static Result ThrowIfNull(this Result result, string? name = null) - => result.IsSuccess && result.Value == null ? throw new ArgumentNullException(name ?? Validation.Validation.ValueNameDefault) : result; - - /// - /// Enables adjustment (changes) to a via an action where the is - /// - /// The . - /// The . - /// The adjusting action (invoked only where the underlying is not null). - /// The resulting . - public static Result Adjusts(this Result result, Action adjuster) - { - if (result.IsSuccess) - result.Value.Adjust(adjuster); - - return result; - } - - /// - /// Enables adjustment (changes) to a via an action where the is - /// - /// The . - /// The . - /// The adjusting action (invoked only where the underlying is not null). - /// The resulting . - public static async Task> Adjusts(this Task> result, Action adjuster) - { - var r = await result.ConfigureAwait(false); - return r.Adjusts(adjuster); - } - - /// - /// Enables adjustment (changes) to a via an action where the is - /// - /// The . - /// The . - /// The adjusting action (invoked only where the underlying is not null). - /// The resulting . - public static async Task> AdjustsAsync(this Result result, Func adjuster) - { - if (result.IsSuccess && result.Value is not null) - await adjuster(result.Value).ConfigureAwait(false); - - return result; - } - - /// - /// Enables adjustment (changes) to a via an action where the is - /// - /// The . - /// The . - /// The adjusting action (invoked only where the underlying is not null). - /// The resulting . - public static async Task> AdjustsAsync(this Task> result, Func adjuster) - { - var r = await result.ConfigureAwait(false); - return await r.AdjustsAsync(adjuster).ConfigureAwait(false); - } - - /// - /// Checks whether the user has the required (see ). - /// - /// The or . - /// The . - /// The ; where null will attempt to use where . - /// The permission to validate. - /// The resulting . - public static TResult UserIsAuthorized(this TResult result, ExecutionContext executionContext, string permission) where TResult : IResult - { - if (result.IsFailure) - return result; - - executionContext ??= ExecutionContext.HasCurrent ? ExecutionContext.Current : executionContext.ThrowIfNull(nameof(executionContext)); - var r = executionContext.UserIsAuthorized(permission); - return r.IsSuccess ? result : (TResult)result.ToFailure(r.Error); - } - - /// - /// Checks whether the user has the required permission as a combination of and (see ). - /// - /// The or . - /// The . - /// The ; where null will attempt to use where . - /// The entity name. - /// The action name. - /// The resulting . - public static TResult UserIsAuthorized(this TResult result, ExecutionContext executionContext, string entity, string action) where TResult : IResult - { - if (result.IsFailure) - return result; - - executionContext ??= ExecutionContext.HasCurrent ? ExecutionContext.Current : executionContext.ThrowIfNull(nameof(executionContext)); - var r = executionContext.UserIsAuthorized(entity, action); - return r.IsSuccess ? result : (TResult)result.ToFailure(r.Error); - } - - /// - /// Checks whether the user has the required (see ). - /// - /// The or . - /// The . - /// The permission to validate. - /// The resulting . - public static TResult UserIsAuthorized(this TResult result, string permission) where TResult : IResult => UserIsAuthorized(result, (ExecutionContext)null!, permission); - - /// - /// Checks whether the user has the required permission as a combination of and (see ). - /// - /// The or . - /// The . - /// The entity name. - /// The action name. - /// The resulting . - public static TResult UserIsAuthorized(this TResult result, string entity, string action) where TResult : IResult => UserIsAuthorized(result, null!, entity, action); - - /// - /// Checks whether the user is in specified (see ). - /// - /// The or . - /// The . - /// The ; where null will attempt to use where . - /// The role name. - /// The resulting . - public static TResult UserIsInRole(this TResult result, ExecutionContext executionContext, string role) where TResult : IResult - { - if (result.IsFailure) - return result; - - executionContext ??= ExecutionContext.HasCurrent ? ExecutionContext.Current : executionContext.ThrowIfNull(nameof(executionContext)); - var r = executionContext.UserIsInRole(role); - return r.IsSuccess ? result : (TResult)result.ToFailure(r.Error); - } - - /// - /// Checks whether the user is in specified (see ). - /// - /// The or . - /// The . - /// The role name. - /// The resulting . - public static TResult UserIsInRole(this TResult result, string role) where TResult : IResult => UserIsInRole(result, null!, role); + ThrowIfNull(result); + func.ThrowIfNull(name ?? nameof(func)); } } \ No newline at end of file diff --git a/src/CoreEx/Results/ThenExtensions.cs b/src/CoreEx/Results/ThenExtensions.cs deleted file mode 100644 index ebd05d44..00000000 --- a/src/CoreEx/Results/ThenExtensions.cs +++ /dev/null @@ -1,1136 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading.Tasks; - -namespace CoreEx.Results -{ - public static partial class ResultsExtensions - { - #region Synchronous - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result Then(this Result result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - if (result.IsSuccess) - action(); - - return result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result Then(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result Then(this Result result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - if (result.IsSuccess) - action(result.Value); - - return result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result Then(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? Result.Ok(func(result.Value)) : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result Then(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func(result.Value) : result; - } - - /// - /// Executes the where the is (the will not be lost). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result Then(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func(result.Value).Combine(result) : result; - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? Result.Ok(func()) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func() : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenAs(this Result result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - if (result.IsSuccess) - action(result.Value); - - return result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func(result.Value) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? Result.Ok(func(result.Value)) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func(result.Value) : result.Bind(); - } - - /* IToResult */ - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenFrom(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func().ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenFrom(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func(result.Value).ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenFrom(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func(result.Value).ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenFromAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func(result.Value).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenFromAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func().ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenFromAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func().ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenFromAs(this Result result, Func func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func(result.Value).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static Result ThenFromAs(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? func(result.Value).ToResult() : result.Bind(); - } - - #endregion - - #region AsyncResult - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task Then(this Task result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.Then(action); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task Then(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.Then(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> Then(this Task> result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.Then(action); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> Then(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.Then(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> Then(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.Then(func); - } - - /// - /// Executes the where the is (the will not be lost). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> Then(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.Then(func); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAs(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenAs(func); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAs(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenAs(func); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenAs(this Task> result, Action action) - { - ThrowIfNull(result, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.ThenAs(action); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenAs(func); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenAs(func); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAs(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenAs(func); - } - - /* IToResult */ - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenFrom(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenFrom(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFrom(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenFrom(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFrom(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenFrom(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenFromAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenFromAs(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAs(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenFromAs(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAs(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenFromAs(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAs(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenFromAs(func); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAs(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return r.ThenFromAs(func); - } - - #endregion - - #region AsyncFunc - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenAsync(this Result result, Func func) - { - ThrowIfNull(result, func); - if (result.IsSuccess) - await func().ConfigureAwait(false); - - return result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? await func().ConfigureAwait(false) : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsync(this Result result, Func func) - { - ThrowIfNull(result, func); - if (result.IsSuccess) - await func(result.Value).ConfigureAwait(false); - - return result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? Result.Ok(await func(result.Value).ConfigureAwait(false)) : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? await func(result.Value).ConfigureAwait(false) : result; - } - - /// - /// Executes the where the is (the will not be lost). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? (await func(result.Value).ConfigureAwait(false)).Combine(result) : result; - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? Result.Ok(await func().ConfigureAwait(false)) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? await func().ConfigureAwait(false) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenAsAsync(this Result result, Func func) - { - ThrowIfNull(result, func); - if (result.IsSuccess) - await func(result.Value).ConfigureAwait(false); - - return result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? await func(result.Value).ConfigureAwait(false) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? Result.Ok(await func(result.Value).ConfigureAwait(false)) : result.Bind(); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? await func(result.Value).ConfigureAwait(false) : result.Bind(); - } - - /* IToResult */ - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenFromAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? (await func().ConfigureAwait(false)).ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? (await func(result.Value).ConfigureAwait(false)).ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? (await func(result.Value).ConfigureAwait(false)).ToResult() : result; - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenFromAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? (await func(result.Value).ConfigureAwait(false)).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? (await func().ConfigureAwait(false)).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? (await func().ConfigureAwait(false)).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsAsync(this Result result, Func> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? (await func(result.Value).ConfigureAwait(false)).ToResult() : result.Bind(); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsAsync(this Result result, Func>> func) - { - ThrowIfNull(result, func); - return result.IsSuccess ? (await func(result.Value).ConfigureAwait(false)).ToResult() : result.Bind(); - } - - #endregion - - #region AsyncBoth - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenAsync(this Task result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsync(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (the will not be lost). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsAsync(this Task result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenAsAsync(this Task> result, Func func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenAsAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenAsAsync(func).ConfigureAwait(false); - } - - /* IToResult */ - - /// - /// Executes the where the is . - /// - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenFromAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenFromAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenFromAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenFromAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task ThenFromAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenFromAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsAsync(this Task result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenFromAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsAsync(this Task result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenFromAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsAsync(this Task> result, Func> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenFromAsAsync(func).ConfigureAwait(false); - } - - /// - /// Executes the where the is . - /// - /// The . - /// The output (resulting) . - /// The . - /// The function to invoke. - /// The resulting . - public static async Task> ThenFromAsAsync(this Task> result, Func>> func) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.ThenFromAsAsync(func).ConfigureAwait(false); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/CoreEx/Results/WhenExtensions.cs b/src/CoreEx/Results/WhenExtensions.cs deleted file mode 100644 index a6db36d8..00000000 --- a/src/CoreEx/Results/WhenExtensions.cs +++ /dev/null @@ -1,1564 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Threading.Tasks; - -namespace CoreEx.Results -{ - public static partial class ResultsExtensions - { - #region Synchronous - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result When(this Result result, Func condition, Action action, Action? otherwise = null) - { - ThrowIfNull(result, condition, action, nameof(action)); - if (result.IsSuccess) - { - if (condition()) - action(); - else - otherwise?.Invoke(); - } - - return result; - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result When(this Result result, Func condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result; - - if (condition()) - return func(); - else - return otherwise is null ? result : otherwise(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result When(this Result result, Predicate condition, Action action, Action? otherwise = null) - { - ThrowIfNull(result, condition, action, nameof(action)); - if (result.IsSuccess) - { - if (condition(result.Value)) - action(result.Value); - else - otherwise?.Invoke(result.Value); - } - - return result; - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result When(this Result result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result; - - if (condition(result.Value)) - return Result.Ok(func(result.Value)); - else - return otherwise is null ? result : Result.Ok(otherwise(result.Value)); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result When(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result; - - if (condition(result.Value)) - return func(result.Value); - else - return otherwise is null ? result : otherwise(result.Value); - } - - /// - /// Executes the where the is and evaluates to true (the will not be lost). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result When(this Result result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result; - - if (condition(result.Value)) - return func(result.Value).Combine(result); - else - return otherwise is null ? result : func(result.Value).Combine(result); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenAs(this Result result, Func condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition()) - return Result.Ok(func()); - else - return otherwise is null ? result.Bind() : Result.Ok(otherwise()); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenAs(this Result result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition()) - return func(); - else - return otherwise is null ? result.Bind() : otherwise(); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenAs(this Result result, Predicate condition, Action action, Action? otherwise = null) - { - ThrowIfNull(result, condition, action, nameof(action)); - if (result.IsSuccess) - { - if (condition(result.Value)) - action(result.Value); - else - otherwise?.Invoke(result.Value); - } - - return result.Bind(); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenAs(this Result result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition(result.Value)) - return func(result.Value); - else - return otherwise is null ? result.Bind() : otherwise(result.Value); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenAs(this Result result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition(result.Value)) - return Result.Ok(func(result.Value)); - else - return otherwise is null ? result.Bind() : Result.Ok(otherwise(result.Value)); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenAs(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition(result.Value)) - return func(result.Value); - else - return otherwise is null ? result.Bind() : otherwise(result.Value); - } - - /* IToResult */ - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenFrom(this Result result, Func condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result; - - if (condition()) - return func().ToResult(); - else - return otherwise is null ? result : otherwise().ToResult(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenFrom(this Result result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result; - - if (condition(result.Value)) - return func(result.Value).ToResult(); - else - return otherwise is null ? result : otherwise(result.Value).ToResult(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenFrom(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result; - - if (condition(result.Value)) - return func(result.Value).ToResult(); - else - return otherwise is null ? result : otherwise(result.Value).ToResult(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenFromAs(this Result result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition(result.Value)) - return func(result.Value).ToResult(); - else - return otherwise is null ? result.Bind() : otherwise(result.Value).ToResult(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenFromAs(this Result result, Func condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition()) - return func().ToResult(); - else - return otherwise is null ? result.Bind() : otherwise().ToResult(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenFromAs(this Result result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition()) - return func().ToResult(); - else - return otherwise is null ? result.Bind() : otherwise().ToResult(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenFromAs(this Result result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition(result.Value)) - return func(result.Value).ToResult(); - else - return otherwise is null ? result.Bind() : otherwise(result.Value).ToResult(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static Result WhenFromAs(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition(result.Value)) - return func(result.Value).ToResult(); - else - return otherwise is null ? result.Bind() : otherwise(result.Value).ToResult(); - } - - #endregion - - #region AsyncResult - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task When(this Task result, Func condition, Action action, Action? otherwise = null) - { - ThrowIfNull(result, condition, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.When(condition, action, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task When(this Task result, Func condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.When(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> When(this Task> result, Predicate condition, Action action, Action? otherwise = null) - { - ThrowIfNull(result, condition, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.When(condition, action, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> When(this Task> result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.When(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> When(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.When(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true (the will not be lost). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> When(this Task> result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.When(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAs(this Task result, Func condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenAs(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAs(this Task result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenAs(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenAs(this Task> result, Predicate condition, Action action, Action? otherwise = null) - { - ThrowIfNull(result, condition, action, nameof(action)); - var r = await result.ConfigureAwait(false); - return r.WhenAs(condition, action, otherwise); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenAs(this Task> result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenAs(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAs(this Task> result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenAs(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The to invoke. - /// The to invoke where condition/predicate is false (where is also ). - /// The resulting . - public static async Task> WhenAs(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenAs(condition, func, otherwise); - } - - /* IToResult */ - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenFrom(this Task result, Func condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenFrom(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFrom(this Task> result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenFrom(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFrom(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenFrom(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenFromAs(this Task> result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenFromAs(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAs(this Task result, Func condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenFromAs(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAs(this Task result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenFromAs(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAs(this Task> result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenFromAs(condition, func, otherwise); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAs(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return r.WhenFromAs(condition, func, otherwise); - } - - #endregion - - #region AsyncFunc - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenAsync(this Result result, Func condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition()) - await func().ConfigureAwait(false); - else - { - if (otherwise is not null) - await otherwise().ConfigureAwait(false); - } - } - - return result; - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenAsync(this Result result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result; - - if (condition()) - return await func().ConfigureAwait(false); - else - return otherwise is not null ? await otherwise().ConfigureAwait(false) : result; - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsync(this Result result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition(result.Value)) - await func(result.Value).ConfigureAwait(false); - else - { - if (otherwise is not null) - await otherwise(result.Value).ConfigureAwait(false); - } - } - - return result; - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result; - - if (condition(result.Value)) - return Result.Ok(await func(result.Value).ConfigureAwait(false)); - else - return otherwise is not null ? Result.Ok(await otherwise(result.Value).ConfigureAwait(false)) : result; - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsync(this Result result, Predicate condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result; - - if (condition(result.Value)) - return await func(result.Value).ConfigureAwait(false); - else - return otherwise is not null ? await otherwise(result.Value).ConfigureAwait(false) : result; - } - - /// - /// Executes the where the is and evaluates to true (the will not be lost). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, func); - if (result.IsFailure) - return result; - - if (condition(result.Value)) - return await func(result.Value).ConfigureAwait(false); - else - return otherwise is not null ? await otherwise(result.Value).ConfigureAwait(false) : result; - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsAsync(this Result result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition()) - return Result.Ok(await func().ConfigureAwait(false)); - else - return otherwise is not null ? Result.Ok(await otherwise().ConfigureAwait(false)) : result.Bind(); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsAsync(this Result result, Func condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition()) - return await func().ConfigureAwait(false); - else - return otherwise is not null ? await otherwise().ConfigureAwait(false) : result.Bind(); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenAsAsync(this Result result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition(result.Value)) - await func(result.Value).ConfigureAwait(false); - else if (otherwise is not null) - await otherwise(result.Value).ConfigureAwait(false); - } - - return result.Bind(); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenAsAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition(result.Value)) - return await func(result.Value).ConfigureAwait(false); - else - return otherwise is not null ? await otherwise(result.Value).ConfigureAwait(false) : result.Bind(); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition(result.Value)) - return Result.Ok(await func(result.Value).ConfigureAwait(false)); - else if (otherwise is not null) - return Result.Ok(await otherwise(result.Value).ConfigureAwait(false)); - } - - return result.Bind(); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsAsync(this Result result, Predicate condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsFailure) - return result.Bind(); - - if (condition(result.Value)) - return await func(result.Value).ConfigureAwait(false); - else - return otherwise is not null ? await otherwise(result.Value).ConfigureAwait(false) : result.Bind(); - } - - /* IToResult */ - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenFromAsync(this Result result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition()) - return (await func().ConfigureAwait(false)).ToResult(); - else if (otherwise is not null) - return (await otherwise().ConfigureAwait(false)).ToResult(); - } - - return result; - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition(result.Value)) - return (await func(result.Value).ConfigureAwait(false)).ToResult(); - else if (otherwise is not null) - return (await otherwise(result.Value).ConfigureAwait(false)).ToResult(); - } - - return result; - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsync(this Result result, Predicate condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition(result.Value)) - return (await func(result.Value).ConfigureAwait(false)).ToResult(); - else if (otherwise is not null) - return (await otherwise(result.Value).ConfigureAwait(false)).ToResult(); - } - - return result; - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenFromAsAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition(result.Value)) - return (await func(result.Value).ConfigureAwait(false)).ToResult(); - else if (otherwise is not null) - return (await otherwise(result.Value).ConfigureAwait(false)).ToResult(); - } - - return result.Bind(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsAsync(this Result result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition()) - return (await func().ConfigureAwait(false)).ToResult(); - else if (otherwise is not null) - return (await otherwise().ConfigureAwait(false)).ToResult(); - } - - return result.Bind(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsAsync(this Result result, Func condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition()) - return (await func().ConfigureAwait(false)).ToResult(); - else if (otherwise is not null) - return (await otherwise().ConfigureAwait(false)).ToResult(); - } - - return result.Bind(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsAsync(this Result result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition(result.Value)) - return (await func(result.Value).ConfigureAwait(false)).ToResult(); - else if (otherwise is not null) - return (await otherwise(result.Value).ConfigureAwait(false)).ToResult(); - } - - return result.Bind(); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsAsync(this Result result, Predicate condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - if (result.IsSuccess) - { - if (condition(result.Value)) - return (await func(result.Value).ConfigureAwait(false)).ToResult(); - else if (otherwise is not null) - return (await otherwise(result.Value).ConfigureAwait(false)).ToResult(); - } - - return result.Bind(); - } - - #endregion - - #region AsyncBoth - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenAsync(this Task result, Func condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenAsync(this Task result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsync(this Task> result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsync(this Task> result, Predicate condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true (the will not be lost). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsAsync(this Task result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsAsync(this Task result, Func condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenAsAsync(this Task> result, Predicate condition, Func func, Func? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenAsAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true (as new ). - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenAsAsync(this Task> result, Predicate condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /* IToResult */ - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenFromAsync(this Task result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenFromAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenFromAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsync(this Task> result, Predicate condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenFromAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task WhenFromAsAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenFromAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsAsync(this Task result, Func condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenFromAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsAsync(this Task result, Func condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenFromAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsAsync(this Task> result, Predicate condition, Func> func, Func>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenFromAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - /// - /// Executes the where the is and evaluates to true. - /// - /// The . - /// The output (resulting) . - /// The . - /// The condition/predicate that must also be evaluated. - /// The function to invoke where is true (where is ). - /// The function to invoke where is false (where is ). - /// The resulting . - public static async Task> WhenFromAsAsync(this Task> result, Predicate condition, Func>> func, Func>>? otherwise = null) - { - ThrowIfNull(result, condition, func); - var r = await result.ConfigureAwait(false); - return await r.WhenFromAsAsync(condition, func, otherwise).ConfigureAwait(false); - } - - #endregion - - /// - /// Check parameters and throw where null. - /// - private static void ThrowIfNull(object result, object condition, object func, string? name = null) - { - result.ThrowIfNull(nameof(result)); - condition.ThrowIfNull(nameof(condition)); - func.ThrowIfNull(name ?? nameof(func)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Runtime.cs b/src/CoreEx/Runtime.cs new file mode 100644 index 00000000..a35c9f49 --- /dev/null +++ b/src/CoreEx/Runtime.cs @@ -0,0 +1,24 @@ +namespace CoreEx; + +/// +/// Provides standardized runtime utility capabilities. +/// +public static class Runtime +{ + /// + /// Gets a value whose date and time are set to the current Coordinated Universal Time (UTC) date and time and whose offset is Zero, according to either the + /// where ; otherwise, . + /// + public static DateTimeOffset UtcNow => ExecutionContext.TryGetCurrent(out var executionContext) ? executionContext.Timestamp : TimeProvider.System.GetUtcNow(); + + /// + /// Gets a new value using the . + /// + /// A . + public static Guid NewGuid() => IdentifierGenerator.Current.GenerateGuid(); + + /// + /// Gets a new value (see ) that is a formatted as a . + /// + public static string NewId() => IdentifierGenerator.Current.GenerateGuid().ToString(); +} \ No newline at end of file diff --git a/src/CoreEx/Schemas/IReadOnlySchemaVersion.cs b/src/CoreEx/Schemas/IReadOnlySchemaVersion.cs new file mode 100644 index 00000000..cfdc303b --- /dev/null +++ b/src/CoreEx/Schemas/IReadOnlySchemaVersion.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Schemas; + +/// +/// Enables a read-only . +/// +public interface IReadOnlySchemaVersion +{ + /// + /// Gets the schema . + /// + string? SchemaVersion { get; } +} \ No newline at end of file diff --git a/src/CoreEx/Schemas/ISchemaVersion.cs b/src/CoreEx/Schemas/ISchemaVersion.cs new file mode 100644 index 00000000..ccd0b4f8 --- /dev/null +++ b/src/CoreEx/Schemas/ISchemaVersion.cs @@ -0,0 +1,15 @@ +namespace CoreEx.Schemas; + +/// +/// Enables a mutable . +/// +public interface ISchemaVersion : IReadOnlySchemaVersion +{ + /// + string? IReadOnlySchemaVersion.SchemaVersion => SchemaVersion; + + /// + /// Gets or sets the schema . + /// + new string? SchemaVersion { get; set; } +} \ No newline at end of file diff --git a/src/CoreEx/Schemas/Schema.cs b/src/CoreEx/Schemas/Schema.cs new file mode 100644 index 00000000..6418d166 --- /dev/null +++ b/src/CoreEx/Schemas/Schema.cs @@ -0,0 +1,33 @@ +namespace CoreEx.Schemas; + +/// +/// Provides schema metadata utility. +/// +public static class Schema +{ + /// + /// Gets the default version being '1.0'. + /// + public static readonly Version DefaultVersion = new(1, 0); + + /// + /// Gets the as a formatted . + /// + public static readonly string DefaultVersionString = DefaultVersion.ToString(); + + /// + /// Tries to get the configured for the specified (defaults where not found). + /// + /// The entity . + /// The configured metadata where found; otherwise, a defaulted instance. + /// where found; otherwise, . + public static bool TryGetMetadata(out SchemaAttribute metadata) => TryGetMetadata(typeof(TEntity), out metadata); + + /// + /// Tries to get the configured metadata for the specified (defaults where not found). + /// + /// The entity . + /// The configured where found; otherwise, a defaulted instance. + /// where found; otherwise, . + public static bool TryGetMetadata(Type type, out SchemaAttribute metadata) => SchemaAttribute.TryGetCustomAttribute(type, out metadata); +} \ No newline at end of file diff --git a/src/CoreEx/Schemas/SchemaAttribute.cs b/src/CoreEx/Schemas/SchemaAttribute.cs new file mode 100644 index 00000000..1b815655 --- /dev/null +++ b/src/CoreEx/Schemas/SchemaAttribute.cs @@ -0,0 +1,74 @@ +namespace CoreEx.Schemas; + +/// +/// Provides the latest schema metadata for an entity. +/// +/// The entity schema . +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public class SchemaAttribute(string? version = null) : Attribute +{ + private static readonly ConcurrentDictionary> _cache = new(); + + private string? _schema; + private string? _versionString; + + /// + /// Gets the latest entity schema . + /// + /// Defaults to . + public Version Version { get; } = ParseVersion(version); + + /// + /// Gets the as a formatted . + /// + public string VersionString => _versionString ??= Version.ToString(); + + /// + /// Gets or sets the entity name. + /// + /// This defaults to the entity . + public string? Name { get; set; } + + /// + /// Gets or sets the entity schema . + /// + public string? SchemaUri { get => _schema; set => _schema = value is null ? null : new Uri(value, UriKind.RelativeOrAbsolute).ToString(); } + + /// + /// Parses the into a value. + /// + /// The version string. + /// The . + internal static Version ParseVersion(string? version) + { + if (string.IsNullOrEmpty(version)) + return Schema.DefaultVersion; + else if (version.Contains('.')) + return new Version(version); + else + return new Version(int.Parse(version), 0); + } + + /// + /// Tries to get the configured for the specified ; defaults where not found. + /// + /// The entity . + /// The configured where found; otherwise, a defaulted instance. + /// where found; otherwise, . + internal static bool TryGetCustomAttribute(Type type, out SchemaAttribute attribute) + { + (bool exists, SchemaAttribute sa) = _cache.GetOrAdd(type.ThrowIfNull(), type => new Lazy<(bool, SchemaAttribute)>(() => + { + var ea = type.GetCustomAttribute(); + var exists = ea is not null; + + ea ??= new SchemaAttribute(); + ea.Name ??= type.Name; + + return (exists, ea); + })).Value; + + attribute = sa; + return exists; + } +} \ No newline at end of file diff --git a/src/CoreEx/Security/AuthenticationType.cs b/src/CoreEx/Security/AuthenticationType.cs new file mode 100644 index 00000000..bf25fa59 --- /dev/null +++ b/src/CoreEx/Security/AuthenticationType.cs @@ -0,0 +1,33 @@ +namespace CoreEx.Security; + +/// +/// Represents the type of authentication used. +/// +/// See for inspiration. +public enum AuthenticationType +{ + /// + /// Indicates that the authentication type is not known or not specified. + /// + Unknown, + + /// + /// Indicates that the authentication type is unauthenticated; i.e. no authentication has been performed (anonymous). + /// + Unauthenticated, + + /// + /// Indicates that the authentication type is application based; i.e. is the end user of an application (e.g. Facebook, Google, etc.). + /// + ApplicationUser, + + /// + /// Indicates that the authentication type is identity-provider based; i.e. is a specific user (e.g. username/password, SSO, etc.). + /// + AccountUser, + + /// + /// Indicates that the authentication type is system based; i.e. is a service account or system identity (e.g. background service, database, daemon, etc.). + /// + SystemUser +} \ No newline at end of file diff --git a/src/CoreEx/Security/AuthenticationUser.cs b/src/CoreEx/Security/AuthenticationUser.cs new file mode 100644 index 00000000..60a72d7c --- /dev/null +++ b/src/CoreEx/Security/AuthenticationUser.cs @@ -0,0 +1,47 @@ +namespace CoreEx.Security; + +/// +/// Represents a user within the system. +/// +/// It is intended that this is extended to enable +public record class AuthenticationUser : IReadOnlyIdentifier +{ + /// + /// Represents an unknown user; i.e. a user that has not been authenticated or the authentication type is not known. + /// + public static AuthenticationUser Unknown { get; set; } = new AuthenticationUser { Type = AuthenticationType.Unknown, UserName = nameof(Unknown) }; + + /// + /// Represents an anonymous user; i.e. a user that has not been authenticated. + /// + public static AuthenticationUser Anonymous { get; set; } = new AuthenticationUser { Type = AuthenticationType.Unauthenticated, UserName = nameof(Anonymous) }; + + /// + /// Represents the currently authenticated environment user. + /// + public static AuthenticationUser EnvironmentUser { get; set; } = new AuthenticationUser + { + Type = AuthenticationType.AccountUser, + Id = Environment.UserDomainName is null ? Environment.UserName : Environment.UserDomainName + "\\" + Environment.UserName, + UserName = Environment.UserDomainName is null ? Environment.UserName : Environment.UserDomainName + "\\" + Environment.UserName + }; + + /// + /// Gets the type of authentication used for the user. + /// + public required AuthenticationType Type { get; init; } + + /// + /// Gets the unique identifier of the user principle; such as email, service account, etc. + /// + public string? Id { get; init; } + + /// + /// Gets the user name that is used for the likes of auditing etc. + /// + public required string UserName { get; init => field = value.ThrowIfNullOrEmpty(); } + + /// + /// Returns the . + public override string ToString() => UserName; +} \ No newline at end of file diff --git a/src/CoreEx/SystemTime.cs b/src/CoreEx/SystemTime.cs deleted file mode 100644 index c2a26b3f..00000000 --- a/src/CoreEx/SystemTime.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; - -namespace CoreEx -{ - /// - /// Provides the system time in UTC; defaults to . - /// - public class SystemTime : ISystemTime - { - private DateTime? _time; - - /// - /// Gets the default instance which returns the current . - /// - public static SystemTime Default { get; } = new SystemTime(); - - /// - /// Creates a with a fixed specified . - /// - /// The fixed time (converted to UTC). - /// The fixed . - /// This is generally intended for testing purposes. - public static SystemTime CreateFixed(DateTime time) => new() { _time = Cleaner.Clean(time, DateTimeTransform.DateTimeUtc) }; - - /// - /// Gets the instance from the where configured; otherwise, returns a new instance of . - /// - /// The using the where configured; otherwise, a new instance of . - public static ISystemTime Get() => ExecutionContext.GetService() ?? new SystemTime(); - - /// - /// Gets the timestamp for the lifetime; i.e (to enable consistent execution-related timestamping). - /// - /// Where the then the will be used; otherwise, will use (passed through ). - public static DateTime Timestamp => ExecutionContext.HasCurrent ? ExecutionContext.Current.Timestamp : Cleaner.Clean(Get().UtcNow); - - /// - public DateTime UtcNow => _time ?? DateTime.UtcNow; - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/CloudEventSerializer.cs b/src/CoreEx/Text/Json/CloudEventSerializer.cs deleted file mode 100644 index ea6ef23a..00000000 --- a/src/CoreEx/Text/Json/CloudEventSerializer.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CloudNative.CloudEvents; -using CloudNative.CloudEvents.SystemTextJson; -using CoreEx.Events; -using Microsoft.Extensions.Options; -using System; -using System.Net.Mime; -using System.Threading; -using System.Threading.Tasks; -using Stj = System.Text.Json; - -namespace CoreEx.Text.Json -{ - /// - /// Provides the -based . - /// - public class CloudEventSerializer : CloudEventSerializerBase - { - /// - /// Initializes a new instance of the class. - /// - /// The ; where null this will default. - /// The ; where null this will default. - public CloudEventSerializer(EventDataFormatter? eventDataFormatter = null, Stj.JsonSerializerOptions? options = null) : base(eventDataFormatter) - { - EventDataFormatter.JsonSerializer ??= new JsonSerializer(options); - Options = options ?? (Stj.JsonSerializerOptions)EventDataFormatter.JsonSerializer.Options; - } - - /// - /// Gets the . - /// - public Stj.JsonSerializerOptions Options { get; } - - /// - protected override Task DecodeAsync(BinaryData eventData, CancellationToken cancellation = default) - => Task.FromResult(new JsonEventFormatter(Options, new Stj.JsonDocumentOptions()).DecodeStructuredModeMessage(eventData, new ContentType(MediaTypeNames.Application.Json), null)); - - /// - protected override Task DecodeAsync(BinaryData eventData, CancellationToken cancellation = default) - => Task.FromResult(new JsonEventFormatter(Options, new Stj.JsonDocumentOptions()).DecodeStructuredModeMessage(eventData, new ContentType(MediaTypeNames.Application.Json), null)); - - /// - protected override Task EncodeAsync(CloudEvent cloudEvent, CancellationToken cancellation = default) - //=> Task.FromResult(new BinaryData(new JsonEventFormatter(Options, new Stj.JsonDocumentOptions()).EncodeStructuredModeMessage(cloudEvent, out var _))); - => Task.FromResult(new BinaryData(new InternalFormatter(Options, new Stj.JsonDocumentOptions()).EncodeStructuredModeMessage(cloudEvent, out var _))); - - /// - /// Override the formatting where the is a and the is by assuming already serialized. - /// - private class InternalFormatter(Stj.JsonSerializerOptions options, Stj.JsonDocumentOptions jsonDocumentOptions) : JsonEventFormatter(options, jsonDocumentOptions) - { - /// - protected override void EncodeStructuredModeData(CloudEvent cloudEvent, Stj.Utf8JsonWriter writer) - { - if (cloudEvent.Data is BinaryData bd && cloudEvent.DataContentType == MediaTypeNames.Application.Json) - { - writer.WritePropertyName(DataPropertyName); - writer.WriteRawValue(bd, true); - } - else - base.EncodeStructuredModeData(cloudEvent, writer); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/CollectionResultConverterFactory.cs b/src/CoreEx/Text/Json/CollectionResultConverterFactory.cs deleted file mode 100644 index 5243a1fa..00000000 --- a/src/CoreEx/Text/Json/CollectionResultConverterFactory.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Collections; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace CoreEx.Text.Json -{ - /// - /// Performs JSON value conversion for values. - /// - public class CollectionResultConverterFactory : JsonConverterFactory - { - /// - public override bool CanConvert(Type typeToConvert) => typeof(ICollectionResult).IsAssignableFrom(typeToConvert); - - /// - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) - => (JsonConverter)Activator.CreateInstance(typeof(JsonValueConverterReferenceData<>).MakeGenericType(typeToConvert))!; - - /// - /// Performs the "actual" JSON value conversion for values. - /// - private class JsonValueConverterReferenceData : JsonConverter where T : ICollectionResult, new() - { - /// - public override bool HandleNull => false; - - /// - public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.StartArray) - throw new JsonException(); - - var cr = new T(); - cr.Items = (ICollection?)System.Text.Json.JsonSerializer.Deserialize(ref reader, cr.CollectionType, options) ?? throw new InvalidOperationException(); - return cr; - } - - /// - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) - { - writer.WriteStartArray(); - - if (value.Items != null) - { - foreach (var item in value.Items) - { - System.Text.Json.JsonSerializer.Serialize(writer, item, value.ItemType, options); - } - } - - writer.WriteEndArray(); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/CompositeKeyConverterFactory.cs b/src/CoreEx/Text/Json/CompositeKeyConverterFactory.cs deleted file mode 100644 index 1bba9c2c..00000000 --- a/src/CoreEx/Text/Json/CompositeKeyConverterFactory.cs +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Entities; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace CoreEx.Text.Json -{ - /// - /// Performs JSON value conversion for values. - /// - public class CompositeKeyConverterFactory : JsonConverterFactory - { - /// - public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(CompositeKey); - - /// - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new CompositeKeyConverter(); - - /// - /// Performs the "actual" JSON value conversion for a . - /// - private class CompositeKeyConverter : JsonConverter - { - /// - public override bool HandleNull => true; - - /// - public override CompositeKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Null) - return CompositeKey.Empty; - - if (reader.TokenType != JsonTokenType.StartArray) - throw new JsonException($"Expected {nameof(JsonTokenType.StartArray)} for a {nameof(CompositeKey)}; found {reader.TokenType}."); - - var depth = reader.CurrentDepth; - var args = new List(); - - reader.Read(); - while (reader.CurrentDepth > depth) - { - if (reader.TokenType == JsonTokenType.Null) - { - args.Add(null); - reader.Read(); - continue; - } - - if (reader.TokenType != JsonTokenType.StartObject) - throw new JsonException($"Expected {nameof(JsonTokenType.StartObject)} for a {nameof(CompositeKey)}; found {reader.TokenType}."); - - var objDepth = reader.CurrentDepth; - reader.Read(); - while (reader.CurrentDepth > objDepth) - { - if (reader.TokenType != JsonTokenType.PropertyName) - throw new JsonException($"Expected {nameof(JsonTokenType.PropertyName)} for a {nameof(CompositeKey)}; found {reader.TokenType}."); - - var name = reader.GetString(); - reader.Read(); - - switch (name) - { - case "string": args.Add(reader.GetString()); break; - case "char": args.Add(reader.GetString()?.ToCharArray().FirstOrDefault()); break; - case "short": args.Add(reader.GetInt16()); break; - case "int": args.Add(reader.GetInt32()); break; - case "long": args.Add(reader.GetInt64()); break; - case "guid": args.Add(reader.GetGuid()); break; - case "datetime": args.Add(reader.GetDateTime()); break; - case "datetimeoffset": args.Add(reader.GetDateTimeOffset()); break; - case "ushort": args.Add(reader.GetUInt16()); break; - case "uint": args.Add(reader.GetUInt32()); break; - case "ulong": args.Add(reader.GetUInt64()); break; - default: - throw new JsonException($"Unsupported {nameof(CompositeKey)} type '{name}'."); - } - - reader.Read(); - if (reader.TokenType != JsonTokenType.EndObject) - throw new JsonException($"Expected {nameof(JsonTokenType.EndObject)} for a {nameof(CompositeKey)} argument; found {reader.TokenType}."); - } - - reader.Read(); - } - - return new CompositeKey([.. args]); - } - - /// - public override void Write(Utf8JsonWriter writer, CompositeKey value, JsonSerializerOptions options) - { - if (value.Args.Length == 0) - { - writer.WriteNullValue(); - return; - } - - writer.WriteStartArray(); - - foreach (var arg in value.Args) - { - if (arg is null) - { - writer.WriteNullValue(); - continue; - } - - writer.WriteStartObject(); - - _ = arg switch - { - string str => JsonWrite(writer, "string", () => writer.WriteStringValue(str)), - char c => JsonWrite(writer, "char", () => writer.WriteStringValue(c.ToString())), - short s => JsonWrite(writer, "short", () => writer.WriteNumberValue(s)), - int i => JsonWrite(writer, "int", () => writer.WriteNumberValue(i)), - long l => JsonWrite(writer, "long", () => writer.WriteNumberValue(l)), - Guid g => JsonWrite(writer, "guid", () => writer.WriteStringValue(g)), - DateTime d => JsonWrite(writer, "datetime", () => writer.WriteStringValue(d)), - DateTimeOffset o => JsonWrite(writer, "datetimeoffset", () => writer.WriteStringValue(o)), - ushort us => JsonWrite(writer, "ushort", () => writer.WriteNumberValue(us)), - uint ui => JsonWrite(writer, "uint", () => writer.WriteNumberValue(ui)), - ulong ul => JsonWrite(writer, "ulong", () => writer.WriteNumberValue(ul)), - _ => throw new JsonException($"Unsupported {nameof(CompositeKey)} type '{arg.GetType().Name}'.") - }; - - writer.WriteEndObject(); - } - - writer.WriteEndArray(); - } - - /// - /// Provides a simple means to write a JSON property name and value. - /// - private static bool JsonWrite(Utf8JsonWriter writer, string name, Action action) - { - writer.WritePropertyName(name); - action(); - return true; - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/EventDataSerializer.cs b/src/CoreEx/Text/Json/EventDataSerializer.cs deleted file mode 100644 index dac09738..00000000 --- a/src/CoreEx/Text/Json/EventDataSerializer.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Events; -using CoreEx.Json; -using System; - -namespace CoreEx.Text.Json -{ - /// - /// Provides the -based . - /// - public class EventDataSerializer : EventDataSerializerBase - { - /// - /// Initializes a new instance of the class. - /// - /// The ; defaults to . - /// The . - public EventDataSerializer(IJsonSerializer? jsonSerializer = null, EventDataFormatter? eventDataFormatter = null) : base(jsonSerializer ?? new JsonSerializer(), eventDataFormatter) - { - if (JsonSerializer is not CoreEx.Text.Json.JsonSerializer) - throw new ArgumentException($"The {nameof(IJsonSerializer)} instance must be of Type '{typeof(CoreEx.Text.Json.JsonSerializer).FullName}'.", nameof(jsonSerializer)); - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/ExceptionConverterFactory.cs b/src/CoreEx/Text/Json/ExceptionConverterFactory.cs deleted file mode 100644 index 5606f1ad..00000000 --- a/src/CoreEx/Text/Json/ExceptionConverterFactory.cs +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using Stj = System.Text.Json; - -namespace CoreEx.Text.Json -{ - /// - /// Exception converter factory - /// - public class ExceptionConverterFactory : JsonConverterFactory - { - /// - /// Converter for . See for more information. - /// It can serialize to and vice versa, but deserialization is very basic - only handles - /// - private class ExceptionConverter : JsonConverter where TExceptionType : Exception - { - /// - public override bool CanConvert(Type typeToConvert) => typeof(Exception).IsAssignableFrom(typeToConvert); - - /// - public override TExceptionType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.StartObject) - throw new JsonException(); - - TExceptionType exception = default!; - - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndObject) - return exception; - - // Get the key. - if (reader.TokenType != JsonTokenType.PropertyName) - throw new JsonException(); - - string? propertyName = reader.GetString(); - - if (propertyName != nameof(Exception.Message)) - { - // Skip all properties other than the message. - reader.Skip(); - continue; - } - - reader.Read(); - string? message = reader.GetString(); - exception = (TExceptionType)Activator.CreateInstance(typeof(TExceptionType), message)!; - - // read the rest of the exception - while (reader.Read()) - { - reader.Skip(); - } - - return exception; - } - - throw new JsonException(); - } - - /// - public override void Write(Utf8JsonWriter writer, TExceptionType value, JsonSerializerOptions options) - { - var serializableProperties = value.GetType() - .GetProperties() - .Select(uu => new { uu.Name, Value = uu.GetValue(value) }) - .Where(uu => uu.Name != nameof(Exception.TargetSite)); - - if (options?.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull) - serializableProperties = serializableProperties.Where(uu => uu.Value != null); - - var propList = serializableProperties.ToList(); - - if (propList.Count == 0) - return; // Nothing to write - - writer.WriteStartObject(); - - foreach (var prop in propList) - { - writer.WritePropertyName(prop.Name); - Stj.JsonSerializer.Serialize(writer, prop.Value, options); - } - - writer.WriteEndObject(); - } - } - - /// - public override bool CanConvert(Type typeToConvert) => typeof(Exception).IsAssignableFrom(typeToConvert); - - /// - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => (JsonConverter)Activator.CreateInstance(typeof(ExceptionConverter<>).MakeGenericType(typeToConvert))!; - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/JsonExtensions.cs b/src/CoreEx/Text/Json/JsonExtensions.cs deleted file mode 100644 index 622060a3..00000000 --- a/src/CoreEx/Text/Json/JsonExtensions.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using System; -using System.Collections.Generic; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace CoreEx.Text.Json -{ - /// - /// Provides JSON extension methods. - /// - public static class JsonExtensions - { - /// - /// Applies the inclusion of properties (using JSON paths) to an existing . - /// - /// The . - /// The list of paths to include (i.e. remove others). Qualified paths, that include indexing, are also supported. - /// The to enable fluent-style method-chaining. - /// Defaults to to match . Leverages the to perform. - public static JsonNode ApplyInclude(this JsonNode json, params string[] pathsToInclude) => JsonFilterer.Apply(json, pathsToInclude, JsonPropertyFilter.Include); - - /// - /// Applies the exclusion of properties (using JSON paths) to an existing . - /// - /// The . - /// The list of paths to exclude (i.e. remove listed). Qualified paths, that include indexing, are also supported. - /// The to enable fluent-style method-chaining. - /// Defaults to to match . Leverages the to perform. - public static JsonNode ApplyExclude(this JsonNode json, params string[] pathsToExclude) => JsonFilterer.Apply(json, pathsToExclude, JsonPropertyFilter.Exclude); - - /// - /// Trys to get the with the and . - /// - /// The . - /// The property name. - /// The . - /// The named where found. - /// true indicates the property was found; otherwise, false. - public static bool TryGetProperty(this JsonElement json, string propertyName, IEqualityComparer comparer, out JsonElement value) - { - foreach (var j in json.EnumerateObject()) - { - if (comparer.Equals(j.Name, propertyName)) - { - value = j.Value; - return true; - } - } - - value = default; - return false; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/JsonFilterer.cs b/src/CoreEx/Text/Json/JsonFilterer.cs deleted file mode 100644 index 671e02d8..00000000 --- a/src/CoreEx/Text/Json/JsonFilterer.cs +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; - -namespace CoreEx.Text.Json -{ - /// - /// Provides a means to apply a filter to include or exclude JSON properties (in effect removing the unwanted properties). - /// - public static partial class JsonFilterer - { -#if NET8_0_OR_GREATER - private static readonly Regex _regex = IndexesRegex(); -#else - private static readonly Regex _regex = new(@"\[(.*?)\]", RegexOptions.Compiled); -#endif - - /// - /// Gets the standard JSON root path. - /// - public const string JsonRootPath = "$"; - - /// - /// Prepends the JSON with the where not already present. - /// - /// The JSON path. - /// The resulting JSON path. - public static string PrependRootPath(string path) => string.IsNullOrEmpty(path) ? JsonRootPath : (!path.StartsWith(JsonRootPath) ? (path.StartsWith('[') ? $"{JsonRootPath}{path}" : $"{JsonRootPath}.{path}") : path); - - /// - /// Removes all indexes from the specified JSON path. - /// - /// The input JSON path. - /// The resulting JSON path. - /// true indicates indexes were removed; otherwise, false. - public static bool TryRemovePathIndexes(string input, out string path) - { - if (string.IsNullOrEmpty(input)) - { - path = input; - return false; - } - - path = _regex.Replace(input, string.Empty); - return path.Length != input.Length; - } - - /// - /// Trys to apply the JSON (using JSON ) to a resulting in the corresponding . - /// - /// The value . - /// The value. - /// The list of JSON paths to . - /// The corresponding JSON with the filtering applied. - /// The ; defaults to . - /// The optional . - /// The paths ; defaults to . - /// The action. - /// true indicates that at least one JSON node was filtered (removed); otherwise, false for no changes. - public static bool TryApply(T value, IEnumerable? paths, out string json, JsonPropertyFilter filter = JsonPropertyFilter.Include, JsonSerializerOptions? options = null, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) - { - var r = TryApply(value, paths, out JsonNode node, filter, options, comparison, preFilterInspector); - json = node.ToJsonString(options); - return r; - } - - /// - /// Trys to apply the JSON property (using JSON ) to a resulting in the corresponding . - /// - /// The value . - /// The value. - /// The list of JSON paths to . - /// The corresponding with the filtering applied. - /// The ; defaults to . - /// The optional . - /// The paths ; defaults to . - /// The action. - /// true indicates that at least one JSON node was filtered (removed); otherwise, false for no changes. - public static bool TryApply(T value, IEnumerable? paths, out JsonNode json, JsonPropertyFilter filter = JsonPropertyFilter.Include, JsonSerializerOptions? options = null, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) - { - json = System.Text.Json.JsonSerializer.SerializeToNode(value.ThrowIfNull(), options)!; - preFilterInspector?.Invoke(new JsonPreFilterInspector(json)); - - return Apply(json, paths, filter, comparison); - } - - /// - /// Applies the inclusion and exclusion of JSON paths to a specified . - /// - /// The value. - /// The list of JSON paths to . - /// The ; defaults to . - /// The paths ; defaults to . - /// true indicates that at least one JSON node was filtered (removed); otherwise, false for no changes. - public static bool Apply(JsonNode json, IEnumerable? paths, JsonPropertyFilter filter = JsonPropertyFilter.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase) - { - var maxDepth = 0; - var dict = CreateDictionary(paths, filter, comparison, ref maxDepth, true); - - var filtered = false; - if (maxDepth > 0) - JsonFilter(json, dict, filter, 0, maxDepth, ref filtered, comparison); - - return filtered; - } - - /// - /// Create a from the and expands list with intermediary paths where is . - /// - /// The list of JSON paths. - /// The . - /// The paths . - /// The maximum hierarchy depth for all specified . - /// The . - /// Where the is true this indicates the specified path; versus, false that indicates an intermediary path. - public static Dictionary CreateDictionary(IEnumerable? paths, JsonPropertyFilter filter, StringComparison comparison, ref int maxDepth) - => CreateDictionary(paths, filter, comparison, ref maxDepth, false); - - /// - /// Create a from the and expands list with intermediary paths where is . - /// - /// The list of JSON paths. - /// The . - /// The paths . - /// The maximum hierarchy depth for all specified . - /// Indicates whether to prepend the to each path. - /// The . - /// Where the is true this indicates the specified path; versus, false that indicates an intermediary path. - public static Dictionary CreateDictionary(IEnumerable? paths, JsonPropertyFilter filter, StringComparison comparison, ref int maxDepth, bool prependRootPath) - { - var dict = new Dictionary(StringComparer.FromComparison(comparison)); - paths ??= []; - - // Add each 'specified' path. - paths.ForEach(path => dict.TryAdd(prependRootPath ? PrependRootPath(path) : path, true)); - - // Add each 'intermediary' path where applicable. - if (filter == JsonPropertyFilter.Include) - { - var sb = new StringBuilder(); - foreach (var kvp in dict.ToArray()) - { - sb.Clear(); - var parts = kvp.Key.Split('.'); - for (int i = 0; i < parts.Length; i++) - { - if (i > 0) - sb.Append('.'); - - sb.Append(parts[i]); - dict.TryAdd(sb.ToString(), false); - - maxDepth = Math.Max(maxDepth, i + 1); - } - - if (TryRemovePathIndexes(kvp.Key, out var indexless)) - { - sb.Clear(); - parts = indexless.Split('.'); - for (int i = 0; i < parts.Length; i++) - { - if (i > 0) - sb.Append('.'); - - sb.Append(parts[i]); - dict.TryAdd(sb.ToString(), false); - } - } - } - - foreach (var kvp in dict.ToArray()) - { - if (dict.Keys.Any(x => !x.Equals(kvp.Key, comparison) && x.StartsWith(kvp.Key, comparison))) - dict[kvp.Key] = false; - } - } - else - maxDepth = Math.Max(maxDepth, dict.Count == 0 ? 0 : dict.Max(x => x.Key.Count(c => c == '.') + 1)); - - return dict; - } - - /// - /// Filter the JSON nodes based on the includes/excludes. - /// - private static bool JsonFilter(JsonNode json, Dictionary paths, JsonPropertyFilter filter, int depth, int maxDepth, ref bool filtered, StringComparison comparison) - { - // Do not check beyond maximum depth as there is no further filtering required. - if (depth > maxDepth) - return false; - - // Iterate through the properties within the object and filter accordingly. - if (json is JsonObject jo) - { - foreach (var jn in jo.ToArray()) - { - var path = jn.Value is null ? $"{jo.GetPath()}.{jn.Key}" : jn.Value.GetPath(); - bool found = paths.TryGetValue(path, out var isSpecifiedPath); - if (!found && TryRemovePathIndexes(path, out var pathWithoutIndexes)) - found = paths.TryGetValue(pathWithoutIndexes, out isSpecifiedPath); - - if ((filter == JsonPropertyFilter.Include && !found) || (filter == JsonPropertyFilter.Exclude && found)) - { - jo.Remove(jn.Key); - filtered = true; - continue; - } - - if (filter == JsonPropertyFilter.Include && found && isSpecifiedPath) - continue; - - // Where there is a child value then continue navigation. - if (jn.Value != null) - JsonFilter(jn.Value, paths, filter, depth + 1, maxDepth, ref filtered, comparison); - } - } - else if (json is JsonArray ja) - { - // Iterate and filter each item in the array. - for (var i = ja.Count - 1; i >= 0; i--) - { - var jn = ja[i]; - if (jn != null) - { - if (JsonFilter(jn, paths, filter, depth, maxDepth, ref filtered, comparison)) - { - ja.RemoveAt(i); - filtered = true; - } - } - } - } - else if (json is JsonValue) - { - var path = json.GetPath(); - if (!paths.TryGetValue(path, out var isSpecifiedPath) && TryRemovePathIndexes(path, out var pathWithoutIndexes)) - paths.TryGetValue(pathWithoutIndexes, out isSpecifiedPath); - - return filter == JsonPropertyFilter.Include ? !isSpecifiedPath : isSpecifiedPath; - } - - return false; - } - -#if NET8_0_OR_GREATER - /// - /// Provides the generated for . - /// - [GeneratedRegex(@"\[(.*?)\]", RegexOptions.Compiled)] - private static partial Regex IndexesRegex(); -#endif - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/JsonPreFilterInspector.cs b/src/CoreEx/Text/Json/JsonPreFilterInspector.cs deleted file mode 100644 index 36dfd1ec..00000000 --- a/src/CoreEx/Text/Json/JsonPreFilterInspector.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using System.Text.Json.Nodes; - -namespace CoreEx.Text.Json -{ - /// - /// Provides pre (prior) to filtering JSON inspection. - /// - /// The . - public readonly struct JsonPreFilterInspector(JsonNode json) : IJsonPreFilterInspector - { - /// - object IJsonPreFilterInspector.Json => Json; - - /// - /// Gets the before any filtering has been applied. - /// - public JsonNode Json { get; } = json; - - /// - public string? ToJsonString() => Json.ToJsonString(); - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/JsonSerializer.cs b/src/CoreEx/Text/Json/JsonSerializer.cs deleted file mode 100644 index 14d56f27..00000000 --- a/src/CoreEx/Text/Json/JsonSerializer.cs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using Stj = System.Text.Json; - -namespace CoreEx.Text.Json -{ - /// - /// Provides the encapsulated implementation. - /// - public class JsonSerializer : IJsonSerializer - { - /// - /// Gets or sets the default . - /// - /// The following , including use of , will default: - /// - /// = . - /// = false. - /// = . - /// = . - /// = , , , - /// , and . - /// - /// - public static Stj.JsonSerializerOptions DefaultOptions { get; set; } = new Stj.JsonSerializerOptions(Stj.JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - WriteIndented = false, - DictionaryKeyPolicy = SubstituteNamingPolicy.Substitute, - PropertyNamingPolicy = SubstituteNamingPolicy.Substitute, - Converters = { new JsonStringEnumConverter(), new ExceptionConverterFactory(), new ReferenceDataConverterFactory(), new CollectionResultConverterFactory(), new ResultConverterFactory(), new CompositeKeyConverterFactory() } - }; - - /// - /// Initializes a new instance of the class. - /// - /// The . Defaults to . - public JsonSerializer(Stj.JsonSerializerOptions? options = null) - { - Options = options ?? DefaultOptions; - IndentedOptions = new Stj.JsonSerializerOptions(Options) { WriteIndented = true }; - } - - /// - /// Gets the underlying serializer configuration settings/options. - /// - object IJsonSerializer.Options => Options; - - /// - /// Gets the . - /// - public Stj.JsonSerializerOptions Options { get; } - - /// - /// Gets or sets the with = true. - /// - public Stj.JsonSerializerOptions? IndentedOptions { get; } - - /// - public string Serialize(T value, JsonWriteFormat? format = null) => SerializeToBinaryData(value, format).ToString(); - - /// - public BinaryData SerializeToBinaryData(T value, JsonWriteFormat? format = null) - => new(Stj.JsonSerializer.SerializeToUtf8Bytes(value, format == null || format.Value == JsonWriteFormat.None ? Options : IndentedOptions)); - - /// -#if NET7_0_OR_GREATER - public object? Deserialize([StringSyntax(StringSyntaxAttribute.Json)] string json) -#else - public object? Deserialize(string json) -#endif - => Stj.JsonSerializer.Deserialize(json, Options); - - /// -#if NET7_0_OR_GREATER - public object? Deserialize([StringSyntax(StringSyntaxAttribute.Json)] string json, Type type) -#else - public object? Deserialize(string json, Type type) -#endif - => Stj.JsonSerializer.Deserialize(json, type, Options); - - /// -#if NET7_0_OR_GREATER - public T? Deserialize([StringSyntax(StringSyntaxAttribute.Json)] string json) -#else - public T? Deserialize(string json) -#endif - => Stj.JsonSerializer.Deserialize(json, Options)!; - - /// - public object? Deserialize(BinaryData json) => Stj.JsonSerializer.Deserialize(json, Options); - - /// - public object? Deserialize(BinaryData json, Type type) => Stj.JsonSerializer.Deserialize(json, type, Options); - - /// - public T? Deserialize(BinaryData json) => Stj.JsonSerializer.Deserialize(json, Options)!; - - /// - public bool TryApplyFilter(T value, IEnumerable? names, out string json, JsonPropertyFilter filter = JsonPropertyFilter.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) - => JsonFilterer.TryApply(value, names, out json, filter, Options, comparison, preFilterInspector); - - /// - public bool TryApplyFilter(T value, IEnumerable? names, out object json, JsonPropertyFilter filter = JsonPropertyFilter.Include, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) - { - var r = JsonFilterer.TryApply(value, names, out JsonNode node, filter, Options, comparison, preFilterInspector); - json = node; - return r; - } - - /// - bool IJsonSerializer.TryGetJsonName(MemberInfo memberInfo, [NotNullWhen(true)] out string? jsonName) - { - var ji = memberInfo.ThrowIfNull(nameof(memberInfo)).GetCustomAttribute(); - if (ji != null) - { - jsonName = null; - return false; - } - - var jpn = memberInfo.GetCustomAttribute(true); - jsonName = jpn?.Name ?? Options.PropertyNamingPolicy?.ConvertName(memberInfo.Name) ?? memberInfo.Name; - return true; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/NumberToStringConverter.cs b/src/CoreEx/Text/Json/NumberToStringConverter.cs deleted file mode 100644 index fc75e3a3..00000000 --- a/src/CoreEx/Text/Json/NumberToStringConverter.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace CoreEx.Text.Json -{ - /// - /// Provides to conversion where the number is a valid . - /// - public class NumberToStringConverter : JsonConverter - { - /// - public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch - { - JsonTokenType.Null => null, - JsonTokenType.String => reader.GetString(), - JsonTokenType.Number => reader.TryGetDecimal(out var _) ? System.Text.Encoding.Default.GetString(reader.ValueSpan) : throw new JsonException(), - _ => throw new JsonException() - }; - - /// - public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) - => writer.WriteStringValue(value); - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/ReferenceDataContentJsonSerializer.cs b/src/CoreEx/Text/Json/ReferenceDataContentJsonSerializer.cs deleted file mode 100644 index ce5acc5a..00000000 --- a/src/CoreEx/Text/Json/ReferenceDataContentJsonSerializer.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Json; -using CoreEx.RefData; -using System.Text.Json.Serialization; -using Stj = System.Text.Json; - -namespace CoreEx.Text.Json -{ - /// - /// Provides the JSON Serialize and Deserialize implementation to allow types to serialize contents. - /// - /// Generally, types will serialize the as the value; this allows for full contents to be serialized. - /// The . Defaults to . - public class ReferenceDataContentJsonSerializer(Stj.JsonSerializerOptions? options = null) : JsonSerializer(options ?? DefaultOptions), IReferenceDataContentJsonSerializer - { - /// - /// Gets or sets the default without to allow types to serialize contents. - /// - /// The following , including use of , will default: - /// - /// = . - /// = false - /// = . - /// = . - /// = , , , and . - /// - /// - public static new Stj.JsonSerializerOptions DefaultOptions { get; set; } = new Stj.JsonSerializerOptions(Stj.JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - WriteIndented = false, - DictionaryKeyPolicy = SubstituteNamingPolicy.Substitute, - PropertyNamingPolicy = SubstituteNamingPolicy.Substitute, - Converters = { new JsonStringEnumConverter(), new ExceptionConverterFactory(), new CollectionResultConverterFactory(), new ResultConverterFactory(), new ReferenceDataMultiDictionaryConverterFactory() } - }; - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/ReferenceDataConverterFactory.cs b/src/CoreEx/Text/Json/ReferenceDataConverterFactory.cs deleted file mode 100644 index 5dcd78a3..00000000 --- a/src/CoreEx/Text/Json/ReferenceDataConverterFactory.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace CoreEx.Text.Json -{ - /// - /// Performs JSON value conversion for values. - /// - public class ReferenceDataConverterFactory : JsonConverterFactory - { - /// - public override bool CanConvert(Type typeToConvert) => typeof(IReferenceData).IsAssignableFrom(typeToConvert); - - /// - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) - => (JsonConverter)Activator.CreateInstance(typeof(JsonValueConverterReferenceData<>).MakeGenericType(typeToConvert))!; - - /// - /// Performs the "actual" JSON value conversion for values. - /// - private class JsonValueConverterReferenceData : JsonConverter where T : IReferenceData, new() - { - /// - public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Null) - return default; - - if (reader.TokenType != JsonTokenType.String) - throw new JsonException(null, new InvalidCastException($"The {nameof(IReferenceData)} property TokenType must be a {nameof(JsonTokenType.String)} not a {reader.TokenType}. To support an {nameof(IReferenceData)} Object consider using the {nameof(ReferenceDataContentJsonSerializer)} {nameof(CoreEx.Json.IJsonSerializer)} instead.")); - - var code = reader.GetString(); - if (code == null) - return default; - - if (ExecutionContext.HasCurrent) - { - var coll = ReferenceDataOrchestrator.Current.GetByType(typeToConvert); - if (coll != null && coll.TryGetByCode(code, out var rd)) - return (T)rd!; - } - - var rdx = new T() { Code = code }; - ((IReferenceData)rdx).SetInvalid(); - return rdx; - } - - /// - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) - { - if (value == null || value.Code == null) - return; - - writer.WriteStringValue(value.Code); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/ReferenceDataMultiDictionaryConverterFactory.cs b/src/CoreEx/Text/Json/ReferenceDataMultiDictionaryConverterFactory.cs deleted file mode 100644 index b726a23b..00000000 --- a/src/CoreEx/Text/Json/ReferenceDataMultiDictionaryConverterFactory.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.RefData; -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace CoreEx.Text.Json -{ - /// - /// Performs JSON value conversion for values. - /// - /// This is required to ensure each is serialized correctly according to its underlying type. - public class ReferenceDataMultiDictionaryConverterFactory : JsonConverterFactory - { - /// - public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(ReferenceDataMultiDictionary); - - /// - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new ReferenceDataMultiDictionaryConverter(); - - /// - /// Performs the "actual" JSON value conversion for values. - /// - private class ReferenceDataMultiDictionaryConverter : JsonConverter - { - /// - public override ReferenceDataMultiDictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotSupportedException($"Deserialization of Type {nameof(ReferenceDataMultiDictionary)} is not supported."); - - /// - public override void Write(Utf8JsonWriter writer, ReferenceDataMultiDictionary value, JsonSerializerOptions options) - { - writer.WriteStartObject(); - - foreach (var kvp in value) - { - writer.WritePropertyName(options.DictionaryKeyPolicy?.ConvertName(kvp.Key) ?? kvp.Key); - System.Text.Json.JsonSerializer.Serialize(writer, kvp.Value, kvp.Value.GetType(), options); - } - - writer.WriteEndObject(); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/ResultConverterFactory.cs b/src/CoreEx/Text/Json/ResultConverterFactory.cs deleted file mode 100644 index 772eb3bc..00000000 --- a/src/CoreEx/Text/Json/ResultConverterFactory.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Results; -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace CoreEx.Text.Json -{ - /// - /// Performs JSON value conversion for values. - /// - public class ResultConverterFactory : JsonConverterFactory - { - /// - public override bool CanConvert(Type typeToConvert) => typeof(IResult).IsAssignableFrom(typeToConvert); - - /// - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new ResultConverter(); - - /// - /// Performs the "actual" JSON value conversion for a . - /// - private class ResultConverter : JsonConverter - { - /// - public override bool HandleNull => false; - - /// - public override IResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotSupportedException($"Deserialization of Type {nameof(IResult)} is not supported."); - - /// - public override void Write(Utf8JsonWriter writer, IResult value, JsonSerializerOptions options) - { - writer.WriteStartObject(); - - if (value.IsSuccess) - { - writer.WritePropertyName("isSuccess"); - writer.WriteBooleanValue(true); - - if (value.Value is not null) - { - writer.WritePropertyName("value"); - System.Text.Json.JsonSerializer.Serialize(writer, value.Value, value.Value.GetType(), options); - } - } - else - { - writer.WritePropertyName("isFailure"); - writer.WriteBooleanValue(true); - writer.WritePropertyName("error"); - writer.WriteStringValue(value.Error.Message); - } - - writer.WriteEndObject(); - } - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/SubstituteNamingPolicy.cs b/src/CoreEx/Text/Json/SubstituteNamingPolicy.cs deleted file mode 100644 index 24c71d9c..00000000 --- a/src/CoreEx/Text/Json/SubstituteNamingPolicy.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using Stj = System.Text.Json; - -namespace CoreEx.Text.Json -{ - /// - /// Provides a substitution and camel case naming policy. - /// - /// Converts the name by checking , then uses . - public class SubstituteNamingPolicy : Stj.JsonNamingPolicy - { - /// - /// Gets the instance. - /// - public static SubstituteNamingPolicy Substitute { get; } = new SubstituteNamingPolicy(); - - /// - public override string ConvertName(string name) => CoreEx.Json.JsonSerializer.NameSubstitutions.TryGetValue(name, out var jsonName) ? jsonName : CamelCase.ConvertName(name); - } -} \ No newline at end of file diff --git a/src/CoreEx/Text/SentenceCase.cs b/src/CoreEx/Text/SentenceCase.cs index fb8a583d..037e1a6d 100644 --- a/src/CoreEx/Text/SentenceCase.cs +++ b/src/CoreEx/Text/SentenceCase.cs @@ -1,115 +1,316 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Text; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System; -using System.Text.RegularExpressions; -using System.Diagnostics.CodeAnalysis; - -namespace CoreEx.Text +/// +/// Provides common sentence case capabilities. +/// +public static partial class SentenceCase { /// - /// Provides common sentence case capabilities. + /// The pattern for splitting strings into a sentence of words. + /// + public const string WordSplitPattern = @"([a-z](?=[A-Z])|[A-Z](?=[A-Z][a-z])|[\s_-])"; + + /// + /// Gets the compiled for splitting strings into a sentence of words (see ). + /// + public static Regex WordSplitRegex { get; } = _wordSplitRegex(); + + /// + /// Provides the generated for splitting strings into a sentence of words. + /// + [GeneratedRegex(WordSplitPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex _wordSplitRegex(); + + /// + /// Performs a sentence case word split on the specified . /// - public static partial class SentenceCase + /// The text to sentence case word split. + /// An array of words. + public static string[] SplitIntoWords(string? text) { - /// - /// The pattern for splitting strings into a sentence of words. - /// - public const string WordSplitPattern = "([a-z](?=[A-Z])|[A-Z](?=[A-Z][a-z]))"; - - /// - /// Gets the compiled for splitting strings into a sentence of words (see ). - /// -#if NET7_0_OR_GREATER - public static Regex WordSplitRegex { get; } = _wordSplitRegex(); - - /// - /// Provides the generated for splitting strings into a sentence of words. - /// - [GeneratedRegex(WordSplitPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant)] - private static partial Regex _wordSplitRegex(); -#else - public static Regex WordSplitRegex { get; } = new Regex(WordSplitPattern, RegexOptions.CultureInvariant | RegexOptions.Compiled); -#endif - - /// - /// Performs a sentence case word split on the specified . - /// - /// The text to sentence case word split. - /// An array of words. - public static string[] SplitIntoWords(string? text) + if (string.IsNullOrEmpty(text)) + return []; + + var matches = WordSplitRegex.EnumerateMatches(text); + var buffer = ArrayPool.Shared.Rent(16); + int count = 0; + int lastIndex = 0; + + try { - if (string.IsNullOrEmpty(text)) - return []; + foreach (var match in matches) + { + if (count >= buffer.Length) + { + var newBuffer = ArrayPool.Shared.Rent(buffer.Length * 2); + Array.Copy(buffer, newBuffer, count); + ArrayPool.Shared.Return(buffer); + buffer = newBuffer; + } - var s = WordSplitRegex.Replace(text, "$1 "); // Add a space between each word. - return s.Split(' ', StringSplitOptions.RemoveEmptyEntries); // Split the string into words. + char matchedChar = text[match.Index]; + + // If matched char is a delimiter, extract word before it and skip the delimiter + if (matchedChar == '_' || matchedChar == '-' || char.IsWhiteSpace(matchedChar)) + { + if (match.Index > lastIndex) + buffer[count++] = text[lastIndex..match.Index]; + + lastIndex = match.Index + 1; // Skip the delimiter + } + else + { + // Case-change split: include the matched character + buffer[count++] = text[lastIndex..(match.Index + 1)]; + lastIndex = match.Index + 1; + } + } + + if (lastIndex < text.Length) + { + if (count >= buffer.Length) + { + var newBuffer = ArrayPool.Shared.Rent(buffer.Length * 2); + Array.Copy(buffer, newBuffer, count); + ArrayPool.Shared.Return(buffer); + buffer = newBuffer; + } + buffer[count++] = text[lastIndex..]; + } + + var result = new string[count]; + Array.Copy(buffer, result, count); + return result; } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Converts a into sentence case. + /// + /// The text to convert. + /// The as sentence case. + /// For example a value of 'VarNameDB' would return 'Var name DB'. + /// Uses the function to perform the conversion. + [return: NotNullIfNotNull(nameof(text))] + public static string? ToSentenceCase(string? text) => SentenceCaseConverter is null ? text : SentenceCaseConverter(text); - /// - /// Converts a into sentence case. - /// - /// The text to convert. - /// The as sentence case. - /// For example a value of 'VarNameDB' would return 'Var Name DB'. - /// Uses the function to perform the conversion. - [return: NotNullIfNotNull(nameof(text))] - public static string? ToSentenceCase(string? text) => SentenceCaseConverter == null ? text : SentenceCaseConverter(text); - - /// - /// Gets or sets the underlying logic to perform the sentence case conversion. - /// - /// Defaults to the internal logic. - public static Func? SentenceCaseConverter { get; set; } = SentenceCaseConversion; - - /// - /// Performs the out-of-the-box sentence case conversion. - /// - /// The text. - /// Defaults to the following: Initial word splitting is performed using the . First letter is always capitalized, initial full text is tested (and replaced where matched) - /// against , then each word is tested (and replaced where matched) against . Finally, the last word in the initial text is tested against - /// and where matched the final word will be removed. - private static string? SentenceCaseConversion(string? text) + /// + /// Gets or sets the underlying logic to perform the sentence case conversion. + /// + /// Defaults to the logic. + public static Func? SentenceCaseConverter { get; set; } = SentenceCaseConversion; + + /// + /// Performs the out-of-the-box sentence case conversion. + /// + /// The text to convert. + /// Defaults to the following: Initial word splitting is performed using the . First letter is always capitalized, initial full text is tested (and replaced where matched) + /// against , then each word is tested (and replaced where matched) against and first letter lowercased where remainder of word is lowercase. Finally, the last word in the + /// initial text is tested against the and where matched the final word will be removed. + public static string? SentenceCaseConversion(string? text) + { + if (string.IsNullOrEmpty(text)) + return text; + + // Make sure the first character is always upper case. + if (char.IsLower(text[0])) + text = string.Create(text.Length, text, static (span, t) => + { + span[0] = char.ToUpper(t[0], CultureInfo.InvariantCulture); + t.AsSpan(1).CopyTo(span[1..]); + }); + + // Check if there is a one-to-one substitution. + ref var value = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrNullRef(Substitutions, text); + if (!Unsafe.IsNullRef(ref value)) + return value; + + // Determine whether last word should be removed, then go through each word and substitute. + var parts = SplitIntoWords(text); + var removeLastWord = parts.Length > 0 && LastWordRemovals.Contains(parts[^1]); + + for (int i = 0; i < parts.Length; i++) { - if (string.IsNullOrEmpty(text)) - return text; + if (Substitutions.TryGetValue(parts[i], out var iscs)) + parts[i] = iscs; - // Make sure the first character is always upper case. - if (char.IsLower(text[0])) - text = char.ToUpper(text[0], CultureInfo.InvariantCulture) + text[1..]; + if (i > 0 && parts[i].Length >= 2) + parts[i] = LowercaseFirstWhereRestIsLower(parts[i]); + } + + // Rejoin the words back into the final sentence. + return string.Join(" ", parts, 0, parts.Length - (removeLastWord ? 1 : 0)); + } + + /// + /// Lowercase the first character of the specified where the remainder of the text is all lowercase; otherwise returns the original value. + /// + private static string LowercaseFirstWhereRestIsLower(string text) + { + if (string.IsNullOrEmpty(text) || text.Length == 1) + return text; + + ReadOnlySpan span = text.AsSpan(); + + // Check remainder is all lowercase. + for (int i = 1; i < span.Length; i++) + { + if (!char.IsLower(span[i])) + return text; // no allocation + } - // Check if there is a one-to-one substitution. - if (Substitutions.TryGetValue(text, out var scs)) - return scs; + char firstLower = char.ToLowerInvariant(span[0]); - // Determine whether last word should be removed, then go through each word and substitute. - var parts = SplitIntoWords(text); - var removeLastWord = LastWordRemovals.Contains(parts.Last()); + // If already lowercase, don't allocate. + if (firstLower == span[0]) + return text; - for (int i = 0; i < parts.Length; i++) + // Allocate only when we actually need to change. + return string.Create(span.Length, (text, firstLower), static (dest, state) => + { + dest[0] = state.firstLower; + state.text.AsSpan()[1..].CopyTo(dest[1..]); + }); + } + + /// + /// Converts a into PascalCase. + /// + /// The text to convert. + /// The in PascalCase. + /// For example 'employee_id' or 'EmployeeId' would return 'EmployeeId'. + [return: NotNullIfNotNull(nameof(text))] + public static string? ToPascalCase(string? text) + { + if (string.IsNullOrEmpty(text)) + return text; + + var parts = SplitIntoWords(text); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Length == 0) continue; + + // Uppercase first letter, lowercase rest. + if (parts[i].Length == 1) + parts[i] = char.ToUpperInvariant(parts[i][0]).ToString(); + else + { + parts[i] = string.Create(parts[i].Length, parts[i], static (span, word) => + { + span[0] = char.ToUpperInvariant(word[0]); + for (int j = 1; j < word.Length; j++) + { + span[j] = char.ToLowerInvariant(word[j]); + } + }); + } + } + + return string.Concat(parts); + } + + /// + /// Converts a into camelCase. + /// + /// The text to convert. + /// The in camelCase. + /// For example 'EmployeeId' would return 'employeeId'. + [return: NotNullIfNotNull(nameof(text))] + public static string? ToCamelCase(string? text) + { + if (string.IsNullOrEmpty(text)) + return text; + + var parts = SplitIntoWords(text); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Length == 0) continue; + + if (i == 0) + parts[i] = parts[i].ToLowerInvariant(); // First word: all lowercase. + else { - if (Substitutions.TryGetValue(text, out var iscs)) - parts[i] = iscs; + // Other words: uppercase first letter, lowercase rest. + if (parts[i].Length == 1) + { + parts[i] = char.ToUpperInvariant(parts[i][0]).ToString(); + } + else + { + parts[i] = string.Create(parts[i].Length, parts[i], static (span, word) => + { + span[0] = char.ToUpperInvariant(word[0]); + for (int j = 1; j < word.Length; j++) + { + span[j] = char.ToLowerInvariant(word[j]); + } + }); + } } + } - // Rejoin the words back into the final sentence. - return string.Join(" ", parts, 0, parts.Length - (removeLastWord ? 1 : 0)); + return string.Concat(parts); + } + + /// + /// Converts a into kebab-case. + /// + /// The text to convert. + /// The in kebab-case. + /// For example 'EmployeeId' would return 'employee-id'. + [return: NotNullIfNotNull(nameof(text))] + public static string? ToKebabCase(string? text) + { + if (string.IsNullOrEmpty(text)) + return text; + + var parts = SplitIntoWords(text); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Length == 0) continue; + parts[i] = parts[i].ToLowerInvariant(); + } + + return string.Join("-", parts); + } + + /// + /// Converts a into snake_case. + /// + /// The text to convert. + /// The in snake_case. + /// For example 'EmployeeId' would return 'employee_id'. + [return: NotNullIfNotNull(nameof(text))] + public static string? ToSnakeCase(string? text) + { + if (string.IsNullOrEmpty(text)) + return text; + + var parts = SplitIntoWords(text); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Length == 0) continue; + parts[i] = parts[i].ToLowerInvariant(); } - /// - /// Gets or sets the sentence case substitutions where the key is the originating (input) text and the value the corresponding substitution sentence case text. - /// - /// Defaults with the following entry: key 'Id' and value 'Identifier'. - /// This subtitution applies to all words in the text with the exception of the last where it matches the . - public static Dictionary Substitutions { get; set; } = new() { { "Id", "Identifier" } }; - - /// - /// Gets or sets the sentence case last word removal list; i.e. where there is more than one word, and there is a match, the word will be removed. - /// - /// Defaults with the following entry: 'Id'. - /// For example a value of 'EmployeeId' would return just 'Employee'. - public static List LastWordRemovals { get; set; } = ["Id"]; + return string.Join("_", parts); } + + /// + /// Gets or sets the sentence case substitutions where the key is the originating (input) text and the value the corresponding substitution sentence case text. + /// + /// Defaults with the following entry: key 'Id' and value 'Identifier'. + /// This substitution applies to all words in the text with the exception of the last where it matches the . + public static Dictionary Substitutions { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Id", "Identifier" }, { "Etag", "ETag" } }; + + /// + /// Gets or sets the sentence case last word removal list; i.e. where there is more than one word, and there is a match, the word will be removed. + /// + /// Defaults with the following entry: 'Id'. + /// For example a value of 'EmployeeId' would return just 'Employee'. + public static HashSet LastWordRemovals { get; set; } = new HashSet(StringComparer.OrdinalIgnoreCase) { "Id" }; } \ No newline at end of file diff --git a/src/CoreEx/TransientException.cs b/src/CoreEx/TransientException.cs index 478d4c2b..ded84928 100644 --- a/src/CoreEx/TransientException.cs +++ b/src/CoreEx/TransientException.cs @@ -1,78 +1,38 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Localization; -using System; -using System.Net; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents a Transient exception; i.e. is a candidate for a retry. +/// +/// The defaults to: A transient error has occurred; please try again. +/// The error message. +/// The inner . +public class TransientException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(TransientException).FullName, _message), innerException) { + private const string _message = "A transient error has occurred; please try again."; + /// - /// Represents a Transient exception; i.e. is a candidate for a retry. + /// Gets the default retry after interval. /// - /// The defaults to: A data validation error occurred. - public class TransientException : Exception, IExtendedException - { - private const string _message = "A transient error has occurred; please try again."; - private static bool? _shouldExceptionBeLogged; - - /// - /// Get or sets the value. - /// - public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } - - /// - /// Initializes a new instance of the class. - /// - public TransientException() : this(null!) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - public TransientException(string? message) : base(message ?? new LText(typeof(TransientException).FullName, _message)) { } - - /// - /// Initializes a new instance of the class using the specified and . - /// - /// The error message. - /// The inner . - public TransientException(string? message, Exception innerException) : base(message ?? new LText(typeof(TransientException).FullName, _message), innerException) { } + public readonly static TimeSpan DefaultRetryAfter = TimeSpan.FromSeconds(90); - /// - /// - /// - /// The value as a . - public string ErrorType => Abstractions.ErrorType.TransientError.ToString(); - - /// - /// - /// - /// The value as a . - public int ErrorCode => (int)Abstractions.ErrorType.TransientError; - - /// - /// - /// - /// The value. - public HttpStatusCode StatusCode => HttpStatusCode.ServiceUnavailable; - - /// - /// - /// - /// true; is considered transient. - public bool IsTransient => true; + /// + /// Initializes a new instance of the class. + /// + public TransientException() : this(null) { } - /// - /// - /// - /// The value. - public bool ShouldBeLogged => ShouldExceptionBeLogged; + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public TransientException(LText? message) : this(message, null) { } - /// - /// Gets or sets the corresponding HeaderNames.RetryAfter seconds. - /// - /// Defaults to 120 seconds. - public int RetryAfterSeconds { get; set; } = 120; + /// + protected override void OnInitialize() + { + ErrorType = "transient"; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.ServiceUnavailable); + IsTransient = true; + RetryAfter = DefaultRetryAfter; } } \ No newline at end of file diff --git a/src/CoreEx/UnexpectedInternalException.cs b/src/CoreEx/UnexpectedInternalException.cs new file mode 100644 index 00000000..bd3a83b9 --- /dev/null +++ b/src/CoreEx/UnexpectedInternalException.cs @@ -0,0 +1,32 @@ +namespace CoreEx; + +/// +/// Represents an Unexpected (internal server error) exception. +/// +/// The defaults to: An unexpected internal server error has occurred. +/// The error message. +/// The inner . +public class UnexpectedInternalException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(UnexpectedInternalException).FullName, _message), innerException, true) +{ + private const string _message = "An unexpected internal server error has occurred."; + + /// + /// Initializes a new instance of the class. + /// + public UnexpectedInternalException() : this(null) { } + + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public UnexpectedInternalException(LText? message) : this(message, null) { } + + /// + protected override void OnInitialize() + { + ErrorType = "unexpected-internal"; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.InternalServerError); + IsError = false; + } +} \ No newline at end of file diff --git a/src/CoreEx/Validation/DecimalRuleHelper.cs b/src/CoreEx/Validation/DecimalRuleHelper.cs new file mode 100644 index 00000000..32352559 --- /dev/null +++ b/src/CoreEx/Validation/DecimalRuleHelper.cs @@ -0,0 +1,160 @@ +namespace CoreEx.Validation; + +/// +/// Represents a helper for values. +/// +public static class DecimalRuleHelper +{ + /// + /// Gets the default precision for a value. + /// + public const int DefaultPrecision = 18; + + /// + /// Checks the for the specified and . + /// + /// The value to check. + /// The maximum number of significant digits (including ). + /// The maximum number of fractional digits; i.e. decimal places. + /// where valid; otherwise, . + /// A will result in no scale validation. + public static bool CheckPrecisionAndScale(decimal value, int precision = DefaultPrecision, int? scale = null) + { + precision.ThrowWhen(p => p < 1, "Precision minimum value (where specified) is 1."); + scale.ThrowWhen(s => s.HasValue && s.Value < 0, "Scale minimum value (where specified) is 0.").ThrowWhen(s => s.HasValue && s.Value >= precision, "Scale must be less than precision."); + + if (value == 0m) + return true; + + // Calculate lengths once to avoid redundant computation. + var integralLength = CalcIntegralPartLength(value); + var fractionalLength = CalcFractionalPartLength(value); + + if (!CheckPrecision(precision, scale, integralLength, fractionalLength)) + return false; + + return scale is null || CheckScale(scale.Value, fractionalLength); + } + + /// + /// Checks the precision. + /// + /// The maximum number of significant digits (including ). + /// The maximum number of fractional digits; i.e. decimal places. + /// The integral-part length. + /// The fractional-part length. + internal static bool CheckPrecision(int precision, int? scale, int integralLength, int fractionalLength) => (integralLength + (scale ?? fractionalLength)) <= precision; + + /// + /// Checks the to determine whether the fractional-part length is less than or equal to the specified scale. + /// + /// The value to check. + /// The maximum number of fractional digits; i.e. decimal places. + /// where valid; otherwise, . + public static bool CheckScale(decimal value, int scale) + { + scale.ThrowWhen(s => s < 0, "Scale minimum value is 0."); + + if (value == 0m) + return true; + + return CheckScale(scale, CalcFractionalPartLength(value)); + } + + /// + /// Checks the scale. + /// + /// The maximum number of fractional digits; i.e. decimal places. + /// The fractional-part length. + internal static bool CheckScale(int scale, int fractionalLength) => fractionalLength <= scale; + + /// + /// Calculates the integral-part length for a value. + /// + /// The value. + /// The integral-part length. + public static int CalcIntegralPartLength(decimal value) + { + if (value == 0m) + return 0; + + // Get the integral part. + decimal absValue = Math.Abs(Math.Truncate(value)); + if (absValue == 0m) + return 0; + + // Use Log10 for O(1) performance; cast to double is safe here as we only need the magnitude for digit counting. + return (int)Math.Floor(Math.Log10((double)absValue)) + 1; + } + + /// + /// Calculates the fractional-part length for a value. + /// + /// The value. + /// The fractional-part length. + public static int CalcFractionalPartLength(decimal value) + { + if (value == 0m) + return 0; + + // Extract scale directly from decimal's internal representation (O(1) operation); scale is stored in bits 16-23 of the fourth int32 + int scale = (decimal.GetBits(value)[3] >> 16) & 0xFF; + if (scale == 0) + return 0; + + // Get fractional part + decimal fractional = value % 1m; + if (fractional == 0m) + return 0; + + // Multiply to integer form using power of 10, then strip trailing zeros. + decimal multiplied = Math.Abs(fractional) * GetPowerOf10(scale); + while (scale > 0 && multiplied % 10m == 0m) + { + multiplied /= 10m; + scale--; + } + + return scale; + } + + /// + /// Gets the power of 10 for the specified exponent using a lookup table for performance. + /// + private static decimal GetPowerOf10(int exponent) + { + return exponent switch + { + 0 => 1m, + 1 => 10m, + 2 => 100m, + 3 => 1000m, + 4 => 10000m, + 5 => 100000m, + 6 => 1000000m, + 7 => 10000000m, + 8 => 100000000m, + 9 => 1000000000m, + 10 => 10000000000m, + 11 => 100000000000m, + 12 => 1000000000000m, + 13 => 10000000000000m, + 14 => 100000000000000m, + 15 => 1000000000000000m, + 16 => 10000000000000000m, + 17 => 100000000000000000m, + 18 => 1000000000000000000m, + 19 => 10000000000000000000m, + 20 => 100000000000000000000m, + 21 => 1000000000000000000000m, + 22 => 10000000000000000000000m, + 23 => 100000000000000000000000m, + 24 => 1000000000000000000000000m, + 25 => 10000000000000000000000000m, + 26 => 100000000000000000000000000m, + 27 => 1000000000000000000000000000m, + 28 => 10000000000000000000000000000m, + _ => throw new ArgumentOutOfRangeException(nameof(exponent), exponent, "Exponent must be between 0 and 28.") + }; + } +} \ No newline at end of file diff --git a/src/CoreEx/Validation/IValidationResult.cs b/src/CoreEx/Validation/IValidationResult.cs index ba34d0e2..0a803360 100644 --- a/src/CoreEx/Validation/IValidationResult.cs +++ b/src/CoreEx/Validation/IValidationResult.cs @@ -1,47 +1,34 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using CoreEx.Entities; -using CoreEx.Results; -using System; - -namespace CoreEx.Validation +/// +/// Enables validation results. +/// +public interface IValidationResult : IToResult { /// - /// Enables validation results. + /// Gets the value being validated. /// - public interface IValidationResult : ITypedToResult - { - /// - /// Gets the originating value being validated. - /// - object? Value { get; } - - /// - /// Indicates whether there has been a validation or other related error. - /// - bool HasErrors { get; } + object? Value { get; } - /// - /// Gets a where and individual errors have been recorded or other has been recorded; otherwise, null. - /// - MessageItemCollection? Messages { get; } + /// + /// Indicates whether there has been one or more validation errors. + /// + bool HasErrors { get; } - /// - /// Converts the into a corresponding . - /// - /// The corresponding (typically a ) where ; otherwise, null. - Exception? ToException(); + /// + /// Gets a where and individual errors have been recorded or other has been recorded; otherwise, . + /// + MessageItemCollection? Messages { get; } - /// - /// Throws an (typically a ) where . - /// - /// The to support fluent-style method-chaining. - IValidationResult ThrowOnError(); + /// + /// Converts the into a corresponding . + /// + /// The corresponding (typically a ) where ; otherwise, . + Exception? ToException(); - /// - /// Gets the where returned from within an underlying validation operation. - /// - /// Where the related has an state then the underlying takes precedence over the validation . - Result? FailureResult { get; } - } + /// + /// Throws an (typically a ) where . + /// + /// The to support fluent-style method-chaining. + IValidationResult ThrowOnError(); } \ No newline at end of file diff --git a/src/CoreEx/Validation/IValidationResultT.cs b/src/CoreEx/Validation/IValidationResultT.cs index fc6c92d2..18360c6f 100644 --- a/src/CoreEx/Validation/IValidationResultT.cs +++ b/src/CoreEx/Validation/IValidationResultT.cs @@ -1,21 +1,16 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using System; - -namespace CoreEx.Validation +/// +/// Enables typed validation results. +/// +/// The . +public interface IValidationResult : IValidationResult { + /// + object? IValidationResult.Value => Value; + /// - /// Enables typed validation results. + /// Gets the value being validated. /// - /// The . - public interface IValidationResult : IValidationResult - { - /// - object? IValidationResult.Value => Value; - - /// - /// Gets the originating value being validated. - /// - new T? Value { get; } - } + new T? Value { get; } } \ No newline at end of file diff --git a/src/CoreEx/Validation/IValidator.cs b/src/CoreEx/Validation/IValidator.cs index f3f07ca2..2fbdd9cb 100644 --- a/src/CoreEx/Validation/IValidator.cs +++ b/src/CoreEx/Validation/IValidator.cs @@ -1,28 +1,21 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation +/// +/// Enables value validation. +/// +/// Decouples CoreEx from any specific implementation. +public interface IValidator { /// - /// Enables value validation. + /// Gets the for the value that is being validated. /// - /// Decouples CoreEx from any specific implementation. - public interface IValidator - { - /// - /// Gets the for the value that is being validated. - /// - Type ValueType { get; } + Type ValueType { get; } - /// - /// Validate the asynchronously. - /// - /// The value to validate. - /// The . - /// The . - Task ValidateAsync(object value, CancellationToken cancellationToken = default); - } + /// + /// Validate the asynchronously. + /// + /// The value to validate. + /// The . + /// The . + Task ValidateAsync(object? value, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/CoreEx/Validation/IValidatorT.cs b/src/CoreEx/Validation/IValidatorT.cs index 3e75f5ec..1a0e3efb 100644 --- a/src/CoreEx/Validation/IValidatorT.cs +++ b/src/CoreEx/Validation/IValidatorT.cs @@ -1,30 +1,23 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation +/// +/// Enables typed value validation. +/// +/// The value . +/// Decouples CoreEx from any specific implementation. +public interface IValidator : IValidator { - /// - /// Enables typed value validation. - /// - /// The value . - /// Decouples CoreEx from any specific implementation. - public interface IValidator : IValidator - { - /// - Type IValidator.ValueType => typeof(T); + /// + Type IValidator.ValueType => typeof(T); - /// - async Task IValidator.ValidateAsync(object value, CancellationToken cancellationToken) => await ValidateAsync((T)value!, cancellationToken).ConfigureAwait(false); + /// + async Task IValidator.ValidateAsync(object? value, CancellationToken cancellationToken) => await ValidateAsync((T)value!, cancellationToken).ConfigureAwait(false); - /// - /// Validate the asynchronously. - /// - /// The value to validate. - /// The . - /// The . - Task> ValidateAsync(T value, CancellationToken cancellationToken = default); - } + /// + /// Validate the asynchronously. + /// + /// The value to validate. + /// The . + /// The . + Task> ValidateAsync(T value, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/CoreEx/Validation/MultiValidator.cs b/src/CoreEx/Validation/MultiValidator.cs index 32235242..7cf936f7 100644 --- a/src/CoreEx/Validation/MultiValidator.cs +++ b/src/CoreEx/Validation/MultiValidator.cs @@ -1,100 +1,93 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation +/// +/// Enables multiple validations to be performed () resulting in a single consolidated . +/// +public class MultiValidator { /// - /// Enables multiple validations to be performed () resulting in a single consolidated . + /// Creates a new instance. /// - public class MultiValidator - { - /// - /// Creates a new instance. - /// - /// The instance. - public static MultiValidator Create() => new(); + /// The instance. + public static MultiValidator Create() => new(); - /// - /// Gets the list of validators. - /// - public List>> Validators { get; } = []; + /// + /// Private constructor. + /// + private MultiValidator() { } - /// - /// Adds an . - /// - /// The entity . - /// The validator . - /// The . - /// The entity value. - /// The (this) . - public MultiValidator Add(TValidator validator, TEntity value) where TEntity : class where TValidator : IValidator - { - Validators.Add(async ct => await validator.ValidateAsync(value, ct).ConfigureAwait(false)); - return this; - } + /// + /// Gets the list of validators. + /// + public List>> Validators { get; } = []; - /// - /// Adds (chains) a child . - /// - /// The child . - /// The (this) . - public MultiValidator Add(MultiValidator validator) - { - Validators.Add(async ct => await validator.ValidateAsync(ct).ConfigureAwait(false)); - return this; - } + /// + /// Adds an . + /// + /// The entity . + /// The validator . + /// The . + /// The entity value. + /// The to support fluent-style method-chaining. + public MultiValidator Add(TValidator validator, TEntity value) where TEntity : class where TValidator : IValidator + { + Validators.Add(async ct => await validator.ValidateAsync(value, ct).ConfigureAwait(false)); + return this; + } - /// - /// Runs the validations. - /// - /// The . - /// The . - public Task ValidateAsync(CancellationToken cancellationToken = default) => ValidationInvoker.Current.InvokeAsync(this, async (_, cancellationToken) => - { - var res = new MultiValidatorResult(); + /// + /// Adds (chains) a child . + /// + /// The child . + /// The to support fluent-style method-chaining. + public MultiValidator Add(MultiValidator validator) + { + Validators.Add(async ct => await validator.ValidateAsync(ct).ConfigureAwait(false)); + return this; + } - foreach (var v in Validators) - { - var r = await v(cancellationToken).ConfigureAwait(false); - if (r != null) - { - res.SetFailureResult(r.FailureResult); - if (res.FailureResult is not null) - return res; + /// + /// Executes the validations. + /// + /// The . + /// The . + public async Task ValidateAsync(CancellationToken cancellationToken = default) + { + var res = new MultiValidatorResult(); - if (r.Messages != null && r.Messages.Count > 0) - res.Messages.AddRange(r.Messages); - } + foreach (var v in Validators) + { + var r = await v(cancellationToken).ConfigureAwait(false); + if (r is not null) + { + if (r.Messages is not null && r.Messages.Count > 0) + res.Messages.AddRange(r.Messages); } + } - return res; - }, cancellationToken); + return res; + } - /// - /// Runs the validations. - /// - /// Indicates whether to automatically throw a where . - /// The . - /// The . - public async Task ValidateAsync(bool throwOnError, CancellationToken cancellationToken = default) - { - var mvr = await ValidateAsync(cancellationToken).ConfigureAwait(false); - return throwOnError ? mvr.ThrowOnError() : mvr; - } + /// + /// Executes the validations. + /// + /// Indicates whether to automatically throw a where . + /// The . + /// The . + public async Task ValidateAsync(bool throwOnError, CancellationToken cancellationToken = default) + { + var mvr = await ValidateAsync(cancellationToken).ConfigureAwait(false); + return throwOnError ? mvr.ThrowOnError() : mvr; + } - /// - /// Defines an to enable additional validations to be added (see ). - /// - /// The custom action. - /// The (this) . - public MultiValidator Additional(Action action) - { - action?.Invoke(this); - return this; - } + /// + /// Defines an to enable additional validations to be added (see ). + /// + /// The custom action. + /// The to support fluent-style method-chaining. + public MultiValidator Additional(Action action) + { + action?.Invoke(this); + return this; } } \ No newline at end of file diff --git a/src/CoreEx/Validation/MultiValidatorResult.cs b/src/CoreEx/Validation/MultiValidatorResult.cs index 75373fe6..1f76a6a9 100644 --- a/src/CoreEx/Validation/MultiValidatorResult.cs +++ b/src/CoreEx/Validation/MultiValidatorResult.cs @@ -1,115 +1,81 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Validation; -using CoreEx.Entities; -using CoreEx.Results; -using System; -using System.Linq; - -namespace CoreEx.Validation +/// +/// Represents the result of a . +/// +public class MultiValidatorResult : IValidationResult { - /// - /// Represents the result of a . - /// - public class MultiValidatorResult : IValidationResult, IToResult - { - private MessageItemCollection? _messages; + private MessageItemCollection? _messages; - /// - /// This is nonsensical and as such will throw a . - object? IValidationResult.Value => throw new NotSupportedException(); - - /// - public bool HasErrors { get; private set; } + /// + /// This is nonsensical and as such will throw a . + object? IValidationResult.Value => throw new NotSupportedException(); - /// - public MessageItemCollection Messages - { - get - { - if (_messages != null) - return _messages; - - _messages = []; - _messages.CollectionChanged += Messages_CollectionChanged; - return _messages; - } - } + /// + public bool HasErrors { get; private set; } - /// - /// Handle the add of a message. - /// - private void Messages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + /// + public MessageItemCollection Messages + { + get { - if (HasErrors) - return; - - switch (e.Action) - { - case System.Collections.Specialized.NotifyCollectionChangedAction.Add: - foreach (var m in e.NewItems!) - { - MessageItem mi = (MessageItem)m; - if (mi.Type == MessageType.Error) - { - HasErrors = true; - return; - } - } - - break; + if (_messages is not null) + return _messages; - default: - throw new InvalidOperationException("Operation invalid for Messages; only add supported."); - } + _messages = []; + _messages.CollectionChanged += Messages_CollectionChanged; + return _messages; } + } - /// - public Result? FailureResult { get; private set; } + /// + /// Handle the add of a message. + /// + private void Messages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + if (HasErrors) + return; - /// - /// Sets the . - /// - /// The . - internal void SetFailureResult(Result? result) + switch (e.Action) { - if (result is null) - return; + case System.Collections.Specialized.NotifyCollectionChangedAction.Add: + foreach (var m in e.NewItems!) + { + MessageItem mi = (MessageItem)m; + if (mi.Type == MessageType.Error) + { + HasErrors = true; + return; + } + } - if (FailureResult.HasValue) - throw new InvalidOperationException("The ValidationContext is already in a Failure state."); + break; - if (result.Value.IsFailure) - FailureResult = result; + default: + throw new InvalidOperationException("Operation invalid for Messages; only an Add is supported."); } + } - /// - public Exception? ToException() => FailureResult.HasValue ? FailureResult.Value.Error : (HasErrors ? new ValidationException(Messages!) : null); - - /// - IValidationResult IValidationResult.ThrowOnError() => ThrowOnError(); - - /// - /// Throws a where an error was found (and optionally if warnings). - /// - /// Indicates whether to throw where only warnings exist. - /// The to support fluent-style method-chaining. - public MultiValidatorResult ThrowOnError(bool includeWarnings = false) - { - var ex = ToException(); - if (ex is not null) - throw ex; - - if (includeWarnings && Messages != null && Messages.Any(x => x.Type == MessageType.Warning)) - throw new ValidationException(Messages); + /// + public Exception? ToException() => HasErrors ? new ValidationException(Messages!) : null; - return this; - } + /// + IValidationResult IValidationResult.ThrowOnError() => ThrowOnError(); - /// - /// This is largely nonsensical from a perspective and as such the will be set to its default value (see ). - Result ITypedToResult.ToResult() => FailureResult.HasValue ? FailureResult.Value.Bind() : (HasErrors ? Result.ValidationError(Messages!) : Result.None); + /// + /// Throws a where an error was found (and optionally any warnings). + /// + /// The to support fluent-style method-chaining. + public MultiValidatorResult ThrowOnError() + { + var ex = ToException(); + if (ex is not null) + throw ex; - /// - public Result ToResult() => FailureResult ?? (HasErrors ? Result.ValidationError(Messages!) : Result.Success); + return this; } + + /// + public Result ToResult() => HasErrors ? Result.ValidationError(Messages!) : Result.Success; } \ No newline at end of file diff --git a/src/CoreEx/Validation/README.md b/src/CoreEx/Validation/README.md deleted file mode 100644 index 711873a4..00000000 --- a/src/CoreEx/Validation/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# CoreEx.Validation - -The `CoreEx.Validation` namespace provides extended validation capabilities. - -
- -## Motivation - -To support a generic, implementation agnostic, means to support validation that can be leveraged (integrated) in a consistent manner within _CoreEx_. Whereby enabling a developer to leverage their respective framework of choice; for example [FluentValidation](#FluentValidation-implementation). - -
- -## Implementation agnostic - -The [`IValidator`](./IValidator.cs) and [`IValidator`](./IValidatorT.cs) interfaces provide the standard implementation agnostic `ValidateAsync` operations that can be, and are, leveraged within _CoreEx_ to provide validation functionality. - -A corresponding [`IValidationResult`](./IValidationResult.cs) and [`IValidationResult`](./IValidationResultT.cs) provide the corresponding validation result, that includes `HasErrors` and `Messages` (see [`MessageItemCollection`](../Entities/MessageItemCollection.cs)), and functionality to throw a resulting[`ValidationException`](../ValidationException.cs). - -
- -## CoreEx.Validation implementation - -The [`CoreEx.Validation`](../../CoreEx.Validation) project (assembly) provides a _CoreEx_-based implementation of the `IValidator`; being the [`ValidatorBase`](../../CoreEx.Validation/ValidatorBase.cs) and related [`Validator`](../../CoreEx.Validation/ValidatorT.cs), plus utility [`Validator`](../../CoreEx.Validation/Validator.cs). - -
- -## FluentValidation implementation - -[FluentValidation](https://github.com/FluentValidation/FluentValidation) is a popular .NET validator; as such [CoreEx.FluentValidation](../../CoreEx.FluentValidation) is provided to implement, the underlying [`ValidatorWrapper`](../../CoreEx.FluentValidation/ValidatorWrapper.cs) enables. diff --git a/src/CoreEx/Validation/Validation.cs b/src/CoreEx/Validation/Validation.cs index a6baa3d6..7eb4b75e 100644 --- a/src/CoreEx/Validation/Validation.cs +++ b/src/CoreEx/Validation/Validation.cs @@ -1,279 +1,71 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Validation; -using CoreEx.Entities; -using CoreEx.Localization; -using CoreEx.Results; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation +/// +/// Provides standard validation-related capabilities. +/// +public static class Validation { /// - /// Adds validation-related extension methods. + /// Gets or sets the format string for the mandatory error message. /// - public static class Validation - { - /// - /// Gets or sets the format string for the Mandatory error message. - /// - /// Defaults to: '{0} is required.'. - public static LText MandatoryFormat { get; set; } = new("CoreEx.Validation.MandatoryFormat", "{0} is required."); - - /// - /// Gets or sets the default value name. - /// - /// Defaults to: 'value'. - public static string ValueNameDefault { get; set; } = "value"; - - /// - /// Gets or sets the default value . - /// - public static LText ValueTextDefault { get; set; } = "Value"; - - /// - /// Validates (requires) that the is non-default and continues; otherwise, will throw a . - /// - /// The . - /// The value to validate. - /// The value name. - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// The value where non-default. - /// Thrown where the value is default. - [return: NotNull()] -#if NETSTANDARD2_1 - public static T Required(this T value, string? name = null, LText? text = null) -#else - public static T Required(this T value, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null) -#endif - => (Comparer.Default.Compare(value, default!) == 0) ? throw new ValidationException(MessageItem.CreateErrorMessage(name ?? ValueNameDefault, MandatoryFormat, text ?? ((name == null || name == ValueNameDefault) ? ValueTextDefault : name.ToSentenceCase()!))) : value!; - - /// - /// Requires (validates) that the is non-default and continues; otherwise, will return the with a corresponding . - /// - /// The or (see ) . - /// The . - /// The or (see ) instance. - /// The function to return the value to validate is required. - /// The value name. - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// The resulting - public static TResult Requires(this TResult result, Func value, string name, LText? text = null) where TResult : IResult - { - value.ThrowIfNull(nameof(value)); - name.ThrowIfNullOrEmpty(nameof(name)); - - if (result.IsSuccess && Comparer.Default.Compare(value(), default!) == 0) - return (TResult)result.ToFailure(new ValidationException(MessageItem.CreateErrorMessage(name, MandatoryFormat, text ?? name.ToSentenceCase()!))); - - return result; - } - -#if NETSTANDARD2_1 - /// - /// Requires (validates) that the is non-default and continues; otherwise, will return the with a corresponding . - /// - /// The or (see ) . - /// The . - /// The or (see ) instance. - /// The value to validate is required. - /// The value name. - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// The resulting - public static TResult Requires(this TResult result, T value, string name, LText? text = null) where TResult : IResult => result.Requires(() => value, name, text); -#else - /// - /// Requires (validates) that the is non-default and continues; otherwise, will return the with a corresponding . - /// - /// The or (see ) . - /// The . - /// The or (see ) instance. - /// The value to validate is required. - /// The value name (defaults to name using the ). - /// The friendly text name used in validation messages (defaults to as sentence case where not specified). - /// The resulting - public static TResult Requires(this TResult result, T value, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null) where TResult : IResult => result.Requires(() => value, name!, text); -#endif - - /// - /// Validates using the where the is . - /// - /// The . - /// The . - /// The function to get the . - /// The . - /// The resulting . - /// Where the the corresponding will be updated with the . - public static async Task> ValidateAsync(this Result result, Func> validator, CancellationToken cancellationToken = default) where T : class - { - validator.ThrowIfNull(nameof(validator)); - - return await result.ThenAsync(async v => - { - var vi = validator() ?? throw new InvalidOperationException($"The {nameof(validator)} function must return a non-null instance to perform the requested validation."); - var vr = await vi.ValidateAsync(v, cancellationToken).ConfigureAwait(false); - return vr.ToResult(); - }); - } - - /// - /// Validates using the where the is . - /// - /// The . - /// The . - /// The function to get the . - /// The . - /// The resulting . - /// Where the the corresponding will be updated with the . - public static async Task> ValidateAsync(this Task> result, Func> validator, CancellationToken cancellationToken = default) where T : class - { - validator.ThrowIfNull(nameof(validator)); - - return await result.ThenAsync(async v => - { - var vi = validator() ?? throw new InvalidOperationException($"The {nameof(validator)} function must return a non-null instance to perform the requested validation."); - var vr = await vi.ValidateAsync(v, cancellationToken).ConfigureAwait(false); - return vr.ToResult(); - }); - } - - /// - /// Validates using the where the is . - /// - /// The . - /// The . - /// The . - /// The . - /// The resulting . - /// Where the the corresponding will be updated with the . - public static async Task> ValidateAsync(this Result result, IValidator validator, CancellationToken cancellationToken = default) where T : class - { - validator.ThrowIfNull(nameof(validator)); - - return await result.ThenAsync(async v => - { - var vr = await validator.ValidateAsync(v, cancellationToken).ConfigureAwait(false); - return vr.ToResult(); - }); - } - - /// - /// Validates using the where the is . - /// - /// The . - /// The . - /// The . - /// The . - /// The resulting . - /// Where the the corresponding will be updated with the . - public static async Task> ValidateAsync(this Task> result, IValidator validator, CancellationToken cancellationToken = default) where T : class - { - validator.ThrowIfNull(nameof(validator)); - - return await result.ThenAsync(async v => - { - var vr = await validator.ValidateAsync(v, cancellationToken).ConfigureAwait(false); - return vr.ToResult(); - }); - } - - /// - /// Validates using the where the is . - /// - /// The . - /// The function to get the instance. - /// The . - /// The resulting . - public static async Task ValidateAsync(this Result result, Func multiValidator, CancellationToken cancellationToken = default) - { - multiValidator.ThrowIfNull(nameof(multiValidator)); + /// Defaults to: '{0} is required.'. + public static LText MandatoryFormat { get; set; } = new("CoreEx.Validation.MandatoryFormat", "{0} is required."); - return await result.ThenAsync(async () => - { - var mv = multiValidator() ?? throw new InvalidOperationException($"The {nameof(multiValidator)} function must return a non-null instance to perform the requested validation."); - var vr = await mv.ValidateAsync(cancellationToken).ConfigureAwait(false); - return vr.ToResult(); - }); - } - - /// - /// Validates using the where the is . - /// - /// The . - /// The function to get the instance. - /// The . - /// The resulting . - public static async Task ValidateAsync(this Task result, Func multiValidator, CancellationToken cancellationToken = default) - { - multiValidator.ThrowIfNull(nameof(multiValidator)); + /// + /// Gets or sets the format string for an invalid value error message. + /// + /// Defaults to: '{0} is invalid: {1}.'. + public static LText InvalidValueFormat { get; set; } = new("CoreEx.Validation.InvalidValueFormat", "{0} is invalid: {1}."); - return await result.ThenAsync(async () => - { - var mv = multiValidator() ?? throw new InvalidOperationException($"The {nameof(multiValidator)} function must return a non-null instance to perform the requested validation."); - var vr = await mv.ValidateAsync(cancellationToken).ConfigureAwait(false); - return vr.ToResult(); - }); - } + /// + /// Gets or sets the value name. + /// + /// Defaults to: 'value'. + public static string ValueName { get; set; } = "value"; - /// - /// Validates using the where the is . - /// - /// The . - /// The . - /// The function to get the instance. - /// The . - /// The resulting . - public static async Task> ValidateAsync(this Result result, Func multiValidator, CancellationToken cancellationToken = default) - { - multiValidator.ThrowIfNull(nameof(multiValidator)); + /// + /// Gets or sets the value . + /// + public static LText ValueText { get; set; } = new("CoreEx.Validation.ValueText", "Value"); - return await result.ThenAsync(async v => - { - var mv = multiValidator(v) ?? throw new InvalidOperationException($"The {nameof(multiValidator)} function must return a non-null instance to perform the requested validation."); - var vr = await mv.ValidateAsync(cancellationToken).ConfigureAwait(false); - return vr.ToResult().Bind(); - }); - } + /// + /// Gets or sets the representation of . + /// + /// Defaults to: '(null)'. + public static LText NullText { get; set; } = new("CoreEx.Validation.NullText", "(null)"); - /// - /// Validates using the where the is . - /// - /// The . - /// The . - /// The function to get the instance. - /// The . - /// The resulting . - public static async Task> ValidateAsync(this Task> result, Func multiValidator, CancellationToken cancellationToken = default) - { - multiValidator.ThrowIfNull(nameof(multiValidator)); + /// + /// Gets or sets the key name. + /// + /// Defaults to: 'key'. + public static string KeyName { get; set; } = "key"; - return await result.ThenAsync(async v => - { - var mv = multiValidator(v) ?? throw new InvalidOperationException($"The {nameof(multiValidator)} function must return a non-null instance to perform the requested validation."); - var vr = await mv.ValidateAsync(cancellationToken).ConfigureAwait(false); - return vr.ToResult().Bind(); - }); - } + /// + /// Creates a required value . + /// + /// The value name. + /// The friendly text name used in validation messages (defaults to as sentence case where not specified). + /// An optional action to configure the . + /// The resulting . + public static TResult CreateRequiredValueResult(string? name = null, LText? text = null, Action? configure = null) where TResult : IResult, new() + { + var ex = new ValidationException(MessageItem.CreateErrorMessage(name ?? ValueName, MandatoryFormat, text ?? name?.ToSentenceCase() ?? ValueText)); + configure?.Invoke(ex); + return (TResult)new TResult().ToFailure(ex); + } - /// - /// Converts a to a where and are the same. - /// - /// The . - /// The . - /// The value to convert. - /// The resulting . - /// Thrown where and are not the same. - public static Result ConvertValueToResult(T value) - { - if (value is null) - return Result.None; - else if (value is R r) - return Result.Ok(r); - else - throw new InvalidOperationException($"Cannot convert {typeof(T).Name} to {typeof(R).Name}."); - } + /// + /// Creates an invalid value . + /// + /// The context message. + /// The value name. + /// The friendly text name used in validation messages (defaults to as sentence case where not specified). + /// An optional action to configure the . + /// The resulting . + public static TResult CreateInvalidValueResult(string message, string? name = null, LText? text = null, Action? configure = null) where TResult : IResult, new() + { + var ex = new ValidationException(MessageItem.CreateErrorMessage(name ?? ValueName, InvalidValueFormat, text ?? name?.ToSentenceCase() ?? ValueText, message.ThrowIfNullOrEmpty().Trim('.'))); + configure?.Invoke(ex); + return (TResult)new TResult().ToFailure(ex); } } \ No newline at end of file diff --git a/src/CoreEx/Validation/ValidationInvoker.cs b/src/CoreEx/Validation/ValidationInvoker.cs deleted file mode 100644 index 28d4cd59..00000000 --- a/src/CoreEx/Validation/ValidationInvoker.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Invokers; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Validation -{ - /// - /// Provides the invocation wrapping for the instances. - /// - public class ValidationInvoker : InvokerBase - { - private const string ValidationHasErrors = "validation.haserrors"; - private const string ValidationFailureResult = "validation.failureresult"; - private static ValidationInvoker? _default; - - /// - /// Gets the current configured instance (see ). - /// - public static ValidationInvoker Current => CoreEx.ExecutionContext.GetService() ?? (_default ??= new ValidationInvoker()); - - /// - protected override TResult OnInvoke(InvokeArgs invokeArgs, object invoker, System.Func func) => throw new NotImplementedException(); - - /// - protected override Task OnInvokeAsync(InvokeArgs invokeArgs, object invoker, Func> func, CancellationToken cancellationToken) - { - var result = base.OnInvokeAsync(invokeArgs, invoker, func, cancellationToken); - if (invokeArgs.Activity is not null && result is IValidationResult vr) - { - invokeArgs.Activity.AddTag(ValidationHasErrors, vr.HasErrors); - invokeArgs.Activity.AddTag(ValidationFailureResult, vr.FailureResult is null || vr.FailureResult.Value.IsSuccess ? null : vr.FailureResult.ToString()); - } - - return result; - } - } -} \ No newline at end of file diff --git a/src/CoreEx/Validation/ValidatorExtensions.Requires.cs b/src/CoreEx/Validation/ValidatorExtensions.Requires.cs new file mode 100644 index 00000000..984d7b37 --- /dev/null +++ b/src/CoreEx/Validation/ValidatorExtensions.Requires.cs @@ -0,0 +1,48 @@ +namespace CoreEx.Validation; + +public static partial class ValidatorExtensions +{ + /// + /// Validates (requires) that the is non-default and continues; otherwise, will throw a . + /// + /// The . + /// The value to validate. + /// The value name. + /// The friendly text name used in validation messages (defaults to as sentence case where not specified). + /// The value where non-default. + /// Thrown where the value is default. + [return: NotNull()] + public static T Required(this T value, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null) + => (Comparer.Default.Compare(value, default!) == 0) ? throw Validation.CreateRequiredValueResult(name, text).Error : value!; + + /// + /// Requires (validates) that the is non-default and continues; otherwise, will return the with a corresponding . + /// + /// The or (see ) . + /// The . + /// The or (see ) instance. + /// The function to return the value to validate is required. + /// The value name. + /// The friendly text name used in validation messages (defaults to as sentence case where not specified). + /// The resulting + public static TResult Requires(this TResult result, Func value, string name, LText? text = null) where TResult : IResult, new() + { + value.ThrowIfNull(); + name.ThrowIfNullOrEmpty(); + + return result.IsSuccess && Comparer.Default.Compare(value(), default!) == 0 ? Validation.CreateRequiredValueResult(name, text) : result; + } + + /// + /// Requires (validates) that the is non-default and continues; otherwise, will return the with a corresponding . + /// + /// The or (see ) . + /// The . + /// The or (see ) instance. + /// The value to validate is required. + /// The value name (defaults to caller argument expression). + /// The friendly text name used in validation messages (defaults to as sentence case where not specified). + /// The resulting + public static TResult Requires(this TResult result, T value, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null) where TResult : IResult, new() + => result.Requires(() => value, name ?? Validation.ValueName, text); +} \ No newline at end of file diff --git a/src/CoreEx/Validation/ValidatorExtensions.cs b/src/CoreEx/Validation/ValidatorExtensions.cs new file mode 100644 index 00000000..4d6d30a5 --- /dev/null +++ b/src/CoreEx/Validation/ValidatorExtensions.cs @@ -0,0 +1,167 @@ +namespace CoreEx.Validation; + +/// +/// Provides standard validator extensions. +/// +public static partial class ValidatorExtensions +{ + /// + /// Validates using the where the is . + /// + /// The . + /// The . + /// The function to get the . + /// The . + /// The resulting . + /// Where the the corresponding will be updated with the . + public static async Task> ValidateAsync(this Result result, Func> validator, CancellationToken cancellationToken = default) where T : class + { + validator.ThrowIfNull(); + + return await result.ThenAsync(async v => + { + var vi = validator() ?? throw new InvalidOperationException($"The {nameof(validator)} function must return a non-null instance to perform the requested validation."); + var vr = await vi.ValidateAsync(v, cancellationToken).ConfigureAwait(false); + return vr.ToResult(); + }); + } + + /// + /// Validates using the where the is . + /// + /// The . + /// The . + /// The function to get the . + /// The . + /// The resulting . + /// Where the the corresponding will be updated with the . + public static async Task> ValidateAsync(this Task> result, Func> validator, CancellationToken cancellationToken = default) where T : class + { + validator.ThrowIfNull(); + + return await result.ThenAsync(async v => + { + var vi = validator() ?? throw new InvalidOperationException($"The {nameof(validator)} function must return a non-null instance to perform the requested validation."); + var vr = await vi.ValidateAsync(v, cancellationToken).ConfigureAwait(false); + return vr.ToResult(); + }); + } + + /// + /// Validates using the where the is . + /// + /// The . + /// The . + /// The . + /// The . + /// The resulting . + /// Where the the corresponding will be updated with the . + public static async Task> ValidateAsync(this Result result, IValidator validator, CancellationToken cancellationToken = default) where T : class + { + validator.ThrowIfNull(); + + return await result.ThenAsync(async v => + { + var vr = await validator.ValidateAsync(v, cancellationToken).ConfigureAwait(false); + return vr.ToResult(); + }); + } + + /// + /// Validates using the where the is . + /// + /// The . + /// The . + /// The . + /// The . + /// The resulting . + /// Where the the corresponding will be updated with the . + public static async Task> ValidateAsync(this Task> result, IValidator validator, CancellationToken cancellationToken = default) where T : class + { + validator.ThrowIfNull(); + + return await result.ThenAsync(async v => + { + var vr = await validator.ValidateAsync(v, cancellationToken).ConfigureAwait(false); + return vr.ToResult(); + }); + } + + /// + /// Validates using the where the is . + /// + /// The . + /// The function to get the instance. + /// The . + /// The resulting . + public static async Task ValidateAsync(this Result result, Func multiValidator, CancellationToken cancellationToken = default) + { + multiValidator.ThrowIfNull(); + + return await result.ThenAsync(async () => + { + var mv = multiValidator() ?? throw new InvalidOperationException($"The {nameof(multiValidator)} function must return a non-null instance to perform the requested validation."); + var vr = await mv.ValidateAsync(cancellationToken).ConfigureAwait(false); + return vr.ToResult(); + }); + } + + /// + /// Validates using the where the is . + /// + /// The . + /// The function to get the instance. + /// The . + /// The resulting . + public static async Task ValidateAsync(this Task result, Func multiValidator, CancellationToken cancellationToken = default) + { + multiValidator.ThrowIfNull(); + + return await result.ThenAsync(async () => + { + var mv = multiValidator() ?? throw new InvalidOperationException($"The {nameof(multiValidator)} function must return a non-null instance to perform the requested validation."); + var vr = await mv.ValidateAsync(cancellationToken).ConfigureAwait(false); + return vr.ToResult(); + }); + } + + /// + /// Validates using the where the is . + /// + /// The . + /// The . + /// The function to get the instance. + /// The . + /// The resulting . + public static async Task> ValidateAsync(this Result result, Func multiValidator, CancellationToken cancellationToken = default) + { + multiValidator.ThrowIfNull(); + + return await result.ThenAsync(async v => + { + var mv = multiValidator(v) ?? throw new InvalidOperationException($"The {nameof(multiValidator)} function must return a non-null instance to perform the requested validation."); + var vr = await mv.ValidateAsync(cancellationToken).ConfigureAwait(false); + return vr.ToResult().Bind(); + }); + } + + /// + /// Validates using the where the is . + /// + /// The . + /// The . + /// The function to get the instance. + /// The . + /// The resulting . + public static async Task> ValidateAsync(this Task> result, Func multiValidator, CancellationToken cancellationToken = default) + { + multiValidator.ThrowIfNull(); + + return await result.ThenAsync(async v => + { + var mv = multiValidator(v) ?? throw new InvalidOperationException($"The {nameof(multiValidator)} function must return a non-null instance to perform the requested validation."); + var vr = await mv.ValidateAsync(cancellationToken).ConfigureAwait(false); + return vr.ToResult().Bind(); + }); + } +} \ No newline at end of file diff --git a/src/CoreEx/ValidationException.cs b/src/CoreEx/ValidationException.cs index 47d4c47a..0d93f858 100644 --- a/src/CoreEx/ValidationException.cs +++ b/src/CoreEx/ValidationException.cs @@ -1,116 +1,93 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx - -using CoreEx.Abstractions; -using CoreEx.Entities; -using CoreEx.Localization; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Text; - -namespace CoreEx +namespace CoreEx; + +/// +/// Represents a Validation exception. +/// +/// The defaults to: A data validation error occurred. +/// The error message. +/// The inner . +public class ValidationException(LText? message, Exception? innerException) + : ExtendedException(message ?? new LText(typeof(ValidationException).FullName, _message), innerException) { + private const string _message = "A data validation error occurred."; + /// - /// Represents a Validation exception. + /// Creates a new using a with the specified and . /// - /// The defaults to: A data validation error occurred. - public class ValidationException : Exception, IExtendedException - { - private const string _message = "A data validation error occurred."; - private static bool? _shouldExceptionBeLogged; - - /// - /// Get or sets the value. - /// - public static bool ShouldExceptionBeLogged { get => _shouldExceptionBeLogged ?? Internal.ShouldExceptionBeLogged(); set => _shouldExceptionBeLogged = value; } - - /// - /// Initializes a new instance of the class. - /// - public ValidationException() : this(null!) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - public ValidationException(string? message) : base(message ?? new LText(typeof(ValidationException).FullName, _message)) { } - - /// - /// Initializes a new instance of the class using the specified . - /// - /// The error message. - /// The inner . - public ValidationException(string? message, Exception innerException) : base(message ?? new LText(typeof(ValidationException).FullName, _message), innerException) { } + /// The property name. + /// The message text. + /// The . + public static ValidationException Create(string? property, LText text) => new(MessageItem.CreateErrorMessage(property, text)); - /// - /// Initializes a new instance of the with a list and optional . - /// - /// The list. - /// The error message. - public ValidationException(IEnumerable messages, string? message = null) : base(CreateMessage(messages, message ?? new LText(typeof(ValidationException).FullName, _message))) - { - if (messages != null) - Messages = new(messages.Where(x => x.Type == MessageType.Error)); - } + /// + /// Creates a new using a with the specified , text and additional included in the text. + /// + /// The property name. + /// The composite format string. + /// The values that form part of the message text. + /// The . + public static ValidationException Create(string? property, LText format, params IEnumerable values) => new(MessageItem.CreateErrorMessage(property, format, values)); - /// - /// Initializes a new instance of the with a single . - /// - /// The . - /// The error message. - public ValidationException(MessageItem item, string? message = null) : this([item], message) { } + /// + /// Initializes a new instance of the class. + /// + public ValidationException() : this(null) { } - /// - /// Gets the underlying messages. - /// - public MessageItemCollection? Messages { get; } + /// + /// Initializes a new instance of the class using the specified . + /// + /// The error message. + public ValidationException(LText? message) : this(message, null) { } - /// - /// - /// - /// The value as a . - public string ErrorType => Abstractions.ErrorType.ValidationError.ToString(); + /// + /// Initializes a new instance of the with a collection and optional . + /// + /// The collection. + /// The error message. + /// The inner . + public ValidationException(IEnumerable messages, LText? message = null, Exception? innerException = null) : this(message, innerException) + { + if (messages is not null) + Messages = [.. messages.Where(x => x.Type == MessageType.Error)]; + } - /// - /// - /// - /// The value as a . - public int ErrorCode => (int)Abstractions.ErrorType.ValidationError; + /// + /// Initializes a new instance of the with a single . + /// + /// The . + /// The error message. + /// The inner . + public ValidationException(MessageItem item, LText? message = null, Exception? innerException = null) : this(message, innerException) + { + if (item is not null && item.Type == MessageType.Error) + Messages = [item]; + } - /// - /// - /// - /// The value. - public HttpStatusCode StatusCode => HttpStatusCode.BadRequest; + /// + /// Gets the underlying message(s) where applicable. + /// + public MessageItemCollection? Messages { get; } - /// - /// - /// - /// false; is not considered transient. - public bool IsTransient => false; + /// + protected override void OnInitialize() + { + ErrorType = "validation"; + StatusCode = GetConfiguredStatusCode(HttpStatusCode.BadRequest); + } - /// - /// - /// - /// The value. - public bool ShouldBeLogged => ShouldExceptionBeLogged; + /// + /// Prepends the where applicable. + public override string ToString() + { + if (Messages is null || Messages.Count == 0) + return base.ToString(); - /// - /// Creates the exception message. - /// - private static string CreateMessage(IEnumerable mic, string message) + var sb = new StringBuilder("Messages:"); + foreach (var mi in Messages.Where(x => x.Type == MessageType.Error)) { - if (mic == null) - return message; - - var sb = new StringBuilder(message); - foreach (var mi in mic.Where(x => x.Type == MessageType.Error)) - { - sb.Append($" [{mi.Property}: {mi.Text}]"); - } - - return sb.ToString(); + sb.Append($" [{mi.Property}: {mi.Text}]"); } + + return sb.ToString() + Environment.NewLine + base.ToString(); } } \ No newline at end of file diff --git a/src/CoreEx/Wildcards/Wildcard.cs b/src/CoreEx/Wildcards/Wildcard.cs index 760914bf..7a292325 100644 --- a/src/CoreEx/Wildcards/Wildcard.cs +++ b/src/CoreEx/Wildcards/Wildcard.cs @@ -1,191 +1,186 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Wildcards; -using CoreEx.Entities; -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Text; - -namespace CoreEx.Wildcards +/// +/// Provides a standardised approach to parsing and validating wildcard text. +/// +public sealed class Wildcard { /// - /// Provides a standardised approach to parsing and validating wildcard text. + /// Gets the standard multi (zero or more) wildcard character. + /// + public const char MultiWildcardCharacter = '*'; + + /// + /// Gets the standard single wildcard character. + /// + public const char SingleWildcardCharacter = '?'; + + /// + /// Gets the space ' ' character. + /// + public const char SpaceCharacter = ' '; + + /// + /// Gets the and ; i.e does not directly support any wildcard characters. + /// + public static Wildcard None { get; } = new Wildcard(WildcardSelection.None | WildcardSelection.Equal); + + /// + /// Gets the using only the . /// - public class Wildcard + public static Wildcard MultiBasic { get; } = new Wildcard(WildcardSelection.MultiBasic); + + /// + /// Gets the using only the . + /// + public static Wildcard MultiAll { get; } = new Wildcard(WildcardSelection.MultiAll); + + /// + /// Gets the using both the and . + /// + public static Wildcard BothAll { get; } = new Wildcard(WildcardSelection.BothAll); + + /// + /// Gets or sets the default settings (defaults to . + /// + public static Wildcard Default { get; set; } = MultiBasic; + + /// + /// Initializes a new instance of the class. + /// + /// The supported . + /// The .NET multi (zero or more) wildcard character (defaults to ). + /// The .NET single wildcard character (defaults to ). + /// The list of characters that are not allowed. + /// The that defines the treatment of embedded space ' ' characters within the wildcard. + public Wildcard(WildcardSelection supported, char multiWildcard = MultiWildcardCharacter, char singleWildcard = SingleWildcardCharacter, char[]? charactersNotAllowed = null, WildcardSpaceTreatment spaceTreatment = WildcardSpaceTreatment.None) { - /// - /// Gets the standard multi (zero or more) wildcard character. - /// - public const char MultiWildcardCharacter = '*'; - - /// - /// Gets the standard single wildcard character. - /// - public const char SingleWildcardCharacter = '?'; - - /// - /// Gets the space ' ' character. - /// - public const char SpaceCharacter = ' '; - - /// - /// Gets the and ; i.e does not directly support any wildcard characters. - /// - public static Wildcard None { get; } = new Wildcard(WildcardSelection.None | WildcardSelection.Equal); - - /// - /// Gets the using only the . - /// - public static Wildcard MultiBasic { get; } = new Wildcard(WildcardSelection.MultiBasic); - - /// - /// Gets the using only the . - /// - public static Wildcard MultiAll { get; } = new Wildcard(WildcardSelection.MultiAll); - - /// - /// Gets the using both the and . - /// - public static Wildcard BothAll { get; } = new Wildcard(WildcardSelection.BothAll); - - /// - /// Gets or sets the default settings (defaults to . - /// - public static Wildcard Default { get; set; } = MultiBasic; - - /// - /// Initializes a new instance of the class. - /// - /// The supported . - /// The .NET multi (zero or more) wildcard character (defaults to ). - /// The .NET single wildcard character (defaults to ). - /// The list of characters that are not allowed. - /// The option for the wildcard text. - /// The that defines the treatment of embedded space ' ' characters within the wildcard. - public Wildcard(WildcardSelection supported, char multiWildcard = MultiWildcardCharacter, char singleWildcard = SingleWildcardCharacter, char[]? charactersNotAllowed = null, - WildcardSpaceTreatment spaceTreatment = WildcardSpaceTreatment.None, StringTransform transform = StringTransform.EmptyToNull) - { - if (supported == WildcardSelection.Undetermined || supported.HasFlag(WildcardSelection.InvalidCharacter)) - throw new ArgumentException("A Wildcard cannot be configured with Undetermined and/or InvalidCharacter supported selection(s).", nameof(supported)); + if (supported == WildcardSelection.Undetermined || supported.HasFlag(WildcardSelection.InvalidCharacter)) + throw new ArgumentException("A Wildcard cannot be configured with Undetermined and/or InvalidCharacter supported selection(s).", nameof(supported)); - if (multiWildcard != char.MinValue && singleWildcard != char.MinValue && multiWildcard == singleWildcard) - throw new ArgumentException("A Wildcard cannot be configured with the same character for the MultiWildcard and SingleWildcard.", nameof(multiWildcard)); + if (multiWildcard != char.MinValue && singleWildcard != char.MinValue && multiWildcard == singleWildcard) + throw new ArgumentException("A Wildcard cannot be configured with the same character for the MultiWildcard and SingleWildcard.", nameof(multiWildcard)); - if (charactersNotAllowed != null && (charactersNotAllowed.Contains(multiWildcard) || charactersNotAllowed.Contains(singleWildcard))) - throw new ArgumentException("A Wildcard cannot be configured with either the MultiWildcard or SingleWildcard in the CharactersNotAllowed list.", nameof(charactersNotAllowed)); + if (charactersNotAllowed is not null && (charactersNotAllowed.Contains(multiWildcard) || charactersNotAllowed.Contains(singleWildcard))) + throw new ArgumentException("A Wildcard cannot be configured with either the MultiWildcard or SingleWildcard in the CharactersNotAllowed list.", nameof(charactersNotAllowed)); - if (supported.HasFlag(WildcardSelection.MultiWildcard) && multiWildcard == char.MinValue) - throw new ArgumentException("A Wildcard that supports MultiWildcard must also define the MultiWildcard character."); + if (supported.HasFlag(WildcardSelection.MultiWildcard) && multiWildcard == char.MinValue) + throw new ArgumentException("A Wildcard that supports MultiWildcard must also define the MultiWildcard character.", nameof(multiWildcard)); - if (supported.HasFlag(WildcardSelection.SingleWildcard) && singleWildcard == char.MinValue) - throw new ArgumentException("A Wildcard that supports SingleWildcard must also define the SingleWildcard character."); + if (supported.HasFlag(WildcardSelection.SingleWildcard) && singleWildcard == char.MinValue) + throw new ArgumentException("A Wildcard that supports SingleWildcard must also define the SingleWildcard character.", nameof(singleWildcard)); - Supported = supported; - MultiWildcard = multiWildcard; - SingleWildcard = singleWildcard; - CharactersNotAllowed = new ReadOnlyCollection(charactersNotAllowed != null ? (char[])charactersNotAllowed.Clone() : []); - SpaceTreatment = spaceTreatment; - Transform = transform; - } + SupportedSelection = supported; + MultiWildcard = multiWildcard; + SingleWildcard = singleWildcard; + CharactersNotAllowed = new ReadOnlyCollection(charactersNotAllowed is not null ? (char[])charactersNotAllowed.Clone() : []); + SpaceTreatment = spaceTreatment; + } - /// - /// Gets the supported . - /// - public WildcardSelection Supported { get; private set; } - - /// - /// Gets the .NET multi (zero or more) wildcard character. - /// - /// A value of indicates no multi wildcard support. - public char MultiWildcard { get; private set; } - - /// - /// Gets the .NET single wildcard character. - /// - /// A value of indicates no single wildcard support. - public char SingleWildcard { get; private set; } - - /// - /// Gets the list of characters that are not allowed. - /// - public IReadOnlyList CharactersNotAllowed { get; private set; } - - /// - /// Gets the option for the wildcard text. - /// - public StringTransform Transform { get; private set; } - - /// - /// Gets the that defines the treatment of embedded space ' ' characters within the wildcard. - /// - public WildcardSpaceTreatment SpaceTreatment { get; private set; } - - /// - /// Validates the wildcard text against what is to ensure validity. - /// - /// The wildcard text. - /// true indicates that the text is valid; otherwise, false for invalid. - /// Note that leading and trailing spaces are ignored. - public bool Validate(string? text) => !Parse(text).HasError; - - /// - /// Validates the against what is to ensure validity. - /// - /// The to validate. - /// true indicates that the selection is valid; otherwise, false for invalid. - public bool Validate(WildcardSelection selection) - { - if ((selection.HasFlag(WildcardSelection.None) && !Supported.HasFlag(WildcardSelection.None)) || - (selection.HasFlag(WildcardSelection.Equal) && !Supported.HasFlag(WildcardSelection.Equal)) || - (selection.HasFlag(WildcardSelection.Single) && !Supported.HasFlag(WildcardSelection.Single)) || - (selection.HasFlag(WildcardSelection.StartsWith) && !Supported.HasFlag(WildcardSelection.StartsWith)) || - (selection.HasFlag(WildcardSelection.EndsWith) && !Supported.HasFlag(WildcardSelection.EndsWith)) || - (selection.HasFlag(WildcardSelection.Contains) && !Supported.HasFlag(WildcardSelection.Contains)) || - (selection.HasFlag(WildcardSelection.Embedded) && !Supported.HasFlag(WildcardSelection.Embedded)) || - (selection.HasFlag(WildcardSelection.MultiWildcard) && !Supported.HasFlag(WildcardSelection.MultiWildcard)) || - (selection.HasFlag(WildcardSelection.SingleWildcard) && !Supported.HasFlag(WildcardSelection.SingleWildcard)) || - (selection.HasFlag(WildcardSelection.AdjacentWildcards) && !Supported.HasFlag(WildcardSelection.AdjacentWildcards)) || - (selection.HasFlag(WildcardSelection.InvalidCharacter))) - return false; - else - return true; - } + /// + /// Gets the supported . + /// + public WildcardSelection SupportedSelection { get; } - /// - /// Parses the wildcard text to ensure validitity returning a . - /// - /// The wildcard text. - /// The corresponding . - public WildcardResult Parse(string? text) - { - text = Cleaner.Clean(text, StringTrim.Both, Transform); - if (string.IsNullOrEmpty(text)) - return new WildcardResult(this) { Selection = WildcardSelection.None, Text = text }; + /// + /// Gets the .NET multi (zero or more) wildcard character. + /// + /// A value of indicates no multi wildcard support. + public char MultiWildcard { get; } - var sb = new StringBuilder(); - var wr = new WildcardResult(this) { Selection = WildcardSelection.Undetermined }; + /// + /// Gets the .NET single wildcard character. + /// + /// A value of indicates no single wildcard support. + public char SingleWildcard { get; } - if (CharactersNotAllowed != null && CharactersNotAllowed.Count > 0 && text.IndexOfAny([.. CharactersNotAllowed]) >= 0) - wr.Selection |= WildcardSelection.InvalidCharacter; + /// + /// Gets the list of characters that are not allowed. + /// + public IReadOnlyList CharactersNotAllowed { get; } + + /// + /// Gets the that defines the treatment of embedded space ' ' characters within the wildcard. + /// + public WildcardSpaceTreatment SpaceTreatment { get; } + + /// + /// Parses the wildcard text to ensure validity returning a . + /// + /// The wildcard text. + /// The corresponding . + /// Note that any leading or trailing spaces are ignored. + public WildcardResult Parse(string? text) + { + if (string.IsNullOrEmpty(text)) + return new WildcardResult(this) { Selection = WildcardSelection.None, Text = text }; + + text = text.Trim(); + var sb = new StringBuilder(); + var wr = new WildcardResult(this) { Selection = WildcardSelection.Undetermined }; - var hasMulti = SpaceTreatment == WildcardSpaceTreatment.MultiWildcardWhenOthers && Supported.HasFlag(WildcardSelection.MultiWildcard) && text.Contains(MultiWildcardCharacter, StringComparison.InvariantCulture); - var hasTxt = false; + if (CharactersNotAllowed is not null && CharactersNotAllowed.Count > 0 && text.IndexOfAny([.. CharactersNotAllowed]) >= 0) + wr.Selection |= WildcardSelection.InvalidCharacter; + + var hasMulti = SpaceTreatment == WildcardSpaceTreatment.MultiWildcardWhenOthers && SupportedSelection.HasFlag(WildcardSelection.MultiWildcard) && text.Contains(MultiWildcardCharacter, StringComparison.Ordinal); + var hasTxt = false; + + for (int i = 0; i < text.Length; i++) + { + var c = text[i]; + var isMulti = c == MultiWildcard; + var isSingle = c == SingleWildcard; - for (int i = 0; i < text.Length; i++) + if (isMulti) { - var c = text[i]; - var isMulti = c == MultiWildcard; - var isSingle = c == SingleWildcard; + wr.Selection |= WildcardSelection.MultiWildcard; - if (isMulti) + // Skip adjacent multi's as they are redundant. + for (int j = i + 1; j < text.Length; j++) { - wr.Selection |= WildcardSelection.MultiWildcard; + if (text[j] == MultiWildcard) + { + i = j; + wr.Selection |= WildcardSelection.AdjacentWildcards; + continue; + } + + break; + } + } + + if (isSingle) + wr.Selection |= WildcardSelection.SingleWildcard; + + if (isMulti || isSingle) + { + if (text.Length == 1) + wr.Selection |= WildcardSelection.Single; + else if (i == 0) + wr.Selection |= WildcardSelection.EndsWith; + else if (i == text.Length - 1) + wr.Selection |= WildcardSelection.StartsWith; + else + { + if (hasTxt || isSingle) + wr.Selection |= WildcardSelection.Embedded; + else + wr.Selection |= WildcardSelection.EndsWith; + } - // Skip adjacent multi's as they are redundant. + if (i < text.Length - 1 && (text[i + 1] == MultiWildcard || text[i + 1] == SingleWildcard)) + wr.Selection |= WildcardSelection.AdjacentWildcards; + } + else + { + hasTxt = true; + if (c == SpaceCharacter && (SpaceTreatment == WildcardSpaceTreatment.Compress || SpaceTreatment == WildcardSpaceTreatment.MultiWildcardAlways || SpaceTreatment == WildcardSpaceTreatment.MultiWildcardWhenOthers)) + { + // Compress adjacent spaces. + bool skipChar = SpaceTreatment != WildcardSpaceTreatment.Compress && text[i - 1] == MultiWildcardCharacter; for (int j = i + 1; j < text.Length; j++) { - if (text[j] == MultiWildcard) + if (text[j] == SpaceCharacter) { i = j; continue; @@ -193,81 +188,62 @@ public WildcardResult Parse(string? text) break; } - } - if (isSingle) - wr.Selection |= WildcardSelection.SingleWildcard; + if (skipChar || (SpaceTreatment != WildcardSpaceTreatment.Compress && text[i + 1] == MultiWildcardCharacter)) + continue; - if (isMulti || isSingle) - { - if (text.Length == 1) - wr.Selection |= WildcardSelection.Single; - else if (i == 0) - wr.Selection |= WildcardSelection.EndsWith; - else if (i == text.Length - 1) - wr.Selection |= WildcardSelection.StartsWith; - else + if (SpaceTreatment == WildcardSpaceTreatment.MultiWildcardAlways || (SpaceTreatment == WildcardSpaceTreatment.MultiWildcardWhenOthers && hasMulti)) { - if (hasTxt || isSingle) - wr.Selection |= WildcardSelection.Embedded; - else - wr.Selection |= WildcardSelection.EndsWith; + c = MultiWildcardCharacter; + wr.Selection |= WildcardSelection.MultiWildcard; + wr.Selection |= WildcardSelection.Embedded; } - - if (i < text.Length - 1 && (text[i + 1] == MultiWildcard || text[i + 1] == SingleWildcard)) - wr.Selection |= WildcardSelection.AdjacentWildcards; } - else - { - hasTxt = true; - if (c == SpaceCharacter && (SpaceTreatment == WildcardSpaceTreatment.Compress || SpaceTreatment == WildcardSpaceTreatment.MultiWildcardAlways || SpaceTreatment == WildcardSpaceTreatment.MultiWildcardWhenOthers)) - { - // Compress adjacent spaces. - bool skipChar = SpaceTreatment != WildcardSpaceTreatment.Compress && text[i - 1] == MultiWildcardCharacter; - for (int j = i + 1; j < text.Length; j++) - { - if (text[j] == SpaceCharacter) - { - i = j; - continue; - } - - break; - } + } - if (skipChar || (SpaceTreatment != WildcardSpaceTreatment.Compress && text[i + 1] == MultiWildcardCharacter)) - continue; + sb.Append(c); + } - if (SpaceTreatment == WildcardSpaceTreatment.MultiWildcardAlways || (SpaceTreatment == WildcardSpaceTreatment.MultiWildcardWhenOthers && hasMulti)) - { - c = MultiWildcardCharacter; - wr.Selection |= WildcardSelection.MultiWildcard; - wr.Selection |= WildcardSelection.Embedded; - } - } - } + if (!hasTxt && wr.Selection == (WildcardSelection.StartsWith | WildcardSelection.MultiWildcard)) + { + wr.Selection |= WildcardSelection.Single; + wr.Selection ^= WildcardSelection.StartsWith; + } - sb.Append(c); - } + if (hasTxt && wr.Selection.HasFlag(WildcardSelection.StartsWith) && wr.Selection.HasFlag(WildcardSelection.EndsWith) && !wr.Selection.HasFlag(WildcardSelection.Embedded)) + { + wr.Selection |= WildcardSelection.Contains; + wr.Selection ^= WildcardSelection.StartsWith; + wr.Selection ^= WildcardSelection.EndsWith; + } - if (!hasTxt && wr.Selection == (WildcardSelection.StartsWith | WildcardSelection.MultiWildcard)) - { - wr.Selection |= WildcardSelection.Single; - wr.Selection ^= WildcardSelection.StartsWith; - } + if (wr.Selection == WildcardSelection.Undetermined) + wr.Selection |= WildcardSelection.Equal; - if (hasTxt && wr.Selection.HasFlag(WildcardSelection.StartsWith) && wr.Selection.HasFlag(WildcardSelection.EndsWith) && !wr.Selection.HasFlag(WildcardSelection.Embedded)) - { - wr.Selection |= WildcardSelection.Contains; - wr.Selection ^= WildcardSelection.StartsWith; - wr.Selection ^= WildcardSelection.EndsWith; - } + wr.Text = sb.Length == 0 ? null : sb.ToString(); + wr.HasError = !ValidateSelection(wr.Selection); - if (wr.Selection == WildcardSelection.Undetermined) - wr.Selection |= WildcardSelection.Equal; + return wr; + } - wr.Text = sb.Length == 0 ? null : sb.ToString(); - return wr; - } + /// + /// Validates the against what is to ensure validity. + /// + private bool ValidateSelection(WildcardSelection selection) + { + if (selection.HasFlag(WildcardSelection.InvalidCharacter) || + (selection.HasFlag(WildcardSelection.None) && !SupportedSelection.HasFlag(WildcardSelection.None)) || + (selection.HasFlag(WildcardSelection.Equal) && !SupportedSelection.HasFlag(WildcardSelection.Equal)) || + (selection.HasFlag(WildcardSelection.Single) && !SupportedSelection.HasFlag(WildcardSelection.Single)) || + (selection.HasFlag(WildcardSelection.StartsWith) && !SupportedSelection.HasFlag(WildcardSelection.StartsWith)) || + (selection.HasFlag(WildcardSelection.EndsWith) && !SupportedSelection.HasFlag(WildcardSelection.EndsWith)) || + (selection.HasFlag(WildcardSelection.Contains) && !SupportedSelection.HasFlag(WildcardSelection.Contains)) || + (selection.HasFlag(WildcardSelection.Embedded) && !SupportedSelection.HasFlag(WildcardSelection.Embedded)) || + (selection.HasFlag(WildcardSelection.MultiWildcard) && !SupportedSelection.HasFlag(WildcardSelection.MultiWildcard)) || + (selection.HasFlag(WildcardSelection.SingleWildcard) && !SupportedSelection.HasFlag(WildcardSelection.SingleWildcard)) || + (selection.HasFlag(WildcardSelection.AdjacentWildcards) && !SupportedSelection.HasFlag(WildcardSelection.AdjacentWildcards))) + return false; + else + return true; } } \ No newline at end of file diff --git a/src/CoreEx/Wildcards/WildcardResult.cs b/src/CoreEx/Wildcards/WildcardResult.cs index d131b210..3ca199e4 100644 --- a/src/CoreEx/Wildcards/WildcardResult.cs +++ b/src/CoreEx/Wildcards/WildcardResult.cs @@ -1,102 +1,96 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Wildcards; -using System; -using System.Text.RegularExpressions; - -namespace CoreEx.Wildcards +/// +/// Represents the result. +/// +public class WildcardResult { /// - /// Represents the result. + /// Initialize a new instance of the class. + /// + /// The originating configuration. + internal WildcardResult(Wildcard wildcard) => Wildcard = wildcard; + + /// + /// Gets the originating configuration. + /// + internal Wildcard Wildcard { get; private set; } + + /// + /// Gets the resulting . + /// + public WildcardSelection Selection { get; internal set; } + + /// + /// Gets the updated wildcard text. + /// + public string? Text { get; internal set; } + + /// + /// Indicates whether the contains one or more non- errors. + /// + public bool HasError { get; internal set;} + + /// + /// Gets the with all the wildcard characters removed. + /// + public string? GetTextWithoutWildcards() + { + var s = Text; + if (Selection.HasFlag(WildcardSelection.MultiWildcard)) + s = s!.Replace(new string(Wildcard.MultiWildcard, 1), string.Empty, StringComparison.InvariantCulture); + + if (Selection.HasFlag(WildcardSelection.SingleWildcard)) + s = s!.Replace(new string(Wildcard.SingleWildcard, 1), string.Empty, StringComparison.InvariantCulture); + + return s; + } + + /// + /// Throws an where result is . + /// + /// The current instance to enable method-chaining. + public WildcardResult ThrowOnError() => HasError ? throw new InvalidOperationException("Wildcard selection text is not supported.") : this; + + /// + /// Creates the corresponding for the wildcard text. + /// + /// Indicates whether the regular expression should ignore case (default) or not. + /// The corresponding . + /// Throws an where result is . + public Regex CreateRegex(bool ignoreCase = true) => CreateRegex(ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None); + + /// + /// Creates the corresponding for the wildcard text. /// - public class WildcardResult + /// The . + /// The corresponding . + /// Throws an where result is . + public Regex CreateRegex(RegexOptions options) { - /// - /// Initialize a new instance of the class. - /// - /// The originating configuration. - internal WildcardResult(Wildcard wildcard) => Wildcard = wildcard; - - /// - /// Gets the originating configuration. - /// - internal Wildcard Wildcard { get; private set; } - - /// - /// Gets the resulting . - /// - public WildcardSelection Selection { get; internal set; } - - /// - /// Gets the updated wildcard text. - /// - public string? Text { get; internal set; } - - /// - /// Indicates whether the contains one or more non- errors. - /// - public bool HasError => !Wildcard.Validate(Selection); - - /// - /// Gets the with all the wildcard characters removed. - /// - public string? GetTextWithoutWildcards() - { - var s = Text; - if (Selection.HasFlag(WildcardSelection.MultiWildcard)) - s = s!.Replace(new string(Wildcard.MultiWildcard, 1), string.Empty, StringComparison.InvariantCulture); - - if (Selection.HasFlag(WildcardSelection.SingleWildcard)) - s = s!.Replace(new string(Wildcard.SingleWildcard, 1), string.Empty, StringComparison.InvariantCulture); - - return s; - } - - /// - /// Throws an where result is true. - /// - /// The current instance to enable method-chaining. - public WildcardResult ThrowOnError() => HasError ? throw new InvalidOperationException("Wildcard selection text is not supported.") : this; - - /// - /// Creates the corresponding for the wildcard text. - /// - /// Indicates whether the regular expression should ignore case (default) or not. - /// The corresponding . - /// Throws an where result is true. - public Regex CreateRegex(bool ignoreCase = true) => CreateRegex(ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None); - - /// - /// Creates the corresponding for the wildcard text. - /// - /// The . - /// The corresponding . - /// Throws an where result is true. - public Regex CreateRegex(RegexOptions options) - { - ThrowOnError(); - return new Regex(GetRegexPattern(), options); - } - - /// - /// Gets the corresponding regular expression pattern for the wildcard text. - /// - /// The corresponding pattern. - /// Throws an where result is true. - public string GetRegexPattern() - { - ThrowOnError(); - - if (Selection.HasFlag(WildcardSelection.Single)) - return Selection.HasFlag(WildcardSelection.MultiWildcard) ? "^.*$" : "^.$"; - - var p = Regex.Escape(Text!); - if (Selection.HasFlag(WildcardSelection.MultiWildcard)) - p = p.Replace("\\*", ".*", StringComparison.InvariantCulture); - - if (Selection.HasFlag(WildcardSelection.SingleWildcard)) - p = p.Replace("\\?", ".", StringComparison.InvariantCulture); - - return $"^{p}$"; - } + ThrowOnError(); + return new Regex(GetRegexPattern(), options); + } + + /// + /// Gets the corresponding regular expression pattern for the wildcard text. + /// + /// The corresponding pattern. + /// Throws an where result is . + public string GetRegexPattern() + { + ThrowOnError(); + + if (Selection.HasFlag(WildcardSelection.Single)) + return Selection.HasFlag(WildcardSelection.MultiWildcard) ? "^.*$" : "^.$"; + + var p = Regex.Escape(Text!); + if (Selection.HasFlag(WildcardSelection.MultiWildcard)) + p = p.Replace("\\*", ".*", StringComparison.InvariantCulture); + + if (Selection.HasFlag(WildcardSelection.SingleWildcard)) + p = p.Replace("\\?", ".", StringComparison.InvariantCulture); + + return $"^{p}$"; } } \ No newline at end of file diff --git a/src/CoreEx/Wildcards/WildcardSelection.cs b/src/CoreEx/Wildcards/WildcardSelection.cs index 3137a059..bc88b202 100644 --- a/src/CoreEx/Wildcards/WildcardSelection.cs +++ b/src/CoreEx/Wildcards/WildcardSelection.cs @@ -1,88 +1,83 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Wildcards; -using System; - -namespace CoreEx.Wildcards +/// +/// Represents the wildcard selection. +/// +[Flags] +public enum WildcardSelection { /// - /// Represents the wildcard selection. + /// Indicates that the wildcard selection is undetermined. /// - [Flags] - public enum WildcardSelection - { - /// - /// Indicates that the wildcard selection is undetermined. - /// - Undetermined, + Undetermined, - /// - /// Indicates that there was no selection; i.e the text was null or empty (see ). - /// - None = 1, + /// + /// Indicates that there was no selection; i.e the text was null or empty (see ). + /// + None = 1, - /// - /// Indicates that no wildcard characters were found and an equal operation should be performed. - /// - Equal = 2, + /// + /// Indicates that no wildcard characters were found and an equal operation should be performed. + /// + Equal = 2, - /// - /// Indicates a single wildcard character (e.g. '*' or '?'). - /// - Single = 4, + /// + /// Indicates a single wildcard character (e.g. '*' or '?'). + /// + Single = 4, - /// - /// Indicates the selection contains a starts with operation (e.g. 'xxx*', 'xxx?', 'xx*x*', etc). - /// - StartsWith = 8, + /// + /// Indicates the selection contains a starts with operation (e.g. 'xxx*', 'xxx?', 'xx*x*', etc). + /// + StartsWith = 8, - /// - /// Indicates the selection contains an ends with operation (e.g. '*xxx', '?xxx', '?x*xx', etc). - /// - EndsWith = 16, + /// + /// Indicates the selection contains an ends with operation (e.g. '*xxx', '?xxx', '?x*xx', etc). + /// + EndsWith = 16, - /// - /// Indicates the selection contains both a and (with no ) operation (e.g. '*xxx*', '?xxx*', etc). - /// - Contains = 32, + /// + /// Indicates the selection contains both a and (with no ) operation (e.g. '*xxx*', '?xxx*', etc). + /// + Contains = 32, - /// - /// Indicates the selection contains an embedded operation (e.g. 'xx*xx', '*xx*xx*', 'xx?xx*', etc). - /// - Embedded = 64, + /// + /// Indicates the selection contains an embedded operation (e.g. 'xx*xx', '*xx*xx*', 'xx?xx*', etc). + /// + Embedded = 64, - /// - /// Indicates the selection contains at least one instance of the character. - /// - MultiWildcard = 128, + /// + /// Indicates the selection contains at least one instance of the character. + /// + MultiWildcard = 128, - /// - /// Indicates the selection contains at least one instance of the character. - /// - SingleWildcard = 256, + /// + /// Indicates the selection contains at least one instance of the character. + /// + SingleWildcard = 256, - /// - /// Indicates the selection contains adjacent (side-by-side) wildcard characters (e.g. '*?', 'xx**xx', 'xxx**', etc. - /// - AdjacentWildcards = 512, + /// + /// Indicates the selection contains adjacent (side-by-side) wildcard characters (e.g. '*?', 'xx**xx', 'xxx**', etc. + /// + AdjacentWildcards = 512, - /// - /// Indicates the selection contains one or more invalid characters (see ). - /// - InvalidCharacter = 1024, + /// + /// Indicates the selection contains one or more invalid characters (see ). + /// + InvalidCharacter = 1024, - /// - /// Represents the and all selections; excludes and . - /// - BothAll = None | Equal | Single | StartsWith | EndsWith | Contains | Embedded | MultiWildcard | SingleWildcard | AdjacentWildcards, + /// + /// Represents the and all selections; excludes and . + /// + BothAll = None | Equal | Single | StartsWith | EndsWith | Contains | Embedded | MultiWildcard | SingleWildcard | AdjacentWildcards, - /// - /// Represents the basic selections; includes , , , and . - /// - MultiBasic = None | Equal | Single | StartsWith | EndsWith | Contains | MultiWildcard | AdjacentWildcards, + /// + /// Represents the basic selections; includes , , , and . + /// + MultiBasic = None | Equal | Single | StartsWith | EndsWith | Contains | MultiWildcard | AdjacentWildcards, - /// - /// Represents the all selections; includes and . - /// - MultiAll = MultiBasic | Embedded - } + /// + /// Represents the all selections; includes and . + /// + MultiAll = MultiBasic | Embedded } \ No newline at end of file diff --git a/src/CoreEx/Wildcards/WildcardSpaceTreatment.cs b/src/CoreEx/Wildcards/WildcardSpaceTreatment.cs index 77f2eaff..6f889b5d 100644 --- a/src/CoreEx/Wildcards/WildcardSpaceTreatment.cs +++ b/src/CoreEx/Wildcards/WildcardSpaceTreatment.cs @@ -1,34 +1,31 @@ -// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +namespace CoreEx.Wildcards; -namespace CoreEx.Wildcards +/// +/// Defines the treatment of embedded within the wildcard. +/// +/// Note: leading and trailing spaces are always removed. +public enum WildcardSpaceTreatment { /// - /// Defines the treatment of embedded within the wildcard. + /// Indicates that no treatment is to be performed; leave as is. /// - /// Note: leading and trailing spaces are always removed. - public enum WildcardSpaceTreatment - { - /// - /// Indicates that no treatment is to be performed; leave as is. - /// - None, + None, - /// - /// Indicates that multiple adjacent embedded are compressed into a single space. - /// - /// Example where underscope represents space visually: 'XX___XX' -> 'XX_XX'. - Compress, + /// + /// Indicates that multiple adjacent embedded are compressed into a single space. + /// + /// Example where underscope represents space visually: 'XX___XX' -> 'XX_XX'. + Compress, - /// - /// Indicates that the embedded are always compressed and converted to the multi-wildcard character. - /// - /// Examples where underscope represents space visually: 'XX___XX' -> 'XX*XX' and 'XX___XX*' -> 'XX*XX*'. - MultiWildcardAlways, + /// + /// Indicates that the embedded are always compressed and converted to the multi-wildcard character. + /// + /// Examples where underscope represents space visually: 'XX___XX' -> 'XX*XX' and 'XX___XX*' -> 'XX*XX*'. + MultiWildcardAlways, - /// - /// Indicates that the embedded are compressed and converted to the multi-wildcard character only where other multi-wildcards are found. - /// - /// Examples where underscope represents space visually: 'XX___XX' -> 'XX___XX' and 'XX___XX*' -> 'XX*XX*'. - MultiWildcardWhenOthers - } + /// + /// Indicates that the embedded are compressed and converted to the multi-wildcard character only where other multi-wildcards are found. + /// + /// Examples where underscope represents space visually: 'XX___XX' -> 'XX___XX' and 'XX___XX*' -> 'XX*XX*'. + MultiWildcardWhenOthers } \ No newline at end of file diff --git a/src/CoreEx/strong-name-key.snk b/src/CoreEx/strong-name-key.snk deleted file mode 100644 index 5bced3906b9de4a27f90758305b30ed16b1c5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097f?o$f(*a46&Kxkf^(4*Ly9ozLqzCQL8 ze4lx`&2(hhFTIrh<4%ZNH{;**>J_B`(L zg2w#VAib4t`Xlh$Ungys?-x~y+Wvh;xRN67N}>kKsDKiPB*9Gb7j$PhcmQ=c&vKYs zYL8fK??~H;uqL2sxUhx*XOZqD()r`xgYyBHFhtcmx zuFz5&;wc29C`~$WpcEFk`IDWAVMFO_rZ|ClrE@jhq)yI?FC^;2`{-+TQ=dqDZn<_f zpR07zgt%W`u586(DhW&RiBZp%9klaKr>nIxgi$GNxEzgifyIE5#vcv12~t4f%y?2C zyPgfZL#XP7%!Awfy`;)A6cHg>7KRRs>#5 z2I-~)zZO-3_H1R-IV4e+D>_DCrav32(SMsi(9l&PtnFm8Hf7X>cyo?*41FrD5lbJf ipiUG-DI^tR9Mcea^C%Jh+%U*&_-v6R3hAfG$wP6upd;x3 diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 00000000..d50a69f6 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,11 @@ + + + net8.0;net9.0;net10.0 + 4.0.0-preview-1 + enable + enable + preview + true + true + + \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 00000000..efd5f873 --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,9 @@ + + + true + snupkg + true + true + MIT + + \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Api/Controllers/OtherController.cs b/tests/CoreEx.AspNetCore.Test.Api/Controllers/OtherController.cs new file mode 100644 index 00000000..d952f96c --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/Controllers/OtherController.cs @@ -0,0 +1,33 @@ +using CoreEx.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; + +namespace CoreEx.AspNetCore.Test.Api.Controllers; + +[ApiController, Route("api/other")] +public class OtherController(WebApi webApi) : Controller +{ + [HttpGet("messages")] + [ProducesResponseType(204)] + public IActionResult Messages() + { + // Bypasses WebApi but should be picked up by the ExecutionContextMiddleware. + ExecutionContext.Current.AddWarningMessage("Please pay your invoice."); + return NoContent(); + } + + [HttpPost("unhandledexception")] + public IActionResult UnhandledException() => throw new Exception("Oh no, that was unexpected!"); + + [HttpPost("unhandledextendedexception")] + public IActionResult UnhandledExtendedException() => throw new DuplicateException("Oh my, we have one of those already!"); + + [HttpPost("idempotency-mvc/{id}")] + [IdempotencyKey] + public async Task IdempotencyWebApiSuccess(int id) => await webApi.PostAsync(Request, (ro, _) => + { + if (id == 88) + throw new NotFoundException(); + + return Task.FromResult(new { Id = id, Name = id == 99 ? new string('x', 512 * 1024) : "Bob" }); + }); +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Api/Controllers/PersonController.cs b/tests/CoreEx.AspNetCore.Test.Api/Controllers/PersonController.cs new file mode 100644 index 00000000..8951a3bf --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/Controllers/PersonController.cs @@ -0,0 +1,52 @@ +using CoreEx.AspNetCore.Mvc; +using CoreEx.AspNetCore.Test.Api.Entities; +using CoreEx.AspNetCore.Test.Api.Services; +using CoreEx.Http; +using Microsoft.AspNetCore.Mvc; + +namespace CoreEx.AspNetCore.Test.Api.Controllers; + +[ApiController, Route("api/people")] +public class PersonController(WebApi webApi, PersonService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly PersonService _service = service.ThrowIfNull(); + + [HttpGet("{id}"), HttpHead("{id}")] + [ProducesResponseType(typeof(Person), 200)] + [ProducesNotFoundProblem()] + public Task GetAsync(string id) => _webApi.GetAsync(Request, (ro, _) => _service.GetAsync(id)); + + [HttpGet] + [ProducesResponseType(typeof(Person[]), 200)] + [Query(true, true)] + [Paging(true)] + public Task GetItemsAsync() => _webApi.GetAsync(Request, (ro, _) => _service.GetByQueryAsync(ro.QueryArgs, ro.PagingArgs)); + + [HttpPost] + [Accepts] + [ProducesResponseType(201)] + public Task PostAsync() => _webApi.PostAsync(Request, async (ro, ct) => + { + ro.WithLocationUri(p => new Uri($"api/people/{p.Id}", UriKind.Relative)); + return await _service.CreateAsync(ro.Value).ConfigureAwait(false); + }); + + [HttpPut("{id}")] + [Accepts] + [ProducesResponseType(200)] + [ProducesNotFoundProblem()] + public Task PutAsync(string id) => _webApi.PutAsync(Request, (ro, ct) => _service.UpdateAsync(ro.Value.Adjust(p => p.Id = id))); + + [HttpPatch("{id}")] + [Accepts(HttpNames.MergePatchJsonMediaTypeName)] + [ProducesResponseType(typeof(Person), 200)] + [ProducesNotFoundProblem()] + public Task PatchAsync(string id) => _webApi.PatchAsync(Request, + get: (ro, _) => _service.GetAsync(id), + put: (ro, _) => _service.UpdateAsync(ro.Value.Adjust(p => p.Id = id))); + + [HttpDelete("{id}")] + [ProducesResponseType(204)] + public Task DeleteAsync(string id) => _webApi.DeleteAsync(Request, (ro, _) => _service.DeleteAsync(id)); +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Api/Controllers/ReferenceDataController.cs b/tests/CoreEx.AspNetCore.Test.Api/Controllers/ReferenceDataController.cs new file mode 100644 index 00000000..a15fde4d --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/Controllers/ReferenceDataController.cs @@ -0,0 +1,17 @@ +using CoreEx.AspNetCore.Mvc; +using CoreEx.AspNetCore.Test.Api.Entities; +using CoreEx.RefData; +using Microsoft.AspNetCore.Mvc; + +namespace CoreEx.AspNetCore.Test.Api.Controllers; + +[ApiController, Route("api/refdata")] +public class ReferenceDataController(WebApi webApi) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + + [HttpGet("genders"), HttpHead("genders")] + [ProducesResponseType(typeof(Gender[]), 200)] + public Task GetGendersAsync([FromQuery] IEnumerable? codes = default, string? text = default) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct)); +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Api/CoreEx.AspNetCore.Test.Api.csproj b/tests/CoreEx.AspNetCore.Test.Api/CoreEx.AspNetCore.Test.Api.csproj new file mode 100644 index 00000000..b0b6b798 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/CoreEx.AspNetCore.Test.Api.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + false + preview + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CoreEx.AspNetCore.Test.Api/CoreEx.AspNetCore.Test.Api.http b/tests/CoreEx.AspNetCore.Test.Api/CoreEx.AspNetCore.Test.Api.http new file mode 100644 index 00000000..49eb1c9c --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/CoreEx.AspNetCore.Test.Api.http @@ -0,0 +1,6 @@ +@CoreEx.AspNetCore.Test.Api_HostAddress = http://localhost:5096 + +GET {{CoreEx.AspNetCore.Test.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/tests/CoreEx.AspNetCore.Test.Api/Entities/Gender.cs b/tests/CoreEx.AspNetCore.Test.Api/Entities/Gender.cs new file mode 100644 index 00000000..d56f5820 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/Entities/Gender.cs @@ -0,0 +1,8 @@ +using CoreEx.RefData; + +namespace CoreEx.AspNetCore.Test.Api.Entities; + +[ReferenceData] +public partial class Gender : ReferenceData { } + +public class GenderCollection : ReferenceDataCollection { } diff --git a/tests/CoreEx.AspNetCore.Test.Api/Entities/Person.cs b/tests/CoreEx.AspNetCore.Test.Api/Entities/Person.cs new file mode 100644 index 00000000..f1b345b0 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/Entities/Person.cs @@ -0,0 +1,23 @@ +using CoreEx.Entities; +using CoreEx.RefData; + +namespace CoreEx.AspNetCore.Test.Api.Entities; + +[Contract] +public partial class Person : IIdentifier, IETag, IChangeLog +{ + public string? Id { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public DateOnly? Birthday { get; set; } + + [ReferenceData] + public partial string? GenderSid { get; set; } + + public string? ETag { get; set; } + + public ChangeLog? ChangeLog { get; set; } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Api/Program.cs b/tests/CoreEx.AspNetCore.Test.Api/Program.cs new file mode 100644 index 00000000..5697d335 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/Program.cs @@ -0,0 +1,118 @@ +using CoreEx.AspNetCore.Http; +using CoreEx.AspNetCore.NSwag; +using CoreEx.AspNetCore.Test.Api.Entities; +using CoreEx.AspNetCore.Test.Api.Services; +using CoreEx.Http; +using CoreEx.RefData; +using Microsoft.AspNetCore.Mvc; +using OpenTelemetry; + +namespace CoreEx.AspNetCore.Test.Api; + +public class Program +{ + private static void Main(string[] args) + { + // Create the web builder. + var builder = WebApplication.CreateBuilder(args); + + // Add CoreEx host settings. + builder.AddHostSettings(); + + // Add CoreEx services. + builder.Services + .AddExecutionContext() + .AddReferenceDataOrchestrator() + .AddMvcWebApi() + .AddHttpWebApi(); + + // Add all the dynamically registered services. + builder.Services.AddDynamicServicesUsing(); + + // Add in-memory based idempotency provider. + builder.Services + .AddMemoryCache() + .AddMemoryOnlyHybridCache() + .AddHybridCacheIdempotencyProvider(); + + // Add the ASP.NET Core services. + builder.Services.AddControllers(); + + // Add the OpenAPI services. + builder.Services.AddOpenApiDocument(s => + { + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); + }); + + // Add OpenTelemetry tracing. + builder.WithCoreExTelemetry() + .UseOtlpExporter(); + + // Build the application. + var app = builder.Build(); + + // Configure the pipeline/middleware (order is important). + app.UseCoreExExceptionHandler(); + app.UseHttpsRedirection(); + app.UseAuthorization(); + app.UseExecutionContext(); + app.UseIdempotencyKey(); + app.MapControllers(); + + app.UseOpenApi(); + app.UseSwaggerUi(); + + app.MapHealthChecks(); + + // Minimal APIs. + app.MapGet("api/persons/{id}", + (HttpRequest request, WebApi webApi, PersonService2 service, string id) + => webApi.GetWithResultAsync(request, (ro, _) => service.GetAsync(id))) + .Produces().ProducesNotFoundProblem(); + + app.MapGet("api/persons", + (HttpRequest request, WebApi webApi, PersonService2 service) + => webApi.GetWithResultAsync(request, (ro, _) => service.GetByQueryAsync(ro.QueryArgs, ro.PagingArgs))) + .Produces().WithQuery(true, true).WithPaging(true); + + app.MapPost("api/persons", + (HttpRequest request, WebApi webApi, PersonService2 service) + => webApi.PostWithResultAsync(request, async (ro, _) => + { + ro.WithLocationUri(p => new Uri($"api/persons/{p.Id}", UriKind.Relative)); + return await service.CreateAsync(ro.Value).ConfigureAwait(false); + })) + .Accepts().ProducesCreated(); + + app.MapPut("api/persons/{id}", + (HttpRequest request, WebApi webApi, PersonService2 service, string id) + => webApi.PutWithResultAsync(request, (ro, _) => service.UpdateAsync(ro.Value.Adjust(p => p.Id = id)))) + .Accepts().Produces().ProducesNotFoundProblem(); + + app.MapPatch("api/persons/{id}", + (HttpRequest request, WebApi webApi, PersonService2 service, string id) + => webApi.PatchWithResultAsync(request, + get: (ro, _) => service.GetAsync(id), + put: (ro, _) => service.UpdateAsync(ro.Value.Adjust(p => p.Id = id)))) + .Accepts(HttpNames.MergePatchJsonMediaTypeName).Produces().ProducesNotFoundProblem(); + + app.MapDelete("api/persons/{id}", + (HttpRequest request, WebApi webApi, PersonService2 service, string id) + => webApi.DeleteWithResultAsync(request, (ro, _) => service.DeleteAsync(id))) + .ProducesNoContent(); + + app.MapGet("api/referencedata/genders", + (HttpRequest request, WebApi webApi, [FromQuery] string[]? codes = default, string? text = default) + => webApi.GetAsync(request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct))) + .Produces(); + + app.MapPost("api/idempotency-key/test/{id}", + (HttpRequest request, WebApi webApi, int id) + => webApi.PostAsync(request, (_, _) => Task.FromResult(new { Id = id, Name = "Jen" }))) + .WithIdempotencyKey(isRequired: true); + + // Run the application. + app.Run(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.TestApi/Properties/launchSettings.json b/tests/CoreEx.AspNetCore.Test.Api/Properties/launchSettings.json similarity index 52% rename from tests/CoreEx.TestApi/Properties/launchSettings.json rename to tests/CoreEx.AspNetCore.Test.Api/Properties/launchSettings.json index 35ef12f5..6738b684 100644 --- a/tests/CoreEx.TestApi/Properties/launchSettings.json +++ b/tests/CoreEx.AspNetCore.Test.Api/Properties/launchSettings.json @@ -1,30 +1,41 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:53660", - "sslPort": 44380 - } - }, "profiles": { - "IIS Express": { - "commandName": "IISExpress", + "http": { + "commandName": "Project", "launchBrowser": true, - "launchUrl": "products", + "launchUrl": "weatherforecast", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - } + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5096" }, - "CoreEx.TestApi": { + "https": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "http://localhost:5096/swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7004;http://localhost:5096" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52431/", + "sslPort": 44368 } } } \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService.cs b/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService.cs new file mode 100644 index 00000000..08aacf15 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService.cs @@ -0,0 +1,108 @@ +using CoreEx.AspNetCore.Test.Api.Entities; +using CoreEx.Data; +using CoreEx.Data.Querying; +using CoreEx.DependencyInjection; +using CoreEx.Validation; +using System.Collections.Concurrent; + +namespace CoreEx.AspNetCore.Test.Api.Services; + +[ScopedService] +public class PersonService +{ + internal static QueryArgsConfig QueryConfig { get; } = QueryArgsConfig.Create() + .WithFilter(f => f + .AddField("FirstName", c => c.AsLowerCase().WithOperators(QueryFilterOperator.AllStringOperators)) + .AddField("LastName", c => c.AsLowerCase().WithOperators(QueryFilterOperator.AllStringOperators)) + .AddField("Birthday") + .AddReferenceDataField("Gender", "GenderSid")) + .WithOrderBy(o => o + .AddField("LastName", c => c.WithDefault()) + .AddField("FirstName", c => c.WithDefault())); + + private static ConcurrentDictionary _people = Reset(); + + public static ConcurrentDictionary Reset() => _people = new() + { + ["1"] = new Person + { + Id = "1", + FirstName = "John", + LastName = "Doe", + Birthday = new DateOnly(1980, 1, 1), + GenderSid = "M", + ETag = Runtime.NewId() + }, + ["2"] = new Person + { + Id = "2", + FirstName = "Jane", + LastName = "Doe", + Birthday = new DateOnly(1985, 2, 2), + GenderSid = "F", + ETag = Runtime.NewId() + }, + ["3"] = new Person + { + Id = "3", + FirstName = "Alice", + LastName = "Smith", + Birthday = new DateOnly(1990, 3, 3), + GenderSid = "F", + ETag = Runtime.NewId() + }, + ["4"] = new Person + { + Id = "4", + FirstName = "Bob", + LastName = "Smith", + Birthday = new DateOnly(1995, 4, 4), + GenderSid = "M", + ETag = Runtime.NewId() + } + }; + + public Task GetAsync(string id) => Task.FromResult(_people.TryGetValue(id.Required(), out var person) ? person : null); + + public Task CreateAsync(Person person) + { + person.ETag = Guid.NewGuid().ToString(); + if (!_people.TryAdd(person.Id!, person)) + throw new ConflictException(); + + return Task.FromResult(person); + } + + public Task UpdateAsync(Person person) + { + person.ThrowIfNull(); + person.Id.ThrowIfNull(); + + if (!_people.TryGetValue(person.Id!, out var existing)) + throw new NotFoundException(); + + if (existing.ETag != person.ETag) + throw new ConcurrencyException(); + + person.ETag = Guid.NewGuid().ToString(); + _people[person.Id!] = person; + return Task.FromResult(person); + } + + public Task DeleteAsync(string id) + { + _people.TryRemove(id.Required(), out _); + return Task.CompletedTask; + } + + public Task> GetByQueryAsync(QueryArgs query, PagingArgs paging) + { + var ir = new ItemsResult(paging) + { + Items = [.. _people.Values.AsQueryable().Where(QueryConfig, query).WithPaging(paging).OrderBy(QueryConfig, query)] + }; + + ir.WithTotalCount(() => _people.Count); + return Task.FromResult(ir); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService2.cs b/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService2.cs new file mode 100644 index 00000000..1abc4062 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService2.cs @@ -0,0 +1,99 @@ +using CoreEx.AspNetCore.Test.Api.Entities; +using CoreEx.Data; +using CoreEx.Data.Querying; +using CoreEx.DependencyInjection; +using CoreEx.Results; +using CoreEx.Validation; +using System.Collections.Concurrent; + +namespace CoreEx.AspNetCore.Test.Api.Services; + +[ScopedService] +public class PersonService2 +{ + private static ConcurrentDictionary _people = Reset(); + + public static ConcurrentDictionary Reset() => _people = new() + { + ["1"] = new Person + { + Id = "1", + FirstName = "John", + LastName = "Doe", + Birthday = new DateOnly(1980, 1, 1), + GenderSid = "M", + ETag = Runtime.NewId() + }, + ["2"] = new Person + { + Id = "2", + FirstName = "Jane", + LastName = "Doe", + Birthday = new DateOnly(1985, 2, 2), + GenderSid = "F", + ETag = Runtime.NewId() + }, + ["3"] = new Person + { + Id = "3", + FirstName = "Alice", + LastName = "Smith", + Birthday = new DateOnly(1990, 3, 3), + GenderSid = "F", + ETag = Runtime.NewId() + }, + ["4"] = new Person + { + Id = "4", + FirstName = "Bob", + LastName = "Smith", + Birthday = new DateOnly(1995, 4, 4), + GenderSid = "M", + ETag = Runtime.NewId() + } + }; + + public Task> GetAsync(string id) => Task.FromResult(Result.Ok(_people.TryGetValue(id, out var person) ? person : null)); + + public Task> CreateAsync(Person person) + { + person.ETag = Guid.NewGuid().ToString(); + if (!_people.TryAdd(person.Id!, person)) + return Task.FromResult(Result.ConflictError()); + + return Task.FromResult(Result.Ok(person)); + } + + public Task> UpdateAsync(Person person) + { + person.ThrowIfNull(); + person.Id.ThrowIfNull(); + + if (!_people.TryGetValue(person.Id!, out var existing)) + return Task.FromResult(Result.NotFoundError()); + + if (existing.ETag != person.ETag) + return Task.FromResult(Result.ConcurrencyError()); + + person.ETag = Guid.NewGuid().ToString(); + _people[person.Id!] = person; + return Task.FromResult(Result.Ok(person)); + } + + public Task DeleteAsync(string id) + { + _people.TryRemove(id.Required(), out _); + return Result.SuccessTask; + } + + public Task>> GetByQueryAsync(QueryArgs query, PagingArgs paging) + { + var ir = new ItemsResult(paging) + { + Items = [.. _people.Values.AsQueryable().Where(PersonService.QueryConfig, query).WithPaging(paging).OrderBy(PersonService.QueryConfig, query)] + }; + + ir.WithTotalCount(() => _people.Count); + return Result.Go(ir).AsTask(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Api/Services/ReferenceDataService.cs b/tests/CoreEx.AspNetCore.Test.Api/Services/ReferenceDataService.cs new file mode 100644 index 00000000..84429313 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/Services/ReferenceDataService.cs @@ -0,0 +1,22 @@ +using CoreEx.AspNetCore.Test.Api.Entities; +using CoreEx.DependencyInjection; +using CoreEx.RefData; +using CoreEx.RefData.Abstractions; +using CoreEx.Results; + +namespace CoreEx.AspNetCore.Test.Api.Services; + +[ScopedService] +public class ReferenceDataService : IReferenceDataProvider +{ + public IEnumerable<(Type, Type)> Types => + [ + (typeof(Gender), typeof(GenderCollection)) + ]; + + public Task GetAsync(Type type, CancellationToken cancellationToken = default) => type switch + { + _ when type == typeof(Gender) => Task.FromResult(new GenderCollection { { new Gender { Id = "F", Code = "F", Text = "Female" } }, { new Gender { Id = "M", Code = "M", Text = "Male" } }, { new Gender { Id = "X", Code = "X", Text = "Xxx", IsInactive = true } } }), + _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.") + }; +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Api/appsettings.Development.json b/tests/CoreEx.AspNetCore.Test.Api/appsettings.Development.json new file mode 100644 index 00000000..81dff607 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "CoreEx": { + "IncludeExceptionInProblemDetails": true + } +} diff --git a/tests/CoreEx.AspNetCore.Test.Api/appsettings.json b/tests/CoreEx.AspNetCore.Test.Api/appsettings.json new file mode 100644 index 00000000..236927b9 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Api/appsettings.json @@ -0,0 +1,15 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "CoreEx", + "DomainName": "Test" + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/CoreEx.AspNetCore.Test.Unit.csproj b/tests/CoreEx.AspNetCore.Test.Unit/CoreEx.AspNetCore.Test.Unit.csproj new file mode 100644 index 00000000..455716d2 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/CoreEx.AspNetCore.Test.Unit.csproj @@ -0,0 +1,56 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/OtherApiTests.cs b/tests/CoreEx.AspNetCore.Test.Unit/OtherApiTests.cs new file mode 100644 index 00000000..ca1a9841 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/OtherApiTests.cs @@ -0,0 +1,275 @@ +using CoreEx.AspNetCore.Idempotency; +using CoreEx.Caching; +using CoreEx.Http; +using Microsoft.AspNetCore.Mvc; +using System.Net; +using System.Text.Json; +using UnitTestEx.Expectations; + +namespace CoreEx.AspNetCore.Test.Unit; + +[TestFixture] +public class OtherApiTests : WithApiTester +{ + [Test] + public void ExecutionContextMiddleware_Messages() + { + Test.Http() + .Run(HttpMethod.Get, "/api/other/messages") + .AssertNoContent() + .Response.Headers.Should().ContainKey(HttpNames.WarningMessagesHeaderName) + .WhoseValue.Should().BeEquivalentTo("Please pay your invoice."); + } + + [Test] + public async Task UnhandledException_ProblemHandling() + { + var ra = Test.Http() + .Run(HttpMethod.Post, "/api/other/unhandledexception", hrm => + { + hrm.Headers.Add("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"); + hrm.Headers.Add("tracestate", "foo=bar"); + }) + .Assert(HttpStatusCode.InternalServerError) + .AssertJson("{\"title\":\"Oh no, that was unexpected!\",\"status\":500}", pathsToIgnore: ["type", "traceId", "errorCode", "detail"]); + + var cpd = await ra.Response.ToProblemDetailsAsync(); + cpd.Should().NotBeNull(); + cpd.ProblemDetails.Type.Should().NotBeNull(); + cpd.ProblemDetails.Title.Should().Be("Oh no, that was unexpected!"); + cpd.ProblemDetails.Status.Should().Be(500); + cpd.ProblemDetails.Extensions.Should().ContainKey("traceId").WhoseValue.Should().BeOfType(); + cpd.ProblemDetails.Extensions["traceId"].As().ToString().Should().StartWith("00-0af7651916cd43dd8448eb211c80319c-"); + + var pd = ra.GetValue(System.Net.Mime.MediaTypeNames.Application.ProblemJson); + pd.Should().NotBeNull(); + pd.Type.Should().NotBeNull(); + pd.Extensions.Should().ContainKey("traceId").WhoseValue.Should().BeOfType(); + pd.Extensions["traceId"].As().ToString().Should().StartWith("00-0af7651916cd43dd8448eb211c80319c-"); + } + + [Test] + public async Task UnhandledExtendedException_ProblemHandling() + { + var ra = Test.Http() + .Run(HttpMethod.Post, "/api/other/unhandledextendedexception", hrm => + { + hrm.Headers.Add("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"); + hrm.Headers.Add("tracestate", "foo=bar"); + }) + .Assert(HttpStatusCode.Conflict) + .AssertJson("{\"title\":\"Oh my, we have one of those already!\",\"status\":409,\"errorType\":\"duplicate\"}", pathsToIgnore: ["type", "traceId", "errorCode", "detail"]); + + var cpd = await ra.Response.ToProblemDetailsAsync(); + cpd.Should().NotBeNull(); + cpd.ProblemDetails.Type.Should().NotBeNull(); + cpd.ProblemDetails.Title.Should().Be("Oh my, we have one of those already!"); + cpd.ProblemDetails.Status.Should().Be(409); + cpd.ProblemDetails.Extensions.Should().ContainKey("traceId").WhoseValue.Should().BeOfType(); + cpd.ProblemDetails.Extensions["traceId"].As().ToString().Should().StartWith("00-0af7651916cd43dd8448eb211c80319c-"); + + var dex = cpd.ToException(); + dex.Should().NotBeNull(); + dex.Message.Should().Be("Oh my, we have one of those already!"); + dex.ErrorType.Should().Be("duplicate"); + + var pd = ra.GetValue(System.Net.Mime.MediaTypeNames.Application.ProblemJson); + pd.Should().NotBeNull(); + pd.Type.Should().NotBeNull(); + pd.Extensions.Should().ContainKey("traceId").WhoseValue.Should().BeOfType(); + pd.Extensions["traceId"].As().ToString().Should().StartWith("00-0af7651916cd43dd8448eb211c80319c-"); + } + + [Test] + public void Swagger_UI() + { + Test.Http() + .Run(HttpMethod.Get, "/swagger") + .Assert(HttpStatusCode.Found) + .AssertLocationHeader(new Uri("/swagger/index.html", UriKind.Relative)); + + var html = Test.Http() + .Run(HttpMethod.Get, "/swagger/index.html") + .Assert(HttpStatusCode.OK) + .GetContent(); + + html.Should().Contain("Swagger UI"); + } + + [Test] + public void Swagger_JSON() + { + var json = Test.Http() + .Run(HttpMethod.Get, "/swagger/v1/swagger.json") + .Assert(HttpStatusCode.OK) + .AssertContentTypeJson() + .GetContent(); + + json.Should().Contain("CoreEx.AspNetCore.Test.Api"); + json.Should().Contain("\"etag\""); // Asserts that JsonSubstituteNamingPolicy is in effect. + } + + [TestCase("/health/live")] + [TestCase("/health/startup")] + [TestCase("/health/ready")] + public void Health_Lite(string path) + { + Test.Http() + .Run(HttpMethod.Get, path) + .Assert(HttpStatusCode.OK) + .AssertContentTypePlainText() + .AssertContent("Healthy"); + } + + [TestCase("/health/live/detailed")] + [TestCase("/health/startup/detailed")] + [TestCase("/health/ready/detailed")] + public void Health_Detailed(string path) + { + var json = Test.Http() + .Run(HttpMethod.Get, path) + .Assert(HttpStatusCode.OK) + .AssertContentTypeJson() + .GetContent(); + + json.Should().Contain("Healthy"); + } + + [Test] + public void Idempotency_Mvc_Reuse_Key() + { + var ik = Guid.NewGuid().ToString(); + + // First request, should cache. + Test.Http() + .Run(HttpMethod.Post, "/api/other/idempotency-mvc/1", hrm => hrm.WithIdempotencyKey(ik)) + .AssertCreated() + .AssertJson("{\"id\":1,\"name\":\"Bob\"}"); + + // Second request, same Idempotency-Key and same request, should replay. + Test.Http() + .ExpectLogContains($"Idempotent request with key '{ik}' has resulted in the response being replayed from the cache.") + .Run(HttpMethod.Post, "/api/other/idempotency-mvc/1", hrm => hrm.WithIdempotencyKey(ik)) + .AssertCreated() + .AssertJson("{\"id\":1,\"name\":\"Bob\"}"); + + // Third request, same Idempotency-Key but different request, should conflict. + Test.Http() + .Run(HttpMethod.Post, "/api/other/idempotency-mvc/2", hrm => hrm.WithIdempotencyKey(ik)) + .Assert(HttpStatusCode.UnprocessableEntity) + .Value!.Title.Should().Be("The 'Idempotency-Key' header has already been used for a different request."); + + // Verify the cache contents. + Test.ScopedType(async test => + { + var data = await test.Service.GetOrCreateByKeyAsync("Idempotency:" + ik, _ => throw new InvalidOperationException("Must not be empty!")); + data.Should().NotBeNull(); + data.Status.Should().Be(IdempotencyStatus.CompletedAndReplayable); + data.StatusCode.Should().Be((int)HttpStatusCode.Created); + data.Headers.Should().ContainKey("Content-Type").WhoseValue.Should().Contain("application/json"); + data.Body.Should().NotBeNull(); + data.Body.ToString().Should().NotBeNull().And.Be("{\"id\":1,\"name\":\"Bob\"}"); + }); + } + + [Test] + public void Idempotency_Mvc_KeyMissing_Success() + { + Test.Http() + .Run(HttpMethod.Post, "/api/other/idempotency-mvc/1") + .AssertCreated() + .AssertJson("{\"id\":1,\"name\":\"Bob\"}"); + } + + [Test] + public void Idempotency_Mvc_KeyInvalid_Failure() + { + Test.Http() + .Run(HttpMethod.Post, "/api/other/idempotency-mvc/1", hrm => hrm.WithIdempotencyKey("inv@lid_key!")) + .AssertBadRequest() + .Value!.Title.Should().Be("The 'Idempotency-Key' header is invalid."); + } + + [Test] + public void Idempotency_Mvc_NotFound_EmptyCache() + { + var ik = Guid.NewGuid().ToString(); + + // First request, should cache/remove due to error. + Test.Http() + .Run(HttpMethod.Post, "/api/other/idempotency-mvc/88", hrm => hrm.WithIdempotencyKey(ik)) + .AssertNotFound(); + + // Second request, should cache/remove due to error. + Test.Http() + .Run(HttpMethod.Post, "/api/other/idempotency-mvc/88", hrm => hrm.WithIdempotencyKey(ik)) + .AssertNotFound(); + + // Verify that the cache does not contain the idempotency key. + Test.ScopedType(async test => + { + var exists = true; + var entry = await test.Service.GetOrCreateByKeyAsync(ik, _ => + { + exists = false; + return Task.FromResult(new object()); + }); + + exists.Should().BeFalse(); + }); + } + + [Test] + public void Idempotency_Mvc_Response_Too_Large() + { + var ik = Guid.NewGuid().ToString(); + + // First request, should cache as too-large.. + Test.Http() + .Run(HttpMethod.Post, "/api/other/idempotency-mvc/99", hrm => hrm.WithIdempotencyKey(ik)) + .AssertCreated(); + + // Second request, same Idempotency-Key and same request, should conflict due to response too large. + Test.Http() + .Run(HttpMethod.Post, "/api/other/idempotency-mvc/99", hrm => hrm.WithIdempotencyKey(ik)) + .AssertConflict() + .Value!.Title.Should().Be("The response associated with the specified 'Idempotency-Key' is no longer available."); + } + + [Test] + public void Idempotency_Http_Reuse_Key() + { + var ik = Guid.NewGuid().ToString(); + + // First request, should cache. + Test.Http() + .Run(HttpMethod.Post, "/api/idempotency-key/test/1", hrm => hrm.WithIdempotencyKey(ik)) + .AssertCreated() + .AssertJson("{\"id\":1,\"name\":\"Jen\"}"); + + // Second request, same Idempotency-Key and same request, should replay. + Test.Http() + .ExpectLogContains($"Idempotent request with key '{ik}' has resulted in the response being replayed from the cache.") + .Run(HttpMethod.Post, "/api/idempotency-key/test/1", hrm => hrm.WithIdempotencyKey(ik)) + .AssertCreated() + .AssertJson("{\"id\":1,\"name\":\"Jen\"}"); + + // Third request, same Idempotency-Key but different request, should conflict. + Test.Http() + .Run(HttpMethod.Post, "/api/idempotency-key/test/2", hrm => hrm.WithIdempotencyKey(ik)) + .Assert(HttpStatusCode.UnprocessableEntity) + .Value!.Title.Should().Be("The 'Idempotency-Key' header has already been used for a different request."); + + // Verify the cache contents. + Test.ScopedType(async test => + { + var data = await test.Service.GetOrCreateByKeyAsync("Idempotency:" + ik, _ => throw new InvalidOperationException("Must not be empty!")); + data.Should().NotBeNull(); + data.Status.Should().Be(IdempotencyStatus.CompletedAndReplayable); + data.StatusCode.Should().Be((int)HttpStatusCode.Created); + data.Headers.Should().ContainKey("Content-Type").WhoseValue.Should().Contain("application/json"); + data.Body.Should().NotBeNull(); + data.Body.ToString().Should().NotBeNull().And.Be("{\"id\":1,\"name\":\"Jen\"}"); + }); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_HttpMutateTests.cs b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_HttpMutateTests.cs new file mode 100644 index 00000000..27f3c398 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_HttpMutateTests.cs @@ -0,0 +1,7 @@ +namespace CoreEx.AspNetCore.Test.Unit; + +[TestFixture] +public class PersonApi_HttpMutateTests : PersonApi_MutateTestsBase +{ + public override string Route => "api/persons"; +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_HttpQueryTests.cs b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_HttpQueryTests.cs new file mode 100644 index 00000000..fd2879d8 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_HttpQueryTests.cs @@ -0,0 +1,7 @@ +namespace CoreEx.AspNetCore.Test.Unit; + +[TestFixture] +public class PersonApi_HttpQueryTests : PersonApi_QueryTestsBase +{ + public override string Route => "api/persons"; +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs new file mode 100644 index 00000000..0545030f --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs @@ -0,0 +1,216 @@ +using CoreEx.AspNetCore.Test.Api.Entities; +using CoreEx.AspNetCore.Test.Api.Services; + +namespace CoreEx.AspNetCore.Test.Unit; + +[Parallelizable] +public abstract class PersonApi_MutateTestsBase : WithApiTester +{ + public abstract string Route { get; } + + [OneTimeSetUp] + public void OneTimeSetUp() + { + PersonService.Reset(); + PersonService2.Reset(); + } + + [Test] + public void Create_NoValue() + { + Test.Http() + .Run(HttpMethod.Post, $"{Route}") + .AssertBadRequest() + .AssertProblemDetailsTitle("Request body is invalid: Unable to read the request as JSON because the request content type '' is not a known JSON content type."); + } + + [Test] + public void Create_Success() + { + // Create a new entity. + var v = Test.Http() + .Run(HttpMethod.Post, $"{Route}", new Person + { + Id = "5", + FirstName = "Charlie", + LastName = "Brown", + Birthday = new DateOnly(2000, 1, 1), + GenderSid = "M" + }) + .AssertCreated() + .AssertValue(new Person + { + Id = "5", + FirstName = "Charlie", + LastName = "Brown", + Birthday = new DateOnly(2000, 1, 1), + GenderSid = "M", + }, "etag") + .AssertETagHeader() + .AssertLocationHeader(p => new Uri($"{Route}/{p!.Id}", UriKind.Relative)) + .Value; + + // Verify the entity was created. + Test.Http() + .Run(HttpMethod.Get, $"{Route}/5") + .AssertOK() + .AssertValue(v); + } + + [Test] + public void Create_Duplicate() + { + Test.Http() + .Run(HttpMethod.Post, $"{Route}", new Person + { + Id = "1", + FirstName = "Charlie", + LastName = "Brown", + Birthday = new DateOnly(2000, 1, 1), + GenderSid = "M", + }) + .AssertConflict(); + } + + [Test] + public void Update_Success() + { + // Pre-read the entity to get the etag. + var v = Test.Http() + .Run(HttpMethod.Get, $"{Route}/1") + .AssertOK() + .Value; + + v.Should().NotBeNull(); + + // Update the entity. + v.FirstName = "Jane"; + v.LastName = "Doe"; + v = Test.Http() + .Run(HttpMethod.Put, $"{Route}/1", v, r => r.WithIfMatch(v.ETag)) + .AssertOK() + .AssertValue(v, "etag") + .Value; + + // Verify the update. + Test.Http() + .Run(HttpMethod.Get, $"{Route}/1") + .AssertOK() + .AssertValue(v); + } + + [Test] + public void Update_NotFound() + { + Test.Http() + .Run(HttpMethod.Put, $"{Route}/0", new Person + { + Id = "0", + FirstName = "Charlie", + LastName = "Brown", + Birthday = new DateOnly(2000, 1, 1), + GenderSid = "M", + ETag = "oops" + }) + .AssertNotFound(); + } + + [Test] + public void Update_Concurrency() + { + // Pre-read the entity to get the etag. + var v = Test.Http() + .Run(HttpMethod.Get, $"{Route}/1") + .AssertOK() + .Value!; + + // Attempt to update the entity with an invalid etag. + Test.Http() + .Run(HttpMethod.Put, $"{Route}/1", v, r => r.WithIfMatch("oops")) + .AssertPreconditionFailed(); + } + + [Test] + public void Patch_Success() + { + // Pre-read the entity to get the etag. + var v = Test.Http() + .Run(HttpMethod.Get, $"{Route}/1") + .AssertOK() + .Value; + + v.Should().NotBeNull(); + v.LastName += " Jr."; + + // Patch the entity. + v = Test.Http() + .Run(HttpMethod.Patch, $"{Route}/1", new { v.LastName }, r => r.WithMergePatchJsonContentType().WithIfMatch(v.ETag)) + .AssertOK() + .AssertValue(v, "etag") + .Value; + + // Verify the patch. + Test.Http() + .Run(HttpMethod.Get, $"{Route}/1") + .AssertOK() + .AssertValue(v); + } + + [Test] + public void Patch_NotFound() + { + Test.Http() + .Run(HttpMethod.Patch, $"{Route}/0", new { FirstName = "Charlie" }, r => r.WithMergePatchJsonContentType()) + .AssertNotFound(); + } + + [Test] + public void Patch_Concurrency() + { + // Pre-read the entity to get the etag. + var v = Test.Http() + .Run(HttpMethod.Get, $"{Route}/1") + .AssertOK() + .Value!; + + v.LastName += " Jr."; + + // Attempt to patch the entity with an invalid etag. + Test.Http() + .Run(HttpMethod.Patch, $"{Route}/1", new { v.LastName }, r => r.WithMergePatchJsonContentType().WithIfMatch("oops")) + .AssertPreconditionFailed(); + } + + [Test] + public void Delete_Success() + { + // Pre-read the entity to verify it exists. + Test.Http() + .Run(HttpMethod.Get, $"{Route}/4") + .AssertOK(); + + // Delete the entity. + Test.Http() + .Run(HttpMethod.Delete, $"{Route}/4") + .AssertNoContent(); + + // Verify the entity is deleted. + Test.Http() + .Run(HttpMethod.Get, $"{Route}/4") + .AssertNotFound(); + } + + [Test] + public void Delete_NotFound() + { + // Verify the entity does not exist. + Test.Http() + .Run(HttpMethod.Get, $"{Route}/0") + .AssertNotFound(); + + // Delete the entity - should be idempotent. + Test.Http() + .Run(HttpMethod.Delete, $"{Route}/0") + .AssertNoContent(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MvcMutateTests.cs b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MvcMutateTests.cs new file mode 100644 index 00000000..89736927 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MvcMutateTests.cs @@ -0,0 +1,7 @@ +namespace CoreEx.AspNetCore.Test.Unit; + +[TestFixture] +public class PersonApi_MvcMutateTests : PersonApi_MutateTestsBase +{ + public override string Route => "api/people"; +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MvcQueryTests.cs b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MvcQueryTests.cs new file mode 100644 index 00000000..69b0f26a --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MvcQueryTests.cs @@ -0,0 +1,7 @@ +namespace CoreEx.AspNetCore.Test.Unit; + +[TestFixture] +public class PersonApi_MvcQueryTests : PersonApi_QueryTestsBase +{ + public override string Route => "api/people"; +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_QueryTestsBase.cs b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_QueryTestsBase.cs new file mode 100644 index 00000000..dc54a307 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_QueryTestsBase.cs @@ -0,0 +1,192 @@ +using CoreEx.AspNetCore.Test.Api.Entities; +using CoreEx.AspNetCore.Test.Api.Services; +using CoreEx.Http; + +namespace CoreEx.AspNetCore.Test.Unit; + +public abstract class PersonApi_QueryTestsBase : WithApiTester +{ + public abstract string Route { get; } + + [OneTimeSetUp] + public void OneTimeSetUp() + { + PersonService.Reset(); + PersonService2.Reset(); + } + + [Test] + public void Get_Found() + { + Test.Http() + .Run(HttpMethod.Get, $"{Route}/1") + .AssertOK() + .AssertValue(new Person + { + Id = "1", + FirstName = "John", + LastName = "Doe", + Birthday = new DateOnly(1980, 1, 1), + GenderSid = "M", + }, "etag") + .AssertETagHeader(); + } + + [Test] + public void Get_Found_IncludeText() + { + Test.Http() + .Run(HttpMethod.Get, $"{Route}/1?$text") + .AssertOK() + .AssertValue(new Person + { + Id = "1", + FirstName = "John", + LastName = "Doe", + Birthday = new DateOnly(1980, 1, 1), + GenderSid = "M", + GenderText = "Male" + }, "etag") + .AssertETagHeader(); + } + + [Test] + public void Get_NotFound() + { + Test.Http() + .Run(HttpMethod.Get, $"{Route}/0") + .AssertNotFound(); + } + + [Test] + public void Get_NotModified() + { + var v = Test.Http() + .Run(HttpMethod.Get, $"{Route}/1") + .AssertOK() + .Value!; + + Test.Http() + .Run(HttpMethod.Get, $"{Route}/1", r => r.WithIfNoneMatch(v.ETag)) + .AssertNotModified(); + } + + [Test] + public void GetByQuery_Default() + { + Test.Http() + .Run(HttpMethod.Get, Route) + .AssertOK() + .AssertJsonFromResource("Person_GetByQuery_Default.json", "etag") + .AssertNamedHeader(HttpNames.PagingSkipHeaderName, "0") + .AssertNamedHeader(HttpNames.PagingTakeHeaderName, "25") + .AssertETagHeader(); + } + + [Test] + public void GetByQuery_Paging() + { + Test.Http() + .Run(HttpMethod.Get, Route, r => r.WithPaging(1, 2, true)) + .AssertOK() + .AssertJsonFromResource("Person_GetByQuery_Paging.json", "etag") + .AssertNamedHeader(HttpNames.PagingSkipHeaderName, "1") + .AssertNamedHeader(HttpNames.PagingTakeHeaderName, "2") + .AssertNamedHeader(HttpNames.PagingTotalCountHeaderName, "4") + .AssertNamedHeader("Link", $"; rel=\"prev\", ; rel=\"next\"") + .AssertETagHeader(); + } + + [Test] + public void GetByQuery_WithFields() + { + Test.Http() + .Run(HttpMethod.Get, Route, r => r.WithFields("firstname", "lastname")) + .AssertOK() + .AssertJsonFromResource("Person_GetByQuery_IncludeFields.json"); + } + + [Test] + public void GetByQuery_WithoutFields() + { + Test.Http() + .Run(HttpMethod.Get, Route, r => r.WithoutFields("id", "birthday", "gender", "etag")) + .AssertOK() + .AssertJsonFromResource("Person_GetByQuery_IncludeFields.json"); + } + + [Test] + public void GetByQuery_Filter_Eq() + { + var v = Test.Http() + .Run(HttpMethod.Get, Route, r => r.WithQuery("firstname eq 'John'")) + .AssertOK() + .Value; + + v.Should().NotBeNull(); + v.Should().HaveCount(1); + v[0].Id.Should().Be("1"); + } + + [Test] + public void GetByQuery_Filter_EndsWith() + { + var v = Test.Http() + .Run(HttpMethod.Get, Route, r => r.WithQuery("endswith(firstname, 'e')")) + .AssertOK() + .Value; + + v.Should().NotBeNull(); + v.Should().HaveCount(2); + v[0].Id.Should().Be("2"); + v[1].Id.Should().Be("3"); + } + + [Test] + public void GetByQuery_Filter_Gender() + { + var v = Test.Http() + .Run(HttpMethod.Get, Route, r => r.WithQuery("gender in ('m')")) + .AssertOK() + .Value; + + v.Should().NotBeNull(); + v.Should().HaveCount(2); + v[0].Id.Should().Be("1"); + v[1].Id.Should().Be("4"); + } + + [Test] + public void GetByQuery_OrderBy_FirstName() + { + var v = Test.Http() + .Run(HttpMethod.Get, Route, r => r.WithQuery(filter: "gender in ('m')", orderBy: "firstname")) + .AssertOK() + .Value; + + v.Should().NotBeNull(); + v.Should().HaveCount(2); + v[0].Id.Should().Be("4"); + v[1].Id.Should().Be("1"); + } + + [Test] + public void GetByQuery_Filter_Error() + { + var v = Test.Http() + .Run(HttpMethod.Get, Route, r => r.WithQuery("gender gt 'm'")) + .AssertBadRequest() + .AssertContentType("application/problem+json") + .AssertJsonFromResource("Person_GetByQuery_FilterError.json", "traceid"); + } + + [Test] + public void GetByQuery_OrderBy_Error() + { + var v = Test.Http() + .Run(HttpMethod.Get, Route, r => r.WithQuery(orderBy: "eyecolor")) + .AssertBadRequest() + .AssertContentType("application/problem+json") + .AssertJsonFromResource("Person_GetByQuery_OrderByError.json", "traceid"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_HttpTests.cs b/tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_HttpTests.cs new file mode 100644 index 00000000..d4ca0777 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_HttpTests.cs @@ -0,0 +1,7 @@ +namespace CoreEx.AspNetCore.Test.Unit; + +[TestFixture] +public class ReferenceDataApi_HttpTests : ReferenceDataApi_TestsBase +{ + public override string Route => "api/referencedata"; +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_MvcTests.cs b/tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_MvcTests.cs new file mode 100644 index 00000000..fda31852 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_MvcTests.cs @@ -0,0 +1,7 @@ +namespace CoreEx.AspNetCore.Test.Unit; + +[TestFixture] +public class ReferenceDataApi_MvcTests : ReferenceDataApi_TestsBase +{ + public override string Route => "api/refdata"; +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_TestsBase.cs b/tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_TestsBase.cs new file mode 100644 index 00000000..50c01c93 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/ReferenceDataApi_TestsBase.cs @@ -0,0 +1,62 @@ +using CoreEx.AspNetCore.Test.Api.Entities; + +namespace CoreEx.AspNetCore.Test.Unit; + +[Parallelizable] +public abstract class ReferenceDataApi_TestsBase : WithApiTester +{ + public abstract string Route { get; } + + [Test] + public void Gender_Get_All() + { + Test.Http() + .Run(HttpMethod.Get, $"{Route}/genders") + .AssertOK() + .AssertJsonFromResource("Gender_Get_All.json"); + } + + [Test] + public void Gender_Get_Codes() + { + var v = Test.Http() + .Run(HttpMethod.Get, $"{Route}/genders?codes=F&codes=X") + .AssertOK() + .Value; + + v.Should().NotBeNull(); + v.Should().HaveCount(1); + v[0].Should().NotBeNull(); + v[0].Code.Should().Be("F"); + } + + [Test] + public void Gender_Get_Text() + { + var v = Test.Http() + .Run(HttpMethod.Get, $"{Route}/genders?text=F*") + .AssertOK() + .Value; + + v.Should().NotBeNull(); + v.Should().HaveCount(1); + v[0].Should().NotBeNull(); + v[0].Code.Should().Be("F"); + } + + [Test] + public void Gender_Get_Codes_IncludeInactive() + { + var v = Test.Http() + .Run(HttpMethod.Get, $"{Route}/genders?codes=F&codes=X&$inactive") + .AssertOK() + .Value; + + v.Should().NotBeNull(); + v.Should().HaveCount(2); + v[0].Should().NotBeNull(); + v[0].Code.Should().Be("F"); + v[1].Should().NotBeNull(); + v[1].Code.Should().Be("X"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/Resources/Gender_Get_All.json b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Gender_Get_All.json new file mode 100644 index 00000000..76039f74 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Gender_Get_All.json @@ -0,0 +1,12 @@ +[ + { + "id": "F", + "code": "F", + "text": "Female" + }, + { + "id": "M", + "code": "M", + "text": "Male" + } +] \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_Default.json b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_Default.json new file mode 100644 index 00000000..8c0b0950 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_Default.json @@ -0,0 +1,34 @@ +[ + { + "id": "2", + "firstName": "Jane", + "lastName": "Doe", + "birthday": "1985-02-02", + "gender": "F", + "etag": "2b0d6072-2284-4326-949d-394d0a61826c" + }, + { + "id": "1", + "firstName": "John", + "lastName": "Doe", + "birthday": "1980-01-01", + "gender": "M", + "etag": "e4d24a5e-2435-448f-8dcc-a4749368a5cb" + }, + { + "id": "3", + "firstName": "Alice", + "lastName": "Smith", + "birthday": "1990-03-03", + "gender": "F", + "etag": "b1f9f81e-9b6c-4c93-a78a-e2e518657054" + }, + { + "id": "4", + "firstName": "Bob", + "lastName": "Smith", + "birthday": "1995-04-04", + "gender": "M", + "etag": "fc73f19d-97a2-4b6c-a526-93490e6c0454" + } +] \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_FilterError.json b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_FilterError.json new file mode 100644 index 00000000..cab8c08d --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_FilterError.json @@ -0,0 +1,86 @@ +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "A query filter parsing error occurred.", + "status": 400, + "errors": { + "$filter": [ + "Field \u0027gender\u0027 does not support the \u0027gt\u0027 operator." + ] + }, + "errorType": "validation", + "traceId": "00-a981e5aedf51b7fa4cf352d897a310fe-9f5b5a1bbdd59887-01", + "schema": { + "filter": { + "fields": { + "firstname": { + "type": "string", + "operators": [ + "eq", + "ne", + "lt", + "le", + "ge", + "gt", + "in", + "startswith", + "contains", + "endswith" + ] + }, + "lastname": { + "type": "string", + "operators": [ + "eq", + "ne", + "lt", + "le", + "ge", + "gt", + "in", + "startswith", + "contains", + "endswith" + ] + }, + "birthday": { + "type": "string", + "format": "date", + "operators": [ + "eq", + "ne", + "lt", + "le", + "ge", + "gt", + "in" + ] + }, + "gender": { + "type": "string", + "operators": [ + "eq", + "ne", + "in" + ] + } + } + }, + "orderby": { + "fields": { + "lastname": { + "direction": [ + "asc", + "desc" + ] + }, + "firstname": { + "direction": [ + "asc", + "desc" + ] + } + }, + "default": "lastname asc, firstname asc" + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_IncludeFields.json b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_IncludeFields.json new file mode 100644 index 00000000..a8af085b --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_IncludeFields.json @@ -0,0 +1,18 @@ +[ + { + "firstName": "Jane", + "lastName": "Doe" + }, + { + "firstName": "John", + "lastName": "Doe" + }, + { + "firstName": "Alice", + "lastName": "Smith" + }, + { + "firstName": "Bob", + "lastName": "Smith" + } +] \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_OrderByError.json b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_OrderByError.json new file mode 100644 index 00000000..1253f6f9 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_OrderByError.json @@ -0,0 +1,86 @@ +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "A query order-by parsing error occurred.", + "status": 400, + "errors": { + "$orderby": [ + "Field \u0027eyecolor\u0027 is not supported." + ] + }, + "errorType": "validation", + "traceId": "00-6327ec26f7f53f956ef1a9c13f3ef61c-82a2c15e80731f97-01", + "schema": { + "filter": { + "fields": { + "firstname": { + "type": "string", + "operators": [ + "eq", + "ne", + "lt", + "le", + "ge", + "gt", + "in", + "startswith", + "contains", + "endswith" + ] + }, + "lastname": { + "type": "string", + "operators": [ + "eq", + "ne", + "lt", + "le", + "ge", + "gt", + "in", + "startswith", + "contains", + "endswith" + ] + }, + "birthday": { + "type": "string", + "format": "date", + "operators": [ + "eq", + "ne", + "lt", + "le", + "ge", + "gt", + "in" + ] + }, + "gender": { + "type": "string", + "operators": [ + "eq", + "ne", + "in" + ] + } + } + }, + "orderby": { + "fields": { + "lastname": { + "direction": [ + "asc", + "desc" + ] + }, + "firstname": { + "direction": [ + "asc", + "desc" + ] + } + }, + "default": "lastname asc, firstname asc" + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_Paging.json b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_Paging.json new file mode 100644 index 00000000..8ffeaff7 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/Resources/Person_GetByQuery_Paging.json @@ -0,0 +1,18 @@ +[ + { + "id": "1", + "firstName": "John", + "lastName": "Doe", + "birthday": "1980-01-01", + "gender": "M", + "etag": "a71e3cfd-2c40-474b-b4ce-f9617d900cef" + }, + { + "id": "4", + "firstName": "Bob", + "lastName": "Smith", + "birthday": "1995-04-04", + "gender": "M", + "etag": "74a9dd99-167c-4178-b92c-e89db00f4621" + } +] \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Delete.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Delete.cs new file mode 100644 index 00000000..664a3838 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Delete.cs @@ -0,0 +1,34 @@ +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void Delete_Success() + { + Test.Type() + .Run(async w => await w.DeleteAsync(Test.CreateHttpRequest(HttpMethod.Delete), (ro, ct) => Task.CompletedTask)) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Delete_Not_Found() + { + static Task NotFound(WebApiOptions ro, CancellationToken ct) => throw new NotFoundException(); + + Test.Type() + .Run(async w => await w.DeleteAsync(Test.CreateHttpRequest(HttpMethod.Delete), NotFound)) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Delete_Response_Success() + { + Test.Type() + .Run(async w => await w.DeleteAsync(Test.CreateHttpRequest(HttpMethod.Delete), (ro, ct) => Task.FromResult(123))) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertValue(123); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.DeleteWithResult.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.DeleteWithResult.cs new file mode 100644 index 00000000..4d038259 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.DeleteWithResult.cs @@ -0,0 +1,43 @@ +using CoreEx.Results; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void DeleteWithResult_Success() + { + Test.Type() + .Run(async w => await w.DeleteWithResultAsync(Test.CreateHttpRequest(HttpMethod.Delete), (ro, ct) => Result.SuccessTask)) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void DeleteWithResult_Not_Found() + { + Test.Type() + .Run(async w => await w.DeleteWithResultAsync(Test.CreateHttpRequest(HttpMethod.Delete), (ro, ct) => Result.NotFoundError().AsTask())) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void DeleteWithResult_Not_Found_Throw() + { + Test.Type() + .Run(async w => await w.DeleteWithResultAsync(Test.CreateHttpRequest(HttpMethod.Delete), (ro, ct) => throw new NotFoundException())) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void DeleteWithResult_Response_Success() + { + Test.Type() + .Run(async w => await w.DeleteWithResultAsync(Test.CreateHttpRequest(HttpMethod.Delete), (ro, ct) => Result.Go(123).AsTask())) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertValue(123); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Exceptions.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Exceptions.cs new file mode 100644 index 00000000..83e71a44 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Exceptions.cs @@ -0,0 +1,50 @@ +using CoreEx.Localization; +using System.Net; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [TestCase(typeof(AuthenticationException), HttpStatusCode.Unauthorized, "authentication")] + [TestCase(typeof(AuthorizationException), HttpStatusCode.Forbidden, "authorization")] + [TestCase(typeof(BusinessException), HttpStatusCode.BadRequest, "business")] + [TestCase(typeof(ConcurrencyException), HttpStatusCode.PreconditionFailed, "concurrency")] + [TestCase(typeof(ConflictException), HttpStatusCode.Conflict, "conflict")] + [TestCase(typeof(DataConsistencyException), HttpStatusCode.InternalServerError, "data-consistency")] + [TestCase(typeof(DuplicateException), HttpStatusCode.Conflict, "duplicate")] + [TestCase(typeof(NotFoundException), HttpStatusCode.NotFound, "not-found")] + [TestCase(typeof(TransientException), HttpStatusCode.ServiceUnavailable, "transient")] + [TestCase(typeof(ValidationException), HttpStatusCode.BadRequest, "validation")] + [TestCase(typeof(InvalidOperationException), HttpStatusCode.InternalServerError)] + public void Exception_ProblemHandling(Type type, HttpStatusCode statusCode, string? errorType = null, string? errorCode = null) + { + List paths = ["type", "title", "traceid"]; + + var ex = type == typeof(BusinessException) ? new BusinessException("Biz") : (Exception)Activator.CreateInstance(type, null)!; + if (ex is CoreEx.Abstractions.ExtendedException eex && eex.IsError) + { + if (!string.IsNullOrEmpty(errorCode)) + eex.ErrorCode = errorCode; + else + paths.Add("errorCode"); + } + else + { + paths.Add("errorCode"); + paths.Add("errorType"); + paths.Add("detail"); + } + + Test.Type() + .Run(async w => + { + w.ConvertUnhandledExceptionsToProblemDetails = true; // Ensure enabled for unit-testing. + return await w.PostAsync(Test.CreateHttpRequest(HttpMethod.Post, "test"), (ro, ct) => throw ex); + }) + .ToHttpResponseMessageAssertor() + .Assert(statusCode) + .AssertContentType(MediaTypeNames.Application.ProblemJson) + .AssertJson($"{{\"title\":\"{ex.Message}\",\"status\":{(int)statusCode},\"errorType\":\"{errorType}\",\"errorCode\":\"{errorCode}\"}}", pathsToIgnore: [.. paths]); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.ExceptionsWithResult.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.ExceptionsWithResult.cs new file mode 100644 index 00000000..f4b60c0c --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.ExceptionsWithResult.cs @@ -0,0 +1,92 @@ +using CoreEx.Results; +using System.Net; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [TestCase(typeof(AuthenticationException), HttpStatusCode.Unauthorized, "authentication")] + [TestCase(typeof(AuthorizationException), HttpStatusCode.Forbidden, "authorization")] + [TestCase(typeof(BusinessException), HttpStatusCode.BadRequest, "business")] + [TestCase(typeof(ConcurrencyException), HttpStatusCode.PreconditionFailed, "concurrency")] + [TestCase(typeof(ConflictException), HttpStatusCode.Conflict, "conflict")] + [TestCase(typeof(DataConsistencyException), HttpStatusCode.InternalServerError, "data-consistency")] + [TestCase(typeof(DuplicateException), HttpStatusCode.Conflict, "duplicate")] + [TestCase(typeof(NotFoundException), HttpStatusCode.NotFound, "not-found")] + [TestCase(typeof(TransientException), HttpStatusCode.ServiceUnavailable, "transient")] + [TestCase(typeof(ValidationException), HttpStatusCode.BadRequest, "validation")] + [TestCase(typeof(InvalidOperationException), HttpStatusCode.InternalServerError)] + public void ExceptionWithResult_ProblemHandling(Type type, HttpStatusCode statusCode, string? errorType = null, string? errorCode = null) + { + List paths = ["type", "title", "traceid"]; + + var ex = type == typeof(BusinessException) ? new BusinessException("Biz") : (Exception)Activator.CreateInstance(type, null)!; + if (ex is CoreEx.Abstractions.ExtendedException eex && eex.IsError) + { + if (!string.IsNullOrEmpty(errorCode)) + eex.ErrorCode = errorCode; + else + paths.Add("errorCode"); + } + else + { + paths.Add("errorCode"); + paths.Add("errorType"); + paths.Add("detail"); + } + + Test.Type() + .Run(async w => + { + w.ConvertUnhandledExceptionsToProblemDetails = true; // Ensure enabled for unit-testing. + return await w.PostWithResultAsync(Test.CreateHttpRequest(HttpMethod.Post, "test"), (ro, ct) => Task.FromResult(Result.Fail(ex))); + }) + .ToHttpResponseMessageAssertor() + .Assert(statusCode) + .AssertContentType(MediaTypeNames.Application.ProblemJson) + .AssertJson($"{{\"title\":\"{ex.Message}\",\"status\":{(int)statusCode},\"errorType\":\"{errorType}\",\"errorCode\":\"{errorCode}\"}}", pathsToIgnore: [.. paths]); + } + + [TestCase(typeof(AuthenticationException), HttpStatusCode.Unauthorized, "authentication")] + [TestCase(typeof(AuthorizationException), HttpStatusCode.Forbidden, "authorization")] + [TestCase(typeof(BusinessException), HttpStatusCode.BadRequest, "business")] + [TestCase(typeof(ConcurrencyException), HttpStatusCode.PreconditionFailed, "concurrency")] + [TestCase(typeof(ConflictException), HttpStatusCode.Conflict, "conflict")] + [TestCase(typeof(DataConsistencyException), HttpStatusCode.InternalServerError, "data-consistency")] + [TestCase(typeof(DuplicateException), HttpStatusCode.Conflict, "duplicate")] + [TestCase(typeof(NotFoundException), HttpStatusCode.NotFound, "not-found")] + [TestCase(typeof(TransientException), HttpStatusCode.ServiceUnavailable, "transient")] + [TestCase(typeof(ValidationException), HttpStatusCode.BadRequest, "validation")] + [TestCase(typeof(InvalidOperationException), HttpStatusCode.InternalServerError)] + public void ExceptionWithResult_Throw_ProblemHandling(Type type, HttpStatusCode statusCode, string? errorType = null, string? errorCode = null) + { + List paths = ["type", "title", "traceid"]; + + var ex = type == typeof(BusinessException) ? new BusinessException("Biz") : (Exception)Activator.CreateInstance(type, null)!; + if (ex is CoreEx.Abstractions.ExtendedException eex && eex.IsError) + { + if (!string.IsNullOrEmpty(errorCode)) + eex.ErrorCode = errorCode; + else + paths.Add("errorCode"); + } + else + { + paths.Add("errorCode"); + paths.Add("errorType"); + paths.Add("detail"); + } + + Test.Type() + .Run(async w => + { + w.ConvertUnhandledExceptionsToProblemDetails = true; // Ensure enabled for unit-testing. + return await w.PostWithResultAsync(Test.CreateHttpRequest(HttpMethod.Post, "test"), (ro, ct) => throw ex); + }) + .ToHttpResponseMessageAssertor() + .Assert(statusCode) + .AssertContentType(MediaTypeNames.Application.ProblemJson) + .AssertJson($"{{\"title\":\"{ex.Message}\",\"status\":{(int)statusCode},\"errorType\":\"{errorType}\",\"errorCode\":\"{errorCode}\"}}", pathsToIgnore: [.. paths]); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Get.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Get.cs new file mode 100644 index 00000000..ade5ea7f --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Get.cs @@ -0,0 +1,284 @@ +using CoreEx.Data; +using CoreEx.Entities; +using CoreEx.Http; +using System.Net.Http.Headers; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + private readonly string[] _helloWorld = ["Hello", "World"]; + + [Test] + public void Get_Null_As_Not_Found() + { + Test.Type() + .Run(async w => await w.GetAsync(Test.CreateHttpRequest(HttpMethod.Get), (ro, ct) => Task.FromResult(null))) + .ToHttpResponseMessageAssertor() + .AssertNotFound(); + } + + [Test] + public void Get_OK() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult("Hello World"))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue("Hello World") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"07cf55095ef8\""); + } + + [Test] + public void Get_Collection_With_Items() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(_helloWorld))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue(_helloWorld) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"d24bf63378b2\""); + } + + [Test] + public void Get_Collection_Empty() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(Array.Empty()))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("[]") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"4f53cda18c2b\""); + } + + [Test] + public void Get_ItemsResult_With_Items() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ItemsResult(_helloWorld)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue(_helloWorld) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"d24bf63378b2\""); + } + + [Test] + public void Get_ItemsResult_Empty() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ItemsResult()))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("[]") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"4f53cda18c2b\""); + } + + [Test] + public void Get_ItemsResult_With_Total_Count_NoPaging() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ItemsResult(_helloWorld).WithTotalCount(50)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue(_helloWorld) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"d24bf63378b2\"") + .AssertNoNamedHeader(HttpNames.PagingSkipHeaderName) + .AssertNoNamedHeader(HttpNames.PagingTakeHeaderName) + .AssertNoNamedHeader(HttpNames.PagingTotalCountHeaderName); + } + + [Test] + public void Get_ItemsResult_With_Total_Count_WithPaging() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ItemsResult(_helloWorld, PagingArgs.CreateWithCount()).WithTotalCount(50)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue(_helloWorld) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"d24bf63378b2\"") + .AssertNamedHeader(HttpNames.PagingSkipHeaderName, "0") + .AssertNamedHeader(HttpNames.PagingTakeHeaderName, PagingArgs.DefaultTake.ToString()) + .AssertNamedHeader(HttpNames.PagingTotalCountHeaderName, "50"); + } + + [Test] + public void Get_ItemsResult_Prev_Next_Paging_Links1() + { + // Get a mid page. + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$skip=2&$take=5&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ItemsResult(["a", "b", "c", "d", "e"], ro.PagingArgs).WithTotalCount(50)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.GetValues("Link").Should().Contain(["; rel=\"prev\"", "; rel=\"next\""]); + } + + [Test] + public void Get_ItemsResult_Prev_Next_Paging_Links2() + { + // Get the first page. + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$skip=0&$take=2&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ItemsResult(["a", "b"], ro.PagingArgs).WithTotalCount(50)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.GetValues("Link").Should().Contain(["; rel=\"next\""]); + } + + [Test] + public void Get_ItemsResult_Prev_Next_Paging_Links3() + { + // Get the last page. + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$skip=50&$take=2&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ItemsResult(["a"], ro.PagingArgs).WithTotalCount(51)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.GetValues("Link").Should().Contain(["; rel=\"prev\""]); + } + + [Test] + public void Get_ItemsResult_Prev_Next_Paging_Links4() + { + // Get a page with way too much skip - total count will help. + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$skip=250&$take=2&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ItemsResult([], ro.PagingArgs).WithTotalCount(50)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.GetValues("Link").Should().Contain(["; rel=\"prev\""]); + } + + [Test] + public void Get_ItemsResult_Prev_Next_Paging_Links5() + { + // Get a page with way too much skip - can only guess the previous. + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$skip=250&$take=2&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ItemsResult([], ro.PagingArgs)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.GetValues("Link").Should().Contain(["; rel=\"prev\""]); + } + + [Test] + public void Get_ItemsResult_Prev_Next_Paging_Links6() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$take=2&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ItemsResult([], ro.PagingArgs)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.Should().NotContainKeys("Link"); + } + + [Test] + public void Get_Person_Default_ETag() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(Person.GetPerson()))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"firstName":"John","lastName":"Doe","age":30,"etag":"1f5d2ae86bc2"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"1f5d2ae86bc2\""); + } + + [Test] + public void Get_Person_Specified_ETag() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(Person.GetPerson("abcdefg")))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"firstName":"John","lastName":"Doe","age":30,"etag":"abcdefg"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"abcdefg\""); + } + + [Test] + public void Get_Person_Not_Modified() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + hr.Headers.IfNoneMatch = new EntityTagHeaderValue("\"abcdefg\"", true).ToString(); + + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(Person.GetPerson("abcdefg")))) + .ToHttpResponseMessageAssertor(hr) + .AssertNotModified() + .AssertETagHeader("\"abcdefg\""); + } + + [Test] + public void Get_Person_Fields_Include() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + hr.QueryString = hr.QueryString.Add(HttpNames.IncludeFieldsQueryStringName, "firstname,LastName"); + + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(Person.GetPerson()))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"firstName":"John","lastName":"Doe"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"ddbd7f44c126\""); + } + + [Test] + public void Get_Person_Fields_Exclude() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + hr.QueryString = hr.QueryString.Add(HttpNames.ExcludeFieldsQueryStringName, "firstname,LastName"); + + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(Person.GetPerson()))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"age":30,"etag":"1f5d2ae86bc2"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"483e8079ddab\""); + } + + [Test] + public void Get_ValueResult_Default_StatusCode() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ValueResult("Hello World")))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue("Hello World") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"07cf55095ef8\""); + } + + [Test] + public void Get_ValueResult_Specified_StatusCode() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetAsync(hr, (ro, ct) => Task.FromResult(new ValueResult("Hello World", System.Net.HttpStatusCode.Accepted)))) + .ToHttpResponseMessageAssertor(hr) + .Assert(System.Net.HttpStatusCode.Accepted) + .AssertValue("Hello World") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"07cf55095ef8\""); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.GetWithResult.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.GetWithResult.cs new file mode 100644 index 00000000..3f75c199 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.GetWithResult.cs @@ -0,0 +1,283 @@ +using CoreEx.Data; +using CoreEx.Entities; +using CoreEx.Http; +using CoreEx.Results; +using System.Net.Http.Headers; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void GetWithResult_Null_As_Not_Found() + { + Test.Type() + .Run(async w => await w.GetWithResultAsync(Test.CreateHttpRequest(HttpMethod.Get), (ro, ct) => Task.FromResult(Result.Ok(null)))) + .ToHttpResponseMessageAssertor() + .AssertNotFound(); + } + + [Test] + public void GetWithResult_OK() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok("Hello World")))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue("Hello World") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"07cf55095ef8\""); + } + + [Test] + public void GetWithResult_Collection_With_Items() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(_helloWorld)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue(_helloWorld) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"d24bf63378b2\""); + } + + [Test] + public void GetWithResult_Collection_Empty() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(Array.Empty())))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("[]") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"4f53cda18c2b\""); + } + + [Test] + public void GetWithResult_ItemsResult_With_Items() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ItemsResult(_helloWorld))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue(_helloWorld) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"d24bf63378b2\""); + } + + [Test] + public void GetWithResult_ItemsResult_Empty() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ItemsResult())))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("[]") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"4f53cda18c2b\""); + } + + [Test] + public void GetWithResult_ItemsResult_With_Total_Count_NoPaging() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ItemsResult(_helloWorld).WithTotalCount(50))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue(_helloWorld) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"d24bf63378b2\"") + .AssertNoNamedHeader(HttpNames.PagingSkipHeaderName) + .AssertNoNamedHeader(HttpNames.PagingTakeHeaderName) + .AssertNoNamedHeader(HttpNames.PagingTotalCountHeaderName); + } + + [Test] + public void GetWithResult_ItemsResult_With_Total_Count_WithPaging() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ItemsResult(_helloWorld, PagingArgs.CreateWithCount()).WithTotalCount(50))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue(_helloWorld) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"d24bf63378b2\"") + .AssertNamedHeader(HttpNames.PagingSkipHeaderName, "0") + .AssertNamedHeader(HttpNames.PagingTakeHeaderName, PagingArgs.DefaultTake.ToString()) + .AssertNamedHeader(HttpNames.PagingTotalCountHeaderName, "50"); + } + + [Test] + public void GetWithResult_ItemsResult_Prev_Next_Paging_Links1() + { + // GetWithResult a mid page. + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$skip=2&$take=5&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ItemsResult(["a", "b", "c", "d", "e"], ro.PagingArgs).WithTotalCount(50))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.GetValues("Link").Should().Contain(["; rel=\"prev\"", "; rel=\"next\""]); + } + + [Test] + public void GetWithResult_ItemsResult_Prev_Next_Paging_Links2() + { + // GetWithResult the first page. + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$skip=0&$take=2&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ItemsResult(["a", "b"], ro.PagingArgs).WithTotalCount(50))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.GetValues("Link").Should().Contain(["; rel=\"next\""]); + } + + [Test] + public void GetWithResult_ItemsResult_Prev_Next_Paging_Links3() + { + // GetWithResult the last page. + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$skip=50&$take=2&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ItemsResult(["a"], ro.PagingArgs).WithTotalCount(51))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.GetValues("Link").Should().Contain(["; rel=\"prev\""]); + } + + [Test] + public void GetWithResult_ItemsResult_Prev_Next_Paging_Links4() + { + // GetWithResult a page with way too much skip - total count will help. + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$skip=250&$take=2&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ItemsResult([], ro.PagingArgs).WithTotalCount(50))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.GetValues("Link").Should().Contain(["; rel=\"prev\""]); + } + + [Test] + public void GetWithResult_ItemsResult_Prev_Next_Paging_Links5() + { + // GetWithResult a page with way too much skip - can only guess the previous. + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$skip=250&$take=2&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ItemsResult([], ro.PagingArgs))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.GetValues("Link").Should().Contain(["; rel=\"prev\""]); + } + + [Test] + public void GetWithResult_ItemsResult_Prev_Next_Paging_Links6() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get, "test?xyz=uvw&$take=2&$count&abc=def"); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ItemsResult([], ro.PagingArgs))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .Response.Headers.Should().NotContainKeys("Link"); + } + + [Test] + public void GetWithResult_Person_Default_ETag() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(Person.GetPerson())))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"firstName":"John","lastName":"Doe","age":30,"etag":"1f5d2ae86bc2"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"1f5d2ae86bc2\""); + } + + [Test] + public void GetWithResult_Person_Specified_ETag() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(Person.GetPerson("abcdefg"))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"firstName":"John","lastName":"Doe","age":30,"etag":"abcdefg"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"abcdefg\""); + } + + [Test] + public void GetWithResult_Person_Not_Modified() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + hr.Headers.IfNoneMatch = new EntityTagHeaderValue("\"abcdefg\"", true).ToString(); + + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(Person.GetPerson("abcdefg"))))) + .ToHttpResponseMessageAssertor(hr) + .AssertNotModified() + .AssertETagHeader("\"abcdefg\""); + } + + [Test] + public void GetWithResult_Person_Fields_Include() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + hr.QueryString = hr.QueryString.Add(HttpNames.IncludeFieldsQueryStringName, "firstname,LastName"); + + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(Person.GetPerson())))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"firstName":"John","lastName":"Doe"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"ddbd7f44c126\""); + } + + [Test] + public void GetWithResult_Person_Fields_Exclude() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + hr.QueryString = hr.QueryString.Add(HttpNames.ExcludeFieldsQueryStringName, "firstname,LastName"); + + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(Person.GetPerson())))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"age":30,"etag":"1f5d2ae86bc2"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"483e8079ddab\""); + } + + [Test] + public void GetWithResult_ValueResult_Default_StatusCode() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ValueResult("Hello World"))))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue("Hello World") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"07cf55095ef8\""); + } + + [Test] + public void GetWithResult_ValueResult_Specified_StatusCode() + { + var hr = Test.CreateHttpRequest(HttpMethod.Get); + Test.Type() + .Run(async w => await w.GetWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(new ValueResult("Hello World", System.Net.HttpStatusCode.Accepted))))) + .ToHttpResponseMessageAssertor(hr) + .Assert(System.Net.HttpStatusCode.Accepted) + .AssertValue("Hello World") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"07cf55095ef8\""); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.MergePatch.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.MergePatch.cs new file mode 100644 index 00000000..ea9471e7 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.MergePatch.cs @@ -0,0 +1,89 @@ +using CoreEx.Http; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void MergePatch_Invalid_ContentType() + { + Test.Type() + .Run(async w => await w.PatchAsync(Test.CreateHttpRequest(HttpMethod.Patch, "test", r => r.ContentType = MediaTypeNames.Text.Plain), (ro, ct) => throw new InvalidOperationException(), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .Assert(HttpStatusCode.UnsupportedMediaType, "Unsupported 'Content-Type' for an HTTP PATCH; only JSON Merge Patch is supported using either: 'application/merge-patch+json' or 'application/json'."); + } + + [Test] + public void MergePatch_No_Body() + { + Test.Type() + .Run(async w => await w.PatchAsync(Test.CreateHttpRequest(HttpMethod.Patch, "test", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName), (ro, ct) => throw new InvalidOperationException(), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .AssertBadRequest() + .AssertErrors(new ApiError("value", "Value is required.")); + } + + [Test] + public void MergePatch_Invalid_Json() + { + Test.Type() + .Run(async w => await w.PatchAsync(Test.CreateHttpRequest(HttpMethod.Patch, "test", "", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName), (ro, ct) => throw new InvalidOperationException(), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .AssertBadRequest() + .AssertErrors(new ApiError("value", "Value is invalid: '<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.")); + } + + [Test] + public void MergePatch_Not_Found() + { + Test.Type() + .Run(async w => await w.PatchAsync(Test.CreateHttpRequest(HttpMethod.Patch, "test", "{}", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName), (ro, ct) => Task.FromResult(null), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .AssertNotFound(); + } + + [Test] + public void MergePatch_Concurrency() + { + var hr = Test.CreateHttpRequest(HttpMethod.Patch, "test", """{"age": 30}""", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName); + hr.Headers.IfMatch = new EntityTagHeaderValue("\"123456\"", true).ToString(); + + Test.Type() + .Run(async w => await w.PatchAsync(hr, (ro, ct) => Task.FromResult(Person.GetPerson("abcdefg")), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .AssertPreconditionFailed(); + } + + [Test] + public void MergePatch_No_Changes() + { + var hr = Test.CreateHttpRequest(HttpMethod.Patch, "test", """{"age": 30}""", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName); + hr.Headers.IfMatch = new EntityTagHeaderValue("\"abcdef\"", true).ToString(); + + // As there are no changes as a result of merging then the corresponding put operation should not be invoked; should just return as-if it had done something :-) + Test.Type() + .Run(async w => await w.PatchAsync(hr, (ro, ct) => Task.FromResult(Person.GetPerson("abcdef")), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertValue(Person.GetPerson("abcdef")); + } + + [Test] + public void MergePatch_With_Changes() + { + var hr = Test.CreateHttpRequest(HttpMethod.Patch, "test", """{"age": 40}""", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName); + hr.Headers.IfMatch = new EntityTagHeaderValue("\"abcdef\"", true).ToString(); + + var ep = Person.GetPerson("qrstuv"); + ep.Age = 40; + + Test.Type() + .Run(async w => await w.PatchAsync(hr, (ro, ct) => Task.FromResult(Person.GetPerson("abcdef")), (ro, ct) => Task.FromResult(ro.Value.Adjust(p => p.ETag = "qrstuv")))) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertValue(ep, "etag"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.MergePatchWithResult.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.MergePatchWithResult.cs new file mode 100644 index 00000000..e235e23c --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.MergePatchWithResult.cs @@ -0,0 +1,90 @@ +using CoreEx.Http; +using CoreEx.Results; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void MergePatchWithResult_Invalid_ContentType() + { + Test.Type() + .Run(async w => await w.PatchWithResultAsync(Test.CreateHttpRequest(HttpMethod.Patch, "test", r => r.ContentType = MediaTypeNames.Text.Plain), (ro, ct) => throw new InvalidOperationException(), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .Assert(HttpStatusCode.UnsupportedMediaType, "Unsupported 'Content-Type' for an HTTP PATCH; only JSON Merge Patch is supported using either: 'application/merge-patch+json' or 'application/json'."); + } + + [Test] + public void MergePatchWithResult_No_Body() + { + Test.Type() + .Run(async w => await w.PatchWithResultAsync(Test.CreateHttpRequest(HttpMethod.Patch, "test", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName), (ro, ct) => throw new InvalidOperationException(), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .AssertBadRequest() + .AssertErrors(new ApiError("value", "Value is required.")); + } + + [Test] + public void MergePatchWithResult_Invalid_Json() + { + Test.Type() + .Run(async w => await w.PatchWithResultAsync(Test.CreateHttpRequest(HttpMethod.Patch, "test", "", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName), (ro, ct) => throw new InvalidOperationException(), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .AssertBadRequest() + .AssertErrors(new ApiError("value", "Value is invalid: '<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.")); + } + + [Test] + public void MergePatchWithResult_Not_Found() + { + Test.Type() + .Run(async w => await w.PatchWithResultAsync(Test.CreateHttpRequest(HttpMethod.Patch, "test", "{}", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName), (ro, ct) => Task.FromResult(Result.Ok(null)), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .AssertNotFound(); + } + + [Test] + public void MergePatchWithResult_Concurrency() + { + var hr = Test.CreateHttpRequest(HttpMethod.Patch, "test", """{"age": 30}""", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName); + hr.Headers.IfMatch = new EntityTagHeaderValue("\"123456\"", true).ToString(); + + Test.Type() + .Run(async w => await w.PatchWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(Person.GetPerson("abcdefg"))), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .AssertPreconditionFailed(); + } + + [Test] + public void MergePatchWithResult_No_Changes() + { + var hr = Test.CreateHttpRequest(HttpMethod.Patch, "test", """{"age": 30}""", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName); + hr.Headers.IfMatch = new EntityTagHeaderValue("\"abcdef\"", true).ToString(); + + // As there are no changes as a result of merging then the corresponding put operation should not be invoked; should just return as-if it had done something :-) + Test.Type() + .Run(async w => await w.PatchWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(Person.GetPerson("abcdef"))), (ro, ct) => throw new InvalidOperationException())) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertValue(Person.GetPerson("abcdef")); + } + + [Test] + public void MergePatchWithResult_With_Changes() + { + var hr = Test.CreateHttpRequest(HttpMethod.Patch, "test", """{"age": 40}""", r => r.ContentType = HttpNames.MergePatchJsonMediaTypeName); + hr.Headers.IfMatch = new EntityTagHeaderValue("\"abcdef\"", true).ToString(); + + var ep = Person.GetPerson("qrstuv"); + ep.Age = 40; + + Test.Type() + .Run(async w => await w.PatchWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(Person.GetPerson("abcdef"))), (ro, ct) => Task.FromResult(Result.Ok(ro.Value.Adjust(p => p.ETag = "qrstuv"))))) + .ToHttpResponseMessageAssertor() + .AssertOK() + .AssertValue(ep, "etag"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Patch.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Patch.cs new file mode 100644 index 00000000..5d4935c7 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Patch.cs @@ -0,0 +1,120 @@ +using System.Net; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void Patch_No_Body_No_Response() + { + Test.Type() + .Run(async w => await w.PatchAsync(Test.CreateHttpRequest(HttpMethod.Patch), (ro, ct) => Task.CompletedTask)) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Patch_Body_No_Response() + { + Test.Type() + .Run(async w => await w.PatchAsync(Test.CreateJsonHttpRequest(HttpMethod.Patch, "test", Person.GetPerson("xx")), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Task.CompletedTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Patch_Value_No_Response() + { + Test.Type() + .Run(async w => await w.PatchAsync(Test.CreateHttpRequest(HttpMethod.Patch), Person.GetPerson("xx"), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Task.CompletedTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Patch_No_Body_With_Response() + { + var person = Person.GetPerson("abcdefg"); + var hr = Test.CreateHttpRequest(HttpMethod.Patch); + + Test.Type() + .Run(async w => await w.PatchAsync(hr, (ro, ct) => Task.FromResult(person))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue(person) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"abcdefg\""); + } + + [Test] + public void Patch_No_Body_With_Null_Response() + { + var person = Person.GetPerson("abcdefg"); + + Test.Type() + .Run(async w => await w.PatchAsync(Test.CreateHttpRequest(HttpMethod.Patch), (ro, ct) => Task.FromResult(null))) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Patch_Body_With_Response() + { + var hr = Test.CreateJsonHttpRequest(HttpMethod.Patch, "test", Person.GetPerson("xx")); + Test.Type() + .Run(async w => await w.PatchAsync(hr, (ro, ct) => + { + var p = new Person2 + { + LastName = ro.ValueOrDefault!.LastName + "X", + FirstName = ro.ValueOrDefault.FirstName + "Y", + Age = ro.ValueOrDefault.Age + 10, + ETag = "123456" + }; + return Task.FromResult(p); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } + + [Test] + public void Patch_Value_With_Response() + { + var hr = Test.CreateHttpRequest(HttpMethod.Patch); + Test.Type() + .Run(async w => await w.PatchAsync(hr, Person.GetPerson("xx"), (ro, ct) => + { + var p = new Person2 + { + LastName = ro.ValueOrDefault!.LastName + "X", + FirstName = ro.ValueOrDefault.FirstName + "Y", + Age = ro.ValueOrDefault.Age + 10, + ETag = "123456" + }; + return Task.FromResult(p); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PatchWithResult.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PatchWithResult.cs new file mode 100644 index 00000000..0e7188f1 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PatchWithResult.cs @@ -0,0 +1,121 @@ +using CoreEx.Results; +using System.Net; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void PatchWithResult_No_Body_No_Response() + { + Test.Type() + .Run(async w => await w.PatchWithResultAsync(Test.CreateHttpRequest(HttpMethod.Patch), (ro, ct) => Task.FromResult(Result.Success))) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PatchWithResult_Body_No_Response() + { + Test.Type() + .Run(async w => await w.PatchWithResultAsync(Test.CreateJsonHttpRequest(HttpMethod.Patch, "test", Person.GetPerson("xx")), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Result.SuccessTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PatchWithResult_Value_No_Response() + { + Test.Type() + .Run(async w => await w.PatchWithResultAsync(Test.CreateHttpRequest(HttpMethod.Patch), Person.GetPerson("xx"), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Result.SuccessTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PatchWithResult_No_Body_With_Response() + { + var person = Person.GetPerson("abcdefg"); + var hr = Test.CreateHttpRequest(HttpMethod.Patch); + + Test.Type() + .Run(async w => await w.PatchWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(person)))) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertValue(person) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"abcdefg\""); + } + + [Test] + public void PatchWithResult_No_Body_With_Null_Response() + { + var person = Person.GetPerson("abcdefg"); + + Test.Type() + .Run(async w => await w.PatchWithResultAsync(Test.CreateHttpRequest(HttpMethod.Patch), (ro, ct) => Result.Ok(null!).AsTask())) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PatchWithResult_Body_With_Response() + { + var hr = Test.CreateJsonHttpRequest(HttpMethod.Patch, "test", Person.GetPerson("xx")); + Test.Type() + .Run(async w => await w.PatchWithResultAsync(hr, (ro, ct) => + { + var p = new Person2 + { + LastName = ro.ValueOrDefault!.LastName + "X", + FirstName = ro.ValueOrDefault.FirstName + "Y", + Age = ro.ValueOrDefault.Age + 10, + ETag = "123456" + }; + return Task.FromResult(Result.Ok(p)); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } + + [Test] + public void PatchWithResult_Value_With_Response() + { + var hr = Test.CreateHttpRequest(HttpMethod.Patch); + Test.Type() + .Run(async w => await w.PatchWithResultAsync(hr, Person.GetPerson("xx"), (ro, ct) => + { + var p = new Person2 + { + LastName = ro.ValueOrDefault!.LastName + "X", + FirstName = ro.ValueOrDefault.FirstName + "Y", + Age = ro.ValueOrDefault.Age + 10, + ETag = "123456" + }; + return Task.FromResult(Result.Ok(p)); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Post.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Post.cs new file mode 100644 index 00000000..2c6012f5 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Post.cs @@ -0,0 +1,136 @@ +using CoreEx.Results; +using System.Net; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void Post_No_Body_No_Response() + { + Test.Type() + .Run(async w => await w.PostAsync(Test.CreateHttpRequest(HttpMethod.Post), (ro, ct) => Task.CompletedTask)) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Post_No_Body_No_Response_With_Location() + { + var hr = Test.CreateHttpRequest(HttpMethod.Post); + Test.Type() + .Run(async w => await w.PostAsync(hr, (ro, ct) => + { + ro.WithLocationUri(() => new Uri("test", UriKind.Relative)); + return Task.CompletedTask; + }, HttpStatusCode.Created)) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertLocationHeaderContains("test"); + } + + [Test] + public void Post_Body_No_Response() + { + Test.Type() + .Run(async w => await w.PostAsync(Test.CreateJsonHttpRequest(HttpMethod.Post, "test", Person.GetPerson()), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Task.CompletedTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Post_Value_No_Response() + { + Test.Type() + .Run(async w => await w.PostAsync(Test.CreateHttpRequest(HttpMethod.Post), Person.GetPerson(), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Task.CompletedTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Post_No_Body_With_Response() + { + var person = Person.GetPerson("abcdefg"); + var hr = Test.CreateHttpRequest(HttpMethod.Post); + + Test.Type() + .Run(async w => await w.PostAsync(hr, (ro, ct) => Task.FromResult(person))) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertValue(person) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"abcdefg\""); + } + + [Test] + public void Post_No_Body_With_Null_Response() + { + var person = Person.GetPerson("abcdefg"); + + Test.Type() + .Run(async w => await w.PostAsync(Test.CreateHttpRequest(HttpMethod.Post), (ro, ct) => Task.FromResult((Person)null!))) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Post_Body_With_Response() + { + var hr = Test.CreateJsonHttpRequest(HttpMethod.Post, "test", Person.GetPerson()); + Test.Type() + .Run(async w => await w.PostAsync(hr, (ro, ct) => + { + var p = new Person2 + { + LastName = ro.ValueOrDefault!.LastName + "X", + FirstName = ro.ValueOrDefault.FirstName + "Y", + Age = ro.ValueOrDefault.Age + 10, + ETag = "123456" + }; + return Task.FromResult(p); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } + + [Test] + public void Post_Value_With_Response() + { + var hr = Test.CreateHttpRequest(HttpMethod.Post); + Test.Type() + .Run(async w => await w.PostAsync(hr, Person.GetPerson(), (ro, ct) => + { + var p = new Person2 + { + LastName = ro.ValueOrDefault!.LastName + "X", + FirstName = ro.ValueOrDefault.FirstName + "Y", + Age = ro.ValueOrDefault.Age + 10, + ETag = "123456" + }; + return Task.FromResult(p); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PostWithResult.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PostWithResult.cs new file mode 100644 index 00000000..8fa1ea84 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PostWithResult.cs @@ -0,0 +1,136 @@ +using CoreEx.Results; +using System.Net; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void PostWithResult_No_Body_No_Response() + { + Test.Type() + .Run(async w => await w.PostWithResultAsync(Test.CreateHttpRequest(HttpMethod.Post), (ro, ct) => Task.FromResult(Result.Success))) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PostWithResult_No_Body_No_Response_With_Location() + { + var hr = Test.CreateHttpRequest(HttpMethod.Post); + Test.Type() + .Run(async w => await w.PostWithResultAsync(hr, (ro, ct) => + { + ro.WithLocationUri(() => new Uri("test", UriKind.Relative)); + return Task.FromResult(Result.Success); + }, HttpStatusCode.Created)) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertLocationHeaderContains("test"); + } + + [Test] + public void PostWithResult_Body_No_Response() + { + Test.Type() + .Run(async w => await w.PostWithResultAsync(Test.CreateJsonHttpRequest(HttpMethod.Post, "test", Person.GetPerson()), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Result.SuccessTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PostWithResult_Value_No_Response() + { + Test.Type() + .Run(async w => await w.PostWithResultAsync(Test.CreateHttpRequest(HttpMethod.Post), Person.GetPerson(), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Result.SuccessTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PostWithResult_No_Body_With_Response() + { + var person = Person.GetPerson("abcdefg"); + var hr = Test.CreateHttpRequest(HttpMethod.Post); + + Test.Type() + .Run(async w => await w.PostWithResultAsync(hr, (ro, ct) => Task.FromResult(Result.Ok(person)))) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertValue(person) + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"abcdefg\""); + } + + [Test] + public void PostWithResult_No_Body_With_Null_Response() + { + var person = Person.GetPerson("abcdefg"); + + Test.Type() + .Run(async w => await w.PostWithResultAsync(Test.CreateHttpRequest(HttpMethod.Post), (ro, ct) => Result.Ok(null!).AsTask())) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PostWithResult_Body_With_Response() + { + var hr = Test.CreateJsonHttpRequest(HttpMethod.Post, "test", Person.GetPerson()); + Test.Type() + .Run(async w => await w.PostWithResultAsync(hr, (ro, ct) => + { + var p = new Person2 + { + LastName = ro.ValueOrDefault!.LastName + "X", + FirstName = ro.ValueOrDefault.FirstName + "Y", + Age = ro.ValueOrDefault.Age + 10, + ETag = "123456" + }; + return Task.FromResult(Result.Ok(p)); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } + + [Test] + public void PostWithResult_Value_With_Response() + { + var hr = Test.CreateHttpRequest(HttpMethod.Post); + Test.Type() + .Run(async w => await w.PostWithResultAsync(hr, Person.GetPerson(), (ro, ct) => + { + var p = new Person2 + { + LastName = ro.ValueOrDefault!.LastName + "X", + FirstName = ro.ValueOrDefault.FirstName + "Y", + Age = ro.ValueOrDefault.Age + 10, + ETag = "123456" + }; + return Task.FromResult(Result.Ok(p)); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertCreated() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Put.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Put.cs new file mode 100644 index 00000000..fa468008 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.Put.cs @@ -0,0 +1,123 @@ +using System.Net.Http.Headers; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void Put_Body_No_Response() + { + Test.Type() + .Run(async w => await w.PutAsync(Test.CreateJsonHttpRequest(HttpMethod.Put, "test", Person.GetPerson("xx")), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Task.CompletedTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Put_Value_No_Response() + { + Test.Type() + .Run(async w => await w.PutAsync(Test.CreateHttpRequest(HttpMethod.Put), Person.GetPerson("xx"), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Task.CompletedTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Put_Body_With_Response() + { + var hr = Test.CreateJsonHttpRequest(HttpMethod.Put, "test", Person.GetPerson("xx")); + Test.Type() + .Run(async w => await w.PutAsync(hr, (ro, ct) => + { + var p = new Person2(); + p.LastName = ro.ValueOrDefault!.LastName + "X"; + p.FirstName = ro.ValueOrDefault.FirstName + "Y"; + p.Age = ro.ValueOrDefault.Age + 10; + p.ETag = "123456"; + return Task.FromResult(p); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } + + [Test] + public void Put_Value_With_Response() + { + var hr = Test.CreateHttpRequest(HttpMethod.Put); + Test.Type() + .Run(async w => await w.PutAsync(hr, Person.GetPerson("xx"), (ro, ct) => + { + var p = new Person2(); + p.LastName = ro.ValueOrDefault!.LastName + "X"; + p.FirstName = ro.ValueOrDefault.FirstName + "Y"; + p.Age = ro.ValueOrDefault.Age + 10; + p.ETag = "123456"; + return Task.FromResult(p); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } + + [Test] + public void Put_Body_No_Response_IfMatch() + { + var hr = Test.CreateJsonHttpRequest(HttpMethod.Put, "test", Person.GetPerson("123456")); + hr.Headers.IfMatch = new EntityTagHeaderValue("\"abcdefg\"", true).ToString(); + + Test.Type() + .Run(async w => await w.PutAsync(hr, (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + ro.ValueOrDefault.ETag.Should().Be("abcdefg"); // ETag should have been overridden. + ro.ETag.Should().Be("abcdefg"); + return Task.CompletedTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void Put_Value_No_Response_IfMatch() + { + var hr = Test.CreateHttpRequest(HttpMethod.Put); + hr.Headers.IfMatch = new EntityTagHeaderValue("\"abcdefg\"", true).ToString(); + + Test.Type() + .Run(async w => await w.PutAsync(hr, Person.GetPerson("123456"), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + ro.ValueOrDefault.ETag.Should().Be("abcdefg"); // ETag should have been overridden. + ro.ETag.Should().Be("abcdefg"); + return Task.CompletedTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PutWithResult.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PutWithResult.cs new file mode 100644 index 00000000..561937a9 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.PutWithResult.cs @@ -0,0 +1,124 @@ +using CoreEx.Results; +using System.Net.Http.Headers; +using System.Net.Mime; + +namespace CoreEx.AspNetCore.Test.Unit; + +partial class WebApiTestsBase +{ + [Test] + public void PutWithResult_Body_No_Response() + { + Test.Type() + .Run(async w => await w.PutWithResultAsync(Test.CreateJsonHttpRequest(HttpMethod.Put, "test", Person.GetPerson("xx")), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Result.SuccessTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PutWithResult_Value_No_Response() + { + Test.Type() + .Run(async w => await w.PutWithResultAsync(Test.CreateHttpRequest(HttpMethod.Put), Person.GetPerson("xx"), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + return Result.SuccessTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PutWithResult_Body_With_Response() + { + var hr = Test.CreateJsonHttpRequest(HttpMethod.Put, "test", Person.GetPerson("xx")); + Test.Type() + .Run(async w => await w.PutWithResultAsync(hr, (ro, ct) => + { + var p = new Person2(); + p.LastName = ro.ValueOrDefault!.LastName + "X"; + p.FirstName = ro.ValueOrDefault.FirstName + "Y"; + p.Age = ro.ValueOrDefault.Age + 10; + p.ETag = "123456"; + return Task.FromResult(Result.Ok(p)); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } + + [Test] + public void PutWithResult_Value_With_Response() + { + var hr = Test.CreateHttpRequest(HttpMethod.Put); + Test.Type() + .Run(async w => await w.PutWithResultAsync(hr, Person.GetPerson("xx"), (ro, ct) => + { + var p = new Person2(); + p.LastName = ro.ValueOrDefault!.LastName + "X"; + p.FirstName = ro.ValueOrDefault.FirstName + "Y"; + p.Age = ro.ValueOrDefault.Age + 10; + p.ETag = "123456"; + return Task.FromResult(Result.Ok(p)); + })) + .ToHttpResponseMessageAssertor(hr) + .AssertOK() + .AssertJson("""{"firstName":"JohnY","lastName":"DoeX","age":40,"etag":"123456"}""") + .AssertContentType(MediaTypeNames.Application.Json) + .AssertETagHeader("\"123456\""); + } + + [Test] + public void PutWithResult_Body_No_Response_IfMatch() + { + var hr = Test.CreateJsonHttpRequest(HttpMethod.Put, "test", Person.GetPerson("123456")); + hr.Headers.IfMatch = new EntityTagHeaderValue("\"abcdefg\"", true).ToString(); + + Test.Type() + .Run(async w => await w.PutWithResultAsync(hr, (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + ro.ValueOrDefault.ETag.Should().Be("abcdefg"); // ETag should have been overridden. + ro.ETag.Should().Be("abcdefg"); + return Result.SuccessTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } + + [Test] + public void PutWithResult_Value_No_Response_IfMatch() + { + var hr = Test.CreateHttpRequest(HttpMethod.Put); + hr.Headers.IfMatch = new EntityTagHeaderValue("\"abcdefg\"", true).ToString(); + + Test.Type() + .Run(async w => await w.PutWithResultAsync(hr, Person.GetPerson("123456"), (ro, ct) => + { + ro.ValueOrDefault.Should().NotBeNull(); + ro.ValueOrDefault.FirstName.Should().Be("John"); + ro.ValueOrDefault.LastName.Should().Be("Doe"); + ro.ValueOrDefault.Age.Should().Be(30); + ro.ValueOrDefault.ETag.Should().Be("abcdefg"); // ETag should have been overridden. + ro.ETag.Should().Be("abcdefg"); + return Result.SuccessTask; + })) + .ToHttpResponseMessageAssertor() + .AssertNoContent(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.cs new file mode 100644 index 00000000..42e90aac --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApiTestsBase.cs @@ -0,0 +1,19 @@ +using CoreEx.Entities; + +namespace CoreEx.AspNetCore.Test.Unit; + +[Parallelizable] +public abstract partial class WebApiTestsBase : WithApiTester where TWebApi : Abstractions.WebApi +{ + public class Person : IETag + { + public static Person GetPerson(string? etag = null) => new() { FirstName = "John", LastName = "Doe", Age = 30, ETag = etag }; + + public string? FirstName { get; set; } + public string? LastName { get; set; } + public int? Age { get; set; } + public string? ETag { get; set; } + } + + public class Person2 : Person { } +} \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApi_HttpTests.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApi_HttpTests.cs new file mode 100644 index 00000000..b595d687 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApi_HttpTests.cs @@ -0,0 +1,6 @@ +using Microsoft.AspNetCore.Http; + +namespace CoreEx.AspNetCore.Test.Unit; + +[TestFixture] +public class WebApi_HttpTests : WebApiTestsBase { } \ No newline at end of file diff --git a/tests/CoreEx.AspNetCore.Test.Unit/WebApi_MvcTests.cs b/tests/CoreEx.AspNetCore.Test.Unit/WebApi_MvcTests.cs new file mode 100644 index 00000000..0d817420 --- /dev/null +++ b/tests/CoreEx.AspNetCore.Test.Unit/WebApi_MvcTests.cs @@ -0,0 +1,6 @@ +using Microsoft.AspNetCore.Mvc; + +namespace CoreEx.AspNetCore.Test.Unit; + +[TestFixture] +public class WebApi_MvcTests : WebApiTestsBase { } \ No newline at end of file diff --git a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/CoreEx.Azure.Messaging.ServiceBus.Test.Unit.csproj b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/CoreEx.Azure.Messaging.ServiceBus.Test.Unit.csproj new file mode 100644 index 00000000..c46bf036 --- /dev/null +++ b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/CoreEx.Azure.Messaging.ServiceBus.Test.Unit.csproj @@ -0,0 +1,38 @@ + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/EntryPoint.cs b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/EntryPoint.cs new file mode 100644 index 00000000..b4e4d712 --- /dev/null +++ b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/EntryPoint.cs @@ -0,0 +1,33 @@ +using CoreEx.Azure.Messaging.ServiceBus.Test.Unit.Subscribers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace CoreEx.Azure.Messaging.ServiceBus.Test.Unit; + +public class EntryPoint +{ + public static void ConfigureApplication(IHostApplicationBuilder builder) + { + // Add CoreEx host settings. + builder.AddHostSettings("CoreEx.Azure.Messaging.ServiceBus", "UnitTest", new Uri("urn:unit-test")); + + // Add CoreEx services. + builder.Services + .AddExecutionContext() + .AddEventFormatter() + .AddFixedDestinationProvider("unit-test") + .AddAzureServiceBusPublisher(); + + // Add azure service bus client using aspire. + builder.AddAzureServiceBusClient("ServiceBus"); + + // Add Receiving and Subscribing services. + builder.Services + .AddScoped() + .AddSubscribedManager((_, mgr) => mgr.AddSubscriber()) + .AzureServiceBusReceiving() + .WithReceiver(_ => ServiceBusReceiverOptions.CreateForTopicSubscription("unit-test", "default")) + .WithSubscribedSubscriber((_, c) => c.ErrorHandler.Add(Events.Subscribing.ErrorHandling.Catastrophic)) + .Build(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusMessageTests.cs b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusMessageTests.cs new file mode 100644 index 00000000..bc3eb2df --- /dev/null +++ b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusMessageTests.cs @@ -0,0 +1,85 @@ +using Azure.Messaging.ServiceBus; +using CloudNative.CloudEvents; +using CoreEx.Entities; +using CoreEx.Events; + +namespace CoreEx.Azure.Messaging.ServiceBus.Test.Unit; + +public class ServiceBusMessageTests +{ + [Test] + public void CloudEventToServiceBusMessage_Structured() + { + var p = new Product { Id = 1, Sku = "SKU-001" }; + + var ed = EventData.CreateEventWith(p, EventAction.Published).WithTitle("unit.test.title").WithSource(new Uri("http://unit.test.source")); + var ce = new EventFormatter().ConvertToCloudEvent(ed); + + var sbm = ce.ToServiceBusMessage(ContentMode.Structured); + sbm.Should().NotBeNull(); + sbm.ContentType.Should().Be("application/cloudevents+json; charset=utf-8"); + + var sbrm = ConvertToReceivedMessage(sbm); + var ce2 = sbrm.ToCloudEvent(); // Inferred + + var jce = ce.EncodeToJsonElement(); + var jce2 = ce2.EncodeToJsonElement(); + ObjectComparer.Assert(jce, jce2); + } + + [Test] + public void CloudEventToServiceBusMessage_Binary() + { + var p = new Product { Id = 1, Sku = "SKU-001" }; + + var ed = EventData.CreateEventWith(p, EventAction.Published).WithTitle("unit.test.title").WithSource(new Uri("http://unit.test.source")); + var ce = new EventFormatter().ConvertToCloudEvent(ed); + + var sbm = ce.ToServiceBusMessage(ContentMode.Binary); + sbm.Should().NotBeNull(); + + var sbrm = ConvertToReceivedMessage(sbm); + var ce2 = sbrm.ToCloudEvent(); // Inferred + + var jce = ce.EncodeToJsonElement(); + var jce2 = ce2.EncodeToJsonElement(); + + Console.WriteLine(jce); + Console.WriteLine(jce2); + + ObjectComparer.Assert(jce, jce2); + } + + public class Product : IIdentifier + { + public int Id { get; set; } + public string? Sku { get; set; } + } + + internal static ServiceBusReceivedMessage ConvertToReceivedMessage(ServiceBusMessage m) + { + // Copy application properties + var props = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in m.ApplicationProperties) + props[kvp.Key] = kvp.Value; + + return ServiceBusModelFactory.ServiceBusReceivedMessage( + body: m.Body, + messageId: m.MessageId, + partitionKey: m.PartitionKey, + sessionId: m.SessionId, + replyToSessionId: m.ReplyToSessionId, + timeToLive: m.TimeToLive, + correlationId: m.CorrelationId, + subject: m.Subject, + to: m.To, + contentType: m.ContentType, + replyTo: m.ReplyTo, + scheduledEnqueueTime: m.ScheduledEnqueueTime, + properties: props, + deliveryCount: 1, + sequenceNumber: DateTimeOffset.UtcNow.Ticks, + enqueuedTime: DateTimeOffset.UtcNow + ); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusPublisherTests.cs b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusPublisherTests.cs new file mode 100644 index 00000000..bebcc147 --- /dev/null +++ b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusPublisherTests.cs @@ -0,0 +1,60 @@ +using CoreEx.Events.Publishing; +using Microsoft.Extensions.DependencyInjection; +using UnitTestEx.Expectations; + +namespace CoreEx.Azure.Messaging.ServiceBus.Test.Unit; + +public class ServiceBusPublisherTests : WithGenericTester +{ + [Test] + public void PublishAsync_SingleBatchOfOne() => Test.ScopedType(test => + { + test.ExpectLogContains("Sending batch of 1 event(s) to destination 'unit-test'.") + .ExpectLogContains("SendAsync start. MessageCount = 1 [Azure.Messaging.ServiceBus]") + .Run(async ec => + { + var sbp = (ServiceBusPublisher)ec.ServiceProvider!.GetRequiredKeyedService(ServiceBusPublisher.DefaultServiceKey); + + sbp.Add(Events.EventData.CreateEvent("Entity", "Action").WithPartitionKey()); + + sbp.SessionIdStrategy = ServiceBusSessionStrategy.None; + await sbp.PublishAsync(); + }).AssertSuccess(); + }); + + [Test] + public void PublishAsync_MultiBatch() => Test.ScopedType(test => + { + test.ExpectLogContains("Sending batch of 2 event(s) to destination 'unit-test'.") + .ExpectLogContains("Sending batch of 1 event(s) to destination 'unit-test-2'.") + .ExpectLogContains("SendAsync start. MessageCount = 2 [Azure.Messaging.ServiceBus]") + .ExpectLogContains("SendAsync start. MessageCount = 1 [Azure.Messaging.ServiceBus]") + .Run(async ec => + { + var sbp = (ServiceBusPublisher)ec.ServiceProvider!.GetRequiredKeyedService(ServiceBusPublisher.DefaultServiceKey); + + sbp.Add(Events.EventData.CreateEvent("Entity", "Action1").WithPartitionKey()); + sbp.Add(Events.EventData.CreateEvent("Entity", "Action2").WithPartitionKey()); + sbp.Add("unit-test-2", Events.EventData.CreateEvent("Entity", "Action3").WithPartitionKey()); + + sbp.SessionIdStrategy = ServiceBusSessionStrategy.None; + await sbp.PublishAsync(); + }).AssertSuccess(); + }); + + [Test] + public void PublishAsync_Single_UseSessions() => Test.ScopedType(test => + { + test.ExpectLogContains("Sending batch of 1 event(s) to destination 'unit-test'.") + .ExpectLogContains("SendAsync start. MessageCount = 1 [Azure.Messaging.ServiceBus]") + .Run(async ec => + { + var sbp = (ServiceBusPublisher)ec.ServiceProvider!.GetRequiredKeyedService(ServiceBusPublisher.DefaultServiceKey); + + sbp.Add(Events.EventData.CreateEvent("Entity", "Action").WithKey("123")); + + sbp.SessionIdStrategy = ServiceBusSessionStrategy.UsePartitionKeyAsIs; + await sbp.PublishAsync(); + }).AssertSuccess(); + }); +} \ No newline at end of file diff --git a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusReceiverTests.cs b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusReceiverTests.cs new file mode 100644 index 00000000..75e27c8f --- /dev/null +++ b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusReceiverTests.cs @@ -0,0 +1,231 @@ +using Azure.Messaging.ServiceBus; +using CoreEx.Events; +using CoreEx.Events.Publishing; +using CoreEx.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using UnitTestEx.Expectations; + +namespace CoreEx.Azure.Messaging.ServiceBus.Test.Unit; + +public class ServiceBusReceiverTests : WithGenericTester +{ + [SetUp] + public async Task SetUpAsync() + { + var c = Test.Services.GetRequiredService(); + await using var receiver = c.CreateReceiver("unit-test", "default"); + + while (true) + { + var messages = await receiver.ReceiveMessagesAsync(maxMessages: 50, maxWaitTime: TimeSpan.FromMilliseconds(1)); + if (messages.Count == 0) + break; + + foreach (var m in messages) + await receiver.CompleteMessageAsync(m); + } + } + + [Test] + public void Receiver_Cycle_States() => Test.ScopedType(async test => + { + // Create using the root services (not scoped). + var sbr = Test.Services.GetRequiredService>(); + + Test.Run(async () => + { + sbr.Status.Should().Be(ServiceStatus.Initializing); + sbr.StatusReason.Should().BeNull(); + + await sbr.StartAsync().ConfigureAwait(false); + sbr.Status.Should().Be(ServiceStatus.Running); + sbr.StatusReason.Should().BeNull(); + + await sbr.PauseAsync("Reason").ConfigureAwait(false); + sbr.Status.Should().Be(ServiceStatus.Paused); + sbr.StatusReason.Should().Be("Reason"); + + await sbr.ResumeAsync().ConfigureAwait(false); + sbr.Status.Should().Be(ServiceStatus.Running); + sbr.StatusReason.Should().BeNull(); + + await sbr.StopAsync().ConfigureAwait(false); + sbr.Status.Should().Be(ServiceStatus.Stopped); + sbr.StatusReason.Should().BeNull(); + }).AssertSuccess(); + }); + + [Test] + public void ReceiveAsync_Success() => Test.ScopedType(async test => + { + // Publish a message. + var sp = (ServiceBusPublisher)test.Services.GetRequiredKeyedService(ServiceBusPublisher.DefaultServiceKey); + sp.Add(EventData.CreateEventWith(new Subscribers.Product { Id = 1, Sku = "SKU-001" }, "Created")); + await sp.PublishAsync(); + + // Create using the root services (not scoped). + var o = ServiceBusReceiverOptions.CreateForTopicSubscription("unit-test", "default"); + var sbr = ActivatorUtilities.CreateInstance>(Test.Services, o); + + var cts = new CancellationTokenSource(); + cts.CancelAfter(10000); // Ensure test doesn't run indefinitely. + sbr.MessageProcessed += (sender, e) => cts.CancelAfter(5); // Cancel shortly after processing to allow for graceful completion. + + // Act and assert. + Test.ExpectLogContains("Received product with Id: 1 and Sku: SKU-001.") + .Run(async () => + { + try + { + await sbr.StartAsync(cts.Token).ConfigureAwait(false); + await Task.Delay(Timeout.Infinite, cts.Token); // Wait for the message to be processed or timeout; then stop and dispose. + } + finally + { + await sbr.StopAsync().ConfigureAwait(false); + await sbr.DisposeAsync().ConfigureAwait(false); + } + }).AssertException(); + }); + + [Test] + public void ReceiveAsync_Retry_Then_DeadLetter() => Test.ScopedType(async test => + { + // Publish a message. + var sp = (ServiceBusPublisher)test.Services.GetRequiredKeyedService(ServiceBusPublisher.DefaultServiceKey); + sp.Add(EventData.CreateEventWith(new Subscribers.Product { Id = 88, Sku = "SKU-088" }, "Created")); + await sp.PublishAsync(); + + // Create using the root services (not scoped). + var o = ServiceBusReceiverOptions.CreateForTopicSubscription("unit-test", "default"); + o.RetryErrorHandling = Events.Subscribing.ErrorHandling.DeadLetter; + o.MessageResiliency = ServiceBusReceiverResiliency.CreateMessageRetryResiliency(TimeSpan.FromMilliseconds(333), 3, Polly.DelayBackoffType.Exponential); + o.PerUnhandledErrorDelayDuration = TimeSpan.FromMilliseconds(100); + + var sbr = ActivatorUtilities.CreateInstance>(Test.Services, o); + + var cts = new CancellationTokenSource(); + cts.CancelAfter(10000); // Ensure test doesn't run indefinitely. + sbr.MessageProcessed += (sender, e) => cts.CancelAfter(10); // Cancel shortly after processing to allow for graceful completion. + + // Act and assert. + Test.ExpectLogContains("Received product with Id: 88 and Sku: SKU-088.") + .ExpectLogContains("A transient error has occurred; please try again. [Source: ServiceBusSubscribedSubscriber, Handling: Retry]") + .ExpectLogContains("Service bus message retry attempt 1 in 333ms.") + .ExpectLogContains("Service bus message retry attempt 2 in 666ms.") + .ExpectLogContains("Service bus message retry attempt 3 in 1332ms.") + .ExpectLogContains("DeadLetterAsync") + .Run(async () => + { + try + { + await sbr.StartAsync(cts.Token).ConfigureAwait(false); + await Task.Delay(Timeout.Infinite, cts.Token); // Wait for the message to be processed or timeout; then stop and dispose. + } + finally + { + await sbr.StopAsync().ConfigureAwait(false); + await sbr.DisposeAsync().ConfigureAwait(false); + } + }).AssertException(); + }); + + [Test] + public void ReceiveAsync_Catastrophic_Then_Pause() => Test.ScopedType(async test => + { + // Publish a message. + var sp = (ServiceBusPublisher)test.Services.GetRequiredKeyedService(ServiceBusPublisher.DefaultServiceKey); + sp.Add(EventData.CreateEventWith(new Subscribers.Product { Id = 99, Sku = "SKU-099" }, "Created")); + await sp.PublishAsync(); + + // Create using the root services (not scoped). + var o = ServiceBusReceiverOptions.CreateForTopicSubscription("unit-test", "default"); + o.ReceiverResiliency = ServiceBusReceiverResiliency.CreateReceiverCircuitBreakerResiliency(5, TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(333)); + o.PerUnhandledErrorDelayDuration = TimeSpan.FromMilliseconds(100); + + var sbr = ActivatorUtilities.CreateInstance>(Test.Services, o); + + var cts = new CancellationTokenSource(); + cts.CancelAfter(5000); // Ensure test doesn't run indefinitely. + + int messagesProcessed = 0; + + sbr.MessageProcessed += (sender, e) => + { + messagesProcessed++; + System.Threading.Thread.Sleep(10); // Allow some time to catch up. + sbr.Status.Should().BeOneOf(ServiceStatus.Pausing, ServiceStatus.Paused); + sbr.StatusReason.Should().Be("A Catastrophic error occurred within the service bus receiver."); + cts.Cancel(); + }; + + // Act and assert. + Test.ExpectLogContains("Received product with Id: 99 and Sku: SKU-099.") + .ExpectLogContains("A Catastrophic error has occurred within the service bus receiver for subscriber 'ServiceBusSubscribedSubscriber'. Abandoning the message and pausing the receiver.") + .ExpectLogContains("AbandonAsync done.") + .ExpectLogContains("Azure Service Bus receiver: Pausing.") + .ExpectLogContains("Azure Service Bus receiver: Paused.") + .Run(async () => + { + try + { + await sbr.StartAsync(cts.Token).ConfigureAwait(false); + await Task.Delay(Timeout.Infinite, cts.Token); // Wait for the message to be processed or timeout; then stop and dispose. + } + finally + { + await sbr.StopAsync().ConfigureAwait(false); + await sbr.DisposeAsync().ConfigureAwait(false); + } + }).AssertException(); + + messagesProcessed.Should().BeGreaterThanOrEqualTo(1); + }); + + [Test] + public void ReceiveAsync_CircuitBreaker() => Test.ScopedType(async test => + { + // Publish a message. + var sp = (ServiceBusPublisher)test.Services.GetRequiredKeyedService(ServiceBusPublisher.DefaultServiceKey); + sp.Add(EventData.CreateEventWith(new Subscribers.Product { Id = 109, Sku = "SKU-109" }, "Created")); + sp.Add(EventData.CreateEventWith(new Subscribers.Product { Id = 109, Sku = "SKU-109.1" }, "Created")); + sp.Add(EventData.CreateEventWith(new Subscribers.Product { Id = 109, Sku = "SKU-109.2" }, "Created")); + await sp.PublishAsync(); + + // Create using the root services (not scoped). + var o = ServiceBusReceiverOptions.CreateForTopicSubscription("unit-test", "default"); + o.ReceiverResiliency = ServiceBusReceiverResiliency.CreateReceiverCircuitBreakerResiliency(5, TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(333)); + o.PerUnhandledErrorDelayDuration = TimeSpan.FromMilliseconds(100); + + var sbr = ActivatorUtilities.CreateInstance>(Test.Services, o); + + var count = 0; + sbr.MessageProcessed += (sender, e) => count++; + + var cts = new CancellationTokenSource(); + cts.CancelAfter(30000); // Ensure test doesn't run indefinitely. + + Test.ExpectLogContains("Service bus receiver circuit breaker has been tripped for 333ms due to unhandled errors; receiver will be paused.") + .ExpectLogContains("Service bus receiver circuit breaker has been tripped for 666ms due to unhandled errors; receiver will be paused.") + .ExpectLogContains("Service bus receiver circuit breaker has been tripped for 1332ms due to unhandled errors; receiver will be paused.") + .ExpectLogContains("Service bus receiver circuit breaker is attempting to recover in a limited state; receiver has been resumed.") + .Run(async () => + { + try + { + await sbr.StartAsync(cts.Token).ConfigureAwait(false); + await Task.Delay(Timeout.Infinite, cts.Token); // Wait for the message to be processed or timeout; then stop and dispose. + } + finally + { + await sbr.StopAsync().ConfigureAwait(false); + await sbr.DisposeAsync().ConfigureAwait(false); + + if (Test.Logger.IsEnabled(LogLevel.Information)) + Test.Logger.LogInformation("MESSAGE PROCESSED COUNT: {Count}.", count); + } + }).AssertException(); + + }); +} \ No newline at end of file diff --git a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusSubscriberTests.cs b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusSubscriberTests.cs new file mode 100644 index 00000000..3e9ca32b --- /dev/null +++ b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusSubscriberTests.cs @@ -0,0 +1,28 @@ +using CoreEx.Events; +using Microsoft.Extensions.DependencyInjection; +using UnitTestEx.Expectations; + +namespace CoreEx.Azure.Messaging.ServiceBus.Test.Unit; + +public class ServiceBusSubscriberTests : WithGenericTester +{ + [Test] + public void ReceiveAsync_SingleMessage() => Test.ScopedType(test => + { + // Arrange the message. + var ed = EventData.CreateEventWith(new Subscribers.Product { Id = 1, Sku = "SKU-001" }, "Created"); + var ef = test.Services.GetRequiredService(); + var ce = ef.ConvertToCloudEvent(ef.Format(ed)); + var sm = ce.ToServiceBusMessage(); + var msg = ServiceBusMessageTests.ConvertToReceivedMessage(sm); + + // Act & Assert. + test.ExpectLogContains("Received product with Id: 1 and Sku: SKU-001.") + .Run(async _ => + { + var sbss = Test.Services.GetRequiredService(); + var result = await sbss.ReceiveAsync(msg!).ConfigureAwait(false); + result.IsSuccess.Should().BeTrue(); + }).AssertSuccess(); + }); +} diff --git a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/Subscribers/ProductSubscriber.cs b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/Subscribers/ProductSubscriber.cs new file mode 100644 index 00000000..ac8abae3 --- /dev/null +++ b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/Subscribers/ProductSubscriber.cs @@ -0,0 +1,32 @@ +using CoreEx.Entities; +using CoreEx.Events; +using CoreEx.Events.Subscribing; +using CoreEx.Results; +using Microsoft.Extensions.Logging; + +namespace CoreEx.Azure.Messaging.ServiceBus.Test.Unit.Subscribers; + +[Subscribe("**.product.**")] +public class ProductSubscriber(ILogger logger) : SubscribedBase +{ + protected override Task OnReceiveAsync(Product value, EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken = default) + { + logger.LogInformation("Received product with Id: {Id} and Sku: {Sku}.", value.Id, value.Sku); + + if (value.Id == 88) + return Result.TransientError().AsTask(); + else if (value.Id == 99) + return Result.Fail(new InvalidOperationException("Oh no!")).AsTask(); + else if (value.Id == 109) + return Result.Fail(new DivideByZeroException("Might be poison?!")).AsTask(); + + return Result.SuccessTask; + } +} + +public record class Product : IReadOnlyIdentifier +{ + public int Id { get; init; } + + public required string Sku { get; init; } +} \ No newline at end of file diff --git a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/appsettings.unittest.json b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/appsettings.unittest.json new file mode 100644 index 00000000..692a96c0 --- /dev/null +++ b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/appsettings.unittest.json @@ -0,0 +1,11 @@ +{ + "Aspire": { + "Azure": { + "Messaging": { + "ServiceBus": { + "ConnectionString": "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Caching.Redis.Test.Unit/CoreEx.Caching.Redis.Test.Unit.csproj b/tests/CoreEx.Caching.Redis.Test.Unit/CoreEx.Caching.Redis.Test.Unit.csproj new file mode 100644 index 00000000..2b50b620 --- /dev/null +++ b/tests/CoreEx.Caching.Redis.Test.Unit/CoreEx.Caching.Redis.Test.Unit.csproj @@ -0,0 +1,43 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/tests/CoreEx.Caching.Redis.Test.Unit/EntryPoint.cs b/tests/CoreEx.Caching.Redis.Test.Unit/EntryPoint.cs new file mode 100644 index 00000000..303c5c8d --- /dev/null +++ b/tests/CoreEx.Caching.Redis.Test.Unit/EntryPoint.cs @@ -0,0 +1,40 @@ +using CoreEx.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; + +namespace CoreEx.Caching.Redis.Test.Unit; + +public class EntryPoint +{ + public static void ConfigureApplication(IHostApplicationBuilder builder) + { + // Add CoreEx host settings. + builder.AddHostSettings("CoreEx.Caching", "UnitTest"); + + // Add CoreEx services. + builder.Services + .AddExecutionContext(); + + // Add core caching services. + builder.Services.AddMemoryCache(); // Add in-memory cache - L1. + builder.AddRedisDistributedCache("redis"); // Add Redis as the distributed cache (using Aspire library) - L2. + + builder.Services.AddFusionCache() // Add and wire-up FusionCache including backplane. + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = sp.GetRequiredService>().Value.ToString() })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions) + .WithOptions(new FusionCacheOptions { EnableSyncEventHandlersExecution = true }); // ** NOTE: Do NOT use; this is to enable backplane unit tests only! ** + + // Add CoreEx caching services. + builder.Services.AddFusionHybridCache(); // Adds the scoped CoreEx.Caching.IHybridCache for FusionCache. + builder.Services.AddDefaultCacheKeyProvider(); // Adds the default CoreEx.Caching.ICacheKeyProvider. + + // Add the HybridCacheSynchronizer to test also. + builder.Services.AddHybridCacheSynchronizer(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Caching.Redis.Test.Unit/HybridCacheSynchronizerTests.cs b/tests/CoreEx.Caching.Redis.Test.Unit/HybridCacheSynchronizerTests.cs new file mode 100644 index 00000000..0595df85 --- /dev/null +++ b/tests/CoreEx.Caching.Redis.Test.Unit/HybridCacheSynchronizerTests.cs @@ -0,0 +1,37 @@ +using CoreEx.Hosting.Synchronization; +using Microsoft.Extensions.DependencyInjection; + +namespace CoreEx.Caching.Redis.Test.Unit; + +public class HybridCacheSynchronizerTests : WithGenericTester +{ + [Test] + public async Task Synchronize_Two_Processes() + { + // Arrange. + var root = Test.Services.GetRequiredService(); + + await using var ss1 = root.CreateAsyncScope(); + var sp1 = ss1.ServiceProvider; + await using var hs1 = sp1.GetRequiredService(); + + await using var ss2 = root.CreateAsyncScope(); + var sp2 = ss2.ServiceProvider; + await using var hs2 = sp2.GetRequiredService(); + + // Act/Assert - First should enter, second should not. + var r1 = await hs1.EnterAsync(); + r1.Should().BeTrue(); + + var r2 = await hs2.EnterAsync(); + r2.Should().BeFalse(); + + // Assert - After first exits, second can enter. + await hs1.ExitAsync(); + + r2 = await hs2.EnterAsync(); + r2.Should().BeTrue(); + + await hs2.ExitAsync(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Caching.Redis.Test.Unit/HybridCacheTests.cs b/tests/CoreEx.Caching.Redis.Test.Unit/HybridCacheTests.cs new file mode 100644 index 00000000..7bae3097 --- /dev/null +++ b/tests/CoreEx.Caching.Redis.Test.Unit/HybridCacheTests.cs @@ -0,0 +1,257 @@ +using CoreEx.Entities; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace CoreEx.Caching.Redis.Test.Unit; + +public class HybridCacheTests : WithGenericTester +{ + [Test] + public void CacheStrategy_Hybrid() + { + Test.ScopedType(async test => + { + var key = "hybrid-key"; + var val = "hybrid-value"; + + // Remove before we start. + await ClearKeyAsync(test.Services, key); + + // Prepare usage. + var cache = test.Services.GetRequiredService(); + var options = new HybridCacheEntryOptions { Strategy = CacheStrategy.Hybrid }; + + // Create on first access. + var value = await cache.GetOrCreateByKeyAsync(key, async ct => + { + await Task.Delay(1, ct).ConfigureAwait(false); + return val; + }, options); + + value.Should().NotBeNull().And.Be(val); + + // Should be created already. + value = await cache.GetOrCreateByKeyAsync(key, _ => throw new InvalidOperationException("Should be cached?!"), options); + value.Should().NotBeNull().And.Be(val); + + // Check strategy adhered to. + IsInDistributedCache(test.Services, key).Should().BeTrue(); + IsInMemoryCache(test.Services, key).Should().BeTrue(); + }); + } + + [Test] + public void CacheStrategy_Local() + { + Test.ScopedType(async test => + { + var key = "local-key"; + var val = "local-value"; + + // Remove before we start. + await ClearKeyAsync(test.Services, key); + + // Prepare usage. + var cache = test.Services.GetRequiredService(); + var options = new HybridCacheEntryOptions { Strategy = CacheStrategy.Local }; + + // Create on first access. + var value = await cache.GetOrCreateByKeyAsync(key, async ct => + { + await Task.Delay(1, ct).ConfigureAwait(false); + return val; + }, options); + + value.Should().NotBeNull().And.Be(val); + + // Should be created already. + value = await cache.GetOrCreateByKeyAsync(key, _ => throw new InvalidOperationException("Should be cached?!"), options); + value.Should().NotBeNull().And.Be(val); + + // Check strategy adhered to. + IsInDistributedCache(test.Services, key).Should().BeFalse(); + IsInMemoryCache(test.Services, key).Should().BeTrue(); + }); + } + + [Test] + public void CacheStrategy_Distributed() + { + Test.ScopedType(async test => + { + var key = "distributed-key"; + var val = "distributed-value"; + + // Remove before we start. + await ClearKeyAsync(test.Services, key); + + // Prepare usage. + var cache = test.Services.GetRequiredService(); + var options = new HybridCacheEntryOptions { Strategy = CacheStrategy.Distributed }; + + // Create on first access. + var value = await cache.GetOrCreateByKeyAsync(key, async ct => + { + await Task.Delay(1, ct).ConfigureAwait(false); + return val; + }, options); + + value.Should().NotBeNull().And.Be(val); + + // Should be created already. + value = await cache.GetOrCreateByKeyAsync(key, _ => throw new InvalidOperationException("Should be cached?!"), options); + value.Should().NotBeNull().And.Be(val); + + // Check strategy adhered to. + IsInDistributedCache(test.Services, key).Should().BeTrue(); + IsInMemoryCache(test.Services, key).Should().BeFalse(); + }); + } + + [Test] + public async Task Backplane_Synchronization() + { + var secondary = UnitTestEx.GenericTester.Create(); + var key = "backplane-key"; + var val = "backplane-value"; + var val2 = "backplane-value2"; + + // Remove before we start. + Test.ScopedType(async test => + { + await ClearKeyAsync(test.Services, key); + }); + + // Create on secondary first. + secondary.ScopedType(async test => + { + var cache = test.Services.GetRequiredService(); + var value = await cache.GetOrCreateByKeyAsync(key, _ => Task.FromResult(val)); + value.Should().NotBeNull().And.Be(val); + + IsInDistributedCache(test.Services, key).Should().BeTrue(); + IsInMemoryCache(test.Services, key).Should().BeTrue(); + }); + + // Now check on primary. + Test.ScopedType(async test => + { + IsInMemoryCache(test.Services, key).Should().BeFalse(); + + var cache = test.Services.GetRequiredService(); + var value = await cache.GetOrCreateByKeyAsync(key, _ => throw new InvalidOperationException("Should be cached?!")); + value.Should().NotBeNull().And.Be(val); + + IsInDistributedCache(test.Services, key).Should().BeTrue(); + IsInMemoryCache(test.Services, key).Should().BeTrue(); + + // Change the value and confirm. + await cache.SetByKeyAsync(key, val2); + value = await cache.GetOrCreateByKeyAsync(key, _ => throw new InvalidOperationException("Should be cached?!")); + value.Should().NotBeNull().And.Be(val2); + }); + + // Allow backplane to do its thing! + await Task.Delay(1000); + + // Back to secondary to check on change. + secondary.ScopedType(async test => + { + var cache = test.Services.GetRequiredService(); + var value = await cache.GetOrCreateByKeyAsync(key, _ => throw new InvalidOperationException("Should be cached?!")); + value.Should().NotBeNull().And.Be(val2); + + IsInDistributedCache(test.Services, key).Should().BeTrue(); + IsInMemoryCache(test.Services, key).Should().BeTrue(); + }); + + // Verifying separate instances! + var pfc = Test.Services.GetRequiredService(); + var sfc = secondary.Services.GetRequiredService(); + pfc.Should().NotBeSameAs(sfc); + } + + [Test] + public async Task Cache_ByKey() + { + var key = "bykey-key"; + + Test.ScopedType(async test => + { + var cache = test.Services.GetRequiredService(); + + await cache.RemoveByKeyAsync(key); // Removes key:any. + + var p = await cache.GetOrDefaultByKeyAsync(key); + p.Should().BeNull(); + + await cache.RemoveByKeyAsync(key); // Removes key:null. + + p = await cache.GetOrCreateByKeyAsync(key, _ => Task.FromResult(new Person { Id = "123", Name = "Bob", Age = 33 })); + p.Should().NotBeNull(); + p.Name.Should().Be("Bob"); + + var p2 = await cache.GetOrDefaultByKeyAsync(key); + p2.Should().NotBeNull().And.BeEquivalentTo(p); + }); + } + + [Test] + public async Task Cache_ByCompositeKey() + { + var key = "composite-key"; + CompositeKey ckey = key; + + Test.ScopedType(async test => + { + var cache = test.Services.GetRequiredService(); + + await cache.RemoveAsync(ckey); // Removes key:any. + + var p = await cache.GetOrDefaultAsync(ckey); + p.Should().BeNull(); + + await cache.RemoveAsync(ckey); // Removes key:null. + + p = await cache.GetOrCreateAsync(ckey, _ => Task.FromResult(new Person { Id = key, Name = "Bob", Age = 33 })); + p.Should().NotBeNull(); + p.Name.Should().Be("Bob"); + + var p2 = await cache.GetOrDefaultAsync(ckey); + p2.Should().NotBeNull().And.BeEquivalentTo(p); + }); + } + + internal static async Task ClearKeyAsync(IServiceProvider sp, string key) + { + var cache = sp.GetRequiredService(); + await cache.RemoveByKeyAsync(key); + + IsInDistributedCache(sp, key).Should().BeFalse(); + IsInMemoryCache(sp, key).Should().BeFalse(); + } + + private static bool IsInDistributedCache(IServiceProvider sp, string key) + { + var dc = sp.GetRequiredService(); + var ckp = sp.GetRequiredService(); + + return dc.Get("v2:" + ckp.GetFullyQualifiedCacheKey(key)) is not null; // FusionCache prefixes by major version. + } + + private static bool IsInMemoryCache(IServiceProvider sp, string key) + { + var mc = sp.GetRequiredService(); + var ckp = sp.GetRequiredService(); + return mc.TryGetValue(ckp.GetFullyQualifiedCacheKey(key), out var _); + } + + public record Person : IIdentifier + { + public string? Id { get; set; } + public string? Name { get; set; } + public int Age { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Caching.Redis.Test.Unit/ReferenceDataCacheTests.cs b/tests/CoreEx.Caching.Redis.Test.Unit/ReferenceDataCacheTests.cs new file mode 100644 index 00000000..715b0035 --- /dev/null +++ b/tests/CoreEx.Caching.Redis.Test.Unit/ReferenceDataCacheTests.cs @@ -0,0 +1,138 @@ +using CoreEx.RefData; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using UnitTestEx.Expectations; + +namespace CoreEx.Caching.Redis.Test.Unit; + +public class ReferenceDataCacheTests : WithGenericTester +{ + [Test] + public void NoInMemory_GoTo_Distributed() + { + Test.ReplaceScoped(); + Test.ReplaceScoped(); + Test.ReplaceScoped(sp => + { + var rdo = ActivatorUtilities.CreateInstance(sp); + rdo.Register(); + return rdo; + }); + + Test.ScopedType(test => + { + test.ExpectLogContains("calling the factory") + .Run(async _ => + { + // Clear any existing. + await HybridCacheTests.ClearKeyAsync(test.Service.ServiceProvider!, "UnitTest:RefData:CoreEx.Caching.Redis.Test.Unit.ColorCollection"); + + // Should cache for the first time (both in-memory and distributed). + var rdo = test.Services.GetRequiredService(); + var colors = await rdo.GetByTypeAsync(); + colors.Should().NotBeNull().And.BeOfType().Which.Should().HaveCount(3); + }).AssertSuccess(); + }); + + // Second time should be from cache, so log should indicate that. + Test.ScopedType(test => + { + test.ExpectLogContains("[MC] memory entry found") + .Run(async _ => + { + var rdo = test.Services.GetRequiredService(); + var colors = await rdo.GetByTypeAsync(); + colors.Should().NotBeNull().And.BeOfType().Which.Should().HaveCount(3); + }).AssertSuccess(); + }); + + // Clear the in-memory cache and try again, should be from the distributed cache. + var mc = Test.Services.GetRequiredService(); + ((MemoryCache)mc).Clear(); + + // Third time should be from distributed cache, so log should indicate that. + Test.ScopedType(test => + { + test.ExpectLogContains("[MC] memory entry not found") + .ExpectLogContains("[DC] distributed entry found") + .Run(async _ => + { + var rdo = test.Services.GetRequiredService(); + var colors = await rdo.GetByTypeAsync(); + colors.Should().NotBeNull().And.BeOfType().Which.Should().HaveCount(3); + }).AssertSuccess(); + }); + } + + [Test] + public void InMemory_Expired_GetFromDistributed() + { + Test.ReplaceScoped(); + Test.ReplaceScoped(sp => + { + var rdc = ActivatorUtilities.CreateInstance(sp); + rdc.RegisterCacheEntryOptions(new HybridCacheEntryOptions { LocalExpiration = TimeSpan.FromSeconds(1), DistributedExpiration = TimeSpan.FromSeconds(1) }); + return rdc; + }); + Test.ReplaceScoped(sp => + { + var rdo = ActivatorUtilities.CreateInstance(sp); + rdo.Register(); + return rdo; + }); + + Test.ScopedType(test => + { + test.ExpectLogContains("calling the factory") + .Run(async _ => + { + // Clear any existing. + await HybridCacheTests.ClearKeyAsync(test.Service.ServiceProvider!, "UnitTest:RefData:CoreEx.Caching.Redis.Test.Unit.ColorCollection"); + + // Should cache for the first time (both in-memory and distributed). + var rdo = test.Services.GetRequiredService(); + var colors = await rdo.GetByTypeAsync(); + colors.Should().NotBeNull().And.BeOfType().Which.Should().HaveCount(3); + }).AssertSuccess(); + }); + + // Second time should be from cache, so log should indicate that. + Test.ScopedType(test => + { + test.ExpectLogContains("[MC] memory entry found") + .Run(async _ => + { + var rdo = test.Services.GetRequiredService(); + var colors = await rdo.GetByTypeAsync(); + colors.Should().NotBeNull().And.BeOfType().Which.Should().HaveCount(3); + }).AssertSuccess(); + }); + + // Wait for the in-memory cache to expire. + Thread.Sleep(1500); + + // Should be from the distributed cache, so log should indicate that. + Test.ScopedType(test => + { + test.ExpectLogContains("calling the factory") + .Run(async _ => + { + var rdo = test.Services.GetRequiredService(); + var colors = await rdo.GetByTypeAsync(); + colors.Should().NotBeNull().And.BeOfType().Which.Should().HaveCount(3); + }).AssertSuccess(); + }); + } + + public class Color : ReferenceData { } + + public class ColorCollection :ReferenceDataCollection { } + + public class ReferenceDataProvider : IReferenceDataProvider + { + public IEnumerable<(Type, Type)> Types => [(typeof(Color), typeof(ColorCollection))]; + + public Task GetAsync(Type type, CancellationToken cancellationToken = default) + => Task.FromResult(new ColorCollection { new Color { Id = 1, Code = "R", Text = "Red" }, new Color { Id = 2, Code = "G", Text = "Green" }, new Color { Id = 3, Code = "B", Text = "Blue" } }); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Caching.Redis.Test.Unit/appsettings.unittest.json b/tests/CoreEx.Caching.Redis.Test.Unit/appsettings.unittest.json new file mode 100644 index 00000000..a8169105 --- /dev/null +++ b/tests/CoreEx.Caching.Redis.Test.Unit/appsettings.unittest.json @@ -0,0 +1,13 @@ +{ + "Aspire": { + "StackExchange": { + "Redis": { + "ConnectionString": "localhost:6379", + "ConfigurationOptions": { + "ConnectTimeout": 3000, + "ConnectRetry": 2 + } + } + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj b/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj deleted file mode 100644 index c30f13a8..00000000 --- a/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj +++ /dev/null @@ -1,46 +0,0 @@ - - - - net8.0 - enable - enable - false - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - diff --git a/tests/CoreEx.Cosmos.Test/CosmosDb.cs b/tests/CoreEx.Cosmos.Test/CosmosDb.cs deleted file mode 100644 index 54aef6fb..00000000 --- a/tests/CoreEx.Cosmos.Test/CosmosDb.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.Azure.Cosmos; - -namespace CoreEx.Cosmos.Test -{ - public class CosmosDb : Cosmos.CosmosDb - { - public CosmosDb(bool auth, bool partitioning = false) : base(TestSetUp.CosmosDatabase!, TestSetUp.Mapper!) - { - // Apply the container configurations. - Container("Persons1").UsePartitionKey(partitioning ? v => new PartitionKey(v.Filter) : null).UseAuthorizeFilter(q => auth ? q.Where(x => x.Locked == false) : q); - Container("Persons2").UsePartitionKey(partitioning ? v => new PartitionKey(v.Filter) : null!).UseAuthorizeFilter(q => auth ? q.Where(x => x.Locked == false) : q); - this["Persons3"].UseValuePartitionKey(partitioning ? v => new PartitionKey(v.Value.Filter) : null!).UseValueAuthorizeFilter(q => auth ? q.Where(x => x.Value.Locked == false) : q); - Container("Persons3").UseValuePartitionKey(partitioning ? v => new PartitionKey(v.Value.Filter) : null!); - } - - public CosmosDbContainer Persons1 => Container("Persons1").AsTyped(); - - public CosmosDbContainer Persons2 => Container("Persons2"); - - public CosmosDbValueContainer Persons3 => this["Persons3"].AsValueTyped(); - - public CosmosDbValueContainer Persons3X => ValueContainer("Persons3"); - - public CosmosDbContainer PersonsX => Container("PersonsX"); - } -} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbContainerAuthTest.cs b/tests/CoreEx.Cosmos.Test/CosmosDbContainerAuthTest.cs deleted file mode 100644 index 7cd6f17c..00000000 --- a/tests/CoreEx.Cosmos.Test/CosmosDbContainerAuthTest.cs +++ /dev/null @@ -1,195 +0,0 @@ -namespace CoreEx.Cosmos.Test -{ - [TestFixture] - [Category("WithCosmos")] - public class CosmosDbContainerAuthTest - { -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. - private CosmosDb _db; -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. - - [OneTimeSetUp] - public async Task SetUp() - { - await TestSetUp.SetUpAsync().ConfigureAwait(false); - _db = new CosmosDb(auth: true); - } - - [Test] - public void AsQueryable1() => Assert.That(_db.Persons1.Query().AsQueryable().Count(), Is.EqualTo(3)); - - [Test] - public void AsQueryable2() => Assert.That(_db.Persons2.Query().AsQueryable().Count(), Is.EqualTo(3)); - - [Test] - public void AsQueryable3() => Assert.That(_db.Persons3.Query().AsQueryable().Count(), Is.EqualTo(3)); - - [Test] - public async Task Get1Async() - { - Assert.That(await _db.Persons1.GetAsync(404.ToGuid().ToString()), Is.Null); - - var v = await _db.Persons1.GetAsync(1.ToGuid().ToString()); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Id, Is.EqualTo(1.ToGuid().ToString())); - - Assert.ThrowsAsync(() => _db.Persons1.GetAsync(2.ToGuid().ToString())); - } - - [Test] - public async Task Get2Async() - { - Assert.That(await _db.Persons2.GetAsync(404.ToGuid().ToString()), Is.Null); - - var v = await _db.Persons2.GetAsync(1.ToGuid().ToString()); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Id, Is.EqualTo(1.ToGuid().ToString())); - - Assert.ThrowsAsync(() => _db.Persons2.GetAsync(2.ToGuid().ToString())); - } - - [Test] - public async Task Get3Async() - { - Assert.That(await _db.Persons3.GetAsync(404.ToGuid()), Is.Null); - - var v = await _db.Persons3.GetAsync(1.ToGuid().ToString()); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Id, Is.EqualTo(1.ToGuid())); - - Assert.ThrowsAsync(() => _db.Persons3.GetAsync(2.ToGuid())); - } - - [Test] - public async Task Create1Async() - { - Assert.ThrowsAsync(() => _db.Persons1.CreateAsync(null!)); - - var id = Guid.NewGuid().ToString(); - var v = new Person1 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m }; - v = await _db.Persons1.CreateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(id)); - - v = new Person1 { Id = Guid.NewGuid().ToString(), Name = "Harry", Birthday = new DateTime(1999, 07, 21), Salary = 181000m, Locked = true }; - Assert.ThrowsAsync(() => _db.Persons1.CreateAsync(v)); - } - - [Test] - public async Task Create2Async() - { - Assert.ThrowsAsync(() => _db.Persons2.CreateAsync(null!)); - - var id = Guid.NewGuid().ToString(); - var v = new Person2 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m }; - v = await _db.Persons2.CreateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(id)); - - v = new Person2 { Id = Guid.NewGuid().ToString(), Name = "Harry", Birthday = new DateTime(1999, 07, 21), Salary = 181000m, Locked = true }; - Assert.ThrowsAsync(() => _db.Persons2.CreateAsync(v)); - } - - [Test] - public async Task Create3Async() - { - Assert.ThrowsAsync(() => _db.Persons3.CreateAsync(null!)); - - var id = Guid.NewGuid(); - var v = new Person3 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m }; - v = await _db.Persons3.CreateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(id)); - - v = new Person3 { Id = Guid.NewGuid(), Name = "Harry", Birthday = new DateTime(1999, 07, 21), Salary = 181000m, Locked = true }; - Assert.ThrowsAsync(() => _db.Persons3.CreateAsync(v)); - } - - [Test] - public async Task Update1Async() - { - // Update where not auth. - var v = (await _db.Persons1.CosmosContainer.ReadItemAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; - Assert.That(v, Is.Not.Null); - - v.Name += "X"; - Assert.ThrowsAsync(() => _db.Persons1.UpdateAsync(v)); - - // Update to something not auth. - v = (await _db.Persons1.CosmosContainer.ReadItemAsync(5.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; - Assert.That(v, Is.Not.Null); - - v.Name += "X"; - v.Locked = true; - Assert.ThrowsAsync(() => _db.Persons1.UpdateAsync(v)); - - v.Locked = false; - await _db.Persons1.UpdateAsync(v); - } - - [Test] - public async Task Update2Async() - { - // Update where not auth. - var v = (await _db.Persons2.CosmosContainer.ReadItemAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; - Assert.That(v, Is.Not.Null); - - v.Name += "X"; - Assert.ThrowsAsync(() => _db.Persons2.UpdateAsync(v)); - - // Update to something not auth. - v = (await _db.Persons2.CosmosContainer.ReadItemAsync(5.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; - Assert.That(v, Is.Not.Null); - - v.Name += "X"; - v.Locked = true; - Assert.ThrowsAsync(() => _db.Persons2.UpdateAsync(v)); - - v.Locked = false; - await _db.Persons2.UpdateAsync(v); - } - - [Test] - public async Task Update3Async() - { - // Update where not auth. - var v = (await _db.Persons3.CosmosContainer.ReadItemAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; - Assert.That(v, Is.Not.Null); - - v.Name += "X"; - Assert.ThrowsAsync(() => _db.Persons3.UpdateAsync(v)); - - // Update to something not auth. - v = (await _db.Persons3.CosmosContainer.ReadItemAsync(5.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None).ConfigureAwait(false)).Resource; - Assert.That(v, Is.Not.Null); - - v.Name += "X"; - v.Locked = true; - Assert.ThrowsAsync(() => _db.Persons3.UpdateAsync(v)); - - v.Locked = false; - await _db.Persons3.UpdateAsync(v); - } - - [Test] - public async Task Delete1Async() - { - await _db.Persons1.DeleteAsync(3.ToGuid().ToString()); - Assert.ThrowsAsync(() => _db.Persons1.DeleteAsync(4.ToGuid().ToString())); - } - - [Test] - public async Task Delete2Async() - { - await _db.Persons2.DeleteAsync(3.ToGuid().ToString()); - Assert.ThrowsAsync(() => _db.Persons2.DeleteAsync(4.ToGuid().ToString())); - } - - [Test] - public async Task Delete3Async() - { - await _db.Persons3.DeleteAsync(3.ToGuid()); - Assert.ThrowsAsync(() => _db.Persons3.DeleteAsync(4.ToGuid())); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbContainerPartitioningTest.cs b/tests/CoreEx.Cosmos.Test/CosmosDbContainerPartitioningTest.cs deleted file mode 100644 index 9741ff05..00000000 --- a/tests/CoreEx.Cosmos.Test/CosmosDbContainerPartitioningTest.cs +++ /dev/null @@ -1,224 +0,0 @@ -using Microsoft.Azure.Cosmos; - -namespace CoreEx.Cosmos.Test -{ - [TestFixture] - [Category("WithCosmos")] - public class CosmosDbContainerPartitioningTest - { -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. - private CosmosDb _db; -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. - - [OneTimeSetUp] - public async Task SetUp() - { - await TestSetUp.SetUpAsync("/filter", "/value/filter").ConfigureAwait(false); - _db = new CosmosDb(auth: false, partitioning: true) - { - DbArgs = new CosmosDbArgs(new PartitionKey("A")) - }; - } - - [Test] - public async Task Get1Async() - { - var v = await _db.Persons1.GetAsync(1.ToGuid()); - Assert.That(v, Is.Null); - - v = await _db.Persons1.GetAsync(4.ToGuid()); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Name, Is.EqualTo("Sally")); - } - - [Test] - public async Task Create1Async() - { - var id = Guid.NewGuid().ToString(); - var v = new Person1 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m, Filter = "B" }; - Assert.ThrowsAsync(() => _db.Persons1.CreateAsync(v)); - - v.Filter = "A"; - await _db.Persons1.CreateAsync(v); - - v = await _db.Persons1.GetAsync(id); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Name, Is.EqualTo("Michelle")); - } - - [Test] - public async Task Update1Async() - { - var v = await _db.Persons1.GetAsync(4.ToGuid()); - Assert.That(v, Is.Not.Null); - - v!.Name += "X"; - v.Filter = "B"; - Assert.ThrowsAsync(() => _db.Persons1.UpdateAsync(v)); - - v.Filter = "A"; - v = await _db.Persons1.UpdateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Name, Is.EqualTo("SallyX")); - } - - [Test] - public async Task Delete1Async() - { - Assert.ThrowsAsync(() => _db.Persons1.DeleteAsync(1.ToGuid())); - var ir = await _db.Persons1.CosmosContainer.ReadItemAsync(1.ToGuid().ToString(), new PartitionKey("B")).ConfigureAwait(false); - Assert.That(ir, Is.Not.Null); - Assert.That(ir.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK)); - - await _db.Persons1.DeleteAsync(5.ToGuid()); - var v = await _db.Persons1.GetAsync(5.ToGuid()); - Assert.That(v, Is.Null); - } - - [Test] - public async Task Get2Async() - { - var v = await _db.Persons2.GetAsync(1.ToGuid()); - Assert.That(v, Is.Null); - - v = await _db.Persons2.GetAsync(4.ToGuid()); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Name, Is.EqualTo("Sally")); - } - - [Test] - public async Task Create2Async() - { - var id = Guid.NewGuid().ToString(); - var v = new Person2 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m, Filter = "B" }; - Assert.ThrowsAsync(() => _db.Persons2.CreateAsync(v)); - - v.Filter = "A"; - await _db.Persons2.CreateAsync(v); - - v = await _db.Persons2.GetAsync(id); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Name, Is.EqualTo("Michelle")); - } - - [Test] - public async Task Update2Async() - { - var v = await _db.Persons2.GetAsync(4.ToGuid().ToString()); - Assert.That(v, Is.Not.Null); - - v!.Name += "X"; - v.Filter = "B"; - Assert.ThrowsAsync(() => _db.Persons2.UpdateAsync(v)); - - v.Filter = "A"; - v = await _db.Persons2.UpdateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v.Name, Is.EqualTo("SallyX")); - } - - [Test] - public async Task Delete2Async() - { - Assert.ThrowsAsync(() => _db.Persons2.DeleteAsync(1.ToGuid())); - var ir = await _db.Persons2.CosmosContainer.ReadItemAsync(1.ToGuid().ToString(), new PartitionKey("B")).ConfigureAwait(false); - Assert.That(ir, Is.Not.Null); - Assert.That(ir.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK)); - - await _db.Persons2.DeleteAsync(5.ToGuid().ToString()); - var v = await _db.Persons2.GetAsync(5.ToGuid().ToString()); - Assert.That(v, Is.Null); - } - - [Test] - public async Task Get3Async() - { - var v = await _db.Persons3.GetAsync(1.ToGuid()); - Assert.That(v, Is.Null); - - v = await _db.Persons3.GetAsync(4.ToGuid()); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Name, Is.EqualTo("Sally")); - } - - [Test] - public async Task Create3Async() - { - var id = Guid.NewGuid(); - var v = new Person3 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m, Filter = "B" }; - Assert.ThrowsAsync(() => _db.Persons3.CreateAsync(v)); - - v.Filter = "A"; - await _db.Persons3.CreateAsync(v); - - v = await _db.Persons3.GetAsync(id); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Name, Is.EqualTo("Michelle")); - } - - [Test] - public async Task Update3Async() - { - var v = await _db.Persons3.GetAsync(4.ToGuid()); - Assert.That(v, Is.Not.Null); - - v!.Name += "X"; - v.Filter = "B"; - Assert.ThrowsAsync(() => _db.Persons3.UpdateAsync(v)); - - v.Filter = "A"; - v = await _db.Persons3.UpdateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v.Name, Is.EqualTo("SallyX")); - } - - [Test] - public async Task Delete3Async() - { - Assert.ThrowsAsync(() => _db.Persons3.DeleteAsync(1.ToGuid())); - var ir = await _db.Persons3.CosmosContainer.ReadItemAsync(1.ToGuid().ToString(), new PartitionKey("B")).ConfigureAwait(false); - Assert.That(ir, Is.Not.Null); - Assert.That(ir.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK)); - - await _db.Persons3.DeleteAsync(5.ToGuid()); - var v = await _db.Persons3.GetAsync(5.ToGuid()); - Assert.That(v, Is.Null); - } - - [Test] - public async Task SelectValueMultiSetAsync_A() - { - Person3[] people = Array.Empty(); - var hasPerson = false; - - var result = await _db["Persons3"].SelectValueMultiSetWithResultAsync(new PartitionKey("A"), - new MultiSetValueCollArgs(r => people = r.ToArray()), - new MultiSetValueSingleArgs(r => hasPerson = true, isMandatory: false)); - - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(people, Has.Length.EqualTo(3)); - Assert.That(hasPerson, Is.True); - }); - } - - [Test] - public async Task SelectValueMultiSetAsync_B() - { - Person3[] people = Array.Empty(); - var hasPerson = false; - - var result = await _db["Persons3"].SelectValueMultiSetWithResultAsync(new PartitionKey("B"), - new MultiSetValueCollArgs(r => people = r.ToArray()), - new MultiSetValueSingleArgs(r => hasPerson = true, isMandatory: false)); - - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(people, Has.Length.EqualTo(2)); - Assert.That(hasPerson, Is.False); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbContainerTest.cs b/tests/CoreEx.Cosmos.Test/CosmosDbContainerTest.cs deleted file mode 100644 index 3def41d3..00000000 --- a/tests/CoreEx.Cosmos.Test/CosmosDbContainerTest.cs +++ /dev/null @@ -1,300 +0,0 @@ -namespace CoreEx.Cosmos.Test -{ - [TestFixture] - [Category("WithCosmos")] - public class CosmosDbContainerTest - { - private CosmosDb _db; - - [OneTimeSetUp] - public async Task SetUp() - { - await TestSetUp.SetUpAsync().ConfigureAwait(false); - _db = new CosmosDb(auth: false); - } - - [Test] - public async Task Get1Async() - { - Assert.That(await _db.Persons1.GetAsync(404.ToGuid()), Is.Null); - - var v = await _db.Persons1.GetAsync(1.ToGuid().ToString()); - Assert.That(v, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(v!.Id, Is.EqualTo(1.ToGuid().ToString())); - Assert.That(v.Name, Is.EqualTo("Rebecca")); - Assert.That(v.Birthday, Is.EqualTo(new DateTime(1990, 08, 07, 0, 0, 0, DateTimeKind.Unspecified))); - Assert.That(v.Salary, Is.EqualTo(150000m)); - }); - } - - [Test] - public async Task Get2Async() - { - Assert.That(await _db.Persons2.GetAsync(404.ToGuid()), Is.Null); - - var v = await _db.Persons2.GetAsync(1.ToGuid().ToString()); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Id, Is.EqualTo(1.ToGuid().ToString())); - Assert.That(v.Name, Is.EqualTo("Rebecca")); - Assert.That(v.Birthday, Is.EqualTo(new DateTime(1990, 08, 07, 0, 0, 0, DateTimeKind.Unspecified))); - Assert.That(v.Salary, Is.EqualTo(150000m)); - Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); - Assert.That(v.ETag, Is.Not.Null); - Assert.That(v.ETag, Does.Not.StartsWith("\"")); - } - - [Test] - public async Task Get3Async() - { - Assert.That(await _db.Persons3.GetAsync(404.ToGuid()), Is.Null); - - var v = await _db.Persons3.GetAsync(1.ToGuid()); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Id, Is.EqualTo(1.ToGuid())); - Assert.That(v.Name, Is.EqualTo("Rebecca")); - Assert.That(v.Birthday, Is.EqualTo(new DateTime(1990, 08, 07, 0, 0, 0, DateTimeKind.Unspecified))); - Assert.That(v.Salary, Is.EqualTo(150000m)); - Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); - Assert.That(v.ChangeLog.UpdatedBy, Is.Null); - Assert.That(v.ChangeLog.UpdatedDate, Is.Null); - Assert.That(v.ETag, Is.Not.Null); - Assert.That(v.ETag, Does.Not.StartsWith("\"")); - - // Different type. - Assert.That(await _db.Persons3.GetAsync(100.ToGuid()), Is.Null); - } - - [Test] - public async Task Create1Async() - { - Assert.ThrowsAsync(() => _db.Persons1.CreateAsync(null!)); - - var id = Guid.NewGuid().ToString(); - var v = new Person1 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m }; - v = await _db.Persons1.CreateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(id)); - Assert.That(v.Name, Is.EqualTo("Michelle")); - Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); - Assert.That(v.Salary, Is.EqualTo(181000m)); - - v = await _db.Persons1.GetAsync(v.Id); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Id, Is.EqualTo(id)); - Assert.That(v.Name, Is.EqualTo("Michelle")); - Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); - Assert.That(v.Salary, Is.EqualTo(181000m)); - } - - [Test] - public async Task Create2Async() - { - Assert.ThrowsAsync(() => _db.Persons2.CreateAsync(null!)); - - var id = Guid.NewGuid().ToString(); - var v = new Person2 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m }; - v = await _db.Persons2.CreateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Id, Is.EqualTo(id)); - Assert.That(v.Name, Is.EqualTo("Michelle")); - Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); - Assert.That(v.Salary, Is.EqualTo(181000m)); - Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); - Assert.That(v.ChangeLog.UpdatedBy, Is.Null); - Assert.That(v.ChangeLog.UpdatedDate, Is.Null); - Assert.That(v.ETag, Is.Not.Null); - Assert.That(v.ETag, Does.Not.StartsWith("\"")); - - v = await _db.Persons2.GetAsync(v.Id); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Id, Is.EqualTo(id)); - Assert.That(v.Name, Is.EqualTo("Michelle")); - Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); - Assert.That(v.Salary, Is.EqualTo(181000m)); - Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); - Assert.That(v.ChangeLog.UpdatedBy, Is.Null); - Assert.That(v.ChangeLog.UpdatedDate, Is.Null); - Assert.That(v.ETag, Is.Not.Null); - Assert.That(v.ETag, Does.Not.StartsWith("\"")); - } - - [Test] - public async Task Create3Async() - { - Assert.ThrowsAsync(() => _db.Persons3.CreateAsync(null!)); - - var id = Guid.NewGuid(); - var v = new Person3 { Id = id, Name = "Michelle", Birthday = new DateTime(1979, 08, 12), Salary = 181000m }; - v = await _db.Persons3.CreateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Id, Is.EqualTo(id)); - Assert.That(v.Name, Is.EqualTo("Michelle")); - Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); - Assert.That(v.Salary, Is.EqualTo(181000m)); - Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); - Assert.That(v.ChangeLog.UpdatedBy, Is.Null); - Assert.That(v.ChangeLog.UpdatedDate, Is.Null); - Assert.That(v.ETag, Is.Not.Null); - Assert.That(v.ETag, Does.Not.StartsWith("\"")); - - v = await _db.Persons3.GetAsync(v.Id); - Assert.That(v, Is.Not.Null); - Assert.That(v!.Id, Is.EqualTo(id)); - Assert.That(v.Name, Is.EqualTo("Michelle")); - Assert.That(v.Birthday, Is.EqualTo(new DateTime(1979, 08, 12))); - Assert.That(v.Salary, Is.EqualTo(181000m)); - Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); - Assert.That(v.ChangeLog.UpdatedBy, Is.Null); - Assert.That(v.ChangeLog.UpdatedDate, Is.Null); - Assert.That(v.ETag, Is.Not.Null); - Assert.That(v.ETag, Does.Not.StartsWith("\"")); - } - - [Test] - public async Task Update1Async() - { - Assert.ThrowsAsync(() => _db.Persons1.UpdateAsync(null!)); - - // Get previous. - var v = await _db.Persons1.GetAsync(5.ToGuid().ToString()); - Assert.That(v, Is.Not.Null); - - // Update testing. - v!.Id = 404.ToGuid().ToString(); - Assert.ThrowsAsync(() => _db.Persons1.UpdateAsync(v)); - - v.Id = 5.ToGuid().ToString(); - v.Name += "X"; - v = await _db.Persons1.UpdateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(5.ToGuid().ToString())); - Assert.That(v.Name, Is.EqualTo("MikeX")); - } - - [Test] - public async Task Update2Async() - { - Assert.ThrowsAsync(() => _db.Persons2.UpdateAsync(null!)); - - // Get previous. - var v = await _db.Persons2.GetAsync(5.ToGuid().ToString()); - Assert.That(v, Is.Not.Null); - - // Update testing. - v!.Id = 404.ToGuid().ToString(); - Assert.ThrowsAsync(() => _db.Persons2.UpdateAsync(v)); - - v.Id = 5.ToGuid().ToString(); - v.Name += "X"; - v = await _db.Persons2.UpdateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(5.ToGuid().ToString())); - Assert.That(v.Name, Is.EqualTo("MikeX")); - Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); - Assert.That(v.ChangeLog.UpdatedBy, Is.Not.Null); - Assert.That(v.ChangeLog.UpdatedDate, Is.Not.Null); - Assert.That(v.ETag, Is.Not.Null); - Assert.That(v.ETag, Does.Not.StartsWith("\"")); - - v.ETag = "ZZZZZ"; - v.Name += "X"; - Assert.ThrowsAsync(() => _db.Persons2.UpdateAsync(v)); - } - - [Test] - public async Task Update3Async() - { - Assert.ThrowsAsync(() => _db.Persons3.UpdateAsync(null!)); - - // Get previous. - var v = await _db.Persons3.GetAsync(5.ToGuid()); - Assert.That(v, Is.Not.Null); - - // Update testing. - v!.Id = 404.ToGuid(); - Assert.ThrowsAsync(() => _db.Persons3.UpdateAsync(v)); - - v.Id = 5.ToGuid(); - v.Name += "X"; - v = await _db.Persons3.UpdateAsync(v); - Assert.That(v, Is.Not.Null); - Assert.That(v.Id, Is.EqualTo(5.ToGuid())); - Assert.That(v.Name, Is.EqualTo("MikeX")); - Assert.That(v.ChangeLog, Is.Not.Null); - Assert.That(v.ChangeLog!.CreatedBy, Is.Not.Null); - Assert.That(v.ChangeLog.CreatedDate, Is.Not.Null); - Assert.That(v.ChangeLog.UpdatedBy, Is.Not.Null); - Assert.That(v.ChangeLog.UpdatedDate, Is.Not.Null); - Assert.That(v.ETag, Is.Not.Null); - Assert.That(v.ETag, Does.Not.StartsWith("\"")); - - v.ETag = "ZZZZZ"; - v.Name += "X"; - Assert.ThrowsAsync(() => _db.Persons3.UpdateAsync(v)); - } - - [Test] - public async Task Delete1Async() - { - Assert.ThrowsAsync(() => _db.Persons1.DeleteAsync(404.ToGuid().ToString())); - - await _db.Persons1.DeleteAsync(4.ToGuid().ToString()); - - using (var r = await _db.Persons1.CosmosContainer.ReadItemStreamAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) - { - Assert.That(r, Is.Not.Null); - Assert.That(r.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.NotFound)); - }; - } - - [Test] - public async Task Delete2Async() - { - Assert.ThrowsAsync(() => _db.Persons2.DeleteAsync(404.ToGuid().ToString())); - - await _db.Persons2.DeleteAsync(4.ToGuid().ToString()); - - using (var r = await _db.Persons2.CosmosContainer.ReadItemStreamAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) - { - Assert.That(r, Is.Not.Null); - Assert.That(r.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.NotFound)); - }; - } - - [Test] - public async Task Delete3Async() - { - Assert.ThrowsAsync(() => _db.Persons3.DeleteAsync(404.ToGuid())); - - await _db.Persons3.DeleteAsync(4.ToGuid()); - - using (var r = await _db.Persons3.CosmosContainer.ReadItemStreamAsync(4.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) - { - Assert.That(r, Is.Not.Null); - Assert.That(r.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.NotFound)); - }; - - using (var r = await _db.Persons3.CosmosContainer.ReadItemStreamAsync(100.ToGuid().ToString(), Microsoft.Azure.Cosmos.PartitionKey.None)) - { - Assert.That(r, Is.Not.Null); - Assert.That(r.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK)); - }; - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbModelTest.cs b/tests/CoreEx.Cosmos.Test/CosmosDbModelTest.cs deleted file mode 100644 index 5f00fdad..00000000 --- a/tests/CoreEx.Cosmos.Test/CosmosDbModelTest.cs +++ /dev/null @@ -1,42 +0,0 @@ -using CoreEx.Cosmos.Model; -using Microsoft.Azure.Cosmos; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace CoreEx.Cosmos.Test -{ - [TestFixture] - [Category("WithCosmos")] - public class CosmosDbModelTest - { - private CosmosDb _db; - - [OneTimeSetUp] - public async Task SetUp() - { - await TestSetUp.SetUpAsync().ConfigureAwait(false); - _db = new CosmosDb(auth: false); - } - - [Test] - public async Task SelectMultiSetWithResultAsync() - { - PersonX1[] people = Array.Empty(); - var hasPerson = false; - - var result = await _db.PersonsX.Model.SelectMultiSetWithResultAsync(PartitionKey.None, - new MultiSetModelCollArgs(r => people = r.ToArray()), - new MultiSetModelSingleArgs(r => hasPerson = true)); - - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(people, Has.Length.EqualTo(2)); - Assert.That(hasPerson, Is.True); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbQueryPartitioningTest.cs b/tests/CoreEx.Cosmos.Test/CosmosDbQueryPartitioningTest.cs deleted file mode 100644 index 8711b29d..00000000 --- a/tests/CoreEx.Cosmos.Test/CosmosDbQueryPartitioningTest.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.Azure.Cosmos; - -namespace CoreEx.Cosmos.Test -{ - [TestFixture] - [Category("WithCosmos")] - public class CosmosDbQueryPartitioningTest - { - private CosmosDb _db; - - [OneTimeSetUp] - public async Task SetUp() - { - await TestSetUp.SetUpAsync("/filter", "/value/filter").ConfigureAwait(false); - _db = new CosmosDb(auth: false); - } - - [Test] - public async Task Query1() - { - var v = await _db.Persons1.Query().ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(5)); - - v = await _db.Persons1.Query(new PartitionKey("A")).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(3)); - } - - [Test] - public async Task Query2() - { - var v = await _db.Persons2.Query().ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(5)); - - v = await _db.Persons2.Query(new PartitionKey("A")).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(3)); - } - - [Test] - public async Task Query3() - { - var v = await _db.Persons3.Query().ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(5)); - - v = await _db.Persons3.Query(new PartitionKey("A")).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(3)); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs b/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs deleted file mode 100644 index 1ba10d94..00000000 --- a/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs +++ /dev/null @@ -1,230 +0,0 @@ -using CoreEx.Data.Querying; -using CoreEx.Data.Querying.Expressions; -using CoreEx.Entities; - -namespace CoreEx.Cosmos.Test -{ - [TestFixture] - [Category("WithCosmos")] - public class CosmosDbQueryTest - { - private CosmosDb _db; - - [OneTimeSetUp] - public async Task SetUp() - { - await TestSetUp.SetUpAsync().ConfigureAwait(false); - _db = new CosmosDb(auth: false); - } - - [Test] - public async Task Query_NoPaging1() - { - var v = await _db.Persons1.Query().ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(5)); - - v = await _db.Persons1.Query(q => q.Where(x => x.Name == "Greg")).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(1)); - Assert.That(v[0].Name, Is.EqualTo("Greg")); - - v = await _db.Persons1.Query(q => q.Where(x => x.Name == "GREG")).ToArrayAsync(); - Assert.That(v, Is.Empty); - } - - [Test] - public async Task Query_Paging1() - { - var pr = new Entities.PagingResult(Entities.PagingArgs.CreateSkipAndTake(1, 2, true)); - var v = await _db.Persons1.Query(q => q.OrderBy(x => x.Id)).WithPaging(pr).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Gary")); - Assert.That(v[1].Name, Is.EqualTo("Greg")); - Assert.That(pr.TotalCount, Is.EqualTo(5)); - - v = await _db.Persons1.Query(q => q.OrderBy(x => x.Name)).WithPaging(1, 2).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Greg")); - Assert.That(v[1].Name, Is.EqualTo("Mike")); - - var vr = await _db.Persons1.Query(q => q.OrderBy(x => x.Name).Where(x => !x.Locked)).WithPaging(Entities.PagingArgs.CreateSkipAndTake(1, 2, true)).SelectResultAsync(); - Assert.That(vr.Items, Has.Count.EqualTo(2)); - Assert.That(vr.Items[0].Name, Is.EqualTo("Mike")); - Assert.That(vr.Items[1].Name, Is.EqualTo("Rebecca")); - Assert.That(vr.Paging, Is.Not.Null); - Assert.That(vr.Paging!.TotalCount, Is.EqualTo(3)); - } - - [Test] - public async Task Query_Wildcards1() - { - var v = await _db.Persons1.Query(q => q.WhereWildcard(x => x.Name, "g*").OrderBy(x => x.Id)).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Gary")); - Assert.That(v[1].Name, Is.EqualTo("Greg")); - - v = await _db.Persons1.Query(q => q.WhereWildcard(x => x.Name, "*Y").OrderBy(x => x.Id)).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Gary")); - Assert.That(v[1].Name, Is.EqualTo("Sally")); - - v = await _db.Persons1.Query(q => q.WhereWildcard(x => x.Name, "*e*").OrderBy(x => x.Id)).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(3)); - Assert.That(v[0].Name, Is.EqualTo("Rebecca")); - Assert.That(v[1].Name, Is.EqualTo("Greg")); - Assert.That(v[2].Name, Is.EqualTo("Mike")); - - var ex = Assert.ThrowsAsync(() => _db.Persons1.Query(q => q.WhereWildcard(x => x.Name, "*m*e")).ToArrayAsync()); - Assert.That(ex!.Message, Is.EqualTo("Wildcard selection text is not supported.")); - } - - [Test] - public async Task Query_NoPaging2() - { - var v = await _db.Persons2.Query().ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(5)); - - v = await _db.Persons2.Query(q => q.Where(x => x.Name == "Greg")).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(1)); - Assert.That(v[0].Name, Is.EqualTo("Greg")); - - v = await _db.Persons2.Query(q => q.Where(x => x.Name == "GREG")).ToArrayAsync(); - Assert.That(v, Is.Empty); - } - - [Test] - public async Task Query_Paging2() - { - var pr = new Entities.PagingResult(Entities.PagingArgs.CreateSkipAndTake(1, 2, true)); - var v = await _db.Persons2.Query(q => q.OrderBy(x => x.Id)).WithPaging(pr).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Gary")); - Assert.That(v[1].Name, Is.EqualTo("Greg")); - Assert.That(pr.TotalCount, Is.EqualTo(5)); - - v = await _db.Persons2.Query(q => q.OrderBy(x => x.Name)).WithPaging(1, 2).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Greg")); - Assert.That(v[1].Name, Is.EqualTo("Mike")); - - var vr = await _db.Persons2.Query(q => q.OrderBy(x => x.Name).Where(x => !x.Locked)).WithPaging(Entities.PagingArgs.CreateSkipAndTake(1, 2, true)).SelectResultAsync(); - Assert.That(vr.Items, Has.Count.EqualTo(2)); - Assert.That(vr.Items[0].Name, Is.EqualTo("Mike")); - Assert.That(vr.Items[1].Name, Is.EqualTo("Rebecca")); - Assert.That(vr.Paging, Is.Not.Null); - Assert.That(vr.Paging!.TotalCount, Is.EqualTo(3)); - } - - [Test] - public async Task Query_Wildcards2() - { - var v = await _db.Persons2.Query(q => q.WhereWildcard(x => x.Name, "g*").OrderBy(x => x.Id)).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Gary")); - Assert.That(v[1].Name, Is.EqualTo("Greg")); - - v = await _db.Persons2.Query(q => q.WhereWildcard(x => x.Name, "*Y").OrderBy(x => x.Id)).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Gary")); - Assert.That(v[1].Name, Is.EqualTo("Sally")); - - v = await _db.Persons2.Query(q => q.WhereWildcard(x => x.Name, "*e*").OrderBy(x => x.Id)).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(3)); - Assert.That(v[0].Name, Is.EqualTo("Rebecca")); - Assert.That(v[1].Name, Is.EqualTo("Greg")); - Assert.That(v[2].Name, Is.EqualTo("Mike")); - - var ex = Assert.ThrowsAsync(() => _db.Persons2.Query(q => q.WhereWildcard(x => x.Name, "*m*e")).ToArrayAsync()); - Assert.That(ex!.Message, Is.EqualTo("Wildcard selection text is not supported.")); - } - - [Test] - public async Task Query_NoPaging3() - { - var v = await _db.Persons3.Query().ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(5)); - - v = await _db.Persons3.Query(q => q.Where(x => x.Value.Name == "Greg")).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(1)); - Assert.That(v[0].Name, Is.EqualTo("Greg")); - - v = await _db.Persons3.Query(q => q.Where(x => x.Value.Name == "GREG")).ToArrayAsync(); - Assert.That(v, Is.Empty); - } - - [Test] - public async Task Query_Paging3() - { - var pr = new Entities.PagingResult(Entities.PagingArgs.CreateSkipAndTake(1, 2, true)); - var v = await _db.Persons3.Query(q => q.OrderBy(x => x.Id)).WithPaging(pr).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Gary")); - Assert.That(v[1].Name, Is.EqualTo("Greg")); - Assert.That(pr.TotalCount, Is.EqualTo(5)); - - v = await _db.Persons3.Query(q => q.OrderBy(x => x.Value.Name)).WithPaging(1, 2).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Greg")); - Assert.That(v[1].Name, Is.EqualTo("Mike")); - - var vr = await _db.Persons3.Query(q => q.OrderBy(x => x.Value.Name).Where(x => !x.Value.Locked)).WithPaging(Entities.PagingArgs.CreateSkipAndTake(1, 2, true)).SelectResultAsync(); - Assert.That(vr.Items, Has.Count.EqualTo(2)); - Assert.That(vr.Items[0].Name, Is.EqualTo("Mike")); - Assert.That(vr.Items[1].Name, Is.EqualTo("Rebecca")); - Assert.That(vr.Paging, Is.Not.Null); - Assert.That(vr.Paging!.TotalCount, Is.EqualTo(3)); - } - - [Test] - public async Task Query_Wildcards3() - { - var v = await _db.Persons3.Query(q => q.WhereWildcard(x => x.Value.Name, "g*").OrderBy(x => x.Id)).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Gary")); - Assert.That(v[1].Name, Is.EqualTo("Greg")); - - v = await _db.Persons3.Query(q => q.WhereWildcard(x => x.Value.Name, "*Y").OrderBy(x => x.Id)).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Name, Is.EqualTo("Gary")); - Assert.That(v[1].Name, Is.EqualTo("Sally")); - - v = await _db.Persons3.Query(q => q.WhereWildcard(x => x.Value.Name, "*e*").OrderBy(x => x.Id)).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(3)); - Assert.That(v[0].Name, Is.EqualTo("Rebecca")); - Assert.That(v[1].Name, Is.EqualTo("Greg")); - Assert.That(v[2].Name, Is.EqualTo("Mike")); - - var ex = Assert.ThrowsAsync(() => _db.Persons3.Query(q => q.WhereWildcard(x => x.Value.Name, "*m*e")).ToArrayAsync()); - Assert.That(ex!.Message, Is.EqualTo("Wildcard selection text is not supported.")); - } - - [Test] - public async Task ModelQuery_Paging3() - { - var pr = new Entities.PagingResult(Entities.PagingArgs.CreateSkipAndTake(1, 2, true)); - var v = await _db.Persons3.Model.Query(q => q.OrderBy(x => x.Id)).WithPaging(pr).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Value.Name, Is.EqualTo("Gary")); - Assert.That(v[1].Value.Name, Is.EqualTo("Greg")); - Assert.That(pr.TotalCount, Is.EqualTo(5)); - - v = await _db.Persons3.Model.Query(q => q.OrderBy(x => x.Value.Name)).WithPaging(1, 2).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Value.Name, Is.EqualTo("Greg")); - Assert.That(v[1].Value.Name, Is.EqualTo("Mike")); - } - - [Test] - public async Task ModelQuery_WithFilter() - { - var qac = QueryArgsConfig.Create() - .WithFilter(f => f - .AddField("Name", "Value.Name", c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) - .AddField("Birthday", "Value.Birthday")); - - var v = await _db.Persons3.Model.Query(q => q.Where(qac, QueryArgs.Create("endswith(name, 'Y')")).OrderBy(x => x.Id)).ToArrayAsync(); - Assert.That(v, Has.Length.EqualTo(2)); - Assert.That(v[0].Value.Name, Is.EqualTo("Gary")); - Assert.That(v[1].Value.Name, Is.EqualTo("Sally")); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/Data/Data.yaml b/tests/CoreEx.Cosmos.Test/Data/Data.yaml deleted file mode 100644 index ebc337fc..00000000 --- a/tests/CoreEx.Cosmos.Test/Data/Data.yaml +++ /dev/null @@ -1,24 +0,0 @@ -Data: - - Person1: - - { id: ^1, name: "Rebecca", birthday: 1990-08-07, salary: 150000, locked: false, filter: B } - - { id: ^2, name: "Gary", birthday: 1986-11-04, salary: 95000, locked: true, filter: B } - - { id: ^3, name: "Greg", birthday: 1970-07-06, salary: 101000, locked: false, filter: A } - - { id: ^4, name: "Sally", birthday: 1999-02-28, salary: 50000, locked: true, filter: A } - - { id: ^5, name: "Mike", birthday: 1967-09-13, salary: 135000, locked: false, filter: A } - - Person2: - - { id: ^1, name: "Rebecca", birthday: 1990-08-07, salary: 150000, locked: false, filter: B } - - { id: ^2, name: "Gary", birthday: 1986-11-04, salary: 95000, locked: true, filter: B } - - { id: ^3, name: "Greg", birthday: 1970-07-06, salary: 101000, locked: false, filter: A } - - { id: ^4, name: "Sally", birthday: 1999-02-28, salary: 50000, locked: true, filter: A } - - { id: ^5, name: "Mike", birthday: 1967-09-13, salary: 135000, locked: false, filter: A } - - Person3: - - { id: ^1, name: "Rebecca", birthday: 1990-08-07, salary: 150000, locked: false, filter: B } - - { id: ^2, name: "Gary", birthday: 1986-11-04, salary: 95000, locked: true, filter: B } - - { id: ^3, name: "Greg", birthday: 1970-07-06, salary: 101000, locked: false, filter: A } - - { id: ^4, name: "Sally", birthday: 1999-02-28, salary: 50000, locked: true, filter: A } - - { id: ^5, name: "Mike", birthday: 1967-09-13, salary: 135000, locked: false, filter: A } - - PersonX: - - { id: A, type: PersonX1, text: "AAA" } - - { id: B, type: PersonX2, name: "BBB" } - - { id: C, type: PersonX1, text: "CCC" } - - { id: D, type: PersonX3, text: "DDD" } \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/Data/RefData.yaml b/tests/CoreEx.Cosmos.Test/Data/RefData.yaml deleted file mode 100644 index a806b7c4..00000000 --- a/tests/CoreEx.Cosmos.Test/Data/RefData.yaml +++ /dev/null @@ -1,4 +0,0 @@ -Ref: - - Gender: - - F: Female - - M: Male \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/Models.cs b/tests/CoreEx.Cosmos.Test/Models.cs deleted file mode 100644 index 6d35c720..00000000 --- a/tests/CoreEx.Cosmos.Test/Models.cs +++ /dev/null @@ -1,68 +0,0 @@ -using CoreEx.Entities; -using CoreEx.RefData; -using Newtonsoft.Json; - -namespace CoreEx.Cosmos.Test -{ - public class Person1 : IIdentifier - { - public string? Id { get; set; } - public string? Name { get; set; } - public DateTime Birthday { get; set; } - public decimal Salary { get; set; } - public bool Locked { get; set; } - public string? Filter { get; set; } - } - - public class Person1Collection : List { } - - public class Person1CollectionResult : CollectionResult { } - - public class Person2 : Person1, IChangeLog, IETag - { - public ChangeLog? ChangeLog { get; set; } - - [JsonProperty("_etag")] - public string? ETag { get; set; } - } - - public class Person2Collection : List { } - - public class Person2CollectionResult : CollectionResult { } - - public class Person3 : IIdentifier, IChangeLog, IETag - { - public Guid Id { get; set; } - public string? Name { get; set; } - public DateTime Birthday { get; set; } - public decimal Salary { get; set; } - public bool Locked { get; set; } - public string? Filter { get; set; } - public ChangeLog? ChangeLog { get; set; } - public string? ETag { get; set; } - } - - public class Person3Collection : List { } - - public class Person3CollectionResult : CollectionResult { } - - public class Gender : ReferenceDataBase { } - - public class PersonX1 : IIdentifier, ICosmosDbType - { - public string? Id { get; set; } - - public string Type { get; set; } = "PersonX1"; - - public string? Text { get; set; } - } - - public class PersonX2 : IIdentifier, ICosmosDbType - { - public string? Id { get; set; } - - public string Type { get; set; } = "PersonX2"; - - public string? Name { get; set; } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/TestSetUp.cs b/tests/CoreEx.Cosmos.Test/TestSetUp.cs deleted file mode 100644 index 2d3c75e2..00000000 --- a/tests/CoreEx.Cosmos.Test/TestSetUp.cs +++ /dev/null @@ -1,102 +0,0 @@ -using CoreEx.Cosmos.Batch; -using CoreEx.Json.Data; -using CoreEx.Mapping; -using Microsoft.Azure.Cosmos; -using AzCosmos = Microsoft.Azure.Cosmos; - -namespace CoreEx.Cosmos.Test -{ - public static class TestSetUp - { - public static AzCosmos.CosmosClient? CosmosClient { get; private set; } - - public static AzCosmos.Database? CosmosDatabase { get; private set; } - - public static IMapper? Mapper { get; private set; } - - public static async Task SetUpAsync(string partitionKeyPath = "/_partitionKey", string valuePartitionKeyPath = "/_partitionKey") - { - CoreEx.Cosmos.Batch.CosmosDbBatch.SequentialExecution = true; - - //cleanup if client was already created ?? - CosmosClient?.Dispose(); - - var cco = new AzCosmos.CosmosClientOptions - { - SerializerOptions = new AzCosmos.CosmosSerializationOptions { PropertyNamingPolicy = AzCosmos.CosmosPropertyNamingPolicy.CamelCase, IgnoreNullValues = true }, - // https://docs.microsoft.com/en-us/azure/cosmos-db/linux-emulator?tabs=sql-api%2Cssl-netstd21#my-app-cant-connect-to-emulator-endpoint-the-tlsssl-connection-couldnt-be-established-or-i-cant-start-the-data-explorer - HttpClientFactory = () => - { - HttpMessageHandler httpMessageHandler = new HttpClientHandler() - { - ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - }; - - return new HttpClient(httpMessageHandler); - }, - ConnectionMode = AzCosmos.ConnectionMode.Gateway, - RequestTimeout = TimeSpan.FromMinutes(3) - }; - - var endpoint = Environment.GetEnvironmentVariable("CoreEx_Cosmos_Test_Endpoint") ?? "https://localhost:8081"; - var token = Environment.GetEnvironmentVariable("CoreEx_Cosmos_Test_Token") ?? "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; - CosmosClient = new AzCosmos.CosmosClient(endpoint, token, cco); - - CosmosDatabase = (await CosmosClient.CreateDatabaseIfNotExistsAsync("CoreEx.Cosmos.Test").ConfigureAwait(false)).Database; - - Mapper ??= new AutoMapperWrapper(new AutoMapper.Mapper(new AutoMapper.MapperConfiguration(c => - { - c.AddProfile(); - c.CreateMap(); - c.CreateMap(); - c.CreateMap(); - }))); - - var c1 = await CosmosDatabase.ReplaceOrCreateContainerAsync(new AzCosmos.ContainerProperties - { - Id = "Persons1", - PartitionKeyPath = partitionKeyPath, - UniqueKeyPolicy = new AzCosmos.UniqueKeyPolicy { UniqueKeys = { new AzCosmos.UniqueKey { Paths = { "/name" } } } } - }, 400).ConfigureAwait(false); - - var c2 = await CosmosDatabase.ReplaceOrCreateContainerAsync(new AzCosmos.ContainerProperties - { - Id = "Persons2", - PartitionKeyPath = partitionKeyPath, - UniqueKeyPolicy = new AzCosmos.UniqueKeyPolicy { UniqueKeys = { new AzCosmos.UniqueKey { Paths = { "/name" } } } } - }, 400).ConfigureAwait(false); - - var c3 = await CosmosDatabase.ReplaceOrCreateContainerAsync(new AzCosmos.ContainerProperties - { - Id = "Persons3", - PartitionKeyPath = valuePartitionKeyPath, - UniqueKeyPolicy = new AzCosmos.UniqueKeyPolicy { UniqueKeys = { new AzCosmos.UniqueKey { Paths = { "/type", "/value/name" } } } } - }, 400).ConfigureAwait(false); - - var c4 = await CosmosDatabase.ReplaceOrCreateContainerAsync(new AzCosmos.ContainerProperties - { - Id = "RefData", - PartitionKeyPath = "/_partitionKey", - UniqueKeyPolicy = new AzCosmos.UniqueKeyPolicy { UniqueKeys = { new AzCosmos.UniqueKey { Paths = { "/type", "/value/code" } } } } - }, 400); - - var c5 = await CosmosDatabase.ReplaceOrCreateContainerAsync(new AzCosmos.ContainerProperties - { - Id = "PersonsX", - PartitionKeyPath = "/_partitionKey" - }, 400); - - var db = new CosmosDb(auth: false); - - var jdr = JsonDataReader.ParseYaml("Data.yaml"); - await db.Persons1.ImportBatchAsync(jdr); - await db.Persons2.ImportBatchAsync(jdr); - await db.Persons3.ImportValueBatchAsync(jdr); - await db.ImportValueBatchAsync("Persons3", new Person1[] { new() { Id = 100.ToGuid().ToString(), Filter = "A" } }); // Add other random "type" to Person3. - await db.PersonsX.ImportJsonBatchAsync(jdr, "PersonX"); - - jdr = JsonDataReader.ParseYaml("RefData.yaml", new JsonDataReaderArgs(new Text.Json.ReferenceDataContentJsonSerializer())); - await db.ImportValueBatchAsync("RefData", jdr, new Type[] { typeof(Gender) }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Cosmos.Test/TestingWithEmulator.md b/tests/CoreEx.Cosmos.Test/TestingWithEmulator.md deleted file mode 100644 index abdcc8fa..00000000 --- a/tests/CoreEx.Cosmos.Test/TestingWithEmulator.md +++ /dev/null @@ -1,58 +0,0 @@ -# About - -Tests can be executed with [Cosmos Db Emulator](https://learn.microsoft.com/en-us/azure/cosmos-db/linux-emulator?tabs=sql-api%2Cssl-netstd21) running in a container. - -> Caution! Emulator requires 4 CPUs to execute tests successfully. That's more than free GitHub runners offer (2). - -## Docker compose - -Use following docker compose file to simulate github actions environment: - -```yaml -# To run in root directory: docker-compose -f docker-compose.cosmos.yml run --rm myhr-cosmos-tests -version: '3.4' - -services: - - cosmos: - image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest - container_name: azure-cosmos-emulator - healthcheck: - test: curl -f -k https://localhost:8081/_explorer/emulator.pem || exit 1 - interval: 1m30s - timeout: 10s - retries: 3 - start_period: 30s - tty: true - ports: - - 8081:8081 - - 10251:10251 - - 10252:10252 - - 10253:10253 - - 10254:10254 - - 10255:10255 - environment: - - AZURE_COSMOS_EMULATOR_PARTITION_COUNT=20 - - AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=false - deploy: - resources: - limits: - cpus: "4.0" # Tests fail with 2 CPUs - memory: 4g - - myhr-cosmos-tests: - image: mcr.microsoft.com/dotnet/sdk:6.0 - stdin_open: true # docker run -i - tty: true # docker run -t - volumes: - - .:/src - command: cd /src/tests/CoreEx.Cosmos.Test && dotnet test - depends_on: - - cosmos -``` - -## Running tests - -* Connection string needs to be updated to reflect docker service name: `AzCosmos.CosmosClient("https://cosmos:8081"`. -* cd to tests directory `cd src/tests/CoreEx.Cosmos.Test/` -* run tests `dotnet test` diff --git a/tests/CoreEx.Cosmos.Test/Usings.cs b/tests/CoreEx.Cosmos.Test/Usings.cs deleted file mode 100644 index cefced49..00000000 --- a/tests/CoreEx.Cosmos.Test/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using NUnit.Framework; \ No newline at end of file diff --git a/tests/CoreEx.Data.Test.Unit/CoreEx.Data.Test.Unit.csproj b/tests/CoreEx.Data.Test.Unit/CoreEx.Data.Test.Unit.csproj new file mode 100644 index 00000000..5c76dc5e --- /dev/null +++ b/tests/CoreEx.Data.Test.Unit/CoreEx.Data.Test.Unit.csproj @@ -0,0 +1,44 @@ + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CoreEx.Data.Test.Unit/Querying/QueryFilterParserTests.cs b/tests/CoreEx.Data.Test.Unit/Querying/QueryFilterParserTests.cs new file mode 100644 index 00000000..f393fca1 --- /dev/null +++ b/tests/CoreEx.Data.Test.Unit/Querying/QueryFilterParserTests.cs @@ -0,0 +1,243 @@ +using CoreEx.Data.Querying; +using CoreEx.Data.Querying.Expressions; +using CoreEx.Entities; + +namespace CoreEx.Data.Test.Unit.Querying; + +[TestFixture] +public class QueryFilterParserTests +{ + // NOTHING... + [TestCase(null, null)] + [TestCase("", null)] + [TestCase(" ", null)] + + // COMPARISON... + [TestCase("lastname eq 'Smith'", "(LastName != null && LastName == @0)", "Smith")] + [TestCase("lastname eq null", "LastName == null")] + [TestCase("firstname eq 'Angela'", "FirstName.ToUpper() == @0", "ANGELA")] + [TestCase("code eq 'Xyz'", "Code == @0", "Xyz")] + [TestCase("age lt 100", "Age < @0", 100)] + [TestCase("age le 100", "Age <= @0", 100)] + [TestCase("age gt 100", "Age > @0", 100)] + [TestCase("age ge 100", "Age >= @0", 100)] + [TestCase("salary gt 1036.42", "Salary > @0", 1036.42)] + [TestCase("isold eq true", "IsOld == true")] + [TestCase("IsOld ne false", "IsOld != false")] + [TestCase("ISOLD ne null", "IsOld != null")] + [TestCase("isold", "IsOld")] + [TestCase("messagetype eq 'info'", "MessageType == @0", MessageType.Info)] + + // IN... + [TestCase("code in ('abc', 'def')", "Code in (@0, @1)", "abc", "def")] + [TestCase("age in (20, 30, 40)", "Age in (@0, @1, @2)", 20, 30, 40)] + [TestCase("age in (20)", "Age in (@0)", 20)] + + // AND/OR... + [TestCase("(age eq 1 or age eq 2) and isold eq true", "(Age == @0 || Age == @1) && IsOld == true", 1, 2)] + [TestCase(" ( age eq 1 or age eq 2 ) and isold ", "(Age == @0 || Age == @1) && IsOld", 1, 2)] + [TestCase("(age eq 1 or age eq 2) or (age eq 8 or age eq 9)", "(Age == @0 || Age == @1) || (Age == @2 || Age == @3)", 1, 2, 8, 9)] + [TestCase("((age eq 1 or age eq 2) or (age eq 8 or age eq 9))", "((Age == @0 || Age == @1) || (Age == @2 || Age == @3))", 1, 2, 8, 9)] + + // FUNCTIONS... + [TestCase("startswith(firstName, 'abc')", "FirstName.ToUpper().StartsWith(@0)", "ABC")] + [TestCase("endswith(firstName, 'abc')", "FirstName.ToUpper().EndsWith(@0)", "ABC")] + [TestCase("contains(firstName, 'abc')", "FirstName.ToUpper().Contains(@0)", "ABC")] + [TestCase("contains(lastname, 'xyz')", "(LastName != null && LastName.Contains(@0))", "xyz")] + + // NOT... + [TestCase("not (age eq 1)", "!(Age == @0)", 1)] + [TestCase("age eq 1 and not (age eq 2)", "Age == @0 && !(Age == @1)", 1, 2)] + + // LITERALS... + [TestCase("code eq ''", "Code == @0", "")] + [TestCase("code eq ''''", "Code == @0", "'")] + [TestCase("code eq 'x''x'", "Code == @0", "x'x")] + [TestCase("code eq 'x'''", "Code == @0", "x'")] + [TestCase("code eq '''x'", "Code == @0", "'x")] + [TestCase("code eq '''x'''", "Code == @0", "'x'")] + [TestCase("code eq 'null'", "Code == @0", "null")] + + // NULL_FIELD... + [TestCase("terminated eq null", "TerminatedDate == null")] + [TestCase("terminated ne null", "TerminatedDate != null")] + public void Parse_Success(string? filter, string? expected, params object[] expectedArgs) => TestUtility.AssertFilterSuccess(filter, expected, expectedArgs); + + [Test] + public void Parse_Dates_Success() + { + TestUtility.AssertFilterSuccess("birthday eq 1980-01-01", "BirthDate == @0", new DateTime(1980, 1, 1)); + TestUtility.AssertFilterSuccess("birthday ne 1980-01-01", "BirthDate != @0", new DateTime(1980, 1, 1)); + } + + // COMPARISON... + [TestCase("banana", "Field 'banana' is not supported.")] + [TestCase("banana eq", "Field 'banana' is not supported.")] + [TestCase("age apple", "Field 'age' does not support 'apple' as an operator.")] + [TestCase("age 'apple'", "Field 'age' does not support ''apple'' as an operator.")] + [TestCase("age eq 'apple'", "Field 'age' constant 'apple' must not be specified as a Literal where the underlying type is not a string.")] + [TestCase("age eq 1990-01-01", "Field 'age' has a value '1990-01-01' that is not a valid Int32: The input string '1990-01-01' was not in a correct format.")] + [TestCase("null eq null", "There is a 'null' positioning that is syntactically incorrect.")] + [TestCase("true eq null", "There is a 'true' positioning that is syntactically incorrect.")] + [TestCase("false eq null", "There is a 'false' positioning that is syntactically incorrect.")] + [TestCase("and", "There is a 'and' positioning that is syntactically incorrect.")] + [TestCase("or", "There is a 'or' positioning that is syntactically incorrect.")] + [TestCase("and age eq 1", "There is a 'and' positioning that is syntactically incorrect.")] + [TestCase("or age eq 1", "There is a 'or' positioning that is syntactically incorrect.")] + [TestCase("age eq 1 and", "The final expression is incomplete.")] + [TestCase("age eq 1 or", "The final expression is incomplete.")] + [TestCase("isold ge true", "Field 'isold' does not support the 'ge' operator.")] + [TestCase("age xx 1", "Field 'age' does not support 'xx' as an operator.")] + [TestCase("age ge null", "Field 'age' constant must not be null for an 'ge' operator.")] + [TestCase("age eq null", "Field 'age' constant 'null' is not supported.")] + [TestCase("messagetype eq 'wonky'", "Field 'messagetype' has a value 'wonky' that is not a valid MessageType.")] + + // BRACKETS... + [TestCase("(age eq 1", "There is an opening '(' that has no matching closing ')'.")] + [TestCase("age eq 1)", "There is a closing ')' that has no matching opening '('.")] + [TestCase("age ( 1", "Field 'age' does not support '(' as an operator.")] + [TestCase("age eq (", "Field 'age' constant '(' is not considered valid.")] + [TestCase("age eq )", "Field 'age' constant ')' is not considered valid.")] + + // IN... + [TestCase("code in", "The final expression is incomplete.")] + [TestCase("code in ()", "Field 'code' constant must be specified before the closing ')' for the 'in' operator.")] + [TestCase("code in (null)", "Field 'code' constant must not be null for an 'in' operator.")] + [TestCase("code in ))", "Field 'code' must specify an opening '(' for the 'in' operator.")] + [TestCase("code in ((", "Field 'code' must close ')' the 'in' operator before specifying a further open '('.")] + [TestCase("code in (,)", "Field 'code' constant ',' is not considered valid.")] + [TestCase("age in (1 2)", "Field 'age' expects a ',' separator between constant values for an 'in' operator.")] + + // AND/OR... + [TestCase("or age eq 1", "There is a 'or' positioning that is syntactically incorrect.")] + [TestCase("and age eq 1", "There is a 'and' positioning that is syntactically incorrect.")] + [TestCase("age or eq 1", "Field 'age' does not support 'or' as an operator.")] + [TestCase("age eq and 1", "Field 'age' constant 'and' is not considered valid.")] + [TestCase("age eq 1 and and age eq 2", "There is a 'and' positioning that is syntactically incorrect.")] + [TestCase("age eq 1 or or age eq 2", "There is a 'or' positioning that is syntactically incorrect.")] + + // FUNCTIONS... + [TestCase("startswith(code, 'abc')", "Field 'code' does not support the 'startswith' function.")] + [TestCase("startswith)code, 'abc')", "A 'startswith' function expects an opening '(' not a ')'.")] + [TestCase("startswith(firstname( 'abc')", "A 'startswith' function expects a ',' separator between the field and its constant.")] + [TestCase("startswith(firstname, null)", "A 'startswith' function references a null constant which is not supported.")] + [TestCase("startswith(firstname, 'abc',", "A 'startswith' function expects a closing ')' not a ','.")] + + // NOT... + [TestCase("age eq 1 and not age eq 2", "A 'not' expects an opening '(' to start an expression versus a syntactically incorrect 'age' token.")] + [TestCase("age eq 1 not", "There is a 'not' positioning that is syntactically incorrect.")] + + // LITERALS... + [TestCase("code eq '", "A Literal has not been terminated.")] + [TestCase("code eq '''", "A Literal has not been terminated.")] + [TestCase("code eq '''''", "A Literal has not been terminated.")] + [TestCase("code eq 1", "Field 'code' constant '1' must be specified as a Literal where the underlying type is a string.")] + [TestCase("age eq '8'", "Field 'age' constant '8' must not be specified as a Literal where the underlying type is not a string.")] + + // DATES... + [TestCase("birthday eq '32'", "Field 'birthday' constant '32' must not be specified as a Literal where the underlying type is not a string.")] + [TestCase("birthday eq kiwifruit", "Field 'birthday' constant 'kiwifruit' is not considered valid.")] + [TestCase("birthday eq 1980-13-01", "Field 'birthday' has a value '1980-13-01' that is not a valid DateTime: String '1980-13-01' was not recognized as a valid DateTime.")] + [TestCase("birthday eq 1980-01-32", "Field 'birthday' has a value '1980-01-32' that is not a valid DateTime: String '1980-01-32' was not recognized as a valid DateTime.")] + + // NULL_FIELD... + [TestCase("terminated eq 13", "Field 'terminated' with value '13' is invalid: Only null comparisons are supported.")] + [TestCase("terminated gt null", "Field 'terminated' does not support the 'gt' operator.")] + public void Parse_Error(string? filter, string expected) => TestUtility.AssertFilterError(filter, expected); + + [TestCase("lastname eq 'Smith'", "LastName == @0", "Smith")] + [TestCase(null, "LastName == @0", "Brown")] + [TestCase("firstname eq 'Jenny'", "FirstName == @0 && LastName == @1", "Jenny", "Brown")] + public void Parse_WithFieldDefault(string? filter, string expected, params object[] expectedArgs) + { + var config = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName", c => c.WithDefault(new QueryStatement("LastName == @0", "Brown"))) + .AddField("FirstName") + .WithDefault(new QueryStatement("FirstName == @0", "Zoe"))); + + TestUtility.AssertFilterSuccess(config, filter, expected, expectedArgs); + } + + [TestCase("lastname eq 'Smith'", "LastName == @0", "Smith")] + [TestCase("", "FirstName == @0", "Zoe")] + [TestCase(null, "FirstName == @0", "Zoe")] + public void Parse_WithDefault(string? filter, string expected, params object[] expectedArgs) + { + var config = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName") + .AddField("FirstName") + .WithDefault(new QueryStatement("FirstName == @0", "Zoe"))); + + TestUtility.AssertFilterSuccess(config, filter, expected, expectedArgs); + } + + [TestCase(true, "lastname eq 'Smith'", "LastName == @0", "Smith")] + [TestCase(true, "firstname eq 'Angela'", "FirstName == @0 && LastName != null", "Angela")] + [TestCase(true, null, "LastName != null")] + [TestCase(false, "lastname eq 'Smith' and firstname eq 'Angela'", "Only a single field filter is allowed.")] + public void Parse_OnQuery(bool success, string? filter, string expected, params object[] expectedArgs) + { + var config = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName") + .AddField("FirstName") + .OnQuery(result => + { + if (!result.Fields.Contains("LastName")) + result.Writer.AppendStatement(new QueryStatement("LastName != null")); + + if (result.Fields.Count > 1) + throw new QueryFilterParserException("Only a single field filter is allowed."); + })); + + if (success) + TestUtility.AssertFilterSuccess(config, filter, expected, expectedArgs); + else + TestUtility.AssertFilterError(config, filter, expected); + } + + [TestCase("lastname ne 'abc'", "LastName != @0", "abc")] + [TestCase("lastname eq 'abc'", "LastName EQUALS @0", "abc")] + public void Parse_WithResultWriter(string? filter, string expected, string expectedArgs) + { + static bool LastNameWriter(IQueryFilterFieldStatementExpression expression, QueryFilterParserWriter writer) + { + if (expression is QueryFilterOperatorExpression oex && oex.Operator.Kind == QueryFilterTokenKind.Equal) + { + writer.AppendStatement(new QueryStatement("LastName EQUALS @0", oex.GetConstantValue(0))); + return true; + } + + return false; + } + + var config = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName", c => c.WithResultWriter(LastNameWriter)) + .AddField("FirstName")); + } + + [Test] + public void Config_ToString() + { + var s = TestUtility.Config.FilterParser.ToString(); + s.Should().NotBeNull(); + + Console.WriteLine(s); + + s.Should().NotBeNull().And.Be(Resource.GetString("FilterToString.txt")); + } + + [Test] + public void Config_ToSchemaDictionary() + { + var json = TestUtility.Config.FilterParser.ToJsonSchema(); + json.Should().NotBeNull(); + + Console.WriteLine(json.ToString()); + + ObjectComparer.AssertJsonFromResource("FilterSchema.json", json.ToString()); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Data.Test.Unit/Querying/QueryOrderByParserTests.cs b/tests/CoreEx.Data.Test.Unit/Querying/QueryOrderByParserTests.cs new file mode 100644 index 00000000..d4a99b41 --- /dev/null +++ b/tests/CoreEx.Data.Test.Unit/Querying/QueryOrderByParserTests.cs @@ -0,0 +1,39 @@ +namespace CoreEx.Data.Test.Unit.Querying; + +[TestFixture] +public class QueryOrderByParserTests +{ + [TestCase("firstname asc, birthday", "FirstName, BirthDate")] + [TestCase("lastname asc, birthday desc", "LastName, BirthDate desc")] + [TestCase(null, "LastName, FirstName")] + public void Parse_Success(string? filter, string expected) => TestUtility.AssertOrderBySuccess(filter, expected); + + [TestCase("firstname, middlename", "Field 'middlename' is not supported.")] + [TestCase("firstname, birthday asc", "Field 'birthday' direction 'asc' is invalid; not supported.")] + [TestCase("firstname, birthday both", "Field 'birthday' direction 'both' is invalid; must be either 'asc' (ascending) or 'desc' (descending).")] + [TestCase("firstname asc, firstname desc", "Field 'firstname' must not be specified more than once.")] + [TestCase("firstname asc desc", "Statement is syntactically incorrect.")] + public void Parse_Error(string? filter, string expected) => TestUtility.AssertOrderByError(filter, expected); + + [Test] + public void Config_ToString() + { + var s = TestUtility.Config.OrderByParser.ToString(); + s.Should().NotBeNull(); + + Console.WriteLine(s); + + s.Should().NotBeNull().And.Be(Resource.GetString("OrderByToString.txt")); + } + + [Test] + public void Config_ToSchemaDictionary() + { + var json = TestUtility.Config.OrderByParser.ToJsonSchema(); + json.Should().NotBeNull(); + + Console.WriteLine(json.ToString()); + + ObjectComparer.AssertJsonFromResource("OrderBySchema.json", json.ToString()); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Data.Test.Unit/Querying/TestUtility.cs b/tests/CoreEx.Data.Test.Unit/Querying/TestUtility.cs new file mode 100644 index 00000000..e2f537fd --- /dev/null +++ b/tests/CoreEx.Data.Test.Unit/Querying/TestUtility.cs @@ -0,0 +1,71 @@ +using CoreEx.Data.Querying; +using CoreEx.Entities; +using CoreEx.Http; + +namespace CoreEx.Data.Test.Unit.Querying; + +internal class TestUtility +{ + public static readonly QueryArgsConfig Config = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName", c => c.WithOperators(QueryFilterOperator.AllStringOperators).AlsoCheckNotNull()) + .AddField("FirstName", c => c.WithOperators(QueryFilterOperator.AllStringOperators).AsUpperCase()) + .AddField("Code", c => c.WithOperators(QueryFilterOperator.EqualityOperators)) + .AddField("Birthday", "BirthDate") + .AddField("Age", c => c.WithHelpText("Age is but a number.")) + .AddField("Salary") + .AddField("IsOld", c => c.AsNullable()) + .AddNullField("Terminated", "TerminatedDate") + .AddField("MessageType") + .WithHelpText($"The OData-like filtering is awesome!")) + .WithOrderBy(order => order + .AddField("LastName", c => c.WithDefault()) + .AddField("FirstName", c => c.WithDefault()) + .AddField("Birthday", "BirthDate", c => c.WithDirection(QueryOrderByDirection.Descending)) + .WithHelpText($"The OData-like ordering is awesome!")); + + public static void AssertFilterSuccess(string? filter, string? expected, params object[] expectedArgs) => AssertFilterSuccess(Config, filter, expected, expectedArgs); + + public static void AssertFilterSuccess(QueryArgsConfig config, string? filter, string? expected, params object[] expectedArgs) + { + var result = config.FilterParser.Parse(filter); + result.Should().NotBeNull(); + result.HasError.Should().BeFalse(); + result.Error.Should().BeNull(); + result.ToLinqString(out var args).Should().Be(expected); + args.Should().BeEquivalentTo(expectedArgs); + } + + public static void AssertFilterError(string? filter, string expected) => AssertFilterError(Config, filter, expected); + + public static void AssertFilterError(QueryArgsConfig config, string? filter, string expected) + { + var result = config.FilterParser.Parse(filter); + result.Should().NotBeNull(); + result.HasError.Should().BeTrue(); + result.Error.Should().NotBeNull(); + result.Error.Messages.Should().NotBeNull().And.HaveCount(1); + result.Error.Messages[0].Property.Should().Be(HttpNames.QueryFilterQueryStringName); + result.Error.Messages[0].Text.ToString().Should().StartWith(expected); + } + + public static void AssertOrderBySuccess(string? orderBy, string expected) + { + var result = Config.OrderByParser.Parse(orderBy); + result.Should().NotBeNull(); + result.HasError.Should().BeFalse(); + result.Error.Should().BeNull(); + result.ToLinqString().Should().Be(expected); + } + + public static void AssertOrderByError(string? orderBy, string expected) + { + var result = Config.OrderByParser.Parse(orderBy); + result.Should().NotBeNull(); + result.HasError.Should().BeTrue(); + result.Error.Should().NotBeNull(); + result.Error.Messages.Should().NotBeNull().And.HaveCount(1); + result.Error.Messages[0].Property.Should().Be(HttpNames.QueryOrderByQueryStringName); + result.Error.Messages[0].Text.ToString().Should().StartWith(expected); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Data.Test.Unit/Resources/FilterSchema.json b/tests/CoreEx.Data.Test.Unit/Resources/FilterSchema.json new file mode 100644 index 00000000..09086fc2 --- /dev/null +++ b/tests/CoreEx.Data.Test.Unit/Resources/FilterSchema.json @@ -0,0 +1,49 @@ +{ + "fields": { + "lastname": { + "type": "string", + "nullable": true, + "operators": [ "eq", "ne", "lt", "le", "ge", "gt", "in", "startswith", "contains", "endswith" ] + }, + "firstname": { + "type": "string", + "operators": [ "eq", "ne", "lt", "le", "ge", "gt", "in", "startswith", "contains", "endswith" ] + }, + "code": { + "type": "string", + "operators": [ "eq", "ne", "in" ] + }, + "birthday": { + "type": "string", + "format": "date-time", + "operators": [ "eq", "ne", "lt", "le", "ge", "gt", "in" ] + }, + "age": { + "type": "integer", + "format": "int32", + "operators": [ "eq", "ne", "lt", "le", "ge", "gt", "in" ], + "description": "Age is but a number." + }, + "salary": { + "type": "number", + "format": "decimal", + "operators": [ "eq", "ne", "lt", "le", "ge", "gt", "in" ] + }, + "isold": { + "type": "boolean", + "nullable": true, + "operators": [ "eq", "ne" ] + }, + "terminated": { + "type": "object", + "nullable": true, + "operators": [ "eq", "ne" ] + }, + "messagetype": { + "type": "string", + "operators": [ "eq", "ne", "in" ], + "enum": [ "Info", "Warning", "Error" ] + } + }, + "description": "The OData-like filtering is awesome!" +} \ No newline at end of file diff --git a/tests/CoreEx.Data.Test.Unit/Resources/FilterToString.txt b/tests/CoreEx.Data.Test.Unit/Resources/FilterToString.txt new file mode 100644 index 00000000..8c6297c6 --- /dev/null +++ b/tests/CoreEx.Data.Test.Unit/Resources/FilterToString.txt @@ -0,0 +1,12 @@ +Filter field(s) are as follows: +LastName (Type: String, Null: true, Operators: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) +FirstName (Type: String, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) +Code (Type: String, Null: false, Operators: EQ, NE, IN) +Birthday (Type: DateTime, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) +Age (Type: Int32, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) - Age is but a number. +Salary (Type: Decimal, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) +IsOld (Type: Boolean, Null: true, Operators: EQ, NE) +Terminated (Type: , Null: true, Operators: EQ, NE) +MessageType (Type: MessageType, Null: false, Operators: EQ, NE, IN) +--- +Note: The OData-like filtering is awesome! \ No newline at end of file diff --git a/tests/CoreEx.Data.Test.Unit/Resources/OrderBySchema.json b/tests/CoreEx.Data.Test.Unit/Resources/OrderBySchema.json new file mode 100644 index 00000000..da4e03f3 --- /dev/null +++ b/tests/CoreEx.Data.Test.Unit/Resources/OrderBySchema.json @@ -0,0 +1,9 @@ +{ + "fields": { + "lastname": { "direction": [ "asc", "desc" ] }, + "firstname": { "direction": [ "asc", "desc" ] }, + "birthday": { "direction": [ "desc" ] } + }, + "default": "lastname asc, firstname asc", + "description": "The OData-like ordering is awesome!" +} \ No newline at end of file diff --git a/tests/CoreEx.Data.Test.Unit/Resources/OrderByToString.txt b/tests/CoreEx.Data.Test.Unit/Resources/OrderByToString.txt new file mode 100644 index 00000000..eedf39bb --- /dev/null +++ b/tests/CoreEx.Data.Test.Unit/Resources/OrderByToString.txt @@ -0,0 +1,8 @@ +Order-by field(s) are as follows: +lastname (Direction: asc or desc) +firstname (Direction: asc or desc) +birthday (Direction: desc) +--- +Default: lastname asc, firstname asc +--- +Note: The OData-like ordering is awesome! \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/CoreEx.Database.SqlServer.Test.Console.csproj b/tests/CoreEx.Database.SqlServer.Test.Console/CoreEx.Database.SqlServer.Test.Console.csproj new file mode 100644 index 00000000..04d83e23 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Console/CoreEx.Database.SqlServer.Test.Console.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/Data/data.yaml b/tests/CoreEx.Database.SqlServer.Test.Console/Data/data.yaml new file mode 100644 index 00000000..3670a24b --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Console/Data/data.yaml @@ -0,0 +1,12 @@ +Test: +- Table: + - { TableId: 1 } + - { TableId: 2, Text: Abc, Number: 123, Amount: 45.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } + - { TableId: 3, Text: Ace, Number: 567, Amount: 45.67, Flag: true, Date: 2024-03-13, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } + - { TableId: 4, Text: Bdf, Number: 901, Amount: 45.67, Flag: false, Date: 2025-01-08, Time: 14:30:59, TenantId: B, IsDeleted: 1, Json: {"Key":"Value"} } + - { TableId: 5, Text: Qrs, Number: 765, Amount: 45.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: B, Json: {"Key":"Value"} } + - { TableId: 6, Text: Jkl, Number: 765, Amount: 56.24, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } + - { TableId: 7, Text: Blq, Number: 765, Amount: 45.67, Flag: false, Date: 2024-06-20, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } + - { TableId: 8, Text: Zyi, Number: 765, Amount: 99.23, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } + - { TableId: 9, Text: Abz, Number: 765, Amount: 45.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } + - { TableId: 10, Text: Qpo, Number: 881, Amount: 12.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, IsDeleted: 1, Json: {"Key":"Value"} } \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/001-create-test-schema.sql b/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/001-create-test-schema.sql new file mode 100644 index 00000000..20057278 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/001-create-test-schema.sql @@ -0,0 +1 @@ +CREATE SCHEMA [Test] \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/002-create-test-table.sql b/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/002-create-test-table.sql new file mode 100644 index 00000000..d773a458 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/002-create-test-table.sql @@ -0,0 +1,17 @@ +CREATE TABLE [Test].[Table] ( + [TableId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, + [Text] NVARCHAR (200) NULL, + [Number] INT NULL, + [Amount] MONEY NULL, + [Flag] BIT NULL, + [Date] DATE NULL, + [Time] TIME NULL, + [Json] NVARCHAR (500) NULL, + [TenantId] NVARCHAR(20) NULL, + [RowVersion] TIMESTAMP NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL, + [IsDeleted] BIT DEFAULT 0 NOT NULL +) \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/003-create-test-table-unique-index.sql b/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/003-create-test-table-unique-index.sql new file mode 100644 index 00000000..2aa9f3ce --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/003-create-test-table-unique-index.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX IX_Test_Table_TenantId_Text + ON [Test].[Table] (TenantId, Text); \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/Program.cs b/tests/CoreEx.Database.SqlServer.Test.Console/Program.cs new file mode 100644 index 00000000..1cc1d34d --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Console/Program.cs @@ -0,0 +1,27 @@ +using DbEx.Migration; +using DbEx.SqlServer.Console; + +namespace CoreEx.Database.SqlServer.Test.Console; + +/// +/// Represents the database utilities program (capability). +/// +public class Program +{ + /// + /// Main startup. + /// + /// The startup arguments. + /// The status code whereby zero indicates success. + public static Task Main(string[] args) => SqlServerMigrationConsole + .Create("Data Source=127.0.0.1,1433;Initial Catalog=CoreEx.Test;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true") + .Configure(c => ConfigureMigrationArgs(c.Args)) + .RunAsync(args); + + /// + /// Configure the . + /// + /// 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.Console/Properties/launchSettings.json b/tests/CoreEx.Database.SqlServer.Test.Console/Properties/launchSettings.json new file mode 100644 index 00000000..5e8e1231 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Console/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "CoreEx.Database.SqlServer.Test.Console": { + "commandName": "Project", + "commandLineArgs": "dropandall" + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/database.beef-5.yaml b/tests/CoreEx.Database.SqlServer.Test.Console/database.beef-5.yaml new file mode 100644 index 00000000..e69de29b diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDto.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDto.cs new file mode 100644 index 00000000..a2be3e89 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDto.cs @@ -0,0 +1,21 @@ +using CoreEx.Entities; + +namespace CoreEx.Database.SqlServer.Test.Unit.Contracts; + +public class TestTableDto : IIdentifier, IETag, IChangeLog +{ + public Guid Id { get; set; } + public string? Text { get; set; } + public int? Number { get; set; } + public decimal? Amount { get; set; } + public DateOnly? Date { get; set; } + public TimeOnly? Time { get; set; } + public KeyValueDto? Key { get; set; } + public string? ETag { get; set; } + public ChangeLog? ChangeLog { get; set; } + + public class KeyValueDto + { + public string? Key { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDtoMapper.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDtoMapper.cs new file mode 100644 index 00000000..231ac0f3 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDtoMapper.cs @@ -0,0 +1,32 @@ +using CoreEx.Database.SqlServer.Test.Unit.Models; +using CoreEx.Json; +using CoreEx.Mapping; +using System.Text.Json; + +namespace CoreEx.Database.SqlServer.Test.Unit.Contracts; + +public class TestTableDtoMapper : BiDirectionMapper +{ + protected override TestTable OnMap(TestTableDto source) => new TestTable() + { + Id = source.Id, + Text = source.Text, + Number = source.Number, + Amount = source.Amount, + Date = source.Date, + Time = source.Time, + Json = source.Key is null ? null : JsonSerializer.SerializeToElement(source.Key, JsonDefaults.SerializerOptions), + Flag = true + }.MapStandardFrom(source); + + protected override TestTableDto OnMap(TestTable source) => new TestTableDto() + { + Id = source.Id, + Text = source.Text, + Number = source.Number, + Amount = source.Amount, + Date = source.Date, + Time = source.Time, + Key = source.Json?.Deserialize(JsonDefaults.SerializerOptions) + }.MapStandardFrom(source); +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/CoreEx.Database.SqlServer.Test.Unit.csproj b/tests/CoreEx.Database.SqlServer.Test.Unit/CoreEx.Database.SqlServer.Test.Unit.csproj new file mode 100644 index 00000000..9b0b7e4f --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/CoreEx.Database.SqlServer.Test.Unit.csproj @@ -0,0 +1,50 @@ + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTestBase.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTestBase.cs new file mode 100644 index 00000000..30339908 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTestBase.cs @@ -0,0 +1,25 @@ +using CoreEx.Database.SqlServer.Test.Console; +using DbEx; +using DbEx.Migration; +using DbEx.SqlServer.Migration; +using Microsoft.Extensions.Configuration; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public abstract class DatabaseTestBase : WithGenericTester +{ + [OneTimeSetUp()] + public async Task OneTimeSetUp() + { + var cs = Test.Configuration.GetValue("Aspire:Microsoft:Data:SqlClient:ConnectionString"); + var ma = Program.ConfigureMigrationArgs(new MigrationArgs(MigrationCommand.DropAndAll, cs)).AddAssembly(); + + using var m = new SqlServerMigration(ma); + var (Success, Output) = await m.MigrateAndLogAsync().ConfigureAwait(false); + + TestContext.Progress.WriteLine(Output); + + if (!Success) + Assert.Fail("SqlServerMigration failed."); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTests.cs new file mode 100644 index 00000000..f82be310 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTests.cs @@ -0,0 +1,191 @@ +using CoreEx.Database.SqlServer.Test.Unit.Models; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public class DatabaseTests : DatabaseTestBase +{ + [Test] + public void SelectAndGetNullValues() => Test.ScopedType(test => + { + test.Run(async db => + { + var tt = await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 1.ToGuid()).SelectSingleAsync(new TestTableMapper()).ConfigureAwait(false); + + tt.Should().NotBeNull(); + tt.Id.Should().Be(1.ToGuid()); + tt.Text.Should().BeNull(); + tt.Number.Should().BeNull(); + tt.Amount.Should().BeNull(); + tt.Flag.Should().BeNull(); + tt.Date.Should().BeNull(); + tt.Time.Should().BeNull(); + tt.Json.Should().BeNull(); + tt.ETag.Should().NotBeNull(); + tt.CreatedBy.Should().NotBeNull(); + tt.CreatedOn.Should().NotBeNull(); + tt.UpdatedBy.Should().BeNull(); + tt.UpdatedOn.Should().BeNull(); + }).AssertSuccess(); + }); + + [Test] + public void SelectAndGetValues() => Test.ScopedType(test => + { + test.Run(async db => + { + var tt = await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 2.ToGuid()).SelectSingleAsync(new TestTableMapper()).ConfigureAwait(false); + + tt.Should().NotBeNull(); + tt.Id.Should().Be(2.ToGuid()); + tt.Text.Should().Be("Abc"); + tt.Number.Should().Be(123); + tt.Amount.Should().Be(45.67m); + tt.Flag.Should().BeTrue(); + tt.Date.Should().Be(new DateOnly(2024, 6, 20)); + tt.Time.Should().Be(new TimeOnly(14, 30, 59)); + tt.ETag.Should().NotBeNull(); + tt.Json.Should().NotBeNull().And.Subject.ToString().Should().Be("{\"Key\": \"Value\"}"); + tt.CreatedBy.Should().NotBeNull(); + tt.CreatedOn.Should().NotBeNull(); + tt.UpdatedBy.Should().BeNull(); + tt.UpdatedOn.Should().BeNull(); + }).AssertSuccess(); + }); + + [Test] + public void SelectSingleAsync() => Test.ScopedType(test => + { + test.Run(async db => + { + // Exactly one row. + var tt = await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 2.ToGuid()).SelectSingleAsync(new TestTableMapper()).ConfigureAwait(false); + tt.Should().NotBeNull(); + + // More than one row. + var act = () => db.Statement("SELECT * FROM [Test].[Table]").SelectSingleAsync(new TestTableMapper()); + await act.Should().ThrowAsync().WithMessage("SelectSingleAsync has returned more than one row."); + + // No rows. + var act2 = () => db.Statement("SELECT * FROM [Test].[Table ]WHERE [TableId] = @Id").Param("Id", 404.ToGuid()).SelectSingleAsync(new TestTableMapper()); + await act2.Should().ThrowAsync().WithMessage("SelectSingleAsync has not returned a row."); + + }).AssertSuccess(); + }); + + [Test] + public void SelectSingleOrDefaultAsync() => Test.ScopedType(test => + { + test.Run(async db => + { + // Exactly one row. + var tt = await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 2.ToGuid()).SelectSingleOrDefaultAsync(new TestTableMapper()).ConfigureAwait(false); + tt.Should().NotBeNull(); + + // More than one row. + var act = () => db.Statement("SELECT * FROM [Test].[Table]").SelectSingleOrDefaultAsync(new TestTableMapper()); + await act.Should().ThrowAsync().WithMessage("SelectSingleOrDefaultAsync has returned more than one row."); + + // No rows. + var tt2 = await db.Statement("SELECT * FROM [Test].[Table ]WHERE [TableId] = @Id").Param("Id", 404.ToGuid()).SelectSingleOrDefaultAsync(new TestTableMapper()).ConfigureAwait(false); + tt2.Should().BeNull(); + }).AssertSuccess(); + }); + + [Test] + public void SelectFirstAsync() => Test.ScopedType(test => + { + test.Run(async db => + { + // Exactly one row. + var tt = await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 2.ToGuid()).SelectFirstAsync(new TestTableMapper()).ConfigureAwait(false); + tt.Should().NotBeNull(); + + // More than one row. + var tt2 = await db.Statement("SELECT * FROM [Test].[Table]").SelectFirstAsync(new TestTableMapper()).ConfigureAwait(false); + tt2.Should().NotBeNull(); + + // No rows. + var act = () => db.Statement("SELECT * FROM [Test].[Table ]WHERE [TableId] = @Id").Param("Id", 404.ToGuid()).SelectFirstAsync(new TestTableMapper()); + await act.Should().ThrowAsync().WithMessage("SelectFirstAsync has not returned a row."); + }).AssertSuccess(); + }); + + [Test] + public void SelectFirstOrDefaultAsync() => Test.ScopedType(test => + { + test.Run(async db => + { + // Exactly one row. + var tt = await db.Statement("SELECT * FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 2.ToGuid()).SelectFirstOrDefaultAsync(new TestTableMapper()).ConfigureAwait(false); + tt.Should().NotBeNull(); + + // More than one row. + var tt2 = await db.Statement("SELECT * FROM [Test].[Table]").SelectFirstOrDefaultAsync(new TestTableMapper()).ConfigureAwait(false); + tt2.Should().NotBeNull(); + + // No rows. + var tt3 = await db.Statement("SELECT * FROM [Test].[Table ]WHERE [TableId] = @Id").Param("Id", 404.ToGuid()).SelectFirstOrDefaultAsync(new TestTableMapper()).ConfigureAwait(false); + tt3.Should().BeNull(); + }).AssertSuccess(); + }); + + [Test] + public void SelectQueryAsync() => Test.ScopedType(test => + { + test.Run(async db => + { + var tts = await db.Statement("SELECT * FROM [Test].[Table]").SelectQueryAsync(new TestTableMapper()).ConfigureAwait(false); + tts.Should().NotBeNull().And.HaveCount(10); + + var ttc = new List(); + await db.Statement("SELECT * FROM [Test].[Table]").SelectQueryAsync(ttc, new TestTableMapper()).ConfigureAwait(false); + ttc.Should().NotBeNull().And.HaveCount(10); + }).AssertSuccess(); + }); + + [Test] + public void SelectAsync() => Test.ScopedType(test => + { + test.Run(async db => + { + var count = 0; + await db.Statement("SELECT * FROM [Test].[Table]").SelectAsync(r => + { + count++; + return count < 3; + }).ConfigureAwait(false); + + count.Should().Be(3); + }).AssertSuccess(); + }); + + [Test] + public void ScalarAsync() => Test.ScopedType(test => + { + test.Run(async db => + { + var count = await db.Statement("SELECT COUNT(*) FROM [Test].[Table]").ScalarAsync().ConfigureAwait(false); + count.Should().Be(10); + + var created = await db.Statement("SELECT TOP 1 [CreatedOn] FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 1.ToGuid()).ScalarAsync().ConfigureAwait(false); + created.Should().NotBeNull(); + + created = await db.Statement("SELECT TOP 1 [CreatedOn] FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 404.ToGuid()).ScalarAsync().ConfigureAwait(false); + created.Should().BeNull(); + + }).AssertSuccess(); + }); + + [Test] + public void NonQueryAsync() => Test.ScopedType(test => + { + test.Run(async db => + { + var rows = await db.Statement("UPDATE [Test].[Table] SET [Number] = @Number + 1 WHERE [TableId] = @Id").Param("Id", 4.ToGuid()).Param("Number", 88).NonQueryAsync().ConfigureAwait(false); + rows.Should().Be(1); + + var number = await db.Statement("SELECT [Number] FROM [Test].[Table] WHERE [TableId] = @Id").Param("Id", 4.ToGuid()).ScalarAsync().ConfigureAwait(false); + number.Should().Be(89); + }).AssertSuccess(); + }); +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Create.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Create.cs new file mode 100644 index 00000000..71b663da --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Create.cs @@ -0,0 +1,146 @@ +using CoreEx.Database.SqlServer.Test.Unit.Contracts; +using CoreEx.Database.SqlServer.Test.Unit.Models; +using CoreEx.Database.SqlServer.Test.Unit.Repository; +using CoreEx.Results; +using System.Text.Json; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public partial class EntityFrameworkCrudTests : DatabaseTestBase +{ + [Test] + public void Create_IsDeleted() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var m = new TestTable + { + IsDeleted = true + }; + + var act = async () => await ef.Table.CreateWithResultAsync(m).ConfigureAwait(false); + await act.Should().ThrowAsync().WithMessage("*Cannot create a model with a deleted state; IsDeleted must be false.*"); + }).AssertSuccess()); + + [Test] + public void Create_NotAuthorized() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var m = new TestTable + { + Flag = false, + }; + + var r = await ef.Table.CreateWithResultAsync(m).ConfigureAwait(false); + r.IsAuthorizationError.Should().BeTrue(); + }).AssertSuccess()); + + [Test] + public void Create_Duplicate_Id() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var m = new TestTable + { + Id = 2.ToGuid(), + Flag = true + }; + + var r = await ef.Table.CreateWithResultAsync(m).ConfigureAwait(false); + r.IsDuplicateError.Should().BeTrue(); + }).AssertSuccess()); + + [Test] + public void Create_Duplicate_Text() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var m = new TestTable + { + Id = Runtime.NewGuid(), + Flag = true, + Text = "Abc" + }; + + var r = await ef.Table.CreateWithResultAsync(m).ConfigureAwait(false); + r.IsDuplicateError.Should().BeTrue(); + }).AssertSuccess()); + + [Test] + public void Create_Success() => Test.ScopedType(test => test.Run(async _ => + { + using var jd = JsonDocument.Parse("{\"Key\": \"Value\"}"); + var id = Runtime.NewGuid(); + + var ef = ExecutionContext.GetRequiredService(); + var m = new TestTable + { + Id = id, + Text = "New", + Number = 999, + Amount = 12.34m, + Flag = true, + Date = new DateOnly(2024, 7, 1), + Time = new TimeOnly(10, 20, 30), + Json = jd.RootElement.Clone() + }; + + var created = await ef.Table.CreateAsync(m).ConfigureAwait(false); + + created.Should().NotBeNull(); + created.Value.Id.Should().Be(id); + created.Value.Text.Should().Be("New"); + created.Value.Number.Should().Be(999); + created.Value.Amount.Should().Be(12.34m); + created.Value.Flag.Should().BeTrue(); + created.Value.Date.Should().Be(new DateOnly(2024, 7, 1)); + created.Value.Time.Should().Be(new TimeOnly(10, 20, 30)); + created.Value.Json.Should().NotBeNull().And.Subject.ToString().Should().Be("{\"Key\": \"Value\"}"); + created.Value.ETag.Should().NotBeNull(); + created.Value.TenantId.Should().Be("A"); + created.Value.CreatedBy.Should().NotBeNull(); + created.Value.CreatedOn.Should().NotBeNull(); + created.Value.UpdatedBy.Should().BeNull(); + created.Value.UpdatedOn.Should().BeNull(); + + // Verify can be retrieved and is same. + m = await ef.Table.GetAsync(id).ConfigureAwait(false); + ObjectComparer.Assert(created.Value, m); + }).AssertSuccess()); + + [Test] + public void Create_Mapped_Success() => Test.ScopedType(test => test.Run(async _ => + { + var id = Runtime.NewGuid(); + var ef = ExecutionContext.GetRequiredService(); + var v = new TestTableDto + { + Id = id, + Text = "Val", + Number = 999, + Amount = 12.34m, + Date = new DateOnly(2024, 7, 1), + Time = new TimeOnly(10, 20, 30), + Key = new TestTableDto.KeyValueDto { Key = "Value" } + }; + + var created = await ef.TableDto.CreateAsync(v).ConfigureAwait(false); + + created.Should().NotBeNull(); + created.Value.Id.Should().Be(id); + created.Value.Text.Should().Be("Val"); + created.Value.Number.Should().Be(999); + created.Value.Amount.Should().Be(12.34m); + created.Value.Date.Should().Be(new DateOnly(2024, 7, 1)); + created.Value.Time.Should().Be(new TimeOnly(10, 20, 30)); + created.Value.Key.Should().NotBeNull(); + created.Value.Key.Key.Should().Be("Value"); + created.Value.ETag.Should().NotBeNullOrEmpty(); + created.Value.ChangeLog.Should().NotBeNull(); + created.Value.ChangeLog.CreatedBy.Should().NotBeNull(); + created.Value.ChangeLog.CreatedOn.Should().NotBeNull(); + created.Value.ChangeLog.UpdatedBy.Should().BeNull(); + created.Value.ChangeLog.UpdatedOn.Should().BeNull(); + + // Verify can be retrieved and is same. + v = await ef.TableDto.GetAsync(id).ConfigureAwait(false); + ObjectComparer.Assert(created.Value, v); + }).AssertSuccess()); +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Get.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Get.cs new file mode 100644 index 00000000..607bf8f7 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Get.cs @@ -0,0 +1,82 @@ +using CoreEx.Database.SqlServer.Test.Unit.Repository; +using CoreEx.Results; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public partial class EntityFrameworkCrudTests : DatabaseTestBase +{ + [Test] + public void Get_NotFound() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var tt = await ef.Table.GetAsync(404.ToGuid()).ConfigureAwait(false); + tt.Should().BeNull(); + }).AssertSuccess()); + + [Test] + public void Get_NotFound_WrongTenant() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var tt = await ef.Table.GetAsync(5.ToGuid()).ConfigureAwait(false); + tt.Should().BeNull(); + }).AssertSuccess()); + + [Test] + public void Get_NotFound_IsDeleted() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var tt = await ef.Table.GetAsync(4.ToGuid()).ConfigureAwait(false); + tt.Should().BeNull(); + }).AssertSuccess()); + + [Test] + public void Get_NotAuthozied() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var r = await ef.Table.GetWithResultAsync(7.ToGuid()).ConfigureAwait(false); + r.IsAuthorizationError.Should().BeTrue(); + }).AssertSuccess()); + + [Test] + public void Get_Found() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + + var tt = await ef.Table.GetAsync(2.ToGuid()).ConfigureAwait(false); + tt.Should().NotBeNull(); + tt.Id.Should().Be(2.ToGuid()); + tt.Text.Should().Be("Abc"); + tt.Number.Should().Be(123); + tt.Amount.Should().Be(45.67m); + tt.Flag.Should().BeTrue(); + tt.Date.Should().Be(new DateOnly(2024, 6, 20)); + tt.Time.Should().Be(new TimeOnly(14, 30, 59)); + tt.Json.Should().NotBeNull().And.Subject.ToString().Should().Be("{\"Key\": \"Value\"}"); + tt.CreatedBy.Should().NotBeNull(); + tt.CreatedOn.Should().NotBeNull(); + tt.UpdatedBy.Should().BeNull(); + tt.UpdatedOn.Should().BeNull(); + }).AssertSuccess()); + + [Test] + public void Get_Mapped_Found() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + + var dto = await ef.TableDto.GetAsync(2.ToGuid()).ConfigureAwait(false); + dto.Should().NotBeNull(); + dto.Id.Should().Be(2.ToGuid()); + dto.Text.Should().Be("Abc"); + dto.Number.Should().Be(123); + dto.Amount.Should().Be(45.67m); + dto.Date.Should().Be(new DateOnly(2024, 6, 20)); + dto.Time.Should().Be(new TimeOnly(14, 30, 59)); + dto.Key.Should().NotBeNull(); + dto.Key.Key.Should().Be("Value"); dto.ETag.Should().NotBeNullOrEmpty(); + dto.ChangeLog.Should().NotBeNull(); + dto.ChangeLog.CreatedBy.Should().NotBeNull(); + dto.ChangeLog.CreatedOn.Should().NotBeNull(); + dto.ChangeLog.UpdatedBy.Should().BeNull(); + dto.ChangeLog.UpdatedOn.Should().BeNull(); + }).AssertSuccess()); +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.cs new file mode 100644 index 00000000..f9cf5f74 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.cs @@ -0,0 +1,5 @@ +namespace CoreEx.Database.SqlServer.Test.Unit; + +public partial class EntityFrameworkCrudTests : DatabaseTestBase +{ +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTestsDelete.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTestsDelete.cs new file mode 100644 index 00000000..671bb166 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTestsDelete.cs @@ -0,0 +1,61 @@ +using CoreEx.Database.SqlServer.Test.Unit.Models; +using CoreEx.Database.SqlServer.Test.Unit.Repository; +using CoreEx.Results; +using CoreEx.EntityFrameworkCore; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public partial class EntityFrameworkCrudTests : DatabaseTestBase +{ + [Test] + public void Delete_IsDeleted() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var r = await ef.Table.DeleteAsync(10.ToGuid()).ConfigureAwait(false); + r.WasMutated.Should().BeFalse(); + }).AssertSuccess()); + + [Test] + public void Delete_NotFound() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var r = await ef.Table.DeleteAsync(404.ToGuid()).ConfigureAwait(false); + r.WasMutated.Should().BeFalse(); + }).AssertSuccess()); + + [Test] + public void Delete_NotAuthorized() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var r = await ef.Table.DeleteWithResultAsync(7.ToGuid()).ConfigureAwait(false); + r.IsAuthorizationError.Should().BeTrue(); + }).AssertSuccess()); + + [Test] + public void Delete_Mapped_Success() => Test.ScopedType(test => test.Run(async _ => + { + // NOTE: The TableDo is a proxy over the Table, so no need to test independently. + + var id = 9.ToGuid(); + var ef = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + + // Pre-get existing before modification. + var m = await ef.TableDto.GetAsync(id).ConfigureAwait(false); + m.Should().NotBeNull(); + + // Delete it. + var r = await ef.TableDto.DeleteAsync(id).ConfigureAwait(false); + r.WasMutated.Should().BeTrue(); + + // Delete it again. + dc.ChangeTracker.Clear(); + r = await ef.TableDto.DeleteAsync(id).ConfigureAwait(false); + r.WasMutated.Should().BeFalse(); + + // Re-get to ensure Deleted. + dc.ChangeTracker.Clear(); + m = await ef.TableDto.GetAsync(id).ConfigureAwait(false); + m.Should().BeNull(); + }).AssertSuccess()); +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTestsUpdate.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTestsUpdate.cs new file mode 100644 index 00000000..3e2f9012 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTestsUpdate.cs @@ -0,0 +1,157 @@ +using CoreEx.Database.SqlServer.Test.Unit.Models; +using CoreEx.Database.SqlServer.Test.Unit.Repository; +using CoreEx.Results; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public partial class EntityFrameworkCrudTests : DatabaseTestBase +{ + [Test] + public void Update_IsDeleted() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var m = new TestTable + { + Id = 1.ToGuid(), + IsDeleted = true + }; + + var act = async () => await ef.Table.UpdateWithResultAsync(m).ConfigureAwait(false); + await act.Should().ThrowAsync().WithMessage("*Cannot update a model and set to the deleted state (IsDeleted must be false); use the delete operation to perform.*"); + }).AssertSuccess()); + + [Test] + public void Update_NotFound() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var m = new TestTable + { + Id = 404.ToGuid() + }; + + var r = await ef.Table.UpdateWithResultAsync(m).ConfigureAwait(false); + r.IsNotFoundError.Should().BeTrue(); + }).AssertSuccess()); + + [Test] + public void Update_Concurrency() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var m = new TestTable + { + Id = 8.ToGuid(), + ETag = "InvalidETag" + }; + + var r = await ef.Table.UpdateWithResultAsync(m).ConfigureAwait(false); + r.IsConcurrencyError.Should().BeTrue(); + }).AssertSuccess()); + + [Test] + public void Update_Duplicate_Text() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + + // Pre-get existing before modification. + var m = await ef.Table.GetAsync(8.ToGuid()).ConfigureAwait(false); + m.Should().NotBeNull(); + + // Modify a pre-existing value. + m.Text = "Abc"; + + // Update it. + var act = async () => await ef.Table.UpdateAsync(m).ConfigureAwait(false); + await act.Should().ThrowAsync(); + }).AssertSuccess()); + + [Test] + public void Update_Success_Attached() => Test.ScopedType(test => test.Run(async _ => + { + var id = 8.ToGuid(); + var ef = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + + // Pre-get existing before modification. + var m = await ef.Table.GetAsync(id).ConfigureAwait(false); + m.Should().NotBeNull(); + + // Modify a property. + m.Text += "XXX"; + + // Update it. + var u = await ef.Table.UpdateAsync(m).ConfigureAwait(false); + u.WasMutated.Should().BeTrue(); + u.Value.Text.Should().Be(m.Text); + u.Value.UpdatedBy.Should().NotBeNull(); + u.Value.UpdatedOn.Should().NotBeNull(); + + // Re-get to ensure updated. + dc.ChangeTracker.Clear(); + var r = await ef.Table.GetAsync(id).ConfigureAwait(false); + r.Should().NotBeNull(); + r.Text.Should().Be(m.Text); + + ObjectComparer.Assert(u.Value, r); + }).AssertSuccess()); + + [Test] + public void Update_Success_Detached() => Test.ScopedType(test => test.Run(async _ => + { + var id = 8.ToGuid(); + var ef = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + + // Pre-get existing before modification. + var m = await ef.Table.GetAsync(id).ConfigureAwait(false); + m.Should().NotBeNull(); + dc.ChangeTracker.Clear(); + + //Modify a property. + m.Text += "YYY"; + + // Update it. + var u = await ef.Table.UpdateAsync(m).ConfigureAwait(false); + u.WasMutated.Should().BeTrue(); + u.Value.Text.Should().Be(m.Text); + u.Value.UpdatedBy.Should().NotBeNull(); + u.Value.UpdatedOn.Should().NotBeNull(); + + // Re-get to ensure updated. + dc.ChangeTracker.Clear(); + var r = await ef.Table.GetAsync(id).ConfigureAwait(false); + r.Should().NotBeNull(); + r.Text.Should().Be(m.Text); + + ObjectComparer.Assert(u.Value, r); + }).AssertSuccess()); + + [Test] + public void Update_Mapped_Success() => Test.ScopedType(test => test.Run(async _ => + { + var id = 8.ToGuid(); + var ef = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + + // Pre-get existing before modification. + var v = await ef.TableDto.GetAsync(id).ConfigureAwait(false); + v.Should().NotBeNull(); + + // Modify a value. + v.Text += "ZZZ"; + + // Update it. + var u = await ef.TableDto.UpdateAsync(v).ConfigureAwait(false); + u.Value.Text.Should().Be(v.Text); + u.Value.ChangeLog.Should().NotBeNull(); + u.Value.ChangeLog.UpdatedBy.Should().NotBeNull(); + u.Value.ChangeLog.UpdatedOn.Should().NotBeNull(); + + // Re-get to ensure updated. + dc.ChangeTracker.Clear(); + var r = await ef.TableDto.GetAsync(id).ConfigureAwait(false); + r.Should().NotBeNull(); + r.Text.Should().Be(v.Text); + + ObjectComparer.Assert(u.Value, r); + }).AssertSuccess()); +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkQueryTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkQueryTests.cs new file mode 100644 index 00000000..84b0afb6 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkQueryTests.cs @@ -0,0 +1,165 @@ +using CoreEx.Data; +using CoreEx.Data.Querying; +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 Microsoft.EntityFrameworkCore; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public class EntityFrameworkQueryTests : DatabaseTestBase +{ + [Test] + public void Query_ByPassFilters() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query(); + var c = await q.CountAsync().ConfigureAwait(false); + c.Should().Be(5); // Excludes deleted, not-authorized and wrong-tenant. + + q = ef.Table.Query(new EntityFrameworkCore.EfDbArgs { BypassFilters = true }); + c = await q.CountAsync().ConfigureAwait(false); + c.Should().Be(7); // Excludes wrong-tenant (configured as not bypassable). + }).AssertSuccess()); + + [Test] + public void Query_ToItemsResult() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query().OrderBy(x => x.Text); + + var ir = await q.ToItemsResultAsync().ConfigureAwait(false); + ir.Should().NotBeNull(); + ir.Items.Should().NotBeNull(); + ir.Items.Count().Should().Be(5); + ir.Paging.Should().NotBeNull(); + ir.Paging.Skip.Should().Be(0); + ir.Paging.Take.Should().Be(PagingArgs.DefaultTake); + ir.Paging.TotalCount.Should().BeNull(); + ir.Items.Select(x => x.Text).Should().BeEquivalentTo(["Abc", "Abz", "Ace", "Jkl", "Zyi"], options => options.WithStrictOrdering()); + + }).AssertSuccess()); + + [Test] + public void Query_ToItemsResult_WithPaging() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query().OrderBy(x => x.Text); + + var ir = await q.ToItemsResultAsync(new PagingArgs(2, 2)).ConfigureAwait(false); + ir.Should().NotBeNull(); + ir.Items.Should().NotBeNull(); + ir.Items.Count().Should().Be(2); + ir.Paging.Should().NotBeNull(); + ir.Paging.Skip.Should().Be(2); + ir.Paging.Take.Should().Be(2); + ir.Paging.TotalCount.Should().BeNull(); + ir.Items.Select(x => x.Text).Should().BeEquivalentTo(["Ace", "Jkl"], options => options.WithStrictOrdering()); + + }).AssertSuccess()); + + [Test] + public void Query_ToItemsResult_WithPaging_Count() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query().OrderBy(x => x.Text); + + var ir = await q.ToItemsResultAsync(new PagingArgs(2, 2, true)).ConfigureAwait(false); + ir.Should().NotBeNull(); + ir.Items.Should().NotBeNull(); + ir.Items.Count().Should().Be(2); + ir.Paging.Should().NotBeNull(); + ir.Paging.Skip.Should().Be(2); + ir.Paging.Take.Should().Be(2); + ir.Paging.TotalCount.Should().Be(5); + ir.Items.Select(x => x.Text).Should().BeEquivalentTo(["Ace", "Jkl"], options => options.WithStrictOrdering()); + }).AssertSuccess()); + + [Test] + public void Query_ToMappedItemsResult() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query().OrderBy(x => x.Text); + + var ir = await q.ToMappedItemsResultAsync(TestTableDtoMapper.From).ConfigureAwait(false); + ir.Should().NotBeNull(); + ir.Items.Should().NotBeNull(); + ir.Items.Count().Should().Be(5); + ir.Paging.Should().NotBeNull(); + ir.Paging.Skip.Should().Be(0); + ir.Paging.Take.Should().Be(PagingArgs.DefaultTake); + ir.Paging.TotalCount.Should().BeNull(); + ir.Items.Select(x => x.Text).Should().BeEquivalentTo(["Abc", "Abz", "Ace", "Jkl", "Zyi"], options => options.WithStrictOrdering()); + + // Mapping worked? + var item = ir.Items.First(); + item.Id.Should().Be(2.ToGuid()); + item.Text.Should().Be("Abc"); + item.Number.Should().Be(123); + item.Amount.Should().Be(45.67m); + item.Date.Should().Be(new DateOnly(2024, 06, 20)); + item.Time.Should().Be(new TimeOnly(14, 30, 59)); + item.Key.Should().NotBeNull(); + item.Key.Key.Should().Be("Value"); + item.ETag.Should().NotBeNullOrEmpty(); + item.ChangeLog.Should().NotBeNull(); + item.ChangeLog.CreatedBy.Should().NotBeNull(); + item.ChangeLog.CreatedOn.Should().NotBeNull(); + item.ChangeLog.UpdatedBy.Should().BeNull(); + item.ChangeLog.UpdatedOn.Should().BeNull(); + }).AssertSuccess()); + + [Test] + public void Query_ToMappedtemsResult_WithPaging() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query().OrderBy(x => x.Text); + + var ir = await q.ToMappedItemsResultAsync(TestTableDtoMapper.From, new PagingArgs(2, 2)).ConfigureAwait(false); + ir.Should().NotBeNull(); + ir.Items.Should().NotBeNull(); + ir.Items.Count().Should().Be(2); + ir.Paging.Should().NotBeNull(); + ir.Paging.Skip.Should().Be(2); + ir.Paging.Take.Should().Be(2); + ir.Paging.TotalCount.Should().BeNull(); + ir.Items.Select(x => x.Text).Should().BeEquivalentTo(["Ace", "Jkl"], options => options.WithStrictOrdering()); + }).AssertSuccess()); + + [Test] + public void Query_ToMappedItemsResult_WithPaging_Count() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var q = ef.Table.Query().OrderBy(x => x.Text); + + var ir = await q.ToMappedItemsResultAsync(TestTableDtoMapper.From, new PagingArgs(1, 3, true)).ConfigureAwait(false); + ir.Should().NotBeNull(); + ir.Items.Should().NotBeNull(); + ir.Items.Count().Should().Be(3); + ir.Paging.Should().NotBeNull(); + ir.Paging.Skip.Should().Be(1); + ir.Paging.Take.Should().Be(3); + ir.Paging.TotalCount.Should().Be(5); + ir.Items.Select(x => x.Text).Should().BeEquivalentTo(["Abz", "Ace", "Jkl"], options => options.WithStrictOrdering()); + }).AssertSuccess()); + + [Test] + public void Query_Dynamic_Filter() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var qa = new QueryArgs { Filter = "startswith(text, 'a')" }; + var q = ef.Table.Query().Where(_queryArgsConfig, qa).OrderBy(_queryArgsConfig, qa); + var ir = await q.ToItemsResultAsync(PagingArgs.None).ConfigureAwait(false); + + ir.Should().NotBeNull(); + ir.Items.Should().NotBeNull(); + ir.Items.Count().Should().Be(3); + ir.Items.Select(x => x.Text).Should().BeEquivalentTo(["Abc", "Abz", "Ace"], options => options.WithStrictOrdering()); + ir.Paging.Should().BeNull(); + }).AssertSuccess()); + + private static readonly QueryArgsConfig _queryArgsConfig = QueryArgsConfig.Create() + .WithFilter(f => f.AddField(nameof(TestTable.Text), c => c.AsLowerCase().WithOperators(QueryFilterOperator.AllStringOperators))) + .WithOrderBy(o => o.AddField(nameof(TestTable.Text), c => c.WithDefault())); +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkUowTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkUowTests.cs new file mode 100644 index 00000000..dc09e532 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkUowTests.cs @@ -0,0 +1,99 @@ +using CoreEx.Data; +using CoreEx.Database.SqlServer.Test.Unit.Repository; +using CoreEx.Results; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public class EntityFrameworkUowTests : DatabaseTestBase +{ + [Test] + public void Rollback_On_Exception() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var uow = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + + var act = async () => + { + await uow.ExecuteAsync(async () => + { + var r = await ef.Table.DeleteAsync(2.ToGuid()).ConfigureAwait(false); + r.WasMutated.Should().BeTrue(); // I.e. was deleted successfully! + + throw new InvalidOperationException("Stop!"); + }).ConfigureAwait(false); + }; + + await act.Should().ThrowAsync().WithMessage("Stop!").ConfigureAwait(false); + + // Confirm the record is still there! + dc.ChangeTracker.Clear(); + var m = await ef.Table.GetAsync(2.ToGuid()).ConfigureAwait(false); + m.Should().NotBeNull(); + + }).AssertSuccess()); + + [Test] + public void Rollback_On_Error() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var uow = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + + var r = await uow.ExecuteAsync(async () => + { + var r = await ef.Table.DeleteAsync(2.ToGuid()).ConfigureAwait(false); + r.WasMutated.Should().BeTrue(); // I.e. was deleted successfully! + + return Result.ConflictError("Stop!"); + }).ConfigureAwait(false); + + r.IsConflictError.Should().BeTrue(); + r.Error.Message.Should().Be("Stop!"); + + // Confirm the record is still there! + dc.ChangeTracker.Clear(); + var m = await ef.Table.GetAsync(2.ToGuid()).ConfigureAwait(false); + m.Should().NotBeNull(); + + }).AssertSuccess()); + + [Test] + public void Commit_Success() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var uow = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + + await uow.ExecuteAsync(async () => + { + var r = await ef.Table.DeleteAsync(8.ToGuid()).ConfigureAwait(false); + r.WasMutated.Should().BeTrue(); // I.e. was deleted successfully! + }).ConfigureAwait(false); + + // Confirm the record is gone! + dc.ChangeTracker.Clear(); + var m = await ef.Table.GetAsync(8.ToGuid()).ConfigureAwait(false); + m.Should().BeNull(); + }).AssertSuccess()); + + [Test] + public void Commit_Success_Result() => Test.ScopedType(test => test.Run(async _ => + { + var ef = ExecutionContext.GetRequiredService(); + var uow = ExecutionContext.GetRequiredService(); + var dc = ExecutionContext.GetRequiredService(); + + await uow.ExecuteAsync(async () => + { + var r = await ef.Table.DeleteAsync(9.ToGuid()).ConfigureAwait(false); + r.WasMutated.Should().BeTrue(); // I.e. was deleted successfully! + return Result.Success; + }).ConfigureAwait(false); + + // Confirm the record is gone! + dc.ChangeTracker.Clear(); + var m = await ef.Table.GetAsync(9.ToGuid()).ConfigureAwait(false); + m.Should().BeNull(); + }).AssertSuccess()); +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntryPoint.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntryPoint.cs new file mode 100644 index 00000000..bcd4cb0d --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntryPoint.cs @@ -0,0 +1,20 @@ +using CoreEx.Database.SqlServer.Test.Unit.Repository; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace CoreEx.Database.SqlServer.Test.Unit; + +public class EntryPoint +{ + public static void ConfigureApplication(IHostApplicationBuilder builder) + { + builder.Services.AddExecutionContext(_ => new ExecutionContext { TenantId = "A" }); + + builder.AddSqlServerClient("SqlServer"); + builder.Services.AddSqlServerDatabase(); + builder.Services.AddSqlServerUnitOfWork(); + + builder.Services.AddDbContext(); + builder.Services.AddEfDb(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTable.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTable.cs new file mode 100644 index 00000000..d5e22f18 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTable.cs @@ -0,0 +1,24 @@ +using CoreEx.Data; +using CoreEx.Entities; +using System.Text.Json; + +namespace CoreEx.Database.SqlServer.Test.Unit.Models; + +public class TestTable : IIdentifier, IETag, IChangeLogEx, ITenantId, ILogicallyDeleted +{ + public Guid Id { get; set; } + public string? Text { get; set; } + public int? Number { get; set; } + public decimal? Amount { get; set; } + public bool? Flag { get; set; } + public DateOnly? Date { get; set; } + public TimeOnly? Time { get; set; } + public JsonElement? Json { get; set; } + public string? ETag { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? CreatedOn { get; set; } + public string? UpdatedBy { get; set; } + public DateTimeOffset? UpdatedOn { get; set; } + public string? TenantId { get; set; } + public bool IsDeleted { get; set; } +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTableMapper.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTableMapper.cs new file mode 100644 index 00000000..2d3d554f --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTableMapper.cs @@ -0,0 +1,19 @@ +using CoreEx.Database.Mapping; +using System.Text.Json; + +namespace CoreEx.Database.SqlServer.Test.Unit.Models; + +public class TestTableMapper : DatabaseMapper +{ + public override TestTable MapFromDb(DatabaseRecord r, OperationType operationType = OperationType.Unspecified) => new TestTable() + { + Id = r.GetValue("TableId"), + Text = r.GetValue("Text"), + Number = r.GetValue("Number"), + Amount = r.GetValue("Amount"), + Flag = r.GetValue("Flag"), + Date = r.GetValue("Date"), + Time = r.GetValue("Time"), + Json = r.GetValueFromJson("Json") + }.MapStandardFromDb(r); +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestDbContext.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestDbContext.cs new file mode 100644 index 00000000..f2723a45 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestDbContext.cs @@ -0,0 +1,47 @@ +using CoreEx.Database.SqlServer.Test.Unit.Models; +using CoreEx.EntityFrameworkCore; +using CoreEx.EntityFrameworkCore.Converters; +using Microsoft.EntityFrameworkCore; + +namespace CoreEx.Database.SqlServer.Test.Unit.Repository; + +public class TestDbContext(DbContextOptions options, SqlServerDatabase database) : DbContext(options), IEfDbContext +{ + public IDatabase BaseDatabase { get; } = database.ThrowIfNull(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + // Uses IDatabase.Connection to ensure the same database/connection is used. + if (!optionsBuilder.IsConfigured) + optionsBuilder.UseSqlServer(BaseDatabase.Connection); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Add the model configuration. + modelBuilder.ThrowIfNull().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.Number).HasColumnName("Number").HasColumnType("INT"); + e.Property(p => p.Amount).HasColumnName("Amount").HasColumnType("MONEY"); + e.Property(p => p.Flag).HasColumnName("Flag").HasColumnType("BIT"); + e.Property(p => p.Date).HasColumnName("Date").HasColumnType("DATE"); + e.Property(p => p.Time).HasColumnName("Time").HasColumnType("TIME"); + e.Property(p => p.Json).HasColumnName("Json").HasColumnType("NVARCHAR(500)").HasConversion(JsonElementStringConverter.Default); + e.Property(p => p.TenantId).HasColumnName("TenantId").HasColumnType("NVARCHAR(20)"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); + 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); + }); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestEfDb.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestEfDb.cs new file mode 100644 index 00000000..0468c9a3 --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestEfDb.cs @@ -0,0 +1,18 @@ +using CoreEx.Database.SqlServer.Test.Unit.Models; +using CoreEx.EntityFrameworkCore; +using CoreEx.Results; + +namespace CoreEx.Database.SqlServer.Test.Unit.Repository; + +public class TestEfDb(TestDbContext dbContext) : EfDb(dbContext, _options) +{ + private static readonly EfDbOptions _options = new EfDbOptions() + .WithModel(mo => mo + .WithTenantFilter(allowFilterBypass: false) + .WithLogicalDeleteFilter(allowFilterBypass: true) + .WithFilter(q => q.Where(x => x.Flag != null && x.Flag == true), (_, _) => Result.AuthorizationError(), allowFilterBypass: true)); + + public EfDbModel Table => Model(); + + public EfDbMappedModel TableDto => Table.ToMappedModel(Contracts.TestTableDtoMapper.Default); +} \ No newline at end of file diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/appsettings.unittest.json b/tests/CoreEx.Database.SqlServer.Test.Unit/appsettings.unittest.json new file mode 100644 index 00000000..9d229aed --- /dev/null +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/appsettings.unittest.json @@ -0,0 +1,24 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "CoreEx", + "DomainName": "Test" + } + }, + "Logging": { + "LogLevel": { + "Default": "Debug", + "CoreEx": "Debug", + "Microsoft.Data": "Warning" + } + }, + "Aspire": { + "Microsoft": { + "Data": { + "SqlClient": { + "ConnectionString": "Data Source=127.0.0.1,1433;Initial Catalog=CoreEx.Test;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Database.Test.Unit/CoreEx.Database.Test.Unit.csproj b/tests/CoreEx.Database.Test.Unit/CoreEx.Database.Test.Unit.csproj new file mode 100644 index 00000000..117990b2 --- /dev/null +++ b/tests/CoreEx.Database.Test.Unit/CoreEx.Database.Test.Unit.csproj @@ -0,0 +1,34 @@ + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CoreEx.Database.Test.Unit/DatabaseParameterCollectionTests.cs b/tests/CoreEx.Database.Test.Unit/DatabaseParameterCollectionTests.cs new file mode 100644 index 00000000..06fa84c7 --- /dev/null +++ b/tests/CoreEx.Database.Test.Unit/DatabaseParameterCollectionTests.cs @@ -0,0 +1,278 @@ +using CoreEx.Database.SqlServer; +using CoreEx.Mapping.Converters; +using Microsoft.Data.SqlClient; +using System.Data; + +namespace CoreEx.Database.Test.Unit; + +[TestFixture] +public class DatabaseParameterCollectionTests +{ + [Test] + public void AddParameter_Null() + { + var dp = CreateCollection().AddParameter("foo"); + dp.ParameterName.Should().Be("@foo"); + dp.DbType.Should().Be(System.Data.DbType.String); + dp.Value.Should().Be(DBNull.Value); + dp.Direction.Should().Be(System.Data.ParameterDirection.Input); + } + + [Test] + public void AddParameter_WithValue() + { + var dp = CreateCollection().AddParameter("bar", 123); + dp.ParameterName.Should().Be("@bar"); + dp.Value.Should().Be(123); + dp.Direction.Should().Be(ParameterDirection.Input); + } + + [Test] + public void AddParameter_WithDbType() + { + var dp = CreateCollection().AddParameter("baz", 456, DbType.Int32, ParameterDirection.Output); + dp.ParameterName.Should().Be("@baz"); + dp.Value.Should().Be(456); + dp.DbType.Should().Be(DbType.Int32); + dp.Direction.Should().Be(ParameterDirection.Output); + } + + [Test] + public void AddParameter_WithSize() + { + var dp = CreateCollection().AddParameter("qux", DbType.String, 50, ParameterDirection.Input); + dp.ParameterName.Should().Be("@qux"); + dp.DbType.Should().Be(DbType.String); + dp.Size.Should().Be(50); + dp.Direction.Should().Be(ParameterDirection.Input); + } + + [Test] + public void AddParameter_DateTimeOffset() + { + var dto = new DateTimeOffset(2020, 01, 08, 06, 30, 59, TimeSpan.FromHours(8)); + var dtoUtc = dto.ToUniversalTime(); + var dp = CreateCollection().AddParameter("dto", dto); + dp.ParameterName.Should().Be("@dto"); + dp.Value.Should().Be(dtoUtc); + + // Cast it back and make sure it's UTC. + var dtoUtcCast = (DateTimeOffset)dp.Value; + dtoUtcCast.Offset.Should().Be(TimeSpan.Zero); + } + + [Test] + public void AddJsonParameter() + { + var obj = new { X = 1, Y = "abc" }; + var dp = CreateCollection().AddJsonParameter("json", obj); + dp.ParameterName.Should().Be("@json"); + dp.Value.Should().Be(System.Text.Json.JsonSerializer.Serialize(obj, CreateDatabase().JsonSerializerOptions)); + } + + [Test] + public void AddWildcardParameter() + { + var dp = CreateCollection().AddWildcardParameter("wild", "a*b?c"); + dp.ParameterName.Should().Be("@wild"); + dp.Value.Should().Be("a%b_c"); + + var dp2 = CreateCollection().AddWildcardParameter("wildx", "a%b_c"); + dp2.ParameterName.Should().Be("@wildx"); + dp2.Value.Should().Be("a[%]b[_]c"); + } + + [Test] + public void AddReturnValueParameter() + { + var dp = CreateCollection().AddReturnValueParameter(); + dp.ParameterName.Should().Be("@ReturnValue"); + dp.DbType.Should().Be(DbType.Int32); + dp.Direction.Should().Be(ParameterDirection.ReturnValue); + } + + [Test] + public void AddReselectRecordParam() + { + var dp = CreateCollection().AddReselectRecordParam(); + dp.ParameterName.Should().Be("@ReselectRecord"); + dp.Value.Should().Be(true); + } + + [Test] + public void AddRowVersionParam() + { + var rv = StringBase64Converter.Default.ConvertToDestination("12345678"); + var dp = CreateCollection().AddRowVersionParam("12345678"); + dp.ParameterName.Should().Be("@RowVersion"); + dp.Value.Should().BeEquivalentTo(rv); + } + + [Test] + public void ParameterizeName_AddsAtIfMissing() + { + DatabaseParameterCollection.ParameterizeName("foo").Should().Be("@foo"); + DatabaseParameterCollection.ParameterizeName("@bar").Should().Be("@bar"); + } + + [Test] + public void Add_And_Remove_Parameter() + { + var collection = CreateCollection(); + var param = collection.AddParameter("foo", 1); + collection.Contains(param).Should().BeTrue(); + collection.Remove(param).Should().BeTrue(); + collection.Contains(param).Should().BeFalse(); + } + + [Test] + public void Clear_RemovesAllParameters() + { + var collection = CreateCollection(); + collection.AddParameter("foo", 1); + collection.AddParameter("bar", 2); + collection.Count.Should().Be(2); + collection.Clear(); + collection.Count.Should().Be(0); + } + + [Test] + public void Indexer_ReturnsParameter() + { + var collection = CreateCollection(); + var param = collection.AddParameter("foo", 1); + collection[0].Should().Be(param); + } + + /* Extension Methods */ + + [Test] + public void ExtensionMethods_Param() + { + var collection = CreateCollection() + .Param("abc") + .Param("foo", 1) + .Param("bar", "abc", DbType.String, ParameterDirection.Output) + .Param("baz", DbType.Int32, 10, ParameterDirection.Input) + .JsonParam("json", new { X = 1, Y = "abc" }) + .WildCardParam("wild", "*") + .RowVersionParam("12345678") + .ReselectRecordParam(true); + + collection.Count.Should().Be(8); + collection[0].ParameterName.Should().Be("@abc"); + collection[1].ParameterName.Should().Be("@foo"); + collection[2].ParameterName.Should().Be("@bar"); + collection[3].ParameterName.Should().Be("@baz"); + collection[4].ParameterName.Should().Be("@json"); + collection[5].ParameterName.Should().Be("@wild"); + collection[6].ParameterName.Should().Be("@RowVersion"); + collection[7].ParameterName.Should().Be("@ReselectRecord"); + } + + [Test] + public void ExtensionMethods_ParamWith() + { + var collection = CreateCollection() + .ParamWhen(false, "foo", () => 1) + .ParamWhen(true, "fop", () => 1) + .ParamWhen(false, "bar", () => "abc", DbType.String, ParameterDirection.Output) + .ParamWhen(true, "baz", () => 10, DbType.Int32, ParameterDirection.Input) + .JsonParamWhen(false, "json", () => new { X = 1, Y = "abc" }) + .JsonParamWhen(true, "jsonx", () => new { X = 1, Y = "abc" }) + .WildcardParamWhen(false, "wild", () => "*") + .WildcardParamWhen(true, "wildx", () => "*"); + + collection.Count.Should().Be(4); + collection[0].ParameterName.Should().Be("@fop"); + collection[1].ParameterName.Should().Be("@baz"); + collection[2].ParameterName.Should().Be("@jsonx"); + collection[3].ParameterName.Should().Be("@wildx"); + + collection = CreateCollection().RowVersionParamWhen(false, "12345678"); + collection.Count.Should().Be(0); + + collection = CreateCollection().RowVersionParamWhen(true, "12345678"); + collection.Count.Should().Be(1); + collection[0].ParameterName.Should().Be("@RowVersion"); + } + + [Test] + public void ExtensionMethods_ParamWhen_Itself() + { + var collection = CreateCollection() + .ParamWith((string?)null, "foo") + .ParamWith("abc", "fop") + .ParamWith(0, "bar", direction: ParameterDirection.Input) + .ParamWith(1, "baz", direction: ParameterDirection.InputOutput) + .JsonParamWith((string?)null, "json") + .JsonParamWith("xyz", "jsonx") + .WildcardParamWith(null, "wild") + .WildcardParamWith("abc", "wildx"); + + collection.Count.Should().Be(4); + collection[0].ParameterName.Should().Be("@fop"); + collection[1].ParameterName.Should().Be("@baz"); + collection[2].ParameterName.Should().Be("@jsonx"); + collection[3].ParameterName.Should().Be("@wildx"); + + collection = CreateCollection().RowVersionParamWith(null); + collection.Count.Should().Be(0); + + collection = CreateCollection().RowVersionParamWith("12345678"); + collection.Count.Should().Be(1); + collection[0].ParameterName.Should().Be("@RowVersion"); + } + + [Test] + public void ExtensionMethods_ParamWhen_Value() + { + var collection = CreateCollection() + .ParamWith((string?)null, "foo", () => 1) + .ParamWith("abc", "fop", () => 1) + .ParamWith(0, "bar", () => 1) + .ParamWith(1, "baz", () => 1) + .JsonParamWith((string?)null, "json", () => 1) + .JsonParamWith("xyz", "jsonx", () => 1) + .WildcardParamWith((string?)null, "wild", () => "*") + .WildcardParamWith("xyz", "wildx", () => "*"); + + collection.Count.Should().Be(4); + collection[0].ParameterName.Should().Be("@fop"); + collection[1].ParameterName.Should().Be("@baz"); + collection[2].ParameterName.Should().Be("@jsonx"); + collection[3].ParameterName.Should().Be("@wildx"); + } + + [Test] + public void ExtensionMethods_PagingParams() + { + var collection = CreateCollection().PagingParams(null); + collection.Count.Should().Be(0); + + collection = CreateCollection().PagingParams(new Data.PagingArgs(10, 5)); + collection.Count.Should().Be(2); + collection[0].ParameterName.Should().Be("@PagingSkip"); + collection[0].Value.Should().Be(10); + collection[1].ParameterName.Should().Be("@PagingTake"); + collection[1].Value.Should().Be(5); + + collection = CreateCollection().PagingParams(new Data.PagingArgs(8, 4, true)); + collection.Count.Should().Be(3); + collection[0].ParameterName.Should().Be("@PagingSkip"); + collection[0].Value.Should().Be(8); + collection[1].ParameterName.Should().Be("@PagingTake"); + collection[1].Value.Should().Be(4); + collection[2].ParameterName.Should().Be("@PagingCount"); + collection[2].Value.Should().Be(true); + } + + /* Utility */ + + private static SqlServerDatabase CreateDatabase() => new((SqlConnection)SqlClientFactory.Instance.CreateConnection()) + { + Wildcard = new Extended.DatabaseWildcard(Wildcards.Wildcard.BothAll) + }; + + private static DatabaseParameterCollection CreateCollection() => CreateDatabase().Statement(SqlStatement.FromText("SELECT 1")).Parameters; +} \ No newline at end of file diff --git a/tests/CoreEx.DomainDriven.Test.Unit/AggregateTests.cs b/tests/CoreEx.DomainDriven.Test.Unit/AggregateTests.cs new file mode 100644 index 00000000..4563fb5f --- /dev/null +++ b/tests/CoreEx.DomainDriven.Test.Unit/AggregateTests.cs @@ -0,0 +1,247 @@ +using CoreEx.Entities; +using CoreEx.Events; + +namespace CoreEx.DomainDriven.Test.Unit; + +[TestFixture] +public class AggregateTests +{ + public sealed class TestEntity(int id) : Aggregate(id) + { + // Expose wrappers to call protected base methods for testing. + public TestEntity PublicSetPersistenceState(PersistenceState state) => SetPersistenceState(state); + public TestEntity PublicMakeReadOnly() => MakeReadOnly(); + public TestEntity PublicSetChangeLog(ChangeLog? changeLog) => SetChangeLog(changeLog); + public TestEntity PublicSetETag(string? eTag) => SetETag(eTag); + public TestEntity PublicAddEvent(EventData eventData) => AddEvent(eventData); + public TestEntity PublicClearEvents() => ClearEvents(); + + // Expose wrappers to call protected modification helpers. + public void PublicModify(Action? action = null) => Modify(action); + public TResult PublicModify(Func func) => Modify(func); + public void PublicModifyAndMakeReadOnly(Action? action = null) => ModifyAndMakeReadOnly(action); + public TResult PublicModifyAndMakeReadOnly(Func func) => ModifyAndMakeReadOnly(func); + + public void PublicRemove(Action? action = null) => Remove(action); + } + + [Test] + public void Constructor_SetsId_And_Defaults() + { + var e = new TestEntity(42); + + e.Id.Should().Be(42); + e.IsReadOnly.Should().BeFalse(); + e.PersistenceState.Should().Be(PersistenceState.Unknown); + e.ChangeLog.Should().BeNull(); + e.ETag.Should().BeNull(); + } + + [Test] + public void SetPersistenceState_ValidTransitions() + { + var e = new TestEntity(1); + + e.PublicSetPersistenceState(PersistenceState.NotModified); + e.PersistenceState.Should().Be(PersistenceState.NotModified); + + e.PublicSetPersistenceState(PersistenceState.Modified); + e.PersistenceState.Should().Be(PersistenceState.Modified); + + e.PublicSetPersistenceState(PersistenceState.Removed); + e.PersistenceState.Should().Be(PersistenceState.Removed); + } + + [Test] + public void SetPersistenceState_InvalidTransitions_Throw() + { + var e = new TestEntity(1); + e.PublicSetPersistenceState(PersistenceState.New); + + // Unknown is invalid target + Action actUnknown = () => e.PublicSetPersistenceState(PersistenceState.Unknown); + actUnknown.Should().Throw() + .WithMessage("*cannot be set to 'Unknown'*"); + + // NotModified only allowed from Unknown + Action actNotModifiedAgain = () => e.PublicSetPersistenceState(PersistenceState.NotModified); + actNotModifiedAgain.Should().Throw() + .WithMessage("*can only be set to 'NotModified' from 'Unknown'*"); + } + + [Test] + public void MakeReadOnly_SetsFlag() + { + var e = new TestEntity(1); + + e.IsReadOnly.Should().BeFalse(); + e.PublicMakeReadOnly(); + e.IsReadOnly.Should().BeTrue(); + } + + [Test] + public void CheckReadOnly_Throws_When_ReadOnly() + { + var e = new TestEntity(1); + e.PublicMakeReadOnly(); + + Action act = () => e.PublicModify(); + act.Should().Throw() + .WithMessage(EntityBase.ReadOnlyErrorMessage); + } + + [Test] + public void Modify_SetsModified_When_NotModified() + { + var e = new TestEntity(1); + + e.PublicSetPersistenceState(PersistenceState.NotModified); + e.PersistenceState.Should().Be(PersistenceState.NotModified); + + e.PublicModify(); // no-op. + + e.PersistenceState.Should().Be(PersistenceState.Modified); + } + + [Test] + public void Modify_DoesNotChangeState_When_AlreadyModified() + { + var e = new TestEntity(1); + + e.PublicSetPersistenceState(PersistenceState.Modified); + e.PublicModify(); // no-op. + + e.PersistenceState.Should().Be(PersistenceState.Modified); + } + + [Test] + public void Modify_Function_ReturnsValue_And_SetsModified() + { + var e = new TestEntity(1); + e.PublicSetPersistenceState(PersistenceState.NotModified); + + var result = e.PublicModify(() => "ok"); + result.Should().Be("ok"); + + e.PersistenceState.Should().Be(PersistenceState.Modified); + } + + [Test] + public void Modify_Function_Throws_When_Func_Is_Null() + { + var e = new TestEntity(1); + + Action act = () => e.PublicModify(null!); + act.Should().Throw(); + } + + [Test] + public void ModifyAndMakeReadOnly_SetsModified_Then_ReadOnly() + { + var e = new TestEntity(1); + e.PublicSetPersistenceState(PersistenceState.NotModified); + + e.PublicModifyAndMakeReadOnly(); // no-op. + + e.PersistenceState.Should().Be(PersistenceState.Modified); + e.IsReadOnly.Should().BeTrue(); + } + + [Test] + public void ModifyAndMakeReadOnly_Function_ReturnsValue_And_SetsModified_Then_ReadOnly() + { + var e = new TestEntity(1); + e.PublicSetPersistenceState(PersistenceState.NotModified); + + var result = e.PublicModifyAndMakeReadOnly(() => 123); + result.Should().Be(123); + + e.PersistenceState.Should().Be(PersistenceState.Modified); + e.IsReadOnly.Should().BeTrue(); + } + + [Test] + public void Remove_SetsRemoved_And_ReadOnly() + { + var e = new TestEntity(1); + + e.PublicRemove(); + + e.PersistenceState.Should().Be(PersistenceState.Removed); + e.IsReadOnly.Should().BeTrue(); + } + + [Test] + public void SetChangeLog_Bypasses_ReadOnly_And_DoesNotChangeState() + { + var e = new TestEntity(1); + e.PublicSetPersistenceState(PersistenceState.NotModified); + e.PublicMakeReadOnly(); + + var cl = new ChangeLog { UpdatedBy = "user", UpdatedOn = DateTime.UtcNow }; + + e.PublicSetChangeLog(cl); + + e.ChangeLog.Should().Be(cl); + e.PersistenceState.Should().Be(PersistenceState.NotModified); + e.IsReadOnly.Should().BeTrue(); + } + + [Test] + public void SetETag_Bypasses_ReadOnly_And_DoesNotChangeState() + { + var e = new TestEntity(1); + e.PublicSetPersistenceState(PersistenceState.NotModified); + e.PublicMakeReadOnly(); + + e.PublicSetETag("etag-1"); + + e.ETag.Should().Be("etag-1"); + e.PersistenceState.Should().Be(PersistenceState.NotModified); + e.IsReadOnly.Should().BeTrue(); + } + + [Test] + public void Equality_Compares_By_Id() + { + var e1 = new TestEntity(100); + var e2 = new TestEntity(100); + var e3 = new TestEntity(101); + + e1.Equals(e2).Should().BeTrue("same Id implies equality per DDD"); + e1.Equals(e3).Should().BeFalse(); + e1.Equals((TestEntity?)null).Should().BeFalse(); + + (e1 == e2).Should().BeTrue(); + (e1 == e3).Should().BeFalse(); + (e1 != e2).Should().BeFalse(); + (e1 != e3).Should().BeTrue(); + } + + [Test] + public void ToString_Delegates_To_IEntityKey() + { + var e = new TestEntity(7); + var s = e.ToString(); + + s.Should().Be("7"); + } + + [Test] + public void Create_And_Clear_Events() + { + var e = new TestEntity(7); + + // No events by default. + e.HasEvents.Should().BeFalse(); + + // Add event. + e.PublicAddEvent(EventData.CreateEvent("test-event", "emitted")); + e.HasEvents.Should().BeTrue(); + e.Events.Should().HaveCount(1); + + // Clear events. + e.PublicClearEvents(); + e.HasEvents.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.DomainDriven.Test.Unit/CoreEx.DomainDriven.Test.Unit.csproj b/tests/CoreEx.DomainDriven.Test.Unit/CoreEx.DomainDriven.Test.Unit.csproj new file mode 100644 index 00000000..9c80daed --- /dev/null +++ b/tests/CoreEx.DomainDriven.Test.Unit/CoreEx.DomainDriven.Test.Unit.csproj @@ -0,0 +1,34 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CoreEx.Events.Test.Unit/CloudEventsExtensionsTests.cs b/tests/CoreEx.Events.Test.Unit/CloudEventsExtensionsTests.cs new file mode 100644 index 00000000..739eef9b --- /dev/null +++ b/tests/CoreEx.Events.Test.Unit/CloudEventsExtensionsTests.cs @@ -0,0 +1,132 @@ +using CoreEx.Entities; + +namespace CoreEx.Events.Test.Unit; + +[TestFixture] +public class CloudEventsExtensionsTests +{ + [Test] + public void EncodeToBinaryData_Structured() + { + var ed = new EventData().WithValue(new Product { Id = "abc", Name = "A blue carrot" }).WithAction("Created"); + var ef = new EventFormatter { SourceBaseUri = new Uri("urn:Test"), DomainName = "CoreEx" }; + + var ce = ef.ConvertToCloudEvent(ef.Format(ed)); + var bd = ce.EncodeToBinaryData(CloudNative.CloudEvents.ContentMode.Structured); + bd.Should().NotBeNull(); + bd.MediaType.Should().Be("application/cloudevents+json; charset=utf-8"); + + var ce2 = bd.DecodeToCloudEvent(CloudNative.CloudEvents.ContentMode.Structured); + ce2.Should().NotBeNull(); + var ed2 = ef.Parse(ef.ConvertFromCloudEvent(ce2)); + ed2.Should().NotBeNull(); + + // The casing will have been changed during formatting and cannot be returned to original. + ed2.DomainName.Should().Be("coreex"); + ed2.Entity.Should().Be("product"); + ed2.Action.Should().Be("created"); + + ObjectComparer.Assert(ed.Data, ed2.Data); + } + + [Test] + public void EncodeToBinaryData_Binary() + { + var ed = new EventData().WithValue(new Product { Id = "abc", Name = "A blue carrot" }).WithAction("Created"); + var ef = new EventFormatter { SourceBaseUri = new Uri("urn:Test"), DomainName = "CoreEx" }; + + var ce = ef.ConvertToCloudEvent(ef.Format(ed)); + var bd = ce.EncodeToBinaryData(CloudNative.CloudEvents.ContentMode.Binary); + bd.Should().NotBeNull(); + bd.ToString().Should().Be("""{"id":"abc","name":"A blue carrot"}"""); + + var ce2 = bd.DecodeToCloudEvent(CloudNative.CloudEvents.ContentMode.Binary); + ce2.Should().NotBeNull(); + var ed2 = ef.Parse(ef.ConvertFromCloudEvent(ce2)); + ed2.Should().NotBeNull(); + + // The non-data properties are not included in DataJson format and so will be null. + ed2.DomainName.Should().BeNull(); + ed2.Entity.Should().BeNull(); + ed2.Action.Should().BeNull(); + + ObjectComparer.Assert(ed.Data, ed2.Data); + } + + //[Test] + //public void EncodeToBinaryData_FullBinary() + //{ + // var ed = new EventData().WithValue(new Product { Id = "abc", Name = "A blue carrot" }).WithAction("Created"); + // var ef = new EventFormatter { SourceBaseUri = new Uri("urn:Test"), DomainName = "CoreEx" }; + + // var ce = ef.ConvertToCloudEvent(ef.Format(ed)); + // var bd = ce.EncodeToBinaryData(DataContentFormat.FullBinary); + // bd.Should().NotBeNull(); + + // var ce2 = bd.DecodeToCloudEvent(DataContentFormat.FullBinary); + // ce2.Should().NotBeNull(); + // var ed2 = ef.Parse(ef.ConvertFromCloudEvent(ce2)); + // ed2.Should().NotBeNull(); + + // // The casing will have been changed during formatting and cannot be returned to original. + // ed2.DomainName.Should().Be("coreex"); + // ed2.Entity.Should().Be("product"); + // ed2.Action.Should().Be("created"); + + // ObjectComparer.Assert(ed.Data, ed2.Data); + //} + + //[Test] + //public void EncodeToBinaryData_DataBinary() + //{ + // var ed = new EventData().WithValue(new Product { Id = "abc", Name = "A blue carrot" }).WithAction("Created"); + // var ef = new EventFormatter { SourceBaseUri = new Uri("urn:Test"), DomainName = "CoreEx" }; + + // var ce = ef.ConvertToCloudEvent(ef.Format(ed)); + // var bd = ce.EncodeToBinaryData(DataContentFormat.DataBinary); + // bd.Should().NotBeNull(); + + // var ce2 = bd.DecodeToCloudEvent(DataContentFormat.DataBinary); + // ce2.Should().NotBeNull(); + // var ed2 = ef.Parse(ef.ConvertFromCloudEvent(ce2)); + // ed2.Should().NotBeNull(); + + // // The non-data properties are not included in DataJson format and so will be null. + // ed2.DomainName.Should().BeNull(); + // ed2.Entity.Should().BeNull(); + // ed2.Action.Should().BeNull(); + + // ObjectComparer.Assert(ed.Data, ed2.Data); + //} + + [Test] + public void EncodeToJsonElement_And_Back_Again() + { + var ed = new EventData().WithValue(new Product { Id = "abc", Name = "A blue carrot" }).WithAction("Created"); + var ef = new EventFormatter { SourceBaseUri = new Uri("urn:Test"), DomainName = "coreex" }; + + var ce = ef.ConvertToCloudEvent(ef.Format(ed)); + var je = ce.EncodeToJsonElement(); + je.Should().NotBeNull(); + + Console.WriteLine(je.ToString()); + + var ce2 = je.DecodeToCloudEvent(); + ce2.Should().NotBeNull(); + var ed2 = ef.Parse(ef.ConvertFromCloudEvent(ce2)); + ed2.Should().NotBeNull(); + + // The casing will have been changed during formatting and cannot be returned to original. + ed2.DomainName.Should().Be("coreex"); + ed2.Entity.Should().Be("product"); + ed2.Action.Should().Be("created"); + + ObjectComparer.Assert(ed.Data, ed2.Data); + } + + private class Product : IIdentifier + { + public string? Id { get; set; } + public string? Name { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Events.Test.Unit/CoreEx.Events.Test.Unit.csproj b/tests/CoreEx.Events.Test.Unit/CoreEx.Events.Test.Unit.csproj new file mode 100644 index 00000000..0d167a04 --- /dev/null +++ b/tests/CoreEx.Events.Test.Unit/CoreEx.Events.Test.Unit.csproj @@ -0,0 +1,35 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CoreEx.Events.Test.Unit/EventDataTests.cs b/tests/CoreEx.Events.Test.Unit/EventDataTests.cs new file mode 100644 index 00000000..b4bc8d15 --- /dev/null +++ b/tests/CoreEx.Events.Test.Unit/EventDataTests.cs @@ -0,0 +1,277 @@ +using CoreEx.Data; +using CoreEx.Entities; +using CoreEx.Security; + +namespace CoreEx.Events.Test.Unit; + +[TestFixture] +public class EventDataTests +{ + [Test] + public void Property_GetSet_All() + { + var dt = DateTimeOffset.UtcNow; + var uri = new Uri("https://test"); + var ver = new Version(1, 2, 3, 4); + + var ed = new EventData + { + Id = "id1", + Timestamp = dt, + DomainName = "DOMAIN", + TenantId = "TENANT", + Entity = "ENTITY", + Action = "ACTION", + Key = "key1", + TraceParent = "tp", + TraceState = "ts", + UserType = AuthenticationType.AccountUser, + UserId = "user1", + Data = new BinaryData("abc"), + DataSchema = uri, + DataSchemaVersion = ver, + PartitionKey = "pk", + ReplyTo = "reply", + Title = "title", + Source = uri + }; + + ed.Id.Should().Be("id1"); + ed.Timestamp.Should().Be(dt); + ed.DomainName.Should().Be("DOMAIN"); + ed.TenantId.Should().Be("TENANT"); + ed.Entity.Should().Be("ENTITY"); + ed.Action.Should().Be("ACTION"); + ed.Key.Should().Be("key1"); + ed.TraceParent.Should().Be("tp"); + ed.TraceState.Should().Be("ts"); + ed.UserType.Should().Be(AuthenticationType.AccountUser); + ed.UserId.Should().Be("user1"); + ed.Data!.ToString().Should().Be("abc"); + ed.DataSchema.Should().Be(uri); + ed.DataSchemaVersion.Should().Be(ver); + ed.PartitionKey.Should().Be("pk"); + ed.ReplyTo.Should().Be("reply"); + ed.Title.Should().Be("title"); + ed.Source.Should().Be(uri); + } + + [Test] + public void SetAttribute_And_TryGetAttribute() + { + var ed = new EventData(); + ed.SetAttribute("foo", 123); + ed.SetAttribute("bar", "baz"); + + ed.Attributes.Should().ContainKey("foo"); + ed.Attributes.Should().ContainKey("bar"); + ed.Attributes["foo"].Should().Be(123); + ed.Attributes["bar"].Should().Be("baz"); + + ed.TryGetAttribute("foo", out var v1).Should().BeTrue(); + v1.Should().Be(123); + + ed.TryGetAttribute("bar", out var v2).Should().BeTrue(); + v2.Should().Be("baz"); + + ed.TryGetAttribute("notfound", out var v3).Should().BeFalse(); + v3.Should().BeNull(); + } + + [Test] + public void WithEntity_SetsEntity() + { + var ed = new EventData().WithEntity("Order"); + ed.Entity.Should().Be("Order"); + } + + [Test] + public void WithAction_String_SetsAction() + { + var ed = new EventData().WithAction("Created"); + ed.Action.Should().Be("Created"); + } + + private enum TestAction { Created, Updated } + + [Test] + public void WithAction_Enum_SetsAction() + { + var ed = new EventData().WithAction(TestAction.Updated); + ed.Action.Should().Be("Updated"); + } + + [Test] + public void WithKey_SetsKey() + { + var ed = new EventData().WithKey("123"); + ed.Key.Should().Be("123"); + } + + [Test] + public void WithVersion_SetsDataSchemaVersion() + { + var v = new Version(1, 2, 3); + var ed = new EventData().WithVersion(v); + ed.DataSchemaVersion.Should().Be(v); + } + + [Test] + public void WithUser_SetsUserTypeAndUserId() + { + var user = new Security.AuthenticationUser { Type = AuthenticationType.AccountUser, Id = "u1", UserName = "user1" }; + var ed = new EventData().WithUser(user); + ed.UserType.Should().Be(AuthenticationType.AccountUser); + ed.UserId.Should().Be("u1"); + } + + [Test] + public void WithSchema_SetsDataSchema() + { + var uri = new Uri("https://schema"); + var ed = new EventData().WithSchema(uri); + ed.DataSchema.Should().Be(uri); + } + + [Test] + public void WithTitle_SetsTitle() + { + var ed = new EventData().WithTitle("my.title"); + ed.Title.Should().Be("my.title"); + } + + [Test] + public void WithValue_SetsDataAndEntity() + { + var obj = new TestValue { Id = "42", TenantId = "t1", PartitionKey = "pk1", ETag = "abc" }; + var ed = new EventData().WithValue(obj); + ed.Data.Should().NotBeNull(); + ed.Data.MediaType.Should().Be(System.Net.Mime.MediaTypeNames.Application.Json); + ed.Data.Length.Should().BeGreaterThan(0); + ed.Entity.Should().Be(nameof(TestValue)); + ed.Key.Should().Be("42"); + ed.TenantId.Should().Be("t1"); + ed.PartitionKey.Should().Be("pk1"); + + var json = ed.Data!.ToString(); + json.Should().Contain("abc"); + json.Length.Should().BeGreaterThan(0); + } + + [Test] + public void WithValue_Null_SetsDataNull() + { + var ed = new EventData().WithValue(null); + ed.Data.Should().BeNull(); + } + + [Test] + public void WithValue_ExcludePaths_ExcludesProperties() + { + var obj = new TestValue { Id = "42", TenantId = "t1", PartitionKey = "pk1", Extra = "should-exclude" }; + var ed = new EventData().WithValue(obj, "Extra"); + var json = ed.Data!.ToString(); + json.Should().NotContain("should-exclude"); + } + + [Test] + public void Create_Event() + { + var ed = EventData.CreateEvent("Order", "Created"); + ed.Entity.Should().Be("Order"); + ed.Action.Should().Be("Created"); + ed.MessageType.Should().Be(MessageType.Event); + ed.DomainName.Should().BeNull(); + + ed = EventData.CreateEvent("Order", TestAction.Created); + ed.Entity.Should().Be("Order"); + ed.Action.Should().Be("Created"); + ed.MessageType.Should().Be(MessageType.Event); + ed.DomainName.Should().BeNull(); + + ed = EventData.CreateEventWith(new TestValue { Id = "42", TenantId = "t1", PartitionKey = "pk1" }, "Created"); + ed.Data.Should().NotBeNull(); + ed.Data!.ToString().Should().Contain("\"id\":\"42\""); + ed.Entity.Should().Be("TestValue"); + ed.Action.Should().Be("Created"); + ed.MessageType.Should().Be(MessageType.Event); + ed.DomainName.Should().BeNull(); + + ed = EventData.CreateEventWith(new TestValue { Id = "42", TenantId = "t1", PartitionKey = "pk1" }, TestAction.Created); + ed.Data.Should().NotBeNull(); + ed.Data!.ToString().Should().Contain("\"id\":\"42\""); + ed.Entity.Should().Be("TestValue"); + ed.Action.Should().Be("Created"); + ed.MessageType.Should().Be(MessageType.Event); + ed.DomainName.Should().BeNull(); + } + + [Test] + public void Create_Command() + { + var ed = EventData.CreateCommand("Test", "Order", "Created"); + ed.Entity.Should().Be("Order"); + ed.Action.Should().Be("Created"); + ed.MessageType.Should().Be(MessageType.Command); + ed.DomainName.Should().Be("Test"); + + ed = EventData.CreateCommand("Test", "Order", TestAction.Created); + ed.Entity.Should().Be("Order"); + ed.Action.Should().Be("Created"); + ed.MessageType.Should().Be(MessageType.Command); + ed.DomainName.Should().Be("Test"); + + ed = EventData.CreateCommandWith("Test", new TestValue { Id = "42", TenantId = "t1", PartitionKey = "pk1" }, "Created"); + ed.Data.Should().NotBeNull(); + ed.Data!.ToString().Should().Contain("\"id\":\"42\""); + ed.Entity.Should().Be("TestValue"); + ed.Action.Should().Be("Created"); + ed.MessageType.Should().Be(MessageType.Command); + ed.DomainName.Should().Be("Test"); + + ed = EventData.CreateCommandWith("Test", new TestValue { Id = "42", TenantId = "t1", PartitionKey = "pk1" }, TestAction.Created); + ed.Data.Should().NotBeNull(); + ed.Data!.ToString().Should().Contain("\"id\":\"42\""); + ed.Entity.Should().Be("TestValue"); + ed.Action.Should().Be("Created"); + ed.MessageType.Should().Be(MessageType.Command); + ed.DomainName.Should().Be("Test"); + } + + [Test] + public void ToObjectFromJson_T_ReturnsDeserializedObject() + { + var obj = new TestValue { Id = "42", TenantId = "t1", PartitionKey = "pk1", ETag = "abc" }; + var ed = new EventData().WithValue(obj); + + var obj2 = ed.ToObjectFromJson(); + obj2.Should().NotBeNull(); + obj2.Id.Should().Be(obj.Id); + obj2.TenantId.Should().Be(obj.TenantId); + obj2.PartitionKey.Should().Be(obj.PartitionKey); + obj2.ETag.Should().Be("abc"); + } + + [Test] + public void ToObjectFromJson_T_ReturnsDeserializedObject_Type() + { + var obj = new TestValue { Id = "42", TenantId = "t1", PartitionKey = "pk1", ETag = "abc" }; + var ed = new EventData().WithValue(obj); + + TestValue? obj2 = ed.ToObjectFromJson(); + obj2.Should().NotBeNull(); + obj2.Id.Should().Be(obj.Id); + obj2.TenantId.Should().Be(obj.TenantId); + obj2.PartitionKey.Should().Be(obj.PartitionKey); + obj2.ETag.Should().Be("abc"); + } + + private class TestValue : IIdentifier, IReadOnlyTenantId, IReadOnlyPartitionKey, IReadOnlyETag + { + public string? Id { get; set; } + public string? TenantId { get; set; } + public string? PartitionKey { get; set; } + public string? Extra { get; set; } + public string? ETag { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Events.Test.Unit/EventFormatterTests.cs b/tests/CoreEx.Events.Test.Unit/EventFormatterTests.cs new file mode 100644 index 00000000..0d0f4953 --- /dev/null +++ b/tests/CoreEx.Events.Test.Unit/EventFormatterTests.cs @@ -0,0 +1,246 @@ +using CloudNative.CloudEvents; +using CloudNative.CloudEvents.Extensions; +using CoreEx.Hosting; +using CoreEx.Security; + +namespace CoreEx.Events.Test.Unit; + +[TestFixture] +public class EventFormatterTests +{ + [Test] + public void Property_GetSet_All() + { + var ef = new EventFormatter + { + TraceParentAttributeName = "tp", + TraceStateAttributeName = "ts", + TraceBaggageAttributeName = "tb", + TenantIdAttributeName = "tid", + DataSchemaVersionAttributeName = "dsv", + AuthTypeAttributeName = "at", + AuthIdAttributeName = "aid", + ReplyToAttributeName = "rt", + TitlePrefix = "prefix", + SourceBaseUri = new Uri("https://base/"), + DomainName = "dn" + }; + + ef.TraceParentAttributeName.Should().Be("tp"); + ef.TraceStateAttributeName.Should().Be("ts"); + ef.TraceBaggageAttributeName.Should().Be("tb"); + ef.TenantIdAttributeName.Should().Be("tid"); + ef.DataSchemaVersionAttributeName.Should().Be("dsv"); + ef.AuthTypeAttributeName.Should().Be("at"); + ef.AuthIdAttributeName.Should().Be("aid"); + ef.ReplyToAttributeName.Should().Be("rt"); + ef.TitlePrefix.Should().Be("prefix"); + ef.SourceBaseUri.Should().Be(new Uri("https://base/")); + ef.DomainName.Should().Be("dn"); + } + + [Test] + public void Format_TitleAndSource() + { + var ef = new EventFormatter { TitlePrefix = "pre", SourceBaseUri = new Uri("https://base/"), DomainName = "dom", PartitionKeyIsRequired = false }; + var ed = new EventData + { + DomainName = "dom", + Entity = "ent", + Action = "act", + DataSchemaVersion = new Version(2, 3), + TenantId = "tenant" + }; + + var result = ef.Format(ed); + result.Title.Should().Be("pre.dom.ent.act.v2"); + result.Source.Should().Be(new Uri("https://base/tenant")); + } + + [Test] + public void Format_Title_From_HostSettings() + { + var hs = new HostSettings { SolutionName = "Coreex.Test", DomainName = "Dom", EnvironmentName = "Env" }; + var ef = new EventFormatter(hs) { PartitionKeyIsRequired = false }; + var ed = new EventData + { + Entity = "Ent", + Action = "Act", + DataSchemaVersion = new Version(1, 0) + }; + + var result = ef.Format(ed); + result.Title.Should().Be("coreex.test.dom.ent.act.v1"); + result.Source.Should().Be(new Uri("urn:coreex:test:dom")); + } + + [Test] + public void Parse_Title_Full() + { + var ef = new EventFormatter { TitlePrefix = "pre" }; + var ed = new EventData { Title = "pre.dom.ent.act.v2" }; + var result = ef.Parse(ed); + result.DomainName.Should().Be("dom"); + result.Entity.Should().Be("ent"); + result.Action.Should().Be("act"); + result.DataSchemaVersion.Should().Be(new Version(2, 0)); + } + + [Test] + public void Parse_Title_Partial() + { + var ef = new EventFormatter(); + var ed = new EventData { Title = "dom.ent.act" }; + var result = ef.Parse(ed); + result.DomainName.Should().Be("dom"); + result.Entity.Should().Be("ent"); + result.Action.Should().Be("act"); + result.DataSchemaVersion.Should().BeNull(); + } + + [Test] + public void Convert_EventData_To_CloudEvent_AndBack() + { + var ef = new EventFormatter(); + var ed = new EventData + { + Id = "id1", + Timestamp = DateTimeOffset.UtcNow, + DomainName = "dom", + Entity = "ent", + Action = "act", + Key = "k1", + PartitionKey = "pk1", + ReplyTo = "reply", + UserType = AuthenticationType.AccountUser, + UserId = "user1", + TraceParent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + TraceState = "ts", + TraceBaggage = [new KeyValuePair("foo", "bar")], + DataSchemaVersion = new Version(1, 0), + DataSchema = new Uri("https://schema"), + Data = new BinaryData("abc"), + Title = "dom.ent.act.v1", + Source = new Uri("https://src") + }; + ed.SetAttribute("foo", "bar"); + ed.SetAttribute("_internal", "shouldnotcopy"); + + var ce = ef.ConvertToCloudEvent(ed); + ce.Id.Should().Be(ed.Id); + ce.Type.Should().Be(ed.Title); + ce.Source.Should().Be(ed.Source); + ce.Subject.Should().Be(ed.Key); + ce.Time.Should().Be(ed.Timestamp); + ce.DataContentType.Should().Be(ed.Data!.MediaType); + ce.DataSchema.Should().Be(ed.DataSchema); + ce.Data.Should().Be(ed.Data); + ce[ef.TenantIdAttributeName].Should().Be(ed.TenantId); + ce.GetPartitionKey().Should().Be(ed.PartitionKey); + ce[ef.ReplyToAttributeName].Should().Be(ed.ReplyTo); + ce[ef.AuthTypeAttributeName].Should().Be("user"); + ce[ef.AuthIdAttributeName].Should().Be(ed.UserId); + ce[ef.TraceParentAttributeName].Should().Be(ed.TraceParent); + ce[ef.TraceStateAttributeName].Should().Be(ed.TraceState); + ce[ef.TraceBaggageAttributeName].Should().Be("foo=bar"); + ce[ef.DataSchemaVersionAttributeName].Should().Be(ed.DataSchemaVersion.ToString()); + ce["foo"].Should().Be("bar"); + ce["_internal"].Should().BeNull(); + + // Convert back + var ed2 = ef.ConvertFromCloudEvent(ce); + ed2.Id.Should().Be(ed.Id); + ed2.Timestamp.Should().Be(ed.Timestamp); + ed2.DataSchema.Should().Be(ed.DataSchema); + ed2.Key.Should().Be(ed.Key); + ed2.PartitionKey.Should().Be(ed.PartitionKey); + ed2.DataSchemaVersion.Should().Be(ed.DataSchemaVersion); + ed2.TenantId.Should().Be(ed.TenantId); + ed2.ReplyTo.Should().Be(ed.ReplyTo); + ed2.UserType.Should().Be(ed.UserType); + ed2.UserId.Should().Be(ed.UserId); + ed2.TraceParent.Should().Be(ed.TraceParent); + ed2.TraceState.Should().Be(ed.TraceState); + ed2.TraceBaggage.Should().BeEquivalentTo(ed.TraceBaggage); + ed2.Source.Should().Be(ed.Source); + ed2.Title.Should().Be(ed.Title); + ed2.Attributes.Should().ContainKey("foo"); + ed2.Attributes.Should().NotContainKey("_internal"); + } + + [Test] + public void Convert_CloudEvent_DataNotBinary_Throws() + { + var ef = new EventFormatter(); + var ce = new CloudEvent + { + Id = "id", + Data = "not-binary" + }; + Action act = () => ef.ConvertFromCloudEvent(ce); + act.Should().Throw(); + } + + [Test] + public void AddTracing_SetsTraceParentAndState() + { + var ef = new EventFormatter(); + var ce = new CloudEvent { Id = "id" }; + ef.AddTracing(ce, "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", "vendor=prop"); + ce[ef.TraceParentAttributeName].Should().Be("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"); + ce[ef.TraceStateAttributeName].Should().Be("vendor=prop"); + ce[ef.TraceBaggageAttributeName].Should().BeNull(); + + ce = new CloudEvent { Id = "id" }; + ef.AddTracing(ce, "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", "vendor=prop", [new KeyValuePair("foo", "bar")]); + ce[ef.TraceParentAttributeName].Should().Be("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"); + ce[ef.TraceStateAttributeName].Should().Be("vendor=prop"); + ce[ef.TraceBaggageAttributeName].Should().Be("foo=bar"); + } + + [Test] + public void AddTracing_DoesNotOverwriteExisting() + { + var ef = new EventFormatter(); + var ce = new CloudEvent { Id = "id" }; + ce.SetExtensionAttribute(ef.TraceParentAttributeName, "existing"); + ef.AddTracing(ce, "tp", "ts"); + ce[ef.TraceParentAttributeName].Should().Be("existing"); + } + + [TestCase(AuthenticationType.Unknown, "unknown")] + [TestCase(AuthenticationType.Unauthenticated, "unauthenticated")] + [TestCase(AuthenticationType.ApplicationUser, "app_user")] + [TestCase(AuthenticationType.AccountUser, "user")] + [TestCase(AuthenticationType.SystemUser, "system")] + public void ConvertFromAuthenticationType_And_ConvertToAuthenticationType(AuthenticationType type, string expected) + { + var ef = new EventFormatter(); + var methodFrom = ef.GetType().GetMethod("ConvertFromAuthenticationType", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var methodTo = ef.GetType().GetMethod("ConvertToAuthenticationType", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + var str = (string?)methodFrom!.Invoke(ef, [type]); + str.Should().Be(expected); + + var at = (AuthenticationType?)methodTo!.Invoke(ef, [expected]); + at.Should().Be(type); + } + + [Test] + public void ConvertFromAuthenticationType_Invalid_Throws() + { + var ef = new EventFormatter(); + var method = ef.GetType().GetMethod("ConvertFromAuthenticationType", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Action act = () => method!.Invoke(ef, [(AuthenticationType)999]); + act.Should().Throw(); + } + + [Test] + public void ConvertToAuthenticationType_Invalid_Throws() + { + var ef = new EventFormatter(); + var method = ef.GetType().GetMethod("ConvertToAuthenticationType", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Action act = () => method!.Invoke(ef, ["badtype"]); + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Events.Test.Unit/GlobalSuppressions.cs b/tests/CoreEx.Events.Test.Unit/GlobalSuppressions.cs new file mode 100644 index 00000000..4fa147ec --- /dev/null +++ b/tests/CoreEx.Events.Test.Unit/GlobalSuppressions.cs @@ -0,0 +1,9 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Performance", "CA1806:Do not ignore method results", Justification = "", Scope = "member", Target = "~M:CoreEx.Events.Test.Unit.Publishing.FixedDestinationProviderTests.Name_SetNull_ShouldThrowArgumentNullException")] +[assembly: SuppressMessage("Performance", "CA1806:Do not ignore method results", Justification = "", Scope = "member", Target = "~M:CoreEx.Events.Test.Unit.Publishing.FixedDestinationProviderTests.Name_SetEmpty_ShouldThrowArgumentException")] diff --git a/tests/CoreEx.Events.Test.Unit/Publishing/EventPublisherBaseTests.cs b/tests/CoreEx.Events.Test.Unit/Publishing/EventPublisherBaseTests.cs new file mode 100644 index 00000000..5e231ba2 --- /dev/null +++ b/tests/CoreEx.Events.Test.Unit/Publishing/EventPublisherBaseTests.cs @@ -0,0 +1,176 @@ +using CloudNative.CloudEvents; +using CoreEx.Events.Publishing; + +namespace CoreEx.Events.Test.Unit.Publishing; + +public class EventPublisherBaseTests +{ + private class TestEventPublisher() : EventPublisherBase(new FixedDestinationProvider { Destination = "fixed" }, new EventFormatter() { PartitionKeyIsRequired = false }) + { + public DestinationEvent[]? PublishedEvents { get; private set; } + public CancellationToken? PublishedToken { get; private set; } + public int PublishCallCount { get; private set; } + + protected override Task OnPublishAsync(DestinationEvent[] events, CancellationToken cancellationToken = default) + { + PublishCallCount++; + PublishedEvents = events; + PublishedToken = cancellationToken; + return Task.CompletedTask; + } + } + + private TestEventPublisher _publisher = null!; + + [SetUp] + public void SetUp() => _publisher = new TestEventPublisher(); + + [Test] + public void IsEmpty_ShouldBeTrue_WhenNoEventsAdded() + { + _publisher.IsEmpty.Should().BeTrue(); + } + + [Test] + public void Add_EventData_ShouldQueueEvents() + { + var e1 = new EventData(); + var e2 = new EventData(); + _publisher.Add(e1, e2); + + _publisher.IsEmpty.Should().BeFalse(); + } + + [Test] + public void Add_EventData_ShouldIgnoreNulls() + { + var e1 = new EventData(); + _publisher.Add(e1, null!); + + _publisher.IsEmpty.Should().BeFalse(); + } + + [Test] + public void Add_WithDestination_EventData_ShouldQueueEvents() + { + var e1 = new EventData(); + var e2 = new EventData(); + _publisher.Add("my-dest", e1, e2); + + _publisher.IsEmpty.Should().BeFalse(); + } + + [Test] + public void Add_WithDestination_CloudEvent_ShouldQueueEvents() + { + var ce1 = new CloudEvent(); + var ce2 = new CloudEvent(); + _publisher.Add("my-dest", ce1, ce2); + + _publisher.IsEmpty.Should().BeFalse(); + } + + [Test] + public void Add_WithNullDestination_ShouldThrow() + { + var e1 = new EventData(); + Action act = () => _publisher.Add((string)null!, e1); + act.Should().Throw(); + } + + [Test] + public void Add_WithEmptyDestination_ShouldThrow() + { + var e1 = new EventData(); + Action act = () => _publisher.Add("", e1); + act.Should().Throw(); + } + + [Test] + public void Add_CloudEvent_WithNullDestination_ShouldThrow() + { + var ce1 = new CloudEvent(); + Action act = () => _publisher.Add(null!, ce1); + act.Should().Throw(); + } + + [Test] + public void Add_CloudEvent_WithEmptyDestination_ShouldThrow() + { + var ce1 = new CloudEvent(); + Action act = () => _publisher.Add("", ce1); + act.Should().Throw(); + } + + [Test] + public void Clear_ShouldClearQueue() + { + var e1 = new EventData(); + _publisher.Add(e1); + _publisher.IsEmpty.Should().BeFalse(); + + _publisher.Clear(); + _publisher.IsEmpty.Should().BeTrue(); + } + + [Test] + public async Task Rollback_ShouldRemoveSpecifiedCountOfEvents() + { + var e1 = new EventData { Id = "X" }; + var e2 = new EventData(); + var e3 = new EventData(); + + _publisher.Add(e1, e2, e3); + _publisher.Count.Should().Be(3); + + Action act = () => _publisher.Rollback(4); + act.Should().Throw(); + + _publisher.Rollback(2); + _publisher.Count.Should().Be(1); + + await _publisher.PublishAsync(); + + _publisher.PublishCallCount.Should().Be(1); + _publisher.PublishedEvents!.Length.Should().Be(1); + _publisher.PublishedEvents![0].Event.Id.Should().Be("X"); + + Action act2 = () => _publisher.Rollback(1); + act2.Should().Throw(); + } + + [Test] + public async Task Reset_ShouldResetHasPublished() + { + var e1 = new EventData(); + _publisher.Add(e1); + _publisher.HasBeenPublished.Should().BeFalse(); + + await _publisher.PublishAsync(); + _publisher.HasBeenPublished.Should().BeTrue(); + + _publisher.Reset(); + _publisher.HasBeenPublished.Should().BeFalse(); + } + + [Test] + public async Task PublishAsync_ShouldCallOnPublishAsync_AndReset() + { + var e1 = new EventData(); + _publisher.Add(e1); + + await _publisher.PublishAsync(); + + _publisher.PublishCallCount.Should().Be(1); + _publisher.PublishedEvents.Should().NotBeNull(); + _publisher.HasBeenPublished.Should().BeTrue(); + } + + [Test] + public async Task PublishAsync_WhenEmpty_ShouldNotCallOnPublishAsync() + { + await _publisher.PublishAsync(); + _publisher.PublishCallCount.Should().Be(0); + _publisher.HasBeenPublished.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Events.Test.Unit/Publishing/FixedDestinationProviderTests.cs b/tests/CoreEx.Events.Test.Unit/Publishing/FixedDestinationProviderTests.cs new file mode 100644 index 00000000..1f7190bb --- /dev/null +++ b/tests/CoreEx.Events.Test.Unit/Publishing/FixedDestinationProviderTests.cs @@ -0,0 +1,57 @@ +using CoreEx.Events.Publishing; + +namespace CoreEx.Events.Test.Unit.Publishing; + +[TestFixture] +public class FixedDestinationProviderTests +{ + [Test] + public void Name_SetAndGet_ShouldReturnValue() + { + var provider = new FixedDestinationProvider { Destination = "my-destination" }; + provider.Destination.Should().Be("my-destination"); + } + + [Test] + public void Name_SetNull_ShouldThrowArgumentNullException() + { + Action act = static () => new FixedDestinationProvider { Destination = null! }; + act.Should().Throw().WithParameterName("value"); + } + + [Test] + public void Name_SetEmpty_ShouldThrowArgumentException() + { + Action act = static () => new FixedDestinationProvider { Destination = "" }; + act.Should().Throw().WithParameterName("value"); + } + + [Test] + public void CreateFrom_EventData_ShouldReturnName() + { + var provider = new FixedDestinationProvider { Destination = "fixed-dest" }; + var eventData = new EventData(); + provider.CreateFrom(eventData).Should().Be("fixed-dest"); + } + + [Test] + public void CreateFrom_String_ShouldReturnName() + { + var provider = new FixedDestinationProvider { Destination = "fixed-dest" }; + provider.CreateFrom("any-dest").Should().Be("fixed-dest"); + } + + [Test] + public void CreateNew_DefaultParams_ShouldReturnName() + { + var provider = new FixedDestinationProvider { Destination = "fixed-dest" }; + provider.CreateNew().Should().Be("fixed-dest"); + } + + [Test] + public void CreateNew_WithParams_ShouldReturnName() + { + var provider = new FixedDestinationProvider { Destination = "fixed-dest" }; + provider.CreateNew(MessageType.Command, "domain", true).Should().Be("fixed-dest"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.RefData.Test.Unit/CoreEx.RefData.Test.Unit.csproj b/tests/CoreEx.RefData.Test.Unit/CoreEx.RefData.Test.Unit.csproj new file mode 100644 index 00000000..bdb529f5 --- /dev/null +++ b/tests/CoreEx.RefData.Test.Unit/CoreEx.RefData.Test.Unit.csproj @@ -0,0 +1,36 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataCollectionTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataCollectionTests.cs new file mode 100644 index 00000000..5a410516 --- /dev/null +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataCollectionTests.cs @@ -0,0 +1,206 @@ +using CoreEx.RefData.Abstractions; +using System.Runtime.CompilerServices; + +namespace CoreEx.RefData.Test.Unit; + +public class ReferenceDataCollectionTests +{ + private class TestRefData : ReferenceData { } + + [Test] + public void Constructor_Default() + { + var coll = new ReferenceDataCollection(); + coll.SortOrder.Should().Be(ReferenceDataSortOrder.SortOrder); + } + + [Test] + public void Constructor_WithSortOrderAndComparer() + { + var coll = new ReferenceDataCollection(ReferenceDataSortOrder.Code, StringComparer.Ordinal); + coll.SortOrder.Should().Be(ReferenceDataSortOrder.Code); + } + + [Test] + public void Add_And_Contains() + { + var coll = new ReferenceDataCollection(); + var item = new TestRefData { Id = "1", Code = "A" }; + coll.Add(item); + coll.Contains(item).Should().BeTrue(); + coll.Count.Should().Be(1); + } + + [Test] + public void AddRange_AddsAll() + { + var coll = new ReferenceDataCollection(); + var items = new[] { new TestRefData { Id = "1", Code = "A" }, new TestRefData { Id = "2", Code = "B" } }; + coll.AddRange(items); + coll.Count.Should().Be(2); + } + + [Test] + public void Clear_RemovesAll() + { + var coll = new ReferenceDataCollection + { + new() { Id = "1", Code = "A" } + }; + coll.Clear(); + coll.Count.Should().Be(0); + } + + [Test] + public void ContainsId_And_GetById() + { + var coll = new ReferenceDataCollection(); + var item = new TestRefData { Id = "1", Code = "A" }; + coll.Add(item); + coll.ContainsId("1").Should().BeTrue(); + coll.GetById("1").Should().Be(item); + } + + [Test] + public void ContainsCode_And_GetByCode() + { + var coll = new ReferenceDataCollection(); + var item = new TestRefData { Id = "1", Code = "A" }; + coll.Add(item); + coll.ContainsCode("A").Should().BeTrue(); + coll.GetByCode("A").Should().Be(item); + } + + [Test] + public void TryGetById_And_TryGetByCode() + { + var coll = new ReferenceDataCollection(); + var item = new TestRefData { Id = "1", Code = "A" }; + coll.Add(item); + coll.TryGetById("1", out var foundById).Should().BeTrue(); + foundById.Should().Be(item); + coll.TryGetByCode("A", out var foundByCode).Should().BeTrue(); + foundByCode.Should().Be(item); + } + + [Test] + public void GetEnumerator_Works() + { + var coll = new ReferenceDataCollection(); + var item = new TestRefData { Id = "1", Code = "A" }; + coll.Add(item); + foreach (var i in coll) + i.Should().Be(item); + } + + [Test] + public void ICollection_CopyTo_And_Remove() + { + var coll = new ReferenceDataCollection(); + var item = new TestRefData { Id = "1", Code = "A" }; + coll.Add(item); + var arr = new TestRefData[1]; + var act = () => ((ICollection)coll).CopyTo(arr, 0); + act.Should().Throw(); + } + + [Test] + public void GetItems_Sorting() + { + var items = new List + { + new() { Id = "1", Code = "D", Text = "GGG", SortOrder = 4 }, + new() { Id = "2", Code = "B", Text = "JJJ", SortOrder = 3 }, + new() { Id = "3", Code = "A", Text = "HHH", SortOrder = 2 }, + new() { Id = "4", Code = "C", Text = "III", SortOrder = 1 }, + new() { Id = "5", Code = "E", Text = "FFF", SortOrder = 5, IsInactive = true }, + new() { Id = "6", Code = "-", Text = "EEE", SortOrder = 6 } + }; + + ((IReferenceData)items.Last()).SetInvalid(); + + var coll = new ReferenceDataCollection(ReferenceDataSortOrder.SortOrder); + coll.AddRange(items); + + coll.GetItems().Select(x => x.Id).Should().BeEquivalentTo("5", "4", "3", "2", "1"); + + coll.GetItems(ReferenceDataSortOrder.SortOrder, true).Select(x => x.Id).Should().BeEquivalentTo("4", "3", "2", "1"); + coll.GetItems(ReferenceDataSortOrder.Id, true).Select(x => x.Id).Should().BeEquivalentTo("1", "2", "3", "4"); + coll.GetItems(ReferenceDataSortOrder.Code, true).Select(x => x.Code).Should().BeEquivalentTo("A", "B", "C", "D"); + coll.GetItems(ReferenceDataSortOrder.Text, true).Select(x => x.Code).Should().BeEquivalentTo("D", "A", "C", "B"); + + coll.GetItems(ReferenceDataSortOrder.Code, null).Select(x => x.Code).Should().BeEquivalentTo("A", "B", "C", "D", "E"); + coll.GetItems(ReferenceDataSortOrder.Code, false, null).Select(x => x.Code).Should().BeEquivalentTo("E", "-"); + coll.GetItems(ReferenceDataSortOrder.Code, false, false).Select(x => x.Code).Should().BeEquivalentTo("-"); + } + + [Test] + public async Task AddRangeAsync_IQueryable_AddsAll() + { + var items = new List + { + new() { Id = "1", Code = "A" }, + new() { Id = "2", Code = "B" } + }; + var col = new ReferenceDataCollection(); + await col.AddRangeAsync(items.AsQueryable()); + col.Count.Should().Be(2); + col.GetById("1")!.Code.Should().Be("A"); + col.GetById("2")!.Code.Should().Be("B"); + } + + [Test] + public async Task AddRangeAsync_IAsyncEnumerable_AddsAll() + { + static async IAsyncEnumerable GetItems() + { + yield return new TestRefData { Id = "3", Code = "C" }; + await Task.Delay(1); + yield return new TestRefData { Id = "4", Code = "D" }; + } + + var col = new ReferenceDataCollection(); + await col.AddRangeAsync(GetItems()); + col.Count.Should().Be(2); + col.GetById("3")!.Code.Should().Be("C"); + col.GetById("4")!.Code.Should().Be("D"); + } + + [Test] + public async Task AddRangeAsync_IAsyncEnumerable_Null_DoesNothing() + { + var col = new ReferenceDataCollection(); + await col.AddRangeAsync((IAsyncEnumerable?)null); + col.Count.Should().Be(0); + } + + [Test] + public async Task AddRangeAsync_IQueryable_CancellationToken() + { + var items = new List + { + new() { Id = "5", Code = "E" } + }; + var col = new ReferenceDataCollection(); + using var cts = new CancellationTokenSource(); + await col.AddRangeAsync(items.AsQueryable(), cts.Token); + col.Count.Should().Be(1); + col.GetById("5")!.Code.Should().Be("E"); + } + + [Test] + public async Task AddRangeAsync_IAsyncEnumerable_CancellationToken() + { + static async IAsyncEnumerable GetItems([EnumeratorCancellation] CancellationToken ct = default) + { + yield return new TestRefData { Id = "6", Code = "F" }; + await Task.Delay(1, ct); + } + + var col = new ReferenceDataCollection(); + using var cts = new CancellationTokenSource(); + await col.AddRangeAsync(GetItems(cts.Token), cts.Token); + col.Count.Should().Be(1); + col.GetById("6")!.Code.Should().Be("F"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorTests.cs new file mode 100644 index 00000000..73f35675 --- /dev/null +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorTests.cs @@ -0,0 +1,423 @@ +using CoreEx.Entities; +using CoreEx.Json; +using CoreEx.RefData.Abstractions; +using CoreEx.Results; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace CoreEx.RefData.Test.Unit; + +public partial class ReferenceDataOrchestratorTests +{ + [ReferenceData] + internal partial class DummyRefData : ReferenceData { } + + [ReferenceData] + internal partial class DummyRefData2 : ReferenceData + { + [ReferenceData] + public partial string? ParentSid { get; set; } + + [ReferenceData] + public partial string? InitParentSid { get; init; } + + [ReferenceData] + public partial string? ReadOnlyParentSid { get; } + } + + internal class InvalidRefData : ReferenceData { } + + internal class DummyRefDataCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code, StringComparer.OrdinalIgnoreCase) { } + + internal class DummyRefData2Collection() : ReferenceDataCollection() { } + + private class DummyProvider : IReferenceDataProvider + { + public IEnumerable<(Type, Type)> Types => + [ + (typeof(DummyRefData), typeof(DummyRefDataCollection)), + (typeof(DummyRefData2), typeof(DummyRefData2Collection)) + ]; + + public Task GetAsync(Type type, CancellationToken cancellationToken = default) + => type == typeof(DummyRefData) + ? + Task.FromResult(new DummyRefDataCollection + { + new DummyRefData { Id = 1, Code = "A", Text = "Alpha" }, + new DummyRefData { Id = 2, Code = "C", Text = "Charlie" }, + new DummyRefData { Id = 3, Code = "B", Text = "Beta" }, + new DummyRefData { Id = 4, Code = "D", Text = "Delta", IsInactive = true } + }) + : + Task.FromResult(new DummyRefData2Collection + { + new DummyRefData2 { Id = "A-1", Code = "A", Text = "Alpha", ParentSid = "P" }, + new DummyRefData2 { Id = "C-3", Code = "C", Text = "Charlie" } + }); + } + + [Contract] + internal partial class DummyEntity + { + [ReferenceData] + public partial string? RefDataSid { get; set; } + } + + private static ReferenceDataOrchestrator CreateOrchestrator() + { + var sc = new ServiceCollection(); + sc.AddExecutionContext(sp => new ExecutionContext { ServiceProvider = sp }); + sc.AddSingleton(new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache())); + sc.AddScoped(); + var sp = sc.BuildServiceProvider(); + + var logger = Mock.Of>(); + var ro = new ReferenceDataOrchestrator(sp, logger); + ro.Register(); + return ro; + } + + [SetUp] + public void SetUp() + { + var orch = CreateOrchestrator(); + ReferenceDataOrchestrator.SetCurrent(orch); + ReferenceDataOrchestrator.HasCurrent.Should().BeTrue(); + + _ = orch.ServiceProvider.GetRequiredService(); + } + + [TearDown] + public void TearDown() + { + ReferenceDataOrchestrator.SetCurrent(null); + ReferenceDataOrchestrator.HasCurrent.Should().BeFalse(); + } + + [Test] + public void PrefetchMaxDegreeOfParallelism_GetSet() + { + var orch = CreateOrchestrator(); + orch.PrefetchMaxDegreeOfParallelism = 5; + orch.PrefetchMaxDegreeOfParallelism.Should().Be(5); + } + + [Test] + public void Register_And_ContainsType_ContainsName() + { + var orch = CreateOrchestrator(); + var type = typeof(DummyRefData); + orch.ContainsType().Should().BeTrue(); + orch.ContainsType(type).Should().BeTrue(); + orch.ContainsName(nameof(DummyRefData)).Should().BeTrue(); + + type = typeof(InvalidRefData); + orch.ContainsType().Should().BeFalse(); + orch.ContainsType(type).Should().BeFalse(); + orch.ContainsName(nameof(InvalidRefData)).Should().BeFalse(); + } + + [Test] + public void GetAllTypes_ReturnsRegisteredTypes() + { + var orch = CreateOrchestrator(); + var types = orch.GetAllTypes(); + types.Should().HaveCount(2); + } + + [Test] + public void Indexer_ByType_And_ByName() + { + var orch = CreateOrchestrator(); + var byType = orch[typeof(DummyRefData)]; + var byName = orch[nameof(DummyRefData)]; + byType.Should().NotBeNull(); + byName.Should().NotBeNull(); + } + + [Test] + public void GetByType_And_GetByTypeRequired() + { + var orch = CreateOrchestrator(); + var coll = orch.GetByType(); + coll.Should().NotBeNull(); + var req = orch.GetByTypeRequired(); + req.Should().NotBeNull(); + } + + [Test] + public void GetByName_And_GetByNameRequired() + { + var orch = CreateOrchestrator(); + var coll = orch.GetByName(nameof(DummyRefData)); + coll.Should().NotBeNull(); + var req = orch.GetByNameRequired(nameof(DummyRefData)); + req.Should().NotBeNull(); + } + + [Test] + public void GetAllTypesInNamespace_ReturnsTypes() + { + var types = ReferenceDataOrchestrator.GetAllTypesInNamespace(); + types.Should().Contain(typeof(DummyRefData)); + + types = ReferenceDataOrchestrator.GetAllTypesInNamespace(); + types.Should().BeEmpty(); + } + + [Test] + public void TryGetByCode_ReturnsValidWhenFound() + { + ReferenceDataOrchestrator.TryGetByCode("A", out var item).Should().BeTrue(); + item.Should().NotBeNull(); + item.Id.Should().Be(1); + item.Code.Should().Be("A"); + item.IsActive.Should().BeTrue(); + ((IReferenceData)item).IsValid.Should().BeTrue(); + } + + [Test] + public void TryGetByCode_ReturnsInvalidWhenNotFound() + { + ReferenceDataOrchestrator.TryGetByCode("X", out var item).Should().BeFalse(); + item.Should().NotBeNull(); + item.Id.Should().Be(0); + item.Code.Should().Be("X"); + item.IsActive.Should().BeFalse(); + ((IReferenceData)item).IsValid.Should().BeFalse(); + } + + [Test] + public void TryGetById_ReturnsValidWhenFound() + { + ReferenceDataOrchestrator.TryGetById(1, out var item).Should().BeTrue(); + item.Should().NotBeNull(); + item.Id.Should().Be(1); + item.Code.Should().Be("A"); + item.IsActive.Should().BeTrue(); + ((IReferenceData)item).IsValid.Should().BeTrue(); + } + + [Test] + public void TryGetById_ReturnsInvalidWhenNotFound() + { + ReferenceDataOrchestrator.TryGetById(404, out var item).Should().BeFalse(); + item.Should().NotBeNull(); + item.Id.Should().Be(404); + item.Code.Should().BeNull(); + item.IsActive.Should().BeFalse(); + ((IReferenceData)item).IsValid.Should().BeFalse(); + } + + [Test] + public void TryGetById_Generic_ReturnsValidWhenFound() + { + ReferenceDataOrchestrator.TryGetById(1, out var item).Should().BeTrue(); + item.Should().NotBeNull(); + item.Id.Should().Be(1); + item.Code.Should().Be("A"); + item.IsActive.Should().BeTrue(); + ((IReferenceData)item).IsValid.Should().BeTrue(); + } + + [Test] + public void TryGetById_Generic_ReturnsInvalidWhenNotFound() + { + ReferenceDataOrchestrator.TryGetById(404, out var item).Should().BeFalse(); + item.Should().NotBeNull(); + item.Id.Should().Be(404); + item.Code.Should().BeNull(); + item.IsActive.Should().BeFalse(); + ((IReferenceData)item).IsValid.Should().BeFalse(); + } + + [Test] + public async Task GetWithFilterAsync_All() + { + var coll = await ReferenceDataOrchestrator.Current.GetWithFilterAsync(); + coll.Should().NotBeNull(); + coll.Count().Should().Be(3); + } + + [Test] + public async Task GetWithFilterAsync_Codes() + { + var coll = await ReferenceDataOrchestrator.Current.GetWithFilterAsync(["A","C","Z"]); + coll.Should().NotBeNull(); + coll.Count().Should().Be(2); + coll.Select(x => x.Code).Should().BeEquivalentTo(["A", "C"]); + } + + [Test] + public async Task GetWithFilterAsync_Text_Wildcard() + { + var coll = await ReferenceDataOrchestrator.Current.GetWithFilterAsync(null, "*a"); + coll.Should().NotBeNull(); + coll.Count().Should().Be(2); + coll.Select(x => x.Code).Should().BeEquivalentTo(["A", "B"]); + } + + [Test] + public async Task GetWithFilterAsync_Text_Wildcard_And_Inactive() + { + var coll = await ReferenceDataOrchestrator.Current.GetWithFilterAsync(null, "*a", true); + coll.Should().NotBeNull(); + coll.Count().Should().Be(3); + coll.Select(x => x.Code).Should().BeEquivalentTo(["A", "B", "D"]); + } + + // Entity source generation tests. + + [Test] + public void Entity_ReferenceData_Property() + { + var entity = new DummyEntity { RefDataSid = "A" }; + entity.RefDataSid.Should().Be("A"); + entity.RefData.Should().NotBeNull(); + entity.RefData.Code.Should().Be("A"); + entity.RefData.Id.Should().Be(1); + + entity.RefDataSid = DummyRefData.TryGetByCode("C", out var item) ? item : item; + entity.RefDataSid.Should().Be("C"); + + entity.RefDataSid = DummyRefData.TryGetByCode("Z", out item) ? item : item; + entity.RefDataSid.Should().Be("Z"); + entity.RefData.Should().NotBeNull(); + entity.RefData.Code.Should().Be("Z"); + entity.RefData.Id.Should().Be(0); + entity.RefData.IsActive.Should().BeFalse(); + ((IReferenceData)entity.RefData).IsValid.Should().BeFalse(); + + entity.RefDataSid = null; + entity.RefDataSid.Should().BeNull(); + entity.RefData.Should().BeNull(); + } + + [Test] + public void Entity_ReferenceData_Text() + { + var entity = new DummyEntity { RefDataSid = "A" }; + entity.RefDataText.Should().BeNull(); + + ExecutionContext.Current.IncludeRelatedText = true; + entity.RefDataText.Should().Be("Alpha"); + } + + // Explicit casting magic! + + [Test] + public void Explicit_Cast_From_Id() + { + var rd = (DummyRefData)1; + rd.Id.Should().Be(1); + rd.Code.Should().Be("A"); + rd.IsActive.Should().BeTrue(); + + rd = (DummyRefData)404; + rd.Id.Should().Be(404); + rd.Code.Should().BeNull(); + rd.IsActive.Should().BeFalse(); + ((IReferenceData)rd).IsValid.Should().BeFalse(); + } + + [Test] + public void Explict_Cast_From_Code() + { + var rd = (DummyRefData)"A"; + rd.Id.Should().Be(1); + rd.Code.Should().Be("A"); + rd.IsActive.Should().BeTrue(); + + rd = (DummyRefData)"G"; + rd.Id.Should().Be(0); + rd.Code.Should().Be("G"); + rd.IsActive.Should().BeFalse(); + ((IReferenceData)rd).IsValid.Should().BeFalse(); + } + + [Test] + public void Implicit_Cast_From_Code() + { + DummyRefData? rd = "A"; + rd.Id.Should().Be(1); + rd.Code.Should().Be("A"); + rd.IsActive.Should().BeTrue(); + + rd = "G"; + rd.Id.Should().Be(0); + rd.Code.Should().Be("G"); + rd.IsActive.Should().BeFalse(); + ((IReferenceData)rd).IsValid.Should().BeFalse(); + + rd = (string?)null; + rd.Should().BeNull(); + } + + [Test] + public void Explicit_Cast_From_Id_String() + { + var rd = (DummyRefData2)"A"; + rd.Id.Should().Be("A-1"); + rd.Code.Should().Be("A"); + rd.IsActive.Should().BeTrue(); + + rd = (DummyRefData2)"ABC"; + rd.Id.Should().BeNull(); + rd.Code.Should().Be("ABC"); + rd.IsActive.Should().BeFalse(); + ((IReferenceData)rd).IsValid.Should().BeFalse(); + } + + + [Test] + public void ReferenceDataBase_TryGetBy() + { + DummyRefData.TryGetById(1, out var rd).Should().BeTrue(); + rd.Id.Should().Be(1); + rd.Code.Should().Be("A"); + + DummyRefData.TryGetByCode("A", out rd).Should().BeTrue(); + rd.Id.Should().Be(1); + rd.Code.Should().Be("A"); + + DummyRefData2.TryGetById("C-3", out var rd2).Should().BeTrue(); + rd2.Id.Should().Be("C-3"); + rd2.Code.Should().Be("C"); + + DummyRefData2.TryGetByCode("C", out rd2).Should().BeTrue(); + rd2.Id.Should().Be("C-3"); + rd2.Code.Should().Be("C"); + } + + [Test] + public void ReferenceDataCollection_Serialization_RoundTrip() + { + var orch = CreateOrchestrator(); + var coll = orch[typeof(DummyRefData)]; + + var json = System.Text.Json.JsonSerializer.Serialize((DummyRefDataCollection)coll!, JsonDefaults.SerializerOptions); + + var deserialColl = System.Text.Json.JsonSerializer.Deserialize(json, JsonDefaults.SerializerOptions); + var json2 = System.Text.Json.JsonSerializer.Serialize(deserialColl, JsonDefaults.SerializerOptions); + + json.Should().Be(json2); + } + + [Test] + public void ReferenceDataCollection_Serialization_RoundTrip2() + { + var orch = CreateOrchestrator(); + var coll = orch[typeof(DummyRefData2)]; + + var json = System.Text.Json.JsonSerializer.Serialize(coll!, JsonDefaults.SerializerOptions); + + var deserialColl = System.Text.Json.JsonSerializer.Deserialize(json, JsonDefaults.SerializerOptions); + var json2 = System.Text.Json.JsonSerializer.Serialize(deserialColl, JsonDefaults.SerializerOptions); + + json.Should().Be(json2); + + deserialColl!.Should().Contain(x => x.ParentSid == "P"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataTests.cs new file mode 100644 index 00000000..bb8081da --- /dev/null +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataTests.cs @@ -0,0 +1,152 @@ +using CoreEx.RefData.Abstractions; + +namespace CoreEx.RefData.Test.Unit; + +public class ReferenceDataTests +{ + private class TestRefData : ReferenceData { } + + [Test] + public void Id_Code_Text_Description_SortOrder_ETag() + { + var rd = new TestRefData + { + Id = 123, + Code = "ABC", + Text = "MyText", + Description = "MyDesc", + SortOrder = 5, + ETag = "etag" + }; + + rd.Id.Should().Be(123); + rd.Code.Should().Be("ABC"); + rd.GetText().Should().Be("MyText"); + rd.GetDescription().Should().Be("MyDesc"); + rd.SortOrder.Should().Be(5); + rd.ETag.Should().Be("etag"); + rd.Text.Should().Contain("MyText"); + rd.Description.Should().Contain("MyDesc"); + } + + [Test] + public void ToString_ReturnsTextOrCodeOrId() + { + var rd = new TestRefData { Text = "T", Code = "C", Id = 1 }; + rd.ToString().Should().Contain("T"); + + rd = new TestRefData { Text = null, Code = "C", Id = 1 }; + rd.ToString().Should().Contain("C"); + + rd = new TestRefData { Text = null, Code = null, Id = 1 }; + rd.ToString().Should().Contain("1"); + } + + [Test] + public void IsActive_Default_True() + { + var rd = new TestRefData(); + rd.IsActive.Should().BeTrue(); + } + + [Test] + public void IsActive_SetFalse() + { + var rd = new TestRefData { IsInactive = true }; + rd.IsActive.Should().BeFalse(); + } + + [Test] + public void IsActive_StartDate_EndDate() + { + var now = DateTimeOffset.UtcNow; + var rd = new TestRefData { StartsOn = now.AddDays(-1), EndsOn = now.AddDays(1) }; + rd.IsActive.Should().BeTrue(); + + rd = new TestRefData { StartsOn = now.AddDays(1), EndsOn = now.AddDays(2) }; + rd.IsActive.Should().BeFalse(); + + rd = new TestRefData { StartsOn = now.AddDays(-2), EndsOn = now.AddDays(-1) }; + rd.IsActive.Should().BeFalse(); + } + + [Test] + public void SetInvalid_SetsIsValidFalse_AndIsActiveFalse() + { + var rd = new TestRefData(); + ((IReferenceData)rd).SetInvalid(); + ((IReferenceData)rd).IsValid.Should().BeFalse(); + rd.IsActive.Should().BeFalse(); + } + + [Test] + public void HasMappings_And_Mappings() + { + var rd = new TestRefData(); + rd.HasMappings.Should().BeFalse(); + rd.Mappings.Should().BeNull(); + + rd.SetMapping("x", 123); + rd.HasMappings.Should().BeTrue(); + rd.Mappings.Should().NotBeNull(); + rd.Mappings.Should().ContainKey("x"); + rd.Mappings["x"].Should().Be(123); + } + + [Test] + public void TryGetMapping_FoundAndNotFound() + { + var rd = new TestRefData(); + rd.SetMapping("a", 42); + rd.TryGetMapping("a", out var v).Should().BeTrue(); + v.Should().Be(42); + + rd.TryGetMapping("b", out var v2).Should().BeFalse(); + v2.Should().Be(0); + } + + [Test] + public void Implicit_Cast_To_Id() + { + var rd = new TestRefData { Id = 88 }; + int id = rd; + id.Should().Be(88); + + int? id2 = rd; + id2.Should().Be(88); + + TestRefData? rd2 = null; + id = rd2; + id.Should().Be(0); + + id2 = rd2; + id2.Should().Be(0); + } + + [Test] + public void Implicit_Cast_To_Code() + { + var rd = new TestRefData { Code = "XYZ" }; + string code = rd; + code.Should().Be("XYZ"); + + string? code2 = rd; + code2.Should().Be("XYZ"); + + TestRefData? rd2 = null; + code = rd2; + code.Should().BeNull(); + + code2 = rd2; + code2.Should().BeNull(); + } + + [Test] + public void IComparable() + { + var rd = new TestRefData { Code = "XYZ" }; + var rd2 = new TestRefData { Code = "ABC" }; + + rd.CompareTo(rd2).Should().BeGreaterThan(0); + } +} \ No newline at end of file diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataValidationTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataValidationTests.cs new file mode 100644 index 00000000..7c6ce902 --- /dev/null +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataValidationTests.cs @@ -0,0 +1,99 @@ +using CoreEx.Entities; +using CoreEx.Validation; + +namespace CoreEx.RefData.Test.Unit; + +[TestFixture] +public partial class ReferenceDataOrchestratorTests +{ + [Test] + public async Task Validation() + { + var vr = await ((DummyRefData?)null).Validator(c => c.IsValid()).ValidateAsync(); + vr.HasErrors.Should().BeFalse(); + + vr = await ((DummyRefData)"A").Validator(c => c.IsValid()).ValidateAsync(); + vr.HasErrors.Should().BeFalse(); + + vr = await ((DummyRefData)"Z").Validator(c => c.IsValid()).ValidateAsync(); + vr.HasErrors.Should().BeTrue(); + vr.Messages.Should().ContainSingle().Which.Text.ToString().Should().EndWith("is invalid."); + + vr = await ((DummyRefData)"D").Validator(c => c.IsValid()).ValidateAsync(); + vr.HasErrors.Should().BeTrue(); + vr.Messages.Should().ContainSingle().Which.Text.ToString().Should().EndWith("is invalid."); + + vr = await ((DummyRefData)"D").Validator(c => c.IsValid(allowInactive: true)).ValidateAsync(); + vr.HasErrors.Should().BeFalse(); + } + + [Test] + public async Task Validation_Code() + { + var vr = await ((string?)null).Validator(c => c.ReferenceData(r => r.With())).ValidateAsync(); + vr.HasErrors.Should().BeFalse(); + + vr = await "A".Validator(c => c.ReferenceData(r => r.With())).ValidateAsync(); + vr.HasErrors.Should().BeFalse(); + + vr = await "Z".Validator(c => c.ReferenceData(r => r.With())).ValidateAsync(); + vr.HasErrors.Should().BeTrue(); + vr.Messages.Should().ContainSingle().Which.Text.ToString().Should().EndWith("is invalid."); + + vr = await "D".Validator(c => c.ReferenceData(r => r.With())).ValidateAsync(); + vr.HasErrors.Should().BeTrue(); + vr.Messages.Should().ContainSingle().Which.Text.ToString().Should().EndWith("is invalid."); + + vr = await "D".Validator(c => c.ReferenceData(r => r.With().AllowInactive())).ValidateAsync(); + vr.HasErrors.Should().BeFalse(); + } + + [Test] + public async Task Validation_Override() + { + var e = new Entity { Code = "a" }; + + var ev = Validator.Create().HasProperty(p => p.Code, p => p.ReferenceData(r => r.With())); + var vr = await ev.ValidateAsync(e); + vr.HasErrors.Should().BeFalse(); + e.Code.Should().Be("a"); + + ev = Validator.Create().HasProperty(p => p.Code, p => p.ReferenceData(r => r.With().Override())); + vr = await ev.ValidateAsync(e); + vr.HasErrors.Should().BeFalse(); + e.Code.Should().Be("A"); + } + + [Test] + public async Task Validation_CodeCollection() + { + var e = new Entity { DummySids = ["A", "B", "C"] }; + + var ev = Validator.Create().HasProperty(p => p.Dummies, p => p.AreValid()); + var vr = await ev.ValidateAsync(e); + vr.HasErrors.Should().BeFalse(); + + e.DummySids.Add("D"); + vr = await ev.ValidateAsync(e); + vr.HasErrors.Should().BeTrue(); + vr.Messages.Should().ContainSingle().Which.Text.ToString().Should().EndWith("contains one or more invalid items."); + + ev = Validator.Create().HasProperty(p => p.Dummies, p => p.AreValid(allowInactive: true)); + vr = await ev.ValidateAsync(e); + vr.HasErrors.Should().BeFalse(); + + e.DummySids.Add("Z"); + vr = await ev.ValidateAsync(e); + vr.HasErrors.Should().BeTrue(); + vr.Messages.Should().ContainSingle().Which.Text.ToString().Should().EndWith("contains one or more invalid items."); + } + + [Contract] + internal partial class Entity + { + public string? Code { get; set; } + + [ReferenceDataCodeCollection] + public partial List? DummySids { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj b/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj deleted file mode 100644 index e810a7dc..00000000 --- a/tests/CoreEx.Solace.Test/CoreEx.Solace.Test.csproj +++ /dev/null @@ -1,40 +0,0 @@ - - - - net8.0 - enable - enable - false - true - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - PreserveNewest - Never - - - - diff --git a/tests/CoreEx.Solace.Test/PubSubOrchestratedTest.cs b/tests/CoreEx.Solace.Test/PubSubOrchestratedTest.cs deleted file mode 100644 index f0363282..00000000 --- a/tests/CoreEx.Solace.Test/PubSubOrchestratedTest.cs +++ /dev/null @@ -1,107 +0,0 @@ - -using Microsoft.Extensions.Configuration; -using SolaceSystems.Solclient.Messaging; - -namespace CoreEx.Test.TestFunction -{ - [TestFixture] - [Category("WithSolace")] - public class PubSubOrchestratedTest - { - // NOTE: PubSub local instance must be running in container for test to execute - private static IContext? _solaceContext; - - [Test] - public void PubSubSend_Success() - { - // Arrange - PubSubSender sender = SetupSender(); - var events = new List - { - new EventSendData { Id = "123", Subject = "my.Product", Data = new BinaryData(Encoding.UTF8.GetBytes("Test Message1")), Destination = "try-me" }, - new EventSendData { Id = "124", Subject = "my.Product", Data = new BinaryData(Encoding.UTF8.GetBytes("Test Message2")), Destination = "try-me" } - }; - - // Act - sender.SendAsync(events).Wait(); - } - - [Test] - public void PubSubSend_GreaterThan50Events_OnlySendsBatchOf50() - { - // Arrange - PubSubSender sender = SetupSender(); - var events = new List(); - //var dataPayload1 = new BinaryData(Encoding.UTF8.GetBytes("Test Message1")); - //var dataPayload2 = new BinaryData(Encoding.UTF8.GetBytes("Test Message2")); - // build 51 events - for (int i = 200; i < 251; i++) - { - events.Add(new EventSendData { Id = i.ToString(), Subject = "my.Product", Data = new BinaryData(Encoding.UTF8.GetBytes($"Test Message{i}")), Destination = "try-me" }); - } - - // Act - sender.SendAsync(events).Wait(); - } - - [Test] - public void PubSubSend_NoDestination_SendstoDefaultTopic() - { - // Arrange - PubSubSender sender = SetupSender(); - var events = new List - { - new EventSendData { Id = "123", Subject = "my.Product", Data = new BinaryData(Encoding.UTF8.GetBytes("Test Message1")) }, - new EventSendData { Id = "124", Subject = "my.Product", Data = new BinaryData(Encoding.UTF8.GetBytes("Test Message2")) } - }; - - // Act - sender.SendAsync(events).Wait(); - } - - [OneTimeTearDown] - public void OneTimeTearDown() - { - _solaceContext?.Dispose(); - } - - private static PubSubSender SetupSender() - { - var logger = GetLogger(); - var convertor = new EventSendDataToPubSubConverter(); - var sessionProperties = new SessionProperties - { - Host = "ws://localhost:8008", - VPNName = "default", - UserName = "default", - Password = "default" - }; - - var config = new ConfigurationBuilder().SetBasePath(Environment.CurrentDirectory).AddJsonFile("appsettings.unittest.json"); - var testSettings = new DefaultSettings(config.Build()); - - if (_solaceContext == null) - { - var cfp = new ContextFactoryProperties { SolClientLogLevel = SolLogLevel.Warning }; - cfp.LogToConsoleError(); - ContextFactory.Instance.Init(cfp); - _solaceContext = ContextFactory.Instance.CreateContext(new ContextProperties(), null); - } - - var sender = new PubSubSender(_solaceContext, sessionProperties, testSettings, logger, null, convertor); - return sender; - } - - /// - /// Gets a console . - /// - /// The logger . - /// The . - private static ILogger GetLogger() => LoggerFactory.Create(b => - { - b.SetMinimumLevel(LogLevel.Trace); - b.ClearProviders(); - b.AddConsole(); - }).CreateLogger(); - } -} \ No newline at end of file diff --git a/tests/CoreEx.Solace.Test/Usings.cs b/tests/CoreEx.Solace.Test/Usings.cs deleted file mode 100644 index d20c0b4f..00000000 --- a/tests/CoreEx.Solace.Test/Usings.cs +++ /dev/null @@ -1,8 +0,0 @@ -global using NUnit.Framework; -global using CoreEx.Events; -global using CoreEx.Solace.PubSub; -global using System.Text; -global using Microsoft.Extensions.Logging; -global using CoreEx.Configuration; -global using SolaceSystems.Solclient.Messaging; -global using Microsoft.Extensions.Configuration; \ No newline at end of file diff --git a/tests/CoreEx.Solace.Test/appsettings.unittest.json b/tests/CoreEx.Solace.Test/appsettings.unittest.json deleted file mode 100644 index 00b68eaa..00000000 --- a/tests/CoreEx.Solace.Test/appsettings.unittest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "TestQueueName": "unit-test-queue", - "TestTopicName": "unit-test-topic", - "TestSubscriptionName": "unit-test-subscription", - "TestQueueNameSessions": "unit-test-queue-sessions", - "PubSubSender": { - "QueueOrTopicName": "ntangle-stream" // Sets the topic for the pubsub sender. - } -} diff --git a/tests/CoreEx.Test.Unit/Abstractions/ExtensionTests.cs b/tests/CoreEx.Test.Unit/Abstractions/ExtensionTests.cs new file mode 100644 index 00000000..2a432f90 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Abstractions/ExtensionTests.cs @@ -0,0 +1,48 @@ +namespace CoreEx.Test.Unit.Abstractions; + +public class ExtenstionTests +{ + [Test] + public void ThrowIfNull_WhenNull() + { + string aussie = null!; + var act = () => aussie.ThrowIfNull(); + act.Should().Throw().WithParameterName("aussie"); + } + + [Test] + public void ThrowIfNull_WhenNotNull() + { + string aussie = "Aussie"; + aussie.ThrowIfNull().Should().Be("Aussie"); + } + + [Test] + public void Adjust_Value_NonNullable() + { + var p = new Person(); + var p2 = p.Adjust(x => x.Name = "Babs"); + p.Name.Should().Be("Babs"); + p2.Name.Should().Be("Babs"); + } + + [Test] + public void Adjust_Value_Nullable() + { + Person? p = null; + p.Adjust(x => x.Name = "Babs"); + p.Should().BeNull(); + } + + [Test] + public void Adjust_Value_Nullable_With_Value() + { + Person? p = new(); + var p2 = p.Adjust(x => x.Name = "Babs"); + p.Should().NotBeNull(); + p.Name.Should().Be("Babs"); + p2.Name.Should().Be("Babs"); + } + + public class Person { public string? Name { get; set; } } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Abstractions/InternalTests.cs b/tests/CoreEx.Test.Unit/Abstractions/InternalTests.cs new file mode 100644 index 00000000..14e216fe --- /dev/null +++ b/tests/CoreEx.Test.Unit/Abstractions/InternalTests.cs @@ -0,0 +1,45 @@ +using CoreEx.Abstractions; +using Microsoft.Extensions.Configuration; + +namespace CoreEx.Test.Unit.Abstractions; + +internal class InternalTests +{ + [Test] + public void TryGetConfigurationValue_Nullable() + { + var config = new ConfigurationBuilder().Build(); + var exists = Internal.TryGetConfigurationValue("MySetting", out var value, config); + exists.Should().BeFalse(); + value.Should().BeNull(); + + config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { { "MySetting", null} }).Build(); + exists = Internal.TryGetConfigurationValue("MySetting", out value, config); + exists.Should().BeFalse(); + value.Should().BeNull(); + + config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { { "MySetting", "0" } }).Build(); + exists = Internal.TryGetConfigurationValue("MySetting", out value, config); + exists.Should().BeTrue(); + value.Should().Be(0); + } + + [Test] + public void TryGetConfigurationValue_NonNullable() + { + var config = new ConfigurationBuilder().Build(); + var exists = Internal.TryGetConfigurationValue("MySetting", out var value, config); + exists.Should().BeFalse(); + value.Should().Be(0); + + config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { { "MySetting", null } }).Build(); + exists = Internal.TryGetConfigurationValue("MySetting", out value, config); + exists.Should().BeFalse(); + value.Should().Be(0); + + config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { { "MySetting", "0" } }).Build(); + exists = Internal.TryGetConfigurationValue("MySetting", out value, config); + exists.Should().BeTrue(); + value.Should().Be(0); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj b/tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj new file mode 100644 index 00000000..7272a854 --- /dev/null +++ b/tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj @@ -0,0 +1,35 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CoreEx.Test.Unit/Data/PagingArgsTests.cs b/tests/CoreEx.Test.Unit/Data/PagingArgsTests.cs new file mode 100644 index 00000000..a4c887e8 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Data/PagingArgsTests.cs @@ -0,0 +1,102 @@ +using CoreEx.Data; + +namespace CoreEx.Test.Unit.Data; + +[TestFixture] +public class PagingArgsTests +{ + [TearDown] + public void TearDown() + { + // Reset static properties to defaults before each test to avoid side effects. + PagingArgs.DefaultTake = 25; + PagingArgs.MaximumTake = 1000; + } + + [Test] + public void Constructor_Defaults() + { + var args = new PagingArgs(); + args.Skip.Should().Be(0); + args.Take.Should().Be(25); + args.IsCountRequested.Should().BeFalse(); + args.IsNone.Should().BeFalse(); + } + + [Test] + public void Constructor_WithValues() + { + var args = new PagingArgs(5, 10, true); + args.Skip.Should().Be(5); + args.Take.Should().Be(10); + args.IsCountRequested.Should().BeTrue(); + } + + [Test] + public void Constructor_Take_Null_UsesDefault() + { + var args = new PagingArgs(2, null, false); + args.Take.Should().Be(25); + } + + [Test] + public void Skip_Negative_SetsToZero() + { + var args = new PagingArgs(-10, 10, false); + args.Skip.Should().Be(0); + } + + [Test] + public void Take_ZeroOrNegative_SetsToDefaultTake() + { + var args1 = new PagingArgs(0, 0, false); + args1.Take.Should().Be(25); + + var args2 = new PagingArgs(0, -5, false); + args2.Take.Should().Be(25); + } + + [Test] + public void Take_AboveMaximum_SetsToMaximumTake() + { + PagingArgs.MaximumTake = 50; + var args = new PagingArgs(0, 100, false); + args.Take.Should().Be(50); + } + + [Test] + public void Default_StaticProperty() + { + var def = PagingArgs.Create(); + def.Skip.Should().Be(0); + def.Take.Should().Be(25); + def.IsCountRequested.Should().BeFalse(); + def.IsNone.Should().BeFalse(); + } + + [Test] + public void None_StaticProperty() + { + var none = PagingArgs.None; + none.Skip.Should().Be(0); + none.Take.Should().Be(25); + none.IsCountRequested.Should().BeFalse(); + none.IsNone.Should().BeTrue(); + } + + [Test] + public void DefaultTake_StaticProperty() + { + PagingArgs.DefaultTake = 77; + var args = new PagingArgs(); + args.Take.Should().Be(77); + } + + [Test] + public void MaximumTake_StaticProperty() + { + PagingArgs.MaximumTake = 10; + var args = new PagingArgs(0, 100, false); + args.Take.Should().Be(10); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Data/PagingResultTests.cs b/tests/CoreEx.Test.Unit/Data/PagingResultTests.cs new file mode 100644 index 00000000..1a906012 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Data/PagingResultTests.cs @@ -0,0 +1,66 @@ +using CoreEx.Data; + +namespace CoreEx.Test.Unit.Data; + +[TestFixture] +public class PagingResultTests +{ + [TearDown] + public void TearDown() + { + PagingArgs.DefaultTake = 25; + } + + [Test] + public void Constructor_Default() + { + var pr = new PagingResult(); + pr.Skip.Should().Be(0); + pr.Take.Should().Be(25); + pr.IsCountRequested.Should().BeFalse(); + pr.TotalCount.Should().BeNull(); + } + + [Test] + public void Constructor_WithPagingArgs() + { + var args = new PagingArgs(3, 7, true); + var pr = new PagingResult(args); + pr.Skip.Should().Be(3); + pr.Take.Should().Be(7); + pr.IsCountRequested.Should().BeTrue(); + } + + [Test] + public void TotalCount_SetAndGet() + { + var pr = new PagingResult().WithTotalCount(123); + pr.TotalCount.Should().Be(123); + } + + [Test] + public void TotalCount_SetNull() + { + var pr = new PagingResult().WithTotalCount(null); + pr.TotalCount.Should().BeNull(); + } + + [Test] + public void TotalCount_SetZeroOrNegative() + { + var pr = new PagingResult().WithTotalCount(0); + pr.TotalCount.Should().Be(0); + + pr.WithTotalCount(-5); + pr.TotalCount.Should().BeNull(); + } + + [Test] + public void WithTotalCount_Fluent() + { + var pr = new PagingResult(); + var ret = pr.WithTotalCount(99); + ret.Should().BeSameAs(pr); + pr.TotalCount.Should().Be(99); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Data/PartitionKeyTests.cs b/tests/CoreEx.Test.Unit/Data/PartitionKeyTests.cs new file mode 100644 index 00000000..1e3030bc --- /dev/null +++ b/tests/CoreEx.Test.Unit/Data/PartitionKeyTests.cs @@ -0,0 +1,27 @@ +namespace CoreEx.Data.Test.Unit; + +public class PartitionKeyTests +{ + [Test] + public void GetPartitionId() + { + PartitionKey.GetPartitionId("abc", 4).Should().Be(1); + PartitionKey.GetPartitionId("ABC", 4).Should().Be(1); + PartitionKey.GetPartitionId("def", 4).Should().Be(2); + PartitionKey.GetPartitionId("klm", 4).Should().Be(0); + } + + [Test] + public void GetPartitionId_DifferentSize() + { + PartitionKey.GetPartitionId("xxx", 4).Should().Be(3); + PartitionKey.GetPartitionId("xxx", 3).Should().Be(1); + } + + [Test] + public void GetPartitionId_CaseSensitive() + { + PartitionKey.GetPartitionId("abc", 4, false).Should().Be(2); + PartitionKey.GetPartitionId("ABC", 4, false).Should().Be(1); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Data/PartitionPickerTests.cs b/tests/CoreEx.Test.Unit/Data/PartitionPickerTests.cs new file mode 100644 index 00000000..17c0c92e --- /dev/null +++ b/tests/CoreEx.Test.Unit/Data/PartitionPickerTests.cs @@ -0,0 +1,99 @@ +using CoreEx.Data; + +namespace CoreEx.Test.Unit.Data; + +public class PartitionPickerTests +{ + [Test] + public void GetNextPartitions_ShouldReturnCount_InRange_NoDuplicates() + { + // Basic behavior + var picker = new PartitionPicker(32, 6, 5); + var parts = picker.GetNextPartitions(DateTimeOffset.UtcNow); + + parts.Should().HaveCount(6); + parts.Should().OnlyHaveUniqueItems(); + parts.Should().OnlyContain(p => p >= 0 && p < 32); + } + + [Test] + public void GetNextPartitions_All_ShouldReturnAll_WithLastSuccessFirst() + { + // Fast path: all partitions + var picker = new PartitionPicker(8, 8, 5); + picker.PrioritizePartition(3); + + var parts = picker.GetNextPartitions(DateTimeOffset.UtcNow); + + parts.Should().HaveCount(8); + parts[0].Should().Be(3); + parts.Should().OnlyHaveUniqueItems(); + parts.OrderBy(x => x).Should().Equal(Enumerable.Range(0, 8)); + } + + [Test] + public void GetNextPartitions_Single_ShouldPreferLastSuccess_ElseDeterministicStart() + { + // Fast path: single partition + var picker = new PartitionPicker(16, 1, 5); + picker.PrioritizePartition(7); + picker.GetNextPartitions(DateTimeOffset.UtcNow) + .Should().Equal([7]); + + var picker2 = new PartitionPicker(16, 1, 5); + var parts2 = picker2.GetNextPartitions(DateTimeOffset.UtcNow); + parts2.Should().HaveCount(1); + parts2[0].Should().BeInRange(0, 15); + } + + [Test] + public void ReportSuccess_ShouldBiasNextPick_FirstItemIsLastSuccess() + { + // Temporal locality + var picker = new PartitionPicker(32, 6, 5); + picker.PrioritizePartition(12); + + var parts = picker.GetNextPartitions(DateTimeOffset.UtcNow); + parts[0].Should().Be(12); + } + + [Test] + public void GetNextPartitions_ShouldChangeAcrossEpochs() + { + // Epoch rotation changes selection + var picker = new PartitionPicker(32, 6, 5); + var t1 = DateTimeOffset.FromUnixTimeSeconds(10_000); + var t2 = DateTimeOffset.FromUnixTimeSeconds(10_000 + 5); + + var a = picker.GetNextPartitions(t1); + var b = picker.GetNextPartitions(t2); + + a.Should().NotEqual(b); + } + + [Test] + public void TwoPickers_ShouldHaveLowIntersection() + { + // Collision reduction heuristic + var a = new PartitionPicker(32, 6, 5); + var b = new PartitionPicker(32, 6, 5); + var now = DateTimeOffset.UtcNow; + + var pa = a.GetNextPartitions(now); + var pb = b.GetNextPartitions(now); + + pa.Intersect(pb).Count().Should().BeLessThanOrEqualTo(3); + } + + [TestCase(32, 10)] + [TestCase(30, 10)] + public void GetNextPartitions_ShouldHaveUniqueWindow(int total, int window) + { + // Coprime stride distribution (no repeats in window) + var picker = new PartitionPicker(total, window, 5); + var parts = picker.GetNextPartitions(DateTimeOffset.UtcNow); + + parts.Should().HaveCount(window); + parts.Should().OnlyHaveUniqueItems(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Data/QueryArgsTests.cs b/tests/CoreEx.Test.Unit/Data/QueryArgsTests.cs new file mode 100644 index 00000000..5258480a --- /dev/null +++ b/tests/CoreEx.Test.Unit/Data/QueryArgsTests.cs @@ -0,0 +1,106 @@ +using CoreEx.Data; + +namespace CoreEx.Test.Unit.Data; + +[TestFixture] +public class QueryArgsTests +{ + [Test] + public void Create_Default() + { + var args = QueryArgs.Create(); + args.Filter.Should().BeNull(); + args.OrderBy.Should().BeNull(); + args.IncludeFields.Should().BeNull(); + args.ExcludeFields.Should().BeNull(); + } + + [Test] + public void Create_WithValues() + { + var args = QueryArgs.Create("x eq 1", "y desc"); + args.Filter.Should().Be("x eq 1"); + args.OrderBy.Should().Be("y desc"); + } + + [Test] + public void Filter_SetAndGet() + { + var args = new QueryArgs() { Filter = "abc" }; + args.Filter.Should().Be("abc"); + } + + [Test] + public void OrderBy_SetAndGet() + { + var args = new QueryArgs + { + OrderBy = "def" + }; + args.OrderBy.Should().Be("def"); + } + + [Test] + public void IncludeFields_SetAndGet() + { + var args = new QueryArgs + { + IncludeFields = ["a", "b"] + }; + args.IncludeFields.Should().BeEquivalentTo("a", "b"); + } + + [Test] + public void ExcludeFields_SetAndGet() + { + var args = new QueryArgs + { + ExcludeFields = ["x", "y"] + }; + args.ExcludeFields.Should().BeEquivalentTo("x", "y"); + } + + [Test] + public void Include_AddsFields() + { + var args = new QueryArgs(); + var ret = args.WithFields("a", "b", "c"); + ret.Should().BeSameAs(args); + args.IncludeFields.Should().BeEquivalentTo("a", "b", "c"); + } + + [Test] + public void Include_AddsFields_MultipleCalls() + { + var args = new QueryArgs(); + args.WithFields("a").WithFields("b", "c"); + args.IncludeFields.Should().BeEquivalentTo("a", "b", "c"); + } + + [Test] + public void Exclude_AddsFields() + { + var args = new QueryArgs(); + var ret = args.WithoutFields("x", "y"); + ret.Should().BeSameAs(args); + args.ExcludeFields.Should().BeEquivalentTo("x", "y"); + } + + [Test] + public void Exclude_AddsFields_MultipleCalls() + { + var args = new QueryArgs(); + args.WithoutFields("x").WithoutFields("y", "z"); + args.ExcludeFields.Should().BeEquivalentTo("x", "y", "z"); + } + + [Test] + public void Include_And_Exclude_AreIndependent() + { + var args = new QueryArgs(); + args.WithFields("a", "b"); + args.WithoutFields("x", "y"); + args.IncludeFields.Should().BeEquivalentTo("a", "b"); + args.ExcludeFields.Should().BeEquivalentTo("x", "y"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Entities/ChangeLogTests.cs b/tests/CoreEx.Test.Unit/Entities/ChangeLogTests.cs new file mode 100644 index 00000000..d0b8ff07 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/ChangeLogTests.cs @@ -0,0 +1,75 @@ +using CoreEx.Entities; + +namespace CoreEx.Test.Unit.Entities; + +internal class ChangeLogTests +{ + [Test] + public void CreateCreated_ShouldSetCreatedByAndCreatedDate() + { + var changeLog = ChangeLog.CreateCreated(); + + changeLog.IsDefault().Should().BeFalse(); + changeLog.CreatedBy.Should().NotBeNullOrEmpty(); + changeLog.CreatedOn.Should().NotBeNull(); + changeLog.UpdatedBy.Should().BeNull(); + changeLog.UpdatedOn.Should().BeNull(); + } + + [Test] + public void CreateChanged_ShouldCopyCreatedAndSetUpdated() + { + var created = ChangeLog.CreateCreated(); + var changed = ChangeLog.CreateChanged(created); + + changed.IsDefault().Should().BeFalse(); + changed.CreatedBy.Should().Be(created.CreatedBy); + changed.CreatedOn.Should().Be(created.CreatedOn); + changed.UpdatedBy.Should().NotBeNullOrEmpty(); + changed.UpdatedOn.Should().NotBeNull(); + } + + [Test] + public void Clone_ShouldReturnEqualButNotSameInstance() + { + var changeLog = ChangeLog.CreateCreated(); + var clone = changeLog with { }; + + clone.IsDefault().Should().BeFalse(); + clone.Should().BeEquivalentTo(changeLog); + clone.Should().NotBeSameAs(changeLog); + } + + [Test] + public void New_IsDefaultAndAutoClean() + { + var changeLog = new ChangeLog(); + changeLog.CreatedBy.Should().BeNull(); + changeLog.CreatedOn.Should().BeNull(); + changeLog.UpdatedBy.Should().BeNull(); + changeLog.UpdatedOn.Should().BeNull(); + changeLog.IsDefault().Should().BeTrue(); + + changeLog = new ChangeLog() { CreatedBy = "", UpdatedBy = "" }; + changeLog.CreatedBy.Should().BeNull(); + changeLog.CreatedOn.Should().BeNull(); + changeLog.UpdatedBy.Should().BeNull(); + changeLog.UpdatedOn.Should().BeNull(); + changeLog.IsDefault().Should().BeTrue(); + } + + [Test] + public void With_ExpressionSupport() + { + var created = ChangeLog.CreateCreated(); + var changeLog = ChangeLog.CreateChanged(created); + + var changed = changeLog with { UpdatedBy = "NewUser" }; + + changed.UpdatedBy.Should().Be("NewUser"); + changed.CreatedBy.Should().Be(created.CreatedBy); + changed.CreatedOn.Should().Be(created.CreatedOn); + changed.UpdatedBy.Should().NotBeNullOrEmpty(); + changed.UpdatedOn.Should().NotBeNull(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Entities/CleanerTests.cs b/tests/CoreEx.Test.Unit/Entities/CleanerTests.cs new file mode 100644 index 00000000..444baa5b --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/CleanerTests.cs @@ -0,0 +1,229 @@ +using CoreEx.Entities; + +namespace CoreEx.Test.Unit.Entities; + +[TestFixture] +public class CleanerTests +{ + [SetUp] + public void SetUp() => Cleaner.ResetDefaults(); + + [Test] + public void Clean_String_Trim_End() + { + var input = "abc "; + var result = Cleaner.Clean(input, StringTrim.End, StringTransform.None, StringCase.None); + result.Should().Be("abc"); + } + + [Test] + public void Clean_String_Trim_Both() + { + var input = " abc "; + var result = Cleaner.Clean(input, StringTrim.Both, StringTransform.None, StringCase.None); + result.Should().Be("abc"); + } + + [Test] + public void Clean_String_Trim_Start() + { + var input = " abc"; + var result = Cleaner.Clean(input, StringTrim.Start, StringTransform.None, StringCase.None); + result.Should().Be("abc"); + } + + [Test] + public void Clean_String_Transform_EmptyToNull() + { + var input = ""; + var result = Cleaner.Clean(input, StringTrim.None, StringTransform.EmptyToNull, StringCase.None); + result.Should().BeNull(); + } + + [Test] + public void Clean_String_Transform_NullToEmpty() + { + string? input = null; + var result = Cleaner.Clean(input, StringTrim.None, StringTransform.NullToEmpty, StringCase.None); + result.Should().Be(string.Empty); + } + + [Test] + public void Clean_String_Case_Lower() + { + var input = "ABC"; + var result = Cleaner.Clean(input, StringTrim.None, StringTransform.None, StringCase.Lower); + result.Should().Be("abc"); + } + + [Test] + public void Clean_String_Case_Upper() + { + var input = "abc"; + var result = Cleaner.Clean(input, StringTrim.None, StringTransform.None, StringCase.Upper); + result.Should().Be("ABC"); + } + + [Test] + public void Clean_String_Case_Title() + { + var input = "a blue carrot"; + var result = Cleaner.Clean(input, StringTrim.None, StringTransform.None, StringCase.Title); + result.Should().Be("A Blue Carrot"); + } + + [Test] + public void Clean_String_Null() + { + Cleaner.DefaultStringTrim = StringTrim.Both; + Cleaner.DefaultStringTransform = StringTransform.EmptyToNull; + Cleaner.DefaultStringCase = StringCase.Upper; + + string? input = null; + var result = Cleaner.Clean(input); + result.Should().BeNull(); + } + + [Test] + public void Clean_String_UseDefault_Values() + { + Cleaner.DefaultStringTrim = StringTrim.Both; + Cleaner.DefaultStringTransform = StringTransform.EmptyToNull; + Cleaner.DefaultStringCase = StringCase.Upper; + + var input = " abc "; + var result = Cleaner.Clean(input); + result.Should().Be("ABC"); + } + + [Test] + public void Clean_DateTime_Transform_Utc() + { + var local = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Local); + var result = Cleaner.Clean(local, DateTimeTransform.DateTimeUtc); + result.Kind.Should().Be(DateTimeKind.Utc); + } + + [Test] + public void Clean_DateTime_Transform_Local() + { + var utc = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var result = Cleaner.Clean(utc, DateTimeTransform.DateTimeLocal); + result.Kind.Should().Be(DateTimeKind.Local); + } + + [Test] + public void Clean_DateTime_Transform_DateOnly() + { + var dt = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Local); + var result = Cleaner.Clean(dt, DateTimeTransform.DateOnly); + result.Date.Should().Be(dt.Date); + result.Kind.Should().Be(DateTimeKind.Unspecified); + } + + [Test] + public void Clean_DateTime_Transform_Unspecified() + { + var dt = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Local); + var result = Cleaner.Clean(dt, DateTimeTransform.DateTimeUnspecified); + result.Kind.Should().Be(DateTimeKind.Unspecified); + } + + [Test] + public void Clean_NullableDateTime_Null() + { + DateTime? dt = null; + var result = Cleaner.Clean(dt, DateTimeTransform.DateTimeUtc); + result.Should().BeNull(); + } + + [Test] + public void Clean_NullableDateTime_Value() + { + DateTime? dt = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Local); + var result = Cleaner.Clean(dt, DateTimeTransform.DateTimeUtc); + result.Should().NotBeNull(); + result!.Value.Kind.Should().Be(DateTimeKind.Utc); + } + + [Test] + public void Clean_Generic_String() + { + var input = " abc "; + var result = Cleaner.Clean(input); + result.Should().Be(" abc"); + } + + [Test] + public void Clean_Generic_DateTime() + { + var dt = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Local); + var result = Cleaner.Clean(dt); + result.Kind.Should().Be(DateTimeKind.Utc); + } + + [Test] + public void Clean_Generic_DateTime_Nullable() + { + DateTime? dt = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Local); + var result = Cleaner.Clean(dt); + result.Should().NotBeNull(); + result.Value.Kind.Should().Be(DateTimeKind.Utc); + + dt = null; + result = Cleaner.Clean(dt); + result.Should().BeNull(); + } + + [Test] + public void Clean_Generic_Null() + { + object? value = null; + var result = Cleaner.Clean(value); + result.Should().BeNull(); + } + + [Test] + public void Clean_Generic_ICollection() + { + List? input = []; + var result = Cleaner.Clean(input); + result.Should().BeNull(); + + List input2 = []; + var result2 = Cleaner.Clean(input2); + result2.Should().BeNull(); // Don't be fooled by lack of nullability declaration (compiler sugar only) - an empty collection is always cleaned to null. + + input = ["Abc"]; + result = Cleaner.Clean(input); + result.Should().NotBeNull(); + } + + [Test] + public void DefaultStringTrim_SetToUseDefault_Throws() + { + Action act = () => Cleaner.DefaultStringTrim = StringTrim.UseDefault; + act.Should().Throw(); + } + + [Test] + public void DefaultStringTransform_SetToUseDefault_Throws() + { + Action act = () => Cleaner.DefaultStringTransform = StringTransform.UseDefault; + act.Should().Throw(); + } + + [Test] + public void DefaultStringCase_SetToUseDefault_Throws() + { + Action act = () => Cleaner.DefaultStringCase = StringCase.UseDefault; + act.Should().Throw(); + } + + [Test] + public void DefaultDateTimeTransform_SetToUseDefault_Throws() + { + Action act = () => Cleaner.DefaultDateTimeTransform = DateTimeTransform.UseDefault; + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Entities/CompositeKeyTests.cs b/tests/CoreEx.Test.Unit/Entities/CompositeKeyTests.cs new file mode 100644 index 00000000..347c7a92 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/CompositeKeyTests.cs @@ -0,0 +1,142 @@ +using CoreEx.Entities; + +namespace CoreEx.Test.Unit.Entities; + +[TestFixture] +public class CompositeKeyTests +{ + [Test] + public void Create_StaticFactory_CreatesKey() + { + var key = CompositeKey.Create(1, "abc", null); + key.Args.Should().BeEquivalentTo(new object?[] { 1, "abc", null }); + } + + [Test] + public void Constructor_Empty() + { + var key = new CompositeKey(); + key.Args.Length.Should().Be(0); + } + + [Test] + public void Constructor_WithArgs() + { + var key = CompositeKey.Create(1, "x", 3.14); + key.Args.Should().BeEquivalentTo(new object?[] { 1, "x", 3.14 }); + } + + [Test] + public void Constructor_NullArgs() + { + var key = new CompositeKey(null!); + key.Args.Length.Should().Be(1); + key.Args[0].Should().BeNull(); + } + + [Test] + public void Args_Immutable() + { + var key = new CompositeKey(1, 2); + var arr = key.Args; + arr[0].Should().Be(1); + arr[1].Should().Be(2); + // ImmutableArray: cannot add/remove, so just check length and values + arr.Length.Should().Be(2); + } + + [Test] + public void Equals_SameArgs_True() + { + var k1 = CompositeKey.Create(1, "a"); + var k2 = CompositeKey.Create(1, "a"); + k1.Equals(k2).Should().BeTrue(); + k1.Should().Be(k2); + (k1 == k2).Should().BeTrue(); + (k1 != k2).Should().BeFalse(); + k1.Equals((object)k2).Should().BeTrue(); + } + + [Test] + public void Equals_DifferentArgs_False() + { + var k1 = new CompositeKey(1, "a"); + var k2 = new CompositeKey(2, "a"); + k1.Equals(k2).Should().BeFalse(); + k1.Should().NotBe(k2); + (k1 == k2).Should().BeFalse(); + (k1 != k2).Should().BeTrue(); + k1.Equals((object)k2).Should().BeFalse(); + } + + [Test] + public void Equals_Object_NotCompositeKey_False() + { + var k1 = new CompositeKey(1, "a"); + k1.Equals("not a key").Should().BeFalse(); + } + + [Test] + public void GetHashCode_EqualKeys_SameHash() + { + var k1 = new CompositeKey(1, "a"); + var k2 = CompositeKey.Create(1, "a"); + k1.GetHashCode().Should().Be(k2.GetHashCode()); + } + + [Test] + public void GetHashCode_DifferentKeys_DifferentHash() + { + var k1 = new CompositeKey(1, "a"); + var k2 = CompositeKey.Create(2, "a"); + k1.GetHashCode().Should().NotBe(k2.GetHashCode()); + } + + [Test] + public void ToString_Formatting() + { + var k1 = new CompositeKey(123456, "abc", new DateTimeOffset(2025, 04, 13, 17, 02, 53, TimeSpan.FromHours(8)), null, -10034.3456m, true); + k1.ToString().Should().Be("123456,abc,2025-04-13T09:02:53.0000000+00:00,,-10034.3456,true"); + + var k2 = CompositeKey.Create(123456, "abc"); + k2.ToString().Should().Be("123456,abc"); + } + + [Test] + public void Single_Key() + { + CompositeKey k1 = 123; + CompositeKey k2 = 123; + k1.Should().Be(k2); + k1.GetHashCode().Should().Be(k2.GetHashCode()); + k1.ToString().Should().Be("123"); + k2.ToString().Should().Be("123"); + } + + [Test] + public void Cast_To_CompositeKey() + { + CompositeKey k1 = 123; // Implicit cast from uint + k1.Args.Should().BeEquivalentTo(new object?[] { 123 }); + CompositeKey k2 = (uint?)456; // Implicit cast from nullable uint + k2.Args.Should().BeEquivalentTo(new object?[] { 456u }); + CompositeKey k3 = 789ul; // Implicit cast from ulong + k3.Args.Should().BeEquivalentTo(new object?[] { 789ul }); + CompositeKey k4 = (ulong?)101112; // Implicit cast from nullable ulong + k4.Args.Should().BeEquivalentTo(new object?[] { 101112ul }); + CompositeKey k5 = new DateOnly(2025, 04, 13); // Implicit cast from DateOnly + k5.Args.Should().BeEquivalentTo(new object?[] { new DateOnly(2025, 04, 13) }); + CompositeKey k6 = (DateOnly?)new DateOnly(2025, 05, 14); // Implicit cast from nullable DateOnly + k6.Args.Should().BeEquivalentTo(new object?[] { new DateOnly(2025, 05, 14) }); + CompositeKey k7 = new TimeOnly(17, 02, 53); // Implicit cast from TimeOnly + k7.Args.Should().BeEquivalentTo(new object?[] { new TimeOnly(17, 02, 53) }); + CompositeKey k8 = (TimeOnly?)new TimeOnly(18, 03, 54); // Implicit cast from nullable TimeOnly + k8.Args.Should().BeEquivalentTo(new object?[] { new TimeOnly(18, 03, 54) }); + byte[] bytes = { 0x01, 0x02 }; + CompositeKey k9 = bytes; // Implicit cast from byte[] + k9.Args.Should().BeEquivalentTo(new object?[] { bytes }); + object?[] arr = { "a", null }; + CompositeKey k10 = arr; // Implicit cast from object?[] + k10.Args.Should().BeEquivalentTo(arr); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Entities/IdentifierTests.cs b/tests/CoreEx.Test.Unit/Entities/IdentifierTests.cs new file mode 100644 index 00000000..897d44ed --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/IdentifierTests.cs @@ -0,0 +1,43 @@ +using CoreEx.Entities; +using CoreEx.Entities.Abstractions; + +namespace CoreEx.Test.Unit.Entities; + +[TestFixture] +public class IdentifierTests +{ + public class ImmutableIdentifier : IReadOnlyIdentifier + { + public int Id { get; init; } + } + + public class MutableIdentifier : IIdentifier + { + public int Id { get; set; } + } + + [Test] + public void ImmutableIdentifier_Test() + { + var ii = new ImmutableIdentifier { Id = 123 }; + ii.Id.Should().Be(123); + ((IReadOnlyIdentifier)ii).IdType.Should().Be(); + ((IReadOnlyIdentifier)ii).IsIdReadOnly.Should().BeTrue(); + Action act = () => ((IReadOnlyIdentifier)ii).SetIdentifier(456); + act.Should().Throw().WithMessage("Identifier is read-only."); + } + + [Test] + public void MutableIdentifier_Test() + { + var mi = new MutableIdentifier { Id = 123 }; + mi.Id.Should().Be(123); + ((IReadOnlyIdentifier)mi).IdType.Should().Be(); + ((IReadOnlyIdentifier)mi).IsIdReadOnly.Should().BeFalse(); + ((IReadOnlyIdentifier)mi).SetIdentifier(456); + mi.Id.Should().Be(456); + + ((IIdentifier)mi).Id = 789; + mi.Id.Should().Be(789); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Entities/MessageItemCollectionTests.cs b/tests/CoreEx.Test.Unit/Entities/MessageItemCollectionTests.cs new file mode 100644 index 00000000..eb1a4ac4 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/MessageItemCollectionTests.cs @@ -0,0 +1,72 @@ +using CoreEx.Entities; + +namespace CoreEx.Test.Unit.Entities; + +[TestFixture] +public class MessageItemCollectionTests +{ + [Test] + public void Ctor_Default_Empty() + { + var coll = new MessageItemCollection(); + coll.Should().BeEmpty(); + } + + [Test] + public void Ctor_WithMessages() + { + var msgs = new List + { + MessageItem.CreateMessage(MessageType.Info, "info"), + MessageItem.CreateMessage(MessageType.Error, "error") + }; + var coll = new MessageItemCollection(msgs); + coll.Should().BeEquivalentTo(msgs); + } + + [Test] + public void ContainsType_TrueAndFalse() + { + var coll = new MessageItemCollection + { + MessageItem.CreateMessage(MessageType.Info, "info"), + MessageItem.CreateMessage(MessageType.Error, "error"), + MessageItem.CreateMessage(MessageType.Warning, "warn") + }; + coll.ContainsType(MessageType.Info).Should().BeTrue(); + coll.ContainsType(MessageType.Error).Should().BeTrue(); + coll.ContainsType(MessageType.Warning).Should().BeTrue(); + coll.ContainsType((MessageType)99).Should().BeFalse(); + } + + [Test] + public void GetMessagesForType_ReturnsFiltered() + { + var coll = new MessageItemCollection + { + MessageItem.CreateMessage(MessageType.Info, "info1"), + MessageItem.CreateMessage(MessageType.Error, "error1"), + MessageItem.CreateMessage(MessageType.Info, "info2"), + MessageItem.CreateMessage(MessageType.Error, "error2") + }; + var infos = coll.GetMessagesForType(MessageType.Info); + infos.Should().HaveCount(2); + infos.Should().OnlyContain(x => x.Type == MessageType.Info); + infos.Should().BeEquivalentTo([MessageItem.CreateMessage(MessageType.Info, "info1"), MessageItem.CreateMessage(MessageType.Info, "info2")]); + + var errors = coll.GetMessagesForType(MessageType.Error); + errors.Should().HaveCount(2); + errors.Should().OnlyContain(x => x.Type == MessageType.Error); + } + + [Test] + public void GetMessagesForType_EmptyResult() + { + var coll = new MessageItemCollection + { + MessageItem.CreateMessage(MessageType.Info, "info") + }; + var warnings = coll.GetMessagesForType(MessageType.Warning); + warnings.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Entities/MessageItemCreateTests.cs b/tests/CoreEx.Test.Unit/Entities/MessageItemCreateTests.cs new file mode 100644 index 00000000..24eaccd9 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/MessageItemCreateTests.cs @@ -0,0 +1,74 @@ +using CoreEx.Entities; +using CoreEx.Localization; + +namespace CoreEx.Test.Unit.Entities; + +[TestFixture] +public class MessageItemCreateTests +{ + [Test] + public void CreateMessage_Type_Text() + { + var ltext = new LText("msg1"); + var item = MessageItem.CreateMessage(MessageType.Info, ltext); + item.Type.Should().Be(MessageType.Info); + item.Text.Should().Be(ltext); + item.Property.Should().BeNull(); + } + + [Test] + public void CreateMessage_Type_Format_Values() + { + var ltext = new LText("format {0} {1}"); + var item = MessageItem.CreateMessage(MessageType.Warning, ltext, 1, "x"); + item.Type.Should().Be(MessageType.Warning); + item.Text.Should().NotBeNull(); + item.Text.Value.KeyAndOrText.Should().Be("format {0} {1}"); + item.Text.Value.Args.Should().BeEquivalentTo(new object[] { 1, "x" }); + item.Property.Should().BeNull(); + } + + [Test] + public void CreateMessage_Property_Type_Text() + { + var ltext = new LText("msg2"); + var item = MessageItem.CreateMessage("PropA", MessageType.Error, ltext); + item.Property.Should().Be("PropA"); + item.Type.Should().Be(MessageType.Error); + item.Text.Should().Be(ltext); + } + + [Test] + public void CreateMessage_Property_Type_Format_Values() + { + var ltext = new LText("format {0}"); + var item = MessageItem.CreateMessage("PropB", MessageType.Info, ltext, 99); + item.Property.Should().Be("PropB"); + item.Type.Should().Be(MessageType.Info); + item.Text.Should().NotBeNull(); + item.Text.Value.KeyAndOrText.Should().Be("format {0}"); + item.Text.Value.Args.Should().BeEquivalentTo(new object[] { 99 }); + } + + [Test] + public void CreateErrorMessage_Property_Text() + { + var ltext = new LText("errormsg"); + var item = MessageItem.CreateErrorMessage("PropC", ltext); + item.Property.Should().Be("PropC"); + item.Type.Should().Be(MessageType.Error); + item.Text.Should().Be(ltext); + } + + [Test] + public void CreateErrorMessage_Property_Format_Values() + { + var ltext = new LText("error {0}"); + var item = MessageItem.CreateErrorMessage("PropD", ltext, "fail"); + item.Property.Should().Be("PropD"); + item.Type.Should().Be(MessageType.Error); + item.Text.Should().NotBeNull(); + item.Text.Value.KeyAndOrText.Should().Be("error {0}"); + item.Text.Value.Args.Should().BeEquivalentTo(new object[] { "fail" }); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Entities/MessageItemTests.cs b/tests/CoreEx.Test.Unit/Entities/MessageItemTests.cs new file mode 100644 index 00000000..7e93817a --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/MessageItemTests.cs @@ -0,0 +1,66 @@ +using CoreEx.Entities; +using CoreEx.Localization; + +namespace CoreEx.Test.Unit.Entities; + +[TestFixture] +public class MessageItemTests +{ + [Test] + public void Constructor_SetsProperties() + { + var ltext = new LText("test message"); + var item = new MessageItem(MessageType.Warning, ltext, "Prop1"); + item.Type.Should().Be(MessageType.Warning); + item.Text.Should().Be(ltext); + item.Property.Should().Be("Prop1"); + } + + [Test] + public void Constructor_NullProperty() + { + var ltext = new LText("msg"); + var item = new MessageItem(MessageType.Info, ltext); + item.Type.Should().Be(MessageType.Info); + item.Text.Should().Be(ltext); + item.Property.Should().BeNull(); + } + + [Test] + public void Type_GetSet() + { + var item = new MessageItem(MessageType.Info, new LText("x")) + { + Type = MessageType.Error + }; + item.Type.Should().Be(MessageType.Error); + } + + [Test] + public void Text_GetSet() + { + var item = new MessageItem(MessageType.Info, new LText("x")); + var ltext = new LText("new text"); + item.Text = ltext; + item.Text.Should().Be(ltext); + } + + [Test] + public void Property_GetSet() + { + var item = new MessageItem(MessageType.Info, new LText("x")) + { + Property = "abc" + }; + item.Property.Should().Be("abc"); + } + + [Test] + public void WithProperty_SetsPropertyAndReturnsSelf() + { + var item = new MessageItem(MessageType.Info, new LText("x")); + var ret = item.WithProperty("p1"); + ret.Should().BeSameAs(item); + item.Property.Should().Be("p1"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/ExceptionTests.cs b/tests/CoreEx.Test.Unit/ExceptionTests.cs new file mode 100644 index 00000000..a5beb3cb --- /dev/null +++ b/tests/CoreEx.Test.Unit/ExceptionTests.cs @@ -0,0 +1,142 @@ +using CoreEx.Entities; +using CoreEx.Localization; +using System.Net; + +namespace CoreEx.Test.Unit; + +[TestFixture] +public class CoreExExceptionTests +{ + [Test] + public void BusinessException_DefaultAndMessage() + { + var ltext = new LText("business"); + var ex1 = new BusinessException(ltext); + ex1.Message.Should().Contain("business"); + } + + [Test] + public void ValidationException_DefaultAndMessage() + { + var ex1 = new ValidationException(); + ex1.Message.Should().NotBeNull(); + ex1.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex1.ErrorType.Should().Be("validation"); + + var ltext = new LText("validation"); + var ex2 = new ValidationException(ltext); + ex2.Message.Should().Contain("validation"); + + var item = new MessageItem(MessageType.Error, new LText("msg")); + var ex3 = new ValidationException(item); + ex3.Messages.Should().ContainSingle(); + + var items = new List { item }; + var ex4 = new ValidationException(items); + ex4.Messages.Should().ContainSingle(); + } + + [Test] + public void ConflictException_DefaultAndMessage() + { + var ex1 = new ConflictException(); + ex1.Message.Should().NotBeNull(); + ex1.StatusCode.Should().Be(HttpStatusCode.Conflict); + ex1.ErrorType.Should().Be("conflict"); + + var ltext = new LText("conflict"); + var ex2 = new ConflictException(ltext); + ex2.Message.Should().Contain("conflict"); + } + + [Test] + public void ConcurrencyException_DefaultAndMessage() + { + var ex1 = new ConcurrencyException(); + ex1.Message.Should().NotBeNull(); + ex1.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + ex1.ErrorType.Should().Be("concurrency"); + + var ltext = new LText("concurrency"); + var ex2 = new ConcurrencyException(ltext); + ex2.Message.Should().Contain("concurrency"); + } + + [Test] + public void DataConsistencyException_DefaultAndMessage() + { + var ex1 = new DataConsistencyException(); + ex1.Message.Should().NotBeNull(); + ex1.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + ex1.ErrorType.Should().Be("data-consistency"); + + var ltext = new LText("dataconsistency"); + var ex2 = new DataConsistencyException(ltext); + ex2.Message.Should().Contain("dataconsistency"); + } + + [Test] + public void DuplicateException_DefaultAndMessage() + { + var ex1 = new DuplicateException(); + ex1.Message.Should().NotBeNull(); + ex1.StatusCode.Should().Be(HttpStatusCode.Conflict); + ex1.ErrorType.Should().Be("duplicate"); + + var ltext = new LText("duplicate"); + var ex2 = new DuplicateException(ltext); + ex2.Message.Should().Contain("duplicate"); + } + + [Test] + public void NotFoundException_DefaultAndMessage() + { + var ex1 = new NotFoundException(); + ex1.Message.Should().NotBeNull(); + ex1.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex1.ErrorType.Should().Be("not-found"); + + var ltext = new LText("notfound"); + var ex2 = new NotFoundException(ltext); + ex2.Message.Should().Contain("notfound"); + } + + [Test] + public void TransientException_DefaultAndMessage() + { + var ex1 = new TransientException(); + ex1.Message.Should().NotBeNull(); + ex1.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + ex1.ErrorType.Should().Be("transient"); + + var ltext = new LText("transient"); + var ex2 = new TransientException(ltext); + ex2.Message.Should().Contain("transient"); + } + + [Test] + public void AuthenticationException_DefaultAndMessage() + { + var ex1 = new AuthenticationException(); + ex1.Message.Should().NotBeNull(); + ex1.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + ex1.ErrorType.Should().Be("authentication"); + + var ltext = new LText("auth"); + var ex2 = new AuthenticationException(ltext); + ex2.Message.Should().Contain("auth"); + } + + [Test] + public void AuthorizationException_DefaultAndMessage() + { + var ex1 = new AuthorizationException(); + ex1.Message.Should().NotBeNull(); + ex1.StatusCode.Should().Be(HttpStatusCode.Forbidden); + ex1.ErrorType.Should().Be("authorization"); + + var ltext = new LText("author"); + var ex2 = new AuthorizationException(ltext); + ex2.Message.Should().Contain("author"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/ExecutionContextTests.cs b/tests/CoreEx.Test.Unit/ExecutionContextTests.cs new file mode 100644 index 00000000..9bfe5a58 --- /dev/null +++ b/tests/CoreEx.Test.Unit/ExecutionContextTests.cs @@ -0,0 +1,150 @@ +using CoreEx.Entities; +using CoreEx.Localization; +using System.Globalization; + +namespace CoreEx.Test.Unit; + +[TestFixture] +public class ExecutionContextTests +{ + [TearDown] + public void TearDown() + { + ExecutionContext.Reset(); + ExecutionContext.Create = () => new ExecutionContext(); + } + + [Test] + public void ServiceProvider_GetSet() + { + var ec = new ExecutionContext(); + var sp = new TestServiceProvider(); + ec.ServiceProvider = sp; + ec.ServiceProvider.Should().BeSameAs(sp); + } + + [Test] + public void UserName_GetSet() + { + var ec = new ExecutionContext + { + User = new Security.AuthenticationUser { Type = Security.AuthenticationType.AccountUser, UserName = "user1" } + }; + ec.User.Should().NotBeNull(); + ec.User.UserName.Should().Be("user1"); + ec.User.Type.Should().Be(Security.AuthenticationType.AccountUser); + } + + [Test] + public void TenantId_GetSet() + { + var ec = new ExecutionContext + { + TenantId = "tenant1" + }; + ec.TenantId.Should().Be("tenant1"); + } + + [Test] + public void Timestamp_DefaultAndSet() + { + var ec = new ExecutionContext(); + var now = DateTimeOffset.UtcNow; + ec.Timestamp.Should().BeOnOrAfter(now.AddSeconds(-1)); + var dt = new DateTimeOffset(2020, 1, 2, 3, 4, 5, TimeSpan.Zero); + ec.Timestamp = dt; + ec.Timestamp.Should().Be(dt); + } + + [Test] + public void UICulture_GetSet() + { + var ec = new ExecutionContext(); + var culture = new CultureInfo("fr-FR"); + ec.UICulture = culture; + ec.UICulture.Should().Be(culture); + } + + [Test] + public void AddWarningMessage_AddsMessage() + { + var ec = new ExecutionContext(); + ec.AddWarningMessage(new LText("warn")); + ec.Messages.Should().NotBeNull(); + ec.Messages!.Count.Should().Be(1); + ec.Messages[0].Type.Should().Be(MessageType.Warning); + ec.Messages[0].Text.Should().Be(new LText("warn")); + } + + [Test] + public void AddInfoMessage_AddsMessage() + { + var ec = new ExecutionContext(); + ec.AddInfoMessage(new LText("info")); + ec.Messages.Should().NotBeNull(); + ec.Messages!.Count.Should().Be(1); + ec.Messages[0].Type.Should().Be(MessageType.Info); + ec.Messages[0].Text.Should().Be(new LText("info")); + } + + [Test] + public void Attributes_IsLazilyCreatedAndWorks() + { + var ec = new ExecutionContext(); + ec.Attributes.Should().NotBeNull(); + ec.Attributes["foo"] = 123; + ec.Attributes["foo"].Should().Be(123); + } + + [Test] + public void IsACopy_FalseByDefault_TrueWhenCopied() + { + var ec = new ExecutionContext(); + ec.IsACopy.Should().BeFalse(); + var copy = ec.CreateCopy(); + copy.IsACopy.Should().BeTrue(); + } + + [Test] + public void CreateCopy_CopiesPropertiesAndSharesMessagesAndAttributes() + { + var ec = new ExecutionContext + { + User = new Security.AuthenticationUser { Type = Security.AuthenticationType.AccountUser, UserName = "user" }, + TenantId = "tenant", + UICulture = new CultureInfo("en-US") + }; + ec.AddInfoMessage(new LText("msg")); + ec.Attributes["k"] = "v"; + var copy = ec.CreateCopy(); + copy.User.Should().NotBeNull(); + copy.User.UserName.Should().Be("user"); + copy.User.Type.Should().Be(Security.AuthenticationType.AccountUser); + copy.TenantId.Should().Be("tenant"); + copy.UICulture.Should().Be(new CultureInfo("en-US")); + copy.Messages.Should().BeSameAs(ec.Messages); + copy.Attributes.Should().NotBeNull(); + copy.Attributes["k"].Should().Be("v"); + } + + [Test] + public void CreateCopy_ThrowsIfCreateIsNull() + { + ExecutionContext.Create = null; + var ec = new ExecutionContext(); + Action act = () => ec.CreateCopy(); + act.Should().Throw(); + } + + [Test] + public void OperationType_Read() + { + var ec = new ExecutionContext { OperationType = OperationType.Get }; + ec.OperationType.IsRead.Should().BeTrue(); + } + + private class TestServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Hosting/Work/WorkOrchestratorTests.cs b/tests/CoreEx.Test.Unit/Hosting/Work/WorkOrchestratorTests.cs new file mode 100644 index 00000000..7f2607a6 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Hosting/Work/WorkOrchestratorTests.cs @@ -0,0 +1,181 @@ +using CoreEx.Hosting.Work; +using CoreEx.Caching; + +namespace CoreEx.Test.Unit.Hosting.Work; + +[TestFixture] +public class WorkOrchestratorTests +{ + [Test] + public async Task Orchestrate_End_To_End() + { + var p = new HybridCacheWorkProvider(new MemoryOnlyHybridCache()); + var o = new WorkOrchestrator(p); + + ExecutionContext.Reset(); + var ec = ExecutionContext.Current; + + var wa = new WorkArgs("Test-Work", "abc"); + + // Start work. + var ws = await o.CreateAsync(wa); + ws.Should().NotBeNull(); + ws.TypeName.Should().Be("Test-Work"); + ws.Id.Should().Be("abc"); + ws.Status.Should().Be(WorkStatus.Created); + ws.Created.Should().Be(ec.Timestamp); + ws.Started.Should().BeNull(); + ws.Indeterminate.Should().BeNull(); + ws.Finished.Should().BeNull(); + ws.Reason.Should().BeNull(); + + // Get not found. + var ws2 = await o.GetWithTypeAsync("Test-Work", "def"); + ws2.Should().BeNull(); + + // Get found. + ws2 = await o.GetWithTypeAsync("Test-Work", "abc"); + ObjectComparer.Assert(ws, ws2); + + // Start work. + ws = await o.StartAsync("abc"); + ws.Should().NotBeNull(); + ws.TypeName.Should().Be("Test-Work"); + ws.Id.Should().Be("abc"); + ws.Status.Should().Be(WorkStatus.Started); + ws.Created.Should().Be(ec.Timestamp); + ws.Started.Should().Be(ec.Timestamp); + ws.Indeterminate.Should().BeNull(); + ws.Finished.Should().BeNull(); + ws.Reason.Should().BeNull(); + + // Indeterminate work. + ws = await o.IndeterminateAsync("abc", "Not sure!"); + ws.Should().NotBeNull(); + ws.TypeName.Should().Be("Test-Work"); + ws.Id.Should().Be("abc"); + ws.Status.Should().Be(WorkStatus.Indeterminate); + ws.Created.Should().Be(ec.Timestamp); + ws.Started.Should().Be(ec.Timestamp); + ws.Indeterminate.Should().Be(ec.Timestamp); + ws.Finished.Should().BeNull(); + ws.Reason.Should().Be("Not sure!"); + + // Get data. + var bd = await o.GetDataAsync("abc"); + bd.Should().BeNull(); + + // Set data. + await o.SetDataValueAsync("abc", "123"); + + // Get data. + var dv = await o.GetDataValueAsync("abc"); + dv.Should().Be("123"); + + // Complete work. + ws = await o.CompleteAsync("abc"); + ws.Should().NotBeNull(); + ws.TypeName.Should().Be("Test-Work"); + ws.Id.Should().Be("abc"); + ws.Status.Should().Be(WorkStatus.Completed); + ws.Created.Should().Be(ec.Timestamp); + ws.Started.Should().Be(ec.Timestamp); + ws.Indeterminate.Should().Be(ec.Timestamp); + ws.Finished.Should().Be(ec.Timestamp); + ws.Reason.Should().BeNull(); + + // Get different type with same id. + ws = await o.GetWithTypeAsync("abc"); + ws.Should().BeNull(); + + // Get correct type with same id, but different user. + ExecutionContext.Current.User = Security.AuthenticationUser.Anonymous; + ws = await o.GetWithTypeAsync("Test-Work", "abc"); + ws.Should().BeNull(); + } + + [Test] + public async Task Auto_Expire_On_Get() + { + var p = new HybridCacheWorkProvider(new MemoryOnlyHybridCache()); + var o = new WorkOrchestrator(p); + + ExecutionContext.Reset(); + var ec = ExecutionContext.Current; + + var wa = new WorkArgs("Test-Work", "abc") { Expiry = TimeSpan.FromDays(-1) }; + + // Start work. + var ws = await o.CreateAsync(wa); + ws.Should().NotBeNull(); + + // Get work - should be expired. + ws = await o.GetAsync("abc"); + ws.Should().NotBeNull(); + ws.Status.Should().Be(WorkStatus.Expired); + ws.Created.Should().Be(ec.Timestamp); + ws.Started.Should().BeNull(); + ws.Indeterminate.Should().BeNull(); + ws.Finished.Should().Be(ec.Timestamp); + } + + [Test] + public async Task Create_Then_Fail() + { + var p = new HybridCacheWorkProvider(new MemoryOnlyHybridCache()); + var o = new WorkOrchestrator(p); + + ExecutionContext.Reset(); + var ec = ExecutionContext.Current; + + var wa = new WorkArgs("Test-Work", "abc"); + + // Create work. + var ws = await o.CreateAsync(wa); + ws.Should().NotBeNull(); + + // Start work. + ws = await o.StartAsync("abc"); + ws.Should().NotBeNull(); + + // Fail work. + ws = await o.FailAsync("abc", "Because I said so!"); + ws.Should().NotBeNull(); + ws.TypeName.Should().Be("Test-Work"); + ws.Id.Should().Be("abc"); + ws.Status.Should().Be(WorkStatus.Failed); + ws.Created.Should().Be(ec.Timestamp); + ws.Started.Should().Be(ec.Timestamp); + ws.Indeterminate.Should().BeNull(); + ws.Finished.Should().Be(ec.Timestamp); + ws.Reason.Should().Be("Because I said so!"); + } + + [Test] + public async Task Create_Then_Cancel() + { + var p = new HybridCacheWorkProvider(new MemoryOnlyHybridCache()); + var o = new WorkOrchestrator(p); + + ExecutionContext.Reset(); + var ec = ExecutionContext.Current; + + var wa = new WorkArgs("Test-Work", "abc"); + + // Start work. + var ws = await o.CreateAsync(wa); + ws.Should().NotBeNull(); + + // Cancel work. + ws = await o.CancelAsync("abc", "Not needed!"); + ws.Should().NotBeNull(); + ws.TypeName.Should().Be("Test-Work"); + ws.Id.Should().Be("abc"); + ws.Status.Should().Be(WorkStatus.Canceled); + ws.Created.Should().Be(ec.Timestamp); + ws.Started.Should().BeNull(); + ws.Indeterminate.Should().BeNull(); + ws.Finished.Should().Be(ec.Timestamp); + ws.Reason.Should().Be("Not needed!"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Http/HttpClientTests.cs b/tests/CoreEx.Test.Unit/Http/HttpClientTests.cs new file mode 100644 index 00000000..cef2bd0a --- /dev/null +++ b/tests/CoreEx.Test.Unit/Http/HttpClientTests.cs @@ -0,0 +1,117 @@ +using CoreEx.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Net; +using UnitTestEx.Expectations; + +namespace CoreEx.Test.Unit.Http; + +[TestFixture] +public class HttpClientTests +{ + [Test] + public void Resiliency_And_No_IdempotencyKey() + { + var mcf = MockHttpClientFactory.Create(); + var mapi = mcf.CreateClient("UnitTest").WithConfigurations(); + + mapi.Request(HttpMethod.Get, "/test-endpoint") + .Respond.WithSequence(s => + { + s.Respond().With(HttpStatusCode.InternalServerError); + s.Respond().With(HttpStatusCode.ServiceUnavailable); + s.Respond().With(HttpStatusCode.RequestTimeout); + s.Respond().With(HttpStatusCode.NoContent); + }); + + mapi.Request(HttpMethod.Get, "/test-endpoint-2") + .Respond.With(HttpStatusCode.NotFound); + + using var test = GenericTester.Create(); + test.ReplaceHttpClientFactory(mcf); + + var client = test.Services.GetRequiredService().CreateClient("UnitTest"); + + test.ExpectLogContains("Result: '500', Handled: 'True', Attempt: '0'") + .ExpectLogContains("Result: '503', Handled: 'True', Attempt: '1'") + .ExpectLogContains("Result: '408', Handled: 'True', Attempt: '2'") + .ExpectLogContains("Result: '204', Handled: 'False', Attempt: '3'") + .Run(async () => + { + var response = await client.GetAsync("/test-endpoint"); + var ik = response.RequestMessage!.Headers.GetValues(HttpNames.IdempotencyKeyHeaderName).FirstOrDefault(); + ik.Should().BeNull(); + }); + + test.ExpectLogContains("Result: '404', Handled: 'False', Attempt: '0'") + .Run(async () => + { + var response = await client.GetAsync("/test-endpoint-2"); + var ik = response.RequestMessage!.Headers.GetValues(HttpNames.IdempotencyKeyHeaderName).FirstOrDefault(); + ik.Should().BeNull(); + }); + + mapi.Verify(); + } + + [Test] + public void Resiliency_And_IdempotencyKey() + { + var mcf = MockHttpClientFactory.Create(); + var mapi = mcf.CreateClient("UnitTest").WithConfigurations(); + + mapi.Request(HttpMethod.Post, "/test-endpoint") + .Respond.WithSequence(s => + { + s.Respond().With(HttpStatusCode.InternalServerError); + s.Respond().With(HttpStatusCode.ServiceUnavailable); + s.Respond().With(HttpStatusCode.RequestTimeout); + s.Respond().With(HttpStatusCode.NoContent); + }); + + mapi.Request(HttpMethod.Post, "/test-endpoint-2") + .Respond.With(HttpStatusCode.NotFound); + + using var test = GenericTester.Create(); + test.ReplaceHttpClientFactory(mcf); + + var client = test.Services.GetRequiredService().CreateClient("UnitTest"); + string? lastIdempotencyKey = null; + + test.ExpectLogContains("Result: '500', Handled: 'True', Attempt: '0'") + .ExpectLogContains("Result: '503', Handled: 'True', Attempt: '1'") + .ExpectLogContains("Result: '408', Handled: 'True', Attempt: '2'") + .ExpectLogContains("Result: '204', Handled: 'False', Attempt: '3'") + .Run(async () => + { + var response = await client.PostAsync("/test-endpoint", null); + var ik = response.RequestMessage!.Headers.GetValues(HttpNames.IdempotencyKeyHeaderName).FirstOrDefault(); + ik.Should().NotBeNullOrEmpty(); + lastIdempotencyKey = ik; + }); + + test.ExpectLogContains("Result: '404', Handled: 'False', Attempt: '0'") + .Run(async () => + { + var response = await client.PostAsync("/test-endpoint-2", null); + var ik = response.RequestMessage!.Headers.GetValues(HttpNames.IdempotencyKeyHeaderName).FirstOrDefault(); + ik.Should().NotBeNullOrEmpty(); + ik.Should().NotBe(lastIdempotencyKey); + }); + + mapi.Verify(); + } + + public class EntryPoint + { + public static void ConfigureApplication(IHostApplicationBuilder builder) + { + builder.Services.AddHttpClient("UnitTest", static client => + { + client.BaseAddress = new Uri("http://unittest"); + }) + .AddIdempotencyKeyHandler() + .AddStandardResilienceHandler(); // https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience?tabs=dotnet-cli#standard-resilience-handler-defaults + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Json/JsonFilterTests.cs b/tests/CoreEx.Test.Unit/Json/JsonFilterTests.cs new file mode 100644 index 00000000..a66919b6 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Json/JsonFilterTests.cs @@ -0,0 +1,503 @@ +using CoreEx.Json; +using System.Text.Json.Nodes; + +namespace CoreEx.Test.Unit.Json; + +[TestFixture] +public class JsonFilterTests +{ + [TestCase(null!, "$")] + [TestCase("", "$")] + [TestCase("foo", "$.foo")] + [TestCase("[0]", "$[0]")] + [TestCase("$.foo", "$.foo")] + public void PrependRootPath_Works(string input, string expected) + { + JsonFilter.PrependRootPath(input).Should().Be(expected); + } + + [TestCase(null!, false, null!)] + [TestCase("", false, "")] + [TestCase("$.foo[0].bar[1]", true, "$.foo.bar")] + [TestCase("$.foo.bar", false, "$.foo.bar")] + public void TryRemovePathIndexes_Works(string input, bool expectedResult, string expectedPath) + { + var result = JsonFilter.TryRemovePathIndexes(input, out var path); + result.Should().Be(expectedResult); + path.Should().Be(expectedPath); + } + + [Test] + public void CreateDictionary_Include_AddsIntermediaries() + { + int maxDepth = 0; + var dict = JsonFilter.CreateDictionary(["$.a.b.c"], JsonFilterOption.Include, StringComparison.Ordinal, ref maxDepth); + dict.Should().ContainKey("$.a.b.c"); + dict.Should().ContainKey("$.a.b"); + dict.Should().ContainKey("$.a"); + dict.Should().ContainKey("$"); + dict["$.a.b.c"].Should().BeTrue(); + dict["$.a.b"].Should().BeFalse(); + dict["$.a"].Should().BeFalse(); + dict["$"].Should().BeFalse(); + maxDepth.Should().Be(4); + } + + [Test] + public void CreateDictionary_Exclude_NoIntermediaries() + { + int maxDepth = 0; + var dict = JsonFilter.CreateDictionary(["$.a.b"], JsonFilterOption.Exclude, StringComparison.Ordinal, ref maxDepth); + dict.Should().ContainKey("$.a.b"); + dict["$.a.b"].Should().BeTrue(); + dict.Count.Should().Be(1); + maxDepth.Should().Be(3); + } + + [Test] + public void TryJsonFilter_Include_RemovesOtherProperties() + { + var json = "{\"a\":1,\"b\":2,\"c\":3}"; + var paths = new[] { "$.a", "$.c" }; + var result = JsonFilter.TryJsonFilter(json, paths, out var filtered, JsonFilterOption.Include); + result.Should().BeTrue(); + filtered.Should().Be("{\"a\":1,\"c\":3}"); + } + + [Test] + public void TryJsonFilter_Exclude_RemovesSpecifiedProperties() + { + var json = "{\"a\":1,\"b\":2,\"c\":3}"; + var paths = new[] { "$.b" }; + var result = JsonFilter.TryJsonFilter(json, paths, out var filtered, JsonFilterOption.Exclude); + result.Should().BeTrue(); + filtered.Should().Be("{\"a\":1,\"c\":3}"); + } + + [Test] + public void TryJsonFilter_NoPaths_NoChange() + { + var json = "{\"a\":1,\"b\":2}"; + var result = JsonFilter.TryJsonFilter(json, null, out var filtered, JsonFilterOption.Include); + result.Should().BeFalse(); + filtered.Should().Be("{\"a\":1,\"b\":2}"); + } + + public class TestObj { public int X { get; set; } public int Y { get; set; } } + + [Test] + public void TryFilter_T_ReturnsFilteredJson() + { + var obj = new TestObj { X = 1, Y = 2 }; + var result = JsonFilter.TryFilter(obj, ["$.X"], out string json, JsonFilterOption.Include); + result.Should().BeTrue(); + json.Should().Be("{\"x\":1}"); + } + + [Test] + public void TryFilter_T_ReturnsFilteredJsonNode() + { + var obj = new TestObj { X = 1, Y = 2 }; + var result = JsonFilter.TryFilter(obj, ["$.Y"], out JsonNode node, JsonFilterOption.Include); + result.Should().BeTrue(); + var json = node.ToJsonString(); + json.Should().Be("{\"y\":2}"); + } + + private const string _json = """ + { + "Name": "John Doe", + "Age": 30, + "IsEmployed": true, + "Skills": ["C#", "JavaScript", "Python"], + "Address": { + "Street": "123 Main St", + "City": "Anytown", + "State": "CA" + }, + "Projects": [ + { + "Name": "Project A", + "Year": 2020, + "Technologies": ["C#", "ASP.NET"] + }, + { + "Name": "Project B", + "Year": 2021, + "Technologies": ["JavaScript", "React"] + } + ] + } + """; + + [Test] + public void TryJsonFilter_Include_Simple() + { + string exp = """ + { + "Name": "John Doe", + "Skills": ["C#", "JavaScript", "Python"] + } + """; + + var r = JsonFilter.TryJsonFilter(_json, ["name", "skills"], out string json); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Include_NoMatches() + { + string exp = """ + { + } + """; + + var r = JsonFilter.TryJsonFilter(_json, ["parent", "address.country", "skills[4]", "projects[3].years"], out string json); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Include_Indexed() + { + string exp = """ + { + "Skills": ["JavaScript"], + "Projects": [ + { + "Name": "Project A", + "Year": 2020, + "Technologies": ["C#", "ASP.NET"] + } + ] + } + """; + + var r = JsonFilter.TryJsonFilter(_json, ["skills[1]", "projects[0]"], out string json); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Include_Indexed_Indexed() + { + string exp = """ + { + "Projects": [ + { + "Technologies": ["React"] + } + ] + } + """; + + var r = JsonFilter.TryJsonFilter(_json, ["projects[1].technologies[1]"], out string json); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Include_Indexed_Property_Indexed() + { + string exp = """ + { + "Projects": [ + { + "Year": 2020 + }, + { + "Year": 2021, + "Technologies": ["React"] + } + ] + } + """; + + var r = JsonFilter.TryJsonFilter(_json, ["projects.year", "projects[1].technologies[1]"], out string json); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Include_Array() + { + string val = """ + [ + { + "Name": "John Doe", + "Age": 30 + }, + { + "Name": "Jane Smith", + "Age": 25 + } + ] + """; + + string exp = """ + [ + { + "Name": "John Doe" + }, + { + "Name": "Jane Smith" + } + ] + """; + + var r = JsonFilter.TryJsonFilter(val, ["name"], out string json); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Include_Null_Value() + { + var r = JsonFilter.TryJsonFilter("null", ["age"], out string json); + r.Should().BeFalse(); + ObjectComparer.AssertJson("null", json); + } + + [Test] + public void TryFilter_Include_Null_Value() + { + var r = JsonFilter.TryFilter(null, ["age"], out string json); + r.Should().BeFalse(); + ObjectComparer.AssertJson("null", json); + } + + [Test] + public void TryFilter_Include_Int_Value() + { + // filtering a json value is non-sensical and will return as-is. + var r = JsonFilter.TryFilter(1, ["age"], out string json); + r.Should().BeFalse(); + ObjectComparer.AssertJson("1", json); + } + + [Test] + public void TryJsonFilter_Exclude_Nothing() + { + string val = """ + { + "Name": "John Doe", + "Age": 30, + "IsEmployed": true + } + """; + + var r = JsonFilter.TryJsonFilter(val, ["height"], out string json, JsonFilterOption.Exclude); + r.Should().BeFalse(); + ObjectComparer.AssertJson(val, json); + } + + [Test] + public void TryJsonFilter_Exclude_Simple() + { + string val = """ + { + "Name": "John Doe", + "Age": 30, + "IsEmployed": true + } + """; + + string exp = """ + { + "Name": "John Doe", + "IsEmployed": true + } + """; + + var r = JsonFilter.TryJsonFilter(val, ["age"], out string json, JsonFilterOption.Exclude); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Exclude_Simple_Array() + { + string val = """ + { + "Name": "John Doe", + "Skills": ["C#", "JavaScript", "Python"] + } + """; + + string exp = """ + { + "Name": "John Doe", + "Skills": ["C#", "Python"] + } + """; + + var r = JsonFilter.TryJsonFilter(val, ["skills[1]"], out string json, JsonFilterOption.Exclude); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Exclude_Complex() + { + string val = """ + { + "Name": "John Doe", + "Address": { + "Street": "123 Main St", + "City": "Anytown", + "State": "CA" + } + } + """; + + string exp = """ + { + "Name": "John Doe", + "Address": { + "Street": "123 Main St" + } + } + """; + + var r = JsonFilter.TryJsonFilter(val, ["address.city", "address.state"], out string json, JsonFilterOption.Exclude); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Exclude_Complex_Array() + { + string val = """ + { + "Name": "John Doe", + "Projects": [ + { + "Name": "Project A", + "Year": 2020, + "Technologies": ["C#", "ASP.NET"] + }, + { + "Name": "Project B", + "Year": 2021, + "Technologies": ["JavaScript", "React"] + } + ] + } + """; + + string exp = """ + { + "Name": "John Doe", + "Projects": [ + { + "Name": "Project A", + "Technologies": ["C#", "ASP.NET"] + }, + { + "Name": "Project B", + "Year": 2021, + "Technologies": ["JavaScript"] + } + ] + } + """; + + var r = JsonFilter.TryJsonFilter(val, ["projects[0].year", "projects[1].technologies[1]"], out string json, JsonFilterOption.Exclude); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Exclude_Array() + { + string val = """ + [ + { + "Name": "John Doe", + "Age": 30 + }, + { + "Name": "Jane Smith", + "Age": 25 + } + ] + """; + + string exp = """ + [ + { + "Name": "John Doe" + }, + { + "Name": "Jane Smith" + } + ] + """; + + var r = JsonFilter.TryJsonFilter(val, ["age"], out string json, JsonFilterOption.Exclude); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } + + [Test] + public void TryJsonFilter_Exclude_Null_Value() + { + var r = JsonFilter.TryJsonFilter("null", ["age"], out string json, JsonFilterOption.Exclude); + r.Should().BeFalse(); + ObjectComparer.AssertJson("null", json); + } + + [Test] + public void TryFilter_Exclude_Null_Value() + { + var r = JsonFilter.TryFilter(null, ["age"], out string json, JsonFilterOption.Exclude); + r.Should().BeFalse(); + ObjectComparer.AssertJson("null", json); + } + + [Test] + public void TryFilter_Exclude_Int_Value() + { + var r = JsonFilter.TryFilter(1, ["age"], out string json, JsonFilterOption.Exclude); + r.Should().BeFalse(); + ObjectComparer.AssertJson("1", json); + } + + [Test] + public void TryFilter_Object_Array_Object() + { + string val = """ + { + "Products": [ + { + "Category": [ + { "A": "Accessories" }, + { "B": "Bikes" } + ], + "Other": [ + { "G": "Gear" } + ] + } + ] + } + """; + + string exp = """ + { + "Products": [ + { + "Category": [ + { "A": "Accessories" }, + { "B": "Bikes" } + ] + } + ] + } + """; + + var r = JsonFilter.TryJsonFilter(val, ["products.category"], out string json); + r.Should().BeTrue(); + ObjectComparer.AssertJson(exp, json); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Json/JsonMergeTests.cs b/tests/CoreEx.Test.Unit/Json/JsonMergeTests.cs new file mode 100644 index 00000000..52975597 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Json/JsonMergeTests.cs @@ -0,0 +1,327 @@ +using CoreEx.Json; + +namespace CoreEx.Test.Unit.Json; + +[TestFixture] +public class JsonMergeTests +{ + #region Merge + + [Test] + public void Merge_Root_Nulls() + { + var p = new JsonMergePatch(); + var r = p.Merge("""null""", """null"""); + r.Should().Be("""null"""); + } + + [Test] + public void Merge_Root_Intrinsics_Are_Replacement() + { + var p = new JsonMergePatch(); + var r = p.Merge("""true""", """0"""); + r.Should().Be("""true"""); + } + + [Test] + public void Merge_Root_Arrays_Are_Replacement() + { + var p = new JsonMergePatch(); + var r = p.Merge("[1,2,3]", "[4,5,6]"); + r.Should().Be("[1,2,3]"); + } + + [Test] + public void Merge_Object_No_Merge() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{}""", """{"age":10}"""); + r.Should().Be("""{"age":10}"""); + } + + [Test] + public void Merge_Objects_Combine() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"weight":100}""", """{"age":10}"""); + r.Should().Be("{\"weight\":100,\"age\":10}"); + } + + [Test] + public void Merge_Objects_Replace_Property() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"age":100}""", """{"weight":100,"age":10}"""); + r.Should().Be("""{"age":100,"weight":100}"""); + } + + [Test] + public void Merge_Objects_Remove_Property_With_Null() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"age":null}""", """{"age":100}"""); + r.Should().Be("""{}"""); + } + + [Test] + public void Merge_Objects_Nested_Mix() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"person":{"name":"bob","groovy":true}}""", """{"person":{"name":"gary","age":10}}"""); + r.Should().Be("""{"person":{"name":"bob","groovy":true,"age":10}}"""); + } + + [Test] + public void Merge_Object_Nested_Array_Unmerged() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"person":{}}""", """{"person":{"names":["bob","gary"]}}"""); + r.Should().Be("""{"person":{"names":["bob","gary"]}}"""); + } + + [Test] + public void Merge_Object_Nested_Array_Replace() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"person":{"names":["bob","jim"]}}""", """{"person":{"names":["bob","gary","simon"]}}"""); + r.Should().Be("""{"person":{"names":["bob","jim"]}}"""); + } + + [Test] + public void Merge_Object_Nested_Object_Array_Replace() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"persons":[{"Name":"bob","age":10}]}""", """{"persons":[{"Name":"simon","age":10},{"Name":"jack","age":30}]}"""); + r.Should().Be("""{"persons":[{"Name":"bob","age":10}]}"""); + } + + [Test] + public void Merge_Object_Nested_Object_Array_Replace2() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"other":33}""", """{"persons":[{"Name":"simon","age":10},{"Name":"jack","age":30}]}"""); + r.Should().Be("""{"other":33,"persons":[{"Name":"simon","age":10},{"Name":"jack","age":30}]}"""); + } + + [Test] + public void Merge_Object_Nested_Object_Array_Remove() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"other":33,"persons":null}""", """{"persons":[{"Name":"simon","age":10},{"Name":"jack","age":30}]}"""); + r.Should().Be("""{"other":33}"""); + } + + #endregion + + #region TryMerge + + [Test] + public void TryMerge_Root_Nulls() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""null""", """null""", out var r); + c.Should().BeFalse(); + r.Should().BeNull(); + } + + [Test] + public void TryMerge_Root_Intrinsics_Are_Replacement() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""true""", """0""", out var r); + c.Should().BeTrue(); + r.Should().Be("""true"""); + } + + [Test] + public void TryMerge_Root_Arrays_Are_Replacement() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("[1,2,3]", "[4,5,6]", out var r); + c.Should().BeTrue(); + r.Should().Be("[1,2,3]"); + } + + [Test] + public void TryMerge_Object_No_Merge() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""{}""", """{"age":10}""", out var r); + c.Should().BeFalse(); + r.Should().BeNull(); + } + + [Test] + public void TryMerge_Objects_Combine() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""{"weight":100}""", """{"age":10}""", out var r); + c.Should().BeTrue(); + r.Should().Be("{\"weight\":100,\"age\":10}"); + } + + [Test] + public void TryMerge_Objects_Replace_Property() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""{"age":100}""", """{"weight":100,"age":10}""", out var r); + c.Should().BeTrue(); + r.Should().Be("""{"age":100,"weight":100}"""); + } + + [Test] + public void TryMerge_Objects_Remove_Property_With_Null() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""{"age":null}""", """{"age":100}""", out var r); + c.Should().BeTrue(); + r.Should().Be("""{}"""); + } + + [Test] + public void TryMerge_Objects_Nested_Mix() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""{"person":{"name":"bob","groovy":true}}""", """{"person":{"name":"gary","age":10}}""", out var r); + c.Should().BeTrue(); + r.Should().Be("""{"person":{"name":"bob","groovy":true,"age":10}}"""); + } + + [Test] + public void TryMerge_Object_Nested_Array_Unmerged() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""{"person":{}}""", """{"person":{"names":["bob","gary"]}}""", out var r); + c.Should().BeFalse(); + r.Should().BeNull(); + } + + [Test] + public void TryMerge_Object_Nested_Array_Replace() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""{"person":{"names":["bob","jim"]}}""", """{"person":{"names":["bob","gary","simon"]}}""", out var r); + c.Should().BeTrue(); + r.Should().Be("""{"person":{"names":["bob","jim"]}}"""); + } + + [Test] + public void TryMerge_Object_Nested_Object_Array_Replace() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""{"persons":[{"Name":"bob","age":10}]}""", """{"persons":[{"Name":"simon","age":10},{"Name":"jack","age":30}]}""", out var r); + c.Should().BeTrue(); + r.Should().Be("""{"persons":[{"Name":"bob","age":10}]}"""); + } + + [Test] + public void TryMerge_Object_Nested_Object_Array_Replace2() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""{"other":33}""", """{"persons":[{"Name":"simon","age":10},{"Name":"jack","age":30}]}""", out var r); + c.Should().BeTrue(); + r.Should().Be("""{"other":33,"persons":[{"Name":"simon","age":10},{"Name":"jack","age":30}]}"""); + } + + [Test] + public void TryMerge_Object_Nested_Object_Array_Remove() + { + var p = new JsonMergePatch(); + var c = p.TryMerge("""{"other":33,"persons":null}""", """{"persons":[{"Name":"simon","age":10},{"Name":"jack","age":30}]}""", out var r); + c.Should().BeTrue(); + r.Should().Be("""{"other":33}"""); + } + + #endregion + + [Test] + public void Merge_Into_Null() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"name":"bob","age":30}""", null!); + r.Should().NotBeNull(); + r.IsSuccess.Should().BeTrue(); + r.Value.HasChanges.Should().BeFalse(); + r.Value.Merged.Should().BeNull(); + } + + [Test] + public void Merge_Into_Value_Type_Mismatch() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"name":"bob","age":"thirty"}""", null!); + r.Should().NotBeNull(); + r.IsFailure.Should().BeTrue(); + r.Error.Should().NotBeNull(); + } + + [Test] + public void Merge_Into_Value_With_Changes() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"name":"bob","age":30}""", new Person { Name = "Jerry", Address = new Address { Street = "Main" } }); + r.Should().NotBeNull(); + r.IsSuccess.Should().BeTrue(); + r.Value.HasChanges.Should().BeTrue(); + ObjectComparer.Assert(new Person { Name = "bob", Age = 30, Address = new Address { Street = "Main" } }, r.Value.Merged); + } + + [Test] + public void Merge_Into_Value_Changes_Unchanged() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"x-name":"bob","x-age":30}""", new Person { Name = "Jerry", Address = new Address { Street = "Main" } }); + r.Should().NotBeNull(); + r.IsSuccess.Should().BeTrue(); + r.Value.HasChanges.Should().BeTrue(); // Is true as the underlying JSON was modified; although did not result in change to value itself as 'x-' fields were ignored during final deserializtion. + ObjectComparer.Assert(new Person { Name = "Jerry", Address = new Address { Street = "Main" } }, r.Value.Merged); + } + + [Test] + public void Merge_Into_Value_Unchained_Unchanged() + { + var p = new JsonMergePatch(); + var r = p.Merge("""{"name":"Jerry"}""", new Person { Name = "Jerry", Address = new Address { Street = "Main" } }); + r.Should().NotBeNull(); + r.IsSuccess.Should().BeTrue(); + r.Value.HasChanges.Should().BeFalse(); + ObjectComparer.Assert(new Person { Name = "Jerry", Address = new Address { Street = "Main" } }, r.Value.Merged); + } + + [Test] + public async Task MergeAsync_Changed() + { + var p = new JsonMergePatch(); + var r = await p.MergeAsync(new BinaryData("""{"name":"bob","age":30}"""), _ => Task.FromResult(new Person())); + r.Should().NotBeNull(); + r.IsSuccess.Should().BeTrue(); + r.Value.HasChanges.Should().BeTrue(); + ObjectComparer.Assert(new Person { Name = "bob", Age = 30 }, r.Value.Merged); + } + + [Test] + public async Task MergeAsync_Get_Null() + { + var p = new JsonMergePatch(); + var r = await p.MergeAsync(new BinaryData("""{"name":"bob","age":30}"""), _ => Task.FromResult(null)); + r.Should().NotBeNull(); + r.IsSuccess.Should().BeTrue(); + r.Value.HasChanges.Should().BeFalse(); + r.Value.Merged.Should().BeNull(); + } + + public class Person + { + public string? Name { get; set; } + public int Age { get; set; } + public string[]? NickNames { get; set; } + public Address? Address { get; set; } + public List? Children { get; set; } + } + + public class Address + { + public string? Street { get; set; } + public string? City { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Json/JsonSubstituteNamingPolicyTests.cs b/tests/CoreEx.Test.Unit/Json/JsonSubstituteNamingPolicyTests.cs new file mode 100644 index 00000000..dc9fe96b --- /dev/null +++ b/tests/CoreEx.Test.Unit/Json/JsonSubstituteNamingPolicyTests.cs @@ -0,0 +1,28 @@ +using CoreEx.Entities; +using CoreEx.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CoreEx.Test.Unit.Json; + +[TestFixture] +public class JsonSubstituteNamingPolicyTests +{ + [Test] + public void Default_Test() + { + var p = new Person { Id = "abc", Age = 55, ETag = "xyz" }; + var json = JsonSerializer.Serialize(p, JsonDefaults.SerializerOptions); + json.Should().Be("""{"id":"abc","years":55,"etag":"xyz"}"""); + } + + private class Person : IIdentifier, IReadOnlyETag + { + public string? Id { get; set; } + + [JsonPropertyName("years")] + public int Age { get; set; } + + public string? ETag { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Localization/LTextTests.cs b/tests/CoreEx.Test.Unit/Localization/LTextTests.cs new file mode 100644 index 00000000..183a1afb --- /dev/null +++ b/tests/CoreEx.Test.Unit/Localization/LTextTests.cs @@ -0,0 +1,153 @@ +using CoreEx.Localization; + +namespace CoreEx.Test.Unit.Localization; + +[TestFixture] +public class LTextTests +{ + [Test] + public void Empty_Static_IsEmpty() + { + LText.Empty.IsEmpty.Should().BeTrue(); + LText.Empty.KeyAndOrText.Should().BeNull(); + LText.Empty.FallbackText.Should().BeNull(); + LText.Empty.Args.Should().BeNull(); + } + + [Test] + public void Constructor_KeyOnly() + { + var ltext = new LText("key1"); + ltext.KeyAndOrText.Should().Be("key1"); + ltext.FallbackText.Should().BeNull(); + ltext.Args.Should().BeNull(); + ltext.IsEmpty.Should().BeFalse(); + ltext.WasFallBackTextSetToNull.Should().BeFalse(); + } + + [Test] + public void Constructor_KeyFallbackArgs() + { + var ltext = new LText("key2", "fallback", 1, "x"); + ltext.KeyAndOrText.Should().Be("key2"); + ltext.FallbackText.Should().Be("fallback"); + ltext.Args.Should().BeEquivalentTo(new object?[] { 1, "x" }); + ltext.IsEmpty.Should().BeFalse(); + ltext.WasFallBackTextSetToNull.Should().BeFalse(); + } + + [Test] + public void Constructor_KeyNullFallbackNull_SetsWasFallbackTextSetToNull() + { + var ltext = new LText("key3", null, 5); + ltext.KeyAndOrText.Should().Be("key3"); + ltext.FallbackText.Should().BeNull(); + ltext.Args.Should().BeEquivalentTo(new object?[] { 5 }); + ltext.WasFallBackTextSetToNull.Should().BeTrue(); + } + + [Test] + public void WithArgs_AppendsArgs() + { + var ltext = new LText("k", "f", 1); + var ltext2 = ltext.WithArgs(2, 3); + ltext2.KeyAndOrText.Should().Be("k"); + ltext2.FallbackText.Should().Be("f"); + ltext2.Args.Should().BeEquivalentTo(new object?[] { 1, 2, 3 }); + ltext2.WasFallBackTextSetToNull.Should().BeFalse(); + } + + [Test] + public void WithArgs_NullOrEmpty_ReturnsSelf() + { + var ltext = new LText("k", "f", 1); + ltext.WithArgs().Should().Be(ltext); + ltext.WithArgs(null!).Should().Be(ltext); + } + + [Test] + public void EnsureNoArgs_NoArgs_ReturnsSelf() + { + var ltext = new LText("k"); + ltext.EnsureNoArgs().Should().Be(ltext); + } + + [Test] + public void EnsureNoArgs_WithArgs_Throws() + { + var ltext = new LText("k", "f", 1); + Action act = () => ltext.EnsureNoArgs(); + act.Should().Throw(); + } + + [Test] + public void ToString_UsesTextProvider() + { + var ltext = new LText("key4", "fallback4"); + var str = ltext.ToString(); + str.Should().Be(TextProvider.Current.GetText(ltext)); + } + + [Test] + public void ToString_FormatArgs() + { + var ltext = new LText("x={0},y={1}").WithArgs(1, "a"); + var str = ltext.ToString(); + str.Should().Be("x=1,y=a"); + } + + [Test] + public void ImplicitCast_LTextToString() + { + var ltext = new LText("key5", "fallback5"); + string? str = ltext; + str.Should().Be(TextProvider.Current.GetText(ltext)); + } + + [Test] + public void ImplicitCast_NullableLTextToString() + { + LText? ltext = null; + string? str = ltext; + str.Should().BeNull(); + } + + [Test] + public void ImplicitCast_StringToLText() + { + LText ltext = "abc"; + ltext.KeyAndOrText.Should().Be("abc"); + ltext.FallbackText.Should().BeNull(); + } + + [Test] + public void Equals_And_Operators() + { + var l1 = new LText("k", "f", 1); + var l2 = new LText("k", "f", 1); + var l3 = new LText("k", "f", 2); + l1.Equals(l2).Should().BeTrue(); + l1.Equals((object)l2).Should().BeTrue(); + (l1 == l2).Should().BeTrue(); + (l1 != l2).Should().BeFalse(); + l1.Equals(l3).Should().BeFalse(); + (l1 == l3).Should().BeFalse(); + (l1 != l3).Should().BeTrue(); + } + + [Test] + public void GetHashCode_EqualObjects_SameHash() + { + var l1 = new LText("k", "f", 1); + var l2 = new LText("k", "f", 1); + l1.GetHashCode().Should().Be(l2.GetHashCode()); + } + + [Test] + public void GetHashCode_DifferentObjects_DifferentHash() + { + var l1 = new LText("k", "f", 1); + var l2 = new LText("k", "f", 2); + l1.GetHashCode().Should().NotBe(l2.GetHashCode()); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Localization/TextProviderTests.cs b/tests/CoreEx.Test.Unit/Localization/TextProviderTests.cs new file mode 100644 index 00000000..3c3dcb43 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Localization/TextProviderTests.cs @@ -0,0 +1,84 @@ +using System.Globalization; +using CoreEx.Localization; +using Microsoft.Extensions.DependencyInjection; + +namespace CoreEx.Test.Unit.Localization; + +[TestFixture] +public class TextProviderTests +{ + private class TestTextProvider : ITextProvider + { + public string? GetText(LText text) => $"[{text.KeyAndOrText}]"; + } + + [TearDown] + public void TearDown() + { + // Reset to default before each test to avoid side effects. + TextProvider.SetTextProvider(null); + ExecutionContext.Reset(); + } + + [Test] + public void SetTextProvider_And_Current() + { + var tp = new TestTextProvider(); + TextProvider.SetTextProvider(tp); + TextProvider.Current.Should().BeSameAs(tp); + } + + [Test] + public void Current_FallsBackToNullTextProvider() + { + TextProvider.SetTextProvider(null); + var current = TextProvider.Current; + current.Should().NotBeNull(); + current.GetType().Name.Should().Be("NullTextProvider"); + } + + [Test] + public void Current_UsesExecutionContextService() + { + var tp = new TestTextProvider(); + var sc = new ServiceCollection(); + sc.AddSingleton(tp); + using var sp = sc.BuildServiceProvider(); + + ExecutionContext.Reset(); + ExecutionContext.SetCurrent(new ExecutionContext { ServiceProvider = sp }); + TextProvider.Current.Should().BeSameAs(tp); + } + + [Test] + public void GetUICulture_ReturnsCurrentUICulture() + { + var culture = TextProvider.GetUICulture(); + culture.Should().Be(CultureInfo.CurrentUICulture); + } + + [Test] + public void Format_NullFormat_ReturnsNull() + { + TextProvider.Format(null, [1]).Should().BeNull(); + } + + [Test] + public void Format_NullArgs_ReturnsFormat() + { + TextProvider.Format("abc", null).Should().Be("abc"); + } + + [Test] + public void Format_EmptyArgs_ReturnsFormat() + { + TextProvider.Format("abc", []).Should().Be("abc"); + } + + [Test] + public void Format_WithArgs_FormatsString() + { + var result = TextProvider.Format("x={0},y={1}", [1, "a"]); + result.Should().Be($"x=1,y=a"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Mapping/Converters/StringToBase64ConverterTests.cs b/tests/CoreEx.Test.Unit/Mapping/Converters/StringToBase64ConverterTests.cs new file mode 100644 index 00000000..47298878 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Mapping/Converters/StringToBase64ConverterTests.cs @@ -0,0 +1,59 @@ +using CoreEx.Mapping.Converters; + +namespace CoreEx.Test.Unit.Mapping.Converters; + +[TestFixture] +public class StringToBase64ConverterTests +{ + private readonly StringBase64Converter _converter = StringBase64Converter.Default; + + [Test] + public void ConvertToDestination_ValidBase64String_ReturnsBytes() + { + var text = "Hello, World!"; + var base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(text)); + var result = _converter.ConvertToDestination(base64); + + result.Should().NotBeNull(); + System.Text.Encoding.UTF8.GetString(result!).Should().Be(text); + } + + [Test] + public void ConvertToDestination_Null_ReturnsNull() + { + _converter.ConvertToDestination((string?)null).Should().BeNull(); + } + + [Test] + public void ConvertToSource_ValidBytes_ReturnsBase64String() + { + var bytes = System.Text.Encoding.UTF8.GetBytes("Test123"); + var result = _converter.ConvertToSource(bytes); + + result.Should().Be(Convert.ToBase64String(bytes)); + } + + [Test] + public void ConvertToSource_Null_ReturnsNull() + { + _converter.ConvertToSource((byte[]?)null).Should().BeNull(); + } + + [Test] + public void RoundTrip_StringToBytesAndBack() + { + var original = "RoundTrip!"; + var base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(original)); + var bytes = _converter.ConvertToDestination(base64); + var roundTrip = _converter.ConvertToSource(bytes); + + roundTrip.Should().Be(base64); + } + + [Test] + public void ConvertToDestination_InvalidBase64_ThrowsFormatException() + { + Action act = () => _converter.ConvertToDestination("not_base64!"); + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Mapping/MapTests.cs b/tests/CoreEx.Test.Unit/Mapping/MapTests.cs new file mode 100644 index 00000000..8fdc8ba9 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Mapping/MapTests.cs @@ -0,0 +1,124 @@ +using CoreEx.Data; +using CoreEx.Entities; +using CoreEx.Mapping; + +namespace CoreEx.Test.Unit.Mapping; + +public class MapTests +{ + private class Source : IReadOnlyIdentifier, IReadOnlyETag, IReadOnlyTenantId, IReadOnlyLogicallyDeleted, IReadOnlyTypeDiscriminator, IReadOnlyPartitionKey, IReadOnlyChangeLogEx + { + public int Id { get; init; } + public string? ETag { get; init; } + public string? TenantId { get; init; } + public bool IsDeleted { get; init; } + public string? TypeDiscriminator { get; init; } + public string? PartitionKey { get; init; } + public string? CreatedBy { get; init; } + public DateTimeOffset? CreatedOn { get; init; } + public string? UpdatedBy { get; init; } + public DateTimeOffset? UpdatedOn { get; init; } + } + + private class Source2 : IReadOnlyIdentifier, IReadOnlyETag, IReadOnlyTenantId, IReadOnlyLogicallyDeleted, IReadOnlyTypeDiscriminator, IReadOnlyPartitionKey, IReadOnlyChangeLog + { + public int Id { get; init; } + public string? ETag { get; init; } + public string? TenantId { get; init; } + public bool IsDeleted { get; init; } + public string? TypeDiscriminator { get; init; } + public string? PartitionKey { get; init; } + public ChangeLog? ChangeLog { get; init; } + } + + private class Destination : IIdentifier, IETag, ITenantId, ILogicallyDeleted, ITypeDiscriminator, IPartitionKey, IChangeLogEx + { + public int Id { get; set; } + public string? ETag { get; set; } + public string? TenantId { get; set; } + public bool IsDeleted { get; set; } + public string? TypeDiscriminator { get; set; } + public string? PartitionKey { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? CreatedOn { get; set; } + public string? UpdatedBy { get; set; } + public DateTimeOffset? UpdatedOn { get; set; } + } + + private class Destination2 : IIdentifier, IETag, ITenantId, ILogicallyDeleted, ITypeDiscriminator, IPartitionKey, IChangeLog + { + public int Id { get; set; } + public Type IdType => typeof(int); + public string? ETag { get; set; } + public string? TenantId { get; set; } + public bool IsDeleted { get; set; } + public string? TypeDiscriminator { get; set; } + public string? PartitionKey { get; set; } + public ChangeLog? ChangeLog { get; set; } + } + + [Test] + public void Standard_MapsAllStandardProperties() + { + var src = new Source + { + Id = 123, + ETag = "etag", + TenantId = "tenant", + IsDeleted = true, + TypeDiscriminator = "type", + PartitionKey = "pk", + CreatedBy = "cb", + CreatedOn = DateTimeOffset.UtcNow.AddDays(-1), + UpdatedBy = "ub", + UpdatedOn = DateTimeOffset.UtcNow + }; + + var dest = new Destination(); + Mapper.MapStandardInto(src, dest); + + dest.Id.Should().Be(123); + dest.ETag.Should().Be("etag"); + dest.TenantId.Should().Be("tenant"); + dest.IsDeleted.Should().BeTrue(); + dest.TypeDiscriminator.Should().Be("type"); + dest.PartitionKey.Should().Be("pk"); + dest.CreatedBy.Should().Be("cb"); + dest.CreatedOn.Should().Be(src.CreatedOn); + dest.UpdatedBy.Should().Be("ub"); + dest.UpdatedOn.Should().Be(src.UpdatedOn); + } + + [Test] + public void Standard_MapsAllStandardProperties2() + { + var src = new Source + { + Id = 123, + ETag = "etag", + TenantId = "tenant", + IsDeleted = true, + TypeDiscriminator = "type", + PartitionKey = "pk", + CreatedBy = "cb", + CreatedOn = DateTimeOffset.UtcNow.AddDays(-1), + UpdatedBy = "ub", + UpdatedOn = DateTimeOffset.UtcNow + }; + + var dest = new Destination2(); + Mapper.MapStandardInto(src, dest); + + dest.Id.Should().Be(123); + dest.ETag.Should().Be("etag"); + dest.TenantId.Should().Be("tenant"); + dest.IsDeleted.Should().BeTrue(); + dest.TypeDiscriminator.Should().Be("type"); + dest.PartitionKey.Should().Be("pk"); + dest.ChangeLog.Should().NotBeNull(); + dest.ChangeLog.CreatedBy.Should().Be("cb"); + dest.ChangeLog.CreatedOn.Should().Be(src.CreatedOn); + dest.ChangeLog.UpdatedBy.Should().Be("ub"); + dest.ChangeLog.UpdatedOn.Should().Be(src.UpdatedOn); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Mapping/MapperTests.cs b/tests/CoreEx.Test.Unit/Mapping/MapperTests.cs new file mode 100644 index 00000000..e8250798 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Mapping/MapperTests.cs @@ -0,0 +1,41 @@ +using CoreEx.Mapping; + +namespace CoreEx.Test.Unit.Mapping; + +public class MapperTests +{ + private class Source { public int Value { get; set; } } + private class Destination { public int Value { get; set; } } + + private class TestMapper : Mapper + { + protected override Destination OnMap(Source source) => new() + { + Value = source.Value + }; + } + + [Test] + public void SourceType_And_DestinationType_AreCorrect() + { + var mapper = new TestMapper(); + ((IMapperBase)mapper).SourceType.Should().Be(); + ((IMapperBase)mapper).DestinationType.Should().Be(); + } + + [Test] + public void Map_NullSource_EqualsNull() + { + var mapper = new TestMapper(); + mapper.Map(null).Should().BeNull(); + } + + [Test] + public void Map_MapsValue() + { + var mapper = new TestMapper(); + var src = new Source { Value = 42 }; + var dest = mapper.Map(src); + dest.Value.Should().Be(42); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Results/ExtensionsAnyTests.cs b/tests/CoreEx.Test.Unit/Results/ExtensionsAnyTests.cs new file mode 100644 index 00000000..fde20913 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ExtensionsAnyTests.cs @@ -0,0 +1,416 @@ +using CoreEx.Results; + +namespace CoreEx.Test.Unit.Results; + +public class ExtensionsAnyTests +{ + [Test] + public void Result_Any_Action_Success() + { + var called = false; + var result = new Result(); + var ret = result.Any(() => called = true); + called.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public void Result_Any_Action_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = result.Any(() => called = true); + called.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public void Result_Any_FuncResult_Success() + { + var called = false; + var result = new Result(); + var ret = result.Any(() => { called = true; return Result.Success; }); + called.Should().BeTrue(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void Result_Any_FuncResult_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = result.Any(() => { called = true; return Result.Success; }); + called.Should().BeTrue(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_Any_ActionT_Success() + { + int? value = null; + var result = new Result(42); + var ret = result.Any(i => value = i); + value.Should().Be(42); + ret.Should().Be(result); + } + + [Test] + public void ResultT_Any_ActionT_Failure() + { + int? value = null; + var result = new Result(new Exception("fail")); + var ret = result.Any(i => value = i); + value.Should().Be(0); // default(int) + ret.Should().Be(result); + } + + [Test] + public void ResultT_Any_FuncT_Success() + { + var result = new Result(10); + var ret = result.Any(i => i * 2); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(20); + } + + [Test] + public void ResultT_Any_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.Any(i => i * 2); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(0); // default(int) + } + + [Test] + public void ResultT_Any_FuncResultT_Success() + { + var result = new Result(5); + var ret = result.Any(i => new Result(i + 1)); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(6); + } + + [Test] + public void ResultT_Any_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.Any(i => new Result(i + 1)); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(1); // default(int) + 1 + } + + [Test] + public void Result_AnyAsT_FuncT_Success() + { + var result = new Result(); + var ret = result.AnyAs(() => 123); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public void Result_AnyAsT_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.AnyAs(() => 123); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public void Result_AnyAsT_FuncResultT_Success() + { + var result = new Result(); + var ret = result.AnyAs(() => new Result("abc")); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public void Result_AnyAsT_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.AnyAs(() => new Result("abc")); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public void ResultT_AnyAs_ActionT_Success() + { + int? captured = null; + var result = new Result(42); + var ret = result.AnyAs(i => captured = i); + captured.Should().Be(42); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_AnyAs_ActionT_Failure() + { + int? captured = null; + var result = new Result(new Exception("fail")); + var ret = result.AnyAs(i => captured = i); + captured.Should().Be(0); // default(int) + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_AnyAs_FuncTResult_Success() + { + var result = new Result(5); + var ret = result.AnyAs(i => new Result()); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_AnyAs_FuncTResult_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.AnyAs(i => new Result()); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_AnyAs_FuncTU_Success() + { + var result = new Result(5); + var ret = result.AnyAs(i => (i * 2).ToString()); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public void ResultT_AnyAs_FuncTU_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.AnyAs(i => (i * 2).ToString()); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("0"); + } + + [Test] + public void ResultT_AnyAs_FuncTResultU_Success() + { + var result = new Result(5); + var ret = result.AnyAs(i => new Result((i * 2).ToString())); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public void ResultT_AnyAs_FuncTResultU_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.AnyAs(i => new Result((i * 2).ToString())); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("0"); + } + + // --- ASYNC --- + + [Test] + public async Task Result_AnyAsync_Action_Success() + { + var called = false; + var result = new Result(); + var ret = await result.AsTask().Any(async () => { called = true; await Task.Yield(); }); + called.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_AnyAsync_Action_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().Any(async () => { called = true; await Task.Yield(); }); + called.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_AnyAsync_FuncResult_Success() + { + var called = false; + var result = new Result(); + var ret = await result.AsTask().AnyAsync(async () => { called = true; await Task.Yield(); return Result.Success; }); + called.Should().BeTrue(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task Result_AnyAsync_FuncResult_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().AnyAsync(async () => { called = true; await Task.Yield(); return Result.Success; }); + called.Should().BeTrue(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task ResultT_AnyAsync_ActionT_Success() + { + int? value = null; + var result = new Result(42); + var ret = await result.AsTask().Any(async i => { value = i; await Task.Yield(); }); + value.Should().Be(42); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_AnyAsync_ActionT_Failure() + { + int? value = null; + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().Any(async i => { value = i; await Task.Yield(); }); + value.Should().Be(0); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_AnyAsync_FuncT_Success() + { + var result = new Result(10); + var ret = await result.AsTask().Any(i => { return i * 2; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(20); + } + + [Test] + public async Task ResultT_AnyAsync_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().Any(i => { return i * 2; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(0); + } + + [Test] + public async Task ResultT_AnyAsync_FuncResultT_Success() + { + var result = new Result(5); + var ret = await result.AsTask().Any(i => { return new Result(i + 1); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(6); + } + + [Test] + public async Task ResultT_AnyAsync_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().Any(i => { return new Result(i + 1); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(1); + } + + [Test] + public async Task Result_AnyAsAsyncT_FuncT_Success() + { + var result = new Result(); + var ret = await result.AsTask().AnyAsAsync(async () => { await Task.Yield(); return 123; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public async Task Result_AnyAsAsyncT_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().AnyAsAsync(async () => { await Task.Yield(); return 123; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public async Task Result_AnyAsAsyncT_FuncResultT_Success() + { + var result = new Result(); + var ret = await result.AsTask().AnyAsAsync(async () => { await Task.Yield(); return new Result("abc"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public async Task Result_AnyAsAsyncT_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().AnyAsAsync(async () => { await Task.Yield(); return new Result("abc"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public async Task ResultT_AnyAsAsync_ActionT_Success() + { + int? captured = null; + var result = new Result(42); + var ret = await result.AsTask().AnyAsAsync(async i => { captured = i; await Task.Yield(); }); + captured.Should().Be(42); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task ResultT_AnyAsAsync_ActionT_Failure() + { + int? captured = null; + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().AnyAsAsync(async i => { captured = i; await Task.Yield(); }); + captured.Should().Be(0); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ResultT_AnyAsAsync_FuncTResult_Success() + { + var result = new Result(5); + var ret = await result.AsTask().AnyAsAsync(async i => { await Task.Yield(); return new Result(); }); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task ResultT_AnyAsAsync_FuncTResult_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().AnyAsAsync(async i => { await Task.Yield(); return new Result(); }); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task ResultT_AnyAsAsync_FuncTU_Success() + { + var result = new Result(5); + var ret = await result.AsTask().AnyAsAsync(async i => { await Task.Yield(); return (i * 2).ToString(); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public async Task ResultT_AnyAsAsync_FuncTU_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().AnyAsAsync(async i => { await Task.Yield(); return (i * 2).ToString(); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("0"); + } + + [Test] + public async Task ResultT_AnyAsAsync_FuncTResultU_Success() + { + var result = new Result(5); + var ret = await result.AsTask().AnyAsAsync(async i => { await Task.Yield(); return new Result((i * 2).ToString()); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public async Task ResultT_AnyAsAsync_FuncTResultU_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().AnyAsAsync(async i => { await Task.Yield(); return new Result((i * 2).ToString()); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("0"); + } +} diff --git a/tests/CoreEx.Test.Unit/Results/ExtensionsMatchTests.cs b/tests/CoreEx.Test.Unit/Results/ExtensionsMatchTests.cs new file mode 100644 index 00000000..03872c66 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ExtensionsMatchTests.cs @@ -0,0 +1,489 @@ +using CoreEx.Results; + +namespace CoreEx.Test.Unit.Results; + +public class ExtensionsMatchTests +{ + [Test] + public void Result_Match_Action_Success() + { + var okCalled = false; + var failCalled = false; + var result = new Result(); + var ret = result.Match(() => okCalled = true, e => failCalled = true); + okCalled.Should().BeTrue(); + failCalled.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public void Result_Match_Action_Failure() + { + var okCalled = false; + var failCalled = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.Match(() => okCalled = true, e => { failCalled = true; e.Should().Be(ex); }); + okCalled.Should().BeFalse(); + failCalled.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public void Result_Match_Func_Success() + { + var okCalled = false; + var failCalled = false; + var result = new Result(); + var ret = result.Match( + () => { okCalled = true; return Result.Success; }, + e => { failCalled = true; return Result.Fail(e); }); + okCalled.Should().BeTrue(); + failCalled.Should().BeFalse(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void Result_Match_Func_Failure() + { + var okCalled = false; + var failCalled = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.Match( + () => { okCalled = true; return Result.Success; }, + e => { failCalled = true; e.Should().Be(ex); return Result.Fail(e); }); + okCalled.Should().BeFalse(); + failCalled.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Result_MatchAsT_Success() + { + var okCalled = false; + var failCalled = false; + var result = new Result(); + var ret = result.MatchAs( + () => { okCalled = true; return Result.Ok(123); }, + e => { failCalled = true; return Result.Fail(e); }); + okCalled.Should().BeTrue(); + failCalled.Should().BeFalse(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public void Result_MatchAsT_Failure() + { + var okCalled = false; + var failCalled = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.MatchAs( + () => { okCalled = true; return Result.Ok(123); }, + e => { failCalled = true; e.Should().Be(ex); return Result.Fail(e); }); + okCalled.Should().BeFalse(); + failCalled.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void ResultT_Match_ActionT_Success() + { + int? okValue = null; + Exception? failEx = null; + var result = new Result(42); + var ret = result.Match(i => okValue = i, e => failEx = e); + okValue.Should().Be(42); + failEx.Should().BeNull(); + ret.Should().Be(result); + } + + [Test] + public void ResultT_Match_ActionT_Failure() + { + int? okValue = null; + Exception? failEx = null; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.Match(i => okValue = i, e => failEx = e); + okValue.Should().BeNull(); + failEx.Should().Be(ex); + ret.Should().Be(result); + } + + [Test] + public void ResultT_Match_FuncT_Success() + { + var result = new Result(10); + var ret = result.Match( + i => new Result(i * 2), + e => new Result(-1)); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(20); + } + + [Test] + public void ResultT_Match_FuncT_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.Match( + i => new Result(i * 2), + e => { e.Should().Be(ex); return new Result(-1); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(-1); + } + + [Test] + public void ResultT_MatchAsTU_Success() + { + var result = new Result(5); + var ret = result.MatchAs( + i => new Result((i * 2).ToString()), + e => new Result("fail")); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public void ResultT_MatchAsTU_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.MatchAs( + i => new Result((i * 2).ToString()), + e => { e.Should().Be(ex); return new Result("fail"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("fail"); + } + + // --- ASYNC --- + + [Test] + public async Task Result_MatchAsync_Action_Success() + { + var okCalled = false; + var failCalled = false; + var result = new Result(); + var ret = await result.AsTask().Match(() => okCalled = true, e => failCalled = true); + okCalled.Should().BeTrue(); + failCalled.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_MatchAsync_Action_Failure() + { + var okCalled = false; + var failCalled = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().Match(() => okCalled = true, e => { failCalled = true; e.Should().Be(ex); }); + okCalled.Should().BeFalse(); + failCalled.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_MatchAsync_Func_Success() + { + var okCalled = false; + var failCalled = false; + var result = new Result(); + var ret = await result.AsTask().Match( + () => { okCalled = true; return Result.Success; }, + e => { failCalled = true; return Result.Fail(e); }); + okCalled.Should().BeTrue(); + failCalled.Should().BeFalse(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task Result_MatchAsync_Func_Failure() + { + var okCalled = false; + var failCalled = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().Match( + () => { okCalled = true; return Result.Success; }, + e => { failCalled = true; e.Should().Be(ex); return Result.Fail(e); }); + okCalled.Should().BeFalse(); + failCalled.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public async Task Result_MatchAsAsyncT_Success() + { + var okCalled = false; + var failCalled = false; + var result = new Result(); + var ret = await result.AsTask().MatchAs( + () => { okCalled = true; return Result.Ok(123); }, + e => { failCalled = true; return Result.Fail(e); }); + okCalled.Should().BeTrue(); + failCalled.Should().BeFalse(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public async Task Result_MatchAsAsyncT_Failure() + { + var okCalled = false; + var failCalled = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().MatchAs( + () => { okCalled = true; return Result.Ok(123); }, + e => { failCalled = true; e.Should().Be(ex); return Result.Fail(e); }); + okCalled.Should().BeFalse(); + failCalled.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public async Task ResultT_MatchAsync_ActionT_Success() + { + int? okValue = null; + Exception? failEx = null; + var result = new Result(42); + var ret = await result.AsTask().Match(i => okValue = i, e => failEx = e); + okValue.Should().Be(42); + failEx.Should().BeNull(); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_MatchAsync_ActionT_Failure() + { + int? okValue = null; + Exception? failEx = null; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().Match(i => okValue = i, e => failEx = e); + okValue.Should().BeNull(); + failEx.Should().Be(ex); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_MatchAsync_FuncT_Success() + { + var result = new Result(10); + var ret = await result.AsTask().Match( + i => new Result(i * 2), + e => new Result(-1)); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(20); + } + + [Test] + public async Task ResultT_MatchAsync_FuncT_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().Match( + i => new Result(i * 2), + e => { e.Should().Be(ex); return new Result(-1); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(-1); + } + + [Test] + public async Task ResultT_MatchAsAsyncTU_Success() + { + var result = new Result(5); + var ret = await result.AsTask().MatchAs( + i => new Result((i * 2).ToString()), + e => new Result("fail")); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public async Task ResultT_MatchAsAsyncTU_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().MatchAs( + i => new Result((i * 2).ToString()), + e => { e.Should().Be(ex); return new Result("fail"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("fail"); + } + + // --- AsyncFunc --- + + [Test] + public async Task Result_MatchAsyncFunc_Success() + { + var okCalled = false; + var failCalled = false; + var result = new Result(); + var ret = await result.MatchAsync( + async () => { okCalled = true; await Task.Yield(); }, + async e => { failCalled = true; await Task.Yield(); }); + okCalled.Should().BeTrue(); + failCalled.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_MatchAsyncFunc_Failure() + { + var okCalled = false; + var failCalled = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.MatchAsync( + async () => { okCalled = true; await Task.Yield(); }, + async e => { failCalled = true; e.Should().Be(ex); await Task.Yield(); }); + okCalled.Should().BeFalse(); + failCalled.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_MatchAsyncFuncResult_Success() + { + var okCalled = false; + var failCalled = false; + var result = new Result(); + var ret = await result.MatchAsync( + async () => { okCalled = true; await Task.Yield(); return Result.Success; }, + async e => { failCalled = true; await Task.Yield(); return Result.Fail(e); }); + okCalled.Should().BeTrue(); + failCalled.Should().BeFalse(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task Result_MatchAsyncFuncResult_Failure() + { + var okCalled = false; + var failCalled = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.MatchAsync( + async () => { okCalled = true; await Task.Yield(); return Result.Success; }, + async e => { failCalled = true; e.Should().Be(ex); await Task.Yield(); return Result.Fail(e); }); + okCalled.Should().BeFalse(); + failCalled.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public async Task Result_MatchAsAsyncFuncT_Success() + { + var okCalled = false; + var failCalled = false; + var result = new Result(); + var ret = await result.MatchAsAsync( + async () => { okCalled = true; await Task.Yield(); return Result.Ok(123); }, + async e => { failCalled = true; await Task.Yield(); return Result.Fail(e); }); + okCalled.Should().BeTrue(); + failCalled.Should().BeFalse(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public async Task Result_MatchAsAsyncFuncT_Failure() + { + var okCalled = false; + var failCalled = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.MatchAsAsync( + async () => { okCalled = true; await Task.Yield(); return Result.Ok(123); }, + async e => { failCalled = true; e.Should().Be(ex); await Task.Yield(); return Result.Fail(e); }); + okCalled.Should().BeFalse(); + failCalled.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public async Task ResultT_MatchAsyncFuncT_Success() + { + int? okValue = null; + Exception? failEx = null; + var result = new Result(42); + var ret = await result.MatchAsync( + async i => { okValue = i; await Task.Yield(); }, + async e => { failEx = e; await Task.Yield(); }); + okValue.Should().Be(42); + failEx.Should().BeNull(); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_MatchAsyncFuncT_Failure() + { + int? okValue = null; + Exception? failEx = null; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.MatchAsync( + async i => { okValue = i; await Task.Yield(); }, + async e => { failEx = e; await Task.Yield(); }); + okValue.Should().BeNull(); + failEx.Should().Be(ex); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_MatchAsyncFuncResultT_Success() + { + var result = new Result(10); + var ret = await result.MatchAsync( + async i => { await Task.Yield(); return new Result(i * 2); }, + async e => { await Task.Yield(); return new Result(-1); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(20); + } + + [Test] + public async Task ResultT_MatchAsyncFuncResultT_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.MatchAsync( + async i => { await Task.Yield(); return new Result(i * 2); }, + async e => { e.Should().Be(ex); await Task.Yield(); return new Result(-1); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(-1); + } + + [Test] + public async Task ResultT_MatchAsAsyncFuncTU_Success() + { + var result = new Result(5); + var ret = await result.MatchAsAsync( + async i => { await Task.Yield(); return new Result((i * 2).ToString()); }, + async e => { await Task.Yield(); return new Result("fail"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public async Task ResultT_MatchAsAsyncFuncTU_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.MatchAsAsync( + async i => { await Task.Yield(); return new Result((i * 2).ToString()); }, + async e => { e.Should().Be(ex); await Task.Yield(); return new Result("fail"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("fail"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Results/ExtensionsOnFailureTests.cs b/tests/CoreEx.Test.Unit/Results/ExtensionsOnFailureTests.cs new file mode 100644 index 00000000..2722950c --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ExtensionsOnFailureTests.cs @@ -0,0 +1,419 @@ +using CoreEx.Results; + +namespace CoreEx.Test.Unit.Results; + +public class ExtensionsOnFailureTests +{ + [Test] + public void Result_OnFailure_Action_Success() + { + var called = false; + var result = new Result(); + var ret = result.OnFailure(e => called = true); + called.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public void Result_OnFailure_Action_Failure() + { + var called = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.OnFailure(e => { called = true; e.Error.Should().Be(ex); }); + called.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void Result_OnFailure_Func_Success() + { + var result = new Result(); + var ret = result.OnFailure(_ => Result.Fail(new Exception("fail2"))); + ret.Should().Be(result); + } + + [Test] + public void Result_OnFailure_Func_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.OnFailure(_ => Result.Success); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_OnFailure_Action_Success() + { + var called = false; + var result = new Result(42); + var ret = result.OnFailure(e => called = true); + called.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public void ResultT_OnFailure_Action_Failure() + { + var called = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.OnFailure(e => { called = true; e.Error.Should().Be(ex); }); + called.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void ResultT_OnFailure_FuncT_Success() + { + var result = new Result(42); + var ret = result.OnFailure(e => 99); + ret.Should().Be(result); + } + + [Test] + public void ResultT_OnFailure_FuncT_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.OnFailure(e => 99); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(99); + } + + [Test] + public void ResultT_OnFailure_FuncResultT_Success() + { + var result = new Result(42); + var ret = result.OnFailure(e => new Result(99)); + ret.Should().Be(result); + } + + [Test] + public void ResultT_OnFailure_FuncResultT_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.OnFailure(e => new Result(99)); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(99); + } + + [Test] + public void Result_OnFailureAsT_FuncT_Success() + { + var result = new Result(); + var ret = result.OnFailureAs(_ => 123); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public void Result_OnFailureAsT_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.OnFailureAs(_ => 123); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public void Result_OnFailureAsT_FuncResultT_Success() + { + var result = new Result(); + var ret = result.OnFailureAs(_ => new Result("abc")); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public void Result_OnFailureAsT_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.OnFailureAs(_ => new Result("abc")); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public void ResultT_OnFailureAs_ActionT_Success() + { + var called = false; + var result = new Result(42); + var ret = result.OnFailureAs(e => called = true); + called.Should().BeFalse(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_OnFailureAs_ActionT_Failure() + { + var called = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.OnFailureAs(e => { called = true; e.Error.Should().Be(ex); }); + called.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void ResultT_OnFailureAs_FuncTResult_Success() + { + var result = new Result(42); + var ret = result.OnFailureAs(e => new Result()); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_OnFailureAs_FuncTResult_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.OnFailureAs(e => new Result()); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_OnFailureAs_FuncTU_Success() + { + var result = new Result(42); + var ret = result.OnFailureAs(e => "abc"); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public void ResultT_OnFailureAs_FuncTU_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.OnFailureAs(e => "abc"); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public void ResultT_OnFailureAs_FuncTResultU_Success() + { + var result = new Result(42); + var ret = result.OnFailureAs(e => new Result("abc")); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public void ResultT_OnFailureAs_FuncTResultU_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.OnFailureAs(e => new Result("abc")); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + // --- ASYNC --- + + [Test] + public async Task Result_OnFailureAsync_Func_Success() + { + var called = false; + var result = new Result(); + var ret = await result.OnFailureAsync(async _ => { called = true; await Task.Yield(); }); + called.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_OnFailureAsync_Func_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = await result.OnFailureAsync(async _ => { called = true; await Task.Yield(); }); + called.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task Result_OnFailureAsync_FuncResult_Success() + { + var result = new Result(); + var ret = await result.OnFailureAsync(async _ => { await Task.Yield(); return Result.Fail(new Exception("fail2")); }); + ret.Should().Be(result); + } + + [Test] + public async Task Result_OnFailureAsync_FuncResult_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.OnFailureAsync(async _ => { await Task.Yield(); return Result.Success; }); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task ResultT_OnFailureAsync_ActionT_Success() + { + var called = false; + var result = new Result(42); + var ret = await result.OnFailureAsync(async e => { called = true; await Task.Yield(); }); + called.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_OnFailureAsync_ActionT_Failure() + { + var called = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.OnFailureAsync(async e => { called = true; e.Error.Should().Be(ex); await Task.Yield(); }); + called.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ResultT_OnFailureAsync_FuncT_Success() + { + var result = new Result(42); + var ret = await result.OnFailureAsync(async e => { await Task.Yield(); return 99; }); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_OnFailureAsync_FuncT_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.OnFailureAsync(async e => { await Task.Yield(); return 99; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(99); + } + + [Test] + public async Task ResultT_OnFailureAsync_FuncResultT_Success() + { + var result = new Result(42); + var ret = await result.OnFailureAsync(async e => { await Task.Yield(); return new Result(99); }); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_OnFailureAsync_FuncResultT_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.OnFailureAsync(async e => { await Task.Yield(); return new Result(99); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(99); + } + + [Test] + public async Task Result_OnFailureAsAsyncT_FuncT_Success() + { + var result = new Result(); + var ret = await result.OnFailureAsAsync(async _ => { await Task.Yield(); return 123; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public async Task Result_OnFailureAsAsyncT_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.OnFailureAsAsync(async _ => { await Task.Yield(); return 123; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public async Task Result_OnFailureAsAsyncT_FuncResultT_Success() + { + var result = new Result(); + var ret = await result.OnFailureAsAsync(async _ => { await Task.Yield(); return new Result("abc"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public async Task Result_OnFailureAsAsyncT_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.OnFailureAsAsync(async _ => { await Task.Yield(); return new Result("abc"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public async Task ResultT_OnFailureAsAsync_ActionT_Success() + { + var called = false; + var result = new Result(42); + var ret = await result.AsTask().OnFailureAsAsync(async e => { called = true; await Task.Yield(); }); + called.Should().BeFalse(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task ResultT_OnFailureAsAsync_ActionT_Failure() + { + var called = false; + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().OnFailureAsAsync(async e => { called = true; e.Error.Should().Be(ex); await Task.Yield(); }); + called.Should().BeTrue(); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ResultT_OnFailureAsAsync_FuncTResult_Success() + { + var result = new Result(42); + var ret = await result.AsTask().OnFailureAsAsync(async e => { await Task.Yield(); return new Result(); }); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task ResultT_OnFailureAsAsync_FuncTResult_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().OnFailureAsAsync(async e => { await Task.Yield(); return new Result(); }); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task ResultT_OnFailureAsAsync_FuncTU_Success() + { + var result = new Result(42); + var ret = await result.AsTask().OnFailureAsAsync(async e => { await Task.Yield(); return "abc"; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public async Task ResultT_OnFailureAsAsync_FuncTU_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().OnFailureAsAsync(async e => { await Task.Yield(); return "abc"; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public async Task ResultT_OnFailureAsAsync_FuncTResultU_Success() + { + var result = new Result(42); + var ret = await result.AsTask().OnFailureAsAsync(async e => { await Task.Yield(); return new Result("abc"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public async Task ResultT_OnFailureAsAsync_FuncTResultU_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().OnFailureAsAsync(async e => { await Task.Yield(); return new Result("abc"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Results/ExtensionsTests.cs b/tests/CoreEx.Test.Unit/Results/ExtensionsTests.cs new file mode 100644 index 00000000..ba77d127 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ExtensionsTests.cs @@ -0,0 +1,359 @@ +using CoreEx.Results; + +namespace CoreEx.Test.Unit.Results; + +public class ExtensionsTests +{ + [Test] + public void Bind_T_U_Success() + { + var result = new Result(5); + var ret = result.Bind(i => new Result(i.ToString())); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("5"); + } + + [Test] + public void Bind_T_U_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.Bind(i => new Result(i.ToString())); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Bind_T_U_Success_TAssignableToU() + { + var result = new Result("abc"); + var ret = result.Bind(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public void Bind_T_U_Success_TNotAssignableToU() + { + var result = new Result(42); + var ret = result.Bind(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public void Bind_T_U_Failure_TAssignableToU() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.Bind(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Bind_T_U_Failure_TNotAssignableToU() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.Bind(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Bind_T_Func_Success() + { + var result = new Result(); + var ret = result.Bind(() => new Result(123)); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public void Bind_T_Func_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.Bind(() => new Result(123)); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Bind_T_Success() + { + var result = new Result(); + var ret = result.Bind(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public void Bind_T_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.Bind(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Bind_T_ResultT_Success() + { + var result = new Result(42); + var ret = result.Bind(); + ret.IsSuccess.Should().BeTrue(); + ret.IsFailure.Should().BeFalse(); + } + + [Test] + public void Bind_T_ResultT_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.Bind(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Combine_Result_Result_BothSuccess() + { + var r1 = new Result(); + var r2 = new Result(); + var ret = r1.Combine(r2); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void Combine_Result_Result_ThisFailure() + { + var ex = new Exception("fail1"); + var r1 = new Result(ex); + var r2 = new Result(); + var ret = r1.Combine(r2); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Combine_Result_Result_OtherFailure() + { + var ex = new Exception("fail2"); + var r1 = new Result(); + var r2 = new Result(ex); + var ret = r1.Combine(r2); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Combine_Result_Result_BothFailure() + { + var ex1 = new Exception("fail1"); + var ex2 = new Exception("fail2"); + var r1 = new Result(ex1); + var r2 = new Result(ex2); + var ret = r1.Combine(r2); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().BeOfType(); + var agg = (AggregateException)ret.Error; + agg.InnerExceptions.Should().Contain([ex1, ex2]); + } + + [Test] + public void Combine_Result_ResultT_BothSuccess() + { + var r1 = new Result(); + var r2 = new Result(42); + var ret = r1.Combine(r2); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(42); + } + + [Test] + public void Combine_Result_ResultT_ThisFailure() + { + var ex = new Exception("fail1"); + var r1 = new Result(ex); + var r2 = new Result(42); + var ret = r1.Combine(r2); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Combine_Result_ResultT_OtherFailure() + { + var ex = new Exception("fail2"); + var r1 = new Result(); + var r2 = new Result(ex); + var ret = r1.Combine(r2); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Combine_Result_ResultT_BothFailure() + { + var ex1 = new Exception("fail1"); + var ex2 = new Exception("fail2"); + var r1 = new Result(ex1); + var r2 = new Result(ex2); + var ret = r1.Combine(r2); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().BeOfType(); + var agg = (AggregateException)ret.Error; + agg.InnerExceptions.Should().Contain([ex1, ex2]); + } + + [Test] + public void Combine_ResultT_ResultU_BothSuccess_TAssignableToU() + { + var r1 = new Result("abc"); + var r2 = new Result(new object()); + var ret = r1.Combine(r2); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public void Combine_ResultT_ResultU_BothSuccess_TNotAssignableToU() + { + var r1 = new Result(42); + var r2 = new Result("abc"); + var ret = r1.Combine(r2); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public void Combine_ResultT_ResultU_ThisFailure() + { + var ex = new Exception("fail1"); + var r1 = new Result(ex); + var r2 = new Result("abc"); + var ret = r1.Combine(r2); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Combine_ResultT_ResultU_OtherFailure() + { + var ex = new Exception("fail2"); + var r1 = new Result(42); + var r2 = new Result(ex); + var ret = r1.Combine(r2); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void Combine_ResultT_ResultU_BothFailure() + { + var ex1 = new Exception("fail1"); + var ex2 = new Exception("fail2"); + var r1 = new Result(ex1); + var r2 = new Result(ex2); + var ret = r1.Combine(r2); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().BeOfType(); + var agg = (AggregateException)ret.Error; + agg.InnerExceptions.Should().Contain([ex1, ex2]); + } + + [Test] + public void AsResult_ResultT_Success() + { + var result = new Result(42); + var ret = result.AsResult(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void AsResult_ResultT_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.AsResult(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public async Task AsResult_ValueTask_ResultT_Success() + { + var result = new Result(42); + var ret = await result.AsTask().AsResultAsync(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task AsResult_ValueTask_ResultT_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().AsResultAsync(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void AsResult_T_U_Success_TAssignableToU() + { + var result = new Result("abc"); + var ret = result.AsResult(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public void AsResult_T_U_Success_TNotAssignableToU() + { + var result = new Result(42); + var ret = result.AsResult(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public void AsResult_T_U_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = result.AsResult(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public async Task AsResult_ValueTask_T_U_Success_TAssignableToU() + { + var result = new Result("abc"); + var ret = await result.AsTask().AsResultAsync(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public async Task AsResult_ValueTask_T_U_Success_TNotAssignableToU() + { + var result = new Result(42); + var ret = await result.AsTask().AsResultAsync(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(default); + } + + [Test] + public async Task AsResult_ValueTask_T_U_Failure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + var ret = await result.AsTask().AsResultAsync(); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Results/ExtensionsThenTests.cs b/tests/CoreEx.Test.Unit/Results/ExtensionsThenTests.cs new file mode 100644 index 00000000..f6466940 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ExtensionsThenTests.cs @@ -0,0 +1,438 @@ +using CoreEx.Results; + +namespace CoreEx.Test.Unit.Results; + +public class ExtensionsThenTests +{ + [Test] + public void Result_Then_Action_Success() + { + var called = false; + var result = new Result(); + var ret = result.Then(() => called = true); + called.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public void Result_Then_Action_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = result.Then(() => called = true); + called.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public void Result_Then_FuncResult_Success() + { + var called = false; + var result = new Result(); + var ret = result.Then(() => { called = true; return Result.Success; }); + called.Should().BeTrue(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void Result_Then_FuncResult_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = result.Then(() => { called = true; return Result.Success; }); + called.Should().BeFalse(); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void ResultT_Then_ActionT_Success() + { + int? value = null; + var result = new Result(42); + var ret = result.Then(i => value = i); + value.Should().Be(42); + ret.Should().Be(result); + } + + [Test] + public void ResultT_Then_ActionT_Failure() + { + int? value = null; + var result = new Result(new Exception("fail")); + var ret = result.Then(i => value = i); + value.Should().BeNull(); + ret.Should().Be(result); + } + + [Test] + public void ResultT_Then_FuncT_Success() + { + var result = new Result(10); + var ret = result.Then(i => i * 2); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(20); + } + + [Test] + public void ResultT_Then_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.Then(i => i * 2); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void ResultT_Then_FuncResultT_Success() + { + var result = new Result(5); + var ret = result.Then(i => new Result(i + 1)); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(6); + } + + [Test] + public void ResultT_Then_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.Then(i => new Result(99)); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void ResultT_Then_FuncTResult_Success() + { + var result = new Result(5); + var ret = result.Then(i => Result.Success); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(5); + } + + [Test] + public void ResultT_Then_FuncTResult_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.Then(i => Result.Success); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void Result_ThenAsT_FuncT_Success() + { + var result = new Result(); + var ret = result.ThenAs(() => 123); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public void Result_ThenAsT_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.ThenAs(() => 123); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void Result_ThenAsT_FuncResultT_Success() + { + var result = new Result(); + var ret = result.ThenAs(() => new Result("abc")); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public void Result_ThenAsT_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.ThenAs(() => new Result("abc")); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void ResultT_ThenAs_ActionT_Success() + { + int? captured = null; + var result = new Result(42); + var ret = result.ThenAs(i => captured = i); + captured.Should().Be(42); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_ThenAs_ActionT_Failure() + { + int? captured = null; + var result = new Result(new Exception("fail")); + var ret = result.ThenAs(i => captured = i); + captured.Should().BeNull(); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void ResultT_ThenAs_FuncTResult_Success() + { + var result = new Result(5); + var ret = result.ThenAs(i => new Result()); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void ResultT_ThenAs_FuncTResult_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.ThenAs(i => new Result()); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void ResultT_ThenAs_FuncTU_Success() + { + var result = new Result(5); + var ret = result.ThenAs(i => (i * 2).ToString()); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public void ResultT_ThenAs_FuncTU_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.ThenAs(i => (i * 2).ToString()); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void ResultT_ThenAs_FuncTResultU_Success() + { + var result = new Result(5); + var ret = result.ThenAs(i => new Result((i * 2).ToString())); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public void ResultT_ThenAs_FuncTResultU_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.ThenAs(i => new Result((i * 2).ToString())); + ret.IsFailure.Should().BeTrue(); + } + + // --- ASYNC --- + + [Test] + public async Task Result_ThenAsync_Action_Success() + { + var called = false; + var result = new Result(); + var ret = await result.ThenAsync(async () => { called = true; await Task.Yield(); }); + called.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_ThenAsync_Action_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = await result.ThenAsync(async () => { called = true; await Task.Yield(); }); + called.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_ThenAsync_FuncResult_Success() + { + var called = false; + var result = new Result(); + var ret = await result.ThenAsync(async () => { called = true; await Task.Yield(); return Result.Success; }); + called.Should().BeTrue(); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task Result_ThenAsync_FuncResult_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = await result.ThenAsync(async () => { called = true; await Task.Yield(); return Result.Success; }); + called.Should().BeFalse(); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ResultT_ThenAsync_ActionT_Success() + { + int? value = null; + var result = new Result(42); + var ret = await result.ThenAsync(async i => { value = i; await Task.Yield(); }); + value.Should().Be(42); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_ThenAsync_ActionT_Failure() + { + int? value = null; + var result = new Result(new Exception("fail")); + var ret = await result.ThenAsync(async i => { value = i; await Task.Yield(); }); + value.Should().BeNull(); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_ThenAsync_FuncT_Success() + { + var result = new Result(10); + var ret = await result.ThenAsync(async i => { await Task.Yield(); return i * 2; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(20); + } + + [Test] + public async Task ResultT_ThenAsync_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.ThenAsync(async i => { await Task.Yield(); return i * 2; }); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ResultT_ThenAsync_FuncResultT_Success() + { + var result = new Result(5); + var ret = await result.ThenAsync(async i => { await Task.Yield(); return new Result(i + 1); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(6); + } + + [Test] + public async Task ResultT_ThenAsync_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.ThenAsync(async i => { await Task.Yield(); return new Result(99); }); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ResultT_ThenAsync_FuncTResult_Success() + { + var result = new Result(5); + var ret = await result.ThenAsync(async i => { await Task.Yield(); return Result.Success; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(5); + } + + [Test] + public async Task ResultT_ThenAsync_FuncTResult_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.ThenAsync(async i => { await Task.Yield(); return Result.Success; }); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task Result_ThenAsAsyncT_FuncT_Success() + { + var result = new Result(); + var ret = await result.ThenAsAsync(async () => { await Task.Yield(); return 123; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public async Task Result_ThenAsAsyncT_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.ThenAsAsync(async () => { await Task.Yield(); return 123; }); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task Result_ThenAsAsyncT_FuncResultT_Success() + { + var result = new Result(); + var ret = await result.ThenAsAsync(async () => { await Task.Yield(); return new Result("abc"); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("abc"); + } + + [Test] + public async Task Result_ThenAsAsyncT_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.ThenAsAsync(async () => { await Task.Yield(); return new Result("abc"); }); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ResultT_ThenAsAsync_ActionT_Success() + { + int? captured = null; + var result = new Result(42); + var ret = await result.AsTask().ThenAsAsync(async i => { captured = i; await Task.Yield(); }); + captured.Should().Be(42); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task ResultT_ThenAsAsync_ActionT_Failure() + { + int? captured = null; + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().ThenAsAsync(async i => { captured = i; await Task.Yield(); }); + captured.Should().BeNull(); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ResultT_ThenAsAsync_FuncTResult_Success() + { + var result = new Result(5); + var ret = await result.AsTask().ThenAsAsync(async i => { await Task.Yield(); return new Result(); }); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task ResultT_ThenAsAsync_FuncTResult_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().ThenAsAsync(async i => { await Task.Yield(); return new Result(); }); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ResultT_ThenAsAsync_FuncTU_Success() + { + var result = new Result(5); + var ret = await result.AsTask().ThenAsAsync(async i => { await Task.Yield(); return (i * 2).ToString(); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public async Task ResultT_ThenAsAsync_FuncTU_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().ThenAsAsync(async i => { await Task.Yield(); return (i * 2).ToString(); }); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ResultT_ThenAsAsync_FuncTResultU_Success() + { + var result = new Result(5); + var ret = await result.AsTask().ThenAsAsync(async i => { await Task.Yield(); return new Result((i * 2).ToString()); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be("10"); + } + + [Test] + public async Task ResultT_ThenAsAsync_FuncTResultU_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.AsTask().ThenAsAsync(async i => { await Task.Yield(); return new Result((i * 2).ToString()); }); + ret.IsFailure.Should().BeTrue(); + } +} diff --git a/tests/CoreEx.Test.Unit/Results/ExtensionsWhenTests.cs b/tests/CoreEx.Test.Unit/Results/ExtensionsWhenTests.cs new file mode 100644 index 00000000..3b599921 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ExtensionsWhenTests.cs @@ -0,0 +1,392 @@ +using CoreEx.Results; + +namespace CoreEx.Test.Unit.Results; + +public class ExtensionsWhenTests +{ + [Test] + public void Result_When_Action_ConditionTrue_Success() + { + var called = false; + var result = new Result(); + var ret = result.When(() => true, () => called = true); + called.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public void Result_When_Action_ConditionFalse_Success() + { + var called = false; + var otherwiseCalled = false; + var result = new Result(); + var ret = result.When(() => false, () => called = true, () => otherwiseCalled = true); + called.Should().BeFalse(); + otherwiseCalled.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public void Result_When_Action_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = result.When(() => true, () => called = true); + called.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public void Result_When_Func_ConditionTrue_Success() + { + var result = new Result(); + var ret = result.When(() => true, () => Result.Success); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void Result_When_Func_ConditionFalse_Success() + { + var result = new Result(); + var ret = result.When(() => false, () => Result.Fail(new Exception("fail")), () => Result.Success); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public void Result_When_Func_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.When(() => true, () => Result.Success); + ret.Should().Be(result); + } + + [Test] + public void ResultT_When_ActionT_ConditionTrue_Success() + { + int? value = null; + var result = new Result(42); + var ret = result.When(i => i == 42, i => value = i); + value.Should().Be(42); + ret.Should().Be(result); + } + + [Test] + public void ResultT_When_ActionT_ConditionFalse_Success() + { + int? value = null; + int? otherwise = null; + var result = new Result(42); + var ret = result.When(i => i == 0, i => value = i, i => otherwise = i); + value.Should().BeNull(); + otherwise.Should().Be(42); + ret.Should().Be(result); + } + + [Test] + public void ResultT_When_ActionT_Failure() + { + int? value = null; + var result = new Result(new Exception("fail")); + var ret = result.When(i => true, i => value = i); + value.Should().BeNull(); + ret.Should().Be(result); + } + + [Test] + public void ResultT_When_FuncT_ConditionTrue_Success() + { + var result = new Result(10); + var ret = result.When(i => i == 10, i => i * 2); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(20); + } + + [Test] + public void ResultT_When_FuncT_ConditionFalse_Success() + { + var result = new Result(10); + var ret = result.When(i => i == 0, i => i * 2, i => 99); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(99); + } + + [Test] + public void ResultT_When_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.When(i => true, i => i * 2); + ret.Should().Be(result); + } + + [Test] + public void ResultT_When_FuncResultT_ConditionTrue_Success() + { + var result = new Result(5); + var ret = result.When(i => i == 5, i => new Result(i + 1)); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(6); + } + + [Test] + public void ResultT_When_FuncResultT_ConditionFalse_Success() + { + var result = new Result(5); + var ret = result.When(i => i == 0, i => new Result(i + 1), i => new Result(99)); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(99); + } + + [Test] + public void ResultT_When_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.When(i => true, i => new Result(i + 1)); + ret.Should().Be(result); + } + + [Test] + public void ResultT_WhenAs_FuncT_ConditionTrue_Success() + { + var result = new Result(5); + var ret = result.WhenAs(i => i == 5, i => i * 2); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(10); + } + + [Test] + public void ResultT_WhenAs_FuncT_ConditionFalse_Success() + { + var result = new Result(5); + var ret = result.WhenAs(i => i == 0, i => i * 2, i => 99); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(99); + } + + [Test] + public void ResultT_WhenAs_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.WhenAs(i => true, i => i * 2); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public void Result_WhenAsT_ConditionTrue_Success() + { + var result = new Result(); + var ret = result.WhenAs(() => true, () => 123); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public void Result_WhenAsT_ConditionFalse_Success() + { + var result = new Result(); + var ret = result.WhenAs(() => false, () => 123, () => 456); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(456); + } + + [Test] + public void Result_WhenAsT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.WhenAs(() => true, () => 123); + ret.IsFailure.Should().BeTrue(); + } + + // --- ASYNC --- + + [Test] + public async Task Result_WhenAsync_Action_ConditionTrue_Success() + { + var called = false; + var result = new Result(); + var ret = await result.WhenAsync(() => true, async () => { called = true; await Task.Yield(); }); + called.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_WhenAsync_Action_ConditionFalse_Success() + { + var called = false; + var otherwiseCalled = false; + var result = new Result(); + var ret = await result.WhenAsync(() => false, async () => { called = true; await Task.Yield(); }, async () => { otherwiseCalled = true; await Task.Yield(); }); + called.Should().BeFalse(); + otherwiseCalled.Should().BeTrue(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_WhenAsync_Action_Failure() + { + var called = false; + var result = new Result(new Exception("fail")); + var ret = await result.WhenAsync(() => true, async () => { called = true; await Task.Yield(); }); + called.Should().BeFalse(); + ret.Should().Be(result); + } + + [Test] + public async Task Result_WhenAsync_Func_ConditionTrue_Success() + { + var result = new Result(); + var ret = await result.WhenAsync(() => true, async () => { await Task.Yield(); return Result.Success; }); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task Result_WhenAsync_Func_ConditionFalse_Success() + { + var result = new Result(); + var ret = await result.WhenAsync(() => false, async () => { await Task.Yield(); return Result.Fail(new Exception("fail")); }, async () => { await Task.Yield(); return Result.Success; }); + ret.IsSuccess.Should().BeTrue(); + } + + [Test] + public async Task Result_WhenAsync_Func_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.WhenAsync(() => true, async () => { await Task.Yield(); return Result.Success; }); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_WhenAsync_ActionT_ConditionTrue_Success() + { + int? value = null; + var result = new Result(42); + var ret = await result.WhenAsync(i => i == 42, async i => { value = i; await Task.Yield(); }); + value.Should().Be(42); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_WhenAsync_ActionT_ConditionFalse_Success() + { + int? value = null; + int? otherwise = null; + var result = new Result(42); + var ret = await result.WhenAsync(i => i == 0, async i => { value = i; await Task.Yield(); }, async i => { otherwise = i; await Task.Yield(); }); + value.Should().BeNull(); + otherwise.Should().Be(42); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_WhenAsync_ActionT_Failure() + { + int? value = null; + var result = new Result(new Exception("fail")); + var ret = await result.WhenAsync(i => true, async i => { value = i; await Task.Yield(); }); + value.Should().BeNull(); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_WhenAsync_FuncT_ConditionTrue_Success() + { + var result = new Result(10); + var ret = await result.WhenAsync(i => i == 10, async i => { await Task.Yield(); return i * 2; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(20); + } + + [Test] + public async Task ResultT_WhenAsync_FuncT_ConditionFalse_Success() + { + var result = new Result(10); + var ret = await result.WhenAsync(i => i == 0, async i => { await Task.Yield(); return i * 2; }, async i => { await Task.Yield(); return 99; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(99); + } + + [Test] + public async Task ResultT_WhenAsync_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.WhenAsync(i => true, async i => { await Task.Yield(); return i * 2; }); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_WhenAsync_FuncResultT_ConditionTrue_Success() + { + var result = new Result(5); + var ret = await result.WhenAsync(i => i == 5, async i => { await Task.Yield(); return new Result(i + 1); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(6); + } + + [Test] + public async Task ResultT_WhenAsync_FuncResultT_ConditionFalse_Success() + { + var result = new Result(5); + var ret = await result.WhenAsync(i => i == 0, async i => { await Task.Yield(); return new Result(i + 1); }, async i => { await Task.Yield(); return new Result(99); }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(99); + } + + [Test] + public async Task ResultT_WhenAsync_FuncResultT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.WhenAsync(i => true, async i => { await Task.Yield(); return new Result(i + 1); }); + ret.Should().Be(result); + } + + [Test] + public async Task ResultT_WhenAsAsync_FuncT_ConditionTrue_Success() + { + var result = new Result(5); + var ret = await result.WhenAsAsync(i => i == 5, async i => { await Task.Yield(); return i * 2; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(10); + } + + [Test] + public async Task ResultT_WhenAsAsync_FuncT_ConditionFalse_Success() + { + var result = new Result(5); + var ret = await result.WhenAsAsync(i => i == 0, async i => { await Task.Yield(); return i * 2; }, async i => { await Task.Yield(); return 99; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(99); + } + + [Test] + public async Task ResultT_WhenAsAsync_FuncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.WhenAsAsync(i => true, async i => { await Task.Yield(); return i * 2; }); + ret.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task Result_WhenAsAsyncT_ConditionTrue_Success() + { + var result = new Result(); + var ret = await result.WhenAsAsync(() => true, async () => { await Task.Yield(); return 123; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(123); + } + + [Test] + public async Task Result_WhenAsAsyncT_ConditionFalse_Success() + { + var result = new Result(); + var ret = await result.WhenAsAsync(() => false, async () => { await Task.Yield(); return 123; }, async () => { await Task.Yield(); return 456; }); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(456); + } + + [Test] + public async Task Result_WhenAsAsyncT_Failure() + { + var result = new Result(new Exception("fail")); + var ret = await result.WhenAsAsync(() => true, async () => { await Task.Yield(); return 123; }); + ret.IsFailure.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Results/ResultErrorTests.cs b/tests/CoreEx.Test.Unit/Results/ResultErrorTests.cs new file mode 100644 index 00000000..4b0aaaf2 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ResultErrorTests.cs @@ -0,0 +1,181 @@ +using CoreEx.Entities; +using CoreEx.Localization; +using CoreEx.Results; + +namespace CoreEx.Test.Unit.Results; + +public class ResultErrorTests +{ + [Test] + public void ValidationError_LText_Null() + { + var result = Result.ValidationError((LText?)null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ValidationException)result.Error).Message.Should().NotBeNull(); + } + + [Test] + public void ValidationError_LText_Value() + { + var ltext = new LText("Validation failed"); + var result = Result.ValidationError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ValidationException)result.Error).Message.Should().Contain("Validation failed"); + } + + [Test] + public void ValidationError_IEnumerable_Null() + { + var result = Result.ValidationError((IEnumerable?)null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void ValidationError_IEnumerable_List() + { + var items = new List { new(MessageType.Error, new LText("msg")) }; + var result = Result.ValidationError(items); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ValidationException)result.Error).Messages.Should().ContainSingle(); + } + + [Test] + public void ValidationError_MessageItem() + { + var item = new MessageItem(MessageType.Error, new LText("msg")); + var result = Result.ValidationError(item); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ValidationException)result.Error).Messages.Should().ContainSingle(); + } + + [Test] + public void ConflictError_Null() + { + var result = Result.ConflictError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void ConflictError_Value() + { + var ltext = new LText("conflict"); + var result = Result.ConflictError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ConflictException)result.Error).Message.Should().Contain("conflict"); + } + + [Test] + public void ConcurrencyError_Null() + { + var result = Result.ConcurrencyError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void ConcurrencyError_Value() + { + var ltext = new LText("concurrency"); + var result = Result.ConcurrencyError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ConcurrencyException)result.Error).Message.Should().Contain("concurrency"); + } + + [Test] + public void DuplicateError_Null() + { + var result = Result.DuplicateError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void DuplicateError_Value() + { + var ltext = new LText("duplicate"); + var result = Result.DuplicateError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((DuplicateException)result.Error).Message.Should().Contain("duplicate"); + } + + [Test] + public void NotFoundError_Null() + { + var result = Result.NotFoundError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void NotFoundError_Value() + { + var ltext = new LText("notfound"); + var result = Result.NotFoundError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((NotFoundException)result.Error).Message.Should().Contain("notfound"); + } + + [Test] + public void TransientError_Null() + { + var result = Result.TransientError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void TransientError_Value() + { + var ltext = new LText("transient"); + var result = Result.TransientError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((TransientException)result.Error).Message.Should().Contain("transient"); + } + + [Test] + public void AuthenticationError_Null() + { + var result = Result.AuthenticationError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void AuthenticationError_Value() + { + var ltext = new LText("auth"); + var result = Result.AuthenticationError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((AuthenticationException)result.Error).Message.Should().Contain("auth"); + } + + [Test] + public void AuthorizationError_Null() + { + var result = Result.AuthorizationError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void AuthorizationError_Value() + { + var ltext = new LText("author"); + var result = Result.AuthorizationError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((AuthorizationException)result.Error).Message.Should().Contain("author"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Results/ResultStaticTests.cs b/tests/CoreEx.Test.Unit/Results/ResultStaticTests.cs new file mode 100644 index 00000000..839001d0 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ResultStaticTests.cs @@ -0,0 +1,74 @@ +using CoreEx.Localization; +using CoreEx.Results; + +namespace CoreEx.Test.Unit.Results; + +public class ResultStaticTests +{ + [Test] + public void Success_ShouldBeSuccess() + { + var result = Result.Success; + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + } + + [Test] + public async Task SuccessValueTask_ShouldBeSuccess() + { + var result = await Result.SuccessTask; + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + } + + [Test] + public void Done_Action_CallsActionAndReturnsSuccess() + { + var called = false; + var result = Result.Done(() => called = true); + called.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); + } + + [Test] + public void Done_Action_Null_Throws() + { + Action act = () => Result.Done(null!); + act.Should().Throw(); + } + + [Test] + public void OkT_ReturnsSuccessWithValue() + { + var result = Result.Ok(123); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(123); + } + + [Test] + public void Fail_Exception_ReturnsFailureWithError() + { + var ex = new Exception("fail"); + var result = Result.Fail(ex); + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ex); + } + + [Test] + public void Fail_LText_Null_ReturnsFailureWithValidationException() + { + var result = Result.Fail((LText?)null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void Fail_LText_Value_ReturnsFailureWithValidationException() + { + var ltext = new LText("fail error"); + var result = Result.Fail(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + result.Error.Message.Should().Contain("fail error"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Results/ResultTErrorTests.cs b/tests/CoreEx.Test.Unit/Results/ResultTErrorTests.cs new file mode 100644 index 00000000..95c64d74 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ResultTErrorTests.cs @@ -0,0 +1,181 @@ +using CoreEx.Entities; +using CoreEx.Localization; +using CoreEx.Results; + +namespace CoreEx.Test.Unit.Results; + +public class ResultTErrorTests +{ + [Test] + public void ValidationError_LText_Null() + { + var result = Result.ValidationError((LText?)null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ValidationException)result.Error).Message.Should().NotBeNull(); + } + + [Test] + public void ValidationError_LText_Value() + { + var ltext = new LText("Validation failed"); + var result = Result.ValidationError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ValidationException)result.Error).Message.Should().Contain("Validation failed"); + } + + [Test] + public void ValidationError_IEnumerable_Null() + { + var result = Result.ValidationError((IEnumerable?)null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void ValidationError_IEnumerable_List() + { + var items = new List { new(MessageType.Error, new LText("msg")) }; + var result = Result.ValidationError(items); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ValidationException)result.Error).Messages.Should().ContainSingle(); + } + + [Test] + public void ValidationError_MessageItem() + { + var item = new MessageItem(MessageType.Error, new LText("msg")); + var result = Result.ValidationError(item); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ValidationException)result.Error).Messages.Should().ContainSingle(); + } + + [Test] + public void ConflictError_Null() + { + var result = Result.ConflictError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void ConflictError_Value() + { + var ltext = new LText("conflict"); + var result = Result.ConflictError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ConflictException)result.Error).Message.Should().Contain("conflict"); + } + + [Test] + public void ConcurrencyError_Null() + { + var result = Result.ConcurrencyError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void ConcurrencyError_Value() + { + var ltext = new LText("concurrency"); + var result = Result.ConcurrencyError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((ConcurrencyException)result.Error).Message.Should().Contain("concurrency"); + } + + [Test] + public void DuplicateError_Null() + { + var result = Result.DuplicateError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void DuplicateError_Value() + { + var ltext = new LText("duplicate"); + var result = Result.DuplicateError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((DuplicateException)result.Error).Message.Should().Contain("duplicate"); + } + + [Test] + public void NotFoundError_Null() + { + var result = Result.NotFoundError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void NotFoundError_Value() + { + var ltext = new LText("notfound"); + var result = Result.NotFoundError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((NotFoundException)result.Error).Message.Should().Contain("notfound"); + } + + [Test] + public void TransientError_Null() + { + var result = Result.TransientError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void TransientError_Value() + { + var ltext = new LText("transient"); + var result = Result.TransientError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((TransientException)result.Error).Message.Should().Contain("transient"); + } + + [Test] + public void AuthenticationError_Null() + { + var result = Result.AuthenticationError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void AuthenticationError_Value() + { + var ltext = new LText("auth"); + var result = Result.AuthenticationError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((AuthenticationException)result.Error).Message.Should().Contain("auth"); + } + + [Test] + public void AuthorizationError_Null() + { + var result = Result.AuthorizationError(null); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void AuthorizationError_Value() + { + var ltext = new LText("author"); + var result = Result.AuthorizationError(ltext); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + ((AuthorizationException)result.Error).Message.Should().Contain("author"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Results/ResultTTests.cs b/tests/CoreEx.Test.Unit/Results/ResultTTests.cs new file mode 100644 index 00000000..baa91c44 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ResultTTests.cs @@ -0,0 +1,145 @@ +using CoreEx.Results; + +namespace CoreEx.Test.Unit.Results; + +public class ResultTTests +{ + [Test] + public void DefaultCtor_ShouldBeSuccess() + { + var result = new Result(); + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + result.Value.Should().Be(0); + } + + [Test] + public void ValueCtor_ShouldBeSuccess() + { + var result = new Result("abc"); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("abc"); + } + + [Test] + public void CtorWithException_ShouldBeFailure() + { + var ex = new InvalidOperationException("fail"); + var result = new Result(ex); + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ex); + } + + [Test] + public void Value_ShouldThrowOnFailure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + Action act = () => { var _ = result.Value; }; + act.Should().Throw().WithMessage("fail"); + } + + [Test] + public void ThrowOnError_ShouldThrowOnFailure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + Action act = () => result.ThrowOnError(); + act.Should().Throw().WithMessage("fail"); + } + + [Test] + public void ThrowOnError_ShouldNotThrowOnSuccess() + { + var result = new Result(123); + var returned = result.ThrowOnError(); + returned.IsSuccess.Should().BeTrue(); + returned.Value.Should().Be(123); + } + + [Test] + public void IsFailureOfType_ShouldReturnTrueForExactType() + { + var ex = new InvalidOperationException(); + var result = new Result(ex); + result.IsFailureOfType().Should().BeTrue(); + result.IsFailureOfType().Should().BeFalse(); + } + + [Test] + public void Required_ShouldReturnValidationErrorOnDefault() + { + var result = new Result(0); + var required = result.Required("Test"); + required.IsFailure.Should().BeTrue(); + } + + [Test] + public void Required_ShouldReturnSelfOnNonDefault() + { + var result = new Result(5); + var required = result.Required("Test"); + required.IsSuccess.Should().BeTrue(); + required.Value.Should().Be(5); + } + + [Test] + public void ImplicitOperator_FromException() + { + Exception ex = new("fail"); + Result result = ex; + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ex); + } + + [Test] + public void ImplicitOperator_FromValue() + { + Result result = "abc"; + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("abc"); + } + + [Test] + public void ImplicitOperator_ToValue() + { + Result result = "abc"; + string value = result; + value.Should().Be("abc"); + } + + [Test] + public void Equals_And_Operators() + { + var r1 = new Result(); + var r2 = new Result(); + var r3 = new Result(new Exception("fail")); + var r4 = new Result(new Exception("fail")); + var r5 = new Result(new Exception("fail2")); + + (r1 == r2).Should().BeTrue(); + (r1 != r2).Should().BeFalse(); + (r1 == r3).Should().BeFalse(); + (r3 != r1).Should().BeTrue(); + (r3 == r4).Should().BeTrue(); // Different exception instances but same message. + (r4 == r5).Should().BeFalse(); // Different exception instances and different message. + } + + [Test] + public void ToString_And_DebuggerString() + { + var r1 = new Result("abc"); + var r2 = new Result(new Exception("fail")); + r1.ToString().Should().StartWith("Success: abc"); + r2.ToString().Should().StartWith("Failure: fail"); + } + + [Test] + public async Task AsValueTask_ShouldReturnSelf() + { + var result = new Result(42); + var valueTask = result.AsTask(); + var awaited = await valueTask; + awaited.Should().Be(result); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Results/ResultTests.cs b/tests/CoreEx.Test.Unit/Results/ResultTests.cs new file mode 100644 index 00000000..3e59f30e --- /dev/null +++ b/tests/CoreEx.Test.Unit/Results/ResultTests.cs @@ -0,0 +1,112 @@ +using CoreEx.Results; +using CoreEx.Results.Abstractions; + +namespace CoreEx.Test.Unit.Results; + +public class ResultTests +{ + [Test] + public void DefaultCtor_ShouldBeSuccess() + { + var result = new Result(); + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + } + + [Test] + public void CtorWithException_ShouldBeFailure() + { + var ex = new InvalidOperationException("fail"); + var result = new Result(ex); + result.IsFailure.Should().BeTrue(); + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Be(ex); + } + + [Test] + public void Error_ShouldThrowOnSuccess() + { + var result = new Result(); + Action act = () => { var _ = result.Error; }; + act.Should().Throw(); + } + + [Test] + public void IResult_Value_ShouldThrowOnFailure() + { + var ex = new Exception("fail"); + IResult result = new Result(ex); + Action act = () => { var _ = result.Value; }; + act.Should().Throw().WithMessage("fail"); + } + + [Test] + public void ThrowOnError_ShouldThrowOnFailure() + { + var ex = new Exception("fail"); + var result = new Result(ex); + Action act = () => result.ThrowOnError(); + act.Should().Throw().WithMessage("fail"); + } + + [Test] + public void ThrowOnError_ShouldNotThrowOnSuccess() + { + var result = new Result(); + var returned = result.ThrowOnError(); + returned.IsSuccess.Should().BeTrue(); + } + + [Test] + public void IsFailureOfType_ShouldReturnTrueForExactType() + { + var ex = new InvalidOperationException(); + var result = new Result(ex); + result.IsFailureOfType().Should().BeTrue(); + result.IsFailureOfType().Should().BeFalse(); + } + + [Test] + public void Equals_And_Operators() + { + var r1 = new Result(); + var r2 = new Result(); + var r3 = new Result(new Exception("fail")); + var r4 = new Result(new Exception("fail")); + var r5 = new Result(new Exception("fail2")); + + (r1 == r2).Should().BeTrue(); + (r1 != r2).Should().BeFalse(); + (r1 == r3).Should().BeFalse(); + (r3 != r1).Should().BeTrue(); + (r3 == r4).Should().BeTrue(); // Different exception instances but same message. + (r4 == r5).Should().BeFalse(); // Different exception instances and different message. + } + + [Test] + public void ToString_And_DebuggerString() + { + var r1 = new Result(); + var r2 = new Result(new Exception("fail")); + r1.ToString().Should().Be("Success."); + r2.ToString().Should().StartWith("Failure: fail"); + } + + [Test] + public void ImplicitOperator_FromException() + { + Exception ex = new("fail"); + Result result = ex; + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(ex); + } + + [Test] + public async Task AsValueTask_ShouldReturnSelf() + { + var result = new Result(); + var valueTask = result.AsTask(); + var awaited = await valueTask; + awaited.Should().Be(result); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Runtime/ReflectionMetadataTests.cs b/tests/CoreEx.Test.Unit/Runtime/ReflectionMetadataTests.cs new file mode 100644 index 00000000..6b031213 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Runtime/ReflectionMetadataTests.cs @@ -0,0 +1,181 @@ +using CoreEx.Entities; +using CoreEx.Metadata; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace CoreEx.Test.Unit.Runtime; + +public class ReflectionMetadataTests +{ + private record class EntityR + { + public int Id { get; set; } + public string? Name { get; set; } + } + + private class EntityA + { + public int Id { get; set; } = 88; + + [Display(Name = "NAME")] + [JsonPropertyName("fullname")] + public string? Name { get; set; } = "Bob"; + + [ContractIgnore] + public bool Internal { get; set; } + + public override bool Equals(object? obj) => RuntimeMetadata.AreEqual(this, obj); + public override int GetHashCode() => RuntimeMetadata.GetHashCode(this); + } + + private class EntityB : EntityA + { + [DisplayFormat(DataFormatString = "{0:C}")] + public decimal Amount { get; set; } + + public override bool Equals(object? obj) => RuntimeMetadata.AreEqual(this, obj); + public override int GetHashCode() => RuntimeMetadata.GetHashCode(this); + } + + private class EntityC : EntityB + { + public ChangeLog? ChangeLog { get; set; } + public List? Children { get; set; } + public Dictionary? Kids { get; set; } + + public override bool Equals(object? obj) => RuntimeMetadata.AreEqual(this, obj); + public override int GetHashCode() => RuntimeMetadata.GetHashCode(this); + } + + private class EntityD(string id) : IReadOnlyIdentifier + { + public string Id => id; + + [String(casing: StringCase.Upper)] + public string? Code { get; set; } + + [Clean(CleanOption.None)] + public string? Description { get; set; } + + public ChangeLog? ChangeLog { get; set; } + + [Clean(CleanOption.Clean)] + public ChangeLog? ChangeLog2 { get; set; } + + [DateTime(DateTimeTransform.DateOnly)] + public DateTime? Date { get; set; } + + public List? Tags { get; set; } + + public string[] Strings { get; set; } = []; + + public override bool Equals(object? obj) => RuntimeMetadata.AreEqual(this, obj); + public override int GetHashCode() => RuntimeMetadata.GetHashCode(this); + } + + private partial class EntityX + { + [DateTime(DateTimeTransform.DateOnly)] + public DateTime D1 { get; set; } + + [DateTime(DateTimeTransform.DateOnly)] + public required DateTime D2 { get; init; } + + [String(casing: StringCase.Upper)] + public string? S1 { get; set; } + + [String(casing: StringCase.Upper)] + public string? S2 { get; init; } + + public override bool Equals(object? obj) => RuntimeMetadata.AreEqual(this, obj); + public override int GetHashCode() => RuntimeMetadata.GetHashCode(this); + } + + [Test] + public void AreEqual_IEnumerable_ReferenceType() + { + var arr1 = new[] { new EntityA { Name = "Bob" }, new EntityA { Name = "Jen" } }; + var arr2 = new[] { new EntityA { Name = "Bob" }, new EntityA { Name = "Jen" } }; + var arr3 = new[] { new EntityA { Name = "Bob" }, new EntityA { Name = "Kat" } }; + var arr4 = new[] { new EntityA { Name = "Bob" } }; + RuntimeMetadata.AreEqual(arr1, arr2).Should().BeTrue(); + RuntimeMetadata.AreEqual(arr1, arr3).Should().BeFalse(); + RuntimeMetadata.AreEqual(arr1, arr4).Should().BeFalse(); + } + + [Test] + public void AreEqual_Reflection() + { + var e1 = new EntityC { Id = 1, Name = "Bob", Amount = 1.3m, ChangeLog = new ChangeLog { CreatedBy = "Dave" }, Children = [new EntityA { Id = 10, Name = "Jen" }] }; + var e2 = new EntityC { Id = 1, Name = "Bob", Amount = 1.3m, ChangeLog = new ChangeLog { CreatedBy = "Dave" }, Children = [new EntityA { Id = 10, Name = "Jen" }] }; + e1.Equals(e2).Should().BeTrue(); + + e2.Name = "Fred"; + e1.Equals(e2).Should().BeFalse(); + + e2.Name = "Bob"; + e1.Equals(e2).Should().BeTrue(); + + e2.ChangeLog = new ChangeLog { CreatedBy = "Mary" }; + e1.Equals(e2).Should().BeFalse(); + + e2.ChangeLog = new ChangeLog { CreatedBy = "Dave" }; + e1.Equals(e2).Should().BeTrue(); + + e2.Children[0].Name = "Kat"; + e1.Equals(e2).Should().BeFalse(); + + e2.Children = [new EntityA { Id = 10, Name = "Jen" }, new EntityA { Id = 11, Name = "Sam" }]; + e1.Equals(e2).Should().BeFalse(); + var e2AsA = (EntityA)e2; + e1.Equals(e2AsA).Should().BeFalse(); + + e2.Children = [new EntityA { Id = 10, Name = "Jen" }]; + e1.Equals(e2).Should().BeTrue(); + e1.Equals(e2AsA).Should().BeTrue(); + } + + [Test] + public void GetHashCode_Reflection() + { + var e1 = new EntityA { Id = 1, Name = "Bob" }; + var e2 = new EntityA { Id = 1, Name = "Bob" }; + e1.GetHashCode().Should().Be(e2.GetHashCode()); + + e2.Name = "Fred"; + e1.GetHashCode().Should().NotBe(e2.GetHashCode()); + } + + [Test] + public void GetHashCode_IEnumerable() + { + var arr1 = new[] { 1, 2, 3 }; + var arr2 = new[] { 1, 2, 3 }; + RuntimeMetadata.GetHashCode(arr1).Should().Be(RuntimeMetadata.GetHashCode(arr2)); + } + + [Test] + public void IsDefault_TrueAndFalse() + { + var e1 = new EntityA { Id = 1, Name = "Mary" }; + RuntimeMetadata.IsDefault(e1).Should().BeFalse(); + + e1.Id = 88; + e1.Name = "Bob"; + RuntimeMetadata.IsDefault(e1).Should().BeFalse(); // This is because the default values are set in the class definition and cannot be determined using reflection. + + e1.Id = default; + e1.Name = default; + RuntimeMetadata.IsDefault(e1).Should().BeTrue(); + } + + [Test] + public void Test() + { + int[] arr1 = { 1, 2, 3 }; + int[]? arr2 = { 1 }; + + arr1 = default!; + arr2 = default; + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs new file mode 100644 index 00000000..eb9c8f40 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs @@ -0,0 +1,434 @@ +using CoreEx.Entities; +using CoreEx.Localization; +using CoreEx.Metadata; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace CoreEx.Test.Unit.Runtime; + +public partial class RuntimeMetadataTests +{ + [Contract] + private partial record class EntityR + { + public int Id { get; set; } + public string? Name { get; set; } + } + + [Contract] + private partial class EntityA + { + public int Id { get; set; } = 88; + + [Display(Name = "NAME")] + [JsonPropertyName("fullname")] + public string? Name { get; set; } = "Bob"; + + [ContractIgnore] + public bool Internal { get; set; } + } + + [Contract] + private partial class EntityB : EntityA + { + [Display(Name = "AMOUNT")] // Should be ignored; as LText is specified and it takes precedence. + [Localization("EntityB.Amount", "Dollars")] + [DisplayFormat(DataFormatString = "{0:C}")] + public decimal Amount { get; set; } + } + + [Contract] + private partial class EntityC : EntityB + { + public List? Children { get; set; } + public Dictionary? Kids { get; set; } + public ChangeLog? ChangeLog { get; set; } + } + + [Contract] + private partial class EntityD(string id) : IReadOnlyIdentifier + { + public string Id => id; + + [String(casing: StringCase.Upper)] + public partial string? Code { get; set; } + + [Clean(CleanOption.None)] + public string? Description { get; set; } + + public ChangeLog? ChangeLog { get; set; } + + [Clean(CleanOption.Clean)] + public ChangeLog? ChangeLog2 { get; set; } + + [DateTime(DateTimeTransform.DateOnly)] + public partial DateTime? Date { get; set; } + + public List? Tags { get; set; } + + public string[] Strings { get; set; } = []; + } + + [Contract] + private partial class EntityX + { + [DateTime(DateTimeTransform.DateOnly)] + public partial DateTime D1 { get; set; } + + [DateTime(DateTimeTransform.DateOnly)] + public required partial DateTime D2 { get; init; } + + [String(casing: StringCase.Upper)] + public partial string? S1 { get; set; } + + [String(casing: StringCase.Upper)] + public partial string? S2 { get; init; } + } + + [Test] + public void AreEqual_ReferenceEquals_True() + { + var obj = new object(); + RuntimeMetadata.AreEqual(obj, obj).Should().BeTrue(); + } + + [Test] + public void AreEqual_NullCases() + { + RuntimeMetadata.AreEqual(null, null).Should().BeTrue(); + RuntimeMetadata.AreEqual(null, new object()).Should().BeFalse(); + RuntimeMetadata.AreEqual(new object(), null).Should().BeFalse(); + } + + [Test] + public void AreEqual_Strings() + { + RuntimeMetadata.AreEqual("abc", "abc").Should().BeTrue(); + RuntimeMetadata.AreEqual("abc", "def").Should().BeFalse(); + } + + [Test] + public void AreEqual_IEnumerable_ValueType() + { + var arr1 = new[] { 1, 2, 3 }; + var arr2 = new[] { 1, 2, 3 }; + var arr3 = new[] { 1, 2, 4 }; + var arr4 = new[] { 1, 2 }; + RuntimeMetadata.AreEqual(arr1, arr2).Should().BeTrue(); + RuntimeMetadata.AreEqual(arr1, arr3).Should().BeFalse(); + RuntimeMetadata.AreEqual(arr1, arr4).Should().BeFalse(); + } + + [Test] + public void AreEqual_IEnumerable_ReferenceType() + { + var arr1 = new[] { new EntityA { Name = "Bob" }, new EntityA { Name = "Jen" } }; + var arr2 = new[] { new EntityA { Name = "Bob" }, new EntityA { Name = "Jen" } }; + var arr3 = new[] { new EntityA { Name = "Bob" }, new EntityA { Name = "Kat" } }; + var arr4 = new[] { new EntityA { Name = "Bob" } }; + RuntimeMetadata.AreEqual(arr1, arr2).Should().BeTrue(); + RuntimeMetadata.AreEqual(arr1, arr3).Should().BeFalse(); + RuntimeMetadata.AreEqual(arr1, arr4).Should().BeFalse(); + } + + [Test] + public void AreEqual_Dictionary() + { + var e1 = new EntityC { Kids = [] }; + e1.Kids.Add("Sal", new EntityA { Name = "Sal" }); + e1.Kids.Add("Kev", new EntityA { Name = "Kev" }); + + var e2 = new EntityC { Kids = [] }; + e2.Kids.Add("Kev", new EntityA { Name = "Kev" }); + e2.Kids.Add("Sal", new EntityA { Name = "Sal" }); + + e1.Equals(e2).Should().BeTrue(); + + e2.Kids["Kev"].Name = "Jon"; + e1.Equals(e2).Should().BeFalse(); + + e2.Kids.Remove("Kev"); + e2.Kids.Add("Jas", new EntityA { Name = "Jas" }); + e1.Equals(e2).Should().BeFalse(); + } + + [Test] + public void AreEqual_IRuntimeMetadata() + { + var e1 = new EntityC { Id = 1, Name = "Bob", Amount = 1.3m, ChangeLog = new ChangeLog { CreatedBy = "Dave" }, Children = [new EntityA { Id = 10, Name = "Jen" }] }; + var e2 = new EntityC { Id = 1, Name = "Bob", Amount = 1.3m, ChangeLog = new ChangeLog { CreatedBy = "Dave" }, Children = [new EntityA { Id = 10, Name = "Jen" }] }; + e1.Equals(e2).Should().BeTrue(); + + e2.Name = "Fred"; + e1.Equals(e2).Should().BeFalse(); + + e2.Name = "Bob"; + e1.Equals(e2).Should().BeTrue(); + + e2.ChangeLog = new ChangeLog { CreatedBy = "Mary" }; + e1.Equals(e2).Should().BeFalse(); + + e2.ChangeLog = new ChangeLog { CreatedBy = "Dave" }; + e1.Equals(e2).Should().BeTrue(); + + e2.Children[0].Name = "Kat"; + e1.Equals(e2).Should().BeFalse(); + + e2.Children = [new EntityA { Id = 10, Name = "Jen" }, new EntityA { Id = 11, Name = "Sam" }]; + e1.Equals(e2).Should().BeFalse(); + var e2AsA = (EntityA)e2; + e1.Equals(e2AsA).Should().BeFalse(); + + e2.Children = [new EntityA { Id = 10, Name = "Jen" }]; + e1.Equals(e2).Should().BeTrue(); + e1.Equals(e2AsA).Should().BeTrue(); + } + + [Test] + public void GetHashCode_Null_Zero() + { + RuntimeMetadata.GetHashCode(null).Should().Be(0); + } + + [Test] + public void GetHashCode_String() + { + RuntimeMetadata.GetHashCode("abc").Should().Be("abc".GetHashCode()); + } + + [Test] + public void GetHashCode_IRuntimeMetadata() + { + var e1 = new EntityA { Id = 1, Name = "Bob" }; + var e2 = new EntityA { Id = 1, Name = "Bob" }; + e1.GetHashCode().Should().Be(e2.GetHashCode()); + + e2.Name = "Fred"; + e1.GetHashCode().Should().NotBe(e2.GetHashCode()); + } + + [Test] + public void GetHashCode_IEnumerable() + { + var arr1 = new[] { 1, 2, 3 }; + var arr2 = new[] { 1, 2, 3 }; + RuntimeMetadata.GetHashCode(arr1).Should().Be(RuntimeMetadata.GetHashCode(arr2)); + } + + [Test] + public void GetHashCode_Default() + { + RuntimeMetadata.GetHashCode(7).Should().Be(7.GetHashCode()); + } + + [Test] + public void IsDefault_TrueAndFalse() + { + var e1 = new EntityA { Id = 1, Name = "Mary" }; + e1.IsDefault().Should().BeFalse(); + + // This is because the Contract generated code sets the default values. + e1.Id = 88; + e1.Name = "Bob"; + e1.IsDefault().Should().BeTrue(); + } + + [Test] + public void IsDefault_Empty_ICollection() + { + EntityC? e = new() + { + Children = [], + Kids = [] + }; + + e.IsDefault().Should().BeTrue(); + + e.Children = [new EntityA()]; + e.IsDefault().Should().BeFalse(); + + e.Children = null; + e.IsDefault().Should().BeTrue(); + + e.Kids = new Dictionary { { "A", new EntityA() } }; + e.IsDefault().Should().BeFalse(); + + e.Kids = null; + e.IsDefault().Should().BeTrue(); + } + + [Test] + public void CopyFrom_CopiesValues() + { + var e1 = new EntityB { Id = 1, Name = "Bob", Amount = 1.3m }; + var e2 = new EntityB(); + e2.CopyFrom(e1); + e2.Equals(e1).Should().BeTrue(); + + var eA = new EntityA { Id = 7, Name = "Fred" }; + e2.CopyFrom(eA); + e2.Id.Should().Be(7); + e2.Name.Should().Be("Fred"); + + var eC = new EntityC { Id = 11, Name = "Jen", Amount = 2.3m, ChangeLog = new ChangeLog { CreatedBy = "Dave" }, Children = [new EntityA { Id = 10, Name = "Jen" }] }; + e2.CopyFrom(eC); + e2.Id.Should().Be(11); + e2.Name.Should().Be("Jen"); + e2.Amount.Should().Be(2.3m); + } + + [Test] + public void CopyFrom_TypeNotAssignable_DoesNothing() + { + var e1 = new EntityB { Id = 1, Name = "Bob", Amount = 1.3m }; + var e2 = new ChangeLog(); + e1.CopyFrom(e2); + + e1.Id.Should().Be(1); + e1.Name.Should().Be("Bob"); + e1.Amount.Should().Be(1.3m); + } + + [Test] + public void PropertyRuntimeMetadata_Text() + { + var a = new EntityA(); + var pd = a.GetPropertyRuntimeMetadata().ToDictionary(p => p.Name); + + var idProp = pd["Id"]; + idProp.Text.KeyAndOrText.Should().Be("Id"); + idProp.Text.FallbackText.Should().Be("Identifier"); // Not specified, so using Name.ToSentenceCase(). + + var nameProp = pd["Name"]; + nameProp.Text.KeyAndOrText.Should().Be("Name"); + nameProp.Text.FallbackText.Should().Be("NAME"); // From DisplayAttribute. + + var b = new EntityB(); + var pd2 = b.GetPropertyRuntimeMetadata().ToDictionary(p => p.Name); + + var amtProp = pd2["Amount"]; + amtProp.Text.KeyAndOrText.Should().Be("EntityB.Amount"); + amtProp.Text.FallbackText.Should().Be("Dollars"); // From LTextAttribute, which takes precedence over DisplayAttribute. + } + + [Test] + public void Read_Only_Property() + { + var d = new EntityD("123") + { + Code = "abc", + Description = "xyz" + }; + + // Copy all properties except Id (as readonly). + var d2 = new EntityD("456"); + d2.CopyFrom(d); + d2.Id.Should().Be("456"); + d2.Code.Should().Be("ABC"); + d2.Description.Should().Be("xyz"); + d2.IsDefault().Should().BeFalse(); + + // Check equality. + d.Equals(d2).Should().BeFalse(); + d.GetHashCode().Should().NotBe(d2.GetHashCode()); + + d2 = new EntityD("123"); + d2.CopyFrom(d); + d.Equals(d2).Should().BeTrue(); + d.GetHashCode().Should().Be(d2.GetHashCode()); + + d2 = new EntityD(null!); + d2.IsDefault().Should().BeTrue(); + } + + [Test] + public void Clean_Including_Property_Graph() + { + var d = new EntityD("123") + { + Code = "abc ", + Description = " xyz ", + Date = DateTime.Now, + ChangeLog = new ChangeLog() { CreatedBy = "" }, + ChangeLog2 = new ChangeLog() { CreatedBy = "" }, + Tags = [], + Strings = [] + }; + + Cleaner.Clean(d); + + d.Id.Should().Be("123"); + d.Code.Should().Be("ABC"); + d.Description.Should().Be(" xyz "); + d.Date.Value.Kind.Should().Be(DateTimeKind.Unspecified); + d.Date.Value.TimeOfDay.Should().Be(TimeSpan.Zero); + d.ChangeLog.Should().BeNull(); + d.ChangeLog2.Should().NotBeNull(); + d.ChangeLog2.IsDefault().Should().BeTrue(); + d.Tags.Should().BeNull(); // List + d.Strings.Should().NotBeNull().And.HaveCount(0); // Array + } + + [Test] + public void GetPropertyRuntimeMetadata_Type_With_Reflection() + { + var prms = RuntimeMetadata.GetPropertyRuntimeMetadata(typeof(EntityC), "Kids", "Internal").ToArray(); + prms.Length.Should().Be(5); + prms.Select(x => x.Name).Should().BeEquivalentTo(["Id", "Name", "Amount", "ChangeLog", "Children"]); + + var c = new EntityC { Name = "BARRY" }; + var p = prms.Single(p => p.Name == "Name"); + p.GetValue(c).Should().Be("BARRY"); + + p.SetValue(c, "FRED"); + c.Name.Should().Be("FRED"); + } + + [Test] + public void GetPropertyRuntimeMetadata_T_With_Reflection() + { + var prms = RuntimeMetadata.GetPropertyRuntimeMetadata().ToArray(); + prms.Length.Should().Be(2); + prms.Select(x => x.Name).Should().BeEquivalentTo(["Id", "Name"]); + + var c = new EntityE(88) { Name = "BARRY" }; + var p = prms.Single(p => p.Name == "Name"); + p.GetValue(c).Should().Be("BARRY"); + + p.SetValue(c, "FRED"); + c.Name.Should().Be("FRED"); + } + + [Test] + public void GetPropertyRuntimeMetadata_PropertyExpression_IEntity() + { + var prm = RuntimeMetadata.GetForExpression(c => c.Name); + prm.Name.Should().Be("Name"); + prm.GetValue(new EntityC { Name = "BARRY" }).Should().Be("BARRY"); + } + + [Test] + public void GetPropertyRuntimeMetadata_PropertyExpression_Class() + { + var prm = RuntimeMetadata.GetForExpression(e => e.Name); + prm.Name.Should().Be("Name"); + prm.GetValue(new EntityE(88) { Name = "BARRY" }).Should().Be("BARRY"); + } + + [Test] + public void CopyInto_With_Reflection() + { + var e1 = new EntityE(1) { Name = "Bob" }; + var e2 = new EntityE(2) { Name = "Kate" }; + + RuntimeMetadata.CopyInto(e1, e2); + e2.Id.Should().Be(2); // Not copied as read-only. + e2.Name.Should().Be("Bob"); + } + + public class EntityE(int id) + { + public int Id { get; } = id; + public string? Name { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Schemas/SchemaTests.cs b/tests/CoreEx.Test.Unit/Schemas/SchemaTests.cs new file mode 100644 index 00000000..a1a4fa79 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Schemas/SchemaTests.cs @@ -0,0 +1,61 @@ +using CoreEx.Schemas; + +namespace CoreEx.Test.Unit.Schemas; + +[TestFixture] +public class SchemaTests +{ + [Test] + public void DefaultVersion_Is_1_0() + { + Schema.DefaultVersion.Should().Be(new Version(1, 0)); + } + + [Test] + public void DefaultVersionString_Is_1_0() + { + Schema.DefaultVersionString.Should().Be("1.0"); + } + + [Test] + public void TryGetMetadata_Generic_FoundAndNotFound() + { + // With attribute + var found = Schema.TryGetMetadata(out var meta); + found.Should().BeTrue(); + meta.Should().NotBeNull(); + meta.VersionString.Should().Be("2.1"); + meta.Name.Should().Be("WithSchema"); + + // Without attribute + var notFound = Schema.TryGetMetadata(out var meta2); + notFound.Should().BeFalse(); + meta2.Should().NotBeNull(); + meta2.Version.Should().Be(Schema.DefaultVersion); + meta2.VersionString.Should().Be(Schema.DefaultVersionString); + meta2.Name.Should().Be("NoSchema"); + } + + [Test] + public void TryGetMetadata_ByType_FoundAndNotFound() + { + var withSchemaType = typeof(WithSchema); + var noSchemaType = typeof(NoSchema); + + var found = Schema.TryGetMetadata(withSchemaType, out var meta); + found.Should().BeTrue(); + meta.VersionString.Should().Be("2.1"); + meta.Name.Should().Be("WithSchema"); + + var notFound = Schema.TryGetMetadata(noSchemaType, out var meta2); + notFound.Should().BeFalse(); + meta2.Version.Should().Be(Schema.DefaultVersion); + meta2.VersionString.Should().Be(Schema.DefaultVersionString); + meta2.Name.Should().Be("NoSchema"); + } + + [Schema("2.1")] + private class WithSchema { } + + private class NoSchema { } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Text/SentenceCaseTests.cs b/tests/CoreEx.Test.Unit/Text/SentenceCaseTests.cs new file mode 100644 index 00000000..6f6d3899 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Text/SentenceCaseTests.cs @@ -0,0 +1,365 @@ +using CoreEx.Text; + +namespace CoreEx.Test.Unit.Text; + +[TestFixture] +public class SentenceCaseTests +{ + [TearDown] + public void TearDown() + { + // Reset static state after each test to avoid side effects. + SentenceCase.Substitutions = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Id", "Identifier" }, { "Etag", "ETag" } }; + SentenceCase.LastWordRemovals = new HashSet(StringComparer.OrdinalIgnoreCase) { "Id" }; + SentenceCase.SentenceCaseConverter = SentenceCase.SentenceCaseConversion; + } + + #region ToPascalCase Tests + + [TestCase("EmployeeId", "EmployeeId")] // Already PascalCase + [TestCase("employeeId", "EmployeeId")] // camelCase + [TestCase("employee_id", "EmployeeId")] // snake_case + [TestCase("employee-id", "EmployeeId")] // kebab-case + [TestCase("Employee Id", "EmployeeId")] // space-separated + [TestCase("EMPLOYEE_ID", "EmployeeId")] // ALL CAPS + [TestCase("XMLParser", "XmlParser")] // Acronym + [TestCase("xml_parser", "XmlParser")] // Acronym from snake + [TestCase("VarNameDB", "VarNameDb")] // Trailing acronym + public void ToPascalCase_ValidInputs_ReturnsExpected(string input, string expected) + { + SentenceCase.ToPascalCase(input).Should().Be(expected); + } + + [TestCase(null)] + [TestCase("")] + public void ToPascalCase_NullOrEmpty_ReturnsSame(string? input) + { + SentenceCase.ToPascalCase(input).Should().Be(input); + } + + [TestCase("a", "A")] + [TestCase("A", "A")] + [TestCase("x", "X")] + public void ToPascalCase_SingleCharacter_ReturnsUppercase(string input, string expected) + { + SentenceCase.ToPascalCase(input).Should().Be(expected); + } + + [TestCase("Employee", "Employee")] + [TestCase("employee", "Employee")] + [TestCase("EMPLOYEE", "Employee")] + public void ToPascalCase_SingleWord_ReturnsExpected(string input, string expected) + { + SentenceCase.ToPascalCase(input).Should().Be(expected); + } + + #endregion + + #region ToCamelCase Tests + + [TestCase("EmployeeId", "employeeId")] + [TestCase("employeeId", "employeeId")] // Already camelCase + [TestCase("employee_id", "employeeId")] + [TestCase("employee-id", "employeeId")] + [TestCase("Employee Id", "employeeId")] + [TestCase("XMLParser", "xmlParser")] + [TestCase("VarNameDB", "varNameDb")] + public void ToCamelCase_ValidInputs_ReturnsExpected(string input, string expected) + { + SentenceCase.ToCamelCase(input).Should().Be(expected); + } + + [TestCase(null)] + [TestCase("")] + public void ToCamelCase_NullOrEmpty_ReturnsSame(string? input) + { + SentenceCase.ToCamelCase(input).Should().Be(input); + } + + #endregion + + #region ToKebabCase Tests + + [TestCase("EmployeeId", "employee-id")] + [TestCase("employeeId", "employee-id")] + [TestCase("employee_id", "employee-id")] + [TestCase("employee-id", "employee-id")] // Already kebab-case + [TestCase("Employee Id", "employee-id")] + [TestCase("XMLParser", "xml-parser")] + [TestCase("VarNameDB", "var-name-db")] + public void ToKebabCase_ValidInputs_ReturnsExpected(string input, string expected) + { + SentenceCase.ToKebabCase(input).Should().Be(expected); + } + + [TestCase(null)] + [TestCase("")] + public void ToKebabCase_NullOrEmpty_ReturnsSame(string? input) + { + SentenceCase.ToKebabCase(input).Should().Be(input); + } + + #endregion + + #region ToSnakeCase Tests + + [TestCase("EmployeeId", "employee_id")] + [TestCase("employeeId", "employee_id")] + [TestCase("employee_id", "employee_id")] // Already snake_case + [TestCase("employee-id", "employee_id")] + [TestCase("Employee Id", "employee_id")] + [TestCase("XMLParser", "xml_parser")] + [TestCase("VarNameDB", "var_name_db")] + public void ToSnakeCase_ValidInputs_ReturnsExpected(string input, string expected) + { + SentenceCase.ToSnakeCase(input).Should().Be(expected); + } + + [TestCase(null)] + [TestCase("")] + public void ToSnakeCase_NullOrEmpty_ReturnsSame(string? input) + { + SentenceCase.ToSnakeCase(input).Should().Be(input); + } + + #endregion + + #region SplitIntoWords Tests + + [Test] + public void SplitIntoWords_PascalCase_ReturnsWords() + { + SentenceCase.SplitIntoWords("EmployeeId").Should().Equal("Employee", "Id"); + } + + [Test] + public void SplitIntoWords_CamelCase_ReturnsWords() + { + SentenceCase.SplitIntoWords("employeeId").Should().Equal("employee", "Id"); + } + + [Test] + public void SplitIntoWords_SnakeCase_ReturnsWords() + { + SentenceCase.SplitIntoWords("employee_id").Should().Equal("employee", "id"); + } + + [Test] + public void SplitIntoWords_KebabCase_ReturnsWords() + { + SentenceCase.SplitIntoWords("employee-id").Should().Equal("employee", "id"); + } + + [Test] + public void SplitIntoWords_SpaceSeparated_ReturnsWords() + { + SentenceCase.SplitIntoWords("Employee Id").Should().Equal("Employee", "Id"); + } + + [Test] + public void SplitIntoWords_Acronyms_ReturnsWords() + { + SentenceCase.SplitIntoWords("XMLParser").Should().Equal("XML", "Parser"); + SentenceCase.SplitIntoWords("HTMLElement").Should().Equal("HTML", "Element"); + SentenceCase.SplitIntoWords("IOStream").Should().Equal("IO", "Stream"); + SentenceCase.SplitIntoWords("DBConnection").Should().Equal("DB", "Connection"); + SentenceCase.SplitIntoWords("HTTPSConnection").Should().Equal("HTTPS", "Connection"); + } + + [Test] + public void SplitIntoWords_TrailingAcronym_ReturnsWords() + { + SentenceCase.SplitIntoWords("VarNameDB").Should().Equal("Var", "Name", "DB"); + } + + [Test] + public void SplitIntoWords_ConsecutiveDelimiters_IgnoresEmpty() + { + SentenceCase.SplitIntoWords("employee__id").Should().Equal("employee", "id"); + SentenceCase.SplitIntoWords("employee--id").Should().Equal("employee", "id"); + SentenceCase.SplitIntoWords("employee id").Should().Equal("employee", "id"); + } + + [Test] + public void SplitIntoWords_LeadingDelimiter_IgnoresLeading() + { + SentenceCase.SplitIntoWords("_employee").Should().Equal("employee"); + SentenceCase.SplitIntoWords("-employee").Should().Equal("employee"); + SentenceCase.SplitIntoWords(" employee").Should().Equal("employee"); + } + + [Test] + public void SplitIntoWords_TrailingDelimiter_IgnoresTrailing() + { + SentenceCase.SplitIntoWords("employee_").Should().Equal("employee"); + SentenceCase.SplitIntoWords("employee-").Should().Equal("employee"); + SentenceCase.SplitIntoWords("employee ").Should().Equal("employee"); + } + + [Test] + public void SplitIntoWords_LeadingAndTrailingDelimiters_IgnoresBoth() + { + SentenceCase.SplitIntoWords("_employee_id_").Should().Equal("employee", "id"); + } + + [Test] + public void SplitIntoWords_OnlyDelimiters_ReturnsEmpty() + { + SentenceCase.SplitIntoWords("___").Should().BeEmpty(); + SentenceCase.SplitIntoWords("---").Should().BeEmpty(); + SentenceCase.SplitIntoWords(" ").Should().BeEmpty(); + } + + [TestCase(null)] + [TestCase("")] + public void SplitIntoWords_NullOrEmpty_ReturnsEmpty(string? input) + { + SentenceCase.SplitIntoWords(input).Should().BeEmpty(); + } + + [Test] + public void SplitIntoWords_SingleWord_ReturnsSingleElement() + { + SentenceCase.SplitIntoWords("Employee").Should().Equal("Employee"); + SentenceCase.SplitIntoWords("employee").Should().Equal("employee"); + } + + [Test] + public void SplitIntoWords_SingleCharacter_ReturnsSingleElement() + { + SentenceCase.SplitIntoWords("a").Should().Equal("a"); + SentenceCase.SplitIntoWords("A").Should().Equal("A"); + } + + [Test] + public void SplitIntoWords_MixedFormats_ReturnsWords() + { + SentenceCase.SplitIntoWords("employee_IdValue").Should().Equal("employee", "Id", "Value"); + SentenceCase.SplitIntoWords("get-UserName").Should().Equal("get", "User", "Name"); + SentenceCase.SplitIntoWords("HTTP_Response").Should().Equal("HTTP", "Response"); + } + + #endregion + + #region ToSentenceCase Tests + + [TestCase("EmployeeId", "Employee")] // Last word removal + [TestCase("ProductName", "Product name")] // Mixed case + [TestCase("XMLParser", "XML parser")] // Acronym + [TestCase("employee_id", "Employee")] // From snake_case + [TestCase("VarNameDB", "Var name DB")] // From docs example + [TestCase("eTag", "ETag")] // ETag substitution + [TestCase("etag", "ETag")] // ETag substitution + public void ToSentenceCase_ValidInputs_ReturnsExpected(string input, string expected) + { + SentenceCase.ToSentenceCase(input).Should().Be(expected); + } + + [TestCase(null)] + [TestCase("")] + public void ToSentenceCase_NullOrEmpty_ReturnsSame(string? input) + { + SentenceCase.ToSentenceCase(input).Should().Be(input); + } + + [Test] + public void ToSentenceCase_WithSubstitutions_AppliesCorrectly() + { + var original = SentenceCase.Substitutions; + try + { + SentenceCase.Substitutions = new() { { "Id", "Identifier" }, { "DB", "Database" } }; + SentenceCase.ToSentenceCase("EmployeeIdCode").Should().Be("Employee identifier code"); + SentenceCase.ToSentenceCase("VarNameDB").Should().Be("Var name database"); + } + finally + { + SentenceCase.Substitutions = original; + } + } + + [Test] + public void ToSentenceCase_WithLastWordRemovals_RemovesCorrectly() + { + var original = SentenceCase.LastWordRemovals; + try + { + SentenceCase.LastWordRemovals = ["Id", "Key"]; + SentenceCase.ToSentenceCase("EmployeeId").Should().Be("Employee"); + SentenceCase.ToSentenceCase("ProductKey").Should().Be("Product"); + SentenceCase.ToSentenceCase("ProductName").Should().Be("Product name"); + } + finally + { + SentenceCase.LastWordRemovals = original; + } + } + + [Test] + public void ToSentenceCase_UsesConverter() + { + SentenceCase.SentenceCaseConverter = s => "fixed"; + SentenceCase.ToSentenceCase("anything").Should().Be("fixed"); + } + + [Test] + public void ToSentenceCase_NullConverter_ReturnsInput() + { + SentenceCase.SentenceCaseConverter = null; + SentenceCase.ToSentenceCase("abcDEF").Should().Be("abcDEF"); + } + + #endregion + + #region All Formats Conversion Tests + + [Test] + public void AllFormats_EmployeeId_ConvertsCorrectly() + { + const string input = "EmployeeId"; + + SentenceCase.ToPascalCase(input).Should().Be("EmployeeId"); + SentenceCase.ToCamelCase(input).Should().Be("employeeId"); + SentenceCase.ToKebabCase(input).Should().Be("employee-id"); + SentenceCase.ToSnakeCase(input).Should().Be("employee_id"); + SentenceCase.ToSentenceCase(input).Should().Be("Employee"); // Last word removed + } + + [Test] + public void AllFormats_XMLParser_ConvertsCorrectly() + { + const string input = "XMLParser"; + + SentenceCase.ToPascalCase(input).Should().Be("XmlParser"); + SentenceCase.ToCamelCase(input).Should().Be("xmlParser"); + SentenceCase.ToKebabCase(input).Should().Be("xml-parser"); + SentenceCase.ToSnakeCase(input).Should().Be("xml_parser"); + SentenceCase.ToSentenceCase(input).Should().Be("XML parser"); + } + + [Test] + public void AllFormats_FromSnakeCase_ConvertsCorrectly() + { + const string input = "employee_id"; + + SentenceCase.ToPascalCase(input).Should().Be("EmployeeId"); + SentenceCase.ToCamelCase(input).Should().Be("employeeId"); + SentenceCase.ToKebabCase(input).Should().Be("employee-id"); + SentenceCase.ToSnakeCase(input).Should().Be("employee_id"); + SentenceCase.ToSentenceCase(input).Should().Be("Employee"); + } + + [Test] + public void AllFormats_FromKebabCase_ConvertsCorrectly() + { + const string input = "employee-id"; + + SentenceCase.ToPascalCase(input).Should().Be("EmployeeId"); + SentenceCase.ToCamelCase(input).Should().Be("employeeId"); + SentenceCase.ToKebabCase(input).Should().Be("employee-id"); + SentenceCase.ToSnakeCase(input).Should().Be("employee_id"); + SentenceCase.ToSentenceCase(input).Should().Be("Employee"); + } + + #endregion +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Validation/ValidationTests.cs b/tests/CoreEx.Test.Unit/Validation/ValidationTests.cs new file mode 100644 index 00000000..f697f8ff --- /dev/null +++ b/tests/CoreEx.Test.Unit/Validation/ValidationTests.cs @@ -0,0 +1,134 @@ +using CoreEx.Results; +using CoreEx.Validation; + +namespace CoreEx.Test.Unit.Validation; + +[TestFixture] +public class ValidationTests +{ + [TearDown] + public void TearDown() + { + // Reset static properties to defaults before each test to avoid side effects. + CoreEx.Validation.Validation.MandatoryFormat = new("CoreEx.Validation.MandatoryFormat", "{0} is required."); + CoreEx.Validation.Validation.ValueName = "value"; + CoreEx.Validation.Validation.ValueText = new("CoreEx.Validation.ValueText", "Value"); + } + + [Test] + public void MandatoryFormat_StaticProperty() + { + CoreEx.Validation.Validation.MandatoryFormat = new("custom", "custom required"); + CoreEx.Validation.Validation.MandatoryFormat.KeyAndOrText.Should().Be("custom"); + CoreEx.Validation.Validation.MandatoryFormat.FallbackText.Should().Be("custom required"); + } + + [Test] + public void ValueNameDefault_StaticProperty() + { + CoreEx.Validation.Validation.ValueName = "foo"; + CoreEx.Validation.Validation.ValueName.Should().Be("foo"); + } + + [Test] + public void ValueTextDefault_StaticProperty() + { + CoreEx.Validation.Validation.ValueText = new("bar", "Bar"); + CoreEx.Validation.Validation.ValueText.KeyAndOrText.Should().Be("bar"); + CoreEx.Validation.Validation.ValueText.FallbackText.Should().Be("Bar"); + } + + [Test] + public void Required_NonDefault_ReturnsValue() + { + var value = 123; + var result = value.Required(); + result.Should().Be(123); + } + + [Test] + public void Required_Default_ThrowsValidationException() + { + int value = 0; + Action act = () => value.Required(); + act.Should().Throw(); + } + + [Test] + public void Required_CustomNameAndText() + { + int value = 0; + Action act = () => value.Required("myValue", new("custom", "Custom")); + var ex = act.Should().Throw() + .WithMessage("A data validation error occurred.") + .Which.Messages.Should().ContainSingle().Which.ToString().Should().Be("MessageItem { Type = Error, Text = Custom is required., Property = myValue }"); + } + + [Test] + public void Requires_ResultT_NonDefault_ReturnsResult() + { + var result = Result.Ok(5).Requires(5, "myValue"); + result.IsSuccess.Should().BeTrue(); + } + + [Test] + public void Requires_ResultT_Default_ReturnsFailure() + { + var result = Result.Ok(0).Requires(0, "myValue"); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void Requires_ResultT_CustomText() + { + var result = Result.Ok(0).Requires(0, "myValue", new("custom", "Custom")); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + var vex = (ValidationException)result.Error; + vex.Message.Should().Be("A data validation error occurred."); + vex.Messages.Should().ContainSingle().Which.ToString().Should().Be("MessageItem { Type = Error, Text = Custom is required., Property = myValue }"); + } + + [Test] + public void Requires_ResultT_Func_NonDefault_ReturnsResult() + { + var result = Result.Ok(5).Requires(() => 5, "myValue"); + result.IsSuccess.Should().BeTrue(); + } + + [Test] + public void Requires_ResultT_Func_Default_ReturnsFailure() + { + var result = Result.Ok(0).Requires(() => 0, "myValue"); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void Requires_ResultT_Func_CustomText() + { + var result = Result.Ok(0).Requires(() => 0, "myValue", "Custom"); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + var vex = (ValidationException)result.Error; + vex.Message.Should().Be("A data validation error occurred."); + vex.Messages.Should().ContainSingle().Which.ToString().Should().Be("MessageItem { Type = Error, Text = Custom is required., Property = myValue }"); + } + + [Test] + public void Requires_ResultT_Func_Null_Throws() + { + var result = Result.Ok(1); + Action act = () => result.Requires((Func)null!, "myValue"); + act.Should().Throw(); + } + + [Test] + public void Requires_ResultT_Func_EmptyName_Throws() + { + var result = Result.Ok(1); + Action act = () => result.Requires(() => 1, ""); + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Wildcards/WildcardTests.cs b/tests/CoreEx.Test.Unit/Wildcards/WildcardTests.cs new file mode 100644 index 00000000..b80c87d2 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Wildcards/WildcardTests.cs @@ -0,0 +1,130 @@ +using CoreEx.Wildcards; + +namespace CoreEx.Test.Unit.Wildcards; + +[TestFixture] +public class WildcardTests +{ + [Test] + public void StaticProperties_AreCorrect() + { + Wildcard.None.SupportedSelection.Should().Be(WildcardSelection.None | WildcardSelection.Equal); + Wildcard.MultiBasic.SupportedSelection.Should().Be(WildcardSelection.MultiBasic); + Wildcard.MultiAll.SupportedSelection.Should().Be(WildcardSelection.MultiAll); + Wildcard.BothAll.SupportedSelection.Should().Be(WildcardSelection.BothAll); + + var oldDefault = Wildcard.Default; + Wildcard.Default = Wildcard.BothAll; + Wildcard.Default.Should().Be(Wildcard.BothAll); + Wildcard.Default = oldDefault; + } + + [Test] + public void Constructor_ValidConfig_SetsProperties() + { + var charsNotAllowed = new[] { '!', '@' }; + var wc = new Wildcard(WildcardSelection.MultiAll, '*', '?', charsNotAllowed, WildcardSpaceTreatment.Compress); + wc.SupportedSelection.Should().Be(WildcardSelection.MultiAll); + wc.MultiWildcard.Should().Be('*'); + wc.SingleWildcard.Should().Be('?'); + wc.CharactersNotAllowed.Should().BeEquivalentTo(charsNotAllowed); + wc.SpaceTreatment.Should().Be(WildcardSpaceTreatment.Compress); + } + + [Test] + public void Constructor_InvalidConfig_Throws() + { +#pragma warning disable CA1806 // Do not ignore method results + Action a1 = () => new Wildcard(WildcardSelection.Undetermined); + a1.Should().Throw(); + + Action a2 = () => new Wildcard(WildcardSelection.InvalidCharacter); + a2.Should().Throw(); + + Action a3 = () => new Wildcard(WildcardSelection.MultiWildcard, '*', '*'); + a3.Should().Throw(); + + Action a4 = () => new Wildcard(WildcardSelection.MultiWildcard, '*', '?', ['*']); + a4.Should().Throw(); + + Action a5 = () => new Wildcard(WildcardSelection.SingleWildcard, '*', char.MinValue); + a5.Should().Throw(); + + Action a6 = () => new Wildcard(WildcardSelection.MultiWildcard, char.MinValue, '?'); + a6.Should().Throw(); +#pragma warning restore CA1806 // Do not ignore method results + } + + [Test] + public void Parse_NullOrEmpty_ReturnsNone() + { + var wc = Wildcard.MultiBasic; + var result = wc.Parse(null); + result.Selection.Should().Be(WildcardSelection.None); + result.Text.Should().BeNull(); + + result = wc.Parse(""); + result.Selection.Should().Be(WildcardSelection.None); + result.Text.Should().Be(""); + } + + [Test] + public void Parse_InvalidPatterns() + { + var wc = Wildcard.MultiBasic; + wc.Parse("a*b").HasError.Should().BeTrue(); + wc.Parse("?bc").HasError.Should().BeTrue(); + wc.Parse("a?c").HasError.Should().BeTrue(); + wc.Parse("ab?").HasError.Should().BeTrue(); + } + + [Test] + public void Parse_ValidPatterns() + { + var wc = Wildcard.BothAll; + var r1 = wc.Parse("abc"); + r1.Selection.Should().HaveFlag(WildcardSelection.Equal); + r1.Text.Should().Be("abc"); + r1.CreateRegex().ToString().Should().Be("^abc$"); + + var r2 = wc.Parse("*abc"); + r2.Selection.Should().HaveFlag(WildcardSelection.EndsWith); + r2.Selection.Should().HaveFlag(WildcardSelection.MultiWildcard); + r2.Text.Should().Be("*abc"); + r2.CreateRegex().ToString().Should().Be("^.*abc$"); + + var r3 = wc.Parse("abc*"); + r3.Selection.Should().HaveFlag(WildcardSelection.StartsWith); + r3.Selection.Should().HaveFlag(WildcardSelection.MultiWildcard); + r3.Text.Should().Be("abc*"); + r3.CreateRegex().ToString().Should().Be("^abc.*$"); + + var r4 = wc.Parse("a*c"); + r4.Selection.Should().HaveFlag(WildcardSelection.Embedded); + r4.Selection.Should().HaveFlag(WildcardSelection.MultiWildcard); + r4.Text.Should().Be("a*c"); + r4.CreateRegex().ToString().Should().Be("^a.*c$"); + + var r5 = wc.Parse("a**c"); + r5.Selection.Should().HaveFlag(WildcardSelection.Embedded); + r5.Selection.Should().HaveFlag(WildcardSelection.MultiWildcard); + r5.Selection.Should().HaveFlag(WildcardSelection.AdjacentWildcards); + r5.Text.Should().Be("a*c"); + r5.CreateRegex().ToString().Should().Be("^a.*c$"); + + var r6 = wc.Parse("a?c"); + r6.Selection.Should().HaveFlag(WildcardSelection.Embedded); + r6.Selection.Should().HaveFlag(WildcardSelection.SingleWildcard); + r6.Text.Should().Be("a?c"); + r6.CreateRegex().ToString().Should().Be("^a.c$"); + } + + [Test] + public void Parse_InvalidCharacters() + { + var wc = new Wildcard(WildcardSelection.BothAll, '*', '?', ['!']); + var result = wc.Parse("a!b"); + result.Selection.Should().HaveFlag(WildcardSelection.InvalidCharacter); + result.HasError.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Test/CoreEx.Test.csproj b/tests/CoreEx.Test/CoreEx.Test.csproj deleted file mode 100644 index 2cbacba2..00000000 --- a/tests/CoreEx.Test/CoreEx.Test.csproj +++ /dev/null @@ -1,54 +0,0 @@ - - - - net8.0 - enable - false - preview - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - - - - - - - - - - PreserveNewest - Never - - - - \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Abstractions/ObjectExtensionsTest.cs b/tests/CoreEx.Test/Framework/Abstractions/ObjectExtensionsTest.cs deleted file mode 100644 index 3fd31921..00000000 --- a/tests/CoreEx.Test/Framework/Abstractions/ObjectExtensionsTest.cs +++ /dev/null @@ -1,59 +0,0 @@ -using NUnit.Framework; -using System; - -namespace CoreEx.Test.Framework.Abstractions -{ - [TestFixture] - public class ObjectExtensionsTest - { - [Test] - public void ThrowIfNull_WhenNull() - { - string aussie = null!; - var ex = Assert.Throws(() => aussie.ThrowIfNull()); - Assert.That(ex!.ParamName, Is.EqualTo("aussie")); - } - - [Test] - public void ThrowIfNull_WhenNotNull() - { - string aussie = "Aussie"; - Assert.That(aussie.ThrowIfNull(), Is.EqualTo(aussie)); - } - - [Test] - public void Adjust_Value_NonNullable() - { - var p = new Person(); - var p2 = p.Adjust(x => x.Name = "Babs"); - Assert.Multiple(() => - { - Assert.That(p.Name, Is.EqualTo("Babs")); - Assert.That(p2.Name, Is.EqualTo("Babs")); - }); - } - - [Test] - public void Adjust_Value_Nullable() - { - Person? p = null; - p.Adjust(x => x.Name = "Babs"); - Assert.That(p, Is.Null); - } - - [Test] - public void Adjust_Value_Nullable_With_Value() - { - Person? p = new(); - var p2 = p.Adjust(x => x.Name = "Babs"); - Assert.That(p, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(p.Name, Is.EqualTo("Babs")); - Assert.That(p2.Name, Is.EqualTo("Babs")); - }); - } - - public class Person { public string? Name { get; set; } } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Abstractions/Reflection/PropertyExpressionTest.cs b/tests/CoreEx.Test/Framework/Abstractions/Reflection/PropertyExpressionTest.cs deleted file mode 100644 index 94a70729..00000000 --- a/tests/CoreEx.Test/Framework/Abstractions/Reflection/PropertyExpressionTest.cs +++ /dev/null @@ -1,84 +0,0 @@ -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Abstractions.Reflection -{ - [TestFixture] - public class PropertyExpressionTest - { - [Test] - public void Create() - { - var pe1 = PropertyExpression.Create(p => p.Id); - Assert.Multiple(() => - { - Assert.That(pe1.Name, Is.EqualTo("Id")); - Assert.That(pe1.JsonName, Is.EqualTo("id")); - Assert.That((string)pe1.Text, Is.EqualTo("Identifier")); - Assert.That(pe1.Text.KeyAndOrText, Is.EqualTo("CoreEx.Test.Framework.Abstractions.Reflection.Person.Id")); - Assert.That(pe1.IsJsonSerializable, Is.True); - }); - - var pe2 = PropertyExpression.Create(p => p.Name); - Assert.Multiple(() => - { - Assert.That(pe2.Name, Is.EqualTo("Name")); - Assert.That(pe2.JsonName, Is.EqualTo("name")); - Assert.That((string)pe2.Text, Is.EqualTo("Fullname")); - Assert.That(pe2.IsJsonSerializable, Is.True); - }); - - var pe3 = PropertyExpression.Create(p => p.Gender); - Assert.Multiple(() => - { - Assert.That(pe3.Name, Is.EqualTo("Gender")); - Assert.That(pe3.JsonName, Is.EqualTo("gender")); - Assert.That((string)pe3.Text, Is.EqualTo("Gender")); - Assert.That(pe3.IsJsonSerializable, Is.False); - }); - - var pe4 = PropertyExpression.Create(p => p.ChangeLog); - Assert.Multiple(() => - { - Assert.That(pe4.Name, Is.EqualTo("ChangeLog")); - Assert.That(pe4.JsonName, Is.EqualTo("changeLog")); - Assert.That((string)pe4.Text, Is.EqualTo("Change Log")); - Assert.That(pe4.IsJsonSerializable, Is.True); - }); - - var pe5 = PropertyExpression.Create(p => p.Secret); - Assert.Multiple(() => - { - Assert.That(pe5.Name, Is.EqualTo("Secret")); - Assert.That(pe5.JsonName, Is.EqualTo(null)); - Assert.That((string)pe5.Text, Is.EqualTo("Secret")); - Assert.That(pe5.IsJsonSerializable, Is.False); - }); - } - - [Test] - public void ToSentenceCase() - { - Assert.Multiple(() => - { - Assert.That(CoreEx.Text.SentenceCase.ToSentenceCase(null), Is.Null); - Assert.That(CoreEx.Text.SentenceCase.ToSentenceCase(string.Empty), Is.EqualTo(string.Empty)); - Assert.That(CoreEx.Text.SentenceCase.ToSentenceCase("Id"), Is.EqualTo("Identifier")); - Assert.That(CoreEx.Text.SentenceCase.ToSentenceCase("id"), Is.EqualTo("Identifier")); - Assert.That(CoreEx.Text.SentenceCase.ToSentenceCase("FirstName"), Is.EqualTo("First Name")); - Assert.That(CoreEx.Text.SentenceCase.ToSentenceCase("firstName"), Is.EqualTo("First Name")); - Assert.That(CoreEx.Text.SentenceCase.ToSentenceCase("EmployeeId"), Is.EqualTo("Employee")); - }); - - var w = CoreEx.Text.SentenceCase.SplitIntoWords("FirstXMLCode"); - Assert.Multiple(() => - { - Assert.That(w, Has.Length.EqualTo(3)); - Assert.That(w[0], Is.EqualTo("First")); - Assert.That(w[1], Is.EqualTo("XML")); - Assert.That(w[2], Is.EqualTo("Code")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs b/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs deleted file mode 100644 index c0896c5d..00000000 --- a/tests/CoreEx.Test/Framework/Abstractions/Reflection/TypeReflectorTest.cs +++ /dev/null @@ -1,319 +0,0 @@ -using CoreEx.Abstractions.Reflection; -using CoreEx.Entities; -using CoreEx.RefData; -using NUnit.Framework; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics; -using System.Text.Json.Serialization; - -namespace CoreEx.Test.Framework.Abstractions.Reflection -{ - [TestFixture] - public class TypeReflectorTest - { - [Test] - public void GetReflector() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - Assert.Multiple(() => - { - Assert.That(tr.GetProperty("Id"), Is.Not.Null); - Assert.That(tr.GetProperty("Name"), Is.Not.Null); - Assert.That(tr.GetProperty("GenderSid"), Is.Not.Null); - Assert.That(tr.GetProperty("Gender"), Is.Not.Null); - Assert.That(tr.GetProperty("Addresses"), Is.Not.Null); - Assert.That(tr.GetProperty("ChangeLog"), Is.Not.Null); - Assert.That(tr.GetProperty("Secret"), Is.Not.Null); - Assert.That(tr.GetProperty("NickNames"), Is.Not.Null); - Assert.That(tr.TryGetProperty("Bananas", out var _), Is.False); - - Assert.That(tr.GetJsonProperty("id"), Is.Not.Null); - Assert.That(tr.GetJsonProperty("Id"), Is.Null); - Assert.That(tr.GetJsonProperty("name"), Is.Not.Null); - Assert.That(tr.GetJsonProperty("genderSid"), Is.Null); - Assert.That(tr.GetJsonProperty("gender"), Is.Not.Null); - Assert.That(tr.GetJsonProperty("addresses"), Is.Not.Null); - Assert.That(tr.GetJsonProperty("changeLog"), Is.Not.Null); - Assert.That(tr.GetJsonProperty("secret"), Is.Null); - Assert.That(tr.GetJsonProperty("nickNames"), Is.Not.Null); - Assert.That(tr.GetJsonProperty("bananas"), Is.Null); - }); - } - - [Test] - public void GetReflector_PropertyReflector_Id() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - var pr = tr.GetProperty("Id"); - Assert.That(pr.GetTypeReflector(), Is.Not.Null); - - var p = new Person { Id = 88 }; - Assert.That(pr.PropertyExpression.GetValue(p), Is.EqualTo(88)); - - pr.PropertyExpression.SetValue(p, 99); - Assert.That(p.Id, Is.EqualTo(99)); - - pr.PropertyExpression.SetValue(p, null!); - Assert.That(p.Id, Is.EqualTo(0)); - } - - [Test] - public void GetReflector_PropertyReflector_Gender() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - var pr = tr.GetJsonProperty("gender"); - Assert.That(pr, Is.Not.Null); - Assert.That(pr!.Name, Is.EqualTo("GenderSid")); - - pr = tr.GetProperty("Gender"); - Assert.That(pr.GetTypeReflector()!.Type.Name, Is.EqualTo("Gender")); - - var g = new Gender { Code = "F" }; - var p = new Person { Gender = g }; - - var g2 = (Gender)pr.PropertyExpression.GetValue(p)!; - Assert.That(g2.Code, Is.EqualTo("F")); - - pr.PropertyExpression.SetValue(p, new Gender { Code = "M" }); - Assert.That(p.Gender.Code, Is.EqualTo("M")); - - pr.PropertyExpression.SetValue(p, null!); - Assert.That(p.Gender, Is.EqualTo(null)); - } - - [Test] - public void GetReflector_PropertyReflector_Addresses() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - var pr = tr.GetProperty("Addresses"); - Assert.That(pr.GetTypeReflector(), Is.Not.Null); - - var a = new List
{ new() { Street = "s", City = "c" } }; - var p = new Person { Addresses = a }; - - var a2 = (List
)pr.PropertyExpression.GetValue(p)!; - Assert.That(a2[0].Street, Is.EqualTo("s")); - - pr.PropertyExpression.SetValue(p, new List
{ new() { Street = "s2", City = "c2" } }); - Assert.That(p.Addresses[0].Street, Is.EqualTo("s2")); - - pr.PropertyExpression.SetValue(p, null!); - Assert.That(p.Addresses, Is.EqualTo(null)); - } - - [Test] - public void GetReflector_PropertyReflector_NickNames() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - var pr = tr.GetProperty("NickNames"); - Assert.That(pr.GetTypeReflector(), Is.Not.Null); - - var n = new string[] { "baz" }; - var p = new Person { NickNames = n }; - - var a2 = (string[])pr.PropertyExpression.GetValue(p)!; - Assert.That(a2[0], Is.EqualTo("baz")); - - pr.PropertyExpression.SetValue(p, new string[] { "gaz" }); - Assert.That(p.NickNames[0], Is.EqualTo("gaz")); - - pr.PropertyExpression.SetValue(p, null!); - Assert.That(p.NickNames, Is.EqualTo(null)); - } - - [Test] - public void GetReflector_PropertyReflector_Salary() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - var pr = tr.GetProperty("Salary"); - Assert.That(pr.GetTypeReflector(), Is.Not.Null); - - var p = new Person { Salary = 1m }; - - pr.PropertyExpression.SetValue(p, 2m); - Assert.That(p.Salary, Is.EqualTo(2m)); - - pr.PropertyExpression.SetValue(p, null!); - Assert.That(p.Salary, Is.EqualTo(null)); - } - - [Test] - public void GetReflector_Compare_Int() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - Assert.Multiple(() => - { - Assert.That(tr.Compare(new int[] { 1, 2, 3 }, new int[] { 1, 2, 3 }), Is.True); - Assert.That(tr.Compare(new int[] { 1, 2, 3 }, new int[] { 1, 2, 4 }), Is.False); - }); - } - - [Test] - public void GetReflector_Compare_String() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - Assert.Multiple(() => - { - Assert.That(tr.Compare(["a", "aa"], ["a", "aa"]), Is.True); - Assert.That(tr.Compare(["a", "aa"], ["b", "bb"]), Is.False); - }); - } - - [Test] - public void GetReflector_PropertyReflector_Compare_Int() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - var pr = tr.GetProperty("Id"); - - Assert.Multiple(() => - { - Assert.That(pr.TypeCode, Is.EqualTo(TypeReflectorTypeCode.Simple)); - Assert.That(pr.Compare(null, null), Is.True); - Assert.That(pr.Compare(1, null), Is.False); - Assert.That(pr.Compare(null, 2), Is.False); - Assert.That(pr.Compare(1, 2), Is.False); - Assert.That(pr.Compare(1, 1), Is.True); - }); - } - - [Test] - public void GetReflector_PropertyReflector_Compare_Nullable() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - var pr = tr.GetProperty("Salary"); - - Assert.Multiple(() => - { - Assert.That(pr.TypeCode, Is.EqualTo(TypeReflectorTypeCode.Simple)); - Assert.That(pr.Compare(null, null), Is.True); - Assert.That(pr.Compare(1m, null), Is.False); - Assert.That(pr.Compare(null, 2m), Is.False); - Assert.That(pr.Compare(1m, 2m), Is.False); - Assert.That(pr.Compare(1m, 1m), Is.True); - }); - } - - [Test] - public void GetReflector_PropertyReflector_Compare_Array() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - var pr = tr.GetProperty("NickNames"); - - Assert.Multiple(() => - { - Assert.That(pr.TypeCode, Is.EqualTo(TypeReflectorTypeCode.Array)); - Assert.That(pr.Compare(null, null), Is.True); - Assert.That(pr.Compare(new string[] { "a", "b" }, null), Is.False); - Assert.That(pr.Compare(null, new string[] { "y", "z" }), Is.False); - Assert.That(pr.Compare(new string[] { "a", "b" }, new string[] { "y", "z" }), Is.False); - Assert.That(pr.Compare(new string[] { "a", "b" }, new string[] { "a", "b", "c" }), Is.False); - Assert.That(pr.Compare(new string[] { "a", "b" }, new string[] { "a", "b" }), Is.True); - }); - } - - [Test] - public void GetReflector_PropertyReflector_Compare_Collection() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - var pr = tr.GetProperty("Addresses"); - - Assert.Multiple(() => - { - Assert.That(pr.TypeCode, Is.EqualTo(TypeReflectorTypeCode.ICollection)); - Assert.That(pr.Compare(null, null), Is.True); - Assert.That(pr.Compare(new List
(), new List
()), Is.True); - - // No equality check for Address, so will all fail. - Assert.That(pr.Compare(new List
{ new() }, new List
{ new() }), Is.False); - Assert.That(pr.Compare(null, new List
{ new() }), Is.False); - Assert.That(pr.Compare(new List
{ new() }, null), Is.False); - Assert.That(pr.Compare(new List
{ new() }, new List
{ new(), new() }), Is.False); - }); - } - - [Test] - public void GetReflector_TypeCode_And_ItemType() - { - Assert.Multiple(() => - { - Assert.That(TypeReflector.GetReflector(new TypeReflectorArgs()).TypeCode, Is.EqualTo(TypeReflectorTypeCode.Simple)); - Assert.That(TypeReflector.GetReflector(new TypeReflectorArgs()).TypeCode, Is.EqualTo(TypeReflectorTypeCode.Simple)); - Assert.That(TypeReflector.GetReflector(new TypeReflectorArgs()).TypeCode, Is.EqualTo(TypeReflectorTypeCode.Array)); - Assert.That(TypeReflector.GetReflector>(new TypeReflectorArgs()).TypeCode, Is.EqualTo(TypeReflectorTypeCode.ICollection)); - Assert.That(TypeReflector.GetReflector>(new TypeReflectorArgs()).TypeCode, Is.EqualTo(TypeReflectorTypeCode.IDictionary)); - Assert.That(TypeReflector.GetReflector(new TypeReflectorArgs()).TypeCode, Is.EqualTo(TypeReflectorTypeCode.Complex)); - - Assert.That(TypeReflector.GetReflector(new TypeReflectorArgs()).ItemType, Is.EqualTo(null)); - Assert.That(TypeReflector.GetReflector(new TypeReflectorArgs()).ItemType, Is.EqualTo(null)); - Assert.That(TypeReflector.GetReflector(new TypeReflectorArgs()).ItemType, Is.EqualTo(typeof(string))); - Assert.That(TypeReflector.GetReflector>(new TypeReflectorArgs()).ItemType, Is.EqualTo(typeof(decimal?))); - Assert.That(TypeReflector.GetReflector>(new TypeReflectorArgs()).ItemType, Is.EqualTo(typeof(Person))); - Assert.That(TypeReflector.GetReflector(new TypeReflectorArgs()).ItemType, Is.EqualTo(null)); - }); - } - - [Test] - public void SetValues() - { - var tr = TypeReflector.GetReflector(new TypeReflectorArgs()); - var p = new Person(); - tr.GetProperty("Id").PropertyExpression.SetValue(p, 88); - tr.GetProperty("Name").PropertyExpression.SetValue(p, "foo"); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < 100000; i++) - { - _ = new Person - { - Id = 88, - Name = "foo" - }; - } - - sw.Stop(); - System.Console.WriteLine($"Raw 100K validations - elapsed: {sw.Elapsed.TotalMilliseconds}ms (per {sw.Elapsed.TotalMilliseconds / 100000}ms)"); - - sw = Stopwatch.StartNew(); - for (int i = 0; i < 100000; i++) - { - p = new Person(); - tr.GetProperty("Id").PropertyExpression.SetValue(p, 88); - tr.GetProperty("Name").PropertyExpression.SetValue(p, "foo"); - } - - sw.Stop(); - System.Console.WriteLine($"Expression 100K validations - elapsed: {sw.Elapsed.TotalMilliseconds}ms (per {sw.Elapsed.TotalMilliseconds / 100000}ms)"); - } - } - - public abstract class PersonBase - { - public abstract int Id { get; set; } - } - - public class Person : PersonBase - { - public override int Id { get; set; } - [Display(Name = "Fullname")] - public string? Name { get; set; } - [JsonPropertyName("gender")] - public string? GenderSid { get; set; } - [JsonIgnore] - public Gender? Gender { get; set; } - public List
? Addresses { get; set; } - public ChangeLog? ChangeLog { get; set; } - [JsonIgnore] - public string? Secret { get; set; } - public string[]? NickNames { get; set; } - public decimal? Salary { get; set; } - } - - public class Address - { - public string? Street { get; set; } - public string? City { get; set; } - } - - public class Gender : ReferenceDataBase { } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Azure/ServiceBus/EventDataToServiceBusConverterTest.cs b/tests/CoreEx.Test/Framework/Azure/ServiceBus/EventDataToServiceBusConverterTest.cs deleted file mode 100644 index 2d1d3b12..00000000 --- a/tests/CoreEx.Test/Framework/Azure/ServiceBus/EventDataToServiceBusConverterTest.cs +++ /dev/null @@ -1,225 +0,0 @@ -using Azure.Core.Amqp; -using CoreEx.Azure.ServiceBus; -using CoreEx.Events; -using CoreEx.TestFunction.Models; -using NUnit.Framework; -using System; -using System.Net.Mime; -using System.Threading.Tasks; -using Az = Azure.Messaging.ServiceBus; - -namespace CoreEx.Test.Framework.Azure.ServiceBus -{ - [TestFixture] - public class EventDataToServiceBusConverterTest - { - [Test] - public async Task Convert_NoValue_Using_TextJsonEventSerialization_ToAndFrom() - { - var c = new EventDataToServiceBusConverter(); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY" }); - AssertServiceBusMessage(m); - - var rm = CreateServiceBusReceivedMessageFromAmqp(m.GetRawAmqpMessage()); - - var edc = new ServiceBusReceivedMessageEventDataConverter(); - var e = await edc.ConvertFromAsync(rm, null, default); - AssertEventData(e); - } - - [Test] - public async Task Convert_WithValue_Using_TextJsonEventSerialization_ToAndFrom() - { - var c = new EventDataToServiceBusConverter(); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY", Value = new Product { Id = "X", Name = "Xxx", Price = 9.99m } }); - AssertServiceBusMessage(m); - - var rm = CreateServiceBusReceivedMessageFromAmqp(m.GetRawAmqpMessage()); - - var edc = new ServiceBusReceivedMessageEventDataConverter(); - var e = await edc.ConvertFromAsync(rm, null, default); - AssertEventData(e); - - var ep = await edc.ConvertFromAsync(rm, default); - AssertEventData(ep); - - ep = (EventData)await edc.ConvertFromAsync(rm, typeof(Product), default); - AssertEventData(ep); - } - - [Test] - public async Task Convert_NoValue_Using_TextJsonEventSerialization_All_ToAndFrom() - { - var es = new CoreEx.Text.Json.EventDataSerializer { SerializeValueOnly = false }; - - var c = new EventDataToServiceBusConverter(es); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY" }); - AssertServiceBusMessage(m); - - var rm = CreateServiceBusReceivedMessageFromAmqp(m.GetRawAmqpMessage()); - - var edc = new ServiceBusReceivedMessageEventDataConverter(es); - var e = await edc.ConvertFromAsync(rm, null, default); - AssertEventData(e); - } - - [Test] - public async Task Convert_WithValue_Using_TextJsonEventSerialization_All_ToAndFrom() - { - var es = new CoreEx.Text.Json.EventDataSerializer { SerializeValueOnly = false }; - - var c = new EventDataToServiceBusConverter(es); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY", Value = new Product { Id = "X", Name = "Xxx", Price = 9.99m } }); - AssertServiceBusMessage(m); - - var rm = CreateServiceBusReceivedMessageFromAmqp(m.GetRawAmqpMessage()); - - var edc = new ServiceBusReceivedMessageEventDataConverter(es); - var e = await edc.ConvertFromAsync(rm, null, default); - AssertEventData(e); - - var ep = await edc.ConvertFromAsync(rm, default); - AssertEventData(ep); - - ep = (EventData)await edc.ConvertFromAsync(rm, typeof(Product), default); - AssertEventData(ep); - } - - [Test] - public async Task Convert_NoValue_Using_TextJsonCloudEventSerialization_All_ToAndFrom() - { - var es = new CoreEx.Text.Json.CloudEventSerializer(); - - var c = new EventDataToServiceBusConverter(es); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Source = new Uri("xxx", UriKind.Relative), Action = "YYY" }); - AssertServiceBusMessage(m); - - var rm = CreateServiceBusReceivedMessageFromAmqp(m.GetRawAmqpMessage()); - - var edc = new ServiceBusReceivedMessageEventDataConverter(es); - var e = await edc.ConvertFromAsync(rm, null, default); - AssertEventData(e); - } - - [Test] - public async Task Convert_As_PlainText() - { - var es = new CoreEx.Text.Json.CloudEventSerializer(); - - var c = new EventDataToServiceBusConverter(es); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Source = new Uri("xxx", UriKind.Relative), Action = "YYY" }); - AssertServiceBusMessage(m); - - var rm = CreateServiceBusReceivedMessageFromAmqp(m.GetRawAmqpMessage(), c => { c.Properties.ContentType = MediaTypeNames.Text.Plain; c.Body = new AmqpMessageBody(new ReadOnlyMemory[] { new BinaryData("Blah").ToMemory()[..] }); }); - - var edc = new ServiceBusReceivedMessageEventDataConverter(es); - var e = await edc.ConvertFromAsync(rm, null, default); - Assert.Multiple(() => - { - Assert.That(e.Value, Is.Not.Null.And.EqualTo("Blah")); - Assert.That(m.Subject, Is.EqualTo("xxx")); - }); - } - - [Test] - public async Task Convert_Value_As_PlainText() - { - var es = new CoreEx.Text.Json.CloudEventSerializer(); - - var c = new EventDataToServiceBusConverter(es); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Source = new Uri("xxx", UriKind.Relative), Action = "YYY" }); - AssertServiceBusMessage(m); - - var rm = CreateServiceBusReceivedMessageFromAmqp(m.GetRawAmqpMessage(), c => { c.Properties.ContentType = MediaTypeNames.Text.Plain; c.Body = new AmqpMessageBody(new ReadOnlyMemory[] { new BinaryData("Blah").ToMemory()[..] }); }); - - var edc = new ServiceBusReceivedMessageEventDataConverter(es); - var e = await edc.ConvertFromAsync(rm, typeof(string), default); - Assert.Multiple(() => - { - Assert.That(e.Value, Is.Not.Null.And.EqualTo("Blah")); - Assert.That(m.Subject, Is.EqualTo("xxx")); - }); - } - - [Test] - public async Task Convert_WithValue_Using_TextJsonCloudEventSerialization_All_ToAndFrom() - { - var es = new CoreEx.Text.Json.CloudEventSerializer(); - - var c = new EventDataToServiceBusConverter(es); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY", Source = new Uri("xxx", UriKind.Relative), Value = new Product { Id = "X", Name = "Xxx", Price = 9.99m } }); - AssertServiceBusMessage(m); - - var rm = CreateServiceBusReceivedMessageFromAmqp(m.GetRawAmqpMessage()); - - var edc = new ServiceBusReceivedMessageEventDataConverter(es); - var e = await edc.ConvertFromAsync(rm, null, default); - AssertEventData(e); - - var ep = await edc.ConvertFromAsync(rm, default); - AssertEventData(ep); - - ep = (EventData)await edc.ConvertFromAsync(rm, typeof(Product), default); - AssertEventData(ep); - } - - private static Az.ServiceBusReceivedMessage CreateServiceBusReceivedMessageFromAmqp(AmqpAnnotatedMessage message, Action? config = null) - { - if (message == null) throw new ArgumentNullException("message"); - - config?.Invoke(message); - - message.Header.DeliveryCount = 1; - message.Header.Durable = true; - message.Header.Priority = 1; - message.Header.TimeToLive = TimeSpan.FromSeconds(60); - - var t = typeof(Az.ServiceBusReceivedMessage); - var c = t.GetConstructor(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, null, new Type[] { typeof(AmqpAnnotatedMessage) }, null); - return c == null - ? throw new InvalidOperationException($"'{typeof(Az.ServiceBusReceivedMessage).Name}' constructor that accepts Type '{typeof(AmqpAnnotatedMessage).Name}' parameter was not found.") - : (Az.ServiceBusReceivedMessage)c.Invoke(new object?[] { message }); - } - - private static void AssertServiceBusMessage(Az.ServiceBusMessage? m) - { - Assert.That(m, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(m!.MessageId, Is.EqualTo("123")); - Assert.That(m.Subject, Is.EqualTo("xxx")); - Assert.That(m.ApplicationProperties.TryGetValue(nameof(EventData.Action), out var a), Is.True); - Assert.That(a, Is.EqualTo("yyy")); - }); - } - - private static void AssertEventData(EventData e) - { - Assert.That(e, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(e.Id, Is.EqualTo("123")); - Assert.That(e.Subject, Is.EqualTo("xxx")); - Assert.That(e.Action, Is.EqualTo("yyy")); - }); - } - - private static void AssertEventData(EventData ep) - { - Assert.That(ep, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ep.Id, Is.EqualTo("123")); - Assert.That(ep.Subject, Is.EqualTo("xxx")); - Assert.That(ep.Action, Is.EqualTo("yyy")); - Assert.That(ep.Value, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(ep.Value.Id, Is.EqualTo("X")); - Assert.That(ep.Value.Name, Is.EqualTo("Xxx")); - Assert.That(ep.Value.Price, Is.EqualTo(9.99m)); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Azure/Storage/BlobAttachmentStorageTest.cs b/tests/CoreEx.Test/Framework/Azure/Storage/BlobAttachmentStorageTest.cs deleted file mode 100644 index da2ba293..00000000 --- a/tests/CoreEx.Test/Framework/Azure/Storage/BlobAttachmentStorageTest.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Azure.Storage.Blobs; -using CoreEx.Azure.Storage; -using CoreEx.Events; -using NUnit.Framework; -using System; -using System.Threading; - -namespace CoreEx.Test.Framework.Azure.Storage -{ - [TestFixture] - public class BlobAttachmentStorageTest - { - private BlobContainerClient? _bcc; - - [OneTimeSetUp] - public void Init() - { - var containerName = "testevents"; - var csn = $"{nameof(BlobAttachmentStorage)}ConnectionString"; - var cs = Environment.GetEnvironmentVariable(csn); - if (cs is null) - Assert.Inconclusive($"Test cannot run as the environment variable '{csn}' is not defined."); - - _bcc = new BlobContainerClient(cs, containerName); - _bcc.CreateIfNotExists(); - } - - [Test] - public void BlobAttachmentStorage_SuccessfulSendToStorage() - { - var testEvent = new EventData { Id = Guid.NewGuid().ToString() }; - var eventData = "{ \"data\": \"1234\" }"; - var attachmentData = new BinaryData(eventData); - var bas = new BlobAttachmentStorage(_bcc!); - - var result = bas.WriteAsync(testEvent, attachmentData, CancellationToken.None).Result; - - Assert.That(result, Is.Not.Null); - Assert.That(result.Attachment, Is.Not.Null); - Assert.That(result.Attachment!.Contains(testEvent.Id), Is.True); - - var eventReceivedData = bas.ReadAync(result, CancellationToken.None).Result; - - Assert.That(eventReceivedData, Is.Not.Null); - Assert.That(eventReceivedData.ToString(), Is.EqualTo(eventData)); - - _bcc!.DeleteBlob($"{testEvent.Id}.json"); - } - - [Test] - public void BlobSasAttachmentStorage_SuccessfulSendToStorage() - { - var testEvent = new EventData { Id = Guid.NewGuid().ToString() }; - var eventData = "{ \"id\": \"1234\" }"; - var attachmentData = new BinaryData(eventData); - var bas = new BlobSasAttachmentStorage(_bcc!); - - var result = bas.WriteAsync(testEvent, attachmentData, CancellationToken.None).Result; - - Assert.That(result, Is.Not.Null); - Assert.That(result.Attachment, Is.Not.Null); - Assert.That(result.Attachment!.Contains(testEvent.Id), Is.True); - - var eventReceivedData = bas.ReadAync(result, CancellationToken.None).Result; - - Assert.That(eventReceivedData, Is.Not.Null); - Assert.That(eventReceivedData.ToString(), Is.EqualTo(eventData)); - - _bcc!.DeleteBlob($"{testEvent.Id}.json"); - } - - [Test] - public void BlobAttachmentStorage_WithTenantId_SetsProperContainer() - { - var testTenantId = "112233"; - var testEvent = new EventData { Id = Guid.NewGuid().ToString(), TenantId = testTenantId }; - var eventData = "{ \"id\": \"1234\" }"; - var attachmentData = new BinaryData(eventData); - var bas = new BlobAttachmentStorage(_bcc!); - - var result = bas.WriteAsync(testEvent, attachmentData, CancellationToken.None).Result; - - Assert.That(result, Is.Not.Null); - Assert.That(result.Attachment, Is.Not.Null); - Assert.That(result.Attachment!.Contains($"{testTenantId}/{testEvent.Id}"), Is.True); - } - - [Test] - public void BlobSasAttachmentStorage_WithTenantId_SetsProperContainer() - { - var testTenantId = "112233"; - var testEvent = new EventData { Id = Guid.NewGuid().ToString(), TenantId = testTenantId }; - var eventData = "{ \"id\": \"1234\" }"; - var attachmentData = new BinaryData(eventData); - var bas = new BlobSasAttachmentStorage(_bcc!); - - var result = bas.WriteAsync(testEvent, attachmentData, CancellationToken.None).Result; - - Assert.That(result, Is.Not.Null); - Assert.That(result.Attachment, Is.Not.Null); - Assert.That(result.Attachment!.Contains($"{testTenantId}/{testEvent.Id}"), Is.True); - } - - [OneTimeTearDown] - public void Cleanup() - { - // Cleanup - _bcc?.Delete(); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Azure/Storage/BlobLockSynchronizerTest.cs b/tests/CoreEx.Test/Framework/Azure/Storage/BlobLockSynchronizerTest.cs deleted file mode 100644 index 4b771015..00000000 --- a/tests/CoreEx.Test/Framework/Azure/Storage/BlobLockSynchronizerTest.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Azure.Storage.Blobs; -using CoreEx.Azure.Storage; -using NUnit.Framework; -using System; -using System.Threading; - -namespace CoreEx.Test.Framework.Azure.Storage -{ - [TestFixture] - public class BlobLockSynchronizerTest - { - /// - /// If this test fails check in the Azure Portal to make sure the lease is available, or file does not exist. - /// - [Test] - public void EnterAndExit_AutoRenew() - { - var csn = $"{nameof(BlobLeaseSynchronizer)}ConnectionString"; - var cs = Environment.GetEnvironmentVariable(csn); - if (cs is null) - Assert.Inconclusive($"Test cannot run as the environment variable '{csn}' is not defined."); - - var bcc = new BlobContainerClient(cs, "event-synchronizer"); - var bls = new TestBlobLockSynchronizer(bcc); - var bls2 = new TestBlobLockSynchronizer(bcc); - - Assert.Multiple(() => - { - // Acquire lease. - Assert.That(bls.Enter(), Is.True); - - // Try immediately. - Assert.That(bls2.Enter(), Is.False); - }); - - // Try within the initial lease time. - Thread.Sleep(12000); - Assert.That(bls2.Enter(), Is.False); - - // Try and should have been renewed. - Thread.Sleep(12000); - Assert.That(bls2.Enter(), Is.False); - - // Release it. - bls.Exit(); - - Assert.Multiple(() => - { - // And final quick go around again. - Assert.That(bls.Enter(), Is.True); - Assert.That(bls2.Enter(), Is.False); - }); - bls.Exit(); - } - } - - public class TestBlobLockSynchronizer : BlobLeaseSynchronizer - { - public TestBlobLockSynchronizer(BlobContainerClient client) : base(client) { } - - public override TimeSpan LeaseDuration => TimeSpan.FromSeconds(20); - - public override TimeSpan AutoRenewLeaseDuration => TimeSpan.FromSeconds(15); - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Caching/RequestCacheTest.cs b/tests/CoreEx.Test/Framework/Caching/RequestCacheTest.cs deleted file mode 100644 index 6333735b..00000000 --- a/tests/CoreEx.Test/Framework/Caching/RequestCacheTest.cs +++ /dev/null @@ -1,31 +0,0 @@ -using CoreEx.Caching; -using CoreEx.Entities; -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Caching -{ - [TestFixture] - public class RequestCacheTest - { - [Test] - public void CacheKeyEntityKeyPrecedence() - { - var e = new Entity(); - - var rc = new RequestCache(); - rc.SetValue(e); - - Assert.Multiple(() => - { - Assert.That(rc.TryGetValue(new CompositeKey(1), out Entity? value), Is.False); - Assert.That(rc.TryGetValue(new CompositeKey(2), out value), Is.True); - }); - } - } - - public class Entity : IEntityKey, ICacheKey - { - public CompositeKey EntityKey => new(1); - public CompositeKey CacheKey => new(2); - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Caching/ResultExtensionsTest.cs b/tests/CoreEx.Test/Framework/Caching/ResultExtensionsTest.cs deleted file mode 100644 index 1b18d7c7..00000000 --- a/tests/CoreEx.Test/Framework/Caching/ResultExtensionsTest.cs +++ /dev/null @@ -1,51 +0,0 @@ -using CoreEx.Caching; -using CoreEx.Results; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Caching -{ - [TestFixture] - internal class ResultExtensionsTest - { - [Test] - public async Task CacheGetOrAddAsync_PreExisting() - { - var rc = new RequestCache(); - rc.SetValue("a", 88); - - var r = await Result.Go().CacheGetOrAddAsync(rc, "a", () => Task.FromResult(Result.Go(99))); - Assert.That(r.Value, Is.EqualTo(88)); - } - - [Test] - public async Task CacheGetOrAddAsync_NotExisting() - { - var rc = new RequestCache(); - rc.SetValue("b", 88); - - var r = await Result.Go().CacheGetOrAddAsync(rc, "a", () => Task.FromResult(Result.Go(99))); - Assert.That(r.Value, Is.EqualTo(99)); - } - - [Test] - public async Task CacheGetOrAddAsync_PreError() - { - var rc = new RequestCache(); - rc.SetValue("a", 88); - - var r = await Result.Fail("bad").CacheGetOrAddAsync(rc, "a", () => Task.FromResult(Result.Go(99))); - Assert.That(r.IsFailure, Is.True); - } - - [Test] - public async Task CacheGetOrAddAsync_FactoryError() - { - var rc = new RequestCache(); - rc.SetValue("b", 88); - - var r = await Result.Go().CacheGetOrAddAsync(rc, "a", () => Task.FromResult(Result.Fail("bad"))); - Assert.That(r.IsFailure, Is.True); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Configuration/SettingsBaseTest.cs b/tests/CoreEx.Test/Framework/Configuration/SettingsBaseTest.cs deleted file mode 100644 index de3a16b1..00000000 --- a/tests/CoreEx.Test/Framework/Configuration/SettingsBaseTest.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using CoreEx.Configuration; -using FluentAssertions; -using Microsoft.Extensions.Configuration; -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Configuration -{ - [TestFixture, NonParallelizable] - public class SettingsBaseTest - { - public class SettingsForTesting : SettingsBase - { - public SettingsForTesting(IConfiguration configuration, params string[] prefixes) : base(configuration, prefixes) - { - } - - public string PropTest => GetValue(); - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "")] - public string PropTest2 => "some hardcoded value"; - - public string SomethingGlobal => GetValue(); - public string PropTestNested => GetValue(defaultValue: SomethingGlobal); - - } - - private static IConfiguration CreateTestConfiguration() - { - Environment.SetEnvironmentVariable("this_is_a_unittest_underscore__key", "underscoreValue"); - ConfigurationBuilder builder = new(); - Dictionary testSettings = new() - { - {"SomethingGlobal", "foo"}, - {"prefix1/key1", "value1"}, - {"prefix2/key2", "value2"}, - {"common/key2", "commonValue2"}, - {"very/custom/prefix/key3", "value3"}, - {"key2", "globalValue2"} - }; - builder.AddInMemoryCollection(testSettings); - return builder.AddEnvironmentVariables("this_is_a_unittest_").Build(); - } - - [Test] - public void CommonSettings_Should_Not_ThrowException_When_ConfigurationNull() - { - _ = new SettingsForTesting(null!); - } - - [Test] - public void CommonSettings_Should_ThrowException_When_PrefixesNull() - { - // Arrange - var configuration = CreateTestConfiguration(); - - // Act - Action act = () => _ = new SettingsForTesting(configuration, prefixes: null!); - - // Assert - act.Should().NotThrow(); - } - - [Test] - public void CommonSettings_Should_ThrowException_When_PrefixIsNullOrEmpty() - { - // Arrange - var configuration = CreateTestConfiguration(); - var prefixes = new string[] { "foo", string.Empty }; - - // Act - Action act = () => _ = new SettingsForTesting(configuration, prefixes); - - // Assert - act.Should().Throw(); - } - - [Test] - public void GetValue_Should_Return_Default_Value_When_Key_Not_Found() - { - var configuration = CreateTestConfiguration(); - var settings = new SettingsForTesting(configuration, new string[] { "prefix2/", "Common/" }); - - var result = settings.GetValue("key2", "foo"); - - // Assert - result.Should().Be("value2", because: "First matched prefix is returned, Sample prefix takes precedence"); - } - - [Test] - public void GetValueByFullKey_Should_Return_Value_When_Key_Found() - { - var configuration = CreateTestConfiguration(); - var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - - var result = settings.GetValue("very/custom/prefix/key3"); - - // Assert - result.Should().Be("value3"); - } - - [Test] - public void GetValueByFullKey_Should_Return_Value_When_Key_FoundWithoutPrefix() - { - var configuration = CreateTestConfiguration(); - var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - - var result = settings.GetValue("SomethingGlobal"); - - // Assert - result.Should().Be("foo"); - } - - [Test] - public void GetValueByFullKey_Should_Return_Default_When_Key_NotFound() - { - var configuration = CreateTestConfiguration(); - var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - - var result = settings.GetValue("very/custom/notfound", true); - - // Assert - result.Should().Be(true); - } - - [Test] - public void GetValue_Should_Return_LastPrefixMatched_Value() - { - var configuration = CreateTestConfiguration(); - var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - - var result = settings.GetValue("very/custom/notfound", true); - - // Assert - result.Should().Be(true); - } - - [Test] - public void GetValue_Should_Return_Value_When_KeysWithDoubleUnderscores() - { - var configuration = CreateTestConfiguration(); - var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - - var result = settings.GetValue("underscore__key"); - - // Assert - result.Should().Be("underscoreValue"); - } - - [Test] - public void GetValue_Should_Return_Value_When_SemicolonKeyUsedForDoubleUnderscore() - { - var configuration = CreateTestConfiguration(); - var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - - var result = settings.GetValue("underscore:key"); - - // Assert - result.Should().Be("underscoreValue"); - } - - [Test] - public void GetValue_Should_Return_Value_From_Property_When_Class_Has_it() - { - var configuration = CreateTestConfiguration(); - var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - - var result = settings.PropTest2; - - // Assert - result.Should().Be("some hardcoded value"); - } - - [Test] - public void GetValue_Should_Return_Value_From_NestedProperty_When_Class_Has_it() - { - var configuration = CreateTestConfiguration(); - var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - - var result = settings.PropTestNested; - - // Assert - settings.SomethingGlobal.Should().Be("foo"); - result.Should().Be("foo"); - } - - // 50 * 5 * 100 ms = 25 seconds - [Test, Repeat(50)] - public void RunInParallel() - { - var configuration = CreateTestConfiguration(); - var settings = new SettingsForTesting(configuration, new string[] { "Sample/", "Common/" }); - - var reps = Enumerable.Range(1, 50); - - var testResult = Parallel.ForEach(reps, (i) => - { - var result = settings.PropTestNested; - - // Assert - settings.SomethingGlobal.Should().Be("foo"); - result.Should().Be("foo"); - - ExamineValuesOfLocalObjectsEitherSideOfAwait(settings).Wait(); - }); - - testResult.IsCompleted.Should().BeTrue(); - } - - static async Task ExamineValuesOfLocalObjectsEitherSideOfAwait(SettingsForTesting settings) - { - var result = settings.PropTestNested; - settings.SomethingGlobal.Should().Be("foo"); - result.Should().Be("foo"); - - await Task.Delay(100); - - result = settings.PropTestNested; - settings.SomethingGlobal.Should().Be("foo"); - result.Should().Be("foo"); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs b/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs deleted file mode 100644 index 7eb0f344..00000000 --- a/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs +++ /dev/null @@ -1,350 +0,0 @@ -using CoreEx.Data.Querying; -using CoreEx.Data.Querying.Expressions; -using NUnit.Framework; -using System; -using System.Linq; - -namespace CoreEx.Test.Framework.Data -{ - [TestFixture] - public class QueryArgsConfigTest - { - private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() - .WithFilter(filter => filter - .AddField("LastName", c => c.WithOperators(QueryFilterOperator.AllStringOperators).AlsoCheckNotNull()) - .AddField("FirstName", c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) - .AddField("Code") - .AddField("Birthday", "BirthDate") - .AddField("Age", c => c.WithHelpText("Age is but a number.")) - .AddField("Salary") - .AddField("IsOld", c => c.AsNullable()) - .AddNullField("Terminated", "TerminatedDate") - .WithHelpText($"---{Environment.NewLine}Note: The OData-like filtering is awesome!")) - .WithOrderBy(order => order - .AddField("FirstName") - .AddField("LastName") - .AddField("Birthday", "BirthDate", c => c.WithDirection(QueryOrderByDirection.Descending)) - .WithDefault("LastName, FirstName") - .WithHelpText($"---{Environment.NewLine}Note: The OData-like ordering is awesome!")); - - private static void AssertFilter(string filter, string expected, params object[] expectedArgs) => AssertFilter(_queryConfig, filter, expected, expectedArgs); - - private static void AssertFilter(QueryArgsConfig config, string? filter, string expected, params object[] expectedArgs) - { - var result = config.FilterParser.Parse(filter); - Assert.That(result.IsSuccess, Is.True); - Assert.Multiple(() => - { - Assert.That(result.Value.ToLinqString(), Is.EqualTo(expected)); - Assert.That(result.Value.Args, Is.EquivalentTo(expectedArgs)); - }); - } - - private static void AssertException(string? filter, string expected) => AssertException(_queryConfig, filter, expected); - - private static void AssertException(QueryArgsConfig config, string? filter, string expected) - { - var result = config.FilterParser.Parse(filter); - Assert.Multiple(() => - { - Assert.That(result.IsFailure, Is.True); - Assert.That(result.Error, Is.TypeOf()); - }); - - var ex = (QueryFilterParserException)result.Error; - - Assert.That(ex.Messages, Is.Not.Null); - Assert.That(ex.Messages, Has.Count.EqualTo(1)); - Assert.Multiple(() => - { - Assert.That(ex.Messages!.First().Property, Is.EqualTo("$filter")); - Assert.That(ex.Messages!.First().Text, Does.StartWith(expected)); - }); - } - - [Test] - public void FilterParser_SimpleValid() - { - AssertFilter("lastname eq 'Smith'", "(LastName != null && LastName == @0)", "Smith"); - AssertFilter("lastname eq null", "LastName == null"); - AssertFilter("firstname eq 'Angela'", "FirstName.ToUpper() == @0", "ANGELA"); - AssertFilter("code eq 'Xyz'", "Code == @0", "Xyz"); - AssertFilter("birthday eq 1980-01-01", "BirthDate == @0", new DateTime(1980, 1, 1)); - AssertFilter("birthday ne 1980-01-01", "BirthDate != @0", new DateTime(1980, 1, 1)); - AssertFilter("age lt 100", "Age < @0", 100); - AssertFilter("age le 100", "Age <= @0", 100); - AssertFilter("age gt 100", "Age > @0", 100); - AssertFilter("age ge 100", "Age >= @0", 100); - AssertFilter("salary gt 1036.42", "Salary > @0", 1036.42); - AssertFilter("isold eq true", "IsOld == true"); - AssertFilter("IsOld ne false", "IsOld != false"); - AssertFilter("ISOLD ne null", "IsOld != null"); - AssertFilter("isold", "IsOld"); - } - - [Test] - public void FilterParser_In() - { - AssertFilter("code in ('abc', 'def')", "Code in (@0, @1)", "abc", "def"); - AssertFilter("age in (20, 30, 40)", "Age in (@0, @1, @2)", 20, 30, 40); - AssertFilter("age in (20)", "Age in (@0)", 20); - - AssertException("code in", "The final expression is incomplete."); - AssertException("code in ()", "Field 'code' constant must be specified before the closing ')' for the 'in' operator."); - AssertException("code in (null)", "Field 'code' constant must not be null for an 'in' operator."); - AssertException("code in ))", "Field 'code' must specify an opening '(' for the 'in' operator."); - AssertException("code in ((", "Field 'code' must close ')' the 'in' operator before specifying a further open '('."); - AssertException("code in (,)", "Field 'code' constant ',' is not considered valid."); - AssertException("age in (1 2)", "Field 'age' expects a ',' separator between constant values for an 'in' operator."); - } - - [Test] - public void FilterParser_ComplexValid() - { - AssertFilter("(age eq 1 or age eq 2) and isold eq true", "(Age == @0 || Age == @1) && IsOld == true", 1, 2); - AssertFilter("(age eq 1 or age eq 2 ) and isold ", "(Age == @0 || Age == @1) && IsOld", 1, 2); - AssertFilter("(age eq 1 or age eq 2) or (age eq 8 or age eq 9)", "(Age == @0 || Age == @1) || (Age == @2 || Age == @3)", 1, 2, 8, 9); - AssertFilter("((age eq 1 or age eq 2) or (age eq 8 or age eq 9))", "((Age == @0 || Age == @1) || (Age == @2 || Age == @3))", 1, 2, 8, 9); - } - - [Test] - public void FilterParser_Invalid() - { - AssertException("banana", "Field 'banana' is not supported."); - AssertException("banana eq", "Field 'banana' is not supported."); - AssertException("age apple", "Field 'age' does not support 'apple' as an operator."); - AssertException("age 'apple'", "Field 'age' does not support ''apple'' as an operator."); - AssertException("age eq 'apple'", "Field 'age' constant 'apple' must not be specified as a Literal where the underlying type is not a string."); - AssertException("age eq 1990-01-01", "Field 'age' has a value '1990-01-01' that is not a valid Int32."); - AssertException("null eq null", "There is a 'null' positioning that is syntactically incorrect."); - AssertException("true eq null", "There is a 'true' positioning that is syntactically incorrect."); - AssertException("false eq null", "There is a 'false' positioning that is syntactically incorrect."); - AssertException("and", "There is a 'and' positioning that is syntactically incorrect."); - AssertException("or", "There is a 'or' positioning that is syntactically incorrect."); - AssertException("and age eq 1", "There is a 'and' positioning that is syntactically incorrect."); - AssertException("or age eq 1", "There is a 'or' positioning that is syntactically incorrect."); - AssertException("age eq 1 and", "The final expression is incomplete."); - AssertException("age eq 1 or", "The final expression is incomplete."); - AssertException("isold ge true", "Field 'isold' does not support the 'ge' operator."); - AssertException("age xx 1", "Field 'age' does not support 'xx' as an operator."); - AssertException("age ge null", "Field 'age' constant must not be null for an 'ge' operator."); - - AssertException("(age eq 1", "There is an opening '(' that has no matching closing ')'."); - AssertException("age eq 1)", "There is a closing ')' that has no matching opening '('."); - AssertException("age ( 1", "Field 'age' does not support '(' as an operator."); - AssertException("age eq (", "Field 'age' constant '(' is not considered valid."); - AssertException("age eq )", "Field 'age' constant ')' is not considered valid."); - AssertException("age eq null", "Field 'age' constant 'null' is not supported."); - } - - [Test] - public void FilterParser_Literals() - { - AssertException("code eq '", "A Literal has not been terminated."); - AssertException("code eq '''", "A Literal has not been terminated."); - AssertException("code eq '''''", "A Literal has not been terminated."); - - AssertFilter("code eq ''", "Code == @0", string.Empty); - AssertFilter("code eq ''''", "Code == @0", "'"); - AssertFilter("code eq 'x''x'", "Code == @0", "x'x"); - AssertFilter("code eq 'x'''", "Code == @0", "x'"); - AssertFilter("code eq '''x'", "Code == @0", "'x"); - AssertFilter("code eq '''x'''", "Code == @0", "'x'"); - - AssertFilter("code eq 'null'", "Code == @0", "null"); - - AssertException("code eq 1", "Field 'code' constant '1' must be specified as a Literal where the underlying type is a string."); - AssertException("age eq '8'", "Field 'age' constant '8' must not be specified as a Literal where the underlying type is not a string."); - } - - [Test] - public void FilterParser_StringFunction() - { - AssertFilter("startswith(firstName, 'abc')", "FirstName.ToUpper().StartsWith(@0)", "ABC"); - AssertFilter("endswith(firstName, 'abc')", "FirstName.ToUpper().EndsWith(@0)", "ABC"); - AssertFilter("contains(firstName, 'abc')", "FirstName.ToUpper().Contains(@0)", "ABC"); - AssertFilter("contains(lastname, 'xyz')", "(LastName != null && LastName.Contains(@0))", "xyz"); - - AssertException("startswith(code, 'abc')", "Field 'code' does not support the 'startswith' function."); - AssertException("startswith)code, 'abc')", "A 'startswith' function expects an opening '(' not a ')'."); - AssertException("startswith(firstname( 'abc')", "A 'startswith' function expects a ',' separator between the field and its constant."); - AssertException("startswith(firstname, null)", "A 'startswith' function references a null constant which is not supported."); - AssertException("startswith(firstname, 'abc',", "A 'startswith' function expects a closing ')' not a ','."); - } - - [Test] - public void FilterParser_Not() - { - AssertFilter("not (age eq 1)", "!(Age == @0)", 1); - AssertFilter("age eq 1 and not (age eq 2)", "Age == @0 && !(Age == @1)", 1, 2); - - AssertException("age eq 1 and not age eq 2", "A 'not' expects an opening '(' to start an expression versus a syntactically incorrect 'age' token."); - AssertException("age eq 1 not", "There is a 'not' positioning that is syntactically incorrect."); - } - - [Test] - public void FilterParser_Field_Default() - { - var config = QueryArgsConfig.Create() - .WithFilter(filter => filter - .AddField("LastName", c => c.WithDefault(new QueryStatement("LastName == @0", "Brown"))) - .AddField("FirstName") - .WithDefault(new QueryStatement("FirstName == @0", "Zoe"))); - - AssertFilter(config, "lastname eq 'Smith'", "LastName == @0", "Smith"); - AssertFilter(config, null, "LastName == @0", "Brown"); - AssertFilter(config, "firstname eq 'Jenny'", "FirstName == @0 && LastName == @1", "Jenny", "Brown"); - } - - [Test] - public void FilterParser_Default() - { - var config = QueryArgsConfig.Create() - .WithFilter(filter => filter - .AddField("LastName") - .AddField("FirstName") - .WithDefault(new QueryStatement("FirstName == @0", "Zoe"))); - - AssertFilter(config, "lastname eq 'Smith'", "LastName == @0", "Smith"); - AssertFilter(config, "", "FirstName == @0", "Zoe"); - AssertFilter(config, null, "FirstName == @0", "Zoe"); - } - - [Test] - public void FilterParser_Field_OnQuery() - { - var config = QueryArgsConfig.Create() - .WithFilter(filter => filter - .AddField("LastName") - .AddField("FirstName") - .OnQuery(result => - { - if (!result.Fields.Contains("LastName")) - result.AppendStatement(new QueryStatement("LastName != null")); - - if (result.Fields.Count > 1) - throw new QueryFilterParserException("Only a single field filter is allowed."); - })); - - AssertFilter(config, "lastname eq 'Smith'", "LastName == @0", "Smith"); - AssertFilter(config, "firstname eq 'Angela'", "FirstName == @0 && LastName != null", "Angela"); - AssertFilter(config, null, "LastName != null"); - - AssertException(config, "lastname eq 'Smith' and firstname eq 'Angela'", "Only a single field filter is allowed."); - } - - [Test] - public void FilterParser_Null() - { - var config = QueryArgsConfig.Create() - .WithFilter(filter => filter - .AddNullField("Terminated", "TerminatedDate")); - - AssertFilter(config, "terminated eq null", "TerminatedDate == null"); - AssertFilter(config, "terminated ne null", "TerminatedDate != null"); - - AssertException(config, "terminated eq 13", "Field 'terminated' with value '13' is invalid: Only null comparisons are supported."); - AssertException(config, "terminated gt null", "Field 'terminated' does not support the 'gt' operator."); - } - - [Test] - public void FilterParser_StatementWriter() - { - static bool LastNameWriter(IQueryFilterFieldStatementExpression expression, QueryFilterParserResult result) - { - if (expression is QueryFilterOperatorExpression oex && oex.Operator.Kind == QueryFilterTokenKind.Equal) - { - result.AppendStatement(new QueryStatement("LastName EQUALS @0", oex.GetConstantValue(0))); - return true; - } - - return false; - } - - var config = QueryArgsConfig.Create() - .WithFilter(filter => filter - .AddField("LastName", c => c.WithResultWriter(LastNameWriter)) - .AddField("FirstName")); - - AssertFilter(config, "lastname ne 'abc'", "LastName != @0", "abc"); - AssertFilter(config, "lastname eq 'abc'", "LastName EQUALS @0", "abc"); - } - - [Test] - public void FilterParser_ToString() - { - var s = _queryConfig.FilterParser.ToString(); - Console.WriteLine(s); - Assert.That(s, Is.EqualTo(@"Filter field(s) are as follows: -LastName (Type: String, Null: true, Operators: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) -FirstName (Type: String, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) -Code (Type: String, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) -Birthday (Type: DateTime, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) -Age (Type: Int32, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) - Age is but a number. -Salary (Type: Decimal, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) -IsOld (Type: Boolean, Null: true, Operators: EQ, NE) -Terminated (Type: , Null: true, Operators: EQ, NE) ---- -Note: The OData-like filtering is awesome!")); - } - - [Test] - public void OrderByParser_Valid() - { - Assert.Multiple(() => - { - Assert.That(_queryConfig.OrderByParser.Parse("firstname, birthday desc").Value.ToLinqString(), Is.EqualTo("FirstName, BirthDate desc")); - Assert.That(_queryConfig.OrderByParser.Parse("lastname asc, birthday desc").Value.ToLinqString(), Is.EqualTo("LastName, BirthDate desc")); - Assert.That(_queryConfig.OrderByParser.Parse(null).Value.ToLinqString(), Is.EqualTo("LastName, FirstName")); - }); - } - - [Test] - public void OrderByParser_Invalid() - { - void AssertException(string? orderBy, string expected) - { - var ex = Assert.Throws(() => _queryConfig.OrderByParser.Parse(orderBy).ThrowOnError()); - Assert.That(ex?.Messages, Is.Not.Null); - Assert.That(ex!.Messages, Has.Count.EqualTo(1)); - Assert.Multiple(() => - { - Assert.That(ex.Messages!.First().Property, Is.EqualTo("$orderby")); - Assert.That(ex.Messages!.First().Text, Does.StartWith(expected)); - }); - } - - AssertException("firstname, middlename", "Field 'middlename' is not supported."); - AssertException("firstname, birthday asc", "Field 'birthday' direction 'asc' is invalid; not supported."); - AssertException("firstname, birthday both", "Field 'birthday' direction 'both' is invalid; must be either 'asc' (ascending) or 'desc' (descending)."); - AssertException("firstname asc, firstname desc", "Field 'firstname' must not be specified more than once."); - AssertException("firstname asc desc", "Statement is syntactically incorrect."); - } - - [Test] - public void OrderByParser_ToString() - { - var s = _queryConfig.OrderByParser.ToString(); - Console.WriteLine(s); - Assert.That(s, Is.EqualTo(@"Order-by field(s) are as follows: -FirstName (Direction: Both) -LastName (Direction: Both) -Birthday (Direction: Descending) ---- -Note: The OData-like ordering is awesome!")); - } - - [Test] - public void QueryArgsParse_Success() - { - var r = _queryConfig.Parse(new CoreEx.Entities.QueryArgs { Filter = "lastname eq 'Smith'", OrderBy = "firstname, birthday desc" }); - Assert.That(r.IsSuccess, Is.True); - Assert.Multiple(() => - { - Assert.That(r.Value!.FilterResult!.ToLinqString(), Is.EqualTo("(LastName != null && LastName == @0)")); - Assert.That(r.Value.FilterResult.Args, Is.EquivalentTo(new object[] { "Smith" })); - Assert.That(r.Value.OrderByResult!.ToLinqString(), Is.EqualTo("FirstName, BirthDate desc")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Database/Mapping/DatabaseMapperTest.cs b/tests/CoreEx.Test/Framework/Database/Mapping/DatabaseMapperTest.cs deleted file mode 100644 index 2ee58bbc..00000000 --- a/tests/CoreEx.Test/Framework/Database/Mapping/DatabaseMapperTest.cs +++ /dev/null @@ -1,70 +0,0 @@ -using CoreEx.Database.Mapping; -using CoreEx.Database.SqlServer; -using CoreEx.Mapping.Converters; -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Database.Mapping -{ - [TestFixture] - internal class DatabaseMapperTest - { - [Test] - public void MapToDb() - { - var p = new Person { Id = 88, Name = "Bob", Address = new Address { Street = "sss", City = "ccc" }, Address2 = new Address { Street = "ttt", City = "ddd" } }; - - var pdm = new PersonDatabaseMapper(); - var dpc = new CoreEx.Database.DatabaseParameterCollection(new SqlServerDatabase(() => null!)); - pdm.MapToDb(p, dpc); - - Assert.That(dpc, Has.Count.EqualTo(5)); - Assert.Multiple(() => - { - Assert.That(dpc[0].ParameterName, Is.EqualTo("@PersonId")); - Assert.That(dpc[0].Value, Is.EqualTo(88)); - Assert.That(dpc[1].ParameterName, Is.EqualTo("@firstname")); - Assert.That(dpc[1].Value, Is.EqualTo("Bob")); - Assert.That(dpc[2].ParameterName, Is.EqualTo("@Street")); - Assert.That(dpc[2].Value, Is.EqualTo("sss")); - Assert.That(dpc[3].ParameterName, Is.EqualTo("@City")); - Assert.That(dpc[3].Value, Is.EqualTo("ccc")); - Assert.That(dpc[4].ParameterName, Is.EqualTo("@Address2")); - Assert.That(dpc[4].Value, Is.EqualTo("{\"street\":\"ttt\",\"city\":\"ddd\"}")); - - Assert.That(pdm[x => x.Name].ColumnName, Is.EqualTo("name")); - }); - } - } - - public class PersonDatabaseMapper : DatabaseMapper - { - private readonly DatabaseMapper
_addressMapper = DatabaseMapper.CreateAuto
("City") - .HasProperty(x => x.City); - - public PersonDatabaseMapper() - { - InheritPropertiesFrom(DatabaseMapper.CreateAuto().HasProperty(x => x.Id, null, "@PersonId")); - Property(x => x.Name, "name", "@firstname"); - Property(x => x.Address).SetMapper(_addressMapper); - Property(x => x.Address2).SetConverter(new ObjectToJsonConverter
()); - } - } - - public class PersonBase - { - public int Id { get; set; } - } - - public class Person : PersonBase - { - public string? Name { get; set; } - public Address? Address { get; set; } - public Address? Address2 { get; set; } - } - - public class Address - { - public string? Street { get; set; } - public string? City { get; set; } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Dataverse/Mapping/DataverseMapperTest.cs b/tests/CoreEx.Test/Framework/Dataverse/Mapping/DataverseMapperTest.cs deleted file mode 100644 index d7dfb573..00000000 --- a/tests/CoreEx.Test/Framework/Dataverse/Mapping/DataverseMapperTest.cs +++ /dev/null @@ -1,92 +0,0 @@ -using NUnit.Framework; -using CoreEx.Dataverse.Mapping; -using Microsoft.Xrm.Sdk; -using System; -using CoreEx.Entities; -using CoreEx.Mapping.Converters; - -namespace CoreEx.Test.Framework.Dataverse.Mapping -{ - [TestFixture] - - internal class DataverseMapperTest - { - [Test] - public void MapToDataverse() - { - var p = new Person { Id = 1.ToGuid().ToString(), Name = "Bob", Address = new Address { Street = "sss", City = "ccc" }, Address2 = new Address { Street = "ttt", City = "ddd" } }; - - var pdm = new PersonDataverseMapper(); - var entity = new Entity("Person"); - pdm.MapToDataverse(p, entity); - - Assert.Multiple(() => - { - Assert.That(entity.Id, Is.EqualTo(1.ToGuid())); - Assert.That(entity.KeyAttributes, Is.Empty); - Assert.That(entity.Attributes, Has.Count.EqualTo(4)); - Assert.That(entity.GetAttributeValue("name"), Is.EqualTo("Bob")); - Assert.That(entity.GetAttributeValue("Street"), Is.EqualTo("sss")); - Assert.That(entity.GetAttributeValue("town"), Is.EqualTo("ccc")); - Assert.That(entity.GetAttributeValue("Address2"), Is.EqualTo("{\"street\":\"ttt\",\"city\":\"ddd\"}")); - }); - - //OrganizationRequest req = new OrganizationRequest(); - //PublicClientApplicationBuilder appBuilder = PublicClientApplicationBuilder.Create("clientId"); - } - - [Test] - public void MapFromDataverse() - { - var pdm = new PersonDataverseMapper(); - var entity = new Entity("Person", 1.ToGuid()); - entity["name"] = "Bob"; - entity["Street"] = "sss"; - entity["town"] = "ccc"; - entity["Address2"] = "{\"street\":\"ttt\",\"city\":\"ddd\"}"; - - var p = pdm.MapFromDataverse(entity)!; - - Assert.Multiple(() => - { - Assert.That(p.Id, Is.EqualTo(1.ToGuid().ToString())); - Assert.That(p.Name, Is.EqualTo("Bob")); - Assert.That(p.Address?.Street, Is.EqualTo("sss")); - Assert.That(p.Address?.City, Is.EqualTo("ccc")); - Assert.That(p.Address2?.Street, Is.EqualTo("ttt")); - Assert.That(p.Address2?.City, Is.EqualTo("ddd")); - }); - } - } - - public class PersonDataverseMapper : DataverseMapper - { - private readonly DataverseMapper
_addressMapper = DataverseMapper.CreateAuto
("City").HasProperty(x => x.City, "town"); - - public PersonDataverseMapper() - { - InheritPropertiesFrom(DataverseMapper.CreateAuto()); - Property(x => x.Name, "name"); - Property(x => x.Address).SetMapper(_addressMapper); - Property(x => x.Address2).SetConverter(new ObjectToJsonConverter
()); - } - } - - public class PersonBase : IIdentifier - { - public string? Id { get; set; } - } - - public class Person : PersonBase - { - public string? Name { get; set; } - public Address? Address { get; set; } - public Address? Address2 { get; set; } - } - - public class Address - { - public string? Street { get; set; } - public string? City { get; set; } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Entities/CleanerTest.cs b/tests/CoreEx.Test/Framework/Entities/CleanerTest.cs deleted file mode 100644 index 6a7f74c2..00000000 --- a/tests/CoreEx.Test/Framework/Entities/CleanerTest.cs +++ /dev/null @@ -1,144 +0,0 @@ -using CoreEx.Configuration; -using CoreEx.Entities; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; -using System; -using System.Collections.Generic; - -namespace CoreEx.Test.Framework.Entities -{ - [TestFixture, NonParallelizable] - public class CleanerTest - { - [Test] - public void DateTimeCleaning() - { - Cleaner.DefaultDateTimeTransform = DateTimeTransform.DateTimeLocal; - var dt = DateTime.UtcNow; - var dtc = Cleaner.Clean(dt); - Assert.That(dtc.Kind, Is.EqualTo(DateTimeKind.Local)); - - Cleaner.DefaultDateTimeTransform = DateTimeTransform.DateTimeUtc; - dtc = Cleaner.Clean(dt); - Assert.That(dtc.Kind, Is.EqualTo(DateTimeKind.Utc)); - - Cleaner.DefaultDateTimeTransform = DateTimeTransform.DateTimeLocal; - } - - [Test] - public void NullableDateTimeCleaning() - { - Cleaner.DefaultDateTimeTransform = DateTimeTransform.DateTimeLocal; - DateTime? dt = DateTime.UtcNow; - DateTime? dtc = Cleaner.Clean(dt); - Assert.That(dtc!.Value.Kind, Is.EqualTo(DateTimeKind.Local)); - - Cleaner.DefaultDateTimeTransform = DateTimeTransform.DateTimeUtc; - dtc = Cleaner.Clean(dt); - Assert.That(dtc!.Value.Kind, Is.EqualTo(DateTimeKind.Utc)); - - Cleaner.DefaultDateTimeTransform = DateTimeTransform.DateTimeLocal; - - dt = null; - dtc = Cleaner.Clean(dt); - Assert.That(dtc, Is.Null); - } - - [Test] - public void DateTimeTransformFromSettings() - { - Cleaner.DefaultDateTimeTransform = DateTimeTransform.DateTimeUtc; - - ConfigurationBuilder builder = new(); - Dictionary testSettings = new() - { - {"CoreEx:Cleaner:DateTimeTransform", "DateTimeLocal"} - }; - builder.AddInMemoryCollection(testSettings); - - IServiceProvider serviceProvider = new ServiceCollection() - .AddSingleton(builder.Build()) - .AddDefaultSettings() - .BuildServiceProvider(); - - using var scope = serviceProvider.CreateScope(); - using var ec = ExecutionContext.CreateNew(); - ec.ServiceProvider = scope.ServiceProvider; - - DateTime? dt = DateTime.UtcNow; - DateTime? dtc = Cleaner.Clean(dt); - Assert.Multiple(() => - { - Assert.That(dtc!.Value.Kind, Is.EqualTo(DateTimeKind.Local)); - Assert.That(Cleaner.DefaultDateTimeTransform, Is.EqualTo(DateTimeTransform.DateTimeUtc)); - }); - } - - [Test] - public void DateTimeTransformFromSettings_Load() - { - Cleaner.DefaultDateTimeTransform = DateTimeTransform.DateTimeUtc; - - ConfigurationBuilder builder = new(); - Dictionary testSettings = new() - { - {"Cleaner:DateTimeTransform", "DateTimeLocal"} - }; - builder.AddInMemoryCollection(testSettings); - - IServiceProvider serviceProvider = new ServiceCollection() - .AddSingleton(builder.Build()) - .AddDefaultSettings() - .BuildServiceProvider(); - - using var scope = serviceProvider.CreateScope(); - using var ec = ExecutionContext.CreateNew(); - ec.ServiceProvider = scope.ServiceProvider; - - DateTime? dtc = DateTime.UtcNow; - for (int i = 0; i < 10000; i++) - { - dtc = Cleaner.Clean(DateTime.UtcNow); - Assert.That(dtc!.Value.Kind, Is.EqualTo(DateTimeKind.Local), "Iteration" + i); - } - } - - [Test] - public void StringTransformCleaning() - { - var s1 = ""; - var s2 = (string?)null; - var s3 = "ABC"; - - Assert.Multiple(() => - { - Assert.That(Cleaner.Clean(s1), Is.Null); - Assert.That(Cleaner.Clean(s2), Is.Null); - Assert.That(Cleaner.Clean(s3), Is.EqualTo("ABC")); - }); - - Cleaner.DefaultStringTransform = StringTransform.NullToEmpty; - Assert.Multiple(() => - { - Assert.That(Cleaner.Clean(s1), Is.EqualTo("")); - Assert.That(Cleaner.Clean(s2), Is.EqualTo("")); - Assert.That(Cleaner.Clean(s3), Is.EqualTo("ABC")); - }); - - Cleaner.DefaultStringTransform = StringTransform.EmptyToNull; - } - - [Test] - public void StringTrimCleaning() - { - var s = " ABC "; - Assert.That(Cleaner.Clean(s), Is.EqualTo(" ABC")); - - Cleaner.DefaultStringTrim = StringTrim.Both; - Assert.That(Cleaner.Clean(s), Is.EqualTo("ABC")); - - Cleaner.DefaultStringTrim = StringTrim.End; - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Entities/CompositeKeyTest.cs b/tests/CoreEx.Test/Framework/Entities/CompositeKeyTest.cs deleted file mode 100644 index 4c1720c8..00000000 --- a/tests/CoreEx.Test/Framework/Entities/CompositeKeyTest.cs +++ /dev/null @@ -1,222 +0,0 @@ -using CoreEx.Entities; -using NUnit.Framework; -using System; - -namespace CoreEx.Test.Framework.Entities -{ - [TestFixture] - public class CompositeKeyTest - { - [Test] - public void DefaultKey() - { - var ck = new CompositeKey(); - Assert.That(ck.Args, Is.Empty); - Assert.Multiple(() => - { - Assert.That(ck.IsInitial, Is.True); - - Assert.That(ck, Is.EqualTo(new CompositeKey())); - }); - Assert.That(ck, Is.Not.EqualTo(new CompositeKey(1))); - - Assert.That(ck, Is.EqualTo(new CompositeKey())); - Assert.Multiple(() => - { - Assert.That(ck, Is.Not.EqualTo(new CompositeKey(1))); - - Assert.That(new CompositeKey().GetHashCode(), Is.EqualTo(ck.GetHashCode())); - Assert.That(new CompositeKey(1).GetHashCode(), Is.Not.EqualTo(ck.GetHashCode())); - }); - } - - [Test] - public void SpecifiedKey() - { - var ck0 = new CompositeKey(0); - Assert.Multiple(() => - { - Assert.That(ck0.Args, Has.Length.EqualTo(1)); - Assert.That(ck0.IsInitial, Is.True); - }); - - var ck1 = new CompositeKey(1); - Assert.Multiple(() => - { - Assert.That(ck1.Args, Has.Length.EqualTo(1)); - Assert.That(ck1.IsInitial, Is.False); - - Assert.That(ck0, Is.EqualTo(new CompositeKey(0))); - Assert.That(ck1, Is.EqualTo(new CompositeKey(1))); - Assert.That(ck0, Is.Not.EqualTo(ck1)); - - Assert.That(new CompositeKey(0).GetHashCode(), Is.EqualTo(ck0.GetHashCode())); - Assert.That(new CompositeKey(1).GetHashCode(), Is.EqualTo(ck1.GetHashCode())); - Assert.That(ck1.GetHashCode(), Is.Not.EqualTo(ck0.GetHashCode())); - }); - } - - [Test] - public void KeyComparisons() - { - Assert.Multiple(() => - { - Assert.That(new CompositeKey(), Is.Not.EqualTo(new CompositeKey(null!))); - Assert.That(new CompositeKey("A"), Is.Not.EqualTo(new CompositeKey("A", null))); - Assert.That(new CompositeKey(1, "A"), Is.Not.EqualTo(new CompositeKey("A", 1))); - }); - } - - [Test] - public void KeyCopy() - { - var ck0 = new CompositeKey("Xyz"); - var ck1 = ck0; - - Assert.That(ck1, Is.EqualTo(ck0)); - } - - [Test] - public void KeyToString_And_CreateFromString() - { - var ck = new CompositeKey(); - Assert.Multiple(() => - { - Assert.That(ck.ToString(), Is.Null); - Assert.That(CompositeKey.TryCreateFromString(ck.ToString(), out ck), Is.True); - Assert.That(ck.Args, Has.Length.EqualTo(1)); - }); - Assert.That(ck.Args[0], Is.Null); - - int? iv = null; - ck = new CompositeKey(iv); - Assert.Multiple(() => - { - Assert.That(ck.ToString(), Is.Null); - Assert.That(CompositeKey.TryCreateFromString(ck.ToString(), out ck), Is.True); - Assert.That(ck.Args, Has.Length.EqualTo(1)); - }); - Assert.That(ck.Args[0], Is.Null); - - ck = new CompositeKey(88); - Assert.Multiple(() => - { - Assert.That(ck.ToString(), Is.EqualTo("88")); - Assert.That(CompositeKey.TryCreateFromString(ck.ToString(), out ck), Is.True); - Assert.That(ck.Args, Has.Length.EqualTo(1)); - }); - Assert.That(ck.Args[0], Is.EqualTo(88)); - - ck = new CompositeKey("abc"); - Assert.Multiple(() => - { - Assert.That(ck.ToString(), Is.EqualTo("abc")); - Assert.That(CompositeKey.TryCreateFromString(ck.ToString(), out ck), Is.True); - Assert.That(ck.Args, Has.Length.EqualTo(1)); - }); - Assert.That(ck.Args[0], Is.EqualTo("abc")); - - ck = new CompositeKey(""); - Assert.Multiple(() => - { - Assert.That(ck.ToString(), Is.EqualTo(string.Empty)); - Assert.That(CompositeKey.TryCreateFromString(ck.ToString(), out ck), Is.True); - Assert.That(ck.Args, Has.Length.EqualTo(1)); - }); - Assert.That(ck.Args[0], Is.Null); - - ck = new CompositeKey(null, null); - Assert.Multiple(() => - { - Assert.That(ck.ToString(), Is.EqualTo(",")); - Assert.That(CompositeKey.TryCreateFromString(ck.ToString(), out ck), Is.True); - Assert.That(ck.Args, Has.Length.EqualTo(2)); - }); - Assert.Multiple(() => - { - Assert.That(ck.Args[0], Is.Null); - Assert.That(ck.Args[1], Is.Null); - }); - - ck = new CompositeKey("text", 'x', short.MinValue, int.MinValue, long.MinValue, ushort.MaxValue, uint.MaxValue, ulong.MaxValue, Guid.Parse("8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc"), - new DateTime(1970, 01, 22, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2000, 01, 22, 20, 59, 43, DateTimeKind.Utc), new DateTimeOffset(2000, 01, 22, 20, 59, 43, TimeSpan.FromHours(-8))); - - Console.WriteLine(ck.ToString()); - Assert.Multiple(() => - { - Assert.That(ck.ToString(), Is.EqualTo("text,x,-32768,-2147483648,-9223372036854775808,65535,4294967295,18446744073709551615,8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc,1970-01-22T00:00:00.0000000,2000-01-22T20:59:43.0000000Z,2000-01-22T20:59:43.0000000-08:00")); - Assert.That(CompositeKey.TryCreateFromString(ck.ToString(), new Type[] { typeof(string), typeof(char), typeof(short), typeof(int), typeof(long), typeof(ushort), typeof(uint), typeof(ulong), typeof(Guid), typeof(DateTime), typeof(DateTime), typeof(DateTimeOffset) }, out ck), Is.True); - Assert.That(ck.Args, Has.Length.EqualTo(12)); - }); - Assert.Multiple(() => - { - Assert.That(ck.Args[0], Is.EqualTo("text")); - Assert.That(ck.Args[1], Is.EqualTo('x')); - Assert.That(ck.Args[2], Is.EqualTo(short.MinValue)); - Assert.That(ck.Args[3], Is.EqualTo(int.MinValue)); - Assert.That(ck.Args[4], Is.EqualTo(long.MinValue)); - Assert.That(ck.Args[5], Is.EqualTo(ushort.MaxValue)); - Assert.That(ck.Args[6], Is.EqualTo(uint.MaxValue)); - Assert.That(ck.Args[7], Is.EqualTo(ulong.MaxValue)); - Assert.That(ck.Args[8], Is.EqualTo(Guid.Parse("8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc"))); - Assert.That(ck.Args[9], Is.EqualTo(new DateTime(1970, 01, 22, 0, 0, 0, DateTimeKind.Unspecified))); - Assert.That(ck.Args[10], Is.EqualTo(new DateTime(2000, 01, 22, 20, 59, 43, DateTimeKind.Utc))); - Assert.That(ck.Args[11], Is.EqualTo(new DateTimeOffset(2000, 01, 22, 20, 59, 43, TimeSpan.FromHours(-8)))); - }); - } - - [Test] - public void KeyToString_And_CreateFromString_Perf() - { - CompositeKey ck; - for (int i = 0; i < 1000; i++) - { - ck = new CompositeKey("text", 'x', short.MinValue, int.MinValue, long.MinValue, ushort.MaxValue, uint.MaxValue, ulong.MaxValue, Guid.Parse("8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc"), - new DateTime(1970, 01, 22, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2000, 01, 22, 20, 59, 43, DateTimeKind.Utc), new DateTimeOffset(2000, 01, 22, 20, 59, 43, TimeSpan.FromHours(-8))); - _ = CompositeKey.TryCreateFromString(ck.ToString(), [typeof(string), typeof(char), typeof(short), typeof(int), typeof(long), typeof(ushort), typeof(uint), typeof(ulong), typeof(Guid), typeof(DateTime), typeof(DateTime), typeof(DateTimeOffset)], out ck); - } - } - - [Test] - public void KeySerializeDeserialize() - { - var ck = new CompositeKey(); - Assert.That(ck.ToJsonString(), Is.EqualTo("null")); - ck = CompositeKey.CreateFromJson(ck.ToJsonString()); - Assert.That(ck, Is.EqualTo(new CompositeKey())); - - ck = new CompositeKey(88); - Assert.That(ck.ToJsonString(), Is.EqualTo("[{\"int\":88}]")); - ck = CompositeKey.CreateFromJson(ck.ToJsonString()); - Assert.That(ck, Is.EqualTo(new CompositeKey(88))); - - ck = new CompositeKey((int?)null); - Assert.That(ck.ToJsonString(), Is.EqualTo("[null]")); - ck = CompositeKey.CreateFromJson(ck.ToJsonString()); - Assert.That(ck, Is.EqualTo(new CompositeKey((int?)null))); - - ck = new CompositeKey((int?)88); - Assert.That(ck.ToJsonString(), Is.EqualTo("[{\"int\":88}]")); - ck = CompositeKey.CreateFromJson(ck.ToJsonString()); - Assert.That(ck, Is.EqualTo(new CompositeKey(88))); - - ck = new CompositeKey("text", 'x', short.MinValue, int.MinValue, long.MinValue, ushort.MaxValue, uint.MaxValue, long.MaxValue, Guid.Parse("8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc"), - new DateTime(1970, 01, 22, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2000, 01, 22, 20, 59, 43, DateTimeKind.Utc), new DateTimeOffset(2000, 01, 22, 20, 59, 43, TimeSpan.FromHours(-8))); - - Assert.That(ck.ToJsonString(), Is.EqualTo("[{\"string\":\"text\"},{\"char\":\"x\"},{\"short\":-32768},{\"int\":-2147483648},{\"long\":-9223372036854775808},{\"ushort\":65535},{\"uint\":4294967295},{\"long\":9223372036854775807},{\"guid\":\"8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc\"},{\"datetime\":\"1970-01-22T00:00:00\"},{\"datetime\":\"2000-01-22T20:59:43Z\"},{\"datetimeoffset\":\"2000-01-22T20:59:43-08:00\"}]")); - - var ck2 = ck; - ck = CompositeKey.CreateFromJson(ck.ToJsonString()); - Assert.That(ck, Is.EqualTo(ck2)); - } - - [Test] - public void KeyDeserializeErrors() - { - Assert.Throws(() => CompositeKey.CreateFromJson("{}")); - Assert.Throws(() => CompositeKey.CreateFromJson("[[]]")); - Assert.Throws(() => CompositeKey.CreateFromJson("[{\"xxx\":1}]")); - Assert.Throws(() => CompositeKey.CreateFromJson("[{\"int\":\"x\"}]")); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Entities/Extended/EntityBaseTest.cs b/tests/CoreEx.Test/Framework/Entities/Extended/EntityBaseTest.cs deleted file mode 100644 index 82489c98..00000000 --- a/tests/CoreEx.Test/Framework/Entities/Extended/EntityBaseTest.cs +++ /dev/null @@ -1,981 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Entities.Extended; -using NUnit.Framework; -using System; -using System.Collections.Generic; - -namespace CoreEx.Test.Framework.Entities.Extended -{ - [TestFixture] - public class EntityBaseTest - { - [Test] - public void ChangeLog_Clone() - { - var cl = new ChangeLogEx { CreatedBy = "username ", CreatedDate = CreateDateTime() }; - var co = cl.Clone(); - - Assert.That(co, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(co.CreatedBy, Is.EqualTo("username")); - Assert.That(co.CreatedDate, Is.EqualTo(CreateDateTime())); - Assert.That(co.UpdatedBy, Is.Null); - Assert.That(co.UpdatedDate, Is.Null); - }); - } - - [Test] - public void ChangeLog_CopyFrom() - { - var cl1 = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() }; - var cl2 = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime(), UpdatedBy = "username2", UpdatedDate = CreateDateTime().AddDays(1) }; - cl1.CopyFrom(cl2); - - Assert.Multiple(() => - { - Assert.That(cl1.CreatedBy, Is.EqualTo("username")); - Assert.That(cl1.CreatedDate, Is.EqualTo(CreateDateTime())); - Assert.That(cl1.UpdatedBy, Is.EqualTo("username2")); - Assert.That(cl1.UpdatedDate, Is.EqualTo(CreateDateTime().AddDays(1))); - }); - } - - [Test] - public void ChangeLog_Equals() - { - ChangeLogEx? cl2 = null; - - var cl1 = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() }; - Assert.That(cl1, Is.Not.EqualTo(cl2)); - - cl2 = (ChangeLogEx)cl1.Clone(); - Assert.That(cl1, Is.EqualTo(cl2)); - - cl2.CreatedBy = "username2"; - Assert.That(cl1, Is.Not.EqualTo(cl2)); - - ChangeLogEx cl3 = cl1; - Assert.That(cl3, Is.EqualTo(cl1)); - } - - [Test] - public void ChangeLog_Equals2() - { - ChangeLogEx? cl1 = null; - ChangeLogEx? cl2 = null; - - Assert.That(cl1, Is.EqualTo(cl2)); - - cl1 = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() }; - Assert.That(cl1, Is.Not.EqualTo(cl2)); - - cl2 = (ChangeLogEx)cl1.Clone(); - Assert.That(cl1, Is.EqualTo(cl2)); - - cl2.CreatedBy = "username2"; - Assert.That(cl1, Is.Not.EqualTo(cl2)); - - ChangeLogEx cl3 = cl1; - Assert.That(cl3, Is.EqualTo(cl1)); - } - - [Test] - public void ChangeLog_HashCode() - { - var cl1 = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() }; - var cl2 = (ChangeLogEx)cl1.Clone(); - Assert.That(cl2.GetHashCode(), Is.EqualTo(cl1.GetHashCode())); - - cl2.CreatedBy = "username2"; - Assert.That(cl2.GetHashCode(), Is.Not.EqualTo(cl1.GetHashCode())); - } - - [Test] - public void ChangeLog_IsInitial() - { - var cl = new ChangeLogEx(); - Assert.That(cl.IsInitial, Is.True); - - cl.UpdatedBy = "username"; - Assert.That(cl.IsInitial, Is.False); - - cl.UpdatedBy = null; - Assert.That(cl.IsInitial, Is.True); - - cl.UpdatedDate = CreateDateTime(); - Assert.That(cl.IsInitial, Is.False); - } - - [Test] - public void ChangleLog_AcceptChanges() - { - var cl = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() }; - Assert.That(cl.IsChanged, Is.True); - - cl.AcceptChanges(); - Assert.That(cl.IsChanged, Is.False); - - cl.UpdatedBy = "username"; - Assert.That(cl.IsChanged, Is.True); - } - - [Test] - public void ChangeLog_MakeReadonly() - { - var cl = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() }; - Assert.That(cl.IsReadOnly, Is.False); - - cl.MakeReadOnly(); - Assert.That(cl.IsReadOnly, Is.True); - - Assert.Throws(() => cl.UpdatedBy = "username"); - } - - [Test] - public void Person_Clone() - { - var p = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var po = (Person)p.Clone(); - - Assert.That(po, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(po.Name, Is.EqualTo("dave")); - Assert.That(po.Age, Is.EqualTo(30)); - Assert.That(po.ChangeLog!.CreatedBy, Is.EqualTo("username")); - Assert.That(po.ChangeLog.CreatedDate, Is.EqualTo(CreateDateTime())); - Assert.That(po.ChangeLog.UpdatedBy, Is.Null); - Assert.That(po.ChangeLog.UpdatedDate, Is.Null); - }); - } - - [Test] - public void Person_CopyFrom() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "sarah", Age = 29, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime(), UpdatedBy = "username2", UpdatedDate = CreateDateTime().AddDays(1) } }; - - p1.CopyFrom(p2); - - Assert.Multiple(() => - { - Assert.That(p1.Name, Is.EqualTo("sarah")); - Assert.That(p1.Age, Is.EqualTo(29)); - Assert.That(p1.ChangeLog.CreatedBy, Is.EqualTo("username")); - Assert.That(p1.ChangeLog.CreatedDate, Is.EqualTo(CreateDateTime())); - Assert.That(p1.ChangeLog.UpdatedBy, Is.EqualTo("username2")); - Assert.That(p1.ChangeLog.UpdatedDate, Is.EqualTo(CreateDateTime().AddDays(1))); - }); - } - - [Test] - public void Person_CreateFrom() - { - var p2 = new Person { Name = "sarah", Age = 29, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime(), UpdatedBy = "username2", UpdatedDate = CreateDateTime().AddDays(1) } }; - var p1 = p2.CopyFromAs(); - - Assert.Multiple(() => - { - Assert.That(p1.Name, Is.EqualTo("sarah")); - Assert.That(p1.Age, Is.EqualTo(29)); - Assert.That(p1.ChangeLog!.CreatedBy, Is.EqualTo("username")); - Assert.That(p1.ChangeLog.CreatedDate, Is.EqualTo(CreateDateTime())); - Assert.That(p1.ChangeLog.UpdatedBy, Is.EqualTo("username2")); - Assert.That(p1.ChangeLog.UpdatedDate, Is.EqualTo(CreateDateTime().AddDays(1))); - }); - } - - [Test] - public void Person_CopyFrom_Hierarchy() - { - var p1 = new Person { Name = "dave", Age = 30 }; - var p2 = new PersonEx { Name = "sarah", Age = 29, Salary = 100000 }; - - p1.CopyFrom(p2); - - Assert.Multiple(() => - { - Assert.That(p1.Name, Is.EqualTo("sarah")); - Assert.That(p1.Age, Is.EqualTo(29)); - }); - - p1.Name = "ivan"; - p1.Age = 55; - - p2.CopyFrom(p1); - Assert.Multiple(() => - { - Assert.That(p2.Name, Is.EqualTo("ivan")); - Assert.That(p2.Age, Is.EqualTo(55)); - Assert.That(p2.Salary, Is.EqualTo(100000)); - }); - } - - [Test] - public void Person_Equals() - { - Person? p2 = null; - - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - Assert.That(p1, Is.Not.EqualTo(p2)); - - p2 = (Person)p1.Clone(); - Assert.That(p1, Is.EqualTo(p2)); - - p2.ChangeLog!.CreatedBy = "username2"; - Assert.That(p1, Is.Not.EqualTo(p2)); - - p2.ChangeLog.CreatedBy = "username"; - Assert.That(p1, Is.EqualTo(p2)); - - p2.Name = "mike"; - Assert.That(p1, Is.Not.EqualTo(p2)); - - Person p3 = p1; - Assert.That(p3, Is.EqualTo(p1)); - } - - [Test] - public void Person_Equals2() - { - Person? p2 = null; - - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - Assert.That(p1, Is.Not.EqualTo(p2)); - - p2 = (Person)p1.Clone(); - Assert.That(p1, Is.EqualTo(p2)); - - p2.ChangeLog!.CreatedBy = "username2"; - Assert.That(p1, Is.Not.EqualTo(p2)); - - p2.ChangeLog.CreatedBy = "username"; - Assert.That(p1, Is.EqualTo(p2)); - - p2.Name = "mike"; - Assert.That(p1, Is.Not.EqualTo(p2)); - - Person p3 = p1; - Assert.That(p3, Is.EqualTo(p1)); - } - - [Test] - public void Person_HashCode() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = (Person)p1.Clone(); - Assert.That(p2.GetHashCode(), Is.EqualTo(p1.GetHashCode())); - - p1.Name = "mike"; - Assert.That(p2.GetHashCode(), Is.Not.EqualTo(p1.GetHashCode())); - - p1.Name = "dave"; - Assert.That(p2.GetHashCode(), Is.EqualTo(p1.GetHashCode())); - - p1.ChangeLog.CreatedBy = "username2"; - Assert.That(p2.GetHashCode(), Is.Not.EqualTo(p1.GetHashCode())); - } - - [Test] - public void Person_IsInitial() - { - var p = new Person(); - Assert.That(p.IsInitial, Is.True); - - p.Name = "mike"; - Assert.That(p.IsInitial, Is.False); - - p.Name = null; - Assert.That(p.IsInitial, Is.True); - - p.ChangeLog = new ChangeLogEx { UpdatedDate = CreateDateTime() }; - Assert.That(p.IsInitial, Is.False); - - p.ChangeLog.UpdatedDate = null; - Assert.That(p.IsInitial, Is.False); - - p.CleanUp(); - Assert.That(p.IsInitial, Is.True); - } - - [Test] - public void Person_AcceptChanges() - { - var p = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - Assert.Multiple(() => - { - Assert.That(p.IsChanged, Is.True); - Assert.That(p.ChangeLog.IsChanged, Is.True); - }); - - p.AcceptChanges(); - Assert.That(p.IsChanged, Is.False); - - p.Name = "julie"; - Assert.Multiple(() => - { - Assert.That(p.IsChanged, Is.True); - Assert.That(p.ChangeLog.IsChanged, Is.False); - }); - - p.AcceptChanges(); - Assert.Multiple(() => - { - Assert.That(p.IsChanged, Is.False); - Assert.That(p.ChangeLog.IsChanged, Is.False); - }); - - p.ChangeLog.CreatedBy = "username2"; - Assert.Multiple(() => - { - Assert.That(p.ChangeLog.IsChanged, Is.True); - Assert.That(p.IsChanged, Is.True); - }); - } - - [Test] - public void Person_Bubbling() - { - var p = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - p.AcceptChanges(); - var cl1 = p.ChangeLog; - var cl2 = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() }; - Assert.Multiple(() => - { - Assert.That(p.IsChanged, Is.False); - Assert.That(p.ChangeLog.IsChanged, Is.False); - Assert.That(cl1.IsChanged, Is.False); - Assert.That(cl2.IsChanged, Is.True); - }); - - p.ChangeLog = cl2; - Assert.Multiple(() => - { - Assert.That(p.IsChanged, Is.True); - Assert.That(p.ChangeLog.IsChanged, Is.True); - Assert.That(cl1.IsChanged, Is.False); - Assert.That(cl2.IsChanged, Is.True); - }); - - p.AcceptChanges(); - Assert.Multiple(() => - { - Assert.That(p.IsChanged, Is.False); - Assert.That(p.ChangeLog.IsChanged, Is.False); - Assert.That(cl1.IsChanged, Is.False); - Assert.That(cl2.IsChanged, Is.False); - }); - - cl1.UpdatedBy = "username"; - Assert.Multiple(() => - { - Assert.That(p.IsChanged, Is.False); - Assert.That(p.ChangeLog.IsChanged, Is.False); - Assert.That(cl1.IsChanged, Is.True); - Assert.That(cl2.IsChanged, Is.False); - }); - - cl2.UpdatedBy = "username"; - Assert.Multiple(() => - { - Assert.That(p.IsChanged, Is.True); - Assert.That(p.ChangeLog.IsChanged, Is.True); - Assert.That(cl1.IsChanged, Is.True); - Assert.That(cl2.IsChanged, Is.True); - }); - } - - [Test] - public void Person_MakeReadonly() - { - var cl = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() }; - Assert.That(cl.IsReadOnly, Is.False); - - cl.MakeReadOnly(); - Assert.That(cl.IsReadOnly, Is.True); - - Assert.Throws(() => cl.UpdatedBy = "username"); - } - - [Test] - public void Person_Load() - { - for (int i = 0; i < 1000; i++) - { - var p1 = new Person { Name = "dave", Age = i, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "dave", Age = i, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - - p1.CleanUp(); - p1.Clone(); - p1.AcceptChanges(); - - if (p1 == p2 && p1.GetHashCode() == p2.GetHashCode()) - { - if (!p1.IsReadOnly) - p1.MakeReadOnly(); - } - else - throw new InvalidOperationException("Should not get here!"); - } - } - - [Test] - public void PersonEx_Clone() - { - var p = new PersonEx { Name = "dave", Age = 30, Salary = 1m, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var po = (PersonEx)p.Clone(); - - Assert.That(po, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(po.Name, Is.EqualTo("dave")); - Assert.That(po.Age, Is.EqualTo(30)); - Assert.That(po.Salary, Is.EqualTo(1m)); - Assert.That(po.ChangeLog!.CreatedBy, Is.EqualTo("username")); - Assert.That(po.ChangeLog.CreatedDate, Is.EqualTo(CreateDateTime())); - Assert.That(po.ChangeLog.UpdatedBy, Is.Null); - Assert.That(po.ChangeLog.UpdatedDate, Is.Null); - }); - } - - [Test] - public void PersonEx_CopyFrom() - { - var p1 = new PersonEx { Name = "dave", Age = 30, Salary = 1m, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new PersonEx { Name = "sarah", Age = 29, Salary = 2m, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime(), UpdatedBy = "username2", UpdatedDate = CreateDateTime().AddDays(1) } }; - - p1.CopyFrom(p2); - - Assert.Multiple(() => - { - Assert.That(p1.Name, Is.EqualTo("sarah")); - Assert.That(p1.Age, Is.EqualTo(29)); - Assert.That(p1.Salary, Is.EqualTo(2m)); - Assert.That(p1.ChangeLog.CreatedBy, Is.EqualTo("username")); - Assert.That(p1.ChangeLog.CreatedDate, Is.EqualTo(CreateDateTime())); - Assert.That(p1.ChangeLog.UpdatedBy, Is.EqualTo("username2")); - Assert.That(p1.ChangeLog.UpdatedDate, Is.EqualTo(CreateDateTime().AddDays(1))); - }); - } - - [Test] - public void PersonEx_Equals() - { - PersonEx? p2 = null; - - var p1 = new PersonEx { Name = "dave", Age = 30, Salary = 1m, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - Assert.That(p1, Is.Not.EqualTo(p2)); - - p2 = (PersonEx)p1.Clone(); - Assert.That(p1, Is.EqualTo(p2)); - - p2.ChangeLog!.CreatedBy = "username2"; - Assert.That(p1, Is.Not.EqualTo(p2)); - - p2.ChangeLog.CreatedBy = "username"; - Assert.That(p1, Is.EqualTo(p2)); - - p2.Name = "mike"; - Assert.That(p1, Is.Not.EqualTo(p2)); - - p2.Name = "dave"; - Assert.That(p1, Is.EqualTo(p2)); - - p2.Salary = 2m; - Assert.That(p1, Is.Not.EqualTo(p2)); - - Person p3 = p1; - Assert.That(p3, Is.EqualTo(p1)); - } - - [Test] - public void PersonEx_Equals2() - { - PersonEx? p2 = null; - - var p1 = new PersonEx { Name = "dave", Age = 30, Salary = 1m, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - Assert.That(p1, Is.Not.EqualTo(p2)); - - p2 = (PersonEx)p1.Clone(); - Assert.That(p1, Is.EqualTo(p2)); - - p2.ChangeLog!.CreatedBy = "username2"; - Assert.That(p1, Is.Not.EqualTo(p2)); - - p2.ChangeLog.CreatedBy = "username"; - Assert.That(p1, Is.EqualTo(p2)); - - p2.Name = "mike"; - Assert.That(p1, Is.Not.EqualTo(p2)); - - p2.Name = "dave"; - Assert.That(p1, Is.EqualTo(p2)); - - p2.Salary = 2m; - Assert.That(p1, Is.Not.EqualTo(p2)); - - Person p3 = p1; - Assert.That(p3, Is.EqualTo(p1)); - } - - [Test] - public void PersonEx_HashCode() - { - var p1 = new PersonEx { Name = "dave", Age = 30, Salary = 1m, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = (PersonEx)p1.Clone(); - Assert.That(p2.GetHashCode(), Is.EqualTo(p1.GetHashCode())); - - p1.Name = "mike"; - Assert.That(p2.GetHashCode(), Is.Not.EqualTo(p1.GetHashCode())); - - p1.Name = "dave"; - Assert.That(p2.GetHashCode(), Is.EqualTo(p1.GetHashCode())); - - p1.Salary = 2m; - Assert.That(p2.GetHashCode(), Is.Not.EqualTo(p1.GetHashCode())); - - p1.Salary = 1m; - Assert.That(p2.GetHashCode(), Is.EqualTo(p1.GetHashCode())); - - p1.ChangeLog.CreatedBy = "username2"; - Assert.That(p2.GetHashCode(), Is.Not.EqualTo(p1.GetHashCode())); - } - - [Test] - public void PersonEx_IsInitial() - { - var p = new PersonEx(); - Assert.That(p.IsInitial, Is.True); - - p.Salary = 0m; - Assert.That(p.IsInitial, Is.False); - - p.Salary = null; - Assert.That(p.IsInitial, Is.True); - - p.Name = "mike"; - Assert.That(p.IsInitial, Is.False); - - p.Name = null; - Assert.That(p.IsInitial, Is.True); - - p.ChangeLog = new ChangeLogEx { UpdatedDate = CreateDateTime() }; - Assert.That(p.IsInitial, Is.False); - - p.ChangeLog.UpdatedDate = null; - Assert.That(p.IsInitial, Is.False); - - p.CleanUp(); - Assert.That(p.IsInitial, Is.True); - } - - [Test] - public void Collection_Person_Clone() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pc = new PersonCollection { p1, p2 }; - - var pc2 = (PersonCollection)pc.Clone(); - Assert.That(pc2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(pc2, Has.Count.EqualTo(2)); - Assert.That(ReferenceEquals(pc2[0], p1), Is.False); - Assert.That(ReferenceEquals(pc2[1], p2), Is.False); - Assert.That(p1, Is.EqualTo(pc2[0])); - Assert.That(p2, Is.EqualTo(pc2[1])); - }); - } - - [Test] - public void Collection_Person_Equals() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pc = new PersonCollection { p1, p2 }; - - Assert.That(pc, Is.Not.EqualTo(null)); - - var pc2 = (PersonCollection)pc.Clone(); - Assert.That(pc, Is.EqualTo(pc2)); - pc2.Add(new Person { Name = "john", Age = 35 }); - Assert.That(pc, Is.Not.EqualTo(pc2)); - - pc2 = (PersonCollection)pc.Clone(); - Assert.That(pc, Is.EqualTo(pc2)); - pc2[1].Name = "jenny"; - Assert.That(pc, Is.Not.EqualTo(pc2)); - } - - [Test] - public void Collection_Person_Equals2() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pc = new PersonCollection { p1, p2 }; - var pc2 = pc; - - Assert.That(pc, Is.Not.EqualTo(null)); - Assert.That(pc, Is.EqualTo(pc2)); - - pc2 = (PersonCollection)pc!.Clone(); - Assert.That(pc, Is.EqualTo(pc2)); - pc2.Add(new Person { Name = "john", Age = 35 }); - Assert.That(pc, Is.Not.EqualTo(pc2)); - - pc2 = (PersonCollection)pc.Clone(); - Assert.That(pc, Is.EqualTo(pc2)); - pc2[1].Name = "jenny"; - Assert.That(pc, Is.Not.EqualTo(pc2)); - } - - [Test] - public void Collection_Person_HashCode() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pc = new PersonCollection { p1, p2 }; - var pc2 = (PersonCollection)pc.Clone(); - Assert.That(pc2.GetHashCode(), Is.EqualTo(pc.GetHashCode())); - - pc2[0].ChangeLog!.CreatedBy = "username2"; - Assert.That(pc2.GetHashCode(), Is.Not.EqualTo(pc.GetHashCode())); - } - - [Test] - public void Collection_Person_AcceptChanges() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pc = new PersonCollection { p1 }; - Assert.Multiple(() => - { - Assert.That(pc.IsChanged, Is.True); - Assert.That(pc[0].IsChanged, Is.True); - Assert.That(pc[0].ChangeLog!.IsChanged, Is.True); - }); - - pc.AcceptChanges(); - Assert.Multiple(() => - { - Assert.That(pc.IsChanged, Is.False); - Assert.That(pc[0].IsChanged, Is.False); - Assert.That(pc[0].ChangeLog!.IsChanged, Is.False); - }); - - pc.Add(p2); - Assert.Multiple(() => - { - Assert.That(pc.IsChanged, Is.True); - Assert.That(pc[0].IsChanged, Is.False); - Assert.That(pc[0].ChangeLog!.IsChanged, Is.False); - Assert.That(pc[1].IsChanged, Is.True); - }); - - pc.AcceptChanges(); - Assert.Multiple(() => - { - Assert.That(pc.IsChanged, Is.False); - Assert.That(pc[0].IsChanged, Is.False); - Assert.That(pc[0].ChangeLog!.IsChanged, Is.False); - Assert.That(pc[1].IsChanged, Is.False); - }); - - pc[0].ChangeLog!.CreatedBy = "username2"; - Assert.Multiple(() => - { - Assert.That(pc.IsChanged, Is.True); - Assert.That(pc[0].IsChanged, Is.True); - Assert.That(pc[0].ChangeLog!.IsChanged, Is.True); - Assert.That(pc[1].IsChanged, Is.False); - }); - } - - [Test] - public void Collect_Person_Clear() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pc = new PersonCollection { p1, p2 }; - Assert.That(pc.IsChanged, Is.True); - - pc.AcceptChanges(); - Assert.That(pc.IsChanged, Is.False); - - pc.Clear(); - Assert.That(pc.IsChanged, Is.True); - } - - [Test] - public void Collection_Person_MakeReadOnly() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pc = new PersonCollection { p1 }; - pc.MakeReadOnly(); - Assert.Multiple(() => - { - Assert.That(pc.IsReadOnly, Is.True); - Assert.That(pc[0].IsReadOnly, Is.True); - Assert.That(pc[0].ChangeLog!.IsReadOnly, Is.True); - }); - Assert.Throws(() => pc.Add(p2)); - Assert.That(pc, Has.Count.EqualTo(1)); - - Assert.Throws(() => pc.Clear()); - Assert.That(pc, Has.Count.EqualTo(1)); - - Assert.Throws(() => pc.Remove(p1)); - Assert.That(pc, Has.Count.EqualTo(1)); - - Assert.Throws(() => pc.RemoveAt(0)); - Assert.That(pc, Has.Count.EqualTo(1)); - - Assert.Throws(() => pc[0] = p2); - Assert.That(pc, Has.Count.EqualTo(1)); - } - - [Test] - public void Collection_Person_GetByPrimaryKey() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pc = new PersonCollection { p1, p2 }; - - var pi = pc.GetByKey(); - Assert.That(pi, Is.Null); - - pi = pc.GetByKey("dave"); - Assert.That(p1, Is.EqualTo(pi)); - - pi = pc.GetByKey("bazza"); - Assert.That(pi, Is.Null); - - pi = pc.GetByKey(p1.PrimaryKey); - Assert.That(p1, Is.EqualTo(pi)); - - pi = pc.GetByKey(new CompositeKey("bazza")); - Assert.That(pi, Is.Null); - } - - [Test] - public void Collection_Person_DeleteByPrimaryKey() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var p3 = new Person { Name = "dave", Age = 40 }; - var pc = new PersonCollection { p1, p2, p3 }; - - pc.RemoveByKey("rebecca"); - Assert.That(pc, Has.Count.EqualTo(3)); - Assert.That(pc[0].Name, Is.EqualTo("dave")); - - pc.RemoveByKey("dave"); - Assert.That(pc, Has.Count.EqualTo(1)); - Assert.That(pc[0].Name, Is.EqualTo("mary")); - - pc.RemoveByKey("mary"); - Assert.That(pc, Is.Empty); - } - - [Test] - public void Collection_Person_ItemsAreAllUnique() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var p3 = new Person { Name = "dave", Age = 40 }; - var pc = new PersonCollection { p1, p2, p3 }; - - Assert.That(pc.IsAnyDuplicates(), Is.True); - - pc.Remove(p3); - Assert.That(pc.IsAnyDuplicates(), Is.False); - - pc = [null!, null!]; - Assert.That(pc.IsAnyDuplicates(), Is.True); - - pc.RemoveAt(0); - Assert.That(pc.IsAnyDuplicates(), Is.False); - } - - [Test] - public void CollectionResult_Person() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pc = new PersonCollection { p1, p2 }; - var pcr = new PersonCollectionResult(pc); - Assert.That(pc, Is.SameAs(pcr.Items)); - - var pc2 = (PersonCollection)pcr; - Assert.That(pc2, Is.SameAs(pc)); - - var pcr2 = (PersonCollectionResult)pcr.Clone(); - Assert.Multiple(() => - { - Assert.That(ReferenceEquals(pcr2, pcr), Is.False); - Assert.That(pcr2, Is.EqualTo(pcr)); - }); - Assert.That(pcr2, Is.EqualTo(pcr)); - } - - [Test] - public void Dictionary_Person_IsChanged() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pd = new PersonDictionary { { "dave", p1 }, { "mary", p2 } }; - Assert.That(pd.IsChanged, Is.True); - - pd.AcceptChanges(); - Assert.Multiple(() => - { - Assert.That(pd.IsChanged, Is.False); - Assert.That(p1.IsChanged, Is.False); - Assert.That(p2.IsChanged, Is.False); - }); - - p1.ChangeLog.CreatedDate = p1.ChangeLog.CreatedDate.Value.AddMinutes(1); - Assert.Multiple(() => - { - Assert.That(pd.IsChanged, Is.True); - Assert.That(p1.IsChanged, Is.True); - Assert.That(p2.IsChanged, Is.False); - }); - - pd.AcceptChanges(); - Assert.Multiple(() => - { - Assert.That(pd.IsChanged, Is.False); - Assert.That(p1.IsChanged, Is.False); - Assert.That(p2.IsChanged, Is.False); - }); - - pd.Remove("mary"); - Assert.That(pd.IsChanged, Is.True); - - pd.AcceptChanges(); - Assert.That(pd.IsChanged, Is.False); - - pd.Remove("mary"); - Assert.That(pd.IsChanged, Is.False); - } - - [Test] - public void Dictionary_Person_ReadOnly() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pd = new PersonDictionary { { "dave", p1 }, { "mary", p2 } }; - pd.MakeReadOnly(); - Assert.That(pd.IsReadOnly, Is.True); - - Assert.Throws(() => pd.Clear()); - Assert.Throws(() => pd.Remove("mary")); - Assert.Throws(() => pd.Add("donna", new Person { Name = "Donna" })); - Assert.Throws(() => pd["donna"] = new Person { Name = "Donna" }); - } - - [Test] - public void Dictionary_Person_Equals() - { - var p1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var p2 = new Person { Name = "mary", Age = 25 }; - var pd = new PersonDictionary { { "dave", p1 }, { "mary", p2 } }; - - var px1 = new Person { Name = "dave", Age = 30, ChangeLog = new ChangeLogEx { CreatedBy = "username", CreatedDate = CreateDateTime() } }; - var px2 = new Person { Name = "mary", Age = 25 }; - var pxd = new PersonDictionary { { "dave", px1 }, { "mary", px2 } }; - - Assert.Multiple(() => - { - Assert.That(pd, Is.EqualTo(pxd)); - Assert.That(pxd.GetHashCode(), Is.EqualTo(pd.GetHashCode())); - }); - - px2.Name += "X"; - Assert.Multiple(() => - { - Assert.That(pd, Is.Not.EqualTo(pxd)); - Assert.That(pxd.GetHashCode(), Is.Not.EqualTo(pd.GetHashCode())); - }); - } - - [Test] - public void Dictionary_Person_Add_Infer_Key() - { - var pd = new PersonDictionary(); - var p2 = new Person { Name = "mary", Age = 25 }; - pd.AddRange([p2]); - - Assert.That(pd, Has.Count.EqualTo(1)); - Assert.That(pd.ContainsKey("mary"), Is.True); - } - - private static DateTime CreateDateTime() => new(2000, 01, 01, 12, 45, 59); - - public class Person : EntityBase, CoreEx.Entities.IPrimaryKey - { - private string? _name; - private int _age; - private ChangeLogEx? _changeLog; - - public string? Name { get => _name; set => SetValue(ref _name, value); } - public int Age { get => _age; set => SetValue(ref _age, value); } - public ChangeLogEx? ChangeLog { get => _changeLog; set => SetValue(ref _changeLog, value); } - - public CoreEx.Entities.CompositeKey PrimaryKey => new(Name); - - protected override IEnumerable GetPropertyValues() - { - yield return CreateProperty(nameof(Name), Name, v => Name = v); - yield return CreateProperty(nameof(Age), Age, v => Age = v); - yield return CreateProperty(nameof(ChangeLog), ChangeLog, v => ChangeLog = v); - } - } - - public class PersonCollection : EntityKeyBaseCollection - { - public PersonCollection() { } - - public PersonCollection(IEnumerable entities) : base(entities) { } - - public static implicit operator PersonCollection(PersonCollectionResult result) => result?.Items!; - } - - public class PersonCollectionResult : EntityCollectionResult - { - public PersonCollectionResult() { } - - public PersonCollectionResult(PagingArgs paging) : base(paging) { } - - public PersonCollectionResult(PersonCollection collection, PagingArgs? paging = null) : base(paging) => Items = collection; - } - - public class PersonEx : Person - { - private decimal? _salary; - - public decimal? Salary { get => _salary; set => SetValue(ref _salary, value); } - - protected override IEnumerable GetPropertyValues() - { - foreach (var pv in base.GetPropertyValues()) - yield return pv; - - yield return CreateProperty(nameof(Salary), Salary, v => Salary = v); - } - } - - public class PersonExCollection : EntityBaseCollection - { - public PersonExCollection() { } - - public PersonExCollection(IEnumerable entities) : base(entities) { } - } - - public class PersonDictionary : EntityBaseDictionary { } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Entities/Extended/EntityCoreTest.cs b/tests/CoreEx.Test/Framework/Entities/Extended/EntityCoreTest.cs deleted file mode 100644 index 89dfdb29..00000000 --- a/tests/CoreEx.Test/Framework/Entities/Extended/EntityCoreTest.cs +++ /dev/null @@ -1,89 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Entities.Extended; -using NUnit.Framework; -using System; - -namespace CoreEx.Test.Framework.Entities.Extended -{ - [TestFixture] - public class EntityCoreTest - { - [Test] - public void SettingAndGetting() - { - var ta = new TestA { Id = 88, Code = " A ", Text = " B ", DateOnly = new DateTime(2000, 01, 01, 12, 59, 59), DateTime = new DateTime(2000, 01, 01, 12, 59, 59), Description = "the AB code." }; - Assert.Multiple(() => - { - Assert.That(ta.Id, Is.EqualTo(88)); - Assert.That(ta.Code, Is.EqualTo("a")); - Assert.That(ta.Text, Is.EqualTo(" B")); - Assert.That(ta.DateOnly, Is.EqualTo(new DateTime(2000, 01, 01))); - Assert.That(ta.DateTime, Is.EqualTo(new DateTime(2000, 01, 01, 12, 59, 59, DateTimeKind.Utc))); - Assert.That(ta.IsChanged, Is.True); - Assert.That(ta.Description, Is.EqualTo("The AB Code.")); - }); - - ta.AcceptChanges(); - Assert.That(ta.IsChanged, Is.False); - - ta.Code = null; - ta.Text = null; - ta.DateTime = null; - ta.Description = null; - Assert.That(ta.Code, Is.Empty); - Assert.Multiple(() => - { - Assert.That(ta.Text, Is.Null); - Assert.That(ta.DateTime, Is.Null); - Assert.That(ta.IsChanged, Is.True); - Assert.That(ta.Description, Is.Null); - }); - } - - private class TestA : EntityCore - { - private long _id; - private string? _code; - private string? _text; - private DateTime _dateOnly; - private DateTime? _dateTime; - private string? _desc; - - public long Id - { - get { return _id; } - set { SetValue(ref _id, value); } - } - - public string? Code - { - get { return _code; } - set { SetValue(ref _code, value, StringTrim.Both, StringTransform.NullToEmpty, StringCase.Lower); } - } - - public string? Text - { - get { return _text; } - set { SetValue(ref _text, value); } - } - - public DateTime DateOnly - { - get { return _dateOnly; } - set { SetValue(ref _dateOnly, value, DateTimeTransform.DateOnly); } - } - - public DateTime? DateTime - { - get { return _dateTime; } - set { SetValue(ref _dateTime, value); } - } - - public string? Description - { - get { return _desc; } - set { SetValue(ref _desc, value, casing: StringCase.Title); } - } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Entities/Extended/ObservableDictionaryTest.cs b/tests/CoreEx.Test/Framework/Entities/Extended/ObservableDictionaryTest.cs deleted file mode 100644 index c6cb8b18..00000000 --- a/tests/CoreEx.Test/Framework/Entities/Extended/ObservableDictionaryTest.cs +++ /dev/null @@ -1,230 +0,0 @@ -using CoreEx.Entities.Extended; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Collections.Specialized; - -namespace CoreEx.Test.Framework.Entities.Extended -{ - [TestFixture] - public class ObservableDictionaryTest - { - private static readonly Func _keyModifier = k => k.ToUpperInvariant(); - - [Test] - public void ObserveAdd() - { - var od = new ObservableDictionary { KeyModifier = _keyModifier }; - var i = 0; - var j = 0; - od.CollectionChanged += (_, e) => - { - Assert.Multiple(() => - { - Assert.That(e.Action, Is.EqualTo(NotifyCollectionChangedAction.Add)); - Assert.That(e.NewItems!, Has.Count.EqualTo(1)); - }); - Assert.Multiple(() => - { - Assert.That(e.NewItems![0], Is.EqualTo(new KeyValuePair("A", 88))); - Assert.That(e.OldItems, Is.Null); - }); - i++; - }; - od.PropertyChanged += (_, e) => - { - Assert.That(e.PropertyName, Is.EqualTo("Count")); - j++; - }; - - od.Add("a", 88); - Assert.Multiple(() => - { - Assert.That(i, Is.EqualTo(1)); - Assert.That(od["a"], Is.EqualTo(88)); - Assert.That(od.TryGetValue("a", out var v), Is.True); - Assert.That(v, Is.EqualTo(88)); - Assert.That(j, Is.EqualTo(1)); - }); - } - - [Test] - public void ObserveIndexerAdd() - { - var od = new ObservableDictionary() { KeyModifier = _keyModifier }; - var i = 0; - var j = 0; - od.CollectionChanged += (_, e) => - { - Assert.Multiple(() => - { - Assert.That(e.Action, Is.EqualTo(NotifyCollectionChangedAction.Add)); - Assert.That(e.NewItems!, Has.Count.EqualTo(1)); - }); - Assert.Multiple(() => - { - Assert.That(e.NewItems![0], Is.EqualTo(new KeyValuePair("A", 88))); - Assert.That(e.OldItems, Is.Null); - }); - i++; - }; - od.PropertyChanged += (_, e) => - { - Assert.That(e.PropertyName, Is.EqualTo("Count")); - j++; - }; - - od["a"] = 88; - Assert.Multiple(() => - { - Assert.That(i, Is.EqualTo(1)); - Assert.That(od["a"], Is.EqualTo(88)); - Assert.That(od.TryGetValue("a", out var v), Is.True); - Assert.That(v, Is.EqualTo(88)); - Assert.That(j, Is.EqualTo(1)); - }); - } - - [Test] - public void ObserveIndexerReplace() - { - var od = new ObservableDictionary { KeyModifier = _keyModifier }; - od.Add("a", 99); - - var i = 0; - var j = 0; - od.CollectionChanged += (_, e) => - { - Assert.Multiple(() => - { - Assert.That(e.Action, Is.EqualTo(NotifyCollectionChangedAction.Replace)); - Assert.That(e.OldItems!, Has.Count.EqualTo(1)); - }); - Assert.Multiple(() => - { - Assert.That(e.OldItems![0], Is.EqualTo(new KeyValuePair("A", 99))); - Assert.That(e.NewItems!, Has.Count.EqualTo(1)); - }); - Assert.That(e.NewItems![0], Is.EqualTo(new KeyValuePair("A", 88))); - i++; - }; - od.PropertyChanged += (_, e) => - { - Assert.That(e.PropertyName, Is.EqualTo("Count")); - j++; - }; - - od["a"] = 88; - Assert.Multiple(() => - { - Assert.That(i, Is.EqualTo(1)); - Assert.That(od["a"], Is.EqualTo(88)); - Assert.That(od.TryGetValue("a", out var v), Is.True); - Assert.That(v, Is.EqualTo(88)); - Assert.That(j, Is.EqualTo(1)); - }); - } - - [Test] - public void ObserveRemove() - { - var od = new ObservableDictionary { KeyModifier = _keyModifier }; - od.Add("a", 99); - - var i = 0; - var j = 0; - od.CollectionChanged += (_, e) => - { - Assert.Multiple(() => - { - Assert.That(e.Action, Is.EqualTo(NotifyCollectionChangedAction.Remove)); - Assert.That(e.OldItems!, Has.Count.EqualTo(1)); - }); - Assert.Multiple(() => - { - Assert.That(e.OldItems![0], Is.EqualTo(new KeyValuePair("A", 99))); - Assert.That(e.NewItems, Is.Null); - }); - i++; - }; - od.PropertyChanged += (_, e) => - { - Assert.That(e.PropertyName, Is.EqualTo("Count")); - j++; - }; - - od.Remove("a"); - Assert.That(i, Is.EqualTo(1)); - Assert.Throws(() => { var x = od["a"]; }); - Assert.Multiple(() => - { - Assert.That(od.TryGetValue("a", out var v), Is.False); - Assert.That(v, Is.EqualTo(0)); - Assert.That(j, Is.EqualTo(1)); - }); - } - - [Test] - public void ObserveClear() - { - var od = new ObservableDictionary { KeyModifier = _keyModifier }; - od.Add("a", 99); - od.Add("b", 98); - - var i = 0; - var j = 0; - od.CollectionChanged += (_, e) => - { - if (i == 0) - { - Assert.Multiple(() => - { - Assert.That(e.Action, Is.EqualTo(NotifyCollectionChangedAction.Remove)); - Assert.That(e.OldItems!, Has.Count.EqualTo(2)); - }); - Assert.Multiple(() => - { - Assert.That(e.OldItems![0], Is.EqualTo(new KeyValuePair("A", 99))); - Assert.That(e.OldItems![1], Is.EqualTo(new KeyValuePair("B", 98))); - Assert.That(e.NewItems, Is.Null); - }); - } - else - { - Assert.Multiple(() => - { - Assert.That(e.Action, Is.EqualTo(NotifyCollectionChangedAction.Reset)); - Assert.That(e.OldItems, Is.Null); - Assert.That(e.NewItems, Is.Null); - }); - } - - i++; - }; - od.PropertyChanged += (_, e) => - { - Assert.That(e.PropertyName, Is.EqualTo("Count")); - j++; - }; - - od.Clear(); - Assert.That(i, Is.EqualTo(2)); - Assert.Throws(() => { var x = od["a"]; }); - Assert.Multiple(() => - { - Assert.That(od.TryGetValue("a", out var v), Is.False); - Assert.That(v, Is.EqualTo(0)); - Assert.That(j, Is.EqualTo(1)); - }); - - // A subsequent clear should not raise any events. - i = j = 0; - od.Clear(); - Assert.Multiple(() => - { - Assert.That(i, Is.EqualTo(0)); - Assert.That(j, Is.EqualTo(0)); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Entities/IdentifierTest.cs b/tests/CoreEx.Test/Framework/Entities/IdentifierTest.cs deleted file mode 100644 index d2e647d8..00000000 --- a/tests/CoreEx.Test/Framework/Entities/IdentifierTest.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CoreEx.Entities; -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Entities -{ - [TestFixture] - public class IdentifierTest - { - [Test] - public void VerifyBaseIdProperty() - { - var p = new Person { Id = 88 }; - Assert.That(p.Id, Is.EqualTo(88)); - - var iii = (IIdentifier)p; - Assert.That(iii.Id, Is.EqualTo(88)); - - var ii = (IIdentifier)p; - Assert.That(ii.Id, Is.EqualTo(88)); - - ii.Id = 99; - Assert.Multiple(() => - { - Assert.That(ii.Id, Is.EqualTo(99)); - Assert.That(iii.Id, Is.EqualTo(99)); - Assert.That(p.Id, Is.EqualTo(99)); - }); - } - - private class Person : IIdentifier - { - public int Id { get; set; } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Entities/PagingArgsTest.cs b/tests/CoreEx.Test/Framework/Entities/PagingArgsTest.cs deleted file mode 100644 index 8688eb70..00000000 --- a/tests/CoreEx.Test/Framework/Entities/PagingArgsTest.cs +++ /dev/null @@ -1,49 +0,0 @@ -using CoreEx.Entities; -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Entities -{ - [TestFixture] - public class PagingArgsTest - { - [Test] - public void CreateSkipAndTake() - { - var pa = PagingArgs.CreateSkipAndTake(10, 20); - Assert.Multiple(() => - { - Assert.That(pa.Skip, Is.EqualTo(10)); - Assert.That(pa.Take, Is.EqualTo(20)); - Assert.That(pa.Option, Is.EqualTo(PagingOption.SkipAndTake)); - }); - } - - [Test] - public void CreatePageAndSize() - { - var pa = PagingArgs.CreatePageAndSize(10, 20); - Assert.Multiple(() => - { - Assert.That(pa.Page, Is.EqualTo(10)); - Assert.That(pa.Size, Is.EqualTo(20)); - Assert.That(pa.Option, Is.EqualTo(PagingOption.PageAndSize)); - }); - } - - [Test] - public void CreateTokenAndTake() - { - PagingArgs.IsTokenSupported = true; - var pa = PagingArgs.CreateTokenAndTake("blah-blah", 20); - Assert.Multiple(() => - { - Assert.That(pa.Token, Is.EqualTo("blah-blah")); - Assert.That(pa.Take, Is.EqualTo(20)); - Assert.That(pa.Option, Is.EqualTo(PagingOption.TokenAndTake)); - }); - } - - [TearDown] - public void TearDown() => PagingArgs.IsTokenSupported = false; - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs b/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs deleted file mode 100644 index 4cefd38e..00000000 --- a/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs +++ /dev/null @@ -1,177 +0,0 @@ -using CoreEx.Events; -using CoreEx.Events.Attachments; -using CoreEx.TestFunction.Models; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using UnitTestEx; - -namespace CoreEx.Test.Framework.Events -{ - [TestFixture] - public class CloudEventSerializerTest - { - [Test] - public async Task SystemTextJson_Serialize_Deserialize1() - { - var es = new CoreEx.Text.Json.CloudEventSerializer() as IEventSerializer; - var ed = CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - ed2 = (EventData)await es.DeserializeAsync(bd, typeof(Product)).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize2() - { - var ef = new EventDataFormatter { SourceDefault = _ => new Uri("null", UriKind.RelativeOrAbsolute) }; - var es = new CoreEx.Text.Json.CloudEventSerializer(ef) as IEventSerializer; - var ed = CreateProductEvent2(); - ef.Format(ed); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent2)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2, "Type", "Source"); - Assert.Multiple(() => - { - Assert.That(ed2.Source, Is.EqualTo(new Uri("null", UriKind.Relative))); - Assert.That(ed2.Type, Is.EqualTo("coreex.testfunction.models.product")); - }); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize1_WithAttachment() - { - var es = new CoreEx.Text.Json.CloudEventSerializer() { AttachmentStorage = CreateEventStorage("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}", 10) } as IEventSerializer; - var ed = CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1Attachement)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - ed2 = (EventData)await es.DeserializeAsync(bd, typeof(Product)).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - var ed3 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed3, "Value"); - Assert.That(ed3.Value?.ToString(), Is.EqualTo("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}")); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize1_WithNoAttachment() - { - var es = new CoreEx.Text.Json.CloudEventSerializer() { AttachmentStorage = CreateEventStorage("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}", 1000) } as IEventSerializer; - var ed = CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - ed2 = (EventData)await es.DeserializeAsync(bd, typeof(Product)).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - var ed3 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed3, "Value"); - Assert.That(ed3.Value?.ToString(), Is.EqualTo("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}")); - } - - [Test] - public async Task NewtonsoftJson_Serialize_Deserialize1() - { - var es = new CoreEx.Newtonsoft.Json.CloudEventSerializer() as IEventSerializer; - var ed = CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - } - - [Test] - public async Task NewtonsoftJson_Serialize_Deserialize2() - { - var ef = new EventDataFormatter { SourceDefault = _ => new Uri("null", UriKind.RelativeOrAbsolute) }; - var es = new CoreEx.Newtonsoft.Json.CloudEventSerializer(ef) as IEventSerializer; - var ed = CreateProductEvent2(); - ef.Format(ed); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent2)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2, "Type", "Source"); - Assert.Multiple(() => - { - Assert.That(ed2.Source, Is.EqualTo(new Uri("null", UriKind.Relative))); - Assert.That(ed2.Type, Is.EqualTo("coreex.testfunction.models.product")); - }); - } - - internal static EventData CreateProductEvent1() => new() - { - Id = "id", - Type = "product.created", - Source = new Uri("product/a", UriKind.Relative), - Subject = "product", - Action = "created", - CorrelationId = "cid", - TenantId = "tid", - Timestamp = new DateTime(2022, 02, 22, 22, 02, 22, DateTimeKind.Utc), - PartitionKey = "pid", - ETag = "etag", - Attributes = new Dictionary { { "fruit", "bananas" } }, - Value = new Product { Id = "A", Name = "B", Price = 1.99m }, - Key = "A" - }; - - internal static EventData CreateProductEvent2() => new() - { - Id = "id", - Timestamp = new DateTime(2022, 02, 22, 22, 02, 22, DateTimeKind.Utc), - Value = new Product { Id = "A", Name = "B", Price = 1.99m }, - CorrelationId = "cid", - Key = "A" - }; - - private const string CloudEvent1 = "{\"specversion\":\"1.0\",\"id\":\"id\",\"time\":\"2022-02-22T22:02:22Z\",\"type\":\"product.created\",\"source\":\"product/a\",\"subject\":\"product\",\"action\":\"created\",\"correlationid\":\"cid\",\"partitionkey\":\"pid\",\"tenantid\":\"tid\",\"etag\":\"etag\",\"key\":\"A\",\"fruit\":\"bananas\",\"datacontenttype\":\"application/json\",\"data\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}}"; - - private const string CloudEvent2 = "{\"specversion\":\"1.0\",\"id\":\"id\",\"time\":\"2022-02-22T22:02:22Z\",\"type\":\"coreex.testfunction.models.product\",\"source\":\"null\",\"correlationid\":\"cid\",\"key\":\"A\",\"datacontenttype\":\"application/json\",\"data\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}}"; - - private const string CloudEvent1Attachement = "{\"specversion\":\"1.0\",\"id\":\"id\",\"time\":\"2022-02-22T22:02:22Z\",\"type\":\"product.created\",\"source\":\"product/a\",\"subject\":\"product\",\"action\":\"created\",\"correlationid\":\"cid\",\"partitionkey\":\"pid\",\"tenantid\":\"tid\",\"etag\":\"etag\",\"key\":\"A\",\"fruit\":\"bananas\",\"datacontenttype\":\"application/json\",\"data\":{\"contentType\":\"application/json\",\"attachment\":\"bananas.json\"}}"; - - internal static EventStorage CreateEventStorage(string? data = null, int? max = null) => new(data) { MaxDataSize = max ?? 100000 }; - - internal class EventStorage : IAttachmentStorage - { - private readonly BinaryData _data; - - public EventStorage(string? data) - => _data = data is null ? BinaryData.Empty : new BinaryData(data); - - public int MaxDataSize { get; set; } = 10; - - public Task ReadAync(EventAttachment attachment, CancellationToken cancellationToken) => Task.FromResult(_data); - - public Task WriteAsync(EventData @event, BinaryData attachmentData, CancellationToken cancellationToken) - { - Assert.That(attachmentData.ToString(), Is.EqualTo(_data.ToString())); - return Task.FromResult(new EventAttachment { Attachment = "bananas.json", ContentType = "application/json" }); - } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Events/EventDataFormatterTest.cs b/tests/CoreEx.Test/Framework/Events/EventDataFormatterTest.cs deleted file mode 100644 index c1b14642..00000000 --- a/tests/CoreEx.Test/Framework/Events/EventDataFormatterTest.cs +++ /dev/null @@ -1,222 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Events; -using CoreEx.TestFunction.Models; -using NUnit.Framework; -using System; -using System.Text.Json.Serialization; -using UnitTestEx; - -namespace CoreEx.Test.Framework.Events -{ - [TestFixture] - public class EventDataFormatterTest - { - [Test] - public void PropertySelection() - { - var ed = CloudEventSerializerTest.CreateProductEvent1(); - var ed2 = new EventData { Id = ed.Id, Timestamp = ed.Timestamp, Value = ed.Value, CorrelationId = null }; - var ef = new EventDataFormatter { PropertySelection = EventDataProperty.None }; - ef.Format(ed); - ObjectComparer.Assert(ed2, ed); - } - - [Test] - public void TypeCasing() - { - var ed = new EventData { Type = "AbCd" }; - var ef = new EventDataFormatter { TypeCasing = CoreEx.Globalization.TextInfoCasing.None }; - ef.Format(ed); - Assert.That(ed.Type, Is.EqualTo("AbCd")); - - ed = new EventData { Type = "AbCd" }; - ef = new EventDataFormatter { TypeCasing = CoreEx.Globalization.TextInfoCasing.Lower }; - ef.Format(ed); - Assert.That(ed.Type, Is.EqualTo("abcd")); - - ed = new EventData { Type = "AbCd" }; - ef = new EventDataFormatter { TypeCasing = CoreEx.Globalization.TextInfoCasing.Upper }; - ef.Format(ed); - Assert.That(ed.Type, Is.EqualTo("ABCD")); - } - - [Test] - public void TypeDefaultToValueTypeName() - { - var ed = new EventData(); - var ef = new EventDataFormatter { TypeDefaultToValueTypeName = false }; - ef.Format(ed); - Assert.That(ed.Type, Is.EqualTo(null)); - - ed = new EventData(); - ef.TypeDefaultToValueTypeName = true; - ef.Format(ed); - Assert.That(ed.Type, Is.EqualTo("none")); - - ed = new EventData { Value = new Product() }; - ef.Format(ed); - Assert.That(ed.Type, Is.EqualTo("coreex.testfunction.models.product")); - - ed = new EventData { Value = new Product() }; - ef.TypeSeparatorCharacter = '/'; - ef.Format(ed); - Assert.That(ed.Type, Is.EqualTo("coreex/testfunction/models/product")); - } - - [Test] - public void TypeAppendIdOrPrimaryKey() - { - var ed = new EventData { Type = "product", Value = new Product { Id = "abc" } }; - var ef = new EventDataFormatter { TypeAppendEntityKey = true }; - ef.Format(ed); - Assert.That(ed.Type, Is.EqualTo("product.abc")); - - ed = new EventData { Type = "product", Value = new BackendProduct { Code = "xyz" } }; - ef.Format(ed); - Assert.That(ed.Type, Is.EqualTo("product.xyz")); - } - - [Test] - public void SubjectCasing() - { - var ed = new EventData { Subject = "AbCd" }; - var ef = new EventDataFormatter { SubjectCasing = CoreEx.Globalization.TextInfoCasing.None }; - ef.Format(ed); - Assert.That(ed.Subject, Is.EqualTo("AbCd")); - - ed = new EventData { Subject = "AbCd" }; - ef = new EventDataFormatter { SubjectCasing = CoreEx.Globalization.TextInfoCasing.Lower }; - ef.Format(ed); - Assert.That(ed.Subject, Is.EqualTo("abcd")); - - ed = new EventData { Subject = "AbCd" }; - ef = new EventDataFormatter { SubjectCasing = CoreEx.Globalization.TextInfoCasing.Upper }; - ef.Format(ed); - Assert.That(ed.Subject, Is.EqualTo("ABCD")); - } - - [Test] - public void SubjectDefaultToValueSubjectName() - { - var ed = new EventData(); - var ef = new EventDataFormatter { SubjectDefaultToValueTypeName = false }; - ef.Format(ed); - Assert.That(ed.Subject, Is.Null); - - ef.SubjectDefaultToValueTypeName = true; - ef.Format(ed); - Assert.That(ed.Subject, Is.Null); - - ed.Value = new Product(); - ef.Format(ed); - Assert.That(ed.Subject, Is.EqualTo("coreex.testfunction.models.product")); - - ed.Subject = null; - ef.SubjectSeparatorCharacter = '/'; - ef.Format(ed); - Assert.That(ed.Subject, Is.EqualTo("coreex/testfunction/models/product")); - } - - [Test] - public void SubjectAppendIdOrPrimaryKey() - { - var ed = new EventData { Subject = "product", Value = new Product { Id = "abc" } }; - var ef = new EventDataFormatter { SubjectAppendEntityKey = true }; - ef.Format(ed); - Assert.That(ed.Subject, Is.EqualTo("product.abc")); - - ed = new EventData { Subject = "product", Value = new BackendProduct { Code = "xyz" } }; - ef.Format(ed); - Assert.That(ed.Subject, Is.EqualTo("product.xyz")); - } - - [Test] - public void ActionCasing() - { - var ed = new EventData { Action = "AbCd" }; - var ef = new EventDataFormatter { ActionCasing = CoreEx.Globalization.TextInfoCasing.None }; - ef.Format(ed); - Assert.That(ed.Action, Is.EqualTo("AbCd")); - - ed = new EventData { Action = "AbCd" }; - ef = new EventDataFormatter { ActionCasing = CoreEx.Globalization.TextInfoCasing.Lower }; - ef.Format(ed); - Assert.That(ed.Action, Is.EqualTo("abcd")); - - ed = new EventData { Action = "AbCd" }; - ef = new EventDataFormatter { ActionCasing = CoreEx.Globalization.TextInfoCasing.Upper }; - ef.Format(ed); - Assert.That(ed.Action, Is.EqualTo("ABCD")); - } - - [Test] - public void SourceDefault() - { - var ed = new EventData(); - var ef = new EventDataFormatter { SourceDefault = _ => new Uri("null", UriKind.Relative) }; - ef.Format(ed); - Assert.That(ed.Source, Is.EqualTo(new Uri("null", UriKind.Relative))); - } - - [Test] - public void ETagDefaultFromValue() - { - var ed = new EventData { Value = new Person { ETag = "xxx" } }; - var ef = new EventDataFormatter { ETagDefaultFromValue = true }; - ef.Format(ed); - Assert.That(ed.ETag, Is.EqualTo("xxx")); - } - - [Test] - public void ETagDefaultGenerated() - { - var ed = new EventData { Value = new Product { Id = "abc" } }; - var ef = new EventDataFormatter { ETagDefaultGenerated = true }; - Assert.Throws(() => ef.Format(ed)); - - ef.JsonSerializer = new CoreEx.Text.Json.JsonSerializer(); - ef.Format(ed); - Assert.That(ed.ETag, Is.EqualTo("0rk/Eu4Si62XCw/qDYxqLh9fhNR/4rrAijmAigS0NDM=")); - } - - [Test] - public void FormattableValue() - { - var ed = new EventData { Value = new SalesOrderItem { OrderNo = "X400", ItemNo = 10, ProductId = "abc" } }; - var ef = new EventDataFormatter(); - ef.Format(ed); - - Assert.Multiple(() => - { - Assert.That(ed.Key, Is.EqualTo("X400,10")); - Assert.That(ed.HasAttributes, Is.True); - Assert.That(ed.Attributes!["_SessionId"], Is.EqualTo("abc")); - }); - } - - internal class Person : IETag - { - public string? Name { get; set; } - - public string? ETag { get; set; } - } - - internal class SalesOrderItem : IPrimaryKey, IEventDataFormatter - { - public string? OrderNo { get; set; } - - public int ItemNo { get; set; } - - public string? ProductId { get; set; } - - [JsonIgnore] - public CompositeKey PrimaryKey => new(OrderNo, ItemNo); - - void IEventDataFormatter.Format(EventData eventData) - { - var p = (SalesOrderItem)eventData.Value!; - eventData.AddAttribute("_SessionId", p.ProductId!); - } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Events/EventDataPublisherTest.cs b/tests/CoreEx.Test/Framework/Events/EventDataPublisherTest.cs deleted file mode 100644 index f2fc39bd..00000000 --- a/tests/CoreEx.Test/Framework/Events/EventDataPublisherTest.cs +++ /dev/null @@ -1,220 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Events; -using NUnit.Framework; -using System; - -namespace CoreEx.Test.Framework.Events -{ - [TestFixture] - public class EventDataPublisherTest - { - [Test] - public void Create() - { - var ep = new NullEventPublisher(); - - var ed = ep.CreateEvent(new System.Uri("http://blah"), "sub", "act"); - Assert.That(ed, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed.Source, Is.EqualTo(new System.Uri("http://blah"))); - Assert.That(ed.Subject, Is.EqualTo("sub")); - Assert.That(ed.Action, Is.EqualTo("act")); - Assert.That(ed.Key, Is.Null); - }); - - ed = ep.CreateEvent(new System.Uri("http://blah"), "sub", "act", "x"); - Assert.That(ed, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed.Source, Is.EqualTo(new System.Uri("http://blah"))); - Assert.That(ed.Subject, Is.EqualTo("sub")); - Assert.That(ed.Action, Is.EqualTo("act")); - Assert.That(ed.Key, Is.EqualTo("x")); - }); - - ed = ep.CreateEvent(new System.Uri("http://blah"), "sub", "act", "a", "b"); - Assert.That(ed, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed.Source, Is.EqualTo(new System.Uri("http://blah"))); - Assert.That(ed.Subject, Is.EqualTo("sub")); - Assert.That(ed.Action, Is.EqualTo("act")); - Assert.That(ed.Key, Is.EqualTo("a,b")); - }); - - ed = ep.CreateEvent(new System.Uri("http://blah"), "sub", "act", new CoreEx.Entities.CompositeKey("c", "d")); - Assert.That(ed, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed.Source, Is.EqualTo(new System.Uri("http://blah"))); - Assert.That(ed.Subject, Is.EqualTo("sub")); - Assert.That(ed.Action, Is.EqualTo("act")); - Assert.That(ed.Key, Is.EqualTo("c,d")); - }); - - var ed2 = ep.CreateEvent("sub", "act"); - Assert.That(ed2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed2.Source, Is.Null); - Assert.That(ed2.Subject, Is.EqualTo("sub")); - Assert.That(ed2.Action, Is.EqualTo("act")); - Assert.That(ed2.Key, Is.Null); - }); - - ed2 = ep.CreateEvent("sub", "act", "x"); - Assert.That(ed2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed2.Source, Is.Null); - Assert.That(ed2.Subject, Is.EqualTo("sub")); - Assert.That(ed2.Action, Is.EqualTo("act")); - Assert.That(ed2.Key, Is.EqualTo("x")); - }); - - ed2 = ep.CreateEvent("sub", "act", "a", "b"); - Assert.That(ed2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed2.Source, Is.Null); - Assert.That(ed2.Subject, Is.EqualTo("sub")); - Assert.That(ed2.Action, Is.EqualTo("act")); - Assert.That(ed2.Key, Is.EqualTo("a,b")); - }); - - ed2 = ep.CreateEvent("sub", "act", new CoreEx.Entities.CompositeKey("c", "d")); - Assert.That(ed2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed2.Source, Is.Null); - Assert.That(ed2.Subject, Is.EqualTo("sub")); - Assert.That(ed2.Action, Is.EqualTo("act")); - Assert.That(ed2.Key, Is.EqualTo("c,d")); - }); - } - - [Test] - public void CreateValue() - { - var ep = new NullEventPublisher(); - ep.EventDataFormatter.KeySeparatorCharacter = '|'; - - var ed1 = ep.CreateValueEvent(new Person1 { Id = 88 }, new System.Uri("http://blah"), "sub", "act"); - Assert.That(ed1, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed1.Source, Is.EqualTo(new System.Uri("http://blah"))); - Assert.That(ed1.Subject, Is.EqualTo("sub")); - Assert.That(ed1.Action, Is.EqualTo("act")); - Assert.That(ed1.Key, Is.EqualTo("88")); - }); - - var ed2 = ep.CreateValueEvent(new Person2 { PrimaryKey = new CompositeKey("a", "b") }, new System.Uri("http://blah"), "sub", "act"); - Assert.That(ed2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed2.Source, Is.EqualTo(new System.Uri("http://blah"))); - Assert.That(ed2.Subject, Is.EqualTo("sub")); - Assert.That(ed2.Action, Is.EqualTo("act")); - Assert.That(ed2.Key, Is.EqualTo("a|b")); - }); - - var ed3 = ep.CreateValueEvent(new Person1 { Id = 88 }, "sub", "act"); - Assert.That(ed3, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed3.Source, Is.Null); - Assert.That(ed3.Subject, Is.EqualTo("sub")); - Assert.That(ed3.Action, Is.EqualTo("act")); - Assert.That(ed3.Key, Is.EqualTo("88")); - }); - - var ed4 = ep.CreateValueEvent(new Person2 { PrimaryKey = new CompositeKey("a", "b") }, "sub", "act"); - Assert.That(ed4, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ed4.Source, Is.Null); - Assert.That(ed4.Subject, Is.EqualTo("sub")); - Assert.That(ed4.Action, Is.EqualTo("act")); - Assert.That(ed4.Key, Is.EqualTo("a|b")); - }); - } - - [Test] - public void ExtentionMethods() - { - var uri = new System.Uri("http://blah"); - var ep = new NullEventPublisher(); - ep.CreateEvent("sub"); - ep.CreateEvent("sub", "act"); - ep.CreateEvent("sub", "act", 88); - ep.CreateEvent("sub", "act", CompositeKey.Create(88)); - - ep.CreateEvent(uri, "sub"); - ep.CreateEvent(uri, "sub", "act"); - ep.CreateEvent(uri, "sub", "act", 88); - ep.CreateEvent(uri, "sub", "act", CompositeKey.Create(88)); - - ep.CreateValueEvent(new Person1 { Id = 88 }, "sub"); - ep.CreateValueEvent(new Person1 { Id = 88 }, "sub", "act"); - ep.CreateValueEvent(new Person1 { Id = 88 }, "sub", "act", 88); - ep.CreateValueEvent(new Person1 { Id = 88 }, "sub", "act", CompositeKey.Create(88)); - - ep.CreateValueEvent(new Person1 { Id = 88 }, uri, "sub"); - ep.CreateValueEvent(new Person1 { Id = 88 }, uri, "sub", "act"); - ep.CreateValueEvent(new Person1 { Id = 88 }, uri, "sub", "act", 88); - ep.CreateValueEvent(new Person1 { Id = 88 }, uri, "sub", "act", CompositeKey.Create(88)); - - ep.PublishEvent("sub"); - ep.PublishEvent("sub", "act"); - ep.PublishEvent("sub", "act", 88); - ep.PublishEvent("sub", "act", CompositeKey.Create(88)); - - ep.PublishEvent(uri, "sub"); - ep.PublishEvent(uri, "sub", "act"); - ep.PublishEvent(uri, "sub", "act", 88); - ep.PublishEvent(uri, "sub", "act", CompositeKey.Create(88)); - - ep.PublishNamedEvent("q", "sub"); - ep.PublishNamedEvent("q", "sub", "act"); - ep.PublishNamedEvent("q", "sub", "act", 88); - ep.PublishNamedEvent("q", "sub", "act", CompositeKey.Create(88)); - - ep.PublishNamedEvent("q", uri, "sub"); - ep.PublishNamedEvent("q", uri, "sub", "act"); - ep.PublishNamedEvent("q", uri, "sub", "act", 88); - ep.PublishNamedEvent("q", uri, "sub", "act", CompositeKey.Create(88)); - - ep.PublishValueEvent(new Person1 { Id = 88 }, "sub"); - ep.PublishValueEvent(new Person1 { Id = 88 }, "sub", "act"); - ep.PublishValueEvent(new Person1 { Id = 88 }, "sub", "act", 88); - ep.PublishValueEvent(new Person1 { Id = 88 }, "sub", "act", CompositeKey.Create(88)); - - ep.PublishValueEvent(new Person1 { Id = 88 }, uri, "sub"); - ep.PublishValueEvent(new Person1 { Id = 88 }, uri, "sub", "act"); - ep.PublishValueEvent(new Person1 { Id = 88 }, uri, "sub", "act", 88); - ep.PublishValueEvent(new Person1 { Id = 88 }, uri, "sub", "act", CompositeKey.Create(88)); - - ep.PublishNamedValueEvent("q", new Person1 { Id = 88 }, "sub"); - ep.PublishNamedValueEvent("q", new Person1 { Id = 88 }, "sub", "act"); - ep.PublishNamedValueEvent("q", new Person1 { Id = 88 }, "sub", "act", 88); - ep.PublishNamedValueEvent("q", new Person1 { Id = 88 }, "sub", "act", CompositeKey.Create(88)); - - ep.PublishNamedValueEvent("q", new Person1 { Id = 88 }, uri, "sub"); - ep.PublishNamedValueEvent("q", new Person1 { Id = 88 }, uri, "sub", "act"); - ep.PublishNamedValueEvent("q", new Person1 { Id = 88 }, uri, "sub", "act", 88); - ep.PublishNamedValueEvent("q", new Person1 { Id = 88 }, uri, "sub", "act", CompositeKey.Create(88)); - } - - public class Person1 : IIdentifier - { - public int Id { get; set; } - } - - public class Person2 : IPrimaryKey - { - public CompositeKey PrimaryKey { get; set; } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs b/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs deleted file mode 100644 index 6ab9a951..00000000 --- a/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs +++ /dev/null @@ -1,318 +0,0 @@ -using CoreEx.Events; -using CoreEx.TestFunction.Models; -using NUnit.Framework; -using System; -using System.Threading.Tasks; -using UnitTestEx; -using Nsj = Newtonsoft.Json; - -namespace CoreEx.Test.Framework.Events -{ - [TestFixture] - public class EventDataSerializerTest - { - [Test] - public async Task SystemTextJson_Serialize_Deserialize_EventData1() - { - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = false } as IEventSerializer; - var ed = CloudEventSerializerTest.CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize_EventData2() - { - var ef = new EventDataFormatter { SourceDefault = _ => new Uri("null", UriKind.RelativeOrAbsolute) }; - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer(), ef) { SerializeValueOnly = false } as IEventSerializer; - var ed = CloudEventSerializerTest.CreateProductEvent2(); - ef.Format(ed); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent2)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2, "Type", "Source"); - Assert.Multiple(() => - { - Assert.That(ed2.Source, Is.EqualTo(new Uri("null", UriKind.Relative))); - Assert.That(ed2.Type, Is.EqualTo("coreex.testfunction.models.product")); - }); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize_EventData3() - { - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = false } as IEventSerializer; - var ped = CloudEventSerializerTest.CreateProductEvent1(); - var ed = new EventData(ped) { Value = ped.Value }; - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - ed2 = (EventData)await es.DeserializeAsync(bd, typeof(Product)).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - var ed3 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed3, "Value"); - Assert.That(ed3.Value?.ToString(), Is.EqualTo("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}")); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize_EventData4() - { - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = false } as IEventSerializer; - var ped = CloudEventSerializerTest.CreateProductEvent1(); - var ed = new EventData(ped); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - var ed3 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed3); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize_ValueOnly() - { - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = true } as IEventSerializer; - var ed = CloudEventSerializerTest.CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}")); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(new EventData { Value = ed.Value }, ed2); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize_ValueOnly2() - { - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = true } as IEventSerializer; - var ped = CloudEventSerializerTest.CreateProductEvent1(); - var ed = new EventData(ped); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(new EventData(), ed2); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize_EventData1_WithAttachment() - { - // Serialized length is > 10, so it will be stored in attachment - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = false, AttachmentStorage = CloudEventSerializerTest.CreateEventStorage("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}", 10) } as IEventSerializer; - var ed = CloudEventSerializerTest.CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1Attachment)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize_EventData1_WithNoAttachment() - { - // Serialized length is < 100, so it will _not_ be stored in attachment - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = false, AttachmentStorage = CloudEventSerializerTest.CreateEventStorage("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}", 100) } as IEventSerializer; - var ed = CloudEventSerializerTest.CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize_EventData3_WithAttachment() - { - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = false, AttachmentStorage = CloudEventSerializerTest.CreateEventStorage("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}", 10) } as IEventSerializer; - var ped = CloudEventSerializerTest.CreateProductEvent1(); - var ed = new EventData(ped) { Value = ped.Value }; - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1Attachment)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - ed2 = (EventData)await es.DeserializeAsync(bd, typeof(Product)).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - var ed3 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed3, "Value"); - Assert.That(ed3.Value?.ToString(), Is.EqualTo("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}")); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize_EventData3_WithNoAttachment() - { - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = false, AttachmentStorage = CloudEventSerializerTest.CreateEventStorage("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}", 100) } as IEventSerializer; - var ped = CloudEventSerializerTest.CreateProductEvent1(); - var ed = new EventData(ped) { Value = ped.Value }; - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - ed2 = (EventData)await es.DeserializeAsync(bd, typeof(Product)).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - var ed3 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed3, "Value"); - Assert.That(ed3.Value?.ToString(), Is.EqualTo("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}")); - } - - [Test] - public async Task SystemTextJson_Serialize_Deserialize_Custom_EventData1() - { - var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = false } as IEventSerializer; - es.CustomSerializers.Add((ed, js, _) => new BinaryData(js.SerializeWithExcludeFilter(ed, "value.price"))); - var ed = CloudEventSerializerTest.CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2, "value.price"); - - Assert.Multiple(() => - { - Assert.That(ed.Value.Price, Is.Not.Zero); - Assert.That(ed2.Value.Price, Is.Zero); // Price should be scrubbed. - }); - } - - [Test] - public async Task NewtonsoftJson_Serialize_Deserialize_EventData1() - { - var es = new CoreEx.Newtonsoft.Json.EventDataSerializer(new Newtonsoft.Json.JsonSerializer()) { SerializeValueOnly = false } as IEventSerializer; - var ed = CloudEventSerializerTest.CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - } - - [Test] - public async Task NewtonsoftJson_Serialize_Deserialize_EventData2() - { - var ef = new EventDataFormatter { SourceDefault = _ => new Uri("null", UriKind.RelativeOrAbsolute) }; - var es = new CoreEx.Newtonsoft.Json.EventDataSerializer(new Newtonsoft.Json.JsonSerializer(), ef) { SerializeValueOnly = false } as IEventSerializer; - var ed = CloudEventSerializerTest.CreateProductEvent2(); - ef.Format(ed); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent2)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2, "Type", "Source"); - Assert.Multiple(() => - { - Assert.That(ed2.Source, Is.EqualTo(new Uri("null", UriKind.Relative))); - Assert.That(ed2.Type, Is.EqualTo("coreex.testfunction.models.product")); - }); - } - - [Test] - public async Task NewtonsoftJson_Serialize_Deserialize_EventData3() - { - var es = new CoreEx.Newtonsoft.Json.EventDataSerializer(new Newtonsoft.Json.JsonSerializer()) { SerializeValueOnly = false } as IEventSerializer; - var ped = CloudEventSerializerTest.CreateProductEvent1(); - var ed = new EventData(ped) { Value = ped.Value }; - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo(CloudEvent1)); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - var ed3 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed3, "Value"); - Assert.That(((Nsj.Linq.JToken)ed3.Value!).ToString(Nsj.Formatting.None), Is.EqualTo("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}")); - } - - [Test] - public async Task NewtonsoftJson_Serialize_Deserialize_EventData4() - { - var es = new CoreEx.Newtonsoft.Json.EventDataSerializer(new Newtonsoft.Json.JsonSerializer()) { SerializeValueOnly = false } as IEventSerializer; - var ped = CloudEventSerializerTest.CreateProductEvent1(); - var ed = new EventData(ped); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2); - - var ed3 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed3); - } - - [Test] - public async Task NewtonsoftJson_Serialize_Deserialize_ValueOnly() - { - var es = new CoreEx.Newtonsoft.Json.EventDataSerializer(new Newtonsoft.Json.JsonSerializer()) { SerializeValueOnly = true } as IEventSerializer; - var ed = CloudEventSerializerTest.CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - Assert.That(bd.ToString(), Is.EqualTo("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}")); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(new EventData { Value = ed.Value }, ed2); - } - - [Test] - public async Task NewtonsoftText_Serialize_Deserialize_ValueOnly2() - { - var es = new CoreEx.Newtonsoft.Json.EventDataSerializer(new Newtonsoft.Json.JsonSerializer()) { SerializeValueOnly = true } as IEventSerializer; - var ped = CloudEventSerializerTest.CreateProductEvent1(); - var ed = new EventData(ped); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(new EventData(), ed2); - } - - [Test] - public async Task NewtonsoftText_Serialize_Deserialize_Custom_EventData1() - { - var es = new CoreEx.Newtonsoft.Json.EventDataSerializer(new Newtonsoft.Json.JsonSerializer()) { SerializeValueOnly = false } as IEventSerializer; - es.CustomSerializers.Add((ed, js, _) => new BinaryData(js.SerializeWithExcludeFilter(ed, "value.price"))); - var ed = CloudEventSerializerTest.CreateProductEvent1(); - var bd = await es.SerializeAsync(ed).ConfigureAwait(false); - Assert.That(bd, Is.Not.Null); - - var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); - ObjectComparer.Assert(ed, ed2, "value.price"); - - Assert.Multiple(() => - { - Assert.That(ed.Value.Price, Is.Not.Zero); - Assert.That(ed2.Value.Price, Is.Zero); // Price should be scrubbed. - }); - } - - private const string CloudEvent1 = "{\"value\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99},\"id\":\"id\",\"subject\":\"product\",\"action\":\"created\",\"type\":\"product.created\",\"source\":\"product/a\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\",\"key\":\"A\",\"tenantId\":\"tid\",\"partitionKey\":\"pid\",\"etag\":\"etag\",\"attributes\":{\"fruit\":\"bananas\"}}"; - - private const string CloudEvent2 = "{\"value\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99},\"id\":\"id\",\"type\":\"coreex.testfunction.models.product\",\"source\":\"null\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\",\"key\":\"A\"}"; - - private const string CloudEvent1Attachment = "{\"value\":{\"contentType\":\"application/json\",\"attachment\":\"bananas.json\"},\"id\":\"id\",\"subject\":\"product\",\"action\":\"created\",\"type\":\"product.created\",\"source\":\"product/a\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\",\"key\":\"A\",\"tenantId\":\"tid\",\"partitionKey\":\"pid\",\"etag\":\"etag\",\"attributes\":{\"fruit\":\"bananas\"}}"; - - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberAttributeTest.cs b/tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberAttributeTest.cs deleted file mode 100644 index 583d52a7..00000000 --- a/tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberAttributeTest.cs +++ /dev/null @@ -1,155 +0,0 @@ -using CoreEx.Events; -using CoreEx.Events.Subscribing; -using NUnit.Framework; -using System; - -namespace CoreEx.Test.Framework.Events.Subscribing -{ - [TestFixture] - public class EventSubscriberAttributeTest - { - [Test] - public void Match_Subject_Only() - { - var edf = new EventDataFormatter(); - var esa = new EventSubscriberAttribute("root.*"); - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, Create("root", null, null)), Is.False); - Assert.That(esa.IsMatch(edf, Create("root.a", null, null)), Is.True); - Assert.That(esa.IsMatch(edf, Create("root.a.b", null, null)), Is.False); - Assert.That(esa.IsMatch(edf, Create("other", null, null)), Is.False); - }); - - esa = new EventSubscriberAttribute("root.*.*"); - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, Create("root", null, null)), Is.False); - Assert.That(esa.IsMatch(edf, Create("root.a", null, null)), Is.False); - Assert.That(esa.IsMatch(edf, Create("ROOT.A.B", null, null)), Is.True); - Assert.That(esa.IsMatch(edf, Create("root.a.b.c", null, null)), Is.False); - }); - - esa = new EventSubscriberAttribute("root.*.**"); - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, Create("root", null, null)), Is.False); - Assert.That(esa.IsMatch(edf, Create("root.a", null, null)), Is.False); - Assert.That(esa.IsMatch(edf, Create("root.a.b", null, null)), Is.True); - Assert.That(esa.IsMatch(edf, Create("root.a.b.c", null, null)), Is.True); - }); - } - - [Test] - public void Match_Type_Only() - { - var edf = new EventDataFormatter(); - var esa = new EventSubscriberAttribute() { Type = "root.*" }; - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, Create(null, "root", null)), Is.False); - Assert.That(esa.IsMatch(edf, Create(null, "root.a", null)), Is.True); - Assert.That(esa.IsMatch(edf, Create(null, "root.a.b", null)), Is.False); - Assert.That(esa.IsMatch(edf, Create(null, "other", null)), Is.False); - }); - - esa = new EventSubscriberAttribute() { Type = "root.*.*" }; - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, Create(null, "root", null)), Is.False); - Assert.That(esa.IsMatch(edf, Create(null, "root.a", null)), Is.False); - Assert.That(esa.IsMatch(edf, Create(null, "ROOT.A.B", null)), Is.True); - Assert.That(esa.IsMatch(edf, Create(null, "root.a.b.c", null)), Is.False); - }); - - esa = new EventSubscriberAttribute() { Type = "root.*.**" }; - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, Create(null, "root", null)), Is.False); - Assert.That(esa.IsMatch(edf, Create(null, "root.a", null)), Is.False); - Assert.That(esa.IsMatch(edf, Create(null, "root.a.b", null)), Is.True); - Assert.That(esa.IsMatch(edf, Create(null, "root.a.b.c", null)), Is.True); - }); - } - - [Test] - public void Match_Actions_Only() - { - var edf = new EventDataFormatter(); - var esa = new EventSubscriberAttribute(null, "a*", "b"); - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, Create(null, null, "a")), Is.True); - Assert.That(esa.IsMatch(edf, Create(null, null, "AA")), Is.True); - Assert.That(esa.IsMatch(edf, Create(null, null, "b")), Is.True); - Assert.That(esa.IsMatch(edf, Create(null, null, "BB")), Is.False); - Assert.That(esa.IsMatch(edf, Create(null, null, "c")), Is.False); - }); - } - - [Test] - public void Match_Multi() - { - var edf = new EventDataFormatter(); - var esa = new EventSubscriberAttribute("root.*", "a*", "b"); - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, Create("root", null, "aa")), Is.False); - Assert.That(esa.IsMatch(edf, Create("root.a", null, "b")), Is.True); - Assert.That(esa.IsMatch(edf, Create("root.a.b", null, "b")), Is.False); - }); - } - - [Test] - public void Match_CaseSensitive() - { - var edf = new EventDataFormatter(); - var esa = new EventSubscriberAttribute("root.*", "a*", "b"); - - Assert.That(esa.IsMatch(edf, Create("ROOT.A", null, "b")), Is.True); - - esa.IgnoreCase = false; - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, Create("root.a", null, "b")), Is.True); - Assert.That(esa.IsMatch(edf, Create("ROOT.A", null, "b")), Is.False); - }); - } - - [Test] - public void Match_Source() - { - var edf = new EventDataFormatter(); - - var esa = new EventSubscriberAttribute(new Uri("*", UriKind.Relative)); - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("test", UriKind.Relative) }), Is.True); - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("http://test", UriKind.Absolute) }), Is.True); - }); - - esa = new EventSubscriberAttribute(new Uri("test/*", UriKind.Relative)); - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("test", UriKind.Relative) }), Is.False); - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("test/abc", UriKind.Relative) }), Is.True); - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("/test/abc", UriKind.Relative) }), Is.True); - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("http://host/test", UriKind.Absolute) }), Is.False); - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("http://host/test/abc", UriKind.Absolute) }), Is.True); - }); - - esa = new EventSubscriberAttribute(new Uri("http://host/test/*", UriKind.Absolute)); - Assert.Multiple(() => - { - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("test/abc", UriKind.Relative) }), Is.False); - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("http://host/test", UriKind.Absolute) }), Is.False); - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("http://host/test/xyz", UriKind.Absolute) }), Is.True); - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("http://tsoh/test/xyz", UriKind.Absolute) }), Is.False); - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("https://host/test/xyz", UriKind.Absolute) }), Is.False); - Assert.That(esa.IsMatch(edf, new EventData { Source = new Uri("http://host:5050/test/xyz", UriKind.Absolute) }), Is.False); - }); - } - - private EventData Create(string? subject, string? type, string? action) => new() { Subject = subject, Type = type, Action = action }; - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberOrchestratorTest.cs b/tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberOrchestratorTest.cs deleted file mode 100644 index add09a53..00000000 --- a/tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberOrchestratorTest.cs +++ /dev/null @@ -1,336 +0,0 @@ -using CoreEx.Configuration; -using CoreEx.Events; -using CoreEx.Events.Subscribing; -using CoreEx.Hosting.Work; -using CoreEx.Results; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Events.Subscribing -{ - [TestFixture] - public class EventSubscriberOrchestratorTest - { - [Test] - public void NoMatch_NoSubscribers() - { - var sb = SetUpServiceProvider(); - var ees = sb.GetRequiredService(); - - var eso = new EventSubscriberOrchestrator(); - var match = eso.TryMatchSubscriber(ees, new EventData { Subject = "my.hr.employee", Type = "blah.blah", Action = "created" }, new EventSubscriberArgs()); - Assert.Multiple(() => - { - Assert.That(match.Matched, Is.False); - Assert.That(match.Subscriber, Is.Null); - Assert.That(match.ValueType, Is.Null); - }); - } - - [Test] - public void NoMatch_WithSubscribers() - { - var sb = SetUpServiceProvider(); - var ees = sb.GetRequiredService(); - - var eso = new EventSubscriberOrchestrator().AddSubscriber().AddSubscriber(); - var match = eso.TryMatchSubscriber(ees, new EventData { Subject = "my.hr.employee", Type = "blah.blah", Action = "created" }, new EventSubscriberArgs()); - Assert.Multiple(() => - { - Assert.That(match.Matched, Is.False); - Assert.That(match.Subscriber, Is.Null); - Assert.That(match.ValueType, Is.Null); - }); - } - - [Test] - public void Match_Success_WithValue() - { - var sb = SetUpServiceProvider(sc => sc.AddScoped()); - var ees = sb.GetRequiredService(); - - var eso = new EventSubscriberOrchestrator().AddSubscriber().AddSubscriber(); - var match = eso.TryMatchSubscriber(ees, new EventData { Subject = "my.hr.employee", Type = "blah.blah", Action = "tweaked" }, new EventSubscriberArgs()); - Assert.Multiple(() => - { - Assert.That(match.Matched, Is.True); - Assert.That(match.Subscriber, Is.Not.Null.And.TypeOf()); - Assert.That(match.ValueType, Is.Not.Null.And.EqualTo(typeof(Employee))); - }); - } - - [Test] - public void Match_Success_NoValue() - { - var sb = SetUpServiceProvider(sc => sc.AddScoped()); - var ees = sb.GetRequiredService(); - - var eso = new EventSubscriberOrchestrator().AddSubscriber().AddSubscriber(); - var match = eso.TryMatchSubscriber(ees, new EventData { Subject = "my.hr.employee", Type = "blah.blah", Action = "deleted" }, new EventSubscriberArgs()); - Assert.Multiple(() => - { - Assert.That(match.Matched, Is.True); - Assert.That(match.Subscriber, Is.Not.Null.And.TypeOf()); - Assert.That(match.ValueType, Is.Null); - }); - } - - [Test] - public void NoMatch_Ambiquous() - { - var sb = SetUpServiceProvider(sc => sc.AddScoped()); - var ees = sb.GetRequiredService(); - - var eso = new EventSubscriberOrchestrator().AddSubscriber().AddSubscriber().UseAmbiquousSubscriberHandling(ErrorHandling.HandleByHost); - var match = eso.TryMatchSubscriber(ees, new EventData { Subject = "my.hr.employee", Type = "blah.blah", Action = "deleted" }, new EventSubscriberArgs()); - Assert.Multiple(() => - { - Assert.That(match.Matched, Is.False); - Assert.That(match.Subscriber, Is.Not.Null.And.TypeOf()); - Assert.That(match.ValueType, Is.Null); - }); - } - - [Test] - public void Match_With_ExtendedMatchMethod() - { - var sb = SetUpServiceProvider(sc => sc.AddScoped()); - var ees = sb.GetRequiredService(); - - var eso = new EventSubscriberOrchestrator().AddSubscriber(); - var match = eso.TryMatchSubscriber(ees, new EventData { Subject = "my.hr.other", Type = "blah.blah", Action = "created", Key = "KEY" }, new EventSubscriberArgs()); - Assert.Multiple(() => - { - Assert.That(match.Matched, Is.True); - Assert.That(match.Subscriber, Is.Not.Null.And.TypeOf()); - Assert.That(match.ValueType, Is.Null); - }); - - match = eso.TryMatchSubscriber(ees, new EventData { Subject = "my.hr.other", Type = "blah.blah", Action = "created", Key = "XXX" }, new EventSubscriberArgs()); - Assert.Multiple(() => - { - Assert.That(match.Matched, Is.False); - Assert.That(match.Subscriber, Is.Null); - Assert.That(match.ValueType, Is.Null); - }); - } - - [Test] public async Task Receive_Unhandled_None() => await ReceiveTest(null, () => throw new System.NotImplementedException("Unhandled exception."), typeof(System.NotImplementedException), false, message: "Unhandled exception."); - [Test] public async Task Receive_Unhandled_Exception() => await ReceiveTest(ms => ms._UnhandledHandling = ErrorHandling.HandleBySubscriber, () => throw new System.NotImplementedException("Unhandled exception."), typeof(System.NotImplementedException), true, message: "Unhandled exception.", ins: "Test.Error.UnhandledError"); - [Test] public async Task Receive_Unhandled_CompleteSilent() => await ReceiveTest(ms => ms._UnhandledHandling = ErrorHandling.CompleteAsSilent, () => throw new System.NotImplementedException("Unhandled exception."), ins: "Test.Complete.UnhandledError"); - - [Test] public async Task Receive_Unhandled_FailFast() - { - // The following must be tested independently (comment out Assert.Inconclusive) to verify as it will kill the test process. - Assert.Inconclusive("FailFast can not be executed within testing as it will kill the test process; must be manually tested!"); - await ReceiveTest(ms => ms._UnhandledHandling = ErrorHandling.CriticalFailFast, () => throw new System.NotImplementedException("Unhandled exception.")); - Assert.Fail("Should never, ever, ever, get here ;-)"); - } - - [Test] public async Task Receive_Security_None() => await ReceiveTest(null, () => throw new AuthenticationException(), typeof(AuthenticationException), false); - [Test] public async Task Receive_Security_Exception() => await ReceiveTest(ms => ms._SecurityHandling = ErrorHandling.HandleBySubscriber, () => throw new AuthorizationException(), typeof(AuthorizationException), true, ins: "Test.Error.AuthorizationError"); - [Test] public async Task Receive_Security_Retry() => await ReceiveTest(ms => ms._SecurityHandling = ErrorHandling.Retry, () => throw new AuthorizationException(), typeof(AuthorizationException), true, true, ins: "Test.Retry.AuthorizationError"); - [Test] public async Task Receive_Security_CompleteSilent() => await ReceiveTest(ms => ms._SecurityHandling = ErrorHandling.CompleteAsSilent, () => throw new AuthorizationException(), ins: "Test.Complete.AuthorizationError"); - - [Test] public async Task Receive_InvalidData_None() => await ReceiveTest(null, () => throw new BusinessException(), typeof(BusinessException), false); - [Test] public async Task Receive_InvalidData_Exception() => await ReceiveTest(ms => ms._InvalidDataHandling = ErrorHandling.HandleBySubscriber, () => throw new ConflictException(), typeof(ConflictException), true, ins: "Test.Error.ConflictError"); - [Test] public async Task Receive_InvalidData_CompleteSilent() => await ReceiveTest(ms => ms._InvalidDataHandling = ErrorHandling.CompleteAsSilent, () => throw new DuplicateException(), ins: "Test.Complete.DuplicateError"); - [Test] public async Task Receive_InvalidData_Exception_ValueIsRequired() => await ReceiveTest(ms => ms._InvalidDataHandling = ErrorHandling.HandleBySubscriber, () => throw new DivideByZeroException(), typeof(ValidationException), true, message: "Invalid message; body was not provided, contained invalid JSON, or was incorrectly formatted: Value is required.", ed: new EventData(), ins: "Test.Error.ValidationError"); - - [Test] public async Task Receive_Concurrency_None() => await ReceiveTest(null, () => throw new ConcurrencyException(), typeof(ConcurrencyException), false); - [Test] public async Task Receive_Concurrency_Exception() => await ReceiveTest(ms => ms._ConcurrencyHandling = ErrorHandling.HandleBySubscriber, () => throw new ConcurrencyException(), typeof(ConcurrencyException), true, ins: "Test.Error.ConcurrencyError"); - [Test] public async Task Receive_Concurrency_CompleteSilent() => await ReceiveTest(ms => ms._ConcurrencyHandling = ErrorHandling.CompleteAsSilent, () => throw new ConcurrencyException(), ins: "Test.Complete.ConcurrencyError"); - - [Test] public async Task Receive_NotFound_None() => await ReceiveTest(null, () => throw new NotFoundException(), typeof(NotFoundException), false); - [Test] public async Task Receive_NotFound_Exception() => await ReceiveTest(ms => ms._NotFoundHandling = ErrorHandling.HandleBySubscriber, () => throw new NotFoundException(), typeof(NotFoundException), true, ins: "Test.Error.NotFoundError"); - [Test] public async Task Receive_NotFound_CompleteSilent() => await ReceiveTest(ms => ms._NotFoundHandling = ErrorHandling.CompleteAsSilent, () => throw new NotFoundException(), ins: "Test.Complete.NotFoundError"); - - [Test] public async Task Receive_Transient_None() => await ReceiveTest(null, () => throw new TransientException(), typeof(TransientException), false, true); - [Test] public async Task Receive_Transient_Retry() => await ReceiveTest(ms => ms._TransientHandling = ErrorHandling.Retry, () => throw new TransientException(), typeof(TransientException), true, true, ins: "Test.Retry.TransientError"); - [Test] public async Task Receive_Transient_Exception() => await ReceiveTest(ms => ms._TransientHandling = ErrorHandling.HandleBySubscriber, () => throw new TransientException(), typeof(TransientException), true, false, ins: "Test.Error.TransientError"); - [Test] public async Task Receive_Transient_CompleteSilent() => await ReceiveTest(ms => ms._TransientHandling = ErrorHandling.CompleteAsSilent, () => throw new TransientException(), ins: "Test.Complete.TransientError"); - - [Test] public async Task Receive_DataConsistency_None() => await ReceiveTest(null, () => throw new DataConsistencyException(), typeof(DataConsistencyException), false, true); - [Test] public async Task Receive_DataConsistency_Retry() => await ReceiveTest(ms => ms._DataConsistencyHandling = ErrorHandling.Retry, () => throw new DataConsistencyException(), typeof(DataConsistencyException), true, true, ins: "Test.Retry.DataConsistencyError"); - [Test] public async Task Receive_DataConsistency_Exception() => await ReceiveTest(ms => ms._DataConsistencyHandling = ErrorHandling.HandleBySubscriber, () => throw new DataConsistencyException(), typeof(DataConsistencyException), true, false, ins: "Test.Error.DataConsistencyError"); - [Test] public async Task Receive_DataConsistency_CompleteSilent() => await ReceiveTest(ms => ms._DataConsistencyHandling = ErrorHandling.CompleteAsSilent, () => throw new DataConsistencyException(), ins: "Test.Complete.DataConsistencyError"); - - [Test] public async Task Receive_Success() => await ReceiveTest(null, () => { }, null, false, ins: "Test.Complete.Success"); - - [Test] - public void GetSubscriber() - { - var s = EventSubscriberOrchestrator.GetSubscribers(); - Assert.That(s, Is.Not.Null.And.Length.EqualTo(4)); - } - - private static ServiceProvider SetUpServiceProvider(Action? action = null) - { - var sc = new ServiceCollection(); - sc.AddLogging(lb => lb.AddConsole()); - sc.AddCloudEventSerializer(); - sc.AddExecutionContext(); - sc.AddDefaultSettings(); - sc.AddSingleton(); - sc.AddEventDataFormatter(); - sc.AddAzureServiceBusReceivedMessageConverter(); - sc.AddSingleton(); - sc.AddSingleton(); - - action?.Invoke(sc); - - return sc.BuildServiceProvider(); - } - - private static async Task ReceiveTest(System.Action? setAction, System.Action receiveAction, System.Type? exceptionType = null, bool eventSubscriberException = true, bool isTransient = false, string? message = null, EventData? ed = null, string? ins = null) - { - var ms = new ModifySubscriber(); - var sb = SetUpServiceProvider(sc => sc.AddSingleton(ms)); - - var eso = new EventSubscriberOrchestrator().AddSubscriber().AddSubscriber(); - var ees = sb.GetRequiredService(); - - var match = eso.TryMatchSubscriber(ees, new EventData { Subject = "my.hr.employee", Type = "blah.blah", Action = "tweaked" }, new EventSubscriberArgs()); - Assert.That(match.Matched, Is.True); - - setAction?.Invoke(ms); - ms.Action = () => receiveAction(); - - try - { - await eso.ReceiveAsync(ees, match.Subscriber!, ed ?? new EventData { Value = new Employee { Id = 1, Name = "Bob" } }, new EventSubscriberArgs()); - Assert.That(exceptionType, Is.Null, "Expected an exception!"); - } - catch (EventSubscriberException esex) - { - Assert.Multiple(() => - { - Assert.That(eventSubscriberException, Is.True, "Should be an EventSubscriberException!"); - Assert.That(esex.IsTransient, Is.EqualTo(isTransient)); - Assert.That(esex.InnerException, Is.Not.Null.And.TypeOf(exceptionType!)); - }); - if (message != null) - Assert.That(esex.Message, Is.Not.Null.And.EqualTo(message)); - } - catch (System.Exception ex) - { - Assert.Multiple(() => - { - Assert.That(eventSubscriberException, Is.False, "Should not be an EventSubscriberException!"); - Assert.That(ex, Is.Not.Null.And.TypeOf(exceptionType!)); - }); - if (message != null) - Assert.That(ex.Message, Is.Not.Null.And.EqualTo(message)); - } - finally - { - // Reset. - ms._UnhandledHandling = ErrorHandling.HandleByHost; - ms._SecurityHandling = ErrorHandling.HandleByHost; - ms._TransientHandling = ErrorHandling.HandleByHost; - ms._NotFoundHandling = ErrorHandling.HandleByHost; - ms._ConcurrencyHandling = ErrorHandling.HandleByHost; - ms._DataConsistencyHandling = ErrorHandling.HandleByHost; - ms._InvalidDataHandling = ErrorHandling.HandleByHost; - } - - var status = ((TestInstrumentation)ees.Instrumentation!).Status; - if (ins is null) - Assert.That(status, Is.Null); - else - Assert.That(status, Is.EqualTo(ins)); - } - - [EventSubscriber("my.hr.employee", "created", "updated")] - [EventSubscriber("my.hr.employee", "tweaked")] - public class ModifySubscriber : SubscriberBase - { - public System.Action Action { get; set; } = () => throw new System.NotImplementedException("Unhandled exception."); - - public override Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) - { - Action(); - return Task.FromResult(Result.Success); - } - - public ErrorHandling _UnhandledHandling = ErrorHandling.HandleByHost; - public ErrorHandling _SecurityHandling = ErrorHandling.HandleByHost; - public ErrorHandling _TransientHandling = ErrorHandling.HandleByHost; - public ErrorHandling _NotFoundHandling = ErrorHandling.HandleByHost; - public ErrorHandling _ConcurrencyHandling = ErrorHandling.HandleByHost; - public ErrorHandling _DataConsistencyHandling = ErrorHandling.HandleByHost; - public ErrorHandling _InvalidDataHandling = ErrorHandling.HandleByHost; - - public override ErrorHandling UnhandledHandling => _UnhandledHandling; - - public override ErrorHandling SecurityHandling => _SecurityHandling; - - public override ErrorHandling TransientHandling => _TransientHandling; - - public override ErrorHandling NotFoundHandling => _NotFoundHandling; - - public override ErrorHandling ConcurrencyHandling => _ConcurrencyHandling; - - public override ErrorHandling DataConsistencyHandling => _DataConsistencyHandling; - - public override ErrorHandling InvalidDataHandling => _InvalidDataHandling; - } - - [EventSubscriber("my.hr.employee", "deleted")] - public class DeleteSubscriber : SubscriberBase - { - public override Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) - { - throw new System.NotImplementedException(); - } - } - - [EventSubscriber("my.hr.employee", "deleted")] - public class DuplicateSubscriber : SubscriberBase - { - public override Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) - { - throw new System.NotImplementedException(); - } - } - - [EventSubscriber("my.hr.other", ExtendedMatchMethod = nameof(IsExtendedMatch))] - public class OthersSubscriber : SubscriberBase - { - public override Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) - { - throw new System.NotImplementedException(); - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Needed by the ExtendedMatchMethod functionality.")] - public static bool IsExtendedMatch(EventData ed, EventSubscriberArgs args) => ed.Key == "KEY"; - } - - public class Employee - { - public int? Id { get; set; } - public string? Name { get; set; } - } - - public class EmployeeEventSub : EventSubscriberBase - { - public EmployeeEventSub(IEventDataConverter eventDataConverter, ExecutionContext executionContext, SettingsBase settings, ILogger logger, EventSubscriberInvoker? eventSubscriberInvoker = null) - : base(eventDataConverter, executionContext, settings, logger, eventSubscriberInvoker) - { - Instrumentation = new TestInstrumentation(); - } - } - - public class TestInstrumentation : EventSubscriberInstrumentationBase - { - public override void Instrument(ErrorHandling? errorHandling = null, Exception? exception = null) => Status += GetInstrumentName("Test", errorHandling, exception); - - public string? Status { get; set; } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/FluentValidation/HttpRequestTest.cs b/tests/CoreEx.Test/Framework/FluentValidation/HttpRequestTest.cs deleted file mode 100644 index 84f989b3..00000000 --- a/tests/CoreEx.Test/Framework/FluentValidation/HttpRequestTest.cs +++ /dev/null @@ -1,66 +0,0 @@ -using CoreEx.AspNetCore.Http; -using CoreEx.FluentValidation; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Models; -using CoreEx.TestFunction.Validators; -using NUnit.Framework; -using System.Net.Http; -using System.Threading.Tasks; -using UnitTestEx; - -namespace CoreEx.Test.Framework.FluentValidation -{ - [TestFixture] - public class HttpRequestTest - { - [Test] - public async Task NoBody_NoValidation() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, ""); - var vr = await hr.ReadAsJsonValueAsync(new Text.Json.JsonSerializer(), valueIsRequired: false, validator: new ProductValidator().Wrap()).ConfigureAwait(false); - Assert.That(vr, Is.Not.Null); - Assert.That(vr.IsValid, Is.True); - } - - [Test] - public async Task NoBody_ValueRequired() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, ""); - var vr = await hr.ReadAsJsonValueAsync(new Text.Json.JsonSerializer(), valueIsRequired: true, validator: new ProductValidator().Wrap()).ConfigureAwait(false); - Assert.That(vr, Is.Not.Null); - Assert.That(vr.IsValid, Is.False); - } - - [Test] - public async Task Value_Error() - { - using var test = FunctionTester.Create(); - var hr = test.CreateJsonHttpRequest(HttpMethod.Get, "", new Product { Id = "B2TF", Name = "DeLorean", Price = 88m }); - var vr = await hr.ReadAsJsonValueAsync(new Text.Json.JsonSerializer(), validator: new ProductValidator().Wrap()).ConfigureAwait(false); - Assert.That(vr, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(vr.IsValid, Is.False); - Assert.That(vr.ValidationException, Is.Not.Null); - }); - - Assert.That(vr.ValidationException, Is.Not.Null.And.TypeOf()); - var vex = (ValidationException)vr.ValidationException!; - Assert.That(vex.Messages, Is.Not.Null); - Assert.That(vex.Messages!, Has.Count.EqualTo(1)); - Assert.That(vex.Messages!.GetMessagesForProperty(nameof(Product.Name))[0].Text, Is.EqualTo("A DeLorean cannot be priced at 88 as that could cause a chain reaction that would unravel the very fabric of the space-time continuum and destroy the entire universe.")); - } - - [Test] - public async Task Value_Success() - { - using var test = FunctionTester.Create(); - var hr = test.CreateJsonHttpRequest(HttpMethod.Get, "", new Product { Id = "B2TF", Name = "DeLorean", Price = 66m }); - var vr = await hr.ReadAsJsonValueAsync(new Text.Json.JsonSerializer(), validator: new ProductValidator().Wrap()).ConfigureAwait(false); - Assert.That(vr, Is.Not.Null); - Assert.That(vr.IsValid, Is.True); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/FluentValidation/MultiValidatorTest.cs b/tests/CoreEx.Test/Framework/FluentValidation/MultiValidatorTest.cs deleted file mode 100644 index edb18bb5..00000000 --- a/tests/CoreEx.Test/Framework/FluentValidation/MultiValidatorTest.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CoreEx.Entities; -using CoreEx.FluentValidation; -using CoreEx.TestFunction.Models; -using CoreEx.TestFunction.Validators; -using CoreEx.Validation; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.FluentValidation -{ - [TestFixture] - public class MultiValidatorTest - { - [Test] - public async Task FluentError() - { - var r = await MultiValidator.Create() - .Add(new ProductValidator().Wrap(), new Product { Id = "B2TF", Name = "DeLorean", Price = 88m }) - .ValidateAsync().ConfigureAwait(false); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages, Has.Count.EqualTo(1)); - }); - Assert.Multiple(() => - { - Assert.That(r.Messages[0].Text, Is.EqualTo("A DeLorean cannot be priced at 88 as that could cause a chain reaction that would unravel the very fabric of the space-time continuum and destroy the entire universe.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Name")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/FluentValidation/ServiceBusTriggerExecutorTest.cs b/tests/CoreEx.Test/Framework/FluentValidation/ServiceBusTriggerExecutorTest.cs deleted file mode 100644 index c26b95b0..00000000 --- a/tests/CoreEx.Test/Framework/FluentValidation/ServiceBusTriggerExecutorTest.cs +++ /dev/null @@ -1,31 +0,0 @@ -using CoreEx.Abstractions; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Functions; -using CoreEx.TestFunction.Models; -using Microsoft.Azure.WebJobs.ServiceBus; -using Moq; -using NUnit.Framework; -using System.Collections.Generic; -using UnitTestEx; - -namespace CoreEx.Test.Framework.FluentValidation -{ - [TestFixture] - public class ServiceBusTriggerExecutorTest - { - [Test] - public void ReceiveAsync_ValidationException_Validation() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(new Product { Id = "Zed", Name = "is dead", Price = 1.99m }); - - test.ServiceBusTrigger() - .Run(s => s.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), ErrorType.ValidationError.ToString(), It.IsAny(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/FluentValidation/ServiceCollectionTest.cs b/tests/CoreEx.Test/Framework/FluentValidation/ServiceCollectionTest.cs deleted file mode 100644 index e134571b..00000000 --- a/tests/CoreEx.Test/Framework/FluentValidation/ServiceCollectionTest.cs +++ /dev/null @@ -1,123 +0,0 @@ -using CoreEx.FluentValidation; -using CoreEx.TestFunction.Models; -using CoreEx.TestFunction.Validators; -using CoreEx.Validation; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; -using System.Threading.Tasks; -using FV = FluentValidation; - -namespace CoreEx.Test.Framework.FluentValidation -{ - public class ServiceCollectionTest - { - [Test] - public async Task ServiceCollectionAdd() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddFluentValidators(alsoRegisterInterfaces: false); - - var sp = sc.BuildServiceProvider(); - - Assert.Multiple(() => - { - Assert.That(sp.GetService(), Is.Not.Null); - Assert.That(sp.GetService>(), Is.Null); - Assert.That(sp.GetService>(), Is.Null); - }); - - var pv = sp.GetRequiredService(); - var cv = pv.Wrap(); - - var rs = await cv.ValidateAsync(new Product()); - Assert.That(rs.HasErrors, Is.True); - - rs = await cv.ValidateAsync(new Product { Id = "XXX", Name = "Blah", Price = 1.5m }); - Assert.That(rs.HasErrors, Is.False); - } - - [Test] - public async Task ServiceCollectionAddWithInterfaces() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddFluentValidators(alsoRegisterInterfaces: true); - - var sp = sc.BuildServiceProvider(); - - Assert.Multiple(() => - { - Assert.That(sp.GetService(), Is.Not.Null); - Assert.That(sp.GetService>(), Is.Not.Null); - Assert.That(sp.GetService>(), Is.Not.Null); - }); - - var iv = sp.GetRequiredService>(); - Assert.That(iv, Is.Not.Null); - - var pv = sp.GetRequiredService(); - var cv = pv.Wrap(); - - var rs = await cv.ValidateAsync(new Product()); - Assert.That(rs.HasErrors, Is.True); - - rs = await cv.ValidateAsync(new Product { Id = "XXX", Name = "Blah", Price = 1.5m }); - Assert.That(rs.HasErrors, Is.False); - } - - [Test] - public void CreateValidator() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddFluentValidators(); - - var sp = sc.BuildServiceProvider(); - - var pv = FluentValidator.Create(sp); - Assert.That(pv, Is.Not.Null); - } - - [Test] - public void CreateValidatorFromInterface() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddFluentValidators(alsoRegisterInterfaces: true); - - var sp = sc.BuildServiceProvider(); - - var pv = FluentValidator.Create>(sp); - Assert.That(pv, Is.Not.Null); - - Assert.That(pv.Wrap(), Is.Not.Null); - } - - [Test] - public async Task InteropTest() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddFluentValidators(); - - var sp = sc.BuildServiceProvider(); - - var rs = await new Product().Validate().Configure(c => c.Mandatory().Interop(FluentValidator.Create(sp).Wrap())).ValidateAsync(); - Assert.That(rs.HasErrors, Is.True); - - rs = await new Product { Id = "XXX", Name = "Blah", Price = 1.5m }.Validate(c => c.Mandatory().Interop(FluentValidator.Create(sp).Wrap())).ValidateAsync(); - Assert.That(rs.HasErrors, Is.False); - } - - [Test] - public async Task InteropFuncTest() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddFluentValidators(); - - var sp = sc.BuildServiceProvider(); - - var rs = await new Product().Validate().Configure(c => c.Mandatory().Interop(FluentValidator.Create(sp).Wrap())).ValidateAsync(); - Assert.That(rs.HasErrors, Is.True); - - rs = await new Product { Id = "XXX", Name = "Blah", Price = 1.5m }.Validate().Configure(c => c.Mandatory().Interop(() => FluentValidator.Create(sp).Wrap())).ValidateAsync(); - Assert.That(rs.HasErrors, Is.False); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Globalization/TextInfoTest.cs b/tests/CoreEx.Test/Framework/Globalization/TextInfoTest.cs deleted file mode 100644 index 2235c3b5..00000000 --- a/tests/CoreEx.Test/Framework/Globalization/TextInfoTest.cs +++ /dev/null @@ -1,22 +0,0 @@ -using CoreEx.Globalization; -using NUnit.Framework; -using System.Globalization; - -namespace CoreEx.Test.Framework.Globalization -{ - [TestFixture] - public class TextInfoTest - { - [Test] - public void ToCasing_None() => Assert.That(CultureInfo.InvariantCulture.TextInfo.ToCasing("AbCd", TextInfoCasing.None), Is.EqualTo("AbCd")); - - [Test] - public void ToCasing_Lower() => Assert.That(CultureInfo.InvariantCulture.TextInfo.ToCasing("AbCd", TextInfoCasing.Lower), Is.EqualTo("abcd")); - - [Test] - public void ToCasing_Upper() => Assert.That(CultureInfo.InvariantCulture.TextInfo.ToCasing("AbCd", TextInfoCasing.Upper), Is.EqualTo("ABCD")); - - [Test] - public void ToCasing_Title() => Assert.That(CultureInfo.InvariantCulture.TextInfo.ToCasing("the qUick BROWN fox.", TextInfoCasing.Title), Is.EqualTo("The Quick BROWN Fox.")); - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Hosting/Work/WorkStateOrchestratorTest.cs b/tests/CoreEx.Test/Framework/Hosting/Work/WorkStateOrchestratorTest.cs deleted file mode 100644 index 63fac744..00000000 --- a/tests/CoreEx.Test/Framework/Hosting/Work/WorkStateOrchestratorTest.cs +++ /dev/null @@ -1,181 +0,0 @@ -using Azure.Data.Tables; -using CoreEx.Azure.Storage; -using CoreEx.Configuration; -using CoreEx.Hosting.Work; -using CoreEx.Test.Framework.Json.Mapping; -using Microsoft.Extensions.Configuration; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using UnitTestEx; - - -namespace CoreEx.Test.Framework.Hosting.Work -{ - [TestFixture] - internal class WorkStateOrchestratorTest - { - private static WorkStateOrchestrator CreateOrchestrator(string persistType) - { - var config = new ConfigurationBuilder().AddInMemoryCollection(new[] { new KeyValuePair(FileWorkStatePersistence.ConfigKey, Path.GetTempPath()) }); - var s = new DefaultSettings(config.Build()); - IWorkStatePersistence p = persistType switch - { - "fs" => new FileWorkStatePersistence(s), - "at" => CreateTableWorkStatePersistence(), - _ => new InMemoryWorkStatePersistence() - }; - - if (p is null) - return null!; - - return new WorkStateOrchestrator(p, s); - } - - private static TableWorkStatePersistence CreateTableWorkStatePersistence() - { - var csn = $"{nameof(BlobAttachmentStorage)}ConnectionString"; - var cs = Environment.GetEnvironmentVariable(csn); - if (string.IsNullOrEmpty(cs)) - return null!; - - var tsc = new TableServiceClient(cs); - return new TableWorkStatePersistence(tsc); - } - - [Test] - public async Task Orchestrate_With_FileSystem() => await Orchestrate(CreateOrchestrator("fs")); - - [Test] - public async Task Orchestrate_With_InMemory() => await Orchestrate(CreateOrchestrator("im")); - - [Test] - public async Task Orchestrate_With_AzureTable() => await Orchestrate(CreateOrchestrator("at")); - - private static async Task Orchestrate(WorkStateOrchestrator o) - { - if (o is null) - { - Assert.Inconclusive("Test cannot run as the environment variable 'TableWorkStatePersistenceConnectionString' is not defined."); - return; - } - - ExecutionContext.Reset(); - ExecutionContext.SetCurrent(new ExecutionContext { UserName = ExecutionContext.EnvironmentUserName }); - - // Clean up before we begin. - await o.Persistence.DeleteAsync("abc", default); - - // Get where not found. - var wr = await o.GetAsync("abc"); - Assert.That(wr, Is.Null); - - // Create work and assert state. - wr = await o.CreateAsync(WorkStateArgs.Create().Adjust(x => x.Id = "abc").Adjust(x => x.Key = "bananas")); - Assert.That(wr, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(wr.Id, Is.EqualTo("abc")); - Assert.That(wr.TypeName, Is.EqualTo(typeof(Person).FullName)); - Assert.That(wr.Status, Is.EqualTo(WorkStatus.Created)); - }); - - ObjectComparer.Assert(wr, await o.GetAsync("abc")); - Assert.That(await o.GetDataAsync("abc", default), Is.Null); - - // Start work and assert state. - wr = await o.StartAsync("abc"); - Assert.That(wr, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(wr.Id, Is.EqualTo("abc")); - Assert.That(wr.Status, Is.EqualTo(WorkStatus.Started)); - }); - - ObjectComparer.Assert(wr, await o.GetAsync("abc")); - Assert.That(await o.GetDataAsync("abc", default), Is.Null); - - // Set to indeterminate and assert state. - wr = await o.IndeterminateAsync("abc", "Not sure!"); - Assert.That(wr, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(wr.Id, Is.EqualTo("abc")); - Assert.That(wr.Status, Is.EqualTo(WorkStatus.Indeterminate)); - Assert.That(wr.Reason, Is.EqualTo("Not sure!")); - }); - - ObjectComparer.Assert(wr, await o.GetAsync("abc")); - Assert.That(await o.GetDataAsync("abc", default), Is.Null); - - // Set the data and assert by getting and comparing. - var p = new Person { Id = "xyz", Name = "John" }; - await o.SetDataAsync("abc", p); - - var p2 = await o.GetDataAsync("abc", default); - Assert.That(p2, Is.Not.Null); - ObjectComparer.Assert(p, await o.GetDataAsync("abc")); - - // Set to complete and assert state. - wr = await o.CompleteAsync("abc"); - Assert.That(wr, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(wr.Id, Is.EqualTo("abc")); - Assert.That(wr.Status, Is.EqualTo(WorkStatus.Completed)); - Assert.That(wr.Reason, Is.Null); - }); - - ObjectComparer.Assert(wr, await o.GetAsync("abc")); - - // Check different type; but same id. Confirms same type as extra pre-caution! - wr = await o.GetAsync("other", "abc"); - Assert.That(wr, Is.Null); - - // Check different username; but same id. Confirms same user as extra/extra pre-caution! - ExecutionContext.Reset(); - ExecutionContext.SetCurrent(new ExecutionContext { UserName = "other" }); - wr = await o.GetAsync("abc"); - Assert.That(wr, Is.Null); - - // Check no username set; but same id. Confirms same user as extra/extra/extra pre-caution! - ExecutionContext.Reset(); - wr = await o.GetAsync("abc"); - Assert.That(wr, Is.Null); - } - - [Test] - public async Task Orchestrate_LargeData_With_FileSystem() => await Orchestrate_LargeData(CreateOrchestrator("fs")); - - [Test] - public async Task Orchestrate_LargeData_With_InMemory() => await Orchestrate_LargeData(CreateOrchestrator("im")); - - [Test] - public async Task Orchestrate_LargeData_With_AzureTable() => await Orchestrate_LargeData(CreateOrchestrator("at")); - - private static async Task Orchestrate_LargeData(WorkStateOrchestrator o) - { - if (o is null) - { - Assert.Inconclusive("Test cannot run as the environment variable 'TableWorkStatePersistenceConnectionString' is not defined."); - return; - } - - // Clean up before we begin. - await o.Persistence.DeleteAsync("large", default); - - // Create the work. - var wr = await o.CreateAsync(WorkStateArgs.Create().Adjust(x => x.Id = "large")); - Assert.That(wr, Is.Not.Null); - - // Set large data and assert by getting and comparing. - var ls = new string('x', 959998); - await o.SetDataAsync("large", ls); - - var ls2 = await o.GetDataAsync("large", default); - Assert.That(ls2, Is.EqualTo(ls)); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Http/HttpRequestOptionsTest.cs b/tests/CoreEx.Test/Framework/Http/HttpRequestOptionsTest.cs deleted file mode 100644 index b1c5ff08..00000000 --- a/tests/CoreEx.Test/Framework/Http/HttpRequestOptionsTest.cs +++ /dev/null @@ -1,183 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Http; -using NUnit.Framework; -using System.Linq; -using System.Net.Http; -using HttpRequestOptions = CoreEx.Http.HttpRequestOptions; - -namespace CoreEx.Test.Framework.Http -{ - [TestFixture] - public class HttpRequestOptionsTest - { - [Test] - public void IncludeAndExcludeFields() - { - var hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - var ro = new HttpRequestOptions().Include("fielda", "fieldb").Exclude("fieldc"); - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$fields=fielda,fieldb&$exclude=fieldc")); - - hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing?id=123"); - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?id=123&$fields=fielda,fieldb&$exclude=fieldc")); - - hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing?id=123"); - ro.Exclude(""); - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?id=123&$fields=fielda,fieldb&$exclude=fieldc,%3capp%26+le%3e")); - } - - [Test] - public void Paging() - { - var hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - var ro = HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(20, 25)); - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$skip=20&$take=25")); - - hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - ro = HttpRequestOptions.Create(PagingArgs.CreateSkipAndTake(20, 25, true)); - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$skip=20&$take=25&$count=true")); - - hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - ro = HttpRequestOptions.Create(PagingArgs.CreatePageAndSize(2, 25)); - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$page=2&$size=25")); - - hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - ro = HttpRequestOptions.Create(PagingArgs.CreatePageAndSize(2, 25, true)); - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$page=2&$size=25&$count=true")); - } - - [Test] - public void ETag() - { - var hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - var ro = new HttpRequestOptions() { ETag = "abc" }; - hr.ApplyRequestOptions(ro); - Assert.Multiple(() => - { - Assert.That(hr.Headers.IfMatch, Is.Empty); - Assert.That(hr.Headers.IfNoneMatch, Has.Count.EqualTo(1)); - }); - Assert.That(hr.Headers.IfNoneMatch.First().Tag, Is.EqualTo("\"abc\"")); - - hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - ro = new HttpRequestOptions() { ETag = "\"abc\"" }; - hr.ApplyRequestOptions(ro); - Assert.Multiple(() => - { - Assert.That(hr.Headers.IfMatch, Is.Empty); - Assert.That(hr.Headers.IfNoneMatch, Has.Count.EqualTo(1)); - }); - Assert.That(hr.Headers.IfNoneMatch.First().Tag, Is.EqualTo("\"abc\"")); - - hr = new HttpRequestMessage(HttpMethod.Head, "https://unittest/testing"); - ro = new HttpRequestOptions() { ETag = "abc" }; - hr.ApplyRequestOptions(ro); - Assert.Multiple(() => - { - Assert.That(hr.Headers.IfMatch, Is.Empty); - Assert.That(hr.Headers.IfNoneMatch, Has.Count.EqualTo(1)); - }); - Assert.That(hr.Headers.IfNoneMatch.First().Tag, Is.EqualTo("\"abc\"")); - - hr = new HttpRequestMessage(HttpMethod.Post, "https://unittest/testing"); - ro = new HttpRequestOptions() { ETag = "abc" }; - hr.ApplyRequestOptions(ro); - Assert.Multiple(() => - { - Assert.That(hr.Headers.IfMatch, Has.Count.EqualTo(1)); - Assert.That(hr.Headers.IfNoneMatch, Is.Empty); - }); - Assert.That(hr.Headers.IfMatch.First().Tag, Is.EqualTo("\"abc\"")); - - hr = new HttpRequestMessage(HttpMethod.Put, "https://unittest/testing"); - ro = new HttpRequestOptions() { ETag = "abc" }; - hr.ApplyRequestOptions(ro); - Assert.Multiple(() => - { - Assert.That(hr.Headers.IfMatch, Has.Count.EqualTo(1)); - Assert.That(hr.Headers.IfNoneMatch, Is.Empty); - }); - Assert.That(hr.Headers.IfMatch.First().Tag, Is.EqualTo("\"abc\"")); - - hr = new HttpRequestMessage(HttpMethod.Patch, "https://unittest/testing"); - ro = new HttpRequestOptions() { ETag = "abc" }; - hr.ApplyRequestOptions(ro); - Assert.Multiple(() => - { - Assert.That(hr.Headers.IfMatch, Has.Count.EqualTo(1)); - Assert.That(hr.Headers.IfNoneMatch, Is.Empty); - }); - Assert.That(hr.Headers.IfMatch.First().Tag, Is.EqualTo("\"abc\"")); - - hr = new HttpRequestMessage(HttpMethod.Delete, "https://unittest/testing"); - ro = new HttpRequestOptions() { ETag = "abc" }; - hr.ApplyRequestOptions(ro); - Assert.Multiple(() => - { - Assert.That(hr.Headers.IfMatch, Has.Count.EqualTo(1)); - Assert.That(hr.Headers.IfNoneMatch, Is.Empty); - }); - Assert.That(hr.Headers.IfMatch.First().Tag, Is.EqualTo("\"abc\"")); - } - - [Test] - public void IncludeText() - { - var hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing?fruit=apple"); - var ro = new HttpRequestOptions() { IncludeText = true }; - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?fruit=apple&$text=true")); - } - - [Test] - public void IncludeInActive() - { - var hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - var ro = new HttpRequestOptions() { IncludeInactive = true }; - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$inactive=true")); - } - - [Test] - public void UrlQueryString() - { - var hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - var ro = new HttpRequestOptions() { UrlQueryString = "fruit=apple" }; - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?fruit=apple")); - - hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing?fruit=banana"); - ro = new HttpRequestOptions() { UrlQueryString = "fruit=apple" }; - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?fruit=banana&fruit=apple")); - } - - [Test] - public void QueryArgsQueryString() - { - QueryArgs qa = "name eq 'bob'"; - var hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - var ro = new HttpRequestOptions() { IncludeInactive = true }.Include("name", "text"); - ro = ro.WithQuery(qa); - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$filter=name+eq+%27bob%27&$fields=name,text&$inactive=true")); - } - - [Test] - public void QueryArgsQueryStringWithIncludeText() - { - var qa = QueryArgs.Create("name eq 'bob'").IncludeText(); - var hr = new HttpRequestMessage(HttpMethod.Get, "https://unittest/testing"); - var ro = new HttpRequestOptions() { IncludeInactive = true }.Include("name", "text"); - ro = ro.WithQuery(qa); - hr.ApplyRequestOptions(ro); - Assert.That(hr.RequestUri!.AbsoluteUri, Is.EqualTo("https://unittest/testing?$filter=name+eq+%27bob%27&$fields=name,text&$text=true&$inactive=true")); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Http/HttpResultTest.cs b/tests/CoreEx.Test/Framework/Http/HttpResultTest.cs deleted file mode 100644 index eacdff25..00000000 --- a/tests/CoreEx.Test/Framework/Http/HttpResultTest.cs +++ /dev/null @@ -1,48 +0,0 @@ -using CoreEx.Http; -using NUnit.Framework; -using System; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Http -{ - [TestFixture] - public class HttpResultTest - { - [Test] - public async Task Create_InternalException() - { - var r = new System.Net.Http.HttpResponseMessage(HttpStatusCode.OK) { Content = new System.Net.Http.StringContent("[]") }; - var hr = await HttpResult.CreateAsync(r); - - Assert.That(hr.IsSuccess, Is.False); - Assert.Throws(() => hr.ThrowOnError()); - Assert.Throws(() => _ = hr.Value); - - var rr = hr.ToResult(); - Assert.Multiple(() => - { - Assert.That(rr.IsSuccess, Is.False); - Assert.That(rr.Error, Is.TypeOf()); - }); - } - - [Test] - public async Task Messages() - { - var r = new HttpResponseMessage(System.Net.HttpStatusCode.OK); - r.Headers.Add("x-messages", """[{"type":"Warning","text":"Please renew licence."}]"""); - - var hr = await HttpResult.CreateAsync(r); - Assert.That(hr, Is.Not.Null); - Assert.That(hr.Messages, Has.Count.EqualTo(1)); - Assert.Multiple(() => - { - Assert.That(hr.Messages[0].Type, Is.EqualTo(CoreEx.Entities.MessageType.Warning)); - Assert.That(hr.Messages[0].Text, Is.EqualTo("Please renew licence.")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Http/TypedHttpClientBaseTest.cs b/tests/CoreEx.Test/Framework/Http/TypedHttpClientBaseTest.cs deleted file mode 100644 index 2b1959fa..00000000 --- a/tests/CoreEx.Test/Framework/Http/TypedHttpClientBaseTest.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using CoreEx.Http; -using CoreEx.RefData; -using NUnit.Framework; -using HttpRequestOptions = CoreEx.Http.HttpRequestOptions; - -namespace CoreEx.Test.Framework.Http -{ - [TestFixture] - public class TypedHttpClientBaseTest - { - [Test] - public void UpdateRequestUri_FormatArgNone() - { - var uri = new TestHttpClient().VerifyUri("product/88"); - Assert.That(uri, Is.EqualTo("/product/88")); - } - - [Test] - public void UpdateRequestUri_FormatArgNull() - { - var uri = new TestHttpClient().VerifyUri("product/{id}"); - Assert.That(uri, Is.EqualTo("/product/")); - - uri = new TestHttpClient().VerifyUri("product/{id}/other"); - Assert.That(uri, Is.EqualTo("/product//other")); - - uri = new TestHttpClient().VerifyUri("product/{}"); - Assert.That(uri, Is.EqualTo("/product/")); - - uri = new TestHttpClient().VerifyUri("product/{}/other"); - Assert.That(uri, Is.EqualTo("/product//other")); - } - - [Test] - public void UpdateRequestUri_FormatArgValue() - { - var arg = new HttpArg("id", 88); - var uri = new TestHttpClient().VerifyUri("product/{id}", null, arg); - Assert.That(uri, Is.EqualTo("/product/88")); - - uri = new TestHttpClient().VerifyUri("product/{id}/other", null, arg); - Assert.That(uri, Is.EqualTo("/product/88/other")); - - var arg2 = new HttpArg("id", "&;"); - uri = new TestHttpClient().VerifyUri("product/{id}", null, arg2); - Assert.That(uri, Is.EqualTo("/product/%26%3B")); - - arg2 = new HttpArg("id", "abc"); - uri = new TestHttpClient().VerifyUri("product/{id}/other", null, arg2); - Assert.That(uri, Is.EqualTo("/product/abc/other")); - } - - [Test] - public void UpdateRequestUri_QueryArg() - { - var arg = new HttpArg("id", 88); - var uri = new TestHttpClient().VerifyUri("product", null, arg); - Assert.That(uri, Is.EqualTo("/product?id=88")); - - var arg2 = new HttpArg("id", "abc"); - uri = new TestHttpClient().VerifyUri("product", null, arg2); - Assert.That(uri, Is.EqualTo("/product?id=abc")); - - var arg3 = new HttpArg("id", new DateTime(2022, 01, 31, 08, 45, 59, DateTimeKind.Utc)); - uri = new TestHttpClient().VerifyUri("product", null, arg3); - Assert.That(uri, Is.EqualTo("/product?id=2022-01-31T08%3a45%3a59.0000000Z")); - } - - [Test] - public void UpdateRequestUri_QueryArgs() - { - var uri = new TestHttpClient().VerifyUri("product", null, - new HttpArg("id", 88), new HttpArg("text", "bananas"), new HttpArg("date", new DateTime(2020, 01, 01, 11, 59, 58, DateTimeKind.Utc)), - new HttpArg("body", "in_the_body_only", HttpArgType.FromBody), - new HttpArg("id2", null), new HttpArg("type", HttpArgType.FromUri), new HttpArg("char", new char[] { 'a', 'b', 'c' }), new HttpArg("gender", new Gender { Id = 1, Code = "F", Text = "Female" })); - - Assert.That(uri, Is.EqualTo("/product?id=88&text=bananas&date=2020-01-01T11%3a59%3a58.0000000Z&type=FromUri&char=a&char=b&char=c&gender=F")); - - uri = new TestHttpClient().VerifyUri("product/{id}", null, - new HttpArg("id", 88), new HttpArg("text", "bananas"), new HttpArg("date", new DateTime(2020, 01, 01, 11, 59, 58, DateTimeKind.Utc)), - new HttpArg("body", "in_the_body_only", HttpArgType.FromBody), - new HttpArg("id2", null), new HttpArg("type", HttpArgType.FromUri), new HttpArg("char", new char[] { 'a', 'b', 'c' }), new HttpArg("gender", new Gender { Id = 1, Code = "F", Text = "Female" })); - - Assert.That(uri, Is.EqualTo("/product/88?text=bananas&date=2020-01-01T11%3a59%3a58.0000000Z&type=FromUri&char=a&char=b&char=c&gender=F")); - } - - [Test] - public void UpdateRequestUri_QueryArg_Class() - { - var arg = new HttpArg("person", new Person { Id = 88, Name = "gary", Date = new DateTime(2020, 01, 01, 11, 59, 58, DateTimeKind.Utc), Amount = -123.85m, Happy = true }); - var uri = new TestHttpClient().VerifyUri("people", null, arg); - Assert.That(uri, Is.EqualTo("/people")); - - arg = new HttpArg("person", new Person { Id = 88, Name = "gary", Date = new DateTime(2020, 01, 01, 11, 59, 58, DateTimeKind.Utc), Amount = -123.85m, Happy = true, Codes = new List { 0, 1, 2 } }, HttpArgType.FromUriUseProperties); - uri = new TestHttpClient().VerifyUri("people", null, arg); - Assert.That(uri, Is.EqualTo("/people?id=88&name=gary&date=2020-01-01T11%3a59%3a58.0000000Z&amount=-123.85&happy=true&codes=0&codes=1&codes=2")); - - arg = new HttpArg("person", new Person { Id = 88, Name = "*gary*", Date = new DateTime(2020, 01, 01, 11, 59, 58, DateTimeKind.Utc), Amount = -123.85m, Happy = true, Codes = new List { 0, 1, 2 } }, HttpArgType.FromUriUseProperties); - uri = new TestHttpClient().VerifyUri("people", null, arg); - Assert.That(uri, Is.EqualTo("/people?id=88&name=*gary*&date=2020-01-01T11%3a59%3a58.0000000Z&amount=-123.85&happy=true&codes=0&codes=1&codes=2")); - - arg = new HttpArg("person", new Person { Id = 88, Name = "gary", Date = new DateTime(2020, 01, 01, 11, 59, 58, DateTimeKind.Unspecified), Amount = -123.85m, Happy = true, Codes = new List { 0, 1, 2 } }, HttpArgType.FromUriUsePropertiesAndPrefix); - uri = new TestHttpClient().VerifyUri("people", null, arg); - Assert.That(uri, Is.EqualTo("/people?person.id=88&person.name=gary&person.date=2020-01-01T11%3a59%3a58.0000000&person.amount=-123.85&person.happy=true&person.codes=0&person.codes=1&person.codes=2")); - } - - [Test] - public void UpdateRequestUri_QueryArg_With_IFormattable() - { - var arg = new HttpArg("coordinates", new Coordinates()); - var uri = new TestHttpClient().VerifyUri("map", null, arg); - Assert.That(uri, Is.EqualTo("/map?coordinates=1%2c2")); - - var arg2 = new HttpArg("ignored", new CoordinatesArgs(), HttpArgType.FromUriUseProperties); - var uri2 = new TestHttpClient().VerifyUri("map", null, arg2); - Assert.That(uri2, Is.EqualTo("/map?coordinates=1%2c2")); - } - - public class Person - { - public int Id { get; set; } - public string? Name { get; set; } - public DateTime Date { get; set; } - public decimal? Amount { get; set; } - public bool Happy { get; set; } - public List? Codes { get; set; } - } - - public class Gender : ReferenceDataBase { } - - public class Coordinates : IFormattable - { - public int Long { get; set; } = 1; - public int Lat { get; set; } = 2; - - public string ToString(string? format, IFormatProvider? formatProvider) => $"{Long},{Lat}"; - } - - public class CoordinatesArgs - { - public Coordinates? Coordinates { get; set; } = new Coordinates(); - } - } - - public class TestHttpClient : TypedHttpClientBase - { - public TestHttpClient() : base(new HttpClient { BaseAddress = new Uri("https://unittest") }, new CoreEx.Text.Json.JsonSerializer()) { } - - public string VerifyUri(string requestUri, HttpRequestOptions? requestOptions = null, params IHttpArg[] args) - { - var request = CreateRequestAsync(HttpMethod.Post, requestUri, requestOptions, args).Result; - return request.RequestUri!.ToString(); - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Http/TypedHttpClientCoreTest.cs b/tests/CoreEx.Test/Framework/Http/TypedHttpClientCoreTest.cs deleted file mode 100644 index e285f66c..00000000 --- a/tests/CoreEx.Test/Framework/Http/TypedHttpClientCoreTest.cs +++ /dev/null @@ -1,831 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using CoreEx.Abstractions; -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.Http.Extended; -using CoreEx.Mapping; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Models; -using NUnit.Framework; -using UnitTestEx; -using HttpRequestOptions = CoreEx.Http.HttpRequestOptions; - -namespace CoreEx.Test.Framework.Http -{ - [TestFixture] - public class TypedHttpClientCoreTest - { - [Test] - public void Get_ValidationError() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.With("{\"Name\":[\"'Name' must not be empty.\"]}", HttpStatusCode.BadRequest); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.GetAsync("test")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.False); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); - Assert.That(r.Result.ErrorCode, Is.Null); - Assert.That(r.Result.ErrorType, Is.Null); - }); - - try - { - r.Result.ThrowOnError(); - Assert.Fail("Should not get here!"); - } - catch (ValidationException vex) - { - Assert.That(vex.Messages, Is.Not.Null); - Assert.That(vex.Messages!, Has.Count.EqualTo(1)); - Assert.Multiple(() => - { - Assert.That(vex.Messages[0].Property, Is.EqualTo("Name")); - Assert.That(vex.Messages[0].Text, Is.EqualTo("'Name' must not be empty.")); - Assert.That(vex.Messages[0].Type, Is.EqualTo(MessageType.Error)); - }); - } - catch - { - Assert.Fail("Should not get here!"); - } - - Assert.Throws(() => r.Result.ThrowOnError(false)); - - mcf.VerifyAll(); - } - - [Test] - public void Get_ValidationError_NoMessages() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.With("Serious error occurred.", HttpStatusCode.BadRequest, r => - { - r.Headers.Add(HttpConsts.ErrorTypeHeaderName, ErrorType.ValidationError.ToString()); - r.Headers.Add(HttpConsts.ErrorCodeHeaderName, ((int)ErrorType.ValidationError).ToString()); - }); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.GetAsync("test")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.False); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); - Assert.That(r.Result.ErrorCode, Is.EqualTo(1)); - Assert.That(r.Result.ErrorType, Is.EqualTo("ValidationError")); - Assert.That(r.Result.Content, Is.EqualTo("Serious error occurred.")); - }); - - var vex = Assert.Throws(() => r.Result.ThrowOnError()); - Assert.That(vex!.Message, Is.EqualTo("Serious error occurred.")); - Assert.Throws(() => r.Result.ThrowOnError(false)); - - mcf.VerifyAll(); - } - - [Test] - public void Get_BusinessError() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.With("Serious error occurred.", HttpStatusCode.BadRequest, r => - { - r.Headers.Add(HttpConsts.ErrorTypeHeaderName, ErrorType.BusinessError.ToString()); - r.Headers.Add(HttpConsts.ErrorCodeHeaderName, ((int)ErrorType.BusinessError).ToString()); - }); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.GetAsync("test")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.False); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); - Assert.That(r.Result.ErrorCode, Is.EqualTo(2)); - Assert.That(r.Result.ErrorType, Is.EqualTo("BusinessError")); - Assert.That(r.Result.Content, Is.EqualTo("Serious error occurred.")); - }); - - Assert.Throws(() => r.Result.ThrowOnError()); - Assert.Throws(() => r.Result.ThrowOnError(false)); - - mcf.VerifyAll(); - } - - [Test] - public void Get_Success() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.With("test-content"); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.GetAsync("test")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(r.Result.Content, Is.EqualTo("test-content")); - }); - - mcf.VerifyAll(); - } - - [Test] - public void Get_SuccessWithResponse() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "product/abc").Respond.WithJson(new Product { Id = "abc", Name = "banana", Price = 0.99m }); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.GetAsync("product/abc")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - }); - ObjectComparer.Assert(new Product { Id = "abc", Name = "banana", Price = 0.99m }, r.Result.Value); - - mcf.VerifyAll(); - } - - [Test] - public void Get_SuccessWithCollectionResult() - { - var pc = new ProductCollection { new Product { Id = "abc", Name = "banana", Price = 0.99m }, new Product { Id = "def", Name = "apple", Price = 0.49m } }; - - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "product").Respond.WithJson(pc); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.GetAsync("product")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - }); - ObjectComparer.Assert(new ProductCollectionResult { Items = pc }, r.Result.Value); - - mcf.VerifyAll(); - } - - [Test] - public void Get_SuccessWithCollectionResultAndPaging() - { - var pc = new ProductCollection { new Product { Id = "abc", Name = "banana", Price = 0.99m }, new Product { Id = "def", Name = "apple", Price = 0.49m } }; - - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "product").Respond.WithJson(pc, HttpStatusCode.OK, r => - { - r.Headers.Add(HttpConsts.PagingSkipHeaderName, "100"); - r.Headers.Add(HttpConsts.PagingTakeHeaderName, "25"); - r.Headers.Add(HttpConsts.PagingTotalCountHeaderName, "1000"); - }); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.GetAsync("product")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - }); - ObjectComparer.Assert(new ProductCollectionResult { Items = pc, Paging = new PagingResult(PagingArgs.CreateSkipAndTake(100, 25), 1000) }, r.Result.Value); - - mcf.VerifyAll(); - } - - [Test] - public void Get_SuccessWithCollectionResultAndTokenPaging() - { - PagingArgs.IsTokenSupported = true; - var pc = new ProductCollection { new Product { Id = "abc", Name = "banana", Price = 0.99m }, new Product { Id = "def", Name = "apple", Price = 0.49m } }; - - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "product").Respond.WithJson(pc, HttpStatusCode.OK, r => - { - r.Headers.Add(HttpConsts.PagingTokenHeaderName, "token"); - r.Headers.Add(HttpConsts.PagingTakeHeaderName, "25"); - r.Headers.Add(HttpConsts.PagingTotalCountHeaderName, "1000"); - }); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.GetAsync("product")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - }); - ObjectComparer.Assert(new ProductCollectionResult { Items = pc, Paging = new PagingResult(PagingArgs.CreateTokenAndTake("token", 25), 1000) }, r.Result.Value); - - mcf.VerifyAll(); - } - - [TearDown] - public void TearDown() => PagingArgs.IsTokenSupported = false; - - [Test] - public void Post_Success() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "test").Respond.With("test-content"); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.PostAsync("test")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(r.Result.Content, Is.EqualTo("test-content")); - }); - - mcf.VerifyAll(); - } - - [Test] - public void Post_SuccessWithRequest() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "test").WithJsonBody(new Product { Id = "abc", Name = "banana", Price = 0.99m }).Respond.With("test-content"); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.PostAsync("test", new Product { Id = "abc", Name = "banana", Price = 0.99m })) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(r.Result.Content, Is.EqualTo("test-content")); - }); - - mcf.VerifyAll(); - } - - [Test] - public void Post_SuccessWithResponse() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "test").Respond.WithJson(new Product { Id = "abc", Name = "banana", Price = 0.99m }); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.PostAsync("test")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - }); - ObjectComparer.Assert(new Product { Id = "abc", Name = "banana", Price = 0.99m }, r.Result.Value); - - mcf.VerifyAll(); - } - - [Test] - public void Post_SuccessWithRequestResponse() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "test").WithJsonBody(new BackendProduct { Code = "def", Description = "apple", RetailPrice = 0.49m }).Respond.WithJson(new Product { Id = "abc", Name = "banana", Price = 0.99m }); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.PostAsync("test", new BackendProduct { Code = "def", Description = "apple", RetailPrice = 0.49m })) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - }); - ObjectComparer.Assert(new Product { Id = "abc", Name = "banana", Price = 0.99m }, r.Result.Value); - - mcf.VerifyAll(); - } - - [Test] - public void Put_SuccessWithRequest() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Put, "test").WithJsonBody(new Product { Id = "abc", Name = "banana", Price = 0.99m }).Respond.With("test-content"); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.PutAsync("test", new Product { Id = "abc", Name = "banana", Price = 0.99m })) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(r.Result.Content, Is.EqualTo("test-content")); - }); - - mcf.VerifyAll(); - } - - [Test] - public void Put_SuccessWithRequestResponse() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Put, "test").WithJsonBody(new BackendProduct { Code = "def", Description = "apple", RetailPrice = 0.49m }).Respond.WithJson(new Product { Id = "abc", Name = "banana", Price = 0.99m }); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.PutAsync("test", new BackendProduct { Code = "def", Description = "apple", RetailPrice = 0.49m })) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - }); - ObjectComparer.Assert(new Product { Id = "abc", Name = "banana", Price = 0.99m }, r.Result.Value); - - mcf.VerifyAll(); - } - - [Test] - public void Delete_Success() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Delete, "test/1").Respond.With(HttpStatusCode.OK); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.DeleteAsync("test/1")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(r.Result.Content, Is.Null); - }); - - mcf.VerifyAll(); - } - - [Test] - public void Head_Success() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Head, "test/1").Respond.With(HttpStatusCode.OK); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.HeadAsync("test/1")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(r.Result.Content, Is.Null); - }); - - mcf.VerifyAll(); - } - - [Test] - public void Head_Success_QueryStringOnly() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Head, "?name=bob").Respond.With(HttpStatusCode.OK); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.HeadAsync("?name=bob")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(r.Result.Content, Is.Null); - }); - - mcf.VerifyAll(); - } - - [Test] - public void Head_Success_QueryStringAndRequestOptions() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Head, "?name=bob&$text=true").Respond.With(HttpStatusCode.OK); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.HeadAsync("?name=bob", new HttpRequestOptions { IncludeText = true })) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(r.Result.Content, Is.Null); - }); - - mcf.VerifyAll(); - } - - [Test] - public void Patch_SuccessMergePatch() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Patch, "test/1").WithBody("{\"name\":\"jenny\"}", HttpConsts.MergePatchMediaTypeName).Respond.With(HttpStatusCode.OK); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.PatchAsync("test/1", HttpPatchOption.MergePatch, "{\"name\":\"jenny\"}")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(r.Result.Content, Is.Null); - }); - - mcf.VerifyAll(); - } - - [Test] - public void Patch_SuccessJsonPatch() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Patch, "test/1").WithBody("{\"name\":\"jenny\"}", HttpConsts.JsonPatchMediaTypeName).Respond.With(HttpStatusCode.OK); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .Type() - .Run(f => f.PatchAsync("test/1", HttpPatchOption.JsonPatch, "{\"name\":\"jenny\"}")) - .AssertSuccess(); - - Assert.Multiple(() => - { - Assert.That(r.Result.IsSuccess, Is.True); - Assert.That(r.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(r.Result.Content, Is.Null); - }); - - mcf.VerifyAll(); - } - - [Test] - public void InternalServerErrorAsTransient() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "test").WithJsonBody(new { ClassName = "Retry" }).Respond.With(HttpStatusCode.InternalServerError); - - using var test = FunctionTester.Create(); - var r = test.ConfigureServices(sc => mcf.Replace(sc)) - .Type() - .Run(f => f.ThrowTransientException().PostAsync("test", new { ClassName = "Retry" } )) - .AssertException(); - - mcf.VerifyAll(); - } - - [Test] - public void ServiceUnavailableAsTransient() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "test").WithJsonBody(new { ClassName = "Retry" }).Respond.With(HttpStatusCode.ServiceUnavailable); - - using var test = FunctionTester.Create(); - var r = test.ConfigureServices(sc => mcf.Replace(sc)) - .Type() - .Run(f => f.ThrowTransientException().PostAsync("test", new { ClassName = "Retry" })) - .AssertException(); - - mcf.VerifyAll(); - } - - [Test] - public void RequestTimeoutAsTransient() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "test").WithJsonBody(new { ClassName = "Retry" }).Respond.With(HttpStatusCode.RequestTimeout); - - using var test = FunctionTester.Create(); - var r = test.ConfigureServices(sc => mcf.Replace(sc)) - .Type() - .Run(f => f.ThrowTransientException().PostAsync("test", new { ClassName = "Retry" })) - .AssertException(); - - mcf.VerifyAll(); - } - - [Test] - public void TooManyRequestsAsTransient() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "test").WithJsonBody(new { ClassName = "Retry" }).Respond.With(HttpStatusCode.TooManyRequests); - - using var test = FunctionTester.Create(); - var r = test.ConfigureServices(sc => mcf.Replace(sc)) - .Type() - .Run(f => f.ThrowTransientException().PostAsync("test", new { ClassName = "Retry" })) - .AssertException(); - - mcf.VerifyAll(); - } - - [Test] - public void SocketExceptionAsTransient() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "test").WithJsonBody(new { ClassName = "Retry" }).Respond.With(HttpStatusCode.TooManyRequests, _ => throw new System.Net.Sockets.SocketException()); - - using var test = FunctionTester.Create(); - var r = test.ConfigureServices(sc => mcf.Replace(sc)) - .Type() - .Run(f => f.ThrowTransientException().PostAsync("test", new { ClassName = "Retry" })) - .AssertException(); - - mcf.VerifyAll(); - } - - [Test] - public async Task TypedHttpClientInstance() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.With("test-content"); - - var thc = mcf.GetHttpClient("Backend")!.CreateTypedClient(onBeforeRequest: (req, ct) => { req.Headers.MaxForwards = 88; return Task.CompletedTask; }); - var res = await thc.GetAsync("test"); - - Assert.That(res, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(res.IsSuccess, Is.True); - Assert.That(res.Content, Is.EqualTo("test-content")); - Assert.That(res.Request!.Headers.MaxForwards, Is.EqualTo(88)); - }); - - mcf.VerifyAll(); - } - - [Test] - public void OnBeforeRequest_Option() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.With("test-content"); - - using var test = FunctionTester.Create(); - var res = test.ConfigureServices(sc => mcf.Replace(sc)) - .Type() - .Run(f => f.OnBeforeRequest((req, ct) => { req.Headers.MaxForwards = 88; return Task.CompletedTask; }).GetAsync("test")) - .AssertSuccess() - .Result; - - Assert.That(res, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(res.IsSuccess, Is.True); - Assert.That(res.Content, Is.EqualTo("test-content")); - Assert.That(res.Request!.Headers.MaxForwards, Is.EqualTo(88)); - }); - - mcf.VerifyAll(); - } - - [Test] - public async Task TypedHttpClientInstance_OnConfiguration() - { - try - { - TypedMappedHttpClient.OnDefaultOptionsConfiguration = null; - TypedHttpClient.OnDefaultOptionsConfiguration = x => x.EnsureSuccess(); - - Assert.Multiple(() => - { - Assert.That(TypedHttpClient.OnDefaultOptionsConfiguration, Is.Not.Null); - Assert.That(TypedMappedHttpClient.OnDefaultOptionsConfiguration, Is.Null); - }); - - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.With("test-content"); - - var thc = mcf.GetHttpClient("Backend")!.CreateTypedClient(onBeforeRequest: (req, ct) => { req.Headers.MaxForwards = 88; return Task.CompletedTask; }); - - Assert.That(thc.SendOptions.ShouldEnsureSuccess, Is.True); - - var res = await thc.GetAsync("test"); - - Assert.That(res, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(res.IsSuccess, Is.True); - Assert.That(res.Content, Is.EqualTo("test-content")); - Assert.That(res.Request!.Headers.MaxForwards, Is.EqualTo(88)); - }); - - mcf.VerifyAll(); - } - finally - { - TypedHttpClient.OnDefaultOptionsConfiguration = null; - } - } - - [Test] - public async Task NullOnNotFound() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.WithSequence(s => - { - s.Respond().With(HttpStatusCode.NotFound); - s.Respond().With("1"); - s.Respond().With("2"); - }); - - var thc = mcf.GetHttpClient("Backend")!.CreateTypedClient(); - var res = await thc.GetAsync("test"); - - Assert.That(res, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(res.IsSuccess, Is.False); - Assert.That(res.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); - Assert.That(res.ErrorType, Is.Null); - Assert.That(res.ErrorCode, Is.Null); - Assert.That(res.WillResultInNullAsNotFound, Is.False); - }); - Assert.Throws(() => _ = res.Value); - - res.NullOnNotFoundResponse = true; - Assert.Multiple(() => - { - Assert.That(res.IsSuccess, Is.True); - Assert.That(res.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); - Assert.That(res.ErrorType, Is.Null); - Assert.That(res.ErrorCode, Is.Null); - Assert.That(res.WillResultInNullAsNotFound, Is.True); - Assert.That(res.Value, Is.EqualTo(0)); - }); - - res = await thc.GetAsync("test"); - Assert.Multiple(() => - { - Assert.That(res.NullOnNotFoundResponse, Is.False); - Assert.That(res.IsSuccess, Is.True); - Assert.That(res.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(res.ErrorType, Is.Null); - Assert.That(res.ErrorCode, Is.Null); - Assert.That(res.WillResultInNullAsNotFound, Is.False); - Assert.That(res.Value, Is.EqualTo(1)); - }); - - res = await thc.NullOnNotFound().GetAsync("test"); - Assert.Multiple(() => - { - Assert.That(res.NullOnNotFoundResponse, Is.True); - Assert.That(res.IsSuccess, Is.True); - Assert.That(res.StatusCode, Is.EqualTo(HttpStatusCode.OK)); - Assert.That(res.ErrorType, Is.Null); - Assert.That(res.ErrorCode, Is.Null); - Assert.That(res.WillResultInNullAsNotFound, Is.False); - Assert.That(res.Value, Is.EqualTo(2)); - }); - } - - [Test] - public async Task ToResult() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.WithSequence(s => - { - s.Respond().With(HttpStatusCode.NotFound); - s.Respond().With("no-not-happy", HttpStatusCode.NotAcceptable); - s.Respond().With(HttpStatusCode.OK); - }); - - var thc = mcf.GetHttpClient("Backend")!.CreateTypedClient(); - - var res = await thc.GetAsync("test"); - var r = res.ToResult(); - Assert.That(r.Error, Is.InstanceOf()); - - res = await thc.GetAsync("test"); - r = res.ToResult(); - Assert.That(r.Error, Is.InstanceOf().And.Message.EqualTo("no-not-happy")); - - res = await thc.GetAsync("test"); - r = res.ToResult(); - Assert.That(r, Is.EqualTo(CoreEx.Results.Result.Success)); - } - - [Test] - public async Task ToResultT() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.WithSequence(s => - { - s.Respond().With(HttpStatusCode.NotFound); - s.Respond().With("no-not-happy", HttpStatusCode.NotAcceptable); - s.Respond().With("2"); - s.Respond().With("a"); - }); - - var thc = mcf.GetHttpClient("Backend")!.CreateTypedClient(); - - var res = await thc.GetAsync("test"); - var r = res.ToResult(); - Assert.That(r.Error, Is.InstanceOf()); - - res = await thc.GetAsync("test"); - r = res.ToResult(); - Assert.That(r.Error, Is.InstanceOf().And.Message.EqualTo("no-not-happy")); - - res = await thc.GetAsync("test"); - r = res.ToResult(); - Assert.That(r.Value, Is.EqualTo(2)); - - res = await thc.GetAsync("test"); - r = res.ToResult(); - Assert.That(r.Error, Is.InstanceOf()); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Http/TypedHttpClientOptionsTest.cs b/tests/CoreEx.Test/Framework/Http/TypedHttpClientOptionsTest.cs deleted file mode 100644 index d3e831c3..00000000 --- a/tests/CoreEx.Test/Framework/Http/TypedHttpClientOptionsTest.cs +++ /dev/null @@ -1,129 +0,0 @@ -using CoreEx.Configuration; -using CoreEx.Http; -using CoreEx.Http.Extended; -using NUnit.Framework; -using System; -using System.Linq; -using System.Net; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Http -{ - [TestFixture] - public class TypedHttpClientOptionsTest - { - private static void AssertIsInitial(TypedHttpClientOptions o) - { - Assert.That(o, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(o.ShouldThrowTransientException, Is.False); - Assert.That(o.ShouldThrowKnownException, Is.False); - Assert.That(o.ShouldThrowKnownUseContentAsMessage, Is.False); - Assert.That(o.IsTransientPredicate, Is.Not.Null); - Assert.That(o.ShouldEnsureSuccess, Is.False); - Assert.That(o.ExpectedStatusCodes, Is.Null); - Assert.That(o.BeforeRequest, Is.Null); - }); - } - - [Test] - public void ThrowTransientException() - { - var o = new TypedHttpClientOptions(); - o.ThrowTransientException(); - Assert.Multiple(() => - { - Assert.That(o.ShouldThrowTransientException, Is.True); - Assert.That(o.IsTransientPredicate, Is.Not.Null); - }); - - var o2 = new TypedHttpClientOptions(o); - Assert.Multiple(() => - { - Assert.That(o2.ShouldThrowTransientException, Is.True); - Assert.That(o2.IsTransientPredicate, Is.Not.Null); - }); - - o.Reset(); - AssertIsInitial(o); - } - - [Test] - public void ThrowKnownException() - { - var o = new TypedHttpClientOptions(); - o.ThrowKnownException(); - Assert.Multiple(() => - { - Assert.That(o.ShouldThrowKnownException, Is.True); - Assert.That(o.ShouldThrowKnownUseContentAsMessage, Is.False); - }); - - o.ThrowKnownException(true); - Assert.Multiple(() => - { - Assert.That(o.ShouldThrowKnownException, Is.True); - Assert.That(o.ShouldThrowKnownUseContentAsMessage, Is.True); - }); - - var o2 = new TypedHttpClientOptions(o); - Assert.Multiple(() => - { - Assert.That(o2.ShouldThrowKnownException, Is.True); - Assert.That(o2.ShouldThrowKnownUseContentAsMessage, Is.True); - }); - - o.Reset(); - AssertIsInitial(o); - } - - [Test] - public void EnsureSuccess() - { - var o = new TypedHttpClientOptions(); - o.EnsureSuccess(); - Assert.That(o.ShouldEnsureSuccess, Is.True); - - var o2 = new TypedHttpClientOptions(o); - Assert.That(o2.ShouldEnsureSuccess, Is.True); - - o.Reset(); - AssertIsInitial(o); - } - - [Test] - public void Ensure() - { - var o = new TypedHttpClientOptions(); - o.EnsureOK(); - o.EnsureAccepted(); - o.EnsureCreated(); - o.EnsureNoContent(); - Assert.That(o.ExpectedStatusCodes!.ToArray(), Is.EqualTo(new HttpStatusCode[] { HttpStatusCode.OK, HttpStatusCode.Accepted, HttpStatusCode.Created, HttpStatusCode.NoContent })); - - o.Ensure(HttpStatusCode.Continue, HttpStatusCode.Conflict); - Assert.That(o.ExpectedStatusCodes!.ToArray(), Is.EqualTo(new HttpStatusCode[] { HttpStatusCode.OK, HttpStatusCode.Accepted, HttpStatusCode.Created, HttpStatusCode.NoContent, HttpStatusCode.Continue, HttpStatusCode.Conflict })); - - var o2 = new TypedHttpClientOptions(o); - Assert.That(o2.ExpectedStatusCodes!.ToArray(), Is.EqualTo(new HttpStatusCode[] { HttpStatusCode.OK, HttpStatusCode.Accepted, HttpStatusCode.Created, HttpStatusCode.NoContent, HttpStatusCode.Continue, HttpStatusCode.Conflict })); - - o.Reset(); - AssertIsInitial(o); - } - - [Test] - public void OnBeforeRequest() - { - var o = new TypedHttpClientOptions(); - o.OnBeforeRequest((r, ct) => Task.CompletedTask); - Assert.That(o.BeforeRequest, Is.Not.Null); - - var o2 = new TypedHttpClientOptions(o); - Assert.That(o2.BeforeRequest, Is.Not.Null); - - o.Reset(); - AssertIsInitial(o); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Http/TypedMapperHttpClientBaseTest.cs b/tests/CoreEx.Test/Framework/Http/TypedMapperHttpClientBaseTest.cs deleted file mode 100644 index dd702bb0..00000000 --- a/tests/CoreEx.Test/Framework/Http/TypedMapperHttpClientBaseTest.cs +++ /dev/null @@ -1,135 +0,0 @@ -using CoreEx.Http.Extended; -using CoreEx.Mapping; -using Moq; -using NUnit.Framework; -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using System.Web.Http; -using UnitTestEx; - -namespace CoreEx.Test.Framework.Http -{ - [TestFixture] - public class TypedMapperHttpClientBaseTest - { - [Test] - public async Task MapSuccess() - { - var m = new Mapper(); - m.Register(new CustomerMapper()); - m.Register(new BackendMapper()); - - var mcf = MockHttpClientFactory.Create(); - mcf.CreateDefaultClient().Request(HttpMethod.Post, "test").WithJsonBody(new Backend { First = "John", Last = "Doe" }).Respond.WithJson(new Backend { First = "John", Last = "Doe" }); - - var mc = new TypedMappedHttpClient(mcf.GetHttpClient()!, m); - var hr = await mc.PostMappedAsync("test", new Customer { FirstName = "John", LastName = "Doe" }); - - Assert.Multiple(() => - { - Assert.That(hr.IsSuccess, Is.True); - Assert.That(hr.Value, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(hr.Value.FirstName, Is.EqualTo("John")); - Assert.That(hr.Value.LastName, Is.EqualTo("Doe")); - }); - - var r = hr.ToResult(); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public async Task MapServerError() - { - var m = new Mapper(); - m.Register(new CustomerMapper()); - m.Register(new BackendMapper()); - - var mcf = MockHttpClientFactory.Create(); - mcf.CreateDefaultClient().Request(HttpMethod.Post, "test").WithJsonBody(new Backend { First = "John", Last = "Doe" }).Respond.With(HttpStatusCode.InternalServerError); - - var mc = new TypedMappedHttpClient(mcf.GetHttpClient()!, m); - var hr = await mc.PostMappedAsync("test", new Customer { FirstName = "John", LastName = "Doe" }); - - Assert.Multiple(() => - { - Assert.That(hr.IsSuccess, Is.False); - Assert.That(hr.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); - }); - - var r = hr.ToResult(); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.False); - Assert.That(r.Error, Is.TypeOf()); - }); - } - - [Test] - public async Task MapJsonError() - { - var m = new Mapper(); - m.Register(new CustomerMapper()); - m.Register(new BackendMapper()); - - var mcf = MockHttpClientFactory.Create(); - mcf.CreateDefaultClient().Request(HttpMethod.Post, "test").WithJsonBody(new Backend { First = "John", Last = "Doe" }).Respond.WithJson("{\"first\":\"Dave\",\"age\":\"ten\"}"); - - var mc = new TypedMappedHttpClient(mcf.GetHttpClient()!, m); - var hr = await mc.PostMappedAsync("test", new Customer { FirstName = "John", LastName = "Doe" }); - - Assert.Multiple(() => - { - Assert.That(hr.IsSuccess, Is.False); - Assert.That(hr.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); - Assert.That(hr.Exception, Is.TypeOf()); - }); - - var r = hr.ToResult(); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.False); - Assert.That(r.Error, Is.TypeOf()); - }); - } - } - - public class Customer - { - public string? FirstName { get; set; } - public string? LastName { get; set; } - } - - public class Backend - { - public string? First { get; set; } - public string? Last { get; set; } - public int? Age { get; set; } - } - - public class CustomerMapper : CoreEx.Mapping.Mapper - { - protected override Backend? OnMap(Customer? source, Backend? destination, OperationTypes operationType) - { - destination ??= new Backend(); - destination.First = source?.FirstName; - destination.Last = source?.LastName; - return destination; - } - } - - public class BackendMapper : Mapper - { - protected override Customer? OnMap(Backend? source, Customer? destination, OperationTypes operationType) - { - destination ??= new Customer(); - destination.FirstName = source?.First; - destination.LastName = source?.Last; - return destination; - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Invokers/InvokerBaseTest.cs b/tests/CoreEx.Test/Framework/Invokers/InvokerBaseTest.cs deleted file mode 100644 index caf956eb..00000000 --- a/tests/CoreEx.Test/Framework/Invokers/InvokerBaseTest.cs +++ /dev/null @@ -1,67 +0,0 @@ -using CoreEx.Invokers; -using NUnit.Framework; -using System; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Invokers -{ - [TestFixture] - public class InvokerBaseTest - { - [Test] - public async Task Invoke_AsyncNoResult() - { - var i = new TestInvoker(); - await i.InvokeAsync(this, async (_, ct) => { await Task.Delay(100, ct); }); - Assert.Multiple(() => - { - Assert.That(i.Before, Is.True); - Assert.That(i.After, Is.True); - }); - } - - [Test] - public void Invoke_AsyncWithResult() - { - var i = new TestInvoker(); - Assert.Multiple(async () => - { - Assert.That(await i.InvokeAsync(this, async (_, ct) => { await Task.Delay(100, ct); return 88; }), Is.EqualTo(88)); - Assert.That(i.Before, Is.True); - Assert.That(i.After, Is.True); - }); - } - - [Test] - public async Task Invoke_AsyncWithResult_Load() - { - var i = new TestInvoker(); - for (var j = 0; j < 100000; j++) - { - await i.InvokeAsync(this, async (_, ct) => { await Task.Delay(0, ct); return 88; }); - } - } - - [Test] - public void Invoke_WithException() - { - var i = new TestInvoker(); - Assert.ThrowsAsync(async () => await i.InvokeAsync(this, async (_, ct) => { await Task.Delay(0, ct); throw new DivideByZeroException(); })); - } - - public class TestInvoker : InvokerBase - { - public bool Before { get; set; } - - public bool After { get; set; } - - protected async override Task OnInvokeAsync(InvokeArgs invokeArgs, InvokerBaseTest invoker, Func> func, object? param, System.Threading.CancellationToken ct) - { - Before = true; - var r = await base.OnInvokeAsync(invokeArgs, invoker, func, param, ct).ConfigureAwait(false); - After = true; - return r; - } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Invokers/InvokerTest.cs b/tests/CoreEx.Test/Framework/Invokers/InvokerTest.cs deleted file mode 100644 index cdbbdc5b..00000000 --- a/tests/CoreEx.Test/Framework/Invokers/InvokerTest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using CoreEx.Invokers; -using CoreEx.Results; -using NUnit.Framework; -using System; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Invokers -{ - [TestFixture] - public class InvokerTest - { - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Json/Compare/JsonElementComparerTest.cs b/tests/CoreEx.Test/Framework/Json/Compare/JsonElementComparerTest.cs deleted file mode 100644 index b4b70a82..00000000 --- a/tests/CoreEx.Test/Framework/Json/Compare/JsonElementComparerTest.cs +++ /dev/null @@ -1,389 +0,0 @@ -using CoreEx.Json.Compare; -using NUnit.Framework; -using System; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace CoreEx.Test.Framework.Json.Compare -{ - [TestFixture] - internal class JsonElementComparerTest - { - [Test] - public void Compare_Object_SameSame() - { - var r = new JsonElementComparer().Compare("{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}", "{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}"); - Assert.That(r.AreEqual, Is.True); - } - - [Test] - public void Compare_Object_DiffOrderAndDiffNumberFormat() - { - var r = new JsonElementComparer().Compare("{\"name\":\"gary\",\"cool\":false,\"age\":40,\"salary\":null}", "{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}"); - Assert.Multiple(() => - { - Assert.That(r.AreEqual, Is.True); - Assert.That(r.ToString(), Is.EqualTo("No differences detected.")); - }); - } - - [Test] - public void Compare_Object_DiffValuesAndTypes() - { - var r = new JsonElementComparer().Compare("{\"name\":\"gary\",\"age\":40.0,\"cool\":null,\"salary\":42000}", "{\"name\":\"brian\",\"age\":41.0,\"cool\":false,\"salary\":null}"); - Assert.Multiple(() => - { - Assert.That(r.HasDifferences, Is.True); - Assert.That(r.ToString(), Is.EqualTo(@"Path '$.name': Value is not equal: ""gary"" != ""brian"". -Path '$.age': Value is not equal: 40.0 != 41.0. -Path '$.cool': Kind is not equal: Null != False. -Path '$.salary': Kind is not equal: Number != Null.")); - }); - } - - [Test] - public void Compare_Object_PropertyNameMismatch() - { - var r = new JsonElementComparer().Compare("{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}", "{\"Name\":\"gary\",\"age\":40.0,\"cool\":false}"); - Assert.Multiple(() => - { - Assert.That(r.HasDifferences, Is.True); - Assert.That(r.ToString(), Is.EqualTo(@"Path '$.name': Does not exist in right JSON. -Path '$.salary': Does not exist in right JSON. -Path '$.Name': Does not exist in left JSON.")); - }); - } - - [Test] - public void Compare_Object_PropertyNameMismatch_Exclude() - { - var r = new JsonElementComparer().Compare("{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}", "{\"Name\":\"gary\",\"age\":40.0,\"cool\":false}", "name", "Name"); - Assert.Multiple(() => - { - Assert.That(r.HasDifferences, Is.True); - Assert.That(r.ToString(), Is.EqualTo(@"Path '$.salary': Does not exist in right JSON.")); - }); - } - - [Test] - public void Compare_Array_LengthMismatch() - { - var r = new JsonElementComparer().Compare("[1,2,3]", "[1,2,3,4]"); - Assert.Multiple(() => - { - Assert.That(r.HasDifferences, Is.True); - Assert.That(r.ToString(), Is.EqualTo("Path '$': Array lengths are not equal: 3 != 4.")); - }); - } - - [Test] - public void Compare_Array_ItemMismatch() - { - var r = new JsonElementComparer().Compare("[1,2,3,5]", "[1,2,3,4]"); - Assert.That(r.ToString(), Is.EqualTo("Path '$[3]': Value is not equal: 5 != 4.")); - } - - [Test] - public void Compare_Object_Array_ItemMismatch() - { - var r = new JsonElementComparer().Compare("{\"names\":[{\"name\":\"gary\"},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\"},{\"name\":\"rebecca\"}]}"); - Assert.That(r.ToString(), Is.EqualTo(@"Path '$.names[1].name': Value is not equal: ""brian"" != ""rebecca"".")); - } - - [Test] - public void Compare_Object_Array_Exclude() - { - var r = new JsonElementComparer().Compare("{\"names\":[{\"name\":\"gary\"},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\"},{\"name\":\"rebecca\"}]}", "names.name"); - Assert.That(r.AreEqual, Is.True); - } - - [Test] - public void Compare_Object_Array_ItemMismatchComplex() - { - var r = new JsonElementComparer().Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}"); - Assert.That(r.ToString(), Is.EqualTo(@"Path '$.names[0].address.street': Value is not equal: 1 != 2.")); - } - - [Test] - public void CompareValue() - { - var r = new JsonElementComparer().CompareValue(100, 100); - Assert.Multiple(() => - { - Assert.That(r.AreEqual, Is.True); - Assert.That(r.ToString(), Is.EqualTo("No differences detected.")); - }); - - r = new JsonElementComparer().CompareValue(100, "Abc"); - Assert.Multiple(() => - { - Assert.That(r.HasDifferences, Is.True); - Assert.That(r.ToString(), Is.EqualTo("Path '$': Kind is not equal: Number != String.")); - }); - } - - [Test] - public void Compare_Exact_Number() - { - var ro = new JsonElementComparerOptions { ValueComparison = JsonElementComparison.Exact }; - var r = new JsonElementComparer(ro).Compare("{\"value\": 1.200}", "{\"value\": 1.2000}"); - Assert.That(r.AreEqual, Is.False); - - r = new JsonElementComparer(ro).Compare("{\"value\": 1.200}", "{\"value\": 1.200}"); - Assert.That(r.AreEqual, Is.True); - - r = new JsonElementComparer(ro).Compare("{\"value\": 1.0E+2}", "{\"value\": 100}"); - Assert.That(r.AreEqual, Is.False); - } - - [Test] - public void Compare_Semantic_Number() - { - var ro = new JsonElementComparerOptions { ValueComparison = JsonElementComparison.Semantic }; - var r = new JsonElementComparer(ro).Compare("{\"value\": 1.200}", "{\"value\": 1.2000}"); - Assert.That(r.AreEqual, Is.True); - - r = new JsonElementComparer(ro).Compare("{\"value\": 1.200}", "{\"value\": 1.200}"); - Assert.That(r.AreEqual, Is.True); - - r = new JsonElementComparer(ro).Compare("{\"value\": 1.0E+2}", "{\"value\": 100}"); - Assert.That(r.AreEqual, Is.True); - } - - [Test] - public void Compare_Exact_String() - { - var ro = new JsonElementComparerOptions { ValueComparison = JsonElementComparison.Exact }; - var r = new JsonElementComparer(ro).Compare("{\"value\": \"2000-01-01\"}", "{\"value\": \"2000-01-01T00:00:00\"}"); - Assert.That(r.AreEqual, Is.False); - - r = new JsonElementComparer(ro).Compare("{\"value\": \"2000-01-01T13:52:18\"}", "{\"value\": \"2000-01-01T13:52:18\"}"); - Assert.That(r.AreEqual, Is.True); - - r = new JsonElementComparer(ro).Compare("{\"value\": \"327b0068-e7a9-40b8-a9d6-14317ce36efd\"}", "{\"value\": \"327b0068-e7a9-40b8-a9d6-14317ce36efd\"}"); - Assert.That(r.AreEqual, Is.True); - - r = new JsonElementComparer(ro).Compare("{\"value\": \"327b0068-E7A9-40B8-A9D6-14317CE36EFD\"}", "{\"value\": \"327b0068-e7a9-40b8-a9d6-14317ce36efd\"}"); - Assert.That(r.AreEqual, Is.False); - } - - [Test] - public void Compare_Semantic_String() - { - var ro = new JsonElementComparerOptions { ValueComparison = JsonElementComparison.Semantic }; - var r = new JsonElementComparer(ro).Compare("{\"value\": \"2000-01-01\"}", "{\"value\": \"2000-01-01T00:00:00\"}"); - Assert.That(r.AreEqual, Is.True); - - r = new JsonElementComparer(ro).Compare("{\"value\": \"2000-01-01T13:52:18\"}", "{\"value\": \"2000-01-01T13:52:18\"}"); - Assert.That(r.AreEqual, Is.True); - - r = new JsonElementComparer(ro).Compare("{\"value\": \"327b0068-e7a9-40b8-a9d6-14317ce36efd\"}", "{\"value\": \"327b0068-e7a9-40b8-a9d6-14317ce36efd\"}"); - Assert.That(r.AreEqual, Is.True); - - r = new JsonElementComparer(ro).Compare("{\"value\": \"327b0068-E7A9-40B8-A9D6-14317CE36EFD\"}", "{\"value\": \"327b0068-e7a9-40b8-a9d6-14317ce36efd\"}"); - Assert.That(r.AreEqual, Is.True); - } - - [Test] - public void Equals_Object_SameSame() - { - Assert.That(new JsonElementComparer().Equals("{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}", "{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}"), Is.True); - } - - [Test] - public void Equals_Object_DiffOrderAndDiffNumberFormat() - { - Assert.That(new JsonElementComparer().Equals("{\"name\":\"gary\",\"cool\":false,\"age\":40,\"salary\":null}", "{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}"), Is.True); - } - - [Test] - public void Equals_Object_DiffValuesAndTypes() - { - Assert.That(new JsonElementComparer().Equals("{\"name\":\"gary\",\"age\":40.0,\"cool\":null,\"salary\":42000}", "{\"name\":\"brian\",\"age\":41.0,\"cool\":false,\"salary\":null}"), Is.False); - } - - [Test] - public void Equals_Object_PropertyNameMismatch() - { - Assert.That(new JsonElementComparer().Equals("{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}", "{\"Name\":\"gary\",\"age\":40.0,\"cool\":false}"), Is.False); - } - - [Test] - public void Equals_Array_LengthMismatch() - { - Assert.That(new JsonElementComparer().Equals("[1,2,3]", "[1,2,3,4]"), Is.False); - } - - [Test] - public void Equals_Array_ItemMismatch() - { - Assert.That(new JsonElementComparer().Equals("[1,2,3,5]", "[1,2,3,4]"), Is.False); - } - - [Test] - public void Equals_Object_Array_ItemMismatch() - { - Assert.That(new JsonElementComparer().Equals("{\"names\":[{\"name\":\"gary\"},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\"},{\"name\":\"rebecca\"}]}"), Is.False); - } - - [Test] - public void Equals_Object_Array_ItemMismatchComplex() - { - Assert.That(new JsonElementComparer().Equals("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}"), Is.False); - } - - [Test] - public void Hashcode_Object_DiffOrderAndDiffNumberFormat() - { - Assert.Multiple(() => - { - Assert.That(new JsonElementComparer().GetHashCode("{\"name\":\"gary\",\"age\":40.0,\"cool\":false,\"salary\":null}"), Is.EqualTo(new JsonElementComparer().GetHashCode("{\"name\":\"gary\",\"cool\":false,\"age\":40,\"salary\":null}"))); - Assert.That(new JsonElementComparer().GetHashCode("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}"), Is.Not.EqualTo(new JsonElementComparer().GetHashCode("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}"))); - }); - } - - [Test] - public void ToMergePatch_Object_Simple() - { - var jn = new JsonElementComparer().Compare("{\"name\":\"gary\",\"age\":40.0,\"cool\":null,\"salary\":42000}", "{\"name\":\"gary\",\"age\":41.0,\"cool\":false,\"salary\":null}").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"age\":41.0,\"cool\":false,\"salary\":null}")); - } - - [Test] - public void ToMergePatch_Object_Simple_Null_To_Null() - { - var jn = new JsonElementComparer().Compare("{\"name\":\"gary\",\"age\":40.0,\"cool\":null,\"salary\":42000}", "{\"name\":\"gary\",\"age\":41.0,\"cool\":null,\"salary\":null}").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"age\":41.0,\"salary\":null}")); - } - - [Test] - public void ToMergePatch_Object_Simple_Null_To_Null_Paths() - { - var jn = new JsonElementComparer().Compare("{\"name\":\"gary\",\"age\":40.0,\"cool\":null,\"salary\":42000}", "{\"name\":\"gary\",\"age\":41.0,\"cool\":null,\"salary\":null}").ToMergePatch("cool"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"age\":41.0,\"cool\":null,\"salary\":null}")); - } - - [Test] - public void ToMergePatch_Object_Simple_Nested() - { - var jn = new JsonElementComparer().Compare("{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":1234}}", "{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":12345}}").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"address\":{\"postcode\":12345}}")); - } - - [Test] - public void ToMergePatch_Object_Simple_Nested_Paths() - { - var jn = new JsonElementComparer().Compare("{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":1234}}", "{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":12345}}").ToMergePatch("address"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"address\":{\"street\":\"petherick\",\"postcode\":12345}}")); - - jn = new JsonElementComparer().Compare("{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":1234}}", "{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":12345}}").ToMergePatch("address.street"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"address\":{\"street\":\"petherick\",\"postcode\":12345}}")); - - jn = new JsonElementComparer().Compare("{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":1234}}", "{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":12345}}").ToMergePatch("address.country"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"address\":{\"postcode\":12345}}")); - - jn = new JsonElementComparer().Compare("{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":1234}}", "{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":1234}}").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{}")); - - jn = new JsonElementComparer().Compare("{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":1234}}", "{\"name\":\"gary\",\"address\":{\"street\":\"petherick\",\"postcode\":1234}}").ToMergePatch("address.country"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{}")); - } - - [Test] - public void ToMergePatch_Object_With_ReplaceAllArray() - { - var jn = new JsonElementComparer().Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\":2}},{\"name\":\"brian\"}]}")); - - jn = new JsonElementComparer().Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{}")); - } - - [Test] - public void ToMergePatch_Object_With_NoReplaceAllArray() - { - var o = new JsonElementComparerOptions { ReplaceAllArrayItemsOnMerge = false }; - var jn = new JsonElementComparer(o).Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"address\":{\"street\":2}}]}")); - - jn = new JsonElementComparer(o).Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{}")); - } - - [Test] - public void ToMergePatch_Object_With_Array_Paths() - { - var jn = new JsonElementComparer().Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}").ToMergePatch("names.name"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\":2}},{\"name\":\"brian\"}]}")); - - jn = new JsonElementComparer().Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}").ToMergePatch("names.name"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\":1}},{\"name\":\"brian\"}]}")); - - jn = new JsonElementComparer().Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}").ToMergePatch("names[1].name"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\":2}},{\"name\":\"brian\"}]}")); - - jn = new JsonElementComparer().Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}").ToMergePatch("names[1].name"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\":1}},{\"name\":\"brian\"}]}")); - } - - [Test] - public void ToMergePatch_Object_With_NoReplaceAllArray_Paths() - { - var o = new JsonElementComparerOptions { ReplaceAllArrayItemsOnMerge = false }; - var jn = new JsonElementComparer(o).Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}").ToMergePatch("names.name"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\":2}},{\"name\":\"brian\"}]}")); - - jn = new JsonElementComparer(o).Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}").ToMergePatch("names.name"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"name\":\"gary\"},{\"name\":\"brian\"}]}")); - - jn = new JsonElementComparer(o).Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 2}},{\"name\":\"brian\"}]}").ToMergePatch("names[1].name"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"address\":{\"street\":2}},{\"name\":\"brian\"}]}")); - - jn = new JsonElementComparer(o).Compare("{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}", "{\"names\":[{\"name\":\"gary\",\"address\":{\"street\": 1}},{\"name\":\"brian\"}]}").ToMergePatch("names[1].name"); - Assert.That(jn!.ToJsonString(), Is.EqualTo("{\"names\":[{\"name\":\"brian\"}]}")); - } - - [Test] - public void ToMergePatch_Value() - { - var jn = new JsonElementComparer().Compare("\"Blah\"", "\"Blah2\"").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("\"Blah2\"")); - - jn = new JsonElementComparer().Compare("123", "456").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("456")); - - jn = new JsonElementComparer().Compare("true", "true").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("true")); - - jn = new JsonElementComparer().Compare("null", "null").ToMergePatch(); - Assert.That(jn, Is.Null); - } - - [Test] - public void ToMergePatch_Root_Array() - { - var jn = new JsonElementComparer().Compare("[1,2,3]", "[1,9,3]").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("[1,9,3]")); - - jn = new JsonElementComparer().Compare("[1,2,3]", "[1,null,3]").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("[1,null,3]")); - - jn = new JsonElementComparer().Compare("[1,2,3]", "[1,null,3,{\"age\":21}]").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("[1,null,3,{\"age\":21}]")); - - var o = new JsonElementComparerOptions { ReplaceAllArrayItemsOnMerge = false }; - jn = new JsonElementComparer(o).Compare("[1,2,3]", "[1,9,3]").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("[9]")); - - jn = new JsonElementComparer(o).Compare("[1,2,3]", "[1,null,3]").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("[null]")); - - jn = new JsonElementComparer(o).Compare("[1,2,3]", "[1,null,{\"age\":21}]").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("[null,{\"age\":21}]")); - - // Array length difference always results in a replace (i.e. all). - jn = new JsonElementComparer(o).Compare("[1,2,3]", "[1,null,{\"age\":21},8]").ToMergePatch(); - Assert.That(jn!.ToJsonString(), Is.EqualTo("[1,null,{\"age\":21},8]")); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Json/Data/JsonDataReaderTest.cs b/tests/CoreEx.Test/Framework/Json/Data/JsonDataReaderTest.cs deleted file mode 100644 index ea9d01b2..00000000 --- a/tests/CoreEx.Test/Framework/Json/Data/JsonDataReaderTest.cs +++ /dev/null @@ -1,243 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Json.Data; -using CoreEx.RefData; -using CoreEx.Text.Json; -using NUnit.Framework; -using System; - -namespace CoreEx.Test.Framework.Json.Data -{ - [TestFixture] - public class JsonDataReaderTest - { - private readonly string data = -@"data: - - Person: - - { id: ^1, first: Bob, last: Smith, age: 23 } - - { id: ^2, first: Jenny, last: Browne, age: 51 } - - Contact: - - { id: ^1, first: Bob, last: Smith } - - { id: ^2, first: Jenny, last: Browne }"; - - [Test] - public void Parse_NonJsonObject() - { - var ex = Assert.Throws(() => JsonDataReader.ParseYaml("apples")); - Assert.That(ex!.Message, Does.StartWith("JSON root element must be an Object.")); - } - - [Test] - public void Parse_JsonNoArray() - { - var ex = Assert.Throws(() => JsonDataReader.ParseJson("{\"fruit\":\"apples\"}")); - Assert.That(ex!.Message, Does.StartWith("JSON root element must be an Object with an underlying array.")); - } - - [Test] - public void Deserialize_NotFound() - { - var jdr = JsonDataReader.ParseYaml(data); - Assert.Multiple(() => - { - Assert.That(jdr.TryDeserialize("Bananas", out var coll), Is.False); - Assert.That(coll, Is.Null); - }); - } - - [Test] - public void Deserialize_Single() - { - var jdr = JsonDataReader.ParseYaml( -@"data: - - Person: - - { id: ^1, first: Bob, last: Smith, age: 23 }", new JsonDataReaderArgs(null, "XXXX", new DateTime(2000, 01, 01))); - - Assert.That(jdr.TryDeserialize("Person", out var coll), Is.True); - Assert.Multiple(() => - { - Assert.That(coll, Is.Not.Null); - Assert.That(coll!, Has.Count.EqualTo(1)); - Assert.That(coll![0].Id, Is.EqualTo(new Guid(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))); - Assert.That(coll[0].First, Is.EqualTo("Bob")); - Assert.That(coll[0].Last, Is.EqualTo("Smith")); - Assert.That(coll[0].Age, Is.EqualTo(23)); - Assert.That(coll[0].ChangeLog, Is.Not.Null); - }); - - Assert.Multiple(() => - { - Assert.That(coll[0].ChangeLog!.CreatedBy, Is.EqualTo("XXXX")); - Assert.That(coll[0].ChangeLog!.CreatedDate, Is.EqualTo(new DateTime(2000, 01, 01))); - Assert.That(coll[0].ChangeLog!.UpdatedBy, Is.Null); - Assert.That(coll[0].ChangeLog!.UpdatedDate, Is.Null); - }); - } - - [Test] - public void Deserialize_Multi() - { - var jdr = JsonDataReader.ParseYaml(data); - Assert.That(jdr.TryDeserialize("Person", out var coll), Is.True); - Assert.Multiple(() => - { - Assert.That(coll, Is.Not.Null); - Assert.That(coll!, Has.Count.EqualTo(2)); - Assert.That(coll![0].Id, Is.EqualTo(new Guid(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))); - Assert.That(coll[0].First, Is.EqualTo("Bob")); - Assert.That(coll[0].Last, Is.EqualTo("Smith")); - Assert.That(coll[0].Age, Is.EqualTo(23)); - Assert.That(coll[1].Id, Is.EqualTo(new Guid(2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))); - Assert.That(coll[1].First, Is.EqualTo("Jenny")); - Assert.That(coll[1].Last, Is.EqualTo("Browne")); - Assert.That(coll[1].Age, Is.EqualTo(51)); - }); - } - - [Test] - public void Deserialize_GenerateIdentifier() - { - var jdr = JsonDataReader.ParseJson("{\"data\":[{\"Person\":[{\"first\":\"Bob\"}]}]}"); - Assert.That(jdr.TryDeserialize("Person", out var coll), Is.True); - Assert.Multiple(() => - { - Assert.That(coll, Is.Not.Null); - Assert.That(coll!, Has.Count.EqualTo(1)); - Assert.That(coll![0].First, Is.EqualTo("Bob")); - Assert.That(coll[0].Id, Is.Not.EqualTo(Guid.Empty)); - }); - } - - [Test] - public void Deserialize_RuntimeParameter1() - { - var jdr = JsonDataReader.ParseJson("{\"data\":[{\"Person\":[{\"first\":\"^(System.Environment.UserName)\"}]}]}"); - Assert.Multiple(() => - { - Assert.That(jdr.TryDeserialize("Person", out var coll), Is.True); - Assert.That(coll, Is.Not.Null); - Assert.That(coll!, Has.Count.EqualTo(1)); - Assert.That(coll![0].First, Is.EqualTo(System.Environment.UserName)); - }); - } - - [Test] - public void Deserialize_RuntimeParameter2() - { - var args = new JsonDataReaderArgs(); - args.Parameters.Add("fruit", "banana"); - var jdr = JsonDataReader.ParseJson("{\"data\":[{\"Person\":[{\"first\":\"^(fruit)\"}]}]}", args); - Assert.Multiple(() => - { - Assert.That(jdr.TryDeserialize("Person", out var coll), Is.True); - Assert.That(coll, Is.Not.Null); - Assert.That(coll!, Has.Count.EqualTo(1)); - Assert.That(coll![0].First, Is.EqualTo("banana")); - }); - } - - [Test] - public void Deserialize_RefData() - { - var jdr = JsonDataReader.ParseJson("{\"data\":[{\"Gender\":[{\"F\":\"Female\"},{\"code\":\"M\",\"text\":\"Male\"},{\"code\":\"O\",\"text\":\"Other\",\"isActive\":false,\"sortOrder\":99}]}]}", new JsonDataReaderArgs(new ReferenceDataContentJsonSerializer())); - Assert.That(jdr.TryDeserialize("Gender", out var coll), Is.True); - Assert.Multiple(() => - { - Assert.That(coll, Is.Not.Null); - Assert.That(coll!, Has.Count.EqualTo(3)); - Assert.That(coll![0].Id, Is.Not.Null); - Assert.That(coll[0].Code, Is.EqualTo("F")); - Assert.That(coll[0].Text, Is.EqualTo("Female")); - Assert.That(coll[0].SortOrder, Is.EqualTo(1)); - Assert.That(coll[0].IsActive, Is.EqualTo(true)); - Assert.That(coll[1].Code, Is.EqualTo("M")); - Assert.That(coll[1].Text, Is.EqualTo("Male")); - Assert.That(coll[1].SortOrder, Is.EqualTo(2)); - Assert.That(coll[1].IsActive, Is.EqualTo(true)); - Assert.That(coll[2].Code, Is.EqualTo("O")); - Assert.That(coll[2].Text, Is.EqualTo("Other")); - Assert.That(coll[2].SortOrder, Is.EqualTo(99)); - Assert.That(coll[2].IsActive, Is.EqualTo(false)); - }); - } - - [Test] - public void Deserialize_RefData2() - { - var jdr = JsonDataReader.ParseJson("{\"data\":[{\"Gender\":[{\"F\":\"Female\"},{\"Code\":\"M\",\"Text\":\"Male\"},{\"Code\":\"O\",\"Text\":\"Other\",\"IsActive\":false,\"SortOrder\":99}]}]}", new JsonDataReaderArgs(new ReferenceDataContentJsonSerializer())); - Assert.That(jdr.TryDeserialize("Gender", out var coll), Is.True); - Assert.Multiple(() => - { - Assert.That(coll, Is.Not.Null); - Assert.That(coll!, Has.Count.EqualTo(3)); - Assert.That(coll![0].Id, Is.Not.Null); - Assert.That(coll[0].Code, Is.EqualTo("F")); - Assert.That(coll[0].Text, Is.EqualTo("Female")); - Assert.That(coll[0].SortOrder, Is.EqualTo(1)); - Assert.That(coll[0].IsActive, Is.EqualTo(true)); - Assert.That(coll[1].Code, Is.EqualTo("M")); - Assert.That(coll[1].Text, Is.EqualTo("Male")); - Assert.That(coll[1].SortOrder, Is.EqualTo(2)); - Assert.That(coll[1].IsActive, Is.EqualTo(true)); - Assert.That(coll[2].Code, Is.EqualTo("O")); - Assert.That(coll[2].Text, Is.EqualTo("Other")); - Assert.That(coll[2].SortOrder, Is.EqualTo(99)); - Assert.That(coll[2].IsActive, Is.EqualTo(false)); - }); - } - - [Test] - public void Deserialize_NumberToStringIdentifier() - { - var jdr = JsonDataReader.ParseJson("{\"data\":[{\"Contact\":[{\"id\":123456}]}]}"); - Assert.That(jdr.TryDeserialize("Contact", out var coll), Is.True); - Assert.Multiple(() => - { - Assert.That(coll, Is.Not.Null); - Assert.That(coll!, Has.Count.EqualTo(1)); - Assert.That(coll![0].Id, Is.EqualTo("123456")); - }); - - jdr = JsonDataReader.ParseYaml( -@"data: - - Contact: - - { id: 0123456, first: Bob }"); - - Assert.That(jdr.TryDeserialize("Contact", out coll), Is.True); - Assert.Multiple(() => - { - Assert.That(coll, Is.Not.Null); - Assert.That(coll!, Has.Count.EqualTo(1)); - Assert.That(coll![0].Id, Is.EqualTo("0123456")); - }); - - jdr = JsonDataReader.ParseJson("{\"data\":[{\"Contact\":[{\"id\":123.456}]}]}"); - Assert.That(jdr.TryDeserialize("Contact", out coll), Is.True); - Assert.Multiple(() => - { - Assert.That(coll, Is.Not.Null); - Assert.That(coll!, Has.Count.EqualTo(1)); - Assert.That(coll[0].Id, Is.EqualTo("123.456")); - }); - - jdr = JsonDataReader.ParseJson("{\"data\":[{\"Contact\":[{\"id\":true}]}]}"); - Assert.Throws(() => jdr.TryDeserialize("Contact", out coll)); - } - - public class Person : IIdentifier, IChangeLog - { - public Guid Id { get; set; } - public string? First { get; set; } - public string? Last { get; set; } - public int? Age { get; set; } - public ChangeLog? ChangeLog { get; set; } - } - - public class Contact : IIdentifier - { - public string? Id { get; set; } - public string? First { get; set; } - } - - public class Gender : ReferenceDataBase { } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Json/JsonEmployeeTest.cs b/tests/CoreEx.Test/Framework/Json/JsonEmployeeTest.cs deleted file mode 100644 index a3f022df..00000000 --- a/tests/CoreEx.Test/Framework/Json/JsonEmployeeTest.cs +++ /dev/null @@ -1,83 +0,0 @@ -using CoreEx.Json; -using CoreEx.TestFunction.Models; -using FluentAssertions; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text; -using Nsj = Newtonsoft.Json; - -namespace CoreEx.Test.Framework.Json -{ - [TestFixture] - public class JsonEmployeeTest - { - public class Employee - { - /// - /// Gets or sets the 'EmployeeId' column value. - /// - public Guid EmployeeId { get; set; } - - /// - /// Gets or sets the 'Email' column value. - /// - public string? Email { get; set; } - - /// - /// Gets or sets the 'FirstName' column value. - /// - public string? FirstName { get; set; } - - /// - /// Gets or sets the 'LastName' column value. - /// - public string? LastName { get; set; } - - /// - /// Gets or sets the 'GenderCode' column value. - /// - public string? GenderCode { get; set; } - - /// - /// Gets or sets the 'Birthday' column value. - /// - public DateTime? Birthday { get; set; } - - /// - /// Gets or sets the 'StartDate' column value. - /// - public DateTime? StartDate { get; set; } - - /// - /// Gets or sets the 'TerminationDate' column value. - /// - public DateTime? TerminationDate { get; set; } - - /// - /// Gets or sets the 'TerminationReasonCode' column value. - /// - public string? TerminationReasonCode { get; set; } - - /// - /// Gets or sets the 'PhoneNo' column value. - /// - public string? PhoneNo { get; set; } - } - - [Test] - public void SystemTextJson_Serialize_Deserialize() - { - // Arrange - var json = "{\n \"email\": \"piotr.karpala@avanade.com\",\n \"FirstName\": \"Piotr\",\n \"lastName\": \"Karpala\",\n \"genderCode\": \"male\",\n \"birthday\": \"1990-03-24T13:49:11.813Z\",\n \"startDate\": \"2022-03-24T13:49:11.813Z\",\n \"phoneNo\": \"985 657 9455\"\n}"; - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - - // Act - var employee = js.Deserialize(json); - - // Assert - employee.Should().NotBeNull(); - employee!.FirstName.Should().Be("Piotr"); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Json/JsonSerializerTest.cs b/tests/CoreEx.Test/Framework/Json/JsonSerializerTest.cs deleted file mode 100644 index 2fc6a463..00000000 --- a/tests/CoreEx.Test/Framework/Json/JsonSerializerTest.cs +++ /dev/null @@ -1,760 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Json; -using FluentAssertions; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using Nsj = Newtonsoft.Json; -using Stj = System.Text.Json; - -namespace CoreEx.Test.Framework.Json -{ - [TestFixture] - public class JsonSerializerTest - { - #region SystemTextJson - - [Test] - public void SystemTextJson_Serialize_Deserialize() - { - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - var p = new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m, Secret = "X", ETag = "xxx" }; - var json = js.Serialize(p); - Assert.That(json, Is.EqualTo("{\"code\":\"A\",\"DESCRIPTION\":\"B\",\"retailPrice\":1.99,\"etag\":\"xxx\"}")); - - p = js.Deserialize(json); - Assert.That(p, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(p!.Code, Is.EqualTo("A")); - Assert.That(p.Description, Is.EqualTo("B")); - Assert.That(p.RetailPrice, Is.EqualTo(1.99m)); - Assert.That(p.Secret, Is.Null); - Assert.That(p.ETag, Is.EqualTo("xxx")); - }); - - p = (BackendProduct)js.Deserialize(json, typeof(BackendProduct))!; - Assert.That(p, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(p.Code, Is.EqualTo("A")); - Assert.That(p.Description, Is.EqualTo("B")); - Assert.That(p.RetailPrice, Is.EqualTo(1.99m)); - Assert.That(p.Secret, Is.Null); - Assert.That(p.ETag, Is.EqualTo("xxx")); - }); - - json = js.Serialize(p, JsonWriteFormat.Indented); - json.Should().Be("{\n \"code\": \"A\",\n \"DESCRIPTION\": \"B\",\n \"retailPrice\": 1.99,\n \"etag\": \"xxx\"\n}" - .Replace("\n", Environment.NewLine), because: "Line breaks should be preserved in indented JSON with different line endings on Linux and Windows"); - } - - [Test] - public void SystemTextJson_Serialize_Deserialize_BinaryData() - { - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - var p = new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m, Secret = "X" }; - var bs = js.SerializeToBinaryData(p); - - p = js.Deserialize(bs); - Assert.That(p, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(p!.Code, Is.EqualTo("A")); - Assert.That(p.Description, Is.EqualTo("B")); - Assert.That(p.RetailPrice, Is.EqualTo(1.99m)); - Assert.That(p.Secret, Is.Null); - }); - - p = (BackendProduct)js.Deserialize(bs, typeof(BackendProduct))!; - Assert.That(p, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(p.Code, Is.EqualTo("A")); - Assert.That(p.Description, Is.EqualTo("B")); - Assert.That(p.RetailPrice, Is.EqualTo(1.99m)); - Assert.That(p.Secret, Is.Null); - }); - } - - [Test] - public void SystemTextJson_Serialize_Deserialize_Dynamic() - { - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - var p = new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m, Secret = "X" }; - var json = js.Serialize(p); - - var o = js.Deserialize(json); - Assert.That(o, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(o, Is.InstanceOf()); - Assert.That(o!.ToString(), Is.EqualTo("{\"code\":\"A\",\"DESCRIPTION\":\"B\",\"retailPrice\":1.99}")); - }); - } - - [Test] - public void SystemTextJson_Serialize_Deserialize_Dynamic_BinaryData() - { - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - var p = new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m, Secret = "X" }; - var bs = js.SerializeToBinaryData(p); - - var o = js.Deserialize(bs); - Assert.That(o, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(o, Is.InstanceOf()); - Assert.That(o!.ToString(), Is.EqualTo("{\"code\":\"A\",\"DESCRIPTION\":\"B\",\"retailPrice\":1.99}")); - }); - } - - [Test] - public void SystemTextJson_TryApplyFilter_JsonString() - { - var p = new Person { FirstName = "John", LastName = "Smith", Addresses = new List
{ new() { Street = "One", City = "First" }, new() { Street = "Two", City = "Second" } } }; - - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - Assert.Multiple(() => - { - Assert.That(js.TryApplyFilter(p, new string[] { "LastName", "Addresses.City", "LastName" }, out string json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(json, Is.EqualTo("{\"firstName\":\"John\",\"addresses\":[{\"street\":\"One\"},{\"street\":\"Two\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "LastName", "Addresses.City", "LastName" }, out json), Is.True); - Assert.That(json, Is.EqualTo("{\"lastName\":\"Smith\",\"addresses\":[{\"city\":\"First\"},{\"city\":\"Second\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "lastname", "addresses" }, out json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(json, Is.EqualTo("{\"firstName\":\"John\"}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "lastname", "addresses" }, out json, JsonPropertyFilter.Include), Is.True); - Assert.That(json, Is.EqualTo("{\"lastName\":\"Smith\",\"addresses\":[{\"street\":\"One\",\"city\":\"First\"},{\"street\":\"Two\",\"city\":\"Second\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "firstName" }, out json), Is.True); - Assert.That(json, Is.EqualTo("{\"firstName\":\"John\"}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "middlename" }, out json), Is.True); - Assert.That(json, Is.EqualTo("{}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "middlename" }, out json, JsonPropertyFilter.Exclude), Is.False); - Assert.That(json, Is.EqualTo("{\"firstName\":\"John\",\"lastName\":\"Smith\",\"addresses\":[{\"street\":\"One\",\"city\":\"First\"},{\"street\":\"Two\",\"city\":\"Second\"}]}")); - }); - } - - [Test] - public void SystemTextJson_TryApplyFilter_JsonObject() - { - var p = new Person { FirstName = "John", LastName = "Smith", Addresses = new List
{ new() { Street = "One", City = "First" }, new() { Street = "Two", City = "Second" } } }; - - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - Assert.That(js.TryApplyFilter(p, new string[] { "LastName", "Addresses.City", "LastName" }, out object json, JsonPropertyFilter.Exclude), Is.True); - Assert.Multiple(() => - { - Assert.That(((System.Text.Json.Nodes.JsonObject)json).ToJsonString(), Is.EqualTo("{\"firstName\":\"John\",\"addresses\":[{\"street\":\"One\"},{\"street\":\"Two\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "LastName", "Addresses.City", "LastName" }, out json), Is.True); - Assert.That(((System.Text.Json.Nodes.JsonObject)json).ToJsonString(), Is.EqualTo("{\"lastName\":\"Smith\",\"addresses\":[{\"city\":\"First\"},{\"city\":\"Second\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "lastname", "addresses" }, out json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(((System.Text.Json.Nodes.JsonObject)json).ToJsonString(), Is.EqualTo("{\"firstName\":\"John\"}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "lastname", "addresses" }, out json, JsonPropertyFilter.Include), Is.True); - Assert.That(((System.Text.Json.Nodes.JsonObject)json).ToJsonString(), Is.EqualTo("{\"lastName\":\"Smith\",\"addresses\":[{\"street\":\"One\",\"city\":\"First\"},{\"street\":\"Two\",\"city\":\"Second\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "firstName" }, out json), Is.True); - Assert.That(((System.Text.Json.Nodes.JsonObject)json).ToJsonString(), Is.EqualTo("{\"firstName\":\"John\"}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "middlename" }, out json), Is.True); - Assert.That(((System.Text.Json.Nodes.JsonObject)json).ToJsonString(), Is.EqualTo("{}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "middlename" }, out json, JsonPropertyFilter.Exclude), Is.False); - Assert.That(((System.Text.Json.Nodes.JsonObject)json).ToJsonString(), Is.EqualTo("{\"firstName\":\"John\",\"lastName\":\"Smith\",\"addresses\":[{\"street\":\"One\",\"city\":\"First\"},{\"street\":\"Two\",\"city\":\"Second\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "addresses[1].city" }, out json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(((System.Text.Json.Nodes.JsonObject)json).ToJsonString(), Is.EqualTo("{\"firstName\":\"John\",\"lastName\":\"Smith\",\"addresses\":[{\"street\":\"One\",\"city\":\"First\"},{\"street\":\"Two\"}]}")); - }); - - p.Address = new Address { Street = "One", City = "First" }; - Assert.Multiple(() => - { - Assert.That(js.TryApplyFilter(p, new string[] { "address" }, out json, JsonPropertyFilter.Include), Is.True); - Assert.That(((System.Text.Json.Nodes.JsonObject)json).ToJsonString(), Is.EqualTo("{\"address\":{\"street\":\"One\",\"city\":\"First\"}}")); - }); - } - - [Test] - public void SystemTextJson_TryApplyFilter_JsonArray() - { - var p = new int[] { 11, 22, 333 }; - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - object json; - Assert.Multiple(() => - { - Assert.That(js.TryApplyFilter(p, new string[] { "[2]" }, out json, JsonPropertyFilter.Include), Is.True); - Assert.That(((System.Text.Json.Nodes.JsonArray)json).ToJsonString(), Is.EqualTo("[333]")); - }); - - js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - Assert.Multiple(() => - { - Assert.That(js.TryApplyFilter(p, new string[] { "[2]" }, out json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(((System.Text.Json.Nodes.JsonArray)json).ToJsonString(), Is.EqualTo("[11,22]")); - }); - } - - [Test] - public void SystemTextJson_Serialize_Deserialize_Exceptions() - { - // Arrange - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - Exception realException; - - try { throw new Exception("Test"); } - catch (Exception ex) { realException = ex; } - - // Act - var serialized = js.Serialize(realException); - var deserialized = js.Deserialize(serialized)!; - - // Assert - deserialized.Data.Should().BeEquivalentTo(realException.Data); - deserialized.Message.Should().BeEquivalentTo(realException.Message, because: "Custom converter only handles Message on deserialization"); - } - - [Test] - public void SystemTextJson_TryGetJsonName() - { - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - var t = typeof(Other); - - Assert.Multiple(() => - { - Assert.That(js.TryGetJsonName(t.GetProperty(nameof(Other.FirstName))!, out string? jn), Is.True); - Assert.That(jn, Is.EqualTo("first-name")); - - Assert.That(js.TryGetJsonName(t.GetProperty(nameof(Other.MiddleName))!, out jn), Is.False); - Assert.That(jn, Is.EqualTo(null)); - - Assert.That(js.TryGetJsonName(t.GetProperty(nameof(Other.LastName))!, out jn), Is.True); - Assert.That(jn, Is.EqualTo("lastName")); - - }); - } - - [Test] - public void SystemTextJson_Serialize_DictionaryKeys() - { - var d = new Dictionary { { "ABC", "XXX" }, { "Efg", "Xxx" }, { "hij", "xxx" }, { "AbEfg", "xxXxx" }, { "ETag", "ETAG" } }; - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - var json = js.Serialize(d); - Assert.That(json, Is.EqualTo("{\"abc\":\"XXX\",\"efg\":\"Xxx\",\"hij\":\"xxx\",\"abEfg\":\"xxXxx\",\"etag\":\"ETAG\"}")); - } - - [Test] - public void SystemTextJson_Serialize_CollectionResult() - { - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - - // Null object. - var pcr = (PersonCollectionResult?)null; - - var json = js.Serialize(pcr); - Assert.That(json, Is.EqualTo("null")); - - pcr = js.Deserialize(json); - Assert.That(pcr, Is.Null); - - // Empty collection. - pcr = new PersonCollectionResult(); - - json = js.Serialize(pcr); - Assert.That(json, Is.EqualTo("[]")); - - pcr = js.Deserialize(json); - Assert.That(pcr, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(pcr!.Items, Is.Not.Null); - Assert.That(pcr.Items, Is.Empty); - Assert.That(pcr.Paging, Is.Null); - }); - - // Items in collection. - pcr.Items.Add(new Person { FirstName = "Jane" }); - pcr.Items.Add(new Person { FirstName = "John" }); - - json = js.Serialize(pcr); - Assert.That(json, Is.EqualTo("[{\"firstName\":\"Jane\"},{\"firstName\":\"John\"}]")); - - pcr = js.Deserialize(json); - Assert.That(pcr, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(pcr!.Items, Is.Not.Null); - Assert.That(pcr.Items, Has.Count.EqualTo(2)); - Assert.That(pcr.Paging, Is.Null); - Assert.That(pcr.Items[0].FirstName, Is.EqualTo("Jane")); - Assert.That(pcr.Items[1].FirstName, Is.EqualTo("John")); - }); - } - - [Test] - public void SystemTextJson_Serialize_CompositeKey() - { - var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; - - var ck = CompositeKey.Empty; - var json = js.Serialize(ck); - Assert.Multiple(() => - { - Assert.That(json, Is.EqualTo("null")); - Assert.That(js.Deserialize(json), Is.EqualTo(ck)); - }); - - ck = new CompositeKey((int?)null); - json = js.Serialize(ck); - Assert.Multiple(() => - { - Assert.That(json, Is.EqualTo("[null]")); - Assert.That(js.Deserialize(json), Is.EqualTo(ck)); - }); - - ck = new CompositeKey(88); - json = js.Serialize(ck); - Assert.Multiple(() => - { - Assert.That(json, Is.EqualTo("[{\"int\":88}]")); - Assert.That(js.Deserialize(json), Is.EqualTo(ck)); - }); - - ck = new CompositeKey("text", 'x', short.MinValue, int.MinValue, long.MinValue, ushort.MaxValue, uint.MaxValue, long.MaxValue, Guid.Parse("8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc"), - new DateTime(1970, 01, 22, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2000, 01, 22, 20, 59, 43, DateTimeKind.Utc), new DateTimeOffset(2000, 01, 22, 20, 59, 43, TimeSpan.FromHours(-8))); - json = js.Serialize(ck); - Assert.Multiple(() => - { - Assert.That(json, Is.EqualTo("[{\"string\":\"text\"},{\"char\":\"x\"},{\"short\":-32768},{\"int\":-2147483648},{\"long\":-9223372036854775808},{\"ushort\":65535},{\"uint\":4294967295},{\"long\":9223372036854775807},{\"guid\":\"8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc\"},{\"datetime\":\"1970-01-22T00:00:00\"},{\"datetime\":\"2000-01-22T20:59:43Z\"},{\"datetimeoffset\":\"2000-01-22T20:59:43-08:00\"}]")); - Assert.That(js.Deserialize(json), Is.EqualTo(ck)); - }); - } - - #endregion - - #region NewtonsoftJson - - [Test] - public void NewtonsoftJson_Serialize_Deserialize() - { - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - var p = new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m, Secret = "X", ETag = "xxx" }; - var json = js.Serialize(p); - Assert.That(json, Is.EqualTo("{\"code\":\"A\",\"DESCRIPTION\":\"B\",\"retailPrice\":1.99,\"etag\":\"xxx\"}")); - - p = js.Deserialize(json); - Assert.That(p, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(p!.Code, Is.EqualTo("A")); - Assert.That(p.Description, Is.EqualTo("B")); - Assert.That(p.RetailPrice, Is.EqualTo(1.99m)); - Assert.That(p.Secret, Is.Null); - Assert.That(p.ETag, Is.EqualTo("xxx")); - }); - - p = (BackendProduct)js.Deserialize(json, typeof(BackendProduct))!; - Assert.That(p, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(p.Code, Is.EqualTo("A")); - Assert.That(p.Description, Is.EqualTo("B")); - Assert.That(p.RetailPrice, Is.EqualTo(1.99m)); - Assert.That(p.Secret, Is.Null); - Assert.That(p.ETag, Is.EqualTo("xxx")); - }); - - json = js.Serialize(p, JsonWriteFormat.Indented); - json.Should().Be("{\n \"code\": \"A\",\n \"DESCRIPTION\": \"B\",\n \"retailPrice\": 1.99,\n \"etag\": \"xxx\"\n}" - .Replace("\n", Environment.NewLine), because: "Line breaks should be preserved in indented JSON with different line endings on Linux and Windows"); - } - - [Test] - public void NewtonsoftJson_Serialize_Deserialize_BinaryData() - { - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - var p = new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m, Secret = "X" }; - var bs = js.SerializeToBinaryData(p); - - p = js.Deserialize(bs); - Assert.That(p, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(p!.Code, Is.EqualTo("A")); - Assert.That(p.Description, Is.EqualTo("B")); - Assert.That(p.RetailPrice, Is.EqualTo(1.99m)); - Assert.That(p.Secret, Is.Null); - }); - - p = (BackendProduct)js.Deserialize(bs, typeof(BackendProduct))!; - Assert.That(p, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(p.Code, Is.EqualTo("A")); - Assert.That(p.Description, Is.EqualTo("B")); - Assert.That(p.RetailPrice, Is.EqualTo(1.99m)); - Assert.That(p.Secret, Is.Null); - }); - } - - [Test] - public void NewtonsoftJson_Serialize_Deserialize_Dynamic() - { - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - var p = new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m, Secret = "X" }; - var json = js.Serialize(p); - - var o = js.Deserialize(json); - Assert.That(o, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(o, Is.InstanceOf()); - Assert.That(((Nsj.Linq.JObject)o!).ToString(Nsj.Formatting.None), Is.EqualTo("{\"code\":\"A\",\"DESCRIPTION\":\"B\",\"retailPrice\":1.99}")); - }); - } - - [Test] - public void NewtonsoftJson_Serialize_Deserialize_Dynamic_BinaryData() - { - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - var p = new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m, Secret = "X" }; - var bs = js.SerializeToBinaryData(p); - - var o = js.Deserialize(bs); - Assert.That(o, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(o, Is.InstanceOf()); - Assert.That(((Nsj.Linq.JObject)o!).ToString(Nsj.Formatting.None), Is.EqualTo("{\"code\":\"A\",\"DESCRIPTION\":\"B\",\"retailPrice\":1.99}")); - }); - } - - [Test] - public void NewtonsoftJson_TryApplyFilter_JsonString() - { - var p = new Person { FirstName = "John", LastName = "Smith", Addresses = new List
{ new() { Street = "One", City = "First" }, new() { Street = "Two", City = "Second" } } }; - - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - Assert.Multiple(() => - { - Assert.That(js.TryApplyFilter(p, new string[] { "LastName", "Addresses.City", "LastName" }, out string json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(json, Is.EqualTo("{\"firstName\":\"John\",\"addresses\":[{\"street\":\"One\"},{\"street\":\"Two\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "LastName", "Addresses.City", "LastName" }, out json), Is.True); - Assert.That(json, Is.EqualTo("{\"lastName\":\"Smith\",\"addresses\":[{\"city\":\"First\"},{\"city\":\"Second\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "lastname", "addresses" }, out json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(json, Is.EqualTo("{\"firstName\":\"John\"}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "lastname", "addresses" }, out json, JsonPropertyFilter.Include), Is.True); - Assert.That(json, Is.EqualTo("{\"lastName\":\"Smith\",\"addresses\":[{\"street\":\"One\",\"city\":\"First\"},{\"street\":\"Two\",\"city\":\"Second\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "firstName" }, out json), Is.True); - Assert.That(json, Is.EqualTo("{\"firstName\":\"John\"}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "middlename" }, out json), Is.True); - Assert.That(json, Is.EqualTo("{}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "middlename" }, out json, JsonPropertyFilter.Exclude), Is.False); - Assert.That(json, Is.EqualTo("{\"firstName\":\"John\",\"lastName\":\"Smith\",\"addresses\":[{\"street\":\"One\",\"city\":\"First\"},{\"street\":\"Two\",\"city\":\"Second\"}]}")); - }); - } - - [Test] - public void NewtonsoftJson_TryApplyFilter_JsonObject() - { - var p = new Person { FirstName = "John", LastName = "Smith", Addresses = new List
{ new() { Street = "One", City = "First" }, new() { Street = "Two", City = "Second" } } }; - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - object json; - - Assert.Multiple(() => - { - Assert.That(js.TryApplyFilter(p, new string[] { "LastName", "Addresses.City", "LastName" }, out json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("{\"firstName\":\"John\",\"addresses\":[{\"street\":\"One\"},{\"street\":\"Two\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "LastName", "Addresses.City", "LastName" }, out json), Is.True); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("{\"lastName\":\"Smith\",\"addresses\":[{\"city\":\"First\"},{\"city\":\"Second\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "lastname", "addresses" }, out json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("{\"firstName\":\"John\"}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "lastname", "addresses" }, out json, JsonPropertyFilter.Include), Is.True); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("{\"lastName\":\"Smith\",\"addresses\":[{\"street\":\"One\",\"city\":\"First\"},{\"street\":\"Two\",\"city\":\"Second\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "firstName" }, out json), Is.True); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("{\"firstName\":\"John\"}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "middlename" }, out json), Is.True); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("{}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "middlename" }, out json, JsonPropertyFilter.Exclude), Is.False); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("{\"firstName\":\"John\",\"lastName\":\"Smith\",\"addresses\":[{\"street\":\"One\",\"city\":\"First\"},{\"street\":\"Two\",\"city\":\"Second\"}]}")); - - Assert.That(js.TryApplyFilter(p, new string[] { "addresses[1].city" }, out json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("{\"firstName\":\"John\",\"lastName\":\"Smith\",\"addresses\":[{\"street\":\"One\",\"city\":\"First\"},{\"street\":\"Two\"}]}")); - }); - - p.Address = new Address { Street = "One", City = "First" }; - Assert.Multiple(() => - { - Assert.That(js.TryApplyFilter(p, new string[] { "address" }, out json, JsonPropertyFilter.Include), Is.True); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("{\"address\":{\"street\":\"One\",\"city\":\"First\"}}")); - }); - } - - [Test] - public void NewtonsoftJson_TryApplyFilter_JsonOArray() - { - var p = new int[] { 11, 22, 333 }; - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - object json; - - Assert.Multiple(() => - { - Assert.That(js.TryApplyFilter(p, new string[] { "[2]" }, out json, JsonPropertyFilter.Include), Is.True); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("[333]")); - }); - - js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - Assert.Multiple(() => - { - Assert.That(js.TryApplyFilter(p, new string[] { "[2]" }, out json, JsonPropertyFilter.Exclude), Is.True); - Assert.That(((Nsj.Linq.JToken)json).ToString(Nsj.Formatting.None), Is.EqualTo("[11,22]")); - }); - } - - [Test] - public void NewtonsoftJson_Serialize_Deserialize_Exceptions() - { - // Arrange - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - Exception realException; - - try { throw new Exception("Test"); } - catch (Exception ex) { realException = ex; } - - // Act - var serialized = js.Serialize(realException); - var deserialized = js.Deserialize(serialized)!; - - // Assert - deserialized.Data.Should().BeEquivalentTo(realException.Data); - deserialized.Message.Should().BeEquivalentTo(realException.Message); - deserialized.StackTrace.Should().BeEquivalentTo(realException.StackTrace); - } - - [Test] - public void NewtonsoftJson_TryGetJsonName() - { - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - var t = typeof(Other); - - Assert.That(js.TryGetJsonName(t.GetProperty(nameof(Other.FirstName))!, out string? jn), Is.True); - Assert.Multiple(() => - { - Assert.That(jn, Is.EqualTo("first_name")); - - Assert.That(js.TryGetJsonName(t.GetProperty(nameof(Other.MiddleName))!, out jn), Is.False); - Assert.That(jn, Is.EqualTo(null)); - - Assert.That(js.TryGetJsonName(t.GetProperty(nameof(Other.LastName))!, out jn), Is.False); - Assert.That(jn, Is.EqualTo(null)); - }); - - // Verify JsonObject usage. - t = typeof(Other2); - Assert.Multiple(() => - { - Assert.That(js.TryGetJsonName(t.GetProperty(nameof(Other2.FirstName))!, out jn), Is.True); - Assert.That(jn, Is.EqualTo("firstName")); - - Assert.That(js.TryGetJsonName(t.GetProperty(nameof(Other2.LastName))!, out jn), Is.False); - Assert.That(jn, Is.EqualTo(null)); - }); - - // Verify ContractResolver STJ marked-up objects. - t = typeof(CoreEx.AspNetCore.WebApis.ExtendedContentResult); - Assert.Multiple(() => - { - Assert.That(js.TryGetJsonName(t.GetProperty(nameof(CoreEx.AspNetCore.WebApis.ExtendedContentResult.AfterExtension))!, out jn), Is.False); - Assert.That(jn, Is.EqualTo(null)); - }); - - t = typeof(CoreEx.Entities.ChangeLog); - Assert.Multiple(() => - { - Assert.That(js.TryGetJsonName(t.GetProperty(nameof(CoreEx.Entities.ChangeLog.CreatedBy))!, out jn), Is.True); - Assert.That(jn, Is.EqualTo("createdBy")); - }); - } - - [Test] - public void NewtonsoftJson_Serialize_DictionaryKeys() - { - var d = new Dictionary { { "ABC", "XXX" }, { "Efg", "Xxx" }, { "hij", "xxx" }, { "AbEfg", "xxXxx" }, { "ETag", "ETAG" } }; - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - var json = js.Serialize(d); - Assert.That(json, Is.EqualTo("{\"abc\":\"XXX\",\"efg\":\"Xxx\",\"hij\":\"xxx\",\"abEfg\":\"xxXxx\",\"etag\":\"ETAG\"}")); - } - - [Test] - public void NewtonsoftJson_Serialize_CollectionResult() - { - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - - // Null object. - var pcr = (PersonCollectionResult?)null; - - var json = js.Serialize(pcr); - Assert.That(json, Is.EqualTo("null")); - - pcr = js.Deserialize(json); - Assert.That(pcr, Is.Null); - - // Empty collection. - pcr = new PersonCollectionResult(); - - json = js.Serialize(pcr); - Assert.That(json, Is.EqualTo("[]")); - - pcr = js.Deserialize(json); - Assert.That(pcr, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(pcr!.Items, Is.Not.Null); - Assert.That(pcr.Items, Is.Empty); - Assert.That(pcr.Paging, Is.Null); - }); - - // Items in collection. - pcr.Items.Add(new Person { FirstName = "Jane" }); - pcr.Items.Add(new Person { FirstName = "John" }); - - json = js.Serialize(pcr); - Assert.That(json, Is.EqualTo("[{\"firstName\":\"Jane\"},{\"firstName\":\"John\"}]")); - - pcr = js.Deserialize(json); - Assert.That(pcr, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(pcr!.Items, Is.Not.Null); - Assert.That(pcr.Items, Has.Count.EqualTo(2)); - Assert.That(pcr.Paging, Is.Null); - Assert.That(pcr.Items[0].FirstName, Is.EqualTo("Jane")); - Assert.That(pcr.Items[1].FirstName, Is.EqualTo("John")); - }); - } - - [Test] - public void Newtonsoft_Serialize_CompositeKey() - { - var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; - - var ck = CompositeKey.Empty; - var json = js.Serialize(ck); - Assert.Multiple(() => - { - Assert.That(json, Is.EqualTo("null")); - Assert.That(js.Deserialize(json), Is.EqualTo(ck)); - }); - - ck = new CompositeKey((int?)null); - json = js.Serialize(ck); - Assert.Multiple(() => - { - Assert.That(json, Is.EqualTo("[null]")); - Assert.That(js.Deserialize(json), Is.EqualTo(ck)); - }); - - ck = new CompositeKey(88); - json = js.Serialize(ck); - Assert.Multiple(() => - { - Assert.That(json, Is.EqualTo("[{\"int\":88}]")); - Assert.That(js.Deserialize(json), Is.EqualTo(ck)); - }); - - ck = new CompositeKey("text", 'x', short.MinValue, int.MinValue, long.MinValue, ushort.MaxValue, uint.MaxValue, long.MaxValue, Guid.Parse("8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc"), - new DateTime(1970, 01, 22, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2000, 01, 22, 20, 59, 43, DateTimeKind.Utc), new DateTimeOffset(2000, 01, 22, 20, 59, 43, TimeSpan.FromHours(-8))); - json = js.Serialize(ck); - Assert.Multiple(() => - { - Assert.That(json, Is.EqualTo("[{\"string\":\"text\"},{\"char\":\"x\"},{\"short\":-32768},{\"int\":-2147483648},{\"long\":-9223372036854775808},{\"ushort\":65535},{\"uint\":4294967295},{\"long\":9223372036854775807},{\"guid\":\"8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc\"},{\"datetime\":\"1970-01-22T00:00:00\"},{\"datetime\":\"2000-01-22T20:59:43Z\"},{\"datetimeoffset\":\"2000-01-22T20:59:43-08:00\"}]")); - Assert.That(js.Deserialize(json), Is.EqualTo(ck)); - }); - } - - #endregion - - public class Person - { - public string? FirstName { get; set; } - public string? LastName { get; set; } - public List
? Addresses { get; set; } - public string? SSN { get; set; } - public decimal NetWorth { get; set; } - public bool? IsAwesome { get; set; } - public Address? Address { get; set; } - } - - public class Address - { - public string? Street { get; set; } - public string? City { get; set; } - } - - public class PersonCollection : List { } - - public class PersonCollectionResult : CollectionResult { } - - public class Other - { - [Stj.Serialization.JsonPropertyName("first-name")] - [Nsj.JsonProperty("first_name")] - public string? FirstName { get; set; } - [Stj.Serialization.JsonIgnore] - public string? MiddleName { get; set; } - [Nsj.JsonIgnore] - public string? LastName { get; set; } - } - - [Nsj.JsonObject(MemberSerialization = Nsj.MemberSerialization.OptIn)] - public class Other2 - { - [Nsj.JsonProperty("firstName")] - public string? FirstName { get; set; } - public string? LastName { get; set; } - } - - public class BackendProduct - { - [Nsj.JsonProperty("code")] - [Stj.Serialization.JsonPropertyName("code")] - public string? Code { get; set; } - - [Nsj.JsonProperty("DESCRIPTION")] - [Stj.Serialization.JsonPropertyName("DESCRIPTION")] - public string? Description { get; set; } - - [Nsj.JsonProperty("retailPrice")] - [Stj.Serialization.JsonPropertyName("retailPrice")] - public decimal RetailPrice { get; set; } - - [Nsj.JsonIgnore] - [Stj.Serialization.JsonIgnore] - public string? Secret { get; set; } - - [Nsj.JsonIgnore] - [Stj.Serialization.JsonIgnore] - public CompositeKey PrimaryKey => new(Code); - - public string? ETag { get; set; } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Json/Mapping/JsonObjectMapperTest.cs b/tests/CoreEx.Test/Framework/Json/Mapping/JsonObjectMapperTest.cs deleted file mode 100644 index 61c25678..00000000 --- a/tests/CoreEx.Test/Framework/Json/Mapping/JsonObjectMapperTest.cs +++ /dev/null @@ -1,79 +0,0 @@ -using NUnit.Framework; -using System; -using CoreEx.Entities; -using CoreEx.Mapping.Converters; -using CoreEx.Json.Mapping; -using System.Text.Json.Nodes; -using System.Text.Json; - -namespace CoreEx.Test.Framework.Json.Mapping -{ - [TestFixture] - internal class JsonObjectMapperTest - { - [Test] - public void MapToJson() - { - var p = new Person { Id = 1.ToGuid().ToString(), Name = "Bob", Address = new Address { Street = "sss", City = "ccc" }, Address2 = new Address { Street = "ttt", City = "ddd" }, Other = new DateTime(2000, 01, 01, 02, 58, 34) }; - - var pdm = new PersonJsonMapper(); - var json = new JsonObject(); - pdm.MapToJson(p, json); - - Assert.That(json.ToJsonString(new JsonSerializerOptions { WriteIndented = false }), Is.EqualTo("{\"id\":\"00000001-0000-0000-0000-000000000000\",\"nname\":\"Bob\",\"street\":\"sss\",\"town\":\"ccc\",\"address2\":{\"street\":\"ttt\",\"city\":\"ddd\"},\"other\":\"01/01/2000 02:58:34\"}")); - } - - [Test] - public void MapFromJson() - { - var json = JsonNode.Parse("{\"id\":\"00000001-0000-0000-0000-000000000000\",\"nname\":\"Bob\",\"street\":\"sss\",\"town\":\"ccc\",\"address2\":{\"street\":\"ttt\",\"city\":\"ddd\"},\"other\":\"01/01/2000 02:58:34\"}")!; - - var pdm = new PersonJsonMapper(); - var p = pdm.MapFromJson(json.AsObject())!; - - Assert.Multiple(() => - { - Assert.That(p.Id, Is.EqualTo(1.ToGuid().ToString())); - Assert.That(p.Name, Is.EqualTo("Bob")); - Assert.That(p.Address?.Street, Is.EqualTo("sss")); - Assert.That(p.Address?.City, Is.EqualTo("ccc")); - Assert.That(p.Address2?.Street, Is.EqualTo("ttt")); - Assert.That(p.Address2?.City, Is.EqualTo("ddd")); - Assert.That(p.Other, Is.EqualTo(new DateTime(2000, 01, 01, 02, 58, 34))); - }); - } - } - - public class PersonJsonMapper : JsonObjectMapper - { - private readonly JsonObjectMapper
_addressMapper = JsonObjectMapper.CreateAuto
("City").HasProperty(x => x.City, "town"); - - public PersonJsonMapper() - { - InheritPropertiesFrom(JsonObjectMapper.CreateAuto()); - Property(x => x.Name, "nname"); - Property(x => x.Address).SetMapper(_addressMapper); - Property(x => x.Address2); - Property(x => x.Other).SetConverter(new DateTimeToStringConverter()); - } - } - - public class PersonBase : IIdentifier - { - public string? Id { get; set; } - } - - public class Person : PersonBase - { - public string? Name { get; set; } - public Address? Address { get; set; } - public Address? Address2 { get; set; } - public DateTime? Other { get; set; } - } - - public class Address - { - public string? Street { get; set; } - public string? City { get; set; } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Json/Merge/Extended/JsonMergePatchExTest.cs b/tests/CoreEx.Test/Framework/Json/Merge/Extended/JsonMergePatchExTest.cs deleted file mode 100644 index cf9e773e..00000000 --- a/tests/CoreEx.Test/Framework/Json/Merge/Extended/JsonMergePatchExTest.cs +++ /dev/null @@ -1,1370 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Json.Merge; -using CoreEx.Json.Merge.Extended; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Json.Merge -{ - [TestFixture] - public class JsonMergePatchExTest - { - public class SubData - { - public string? Code { get; set; } - public string? Text { get; set; } - public int Count { get; set; } - } - - public class KeyData : IPrimaryKey - { - public string? Code { get; set; } - public string? Text { get; set; } - public string? Other { get; set; } - public CompositeKey PrimaryKey => new(Code); - } - - public class KeyDataCollection : EntityKeyCollection { } - - public class NonKeyData - { - public string? Code { get; set; } - - public string? Text { get; set; } - } - - public class NonKeyDataCollection : List { } - - public class TestData - { - public Guid Id { get; set; } - public string? Name { get; set; } - [JsonIgnore] - public string? Ignore { get; set; } - public bool IsValid { get; set; } - public DateTime Date { get; set; } - public int Count { get; set; } - public decimal Amount { get; set; } - public SubData? Sub { get; set; } - public int[]? Values { get; set; } - public List? NoKeys { get; set; } - public List? Keys { get; set; } - public KeyDataCollection? KeysColl { get; set; } - public NonKeyDataCollection? NonKeys { get; set; } - public Dictionary? Dict { get; set; } - public Dictionary? Dict2 { get; set; } - } - - [Test] - public void Merge_NullJsonArgument() - { - var td = new TestData(); - Assert.Throws(() => { new JsonMergePatchEx().Merge(null!, ref td); }); - } - - [Test] - public void Merge_Malformed() - { - var td = new TestData(); - var ex = Assert.Throws(() => new JsonMergePatchEx().Merge(BinaryData.FromString(""), ref td)); - Assert.That(ex!.Message, Is.EqualTo("'<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.")); - } - - [Test] - public void Merge_Empty() - { - var td = new TestData(); - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ }"), ref td), Is.False); - } - - [Test] - public void Merge_Int() - { - int i = 1; - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("1"), ref i), Is.False); - - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("2"), ref i), Is.True); - Assert.That(i, Is.EqualTo(2)); - } - - [Test] - public void Merge_Property_StringValue() - { - var td = new TestData { Name = "Fred" }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"name\": \"Barry\" }"), ref td), Is.True); - Assert.That(td!.Name, Is.EqualTo("Barry")); - }); - } - - [Test] - public void Merge_Property_StringValue_DifferentNameCasingSupported() - { - var td = new TestData { Name = "Fred" }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"nAmE\": \"Barry\" }"), ref td), Is.True); - Assert.That(td!.Name, Is.EqualTo("Barry")); - }); - } - - [Test] - public void Merge_Property_StringValue_DifferentNameCasingNotSupported() - { - var td = new TestData { Name = "Fred" }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { PropertyNameComparer = StringComparer.Ordinal }).Merge(BinaryData.FromString("{ \"nAmE\": \"Barry\" }"), ref td), Is.False); - Assert.That(td!.Name, Is.EqualTo("Fred")); - }); - } - - [Test] - public void Merge_Property_StringNull() - { - var td = new TestData { Name = "Fred" }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"name\": null }"), ref td), Is.True); - Assert.That(td!.Name, Is.Null); - }); - } - - [Test] - public void Merge_Property_StringNumberValue() - { - var td = new TestData { Name = "Fred" }; - var ex = Assert.Throws(() => new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"name\": 123 }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.String. Path: $.name | LineNumber: 0 | BytePositionInLine: 13.")); - } - - [Test] - public void Merge_Property_String_MalformedA() - { - var td = new TestData(); - var ex = Assert.Throws(() => new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"name\": [ \"Barry\" ] }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.String. Path: $.name | LineNumber: 0 | BytePositionInLine: 11.")); - } - - [Test] - public void Merge_PrimitiveTypesA() - { - var td = new TestData(); - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58 }"), ref td), Is.True); - - Assert.That(td!.Id, Is.EqualTo(new Guid("13512759-4f50-e911-b35c-bc83850db74d"))); - Assert.That(td.Name, Is.EqualTo("Barry")); - Assert.That(td.IsValid, Is.True); - Assert.That(td.Date, Is.EqualTo(new DateTime(2018, 12, 31))); - Assert.That(td.Count, Is.EqualTo(12)); - }); - Assert.That(td.Amount, Is.EqualTo(132.58m)); - } - - [Test] - public void Merge_PrimitiveTypes_NonCached_X100() - { - for (int i = 0; i < 100; i++) - { - var td = new TestData(); - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58 }"), ref td), Is.True); - } - } - - [Test] - public void Merge_PrimitiveTypes_Cached_X100() - { - var jom = new JsonMergePatchEx(); - for (int i = 0; i < 100; i++) - { - var td = new TestData(); - Assert.That(jom.Merge(BinaryData.FromString("{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58 }"), ref td), Is.True); - } - } - - [Test] - public void Merge_Property_SubEntityNull() - { - var td = new TestData { Sub = new SubData() }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"sub\": null }"), ref td), Is.True); - Assert.That(td!.Sub, Is.Null); - }); - } - - [Test] - public void Merge_Property_SubEntityNewEmpty() - { - var td = new TestData(); - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"sub\": { } }"), ref td), Is.True); - Assert.That(td!.Sub, Is.Not.Null); - Assert.That(td.Sub!.Code, Is.Null); - Assert.That(td.Sub.Text, Is.Null); - }); - } - - [Test] - public void Merge_Property_SubEntityExistingEmpty() - { - var td = new TestData { Sub = new SubData() }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"sub\": { } }"), ref td), Is.False); - Assert.That(td!.Sub, Is.Not.Null); - Assert.That(td.Sub!.Code, Is.Null); - Assert.That(td.Sub.Text, Is.Null); - }); - } - - [Test] - public void Merge_Property_SubEntityExistingChanged() - { - var td = new TestData { Sub = new SubData() }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"sub\": { \"code\": \"x\", \"text\": \"xxx\" } }"), ref td), Is.True); - Assert.That(td!.Sub, Is.Not.Null); - Assert.That(td.Sub!.Code, Is.EqualTo("x")); - Assert.That(td.Sub.Text, Is.EqualTo("xxx")); - }); - } - - [Test] - public void Merge_Property_ArrayMalformed() - { - var td = new TestData(); - var ex = Assert.Throws(() => new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"values\": { } }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.Int32[]. Path: $.values | LineNumber: 0 | BytePositionInLine: 13.")); - } - - [Test] - public void Merge_Property_ArrayNull() - { - var td = new TestData { Values = new int[] { 1, 2, 3 } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"values\": null }"), ref td), Is.True); - Assert.That(td!.Values, Is.Null); - }); - } - - [Test] - public void Merge_Property_ArrayEmpty() - { - var td = new TestData { Values = new int[] { 1, 2, 3 } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"values\": [] }"), ref td), Is.True); - Assert.That(td!.Values, Is.Not.Null); - Assert.That(td.Values!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_ArrayValues_NoChanges() - { - var td = new TestData { Values = new int[] { 1, 2, 3 } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"values\": [ 1, 2, 3] }"), ref td), Is.False); - Assert.That(td!.Values, Is.Not.Null); - Assert.That(td.Values!, Has.Length.EqualTo(3)); - }); - } - - [Test] - public void Merge_Property_ArrayValues_Changes() - { - var td = new TestData { Values = new int[] { 1, 2, 3 } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"values\": [ 3, 2, 1] }"), ref td), Is.True); - Assert.That(td!.Values, Is.Not.Null); - Assert.That(td.Values!, Has.Length.EqualTo(3)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Values[0], Is.EqualTo(3)); - Assert.That(td.Values[1], Is.EqualTo(2)); - Assert.That(td.Values[2], Is.EqualTo(1)); - }); - } - - [Test] - public void Merge_Property_NoKeys_ListNull() - { - var td = new TestData { NoKeys = new List { new() } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"nokeys\": null }"), ref td), Is.True); - Assert.That(td!.Values, Is.Null); - }); - } - - [Test] - public void Merge_Property_NoKeys_ListEmpty() - { - var td = new TestData { NoKeys = new List { new() } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"nokeys\": [ ] }"), ref td), Is.True); - Assert.That(td!.NoKeys, Is.Not.Null); - Assert.That(td!.NoKeys!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_NoKeys_List() - { - var td = new TestData { NoKeys = new List { new() } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"nokeys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, { }, null ] }"), ref td), Is.True); - Assert.That(td!.NoKeys, Is.Not.Null); - Assert.That(td.NoKeys!, Has.Count.EqualTo(3)); - Assert.That(td.NoKeys![0], Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(td.NoKeys[0].Code, Is.EqualTo("abc")); - Assert.That(td.NoKeys[0].Text, Is.EqualTo("xyz")); - Assert.That(td.NoKeys[1], Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(td.NoKeys[1].Code, Is.Null); - Assert.That(td.NoKeys[1].Text, Is.Null); - Assert.That(td.NoKeys[2], Is.Null); - }); - } - - [Test] - public void Merge_Property_Keys_ListNull() - { - var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"keys\": null }"), ref td), Is.True); - Assert.That(td!.Keys, Is.Null); - }); - } - - [Test] - public void Merge_Property_Keys_ListEmpty() - { - var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"keys\": [ ] }"), ref td), Is.True); - - Assert.That(td!.Keys, Is.Not.Null); - Assert.That(td.Keys!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_Keys_Null() - { - var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"keys\": [ null ] }"), ref td), Is.True); - - Assert.That(td!.Keys, Is.Not.Null); - Assert.That(td.Keys!, Has.Count.EqualTo(1)); - Assert.That(td.Keys![0], Is.Null); - }); - } - - [Test] - public void Merge_Property_Keys_Replace() - { - var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"keys\": [ { \"code\": \"abc\" }, { \"code\": \"uvw\", \"text\": \"xyz\" } ] }"), ref td), Is.True); - - Assert.That(td!.Keys, Is.Not.Null); - Assert.That(td.Keys!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Keys[0].Code, Is.EqualTo("abc")); - Assert.That(td.Keys[0].Text, Is.EqualTo(null)); - Assert.That(td.Keys[1].Code, Is.EqualTo("uvw")); - Assert.That(td.Keys[1].Text, Is.EqualTo("xyz")); - }); - } - - [Test] - public void Merge_Property_Keys_NoChanges() - { - // Note, although technically no changes, there is no means to verify without specific equality checking, so is seen as a change. - var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{ \"keys\": [ { \"code\": \"abc\", \"text\": \"def\" } ] }"), ref td), Is.True); - - Assert.That(td!.Keys, Is.Not.Null); - Assert.That(td.Keys!, Has.Count.EqualTo(1)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Keys[0].Code, Is.EqualTo("abc")); - Assert.That(td.Keys[0].Text, Is.EqualTo("def")); - }); - } - - [Test] - public void Merge_Property_KeysColl_ListNull() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": null }"), ref td), Is.True); - Assert.That(td!.Values, Is.Null); - }); - } - - [Test] - public void Merge_Property_KeysColl_ListEmpty() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ ] }"), ref td), Is.True); - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_KeysColl_Null() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ null ] }"), ref td), Is.True); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(1)); - Assert.That(td.KeysColl![0], Is.Null); - }); - } - - [Test] - public void Merge_Property_KeysColl_DuplicateNulls() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; - var ex = Assert.Throws(() => new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ null, null ] }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON array must not contain items with duplicate 'IEntityKey' keys. Path: $.keyscoll")); - } - - [Test] - public void Merge_Property_KeysColl_DuplicateVals1() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; - var ex = Assert.Throws(() => new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { }, { } ] }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON array must not contain items with duplicate 'IEntityKey' keys. Path: $.keyscoll")); - } - - [Test] - public void Merge_Property_KeysColl_DuplicateVals2() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData() } }; - var ex = Assert.Throws(() => new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\" }, { \"code\": \"a\" } ] }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON array must not contain items with duplicate 'IEntityKey' keys. Path: $.keyscoll")); - } - - [Test] - public void Merge_Property_KeysColl_DuplicateVals_Dest() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData(), new KeyData() } }; - var ex = Assert.Throws(() => new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { } ] }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON array destination collection must not contain items with duplicate 'IEntityKey' keys prior to merge. Path: $.keyscoll")); - } - - [Test] - public void Merge_Property_KeysColl_Null_NoChanges() - { - var td = new TestData { }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": null }"), ref td), Is.False); - - Assert.That(td!.KeysColl, Is.Null); - }); - } - - [Test] - public void Merge_Property_KeysColl_Empty_NoChanges() - { - var td = new TestData { KeysColl = new KeyDataCollection() }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ ] }"), ref td), Is.False); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_KeysColl_NullItem_NoChanges() - { - var td = new TestData { KeysColl = new KeyDataCollection { null! } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ null ] }"), ref td), Is.False); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(1)); - Assert.That(td.KeysColl![0], Is.Null); - }); - } - - [Test] - public void Merge_Property_KeysColl_Item_NoChanges() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\" } ] }"), ref td), Is.False); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(1)); - Assert.That(td.KeysColl![0].Code, Is.EqualTo("a")); - }); - } - - [Test] - public void Merge_Property_KeysColl_KeyedItem_Changes() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\", \"text\": \"zz\" }, { \"code\": \"b\" } ] }"), ref td), Is.True); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); - }); - Assert.Multiple(() => - { - Assert.That(td.KeysColl[0].Code, Is.EqualTo("a")); - Assert.That(td.KeysColl[0].Text, Is.EqualTo("zz")); - Assert.That(td.KeysColl[1].Code, Is.EqualTo("b")); - Assert.That(td.KeysColl[1].Text, Is.EqualTo("bb")); - }); - } - - [Test] - public void Merge_Property_KeysColl_KeyedItem_SequenceChanges() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"b\", \"text\": \"yy\" }, { \"code\": \"a\" } ] }"), ref td), Is.True); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.KeysColl[0].Code, Is.EqualTo("b")); - Assert.That(td.KeysColl[0].Text, Is.EqualTo("yy")); - Assert.That(td.KeysColl[1].Code, Is.EqualTo("a")); - Assert.That(td.KeysColl[1].Text, Is.EqualTo("aa")); - }); - } - - [Test] - public void Merge_Property_KeysColl_KeyedItem_AllNew() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"y\", \"text\": \"yy\" }, { \"code\": \"z\", \"text\": \"zz\" } ] }"), ref td), Is.True); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.KeysColl[0].Code, Is.EqualTo("y")); - Assert.That(td.KeysColl[0].Text, Is.EqualTo("yy")); - Assert.That(td.KeysColl[1].Code, Is.EqualTo("z")); - Assert.That(td.KeysColl[1].Text, Is.EqualTo("zz")); - }); - } - - [Test] - public void Merge_Property_KeysColl_KeyedItem_Delete() - { - var td = new TestData { KeysColl = new KeyDataCollection { new KeyData { Code = "a", Text = "aa" }, new KeyData { Code = "b", Text = "bb" }, new KeyData { Code = "c", Text = "cc" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"keyscoll\": [ { \"code\": \"a\" }, { \"code\": \"c\" } ] }"), ref td), Is.True); - - Assert.That(td!.KeysColl, Is.Not.Null); - Assert.That(td.KeysColl!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.KeysColl[0].Code, Is.EqualTo("a")); - Assert.That(td.KeysColl[0].Text, Is.EqualTo("aa")); - Assert.That(td.KeysColl[1].Code, Is.EqualTo("c")); - Assert.That(td.KeysColl[1].Text, Is.EqualTo("cc")); - }); - } - - // *** Dictionary - DictionaryMergeApproach.Replace - - [Test] - public void Merge_Property_DictReplace_Null() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{ \"dict\": null }"), ref td), Is.True); - Assert.That(td!.Dict, Is.Null); - }); - } - - [Test] - public void Merge_Property_DictReplace_Empty() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{ \"dict\": {} }"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_DictReplace_NullValue() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{ \"dict\": {\"k\":null} }"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo(null)); - }); - } - - [Test] - public void Merge_Property_DictReplace_DuplicateKeys_IntoNull() - { - var td = new TestData(); - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo("v2")); - }); - } - - [Test] - public void Merge_Property_DictReplace_DuplicateKeys_IntoEmpty() - { - var td = new TestData { Dict = new Dictionary() }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo("v2")); - }); - } - - [Test] - public void Merge_Property_DictReplace_NoChange() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k1\":\"v1\"}}"), ref td), Is.False); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("v")); - Assert.That(td.Dict["k1"], Is.EqualTo("v1")); - }); - } - - [Test] - public void Merge_Property_DictReplace_ReOrder_NoChange() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k1\":\"v1\",\"k\":\"v\"}}"), ref td), Is.False); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("v")); - Assert.That(td.Dict["k1"], Is.EqualTo("v1")); - }); - } - - [Test] - public void Merge_Property_DictReplace_AddUpdateDelete_Replace() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k2\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("v")); - Assert.That(td.Dict["k2"], Is.EqualTo("v2")); - }); - } - - // *** Dictionary - DictionaryMergeApproach.Merge - - [Test] - public void Merge_Property_DictMerge_Null() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"dict\": null }"), ref td), Is.True); - Assert.That(td!.Dict, Is.Null); - }); - } - - [Test] - public void Merge_Property_DictMerge_Empty() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - // Should result in no changes as no property (key) was provided. - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"dict\": {} }"), ref td), Is.False); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo("v")); - }); - } - - [Test] - public void Merge_Property_DictMerge_NullValue() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - // A key with a value of null indicates it should be removed. - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{ \"dict\": {\"k\":null} }"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_DictMerge_DuplicateKeys_IntoNull() - { - var td = new TestData { }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo("v2")); - }); - } - - [Test] - public void Merge_Property_DictMerge_DuplicateKeys_IntoEmpty() - { - var td = new TestData { Dict = new Dictionary() }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo("v2")); - }); - } - - [Test] - public void Merge_Property_DictMerge_NoChange() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k1\":\"v1\"}}"), ref td), Is.False); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("v")); - Assert.That(td.Dict["k1"], Is.EqualTo("v1")); - }); - } - - [Test] - public void Merge_Property_DictMerge_ReOrder_NoChange() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict\":{\"k1\":\"v1\",\"k\":\"v\"}}"), ref td), Is.False); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("v")); - Assert.That(td.Dict["k1"], Is.EqualTo("v1")); - }); - } - - [Test] - public void Merge_Property_DictMerge_AddUpdateDelete() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"vx\",\"k2\":\"v2\",\"k1\":null}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("vx")); - Assert.That(td.Dict["k2"], Is.EqualTo("v2")); - }); - } - - [Test] - public void Merge_Property_DictMerge_AddUpdate() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"vx\",\"k2\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(3)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("vx")); - Assert.That(td.Dict["k1"], Is.EqualTo("v1")); - Assert.That(td.Dict["k2"], Is.EqualTo("v2")); - }); - } - - // *** - - [Test] - public void Merge_Property_Dict2Replace_DuplicateKeys_IntoEmpty() - { - var td = new TestData { Dict2 = new Dictionary() }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"a\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(1)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("bb")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("bbb")); - }); - } - - [Test] - public void Merge_Property_Dict2Replace_NoChange() - { - // Note, although technically no changes, there is no means to verify without specific equality checking, so is seen as a change. - var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"b\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("aa")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); - Assert.That(td.Dict2["b"].Code, Is.EqualTo("bb")); - Assert.That(td.Dict2["b"].Text, Is.EqualTo("bbb")); - }); - } - - [Test] - public void Merge_Property_Dict2Replace_AddUpdateDelete_Replace() - { - var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("aaaa")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo(null)); - Assert.That(td.Dict2["c"].Code, Is.EqualTo("cc")); - Assert.That(td.Dict2["c"].Text, Is.EqualTo("ccc")); - }); - } - - // *** - - [Test] - public void Merge_Property_KeyDict2Merge_DuplicateKeys_IntoEmpty() - { - var td = new TestData { Dict2 = new Dictionary() }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"a\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(1)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("bb")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("bbb")); - }); - } - - [Test] - public void Merge_Property_KeyDict2Merge_NoChange() - { - var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"b\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.False); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("aa")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); - Assert.That(td.Dict2["b"].Code, Is.EqualTo("bb")); - Assert.That(td.Dict2["b"].Text, Is.EqualTo("bbb")); - }); - } - - [Test] - public void Merge_Property_KeyDict2Merge_AddUpdateDelete() - { - var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"},\"b\":null}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("aaaa")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); - Assert.That(td.Dict2["c"].Code, Is.EqualTo("cc")); - Assert.That(td.Dict2["c"].Text, Is.EqualTo("ccc")); - }); - } - - [Test] - public void Merge_Property_KeyDict2Merge_AddUpdate() - { - var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(3)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("aaaa")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); - Assert.That(td.Dict2["b"].Code, Is.EqualTo("bb")); - Assert.That(td.Dict2["b"].Text, Is.EqualTo("bbb")); - Assert.That(td.Dict2["c"].Code, Is.EqualTo("cc")); - Assert.That(td.Dict2["c"].Text, Is.EqualTo("ccc")); - }); - } - - // *** - - [Test] - public void Merge_XLoadTest_NoCache_1_1000() - { - for (int i = 0; i < 1000; i++) - { - var td = JsonMergePatchTest._testData; - new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge, DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString(JsonMergePatchTest._text), ref td); - } - } - - [Test] - public void Merge_XLoadTest_WithCache_1_1000() - { - var jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge, DictionaryMergeApproach = DictionaryMergeApproach.Merge }); - for (int i = 0; i < 1000; i++) - { - var td = JsonMergePatchTest._testData; - jmp.Merge(BinaryData.FromString(JsonMergePatchTest._text), ref td); - } - } - - [Test] - public void Merge_XLoadTest_NoCache_2_1000() - { - for (int i = 0; i < 1000; i++) - { - var td = JsonMergePatchTest._testData; - new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge, DictionaryMergeApproach = DictionaryMergeApproach.Merge }).Merge(BinaryData.FromString(JsonMergePatchTest._text), ref td); - } - } - - [Test] - public void Merge_XLoadTest_WithCache_2_1000() - { - var jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { EntityKeyCollectionMergeApproach = EntityKeyCollectionMergeApproach.Merge, DictionaryMergeApproach = DictionaryMergeApproach.Merge }); - for (int i = 0; i < 1000; i++) - { - var td = JsonMergePatchTest._testData; - jmp.Merge(BinaryData.FromString(JsonMergePatchTest._text), ref td); - } - } - - [Test] - public async Task Merge_XLoadTest_WithRead_10000() - { - var jmp = new JsonMergePatchEx(); - - for (int i = 0; i < 10000; i++) - { - var td = new TestData { Values = new int[] { 1, 2, 3 }, Keys = new List { new() { Code = "abc", Text = "def" } }, Dict = new Dictionary() { { "a", "b" } }, Dict2 = new Dictionary { { "x", new KeyData { Code = "xx" } } } }; - _ = await jmp.MergeWithResultAsync(new BinaryData(JsonMergePatchTest._text), async (v, _) => await Task.FromResult(v).ConfigureAwait(false)); - } - } - - // ** - - [Test] - public void MergeInvalidMergeValueForType() - { - var jmp = new JsonMergePatchEx(); - var td = new TestData(); - var ex = Assert.Throws(() => jmp.Merge(BinaryData.FromString("""{ "id": { "value": "123" }}"""), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.Guid. Path: $.id | LineNumber: 0 | BytePositionInLine: 9.")); - } - - [Test] - public void Merge_RootSimple_NullString() - { - string? s = null; - var jmp = new JsonMergePatchEx(); - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref s), Is.False); - Assert.That(s, Is.EqualTo(null)); - }); - - s = "x"; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref s), Is.True); - Assert.That(s, Is.EqualTo(null)); - }); - - s = null; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("\"x\""), ref s), Is.True); - Assert.That(s, Is.EqualTo("x")); - }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("\"x\""), ref s), Is.False); - Assert.That(s, Is.EqualTo("x")); - }); - } - - [Test] - public void Merge_RootSimple_NullInt() - { - int? i = null; - var jmp = new JsonMergePatchEx(); - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref i), Is.False); - Assert.That(i, Is.EqualTo(null)); - }); - - i = 88; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref i), Is.True); - Assert.That(i, Is.EqualTo(null)); - }); - - i = null; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("88"), ref i), Is.True); - Assert.That(i, Is.EqualTo(88)); - }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("88"), ref i), Is.False); - Assert.That(i, Is.EqualTo(88)); - }); - } - - [Test] - public void Merge_RootSimple_Int() - { - int i = 0; - var jmp = new JsonMergePatchEx(); - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("0"), ref i), Is.False); - Assert.That(i, Is.EqualTo(0)); - - Assert.That(jmp.Merge(BinaryData.FromString("88"), ref i), Is.True); - Assert.That(i, Is.EqualTo(88)); - }); - } - - [Test] - public void Merge_RootComplex_Null() - { - SubData? sd = null!; - var jmp = new JsonMergePatchEx(); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref sd), Is.False); - Assert.That(sd, Is.Null); - }); - - sd = new SubData { Code = "X" }; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref sd), Is.True); - Assert.That(sd, Is.Null); - }); - - sd = null; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("{\"code\":\"x\"}"), ref sd), Is.True); - Assert.That(sd, Is.Not.Null); - }); - Assert.That(sd!.Code, Is.EqualTo("x")); - } - - [Test] - public void Merge_RootArray_Simple_Null() - { - var arr = Array.Empty(); - var jmp = new JsonMergePatchEx(); - jmp.Merge(BinaryData.FromString("null"), ref arr); - Assert.That(arr, Is.EqualTo(null)); - - arr = new int[] { 1, 2 }; - jmp.Merge(BinaryData.FromString("null"), ref arr); - Assert.That(arr, Is.EqualTo(null)); - - int[]? arr2 = null; - jmp.Merge(BinaryData.FromString("null"), ref arr2); - Assert.That(arr2, Is.EqualTo(null)); - - arr2 = new int[] { 1, 2 }; - jmp.Merge(BinaryData.FromString("null"), ref arr2); - Assert.That(arr2, Is.EqualTo(null)); - } - - [Test] - public void Merge_RootArray_Simple() - { - var arr = Array.Empty(); - var jmp = new JsonMergePatchEx(); - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("[1,2,3]"), ref arr), Is.True); - Assert.That(arr, Is.EqualTo(new int[] { 1, 2, 3 })); - }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("[1,2,3]"), ref arr), Is.False); - Assert.That(arr, Is.EqualTo(new int[] { 1, 2, 3 })); - }); - } - - [Test] - public void Merge_RootArray_Complex() - { - var arr = new SubData[] { new() { Code = "a", Text = "aa" }, new() { Code = "b", Text = "bb" } }; - var jmp = new JsonMergePatchEx(); - - Assert.Multiple(() => - { - // No equality checker so will appear as changed - is a replacement. - Assert.That(jmp.Merge(BinaryData.FromString("[{\"code\":\"a\",\"text\":\"aa\"},{\"code\":\"b\",\"text\":\"bb\"}]"), ref arr), Is.True); - Assert.That(arr!, Has.Length.EqualTo(2)); - - // Replaced. - Assert.That(jmp.Merge(BinaryData.FromString("[{\"code\":\"c\",\"text\":\"cc\"},{\"code\":\"b\",\"text\":\"bb\"}]"), ref arr), Is.True); - Assert.That(arr!, Has.Length.EqualTo(2)); - Assert.That(arr![0].Code, Is.EqualTo("c")); - }); - } - - [Test] - public void Merge_RootDictionary_Simple() - { - var dict = new Dictionary(); - var jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.True); - Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); - }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.False); - Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); - }); - - dict = new Dictionary(); - jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.True); - Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); - }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.False); - Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); - }); - } - - [Test] - public void Merge_RootDictionary_Complex() - { - var dict = new Dictionary(); - var jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Merge }); - - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":{\"code\":\"xx\"},\"y\":{\"code\":\"yy\",\"text\":\"YY\"}}"), ref dict), Is.True); - Assert.Multiple(() => - { - Assert.That(dict, Is.Not.Null); - Assert.That(dict!, Has.Count.EqualTo(2)); - Assert.That(dict!["x"].Code, Is.EqualTo("xx")); - Assert.That(dict!["x"].Text, Is.EqualTo(null)); - Assert.That(dict!["y"].Code, Is.EqualTo("yy")); - Assert.That(dict!["y"].Text, Is.EqualTo("YY")); - }); - - Assert.That(jmp.Merge(BinaryData.FromString("{\"y\":{\"code\":\"yyy\"},\"x\":{\"code\":\"xxx\"}}"), ref dict), Is.True); - Assert.Multiple(() => - { - Assert.That(dict, Is.Not.Null); - Assert.That(dict!, Has.Count.EqualTo(2)); - Assert.That(dict!["x"].Code, Is.EqualTo("xxx")); - Assert.That(dict!["x"].Text, Is.EqualTo(null)); - Assert.That(dict!["y"].Code, Is.EqualTo("yyy")); - Assert.That(dict!["y"].Text, Is.EqualTo("YY")); - }); - - // -- - - dict = new Dictionary(); - jmp = new JsonMergePatchEx(new JsonMergePatchExOptions { DictionaryMergeApproach = DictionaryMergeApproach.Replace }); - - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":{\"code\":\"xx\"},\"y\":{\"code\":\"yy\",\"text\":\"YY\"}}"), ref dict), Is.True); - Assert.Multiple(() => - { - Assert.That(dict, Is.Not.Null); - Assert.That(dict!, Has.Count.EqualTo(2)); - Assert.That(dict!["x"].Code, Is.EqualTo("xx")); - Assert.That(dict!["x"].Text, Is.EqualTo(null)); - Assert.That(dict!["y"].Code, Is.EqualTo("yy")); - Assert.That(dict!["y"].Text, Is.EqualTo("YY")); - }); - - Assert.That(jmp.Merge(BinaryData.FromString("{\"y\":{\"code\":\"yyy\"},\"x\":{\"code\":\"xxx\"}}"), ref dict), Is.True); - Assert.Multiple(() => - { - - Assert.That(dict, Is.Not.Null); - Assert.That(dict!, Has.Count.EqualTo(2)); - Assert.That(dict!["x"].Code, Is.EqualTo("xxx")); - Assert.That(dict!["x"].Text, Is.EqualTo(null)); - Assert.That(dict!["y"].Code, Is.EqualTo("yyy")); - Assert.That(dict!["y"].Text, Is.EqualTo(null)); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Json/Merge/JsonMergePatchTest.cs b/tests/CoreEx.Test/Framework/Json/Merge/JsonMergePatchTest.cs deleted file mode 100644 index e100dee4..00000000 --- a/tests/CoreEx.Test/Framework/Json/Merge/JsonMergePatchTest.cs +++ /dev/null @@ -1,963 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Json.Merge; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Json.Merge -{ - [TestFixture] - public class JsonMergePatchTest - { - public class SubData - { - public string? Code { get; set; } - public string? Text { get; set; } - public int Count { get; set; } - } - - public class KeyData : IPrimaryKey - { - public string? Code { get; set; } - public string? Text { get; set; } - public string? Other { get; set; } - public CompositeKey PrimaryKey => new(Code); - } - - public class KeyDataCollection : EntityKeyCollection { } - - public class NonKeyData - { - public string? Code { get; set; } - - public string? Text { get; set; } - } - - public class NonKeyDataCollection : List { } - - public class TestData - { - public Guid Id { get; set; } - public string? Name { get; set; } - [JsonIgnore] - public string? Ignore { get; set; } - public bool IsValid { get; set; } - public DateTime Date { get; set; } - public int Count { get; set; } - public decimal Amount { get; set; } - public SubData? Sub { get; set; } - public int[]? Values { get; set; } - public List? NoKeys { get; set; } - public List? Keys { get; set; } - public KeyDataCollection? KeysColl { get; set; } - public NonKeyDataCollection? NonKeys { get; set; } - public Dictionary? Dict { get; set; } - public Dictionary? Dict2 { get; set; } - } - - [Test] - public void Merge_NullJsonArgument() - { - var td = new TestData(); - Assert.Throws(() => { new JsonMergePatch().Merge(null!, ref td); }); - } - - [Test] - public void Merge_Malformed() - { - var td = new TestData(); - var ex = Assert.Throws(() => new JsonMergePatch().Merge(BinaryData.FromString(""), ref td)); - Assert.That(ex!.Message, Is.EqualTo("'<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.")); - } - - [Test] - public void Merge_Empty() - { - var td = new TestData(); - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ }"), ref td), Is.False); - } - - [Test] - public void Merge_Int() - { - int i = 1; - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("1"), ref i), Is.False); - - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("2"), ref i), Is.True); - Assert.That(i, Is.EqualTo(2)); - } - - [Test] - public void Merge_Property_StringValue() - { - var td = new TestData { Name = "Fred" }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"name\": \"Barry\" }"), ref td), Is.True); - Assert.That(td!.Name, Is.EqualTo("Barry")); - }); - } - - [Test] - public void Merge_Property_StringValue_DifferentNameCasingSupported() - { - var td = new TestData { Name = "Fred" }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"nAmE\": \"Barry\" }"), ref td), Is.True); - Assert.That(td!.Name, Is.EqualTo("Barry")); - }); - } - - [Test] - public void Merge_Property_StringValue_DifferentNameCasingNotSupported() - { - var td = new TestData { Name = "Fred" }; - Assert.Multiple(() => - { - // Returns true as this is intitially patching the JSON-to-JSON directly, then deserializing, so seen as a JSON change, but then ignored during deserialization?! Sorry! - Assert.That(new JsonMergePatch(new JsonMergePatchOptions { PropertyNameComparer = StringComparer.Ordinal }).Merge(BinaryData.FromString("{ \"nAmE\": \"Barry\" }"), ref td), Is.True); - Assert.That(td!.Name, Is.EqualTo("Fred")); - }); - } - - [Test] - public void Merge_Property_StringNull() - { - var td = new TestData { Name = "Fred" }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"name\": null }"), ref td), Is.True); - Assert.That(td!.Name, Is.Null); - }); - } - - [Test] - public void Merge_Property_StringNumberValue() - { - var td = new TestData { Name = "Fred" }; - var ex = Assert.Throws(() => new JsonMergePatch().Merge(BinaryData.FromString("{ \"name\": 123 }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.String. Path: $.name | LineNumber: 0 | BytePositionInLine: 13.")); - } - - [Test] - public void Merge_Property_String_MalformedA() - { - var td = new TestData(); - var ex = Assert.Throws(() => new JsonMergePatch().Merge(BinaryData.FromString("{ \"name\": [ \"Barry\" ] }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.String. Path: $.name | LineNumber: 0 | BytePositionInLine: 11.")); - } - - [Test] - public void Merge_PrimitiveTypesA() - { - var td = new TestData(); - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58 }"), ref td), Is.True); - - Assert.That(td!.Id, Is.EqualTo(new Guid("13512759-4f50-e911-b35c-bc83850db74d"))); - Assert.That(td.Name, Is.EqualTo("Barry")); - Assert.That(td.IsValid, Is.True); - Assert.That(td.Date, Is.EqualTo(new DateTime(2018, 12, 31))); - Assert.That(td.Count, Is.EqualTo(12)); - }); - Assert.That(td.Amount, Is.EqualTo(132.58m)); - } - - [Test] - public void Merge_PrimitiveTypes_NonCached_X100() - { - for (int i = 0; i < 100; i++) - { - var td = new TestData(); - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58 }"), ref td), Is.True); - } - } - - [Test] - public void Merge_PrimitiveTypes_Cached_X100() - { - var jom = new JsonMergePatch(); - for (int i = 0; i < 100; i++) - { - var td = new TestData(); - Assert.That(jom.Merge(BinaryData.FromString("{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58 }"), ref td), Is.True); - } - } - - [Test] - public void Merge_Property_SubEntityNull() - { - var td = new TestData { Sub = new SubData() }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"sub\": null }"), ref td), Is.True); - Assert.That(td!.Sub, Is.Null); - }); - } - - [Test] - public void Merge_Property_SubEntityNewEmpty() - { - var td = new TestData(); - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"sub\": { } }"), ref td), Is.True); - Assert.That(td!.Sub, Is.Not.Null); - Assert.That(td.Sub!.Code, Is.Null); - Assert.That(td.Sub.Text, Is.Null); - }); - } - - [Test] - public void Merge_Property_SubEntityExistingEmpty() - { - var td = new TestData { Sub = new SubData() }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"sub\": { } }"), ref td), Is.False); - Assert.That(td!.Sub, Is.Not.Null); - Assert.That(td.Sub!.Code, Is.Null); - Assert.That(td.Sub.Text, Is.Null); - }); - } - - [Test] - public void Merge_Property_SubEntityExistingChanged() - { - var td = new TestData { Sub = new SubData() }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"sub\": { \"code\": \"x\", \"text\": \"xxx\" } }"), ref td), Is.True); - Assert.That(td!.Sub, Is.Not.Null); - Assert.That(td.Sub!.Code, Is.EqualTo("x")); - Assert.That(td.Sub.Text, Is.EqualTo("xxx")); - }); - } - - [Test] - public void Merge_Property_ArrayMalformed() - { - var td = new TestData(); - var ex = Assert.Throws(() => new JsonMergePatch().Merge(BinaryData.FromString("{ \"values\": { } }"), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.Int32[]. Path: $.values | LineNumber: 0 | BytePositionInLine: 13.")); - } - - [Test] - public void Merge_Property_ArrayNull() - { - var td = new TestData { Values = new int[] { 1, 2, 3 } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"values\": null }"), ref td), Is.True); - Assert.That(td!.Values, Is.Null); - }); - } - - [Test] - public void Merge_Property_ArrayEmpty() - { - var td = new TestData { Values = new int[] { 1, 2, 3 } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"values\": [] }"), ref td), Is.True); - Assert.That(td!.Values, Is.Not.Null); - Assert.That(td.Values!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_ArrayValues_NoChanges() - { - var td = new TestData { Values = new int[] { 1, 2, 3 } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"values\": [ 1, 2, 3] }"), ref td), Is.False); - Assert.That(td!.Values, Is.Not.Null); - Assert.That(td.Values!, Has.Length.EqualTo(3)); - }); - } - - [Test] - public void Merge_Property_ArrayValues_Changes() - { - var td = new TestData { Values = new int[] { 1, 2, 3 } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"values\": [ 3, 2, 1] }"), ref td), Is.True); - Assert.That(td!.Values, Is.Not.Null); - Assert.That(td.Values!, Has.Length.EqualTo(3)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Values[0], Is.EqualTo(3)); - Assert.That(td.Values[1], Is.EqualTo(2)); - Assert.That(td.Values[2], Is.EqualTo(1)); - }); - } - - [Test] - public void Merge_Property_NoKeys_ListNull() - { - var td = new TestData { NoKeys = new List { new() } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"nokeys\": null }"), ref td), Is.True); - Assert.That(td!.Values, Is.Null); - }); - } - - [Test] - public void Merge_Property_NoKeys_ListEmpty() - { - var td = new TestData { NoKeys = new List { new() } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"nokeys\": [ ] }"), ref td), Is.True); - Assert.That(td!.NoKeys, Is.Not.Null); - Assert.That(td!.NoKeys!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_NoKeys_List() - { - var td = new TestData { NoKeys = new List { new() } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"nokeys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, { }, null ] }"), ref td), Is.True); - Assert.That(td!.NoKeys, Is.Not.Null); - Assert.That(td.NoKeys!, Has.Count.EqualTo(3)); - Assert.That(td.NoKeys![0], Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(td.NoKeys[0].Code, Is.EqualTo("abc")); - Assert.That(td.NoKeys[0].Text, Is.EqualTo("xyz")); - Assert.That(td.NoKeys[1], Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(td.NoKeys[1].Code, Is.Null); - Assert.That(td.NoKeys[1].Text, Is.Null); - Assert.That(td.NoKeys[2], Is.Null); - }); - } - - [Test] - public void Merge_Property_Keys_ListNull() - { - var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"keys\": null }"), ref td), Is.True); - Assert.That(td!.Keys, Is.Null); - }); - } - - [Test] - public void Merge_Property_Keys_ListEmpty() - { - var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"keys\": [ ] }"), ref td), Is.True); - - Assert.That(td!.Keys, Is.Not.Null); - Assert.That(td.Keys!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_Keys_Null() - { - var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"keys\": [ null ] }"), ref td), Is.True); - - Assert.That(td!.Keys, Is.Not.Null); - Assert.That(td.Keys!, Has.Count.EqualTo(1)); - Assert.That(td.Keys![0], Is.Null); - }); - } - - [Test] - public void Merge_Property_Keys_Replace() - { - var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"keys\": [ { \"code\": \"abc\" }, { \"code\": \"uvw\", \"text\": \"xyz\" } ] }"), ref td), Is.True); - - Assert.That(td!.Keys, Is.Not.Null); - Assert.That(td.Keys!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Keys[0].Code, Is.EqualTo("abc")); - Assert.That(td.Keys[0].Text, Is.EqualTo(null)); - Assert.That(td.Keys[1].Code, Is.EqualTo("uvw")); - Assert.That(td.Keys[1].Text, Is.EqualTo("xyz")); - }); - } - - [Test] - public void Merge_Property_Keys_NoChanges() - { - // Note, although technically no changes, there is no means to verify without specific equality checking, so is seen as a change. - var td = new TestData { Keys = new List { new() { Code = "abc", Text = "def" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"keys\": [ { \"code\": \"abc\", \"text\": \"def\" } ] }"), ref td), Is.True); - - Assert.That(td!.Keys, Is.Not.Null); - Assert.That(td.Keys!, Has.Count.EqualTo(1)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Keys[0].Code, Is.EqualTo("abc")); - Assert.That(td.Keys[0].Text, Is.EqualTo("def")); - }); - } - - // *** Dictionary - DictionaryMergeApproach.Merge - - [Test] - public void Merge_Property_DictMerge_Null() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"dict\": null }"), ref td), Is.True); - Assert.That(td!.Dict, Is.Null); - }); - } - - [Test] - public void Merge_Property_DictMerge_Empty() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - // Should result in no changes as no property (key) was provided. - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"dict\": {} }"), ref td), Is.False); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo("v")); - }); - } - - [Test] - public void Merge_Property_DictMerge_NullValue() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" } } }; - - Assert.Multiple(() => - { - // A key with a value of null indicates it should be removed. - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{ \"dict\": {\"k\":null} }"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Is.Empty); - }); - } - - [Test] - public void Merge_Property_DictMerge_DuplicateKeys_IntoNull() - { - var td = new TestData { }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo("v2")); - }); - } - - [Test] - public void Merge_Property_DictMerge_DuplicateKeys_IntoEmpty() - { - var td = new TestData { Dict = new Dictionary() }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(1)); - Assert.That(td.Dict!["k"], Is.EqualTo("v2")); - }); - } - - [Test] - public void Merge_Property_DictMerge_NoChange() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"v\",\"k1\":\"v1\"}}"), ref td), Is.False); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("v")); - Assert.That(td.Dict["k1"], Is.EqualTo("v1")); - }); - } - - [Test] - public void Merge_Property_DictMerge_ReOrder_NoChange() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict\":{\"k1\":\"v1\",\"k\":\"v\"}}"), ref td), Is.False); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("v")); - Assert.That(td.Dict["k1"], Is.EqualTo("v1")); - }); - } - - [Test] - public void Merge_Property_DictMerge_AddUpdateDelete() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"vx\",\"k2\":\"v2\",\"k1\":null}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("vx")); - Assert.That(td.Dict["k2"], Is.EqualTo("v2")); - }); - } - - [Test] - public void Merge_Property_DictMerge_AddUpdate() - { - var td = new TestData { Dict = new Dictionary { { "k", "v" }, { "k1", "v1" } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict\":{\"k\":\"vx\",\"k2\":\"v2\"}}"), ref td), Is.True); - Assert.That(td!.Dict, Is.Not.Null); - Assert.That(td.Dict!, Has.Count.EqualTo(3)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict["k"], Is.EqualTo("vx")); - Assert.That(td.Dict["k1"], Is.EqualTo("v1")); - Assert.That(td.Dict["k2"], Is.EqualTo("v2")); - }); - } - - // *** - - [Test] - public void Merge_Property_KeyDict2Merge_DuplicateKeys_IntoEmpty() - { - var td = new TestData { Dict2 = new Dictionary() }; - - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"a\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(1)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("bb")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("bbb")); - }); - } - - [Test] - public void Merge_Property_KeyDict2Merge_NoChange() - { - var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aa\",\"text\": \"aaa\"},\"b\":{\"code\": \"bb\",\"text\": \"bbb\"}}}"), ref td), Is.False); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("aa")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); - Assert.That(td.Dict2["b"].Code, Is.EqualTo("bb")); - Assert.That(td.Dict2["b"].Text, Is.EqualTo("bbb")); - }); - } - - [Test] - public void Merge_Property_KeyDict2Merge_AddUpdateDelete() - { - var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"},\"b\":null}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(2)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("aaaa")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); - Assert.That(td.Dict2["c"].Code, Is.EqualTo("cc")); - Assert.That(td.Dict2["c"].Text, Is.EqualTo("ccc")); - }); - } - - [Test] - public void Merge_Property_KeyDict2Merge_AddUpdate() - { - var td = new TestData { Dict2 = new Dictionary { { "a", new KeyData { Code = "aa", Text = "aaa" } }, { "b", new KeyData { Code = "bb", Text = "bbb" } } } }; - Assert.Multiple(() => - { - Assert.That(new JsonMergePatch().Merge(BinaryData.FromString("{\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"), ref td), Is.True); - Assert.That(td!.Dict2, Is.Not.Null); - Assert.That(td.Dict2!, Has.Count.EqualTo(3)); - }); - - Assert.Multiple(() => - { - Assert.That(td.Dict2["a"].Code, Is.EqualTo("aaaa")); - Assert.That(td.Dict2["a"].Text, Is.EqualTo("aaa")); - Assert.That(td.Dict2["b"].Code, Is.EqualTo("bb")); - Assert.That(td.Dict2["b"].Text, Is.EqualTo("bbb")); - Assert.That(td.Dict2["c"].Code, Is.EqualTo("cc")); - Assert.That(td.Dict2["c"].Text, Is.EqualTo("ccc")); - }); - } - - [Test] - public void Merge_RootSimple_NullString() - { - string? s = null; - var jmp = new JsonMergePatch(); - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref s), Is.False); - Assert.That(s, Is.EqualTo(null)); - }); - - s = "x"; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref s), Is.True); - Assert.That(s, Is.EqualTo(null)); - }); - - s = null; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("\"x\""), ref s), Is.True); - Assert.That(s, Is.EqualTo("x")); - }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("\"x\""), ref s), Is.False); - Assert.That(s, Is.EqualTo("x")); - }); - } - - [Test] - public void Merge_RootSimple_NullInt() - { - int? i = null; - var jmp = new JsonMergePatch(); - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref i), Is.False); - Assert.That(i, Is.EqualTo(null)); - }); - - i = 88; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref i), Is.True); - Assert.That(i, Is.EqualTo(null)); - }); - - i = null; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("88"), ref i), Is.True); - Assert.That(i, Is.EqualTo(88)); - }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("88"), ref i), Is.False); - Assert.That(i, Is.EqualTo(88)); - }); - } - - [Test] - public void Merge_RootSimple_Int() - { - int i = 0; - var jmp = new JsonMergePatch(); - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("0"), ref i), Is.False); - Assert.That(i, Is.EqualTo(0)); - - Assert.That(jmp.Merge(BinaryData.FromString("88"), ref i), Is.True); - Assert.That(i, Is.EqualTo(88)); - }); - } - - [Test] - public void Merge_RootComplex_Null() - { - SubData? sd = null!; - var jmp = new JsonMergePatch(); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref sd), Is.False); - Assert.That(sd, Is.Null); - }); - - sd = new SubData { Code = "X" }; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("null"), ref sd), Is.True); - Assert.That(sd, Is.Null); - }); - - sd = null; - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("{\"code\":\"x\"}"), ref sd), Is.True); - Assert.That(sd, Is.Not.Null); - }); - Assert.That(sd!.Code, Is.EqualTo("x")); - } - - [Test] - public void Merge_RootArray_Simple_Null() - { - var arr = Array.Empty(); - var jmp = new JsonMergePatch(); - jmp.Merge(BinaryData.FromString("null"), ref arr); - Assert.That(arr, Is.EqualTo(null)); - - arr = new int[] { 1, 2 }; - jmp.Merge(BinaryData.FromString("null"), ref arr); - Assert.That(arr, Is.EqualTo(null)); - - int[]? arr2 = null; - jmp.Merge(BinaryData.FromString("null"), ref arr2); - Assert.That(arr2, Is.EqualTo(null)); - - arr2 = new int[] { 1, 2 }; - jmp.Merge(BinaryData.FromString("null"), ref arr2); - Assert.That(arr2, Is.EqualTo(null)); - } - - [Test] - public void Merge_RootArray_Simple() - { - var arr = Array.Empty(); - var jmp = new JsonMergePatch(); - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("[1,2,3]"), ref arr), Is.True); - Assert.That(arr, Is.EqualTo(new int[] { 1, 2, 3 })); - }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("[1,2,3]"), ref arr), Is.False); - Assert.That(arr, Is.EqualTo(new int[] { 1, 2, 3 })); - }); - } - - [Test] - public void Merge_RootArray_Complex() - { - var arr = new SubData[] { new() { Code = "a", Text = "aa" }, new() { Code = "b", Text = "bb" } }; - var jmp = new JsonMergePatch(); - - Assert.Multiple(() => - { - // No equality checker so will appear as changed - is a replacement. - Assert.That(jmp.Merge(BinaryData.FromString("[{\"code\":\"a\",\"text\":\"aa\"},{\"code\":\"b\",\"text\":\"bb\"}]"), ref arr), Is.False); - Assert.That(arr!, Has.Length.EqualTo(2)); - - // Replaced. - Assert.That(jmp.Merge(BinaryData.FromString("[{\"code\":\"c\",\"text\":\"cc\"},{\"code\":\"b\",\"text\":\"bb\"}]"), ref arr), Is.True); - Assert.That(arr!, Has.Length.EqualTo(2)); - Assert.That(arr![0].Code, Is.EqualTo("c")); - }); - } - - [Test] - public void Merge_RootDictionary_Simple() - { - var dict = new Dictionary(); - var jmp = new JsonMergePatch(); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.True); - Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); - }); - - Assert.Multiple(() => - { - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":1}"), ref dict), Is.False); - Assert.That(dict, Is.EqualTo(new Dictionary { { "x", 1 } })); - }); - } - - [Test] - public void Merge_RootDictionary_Complex() - { - var dict = new Dictionary(); - var jmp = new JsonMergePatch(); - - Assert.That(jmp.Merge(BinaryData.FromString("{\"x\":{\"code\":\"xx\"},\"y\":{\"code\":\"yy\",\"text\":\"YY\"}}"), ref dict), Is.True); - Assert.Multiple(() => - { - Assert.That(dict, Is.Not.Null); - Assert.That(dict!, Has.Count.EqualTo(2)); - Assert.That(dict!["x"].Code, Is.EqualTo("xx")); - Assert.That(dict!["x"].Text, Is.EqualTo(null)); - Assert.That(dict!["y"].Code, Is.EqualTo("yy")); - Assert.That(dict!["y"].Text, Is.EqualTo("YY")); - }); - - Assert.That(jmp.Merge(BinaryData.FromString("{\"y\":{\"code\":\"yyy\"},\"x\":{\"code\":\"xxx\"}}"), ref dict), Is.True); - Assert.Multiple(() => - { - Assert.That(dict, Is.Not.Null); - Assert.That(dict!, Has.Count.EqualTo(2)); - Assert.That(dict!["x"].Code, Is.EqualTo("xxx")); - Assert.That(dict!["x"].Text, Is.EqualTo(null)); - Assert.That(dict!["y"].Code, Is.EqualTo("yyy")); - Assert.That(dict!["y"].Text, Is.EqualTo("YY")); - }); - } - - [Test] - public void Merge_XLoadTest_1_1000() - { - var jmp = new JsonMergePatch(); - for (int i = 0; i < 1000; i++) - { - var td = _testData; - jmp.Merge(BinaryData.FromString(_text), ref td); - } - } - - [Test] - public void Merge_XLoadTest_2_1000() - { - var jmp = new JsonMergePatch(); - for (int i = 0; i < 1000; i++) - { - var td = _testData; - jmp.Merge(BinaryData.FromString(_text), ref td); - } - } - - [Test] - public void Merge_XLoadTest_1000_1_NoChangeCheck() - { - var jmp = new JsonMergePatch(); - for (int i = 0; i < 1000; i++) - { - _ = jmp.Merge(_jtext, _jtestdata); - } - } - - [Test] - public void Merge_XLoadTest_1000_1_WithChangeCheck() - { - var jmp = new JsonMergePatch(); - for (int i = 0; i < 1000; i++) - { - _ = jmp.TryMerge(_jtext, _jtestdata, out _); - } - } - - [Test] - public void Merge_XLoadTest_1000_2_NoChangeCheck() - { - var jmp = new JsonMergePatch(); - for (int i = 0; i < 1000; i++) - { - _ = jmp.Merge(_jtext, _jtestdata); - } - } - - [Test] - public void Merge_XLoadTest_1000_2_WithChangeCheck() - { - var jmp = new JsonMergePatch(); - for (int i = 0; i < 1000; i++) - { - _ = jmp.TryMerge(_jtext, _jtestdata, out _); - } - } - - [Test] - public async Task Merge_XLoadTest_WithRead_10000() - { - var jmp = new JsonMergePatch(); - - for (int i = 0; i < 10000; i++) - { - var td = new TestData { Values = new int[] { 1, 2, 3 }, Keys = new List { new() { Code = "abc", Text = "def" } }, Dict = new Dictionary() { { "a", "b" } }, Dict2 = new Dictionary { { "x", new KeyData { Code = "xx" } } } }; - _ = await jmp.MergeWithResultAsync(new BinaryData(JsonMergePatchTest._text), async (v, _) => await Task.FromResult(v).ConfigureAwait(false)); - } - } - - [Test] - public void MergeInvalidMergeValueForType() - { - var jmp = new JsonMergePatch(); - var td = new TestData(); - var ex = Assert.Throws(() => jmp.Merge(BinaryData.FromString("""{ "id": { "value": "123" }}"""), ref td)); - Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to System.Guid. Path: $.id | LineNumber: 0 | BytePositionInLine: 9.")); - } - - internal const string _text = "{ \"id\": \"13512759-4f50-e911-b35c-bc83850db74d\", \"name\": \"Barry\", \"isValid\": true, \"date\": \"2018-12-31\", \"count\": \"12\", \"amount\": 132.58, \"dict\": {\"k\":\"v\",\"k1\":\"v1\"}, " - + "\"values\": [ 1, 2, 4], \"sub\": { \"code\": \"abc\", \"text\": \"xyz\" }, \"nokeys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, null, { } ], " - + "\"keys\": [ { \"code\": \"abc\", \"text\": \"xyz\" }, { }, null ],\"dict2\":{\"a\":{\"code\": \"aaaa\"},\"c\":{\"code\": \"cc\",\"text\": \"ccc\"}}}"; - - internal static readonly TestData _testData = new TestData { Values = new int[] { 1, 2, 3 }, Keys = new List { new() { Code = "abc", Text = "def" } }, Dict = new Dictionary() { { "a", "b" } }, Dict2 = new Dictionary { { "x", new KeyData { Code = "xx" } } } }; - - internal static readonly JsonElement _jtext = JsonDocument.Parse(_text).RootElement; - internal static readonly JsonElement _jtestdata = JsonDocument.Parse(JsonSerializer.Serialize(_testData)).RootElement; - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Localization/LTextTest.cs b/tests/CoreEx.Test/Framework/Localization/LTextTest.cs deleted file mode 100644 index fb757223..00000000 --- a/tests/CoreEx.Test/Framework/Localization/LTextTest.cs +++ /dev/null @@ -1,60 +0,0 @@ -using CoreEx.Localization; -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Localization -{ - [TestFixture] - public class LTextTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => TextProvider.SetTextProvider(new NullTextProvider()); - - [Test] - public void Null_ToString() - { - var l = new LText(); - string t = l; - Assert.Multiple(() => - { - Assert.That(t, Is.EqualTo(null)); - Assert.That(l.ToString(), Is.EqualTo(null)); - }); - } - - [Test] - public void Key_ToString() - { - var l = new LText("key"); - string t = l; - Assert.Multiple(() => - { - Assert.That(t, Is.EqualTo("key")); - Assert.That(l.ToString(), Is.EqualTo("key")); - }); - } - - [Test] - public void NumericKey_ToString() - { - var l = new LText(451); - string t = l; - Assert.Multiple(() => - { - Assert.That(t, Is.EqualTo("000451")); - Assert.That(l.ToString(), Is.EqualTo("000451")); - }); - } - - [Test] - public void FallBack_ToString() - { - var l = new LText("key", "fallback"); - string t = l; - Assert.Multiple(() => - { - Assert.That(t, Is.EqualTo("fallback")); - Assert.That(l.ToString(), Is.EqualTo("fallback")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Mapping/Converters/ConverterTest.cs b/tests/CoreEx.Test/Framework/Mapping/Converters/ConverterTest.cs deleted file mode 100644 index a17422c6..00000000 --- a/tests/CoreEx.Test/Framework/Mapping/Converters/ConverterTest.cs +++ /dev/null @@ -1,19 +0,0 @@ -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Mapping.Converters -{ - [TestFixture] - public class ConverterTest - { - [Test] - public void Convert() - { - var converter = CoreEx.Mapping.Converters.Converter.Create(s => int.Parse(s), i => i.ToString()); - Assert.Multiple(() => - { - Assert.That(converter.ToDestination.Convert("123"), Is.EqualTo(123)); - Assert.That(converter.ToSource.Convert(123), Is.EqualTo("123")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Mapping/Converters/DateTimeToStringConverterTest.cs b/tests/CoreEx.Test/Framework/Mapping/Converters/DateTimeToStringConverterTest.cs deleted file mode 100644 index 9559079a..00000000 --- a/tests/CoreEx.Test/Framework/Mapping/Converters/DateTimeToStringConverterTest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using CoreEx.Mapping.Converters; -using NUnit.Framework; -using System; - -namespace CoreEx.Test.Framework.Mapping.Converters -{ - [TestFixture] - public class DateTimeToStringConverterTest - { - [Test] - public void ConvertToDestination() - { - var dt = new DateTime(2022, 11, 28, 13, 09, 42, 987, DateTimeKind.Unspecified); - var val = new DateTimeToStringConverter("yyyy-MMM-dd").ToDestination.Convert(dt); - Assert.That(val, Is.EqualTo("2022-Nov-28")); - } - - [Test] - public void ConvertToSource() - { - var dt = new DateTimeToStringConverter("yyyy-MMM-dd").ToSource.Convert("2022-Nov-28"); - Assert.That(dt, Is.EqualTo(new DateTime(2022, 11, 28, 0, 0, 0, 0, DateTimeKind.Unspecified))); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Mapping/Converters/EncodedStringToDateTimeConverterTest.cs b/tests/CoreEx.Test/Framework/Mapping/Converters/EncodedStringToDateTimeConverterTest.cs deleted file mode 100644 index 812866f8..00000000 --- a/tests/CoreEx.Test/Framework/Mapping/Converters/EncodedStringToDateTimeConverterTest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using CoreEx.Mapping.Converters; -using NUnit.Framework; -using System; - -namespace CoreEx.Test.Framework.Mapping.Converters -{ - [TestFixture] - public class EncodedStringToDateTimeConverterTest - { - [Test] - public void ConvertToSource() - { - var dt = new DateTime(2022, 11, 28, 13, 09, 42, 987, DateTimeKind.Utc); - var val = EncodedStringToDateTimeConverter.Default.ToSource.Convert(dt); - Assert.That(val, Is.EqualTo("sImk0EHR2kg=")); - } - - [Test] - public void ConvertToDestination() - { - var dt = EncodedStringToDateTimeConverter.Default.ToDestination.Convert("sImk0EHR2kg="); - Assert.That(dt, Is.EqualTo(new DateTime(2022, 11, 28, 13, 09, 42, 987, DateTimeKind.Utc))); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Mapping/Converters/EncodedStringToUInt32ConverterTest.cs b/tests/CoreEx.Test/Framework/Mapping/Converters/EncodedStringToUInt32ConverterTest.cs deleted file mode 100644 index 072f9b9b..00000000 --- a/tests/CoreEx.Test/Framework/Mapping/Converters/EncodedStringToUInt32ConverterTest.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CoreEx.Mapping.Converters; -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Mapping.Converters -{ - [TestFixture] - public class EncodedStringToUInt32ConverterTest - { - [Test] - public void ConvertToSource() - { - uint i = 4020; - var val = EncodedStringToUInt32Converter.Default.ToSource.Convert(i); - Assert.That(val, Is.EqualTo("tA8AAA==")); - } - - [Test] - public void ConvertToDestination() - { - var i = EncodedStringToUInt32Converter.Default.ToDestination.Convert("tA8AAA=="); - Assert.That(i, Is.EqualTo(4020)); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Mapping/Converters/TypeToStringConverterTest.cs b/tests/CoreEx.Test/Framework/Mapping/Converters/TypeToStringConverterTest.cs deleted file mode 100644 index 3e697acd..00000000 --- a/tests/CoreEx.Test/Framework/Mapping/Converters/TypeToStringConverterTest.cs +++ /dev/null @@ -1,51 +0,0 @@ -using CoreEx.Mapping.Converters; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text; - -namespace CoreEx.Test.Framework.Mapping.Converters -{ - [TestFixture] - public class TypeToStringConverterTest - { - private const string GuidString = "382c74c3-721d-4f34-80e5-57657b6cbc27"; - private readonly Guid GuidValue = new(GuidString); - - [Test] - public void Convert() - { - Assert.Multiple(() => - { - Assert.That(TypeToStringConverter.Default.ToDestination.Convert(GuidValue), Is.EqualTo(GuidString)); - Assert.That(TypeToStringConverter.Default.ToDestination.Convert(null!), Is.EqualTo(Guid.Empty.ToString())); - Assert.That(TypeToStringConverter.Default.ToDestination.Convert(GuidValue), Is.EqualTo(GuidString)); - Assert.That(TypeToStringConverter.Default.ToDestination.Convert(null), Is.Null); - - Assert.That(TypeToStringConverter.Default.ToSource.Convert(GuidString), Is.EqualTo(GuidValue)); - Assert.That(TypeToStringConverter.Default.ToSource.Convert(null), Is.EqualTo(Guid.Empty)); - Assert.That(TypeToStringConverter.Default.ToSource.Convert(GuidString), Is.EqualTo(GuidValue)); - Assert.That(TypeToStringConverter.Default.ToSource.Convert(null), Is.Null); - - Assert.That(TypeToStringConverter.Default.ToSource.Convert("123"), Is.EqualTo(123)); - - Assert.That(TypeToStringConverter.Default.ToDestination.Convert(TestOption.Abc), Is.EqualTo("Abc")); - Assert.That(TypeToStringConverter.Default.ToSource.Convert("Abc"), Is.EqualTo(TestOption.Abc)); - Assert.That(TypeToStringConverter.Default.ToSource.Convert(null), Is.EqualTo(TestOption.None)); - }); - } - - [Test] - public void Convert_StringToDateTime() - { - var now = DateTime.Now; - Assert.That(TypeToStringConverter.Default.ToSource.Convert(now.ToString("O")), Is.EqualTo(now)); - } - - public enum TestOption - { - None = 0, - Abc = 1 - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Mapping/MapperTest.cs b/tests/CoreEx.Test/Framework/Mapping/MapperTest.cs deleted file mode 100644 index 9203ec9f..00000000 --- a/tests/CoreEx.Test/Framework/Mapping/MapperTest.cs +++ /dev/null @@ -1,1060 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.Validation.Clauses; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; -using System; -using System.Collections.Generic; - -namespace CoreEx.Test.Framework.Mapping -{ - [TestFixture] - public class MapperTest - { - [Test] - public void Mapping() - { - var pm = new Mapper() - .Map((s, d) => d.ID = s.Id) - .Map((s, d) => d.Text = s.Name); - - var m = new Mapper(); - m.Register(pm); - - var p2 = m.Map(new PersonA { Id = 1, Name = "Bob" }); - Assert.Multiple(() => - { - Assert.That(p2.ID, Is.EqualTo(1)); - Assert.That(p2.Text, Is.EqualTo("Bob")); - }); - } - - [Test] - public void Mapping_Collection() - { - var pm = new Mapper() - .Map((s, d) => d.ID = s.Id) - .Map((s, d) => d.Text = s.Name); - - var pmc = new CollectionMapper(); - - var m = new Mapper(); - m.Register(pm); - m.Register(pmc); - - var pac = new PersonACollection() { new PersonA { Id = 1, Name = "Bob" } }; - var pbc = m.Map(pac); - - Assert.That(pbc, Has.Count.EqualTo(1)); - Assert.Multiple(() => - { - Assert.That(pbc[0].ID, Is.EqualTo(1)); - Assert.That(pbc[0].Text, Is.EqualTo("Bob")); - }); - } - - [Test] - public void Mapping_Collection_Auto() - { - var pm = new Mapper() - .Map((s, d) => d.ID = s.Id) - .Map((s, d) => d.Text = s.Name); - - var m = new Mapper(); - m.Register(pm); - - var pac = new PersonACollection() { new PersonA { Id = 1, Name = "Bob" } }; - var pbc = m.Map(pac); - - Assert.That(pbc, Has.Count.EqualTo(1)); - Assert.Multiple(() => - { - Assert.That(pbc[0].ID, Is.EqualTo(1)); - Assert.That(pbc[0].Text, Is.EqualTo("Bob")); - }); - } - - [Test] - public void Mapping_Collection_Auto_Into_Existing() - { - var pm = new Mapper() - .Map((s, d) => d.ID = s.Id) - .Map((s, d) => d.Text = s.Name); - - var m = new Mapper(); - m.Register(pm); - - var pac = new PersonACollection() { new PersonA { Id = 1, Name = "Bob" } }; - var pbc = new PersonBCollection() { new PersonB { ID = 2, Text = "Carly" } }; - var pbc2 = m.Map(pac, pbc); - - Assert.That(pbc, Has.Count.EqualTo(1)); - Assert.Multiple(() => - { - Assert.That(pbc[0].ID, Is.EqualTo(1)); - Assert.That(pbc[0].Text, Is.EqualTo("Bob")); - - Assert.That(pbc2, Has.Count.EqualTo(1)); - }); - Assert.Multiple(() => - { - Assert.That(pbc2[0].ID, Is.EqualTo(1)); - Assert.That(pbc2[0].Text, Is.EqualTo("Bob")); - }); - } - - [Test] - public void Mapping_Collection_Auto_Empty_To_Null() - { - var pm = new Mapper() - .Map((s, d) => d.ID = s.Id) - .Map((s, d) => d.Text = s.Name); - - var m = new Mapper(); - m.Register(pm); - - var pac = new PersonACollection(); - var pbc = new PersonBCollection() { new PersonB { ID = 2, Text = "Carly" } }; - var pbc2 = m.Map(pac, pbc); - - Assert.That(pbc2, Is.Null); - } - - [Test] - public void ServicesMapper() - { - var sc = new ServiceCollection(); - sc.AddMappers(); - } - - [Test] - public void Mapping_Flatten() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Flatten(s => s.Address); - - var ma = new Mapper() - .Map((s, d) => d.Street = s.Street) - .Map((s, d) => d.City = s.City); - - var m = new Mapper(); - m.Register(mc); - m.Register(ma); - - var c = new Contact { Id = 88, Name = "Brian", Address = new Address { Street = "Main", City = "Wellington" } }; - var r = m.Map(c); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.Id, Is.EqualTo(88)); - Assert.That(r.Name, Is.EqualTo("Brian")); - Assert.That(r.Street, Is.EqualTo("Main")); - Assert.That(r.City, Is.EqualTo("Wellington")); - }); - - // Try with Address of null - map into should null-ify! - c.Address = null; - m.Map(c, r); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.Id, Is.EqualTo(88)); - Assert.That(r.Name, Is.EqualTo("Brian")); - Assert.That(r.Street, Is.Null); - Assert.That(r.City, Is.Null); - }); - } - - [Test] - public void Mapping_Flatten_DoubleNest() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Flatten(s => s.Address); - - var ma = new Mapper() - .Map((s, d) => d.Street = s.Street) - .Map((s, d) => d.City = s.City) - .Flatten(s => s.Other); - - var mo = new Mapper() - .Map((s, d) => d.Other = s.Value); - - var m = new Mapper(); - m.Register(mc); - m.Register(ma); - m.Register(mo); - - var c = new Contact { Id = 88, Name = "Brian", Address = new Address { Street = "Main", City = "Wellington" } }; - var r = m.Map(c); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.Id, Is.EqualTo(88)); - Assert.That(r.Name, Is.EqualTo("Brian")); - Assert.That(r.Street, Is.EqualTo("Main")); - Assert.That(r.City, Is.EqualTo("Wellington")); - Assert.That(r.Other, Is.Null); - }); - - c = new Contact { Id = 88, Name = "Brian", Address = new Address { Street = "Main", City = "Wellington", Other = new Other { Value = "Blah" } } }; - r = m.Map(c); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.Id, Is.EqualTo(88)); - Assert.That(r.Name, Is.EqualTo("Brian")); - Assert.That(r.Street, Is.EqualTo("Main")); - Assert.That(r.City, Is.EqualTo("Wellington")); - Assert.That(r.Other, Is.EqualTo("Blah")); - }); - } - - [Test] - public void Mapping_Flatten_DoubleNest_Perf() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Flatten(s => s.Address); - - var ma = new Mapper() - .Map((s, d) => d.Street = s.Street) - .Map((s, d) => d.City = s.City) - .Flatten(s => s.Other); - - var mo = new Mapper() - .Map((s, d) => d.Other = s.Value); - - var m = new Mapper(); - m.Register(mc); - m.Register(ma); - m.Register(mo); - - var c = new Contact { Id = 88, Name = "Brian", Address = new Address { Street = "Main", City = "Wellington" } }; - for (int i = 0; i < 10000; i++) - { - _ = m.Map(c); - } - } - - [Test] - public void Mapping_Flatten_Nullable_NonNullable_Inherit() - { - var m = new Mapper(); - m.Register(new EmployeeMapper()); - m.Register(new TerminationMapper()); - - var e = new Employee { Name = "Tim" }; - var em = m.Map(e); - - Assert.That(em, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(em.Name, Is.EqualTo("Tim")); - Assert.That(em.Reason, Is.Null); - Assert.That(em.Date, Is.Null); - }); - - em = new EmployeeModel { Name = "Tom", Reason = "Because", Date = DateTime.UtcNow }; - em = m.Map(e, em); - - Assert.That(em, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(em.Name, Is.EqualTo("Tim")); - Assert.That(em.Reason, Is.Null); - Assert.That(em.Date, Is.Null); - }); - } - - [Test] - public void Mapping_Flatten_Nullable_NonNullable_Fluent() - { - var me = new Mapper() - .Map((s, d) => d.Name = s.Name) - .Flatten(s => s.Termination); - - var mt = new Mapper() - .Map((s, d) => d.Reason = s.Reason) - .Map((s, d) => d.Date = s.Date) - .InitializeDestination(d => - { - d.Reason = null; - d.Date = null; - return true; - }); - - var m = new Mapper(); - m.Register(me); - m.Register(mt); - - var e = new Employee { Name = "Tim" }; - var em = m.Map(e); - - Assert.That(em, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(em.Name, Is.EqualTo("Tim")); - Assert.That(em.Reason, Is.Null); - Assert.That(em.Date, Is.Null); - }); - - em = new EmployeeModel { Name = "Tom", Reason = "Because", Date = DateTime.UtcNow }; - em = m.Map(e, em); - - Assert.That(em, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(em.Name, Is.EqualTo("Tim")); - Assert.That(em.Reason, Is.Null); - Assert.That(em.Date, Is.Null); - }); - } - - [Test] - public void Mapping_Flatten_Nullable_NonNullable_InLine() - { - var me = new Mapper() - .Map((s, d) => d.Name = s.Name, isSourceInitial: s => s.Name == default, initializeDestination: d => d.Name = default) - .Flatten(s => s.Termination, isSourceInitial: s => s.Termination == default); - - var mt = new Mapper() - .Map((s, d) => d.Reason = s.Reason, isSourceInitial: s => s.Reason == default, initializeDestination: d => d.Reason = default) - .Map((s, d) => d.Date = s.Date, isSourceInitial: s => s.Date == default, initializeDestination: d => d.Date = default); - - var m = new Mapper(); - m.Register(me); - m.Register(mt); - - var e = new Employee { Name = "Tim" }; - var em = m.Map(e); - - Assert.That(em, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(em.Name, Is.EqualTo("Tim")); - Assert.That(em.Reason, Is.Null); - Assert.That(em.Date, Is.Null); - }); - - em = new EmployeeModel { Name = "Tom", Reason = "Because", Date = DateTime.UtcNow }; - em = m.Map(e, em); - - Assert.That(em, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(em.Name, Is.EqualTo("Tim")); - Assert.That(em.Reason, Is.Null); - Assert.That(em.Date, Is.Null); - }); - } - - [Test] - public void Mapping_Expand_Condition() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Expand
((d, v) => d.Address = v, (s, d) => d.Address != null || !(s.Street is null && s.City is null)); - - var ma = new Mapper() - .Map((s, d) => d.Street = s.Street) - .Map((s, d) => d.City = s.City); - - var m = new Mapper(); - m.Register(mc); - m.Register(ma); - - var r = new Model { Id = 88, Name = "Brian", Street = "Main", City = "Wellington" }; - var c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c.Address!.Street, Is.EqualTo("Main")); - Assert.That(c.Address!.City, Is.EqualTo("Wellington")); - }); - - // Try with all properties of Address are default - should not touch as condition not true. - r.Street = null; - r.City = null; - c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Null); - }); - } - - [Test] - public void Mapping_Expand_No_Condition() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Expand
((d, v) => d.Address = v); - - var ma = new Mapper() - .Map((s, d) => d.Street = s.Street) - .Map((s, d) => d.City = s.City); - - var m = new Mapper(); - m.Register(mc); - m.Register(ma); - - var r = new Model { Id = 88, Name = "Brian", Street = "Main", City = "Wellington" }; - var c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c.Address!.Street, Is.EqualTo("Main")); - Assert.That(c.Address!.City, Is.EqualTo("Wellington")); - }); - - // Try with all properties of Address are default - should not touch as condition not true. - r.Street = null; - r.City = null; - c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c.Address!.Street, Is.Null); - Assert.That(c.Address!.City, Is.Null); - }); - } - - [Test] - public void Mapping_Expand_No_Condition_IsInitializedCheck() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Expand
((d, v) => d.Address = v, initializeDestination: d => d.Address = null); - - var ma = new Mapper() - .Map((s, d) => d.Street = s.Street, isSourceInitial: s => s.Street == default) - .Map((s, d) => d.City = s.City, isSourceInitial: s => s.City == default); - - var m = new Mapper(); - m.Register(mc); - m.Register(ma); - - var r = new Model { Id = 88, Name = "Brian", Street = "Main", City = "Wellington" }; - var c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c.Address!.Street, Is.EqualTo("Main")); - Assert.That(c.Address!.City, Is.EqualTo("Wellington")); - }); - - // Try with all properties of Address are default - should nullify as all are initial. - r.Street = null; - r.City = null; - c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Null); - }); - } - - [Test] - public void Mapping_Expand_DoubleNest() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Expand
((d, v) => d.Address = v, (s, d) => d.Address != null || !(s.Street is null && s.City is null)); - - var ma = new Mapper() - .Map((s, d) => d.Street = s.Street) - .Map((s, d) => d.City = s.City) - .Expand((d, v) => d.Other = v, (s, d) => !(s.Other == default)); - - var mo = new Mapper() - .Map((s, d) => d.Value = s.Other); - - var m = new Mapper(); - m.Register(mc); - m.Register(ma); - m.Register(mo); - - // Other won't expand as no value specified. - var r = new Model { Id = 88, Name = "Brian", Street = "Main", City = "Wellington" }; - var c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c.Address!.Street, Is.EqualTo("Main")); - Assert.That(c.Address!.City, Is.EqualTo("Wellington")); - Assert.That(c.Address!.Other, Is.Null); - }); - - // Other expands as it now has a value. - r = new Model { Id = 88, Name = "Brian", Street = "Main", City = "Wellington", Other = "Blah" }; - c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c.Address!.Street, Is.EqualTo("Main")); - Assert.That(c.Address!.City, Is.EqualTo("Wellington")); - Assert.That(c.Address!.Other, Is.Not.Null); - }); - Assert.That(c.Address!.Other!.Value, Is.EqualTo("Blah")); - - // Other will not expand as the Address is not specified; data must flow along expand branch. - r = new Model { Id = 88, Name = "Brian", Street = null, City = null, Other = "Blah" }; - c = m.Map(r); - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Null); - }); - } - - [Test] - public void Mapping_Expand_DoubleNest_Perf() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Expand
((d, v) => d.Address = v, (s, d) => d.Address != null || !(s.Street is null && s.City is null)); - - var ma = new Mapper() - .Map((s, d) => d.Street = s.Street) - .Map((s, d) => d.City = s.City) - .Expand((d, v) => d.Other = v, (s, d) => !(s.Other == default)); - - var mo = new Mapper() - .Map((s, d) => d.Value = s.Other); - - var m = new Mapper(); - m.Register(mc); - m.Register(ma); - m.Register(mo); - - // Other won't expand as no value specified. - var r = new Model { Id = 88, Name = "Brian", Street = "Main", City = "Wellington" }; - for (int i = 0; i < 10000; i++) - { - _ = m.Map(r); - } - } - - [Test] - public void Mapping_Expand_DoubleNext2() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Expand
((d, v) => d.Address = v, (s, d) => !(s.Street is null && s.City is null && s.Other is null)); - - var ma = new Mapper() - .Map((s, d) => d.Street = s.Street) - .Map((s, d) => d.City = s.City) - .Expand((d, v) => d.Other = v, (s, d) => !(s.Other == default)); - - var mo = new Mapper() - .Map((s, d) => d.Value = s.Other); - - var m = new Mapper(); - m.Register(mc); - m.Register(ma); - m.Register(mo); - - // Other won't expand as no value specified. - var r = new Model { Id = 88, Name = "Brian", Street = "Main", City = "Wellington" }; - var c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c.Address!.Street, Is.EqualTo("Main")); - Assert.That(c.Address!.City, Is.EqualTo("Wellington")); - Assert.That(c.Address!.Other, Is.Null); - }); - - // Other expands as it now has a value. - r = new Model { Id = 88, Name = "Brian", Street = "Main", City = "Wellington", Other = "Blah" }; - c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c.Address!.Street, Is.EqualTo("Main")); - Assert.That(c.Address!.City, Is.EqualTo("Wellington")); - Assert.That(c.Address!.Other, Is.Not.Null); - }); - Assert.That(c.Address!.Other!.Value, Is.EqualTo("Blah")); - - // Other *will* expand as the Address has the Other in the condition. - r = new Model { Id = 88, Name = "Brian", Street = null, City = null, Other = "Blah" }; - c = m.Map(r); - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c.Address!.Street, Is.Null); - Assert.That(c.Address!.City, Is.Null); - Assert.That(c.Address!.Other, Is.Not.Null); - }); - Assert.That(c.Address!.Other!.Value, Is.EqualTo("Blah")); - } - - [Test] - public void Test_Class_Property() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Map((o, s, d) => d.Address = o.Map(s.Address, d.Address)); - - var ma = new Mapper() - .Map((s, d) => d.Street = s.Street) - .Map((s, d) => d.City = s.City); - - var m = new Mapper(); - m.Register(mc); - m.Register(ma); - - var r = new Contact { Id = 88, Name = "Brian", Address = new Address { Street = "Main", City = "Wellington" } }; - var c = m.Map(r); - - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c.Address!.Street, Is.EqualTo("Main")); - Assert.That(c.Address!.City, Is.EqualTo("Wellington")); - Assert.That(c.Address!.Other, Is.Null); - }); - - r = new Contact { Id = 88, Name = "Brian", Address = null }; - c = m.Map(r); - Assert.That(c, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c.Id, Is.EqualTo(88)); - Assert.That(c.Name, Is.EqualTo("Brian")); - Assert.That(c.Address, Is.Null); - }); - } - - [Test] - public void ChangeLog_OperationType() - { - var m = new Mapper(); - var cl = new ChangeLog(); - ChangeLog.PrepareCreated((IChangeLogAudit)cl); - ChangeLog.PrepareUpdated((IChangeLogAudit)cl); - - var cl2 = m.Map(cl, OperationTypes.Create); - Assert.That(cl2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(cl2.CreatedBy, Is.Not.Null); - Assert.That(cl2.CreatedDate, Is.Not.Null); - Assert.That(cl2.UpdatedBy, Is.Null); - Assert.That(cl2.UpdatedDate, Is.Null); - }); - - cl2 = m.Map(cl, OperationTypes.Update); - Assert.That(cl2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(cl2.CreatedBy, Is.Null); - Assert.That(cl2.CreatedDate, Is.Null); - Assert.That(cl2.UpdatedBy, Is.Not.Null); - Assert.That(cl2.UpdatedDate, Is.Not.Null); - }); - } - - [Test] - public void Contact_ChangeLog_OperationType() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Map((o, s, d) => d.ChangeLog = o.Map(s.ChangeLog, d.ChangeLog)); - - var m = new Mapper(); - m.Register(mc); - - var cl = new ChangeLog(); - ChangeLog.PrepareCreated((IChangeLogAudit)cl); - ChangeLog.PrepareUpdated((IChangeLogAudit)cl); - - var c = new Contact { Id = 88, Name = "Dave", ChangeLog = cl }; - var c2 = m.Map(c, OperationTypes.Create); - - Assert.That(c2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(c2.Id, Is.EqualTo(88)); - Assert.That(c2.Name, Is.EqualTo("Dave")); - Assert.That(c2.ChangeLog, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(c2.ChangeLog!.CreatedBy, Is.Not.Null); - Assert.That(c2.ChangeLog.CreatedDate, Is.Not.Null); - Assert.That(c2.ChangeLog.UpdatedBy, Is.Null); - Assert.That(c2.ChangeLog.UpdatedDate, Is.Null); - }); - } - - [Test] - public void Register_WithBase() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Map((o, s, d) => d.ChangeLog = o.Map(s.ChangeLog, d.ChangeLog)); - - var mce = new Mapper() - .Map((s, d) => d.ExtraDetail = s.ExtraDetail) - .Base(mc); - - var m = new Mapper(); - m.Register(mc); - m.Register(mce); - - var cd = new ContactDetail { Id = 88, Name = "Dave", ExtraDetail = "read all about it" }; - var cd2 = m.Map(cd); - - Assert.That(cd2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(cd2.Id, Is.EqualTo(88)); - Assert.That(cd2.Name, Is.EqualTo("Dave")); - Assert.That(cd2.ExtraDetail, Is.EqualTo("read all about it")); - }); - } - - [Test] - public void Register_WithBase2() - { - var mc = new Mapper() - .Map((s, d) => d.Id = s.Id) - .Map((s, d) => d.Name = s.Name) - .Map((o, s, d) => d.ChangeLog = o.Map(s.ChangeLog, d.ChangeLog)); - - var mce = new Mapper() - .Map((s, d) => d.ExtraDetail = s.ExtraDetail) - .Base(); - - var m = new Mapper(); - m.Register(mc); - m.Register(mce); - - var cd = new ContactDetail { Id = 88, Name = "Dave", ExtraDetail = "read all about it" }; - var cd2 = m.Map(cd); - - Assert.That(cd2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(cd2.Id, Is.EqualTo(88)); - Assert.That(cd2.Name, Is.EqualTo("Dave")); - Assert.That(cd2.ExtraDetail, Is.EqualTo("read all about it")); - }); - } - - [Test] - public void Register_WithBase3() - { - var mce = new Mapper() - .Map((s, d) => d.ExtraDetail = s.ExtraDetail) - .Base(); - - var m = new Mapper(); - m.Register(new ContactMapper()); - m.Register(mce); - - var cd = new ContactDetail { Id = 88, Name = "Dave", ExtraDetail = "read all about it" }; - var cd2 = m.Map(cd); - - Assert.That(cd2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(cd2.Id, Is.EqualTo(88)); - Assert.That(cd2.Name, Is.EqualTo("Dave")); - Assert.That(cd2.ExtraDetail, Is.EqualTo("read all about it")); - }); - } - - [Test] - public void CustomMapper() - { - var m = new Mapper((s, d, t) => - { - d ??= new PersonB(); - Mapper.WhenCreate(t, () => d!.ID = s?.Id ?? 0); - return d; - }); - - var d = m.Map(new PersonA { Id = 88, Name = "blah" }, null, OperationTypes.Create); - Assert.That(d!.ID, Is.EqualTo(88)); - - d = m.Map(new PersonA { Id = 88, Name = "blah" }, null, OperationTypes.Update); - Assert.That(d!.ID, Is.EqualTo(0)); - } - - [Test] - public void BidirectionalMapper() - { - var bm = new BidirectionalMapper((s, d, _) => - { - d ??= new PersonB(); - d.ID = s?.Id ?? 0; - return d; - }, (s, d, _) => - { - d ??= new PersonA(); - d.Id = s?.ID ?? 0; - return d; - }); - - var pa = new PersonA { Id = 99, Name = "blah" }; - var pb = bm.Map(pa); - var pa2 = bm.Map(pb); - Assert.That(pa2.Id, Is.EqualTo(99)); - - var m = new Mapper(); - m.Register(bm); - - pa = new PersonA { Id = 88, Name = "blah" }; - pb = m.Map(pa); - pa2 = m.Map(pb); - Assert.That(pa2.Id, Is.EqualTo(88)); - } - - [Test] - public void MapWithSameType_Allowed() - { - var m = new Mapper(); - var r = m.TryGetMapper(out var sm); - Assert.Multiple(() => - { - Assert.That(r, Is.True); - Assert.That(sm, Is.Not.Null); - }); - - var p = new PersonA { Id = 88, Name = "blah" }; - var d = sm!.Map(p); - Assert.That(d, Is.Not.Null); - Assert.That(d, Is.SameAs(p)); - } - - [Test] - public void MapWithSameType_NotAllowed() - { - var m = new Mapper - { - MapSameTypeWithSourceValue = false - }; - - var r = m.TryGetMapper(out var sm); - - Assert.Multiple(() => - { - Assert.That(r, Is.False); - Assert.That(sm, Is.Null); - }); - } - - public class PersonAMapper : Mapper - { - public PersonAMapper() - { - Map((s, d) => d.ID = s.Id); - Map((s, d) => d.Text = s.Name); - } - } - - public class PersonA - { - public int Id { get; set; } - public string? Name { get; set; } - } - - public class PersonB - { - public int ID { get; set; } - public string? Text { get; set; } - } - - public class PersonACollection : List { } - - public class PersonBCollection : List { } - - public class PersonACollectionMapper : CollectionMapper { } - - public class Contact - { - public int Id { get; set; } - public string? Name { get; set; } - public Address? Address { get; set; } - public ChangeLog? ChangeLog { get; set; } - } - - public class ContactDetail : Contact - { - public string? ExtraDetail { get; set; } - } - - public class ContactMapper : Mapper - { - public ContactMapper() - { - Map((s, d) => d.Id = s.Id); - Map((s, d) => d.Name = s.Name); - Map((o, s, d) => d.ChangeLog = o.Map(s.ChangeLog, d.ChangeLog)); - } - } - - public class Address - { - public string? Street { get; set; } - public string? City { get; set; } - public Other? Other { get; set; } - } - - public class Other - { - public string? Value { get; set; } - } - - public class Model - { - public int Id { get; set; } - public string? Name { get; set; } - public string? Street { get; set; } - public string? City { get; set; } - public string? Other { get; set; } - } - - public class Employee - { - public string? Name { get; set; } - public Termination? Termination { get; set; } - } - - public class Termination - { - public string? Reason { get; set; } - public DateTime Date { get; set; } - } - - public class EmployeeModel - { - public string? Name { get; set; } - public string? Reason { get; set; } - public DateTime? Date { get; set; } - } - - public class EmployeeMapper : Mapper - { - public EmployeeMapper() - { - Map((s, d) => d.Name = s.Name); - Flatten(s => s.Termination); - } - } - - public class TerminationMapper : Mapper - { - public TerminationMapper() - { - Map((s, d) => d.Reason = s.Reason); - Map((s, d) => d.Date = s.Date); - } - - public override bool InitializeDestination(EmployeeModel d) - { - d.Reason = null; - d.Date = null; - return true; - } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Messaging/Azure/ServiceBus/ServiceBusSubscriberTest.cs b/tests/CoreEx.Test/Framework/Messaging/Azure/ServiceBus/ServiceBusSubscriberTest.cs deleted file mode 100644 index 9e32d82f..00000000 --- a/tests/CoreEx.Test/Framework/Messaging/Azure/ServiceBus/ServiceBusSubscriberTest.cs +++ /dev/null @@ -1,78 +0,0 @@ -using CoreEx.Abstractions; -using CoreEx.Azure.ServiceBus; -using CoreEx.Events; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Models; -using Microsoft.Azure.WebJobs.ServiceBus; -using Moq; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using UnitTestEx; - -namespace CoreEx.Test.Framework.Messaging.Azure.ServiceBus -{ - [TestFixture] - public class ServiceBusSubscriberTest - { - [Test] - public void ReceiveAsync_ValidationException() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(null); - - test.Type() - .Run(s => s.ReceiveAsync(message, actionsMock.Object, (ed, _) => throw new InvalidOperationException("Should not get here."))) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), ErrorType.ValidationError.ToString(), It.IsAny(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void ReceiveAsync_TransientException() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(new Product { Id = "A", Price = 1.99m }); - - test.Type() - .Run(s => s.ReceiveAsync(message, actionsMock.Object, (ed, _) => throw new TransientException())) - .AssertException("A transient error has occurred; please try again."); - - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void ReceiveAsync_UnhandledException() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(new Product { Id = "A", Price = 1.99m }); - - test.Type() - .Run(s => s.ReceiveAsync(message, actionsMock.Object, (ed, _) => throw new DivideByZeroException())) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), ErrorType.UnhandledError.ToString(), It.IsAny(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void ReceiveAsync_Success() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(new Product { Id = "A", Price = 1.99m }); - - test.Type() - .Run(s => s.ReceiveAsync(message, actionsMock.Object, (ed, _) => Task.CompletedTask)) - .AssertSuccess(); - - actionsMock.Verify(m => m.CompleteMessageAsync(message, default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/OData/ODataTest.cs b/tests/CoreEx.Test/Framework/OData/ODataTest.cs deleted file mode 100644 index c3b8c2e3..00000000 --- a/tests/CoreEx.Test/Framework/OData/ODataTest.cs +++ /dev/null @@ -1,424 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Mapping; -using CoreEx.OData; -using NUnit.Framework; -using System; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Soc = Simple.OData.Client; - -namespace CoreEx.Test.Framework.ODatax -{ - [TestFixture] - internal class ODataTest - { - private static string? _personUrl; //https://www.odata.org/odata-services/service-usages/request-key-tutorial/ - - [OneTimeSetUp] - public void OneTimeSetUp() - { - using var hc = new HttpClient(); - var response = hc.GetAsync("https://services.odata.org/TripPinRESTierService/").Result; - Assert.That(response.IsSuccessStatusCode, Is.True); - - _personUrl = response.RequestMessage!.RequestUri!.ToString(); - } - - internal static IMapper GetMapper() - { - var mapper = new Mapper(); - mapper.Register(new Mapper() - .Map((s, d) => d.ID = s.Id) - .Map((s, d) => d.Name = s.Name) - .Map((s, d) => d.Description = s.Description)); - - mapper.Register(new Mapper() - .Map((s, d) => d.Id = s.ID) - .Map((s, d) => d.Name = s.Name) - .Map((s, d) => d.Description = s.Description)); - - mapper.Register(new Mapper() - .Map((s, d) => d.UserName = s.Id) - .Map((s, d) => d.FirstName = s.FirstName) - .Map((s, d) => d.LastName = s.LastName)); - - mapper.Register(new Mapper() - .Map((s, d) => d.Id = s.UserName) - .Map((s, d) => d.FirstName = s.FirstName) - .Map((s, d) => d.LastName = s.LastName)); - - return mapper; - } - - internal static ODataClient GetPersonClient(Soc.ODataClientSettings? settings = null) - { - settings ??= new Soc.ODataClientSettings(); - settings.BaseUri = new Uri(_personUrl!); - settings.BeforeRequest = r => Console.WriteLine($"{r.Method} {r.RequestUri}"); - return new ODataClient(new Soc.ODataClient(settings), GetMapper()); - } - - [Test] - public async Task A010_Get_NotFound() - { - var odata = GetPersonClient(); - var result = await odata.GetWithResultAsync("People", "invalid_key", default); - Assert.That(result.Value, Is.Null); - } - - [Test] - public async Task A020_Get_NotFound_IgnoreResourceNotFoundException() - { - var odata = GetPersonClient(new Soc.ODataClientSettings { IgnoreResourceNotFoundException = true }); - var result = await odata.GetWithResultAsync("People", "invalid_key", default); - Assert.That(result.Value, Is.Null); - } - - [Test] - public async Task A030_Get_Success() - { - var odata = GetPersonClient(); - var result = await odata.GetWithResultAsync("People", "russellwhyte", default); - Assert.That(result.Value, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result.Value.Id, Is.EqualTo("russellwhyte")); - Assert.That(result.Value.FirstName, Is.EqualTo("Russell")); - Assert.That(result.Value.LastName, Is.EqualTo("Whyte")); - }); - } - - [Test] - public async Task B010_SelectSingle() - { - var odata = GetPersonClient(); - var result = await odata.Query("People", q => q.Filter(p => p.UserName == "russellwhyte")).SelectSingleAsync(default); - Assert.That(result, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result.Id, Is.EqualTo("russellwhyte")); - Assert.That(result.FirstName, Is.EqualTo("Russell")); - Assert.That(result.LastName, Is.EqualTo("Whyte")); - }); - - var result2 = (await odata.Query("People", q => q.Filter(p => p.UserName == "russellwhyte")).SelectSingleWithResultAsync(default)).Value; - Assert.That(result2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result2!.Id, Is.EqualTo("russellwhyte")); - Assert.That(result2.FirstName, Is.EqualTo("Russell")); - Assert.That(result2.LastName, Is.EqualTo("Whyte")); - }); - } - - [Test] - public async Task B020_SelectFirstOrDefault() - { - var odata = GetPersonClient(); - var result = await odata.Query("People", q => q.Filter(p => p.FirstName == "Russell")).SelectFirstAsync(default); - Assert.That(result, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result.Id, Is.EqualTo("russellwhyte")); - Assert.That(result.FirstName, Is.EqualTo("Russell")); - Assert.That(result.LastName, Is.EqualTo("Whyte")); - }); - - var result2 = (await odata.Query("People", q => q.Filter(p => p.FirstName == "Russell")).SelectFirstWithResultAsync(default)).Value; - Assert.That(result2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result2!.Id, Is.EqualTo("russellwhyte")); - Assert.That(result2.FirstName, Is.EqualTo("Russell")); - Assert.That(result2.LastName, Is.EqualTo("Whyte")); - }); - - var result3 = await odata.Query("People", q => q.Filter(p => p.FirstName == "does-not-exist")).SelectFirstOrDefaultAsync(default); - Assert.That(result3, Is.Null); - - var result4 = (await odata.Query("People", q => q.Filter(p => p.FirstName == "does-not-exist")).SelectFirstOrDefaultWithResultAsync(default)).Value; - Assert.That(result4, Is.Null); - } - - [Test] - public async Task B030_SelectQueryWithResultAsync() - { - var odata = GetPersonClient(); - var result = await odata.Query("People").WithPaging(2, 3).SelectQueryWithResultAsync(default); - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.Value, Has.Count.EqualTo(3)); - Assert.That(result.Value.Select(x => x.FirstName).ToArray(), Is.EqualTo(new string[] { "Elaine", "Genevieve", "Georgina" })); - }); - } - - [Test] - public async Task B030_SelectResultWithResultAsync() - { - var odata = GetPersonClient(); - var result = await odata.Query("People").WithPaging(PagingArgs.CreateSkipAndTake(2, 3, true)).SelectResultWithResultAsync(default); - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.Value.Items, Has.Count.EqualTo(3)); - Assert.That(result.Value.Items.Select(x => x.FirstName).ToArray(), Is.EqualTo(new string[] { "Elaine", "Genevieve", "Georgina" })); - Assert.That(result.Value.Paging!.TotalCount, Is.EqualTo(20)); - }); - } - - [Test] - public async Task B040_SelectResultWithResultAsync_WildCards() - { - var odata = GetPersonClient(); - var result = await odata.Query("People", q => q.FilterWildcard(x => x.FirstName, "*s*")).WithPaging(PagingArgs.CreateSkipAndTake(2, 3, true)).SelectResultWithResultAsync(default); - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.Value.Items, Has.Count.EqualTo(3)); - Assert.That(result.Value.Items.Select(x => x.FirstName).ToArray(), Is.EqualTo(new string[] { "Russell", "Sallie", "Sandy" })); - Assert.That(result.Value.Paging!.TotalCount, Is.EqualTo(7)); - }); - - // Weird how Scott comes last as it is first when no paging is requested, but that is what is returned from the service . - result = await odata.Query("People", q => q.FilterWildcard(x => x.FirstName, "s*")).WithPaging(PagingArgs.CreateSkipAndTake(2, 3, true)).SelectResultWithResultAsync(default); - Assert.Multiple(() => - { - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.Value.Items, Has.Count.EqualTo(1)); - Assert.That(result.Value.Items.Select(x => x.FirstName).ToArray(), Is.EqualTo(new string[] { "Scott" })); - Assert.That(result.Value.Paging!.TotalCount, Is.EqualTo(3)); - }); - } - - [Test] - public async Task B050_SelectQuery() - { - var odata = GetPersonClient(); - var result = await odata.Query("People", q => q.FilterWith((string)null!, x => x.FirstName == "Scott")).SelectQueryAsync(); - Assert.That(result, Has.Count.EqualTo(20)); - - result = await odata.Query("People", q => q.FilterWith("abc", x => x.FirstName == "Scott")).SelectQueryAsync(); - Assert.That(result, Has.Count.EqualTo(1)); - - odata = GetPersonClient(); - result = await odata.Query("People", q => q.FilterWith((string)null!, x => x.FirstName == "Scott")).SelectQueryAsync(); - Assert.That(result, Has.Count.EqualTo(20)); - - result = await odata.Query("People", q => q.FilterWith("abc", x => x.FirstName == "Scott")).SelectQueryAsync(); - Assert.That(result, Has.Count.EqualTo(1)); - } - - [Test] - public async Task C010_Create_Success() - { - var odata = GetPersonClient(); - var result = await odata.CreateWithResultAsync("People", new Person { Id = "bobsmith", FirstName = "Bob", LastName = "Smith" }, default); - Assert.That(result.Value, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result.Value.Id, Is.EqualTo("bobsmith")); - Assert.That(result.Value.FirstName, Is.EqualTo("Bob")); - Assert.That(result.Value.LastName, Is.EqualTo("Smith")); - }); - - var result2 = await odata.GetWithResultAsync("People", "bobsmith", default); - Assert.That(result2.Value, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result2.Value.Id, Is.EqualTo("bobsmith")); - Assert.That(result2.Value.FirstName, Is.EqualTo("Bob")); - Assert.That(result2.Value.LastName, Is.EqualTo("Smith")); - }); - } - - [Test] - public async Task D010_Update_NotFound() - { - var odata = GetPersonClient(); - odata.Args = new ODataArgs { PreReadOnUpdate = false }; - var result = await odata.GetWithResultAsync("People", "russellwhyte", default); - - result.Value!.Id = "russellwhyteeeee"; - result.Value.FirstName = "Russell2"; - - var ex = Assert.ThrowsAsync(async () => await odata.UpdateWithResultAsync("People", result.Value, default)); - Assert.That(ex!.Code, Is.EqualTo(HttpStatusCode.InternalServerError)); - } - - [Test] - public async Task D020_Update_NotFound_PreRead() - { - var odata = GetPersonClient(); - odata.Args = new ODataArgs { PreReadOnUpdate = true }; - var result = await odata.GetWithResultAsync("People", "russellwhyte", default); - - result.Value!.Id = "russellwhyteeeee"; - result.Value.FirstName = "Russell2"; - var result2 = await odata.UpdateWithResultAsync("People", result.Value, default); - - Assert.Multiple(() => - { - Assert.That(result2.IsFailure, Is.True); - Assert.That(result2.Error, Is.InstanceOf()); - }); - } - - [Test] - public async Task D030_Update_NotFound_NoPreRead() - { - var odata = GetPersonClient(); - odata.Args = new ODataArgs { PreReadOnUpdate = false }; - var result = await odata.GetWithResultAsync("People", "russellwhyte", default); - - result.Value!.Id = "russellwhyteeeee"; - result.Value.FirstName = "Russell2"; - - var ex = Assert.ThrowsAsync(async () => await odata.UpdateWithResultAsync("People", result.Value, default)); - Assert.That(ex!.Code, Is.EqualTo(HttpStatusCode.InternalServerError)); - } - - [Test] - public async Task D040_Update_Success() - { - var odata = GetPersonClient(); - var result = await odata.GetWithResultAsync("People", "russellwhyte", default); - - result.Value!.FirstName = "Russell2"; - var result2 = await odata.UpdateWithResultAsync("People", result.Value, default); - Assert.That(result2.Value, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result2.Value!.Id, Is.EqualTo("russellwhyte")); - Assert.That(result2.Value.FirstName, Is.EqualTo("Russell2")); - Assert.That(result2.Value.LastName, Is.EqualTo("Whyte")); - }); - - result = await odata.GetWithResultAsync("People", "russellwhyte", default); - Assert.That(result.Value, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result.Value!.Id, Is.EqualTo("russellwhyte")); - Assert.That(result.Value.FirstName, Is.EqualTo("Russell2")); - Assert.That(result.Value.LastName, Is.EqualTo("Whyte")); - }); - } - - [Test] - public async Task E010_Delete_PreRead() - { - var odata = GetPersonClient(); - odata.Args = new ODataArgs { PreReadOnDelete = true }; - var result = await odata.DeleteWithResultAsync("People", "russellwhyte", default); - Assert.That(result.IsSuccess, Is.True); - - var result2 = await odata.GetWithResultAsync("People", "russellwhyte", default); - Assert.That(result2.Value, Is.Null); - - // Pre-read will determine not found :-) - result = await odata.DeleteWithResultAsync("People", "russellwhyte", default); - Assert.Multiple(() => - { - Assert.That(result.IsFailure, Is.True); - Assert.That(result.Error, Is.InstanceOf()); - }); - } - - [Test] - public async Task E020_Delete_NoPreRead() - { - var odata = GetPersonClient(); - odata.Args = new ODataArgs { PreReadOnDelete = false }; - var result = await odata.DeleteWithResultAsync("People", "ronaldmundy", default); - Assert.That(result.IsSuccess, Is.True); - - var result2 = await odata.GetWithResultAsync("People", "ronaldmundy", default); - Assert.That(result2.Value, Is.Null); - - // Alas, arguably this endpoint should return not found :-( - var ex = Assert.ThrowsAsync(async () => await odata.DeleteWithResultAsync("People", "ronaldmundy", default)); - Assert.That(ex!.Code, Is.EqualTo(HttpStatusCode.InternalServerError)); - } - - [Test] - public async Task F010_Collection_CRD() - { - var odata = GetPersonClient(); - var ocoll = odata.CreateItemCollection("People", new PersonMapper()); - var result = await ocoll.CreateWithResultAsync(new Person { Id = "barbsmith", FirstName = "Barbara", LastName = "Smith" }); - Assert.That(result.IsSuccess, Is.True); - - var result2 = await ocoll.GetWithResultAsync("barbsmith"); - Assert.That(result2.Value, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result2.Value.Id, Is.EqualTo("barbsmith")); - Assert.That(result2.Value.FirstName, Is.EqualTo("Barbara")); - Assert.That(result2.Value.LastName, Is.EqualTo("Smith")); - }); - - result2 = await ocoll.GetWithResultAsync("barbsmith"); - Assert.That(result2.Value, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result2.Value.Id, Is.EqualTo("barbsmith")); - Assert.That(result2.Value.FirstName, Is.EqualTo("Barbara")); - Assert.That(result2.Value.LastName, Is.EqualTo("Smith")); - }); - - var result3 = await ocoll.DeleteWithResultAsync("barbsmith"); - Assert.That(result3.IsSuccess, Is.True); - - result2 = await ocoll.GetWithResultAsync("barbsmith"); - Assert.That(result2.Value, Is.Null); - } - } - - public class PersonCollectionResult : CollectionResult { } - - public class PersonCollection : System.Collections.Generic.List { } - - public class Person : IIdentifier - { - public string? Id { get; set; } - public string? FirstName { get; set; } - public string? LastName { get; set; } - } - - public class MPerson - { - public string? UserName { get; set; } - public string? FirstName { get; set; } - public string? LastName { get; set; } - } - - public class PersonMapper : OData.Mapping.ODataMapper - { - public PersonMapper() - { - Map(x => x.Id, "UserName").SetPrimaryKey(); - Map(x => x.FirstName); - Map(x => x.LastName); - } - } - - public class Product : IIdentifier - { - public int Id { get; set; } - - public string? Name { get; set; } - - public string? Description { get; set; } - } - - public class MProduct - { - public int ID { get; set; } - - public string? Name { get; set; } - - public string? Description { get; set; } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/RefData/ReferenceDataTest.cs b/tests/CoreEx.Test/Framework/RefData/ReferenceDataTest.cs deleted file mode 100644 index 0539c222..00000000 --- a/tests/CoreEx.Test/Framework/RefData/ReferenceDataTest.cs +++ /dev/null @@ -1,1298 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Entities.Extended; -using CoreEx.Mapping.Converters; -using CoreEx.RefData; -using CoreEx.RefData.Caching; -using CoreEx.RefData.Extended; -using CoreEx.Results; -using CoreEx.TestFunction; -using FluentAssertions; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using UnitTestEx; -using Nsj = Newtonsoft.Json; -using Stj = System.Text.Json; -using CoreEx.AspNetCore.Http; - -namespace CoreEx.Test.Framework.RefData -{ - [TestFixture] - public class ReferenceDataTest - { - [Test] - public void RefDataSimple() - { - var r = new RefDataSimple { Id = 1, Code = "X", Text = "XX" }; - r.Id = 1; - r.Code = "X"; - r.Text = "XX"; - - Assert.Multiple(() => - { - Assert.That(r.Id, Is.EqualTo(1)); - Assert.That(((ReferenceDataBase)r).Id, Is.EqualTo(1)); - Assert.That(((ReferenceDataBase)r).Id, Is.EqualTo(1)); - Assert.That(((IReferenceData)r).Id, Is.EqualTo(1)); - Assert.That(((IIdentifier)r).Id, Is.EqualTo(1)); - Assert.That(r.Code, Is.EqualTo("X")); - Assert.That(r.Text, Is.EqualTo("XX")); - - Assert.That(((IIdentifier)r).IdType, Is.EqualTo(typeof(int))); - }); - - var ir = (IReferenceData)r; - Assert.That(ir.IsValid, Is.True); - - ir.SetInvalid(); - Assert.Multiple(() => - { - Assert.That(ir.IsValid, Is.False); - Assert.That(typeof(int), Is.SameAs(ir.IdType)); - }); - - Assert.That(new CoreEx.Text.Json.ReferenceDataContentJsonSerializer().Serialize(r), Is.EqualTo("{\"id\":1,\"code\":\"X\",\"text\":\"XX\",\"isActive\":true}")); - } - - [Test] - public void Exercise_RefData() - { - var r = new RefData { Id = 1, Code = "X", Text = "XX" }; - r.Id = 1; - r.Code = "X"; - r.Text = "XX"; - - Assert.Multiple(() => - { - Assert.That(r.Id, Is.EqualTo(1)); - Assert.That(((IReferenceData)r).Id, Is.EqualTo(1)); - Assert.That(((ReferenceDataBaseEx)r).Id, Is.EqualTo(1)); - Assert.That(((IIdentifier)r).Id, Is.EqualTo(1)); - - Assert.That(((IIdentifier)r).IdType, Is.EqualTo(typeof(int))); - }); - - // Immutable. - Assert.Throws(() => r.Id = 2); - Assert.Throws(() => r.Code = "Y"); - r.Text = "YY"; - - var r2 = r; - Assert.That(r, Is.EqualTo(r2)); - - r2 = (RefData)r.Clone(); - Assert.That(r, Is.EqualTo(r2)); - Assert.That(r, Is.EqualTo(r2)); - Assert.Multiple(() => - { - Assert.That(r, Is.EqualTo((object)r2)); - Assert.That(r2.GetHashCode(), Is.EqualTo(r.GetHashCode())); - }); - - r2 = new RefData { Id = 1, Code = "X", Text = "XXXX" }; - Assert.That(r, Is.Not.EqualTo(r2)); - Assert.That(r, Is.Not.EqualTo(r2)); - Assert.Multiple(() => - { - Assert.That(r, Is.Not.EqualTo((object)r2)); - Assert.That(r2.GetHashCode(), Is.Not.EqualTo(r.GetHashCode())); - }); - - r.MakeReadOnly(); - Assert.That(r.IsReadOnly, Is.True); - Assert.Throws(() => r.Text = "XXXXX"); - } - - [Test] - public void Exercise_RefDataEx() - { - var r = new RefDataEx { Id = "@", Code = "X", Text = "XX", Description = "XXX", IsActive = true, StartDate = new DateTime(2020, 01, 01), EndDate = new DateTime(2020, 12, 31) }; - r.Id = "@"; - r.Code = "X"; - r.Text = "XX"; - r.Description = "XXX"; - r.IsActive = true; - r.StartDate = new DateTime(2020, 01, 01); - r.EndDate = new DateTime(2020, 12, 31); - - // Immutable. - Assert.Throws(() => r.Id = "Q"); - Assert.Throws(() => r.Code = "Y"); - r.Text = "YY"; - r.Description = "YYY"; - r.IsActive = false; - r.StartDate = r.StartDate.Value.AddDays(1); - r.EndDate = r.EndDate.Value.AddDays(1); - - var r2 = r; - Assert.That(r, Is.EqualTo(r2)); - - r2 = (RefDataEx)r.Clone(); - Assert.That(r, Is.EqualTo(r2)); - Assert.That(r, Is.EqualTo(r2)); - Assert.Multiple(() => - { - Assert.That(r, Is.EqualTo((object)r2)); - Assert.That(r2.GetHashCode(), Is.EqualTo(r.GetHashCode())); - }); - - r2 = new RefDataEx { Id = "@", Code = "X", Text = "XX", Description = "XXX", IsActive = true, StartDate = new DateTime(2020, 01, 01), EndDate = new DateTime(2021, 12, 31) }; - Assert.That(r, Is.Not.EqualTo(r2)); - Assert.That(r, Is.Not.EqualTo(r2)); - Assert.Multiple(() => - { - Assert.That(r, Is.Not.EqualTo((object)r2)); - Assert.That(r2.GetHashCode(), Is.Not.EqualTo(r.GetHashCode())); - }); - - r.MakeReadOnly(); - Assert.That(r.IsReadOnly, Is.True); - Assert.Throws(() => r.Text = "Bananas"); - Assert.Throws(() => r.IsActive = true); - } - - [Test] - public void Exercise_RefDataEx_Mappings() - { - var r = new RefDataEx(); - Assert.Multiple(() => - { - Assert.That(r.IsInitial, Is.True); - Assert.That(r.HasMappings, Is.False); - }); - - r.SetMapping("X", 1); - Assert.Multiple(() => - { - Assert.That(r.IsInitial, Is.False); - Assert.That(r.HasMappings, Is.True); - - Assert.That(r.TryGetMapping("X", out int val), Is.True); - Assert.That(val, Is.EqualTo(1)); - Assert.That(r.GetMapping("X"), Is.EqualTo(1)); - }); - - var r2 = (RefDataEx)r.Clone(); - Assert.Multiple(() => - { - Assert.That(r2.HasMappings, Is.True); - Assert.That(r2.TryGetMapping("X", out int val), Is.True); - Assert.That(val, Is.EqualTo(1)); - Assert.That(r2.GetMapping("X"), Is.EqualTo(1)); - }); - - r2 = new RefDataEx(); - r2.SetMapping("X", 1); - Assert.That(r, Is.EqualTo(r2)); - - r2.SetMapping("Y", 2); - Assert.That(r, Is.Not.EqualTo(r2)); - - r.MakeReadOnly(); - Assert.That(r.IsReadOnly, Is.True); - Assert.Throws(() => r.SetMapping("Y", 2)); - } - - [Test] - public void IsValid_IsActive() - { - var rd = new RefDataEx(); - Assert.Multiple(() => - { - Assert.That(rd.IsActive, Is.True); - Assert.That(rd.IsValid, Is.True); - }); - - rd.IsActive = false; - Assert.Multiple(() => - { - Assert.That(rd.IsActive, Is.False); - Assert.That(rd.IsValid, Is.True); - }); - - rd.IsActive = true; - rd.EndDate = new DateTime(2000, 01, 01); - Assert.Multiple(() => - { - Assert.That(rd.IsActive, Is.False); - Assert.That(rd.IsValid, Is.True); - }); - - rd.EndDate = null; - Assert.Multiple(() => - { - Assert.That(rd.IsActive, Is.True); - Assert.That(rd.IsValid, Is.True); - }); - - rd.StartDate = DateTime.UtcNow.AddDays(20); - Assert.Multiple(() => - { - Assert.That(rd.IsActive, Is.False); - Assert.That(rd.IsValid, Is.True); - }); - - rd.StartDate = DateTime.UtcNow.AddDays(-20); - rd.EndDate = DateTime.UtcNow.AddDays(20); - Assert.Multiple(() => - { - Assert.That(rd.IsActive, Is.True); - Assert.That(rd.IsValid, Is.True); - }); - - // Set invalid explicitly; makes it inactive; can not reset. - ((IReferenceData)rd).SetInvalid(); - Assert.Multiple(() => - { - Assert.That(rd.IsActive, Is.False); - Assert.That(rd.IsValid, Is.False); - }); - - rd.IsActive = true; - Assert.Multiple(() => - { - Assert.That(rd.IsActive, Is.False); - Assert.That(rd.IsValid, Is.False); - }); - } - - [Test] - public void IsValid_IsActive_SystemTime() - { - // Set the system time to the past. - IServiceCollection sc = new ServiceCollection(); - sc.AddExecutionContext(); - sc.AddScoped(_ => SystemTime.CreateFixed(new DateTime(1999, 06, 01))); - var sb = sc.BuildServiceProvider(); - using var scope = sb.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - var rd = new RefDataEx { IsActive = true }; - Assert.That(rd.IsValid, Is.True); - - rd.StartDate = new DateTime(1999, 01, 01); - rd.EndDate = new DateTime(1999, 12, 31); - Assert.That(rd.IsValid, Is.True); - } - - [Test] - public void IsValid_IsActive_ReferenceDataContext() - { - // Set the reference data context back in time. - IServiceCollection sc = new ServiceCollection(); - sc.AddExecutionContext(); - sc.AddScoped(_ => new ReferenceDataContext { Date = new DateTime(1999, 06, 01) }); - var sb = sc.BuildServiceProvider(); - using var scope = sb.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - var rd = new RefDataEx { IsActive = true}; - Assert.That(rd.IsValid, Is.True); - - rd.StartDate = new DateTime(1999, 01, 01); - rd.EndDate = new DateTime(1999, 12, 31); - Assert.That(rd.IsValid, Is.True); - } - - [Test] - public void Casting_FromRefData() - { - Assert.Multiple(() => - { - Assert.That((int)(RefData)null!, Is.EqualTo(0)); - Assert.That((string?)(RefData)null!, Is.EqualTo(null)); - Assert.That((int)new RefData(), Is.EqualTo(0)); - Assert.That((string?)new RefData(), Is.EqualTo(null)); - Assert.That((int)new RefData { Id = 1, Code = "X", Text = "XX" }, Is.EqualTo(1)); - Assert.That((string?)new RefData { Id = 1, Code = "X", Text = "XX" }, Is.EqualTo("X")); - Assert.That((int)new RefDataEx { Id = "X", Code = "XX", Text = "XXX" }, Is.EqualTo(0)); - Assert.That((string?)new RefDataEx { Id = "X", Code = "XX", Text = "XXX" }, Is.EqualTo("XX")); - - Assert.That((int?)(RefDataEx)null!, Is.EqualTo(null)); - Assert.That((string?)(RefDataEx)null!, Is.EqualTo(null)); - Assert.That((int?)new RefDataEx(), Is.EqualTo(null)); - Assert.That((string?)new RefDataEx(), Is.EqualTo(null)); - Assert.That((int?)new RefData { Id = 1, Code = "X", Text = "XX" }, Is.EqualTo(1)); - Assert.That((string?)new RefData { Id = 1, Code = "X", Text = "XX" }, Is.EqualTo("X")); - Assert.That((int?)new RefDataEx { Id = "X", Code = "XX", Text = "XXX" }, Is.EqualTo(null)); - Assert.That((string?)new RefDataEx { Id = "X", Code = "XX", Text = "XXX" }, Is.EqualTo("XX")); - }); - } - - [Test] - public void Collection_Add() - { - var rc = new RefDataExCollection(); - Assert.Throws(() => rc.Add(null!)); - Assert.Throws(() => rc.Add(new RefDataEx()))!.Message.Should().StartWith("Id must not be null."); - Assert.Throws(() => rc.Add(new RefDataEx{ Id = "X" }))!.Message.Should().StartWith("Code must not be null."); - - var r = new RefDataEx { Id = "X", Code = "XX" }; - rc.Add(r); - Assert.That(rc, Has.Count.EqualTo(1)); - Assert.Multiple(() => - { - Assert.That(rc["XX"], Is.SameAs(r)); - Assert.That(rc["xx"], Is.SameAs(r)); - Assert.That(rc.GetByCode("XX"), Is.SameAs(r)); - Assert.That(rc.GetByCode("xx"), Is.SameAs(r)); - Assert.That(rc.GetById("X"), Is.SameAs(r)); - Assert.That(r.IsReadOnly, Is.True); - }); - - Assert.Throws(() => rc.Add(r))!.Message.Should().StartWith("Item already exists within the collection."); - Assert.Throws(() => rc.Add(new RefDataEx { Id = "X", Code = "YY" }))!.Message.Should().StartWith("Item with Id 'X' already exists within the collection."); - Assert.Throws(() => rc.Add(new RefDataEx { Id = "Y", Code = "XX" }))!.Message.Should().StartWith("Item with Code 'XX' already exists within the collection."); - Assert.Throws(() => rc.Add(new RefDataEx { Id = "Y", Code = "xx" }))!.Message.Should().StartWith("Item with Code 'xx' already exists within the collection."); - - r = new RefDataEx { Id = "Y", Code = "YY" }; - rc.Add(r); - Assert.That(rc, Has.Count.EqualTo(2)); - Assert.Multiple(() => - { - Assert.That(rc["YY"], Is.SameAs(r)); - Assert.That(rc["yy"], Is.SameAs(r)); - Assert.That(rc.GetByCode("YY"), Is.SameAs(r)); - Assert.That(rc.GetByCode("yy"), Is.SameAs(r)); - Assert.That(rc.GetById("Y"), Is.SameAs(r)); - Assert.That(r.IsReadOnly, Is.True); - }); - } - - [Test] - public void Collection_Lists() - { - var rc = new RefDataCollection { new RefData { Id = 1, Code = "Z", Text = "A", SortOrder = 2 }, new RefData { Id = 2, Code = "A", Text = "B", IsActive = false, SortOrder = 4 }, new RefData { Id = 3, Code = "Y", Text = "D", SortOrder = 1 }, new RefData { Id = 4, Code = "B", Text = "C", SortOrder = 3 } }; - Assert.Multiple(() => - { - Assert.That(rc.GetItems(ReferenceDataSortOrder.Id, null, null).Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 1, 2, 3, 4 })); - Assert.That(rc.GetItems(ReferenceDataSortOrder.Code, null, null).Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 2, 4, 3, 1 })); - Assert.That(rc.GetItems(ReferenceDataSortOrder.Text, null, null).Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 1, 2, 4, 3 })); - Assert.That(rc.GetItems(ReferenceDataSortOrder.SortOrder, null, null).Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 3, 1, 4, 2 })); - }); - - rc.SortOrder = ReferenceDataSortOrder.Id; - Assert.That(rc.AllList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 1, 2, 3, 4 })); - rc.SortOrder = ReferenceDataSortOrder.Code; - Assert.That(rc.AllList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 2, 4, 3, 1 })); - rc.SortOrder = ReferenceDataSortOrder.Text; - Assert.That(rc.AllList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 1, 2, 4, 3 })); - rc.SortOrder = ReferenceDataSortOrder.SortOrder; - Assert.That(rc.AllList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 3, 1, 4, 2 })); - - rc.SortOrder = ReferenceDataSortOrder.Id; - Assert.That(rc.ActiveList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 1, 3, 4 })); - rc.SortOrder = ReferenceDataSortOrder.Code; - Assert.That(rc.ActiveList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 4, 3, 1 })); - rc.SortOrder = ReferenceDataSortOrder.Text; - Assert.That(rc.ActiveList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 1, 4, 3 })); - rc.SortOrder = ReferenceDataSortOrder.SortOrder; - Assert.That(rc.ActiveList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 3, 1, 4 })); - - ((IReferenceData)rc.GetById(1)!).SetInvalid(); - - rc.SortOrder = ReferenceDataSortOrder.Id; - Assert.That(rc.AllList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 2, 3, 4 })); - rc.SortOrder = ReferenceDataSortOrder.Code; - Assert.That(rc.AllList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 2, 4, 3 })); - rc.SortOrder = ReferenceDataSortOrder.Text; - Assert.That(rc.AllList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 2, 4, 3 })); - rc.SortOrder = ReferenceDataSortOrder.SortOrder; - Assert.That(rc.AllList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 3, 4, 2 })); - - rc.SortOrder = ReferenceDataSortOrder.Id; - Assert.That(rc.ActiveList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 3, 4 })); - rc.SortOrder = ReferenceDataSortOrder.Code; - Assert.That(rc.ActiveList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 4, 3 })); - rc.SortOrder = ReferenceDataSortOrder.Text; - Assert.That(rc.ActiveList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 4, 3 })); - rc.SortOrder = ReferenceDataSortOrder.SortOrder; - Assert.Multiple(() => - { - Assert.That(rc.ActiveList.Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 3, 4 })); - - Assert.That(rc.GetItems(ReferenceDataSortOrder.Id, null, null).Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 1, 2, 3, 4 })); - Assert.That(rc.GetItems(ReferenceDataSortOrder.Code, null, null).Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 2, 4, 3, 1 })); - Assert.That(rc.GetItems(ReferenceDataSortOrder.Text, null, null).Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 1, 2, 4, 3 })); - Assert.That(rc.GetItems(ReferenceDataSortOrder.SortOrder, null, null).Select(x => x.Id).ToArray(), Is.EqualTo(new int[] { 3, 1, 4, 2 })); - }); - } - - [Test] - public void Collection_Mappings() - { - var rc = new RefDataCollection(); - var r = new RefData { Id = 1, Code = "A" }; - r.SetMapping("D365", "A-1"); - r.SetMapping("SAP", 4300); - - rc.Add(r); - Assert.Multiple(() => - { - Assert.That(rc.ContainsMapping("D365", "A-1"), Is.True); - Assert.That(rc.ContainsMapping("SAP", 4300), Is.True); - Assert.That(rc.ContainsMapping("OTHER", Guid.NewGuid()), Is.False); - Assert.That(rc.GetByMapping("D365", "A-1"), Is.SameAs(r)); - Assert.That(rc.GetByMapping("SAP", 4300), Is.SameAs(r)); - Assert.That(rc.GetByMapping("OTHER", Guid.NewGuid()), Is.Null); - Assert.That(rc.TryGetByMapping("SAP", 4300, out RefData? r2), Is.True); - Assert.That(r2, Is.SameAs(r)); - }); - - Assert.Multiple(() => - { - Assert.That(rc.TryGetByMapping("OTHER", Guid.NewGuid(), out RefData? r2), Is.False); - Assert.That(r2, Is.Null); - }); - - var r2 = new RefData { Id = 2, Code = "B" }; - Assert.Multiple(() => - { - r2.SetMapping("D365", "A-2"); - r2.SetMapping("SAP", 4301); - rc.Add(r2); - }); - - Assert.Multiple(() => - { - Assert.That(rc.ContainsMapping("D365", "A-1"), Is.True); - Assert.That(rc.ContainsMapping("D365", "A-2"), Is.True); - - Assert.That(rc.GetByMapping("D365", "A-1"), Is.SameAs(r)); - Assert.That(rc.GetByMapping("D365", "A-2"), Is.SameAs(r2)); - }); - - var r3 = new RefData { Id = 3, Code = "C" }; - r3.SetMapping("D365", "A-2"); - Assert.Throws(() => rc.Add(r3))!.Message.Should().StartWith("Item with Mapping Key 'D365' and Value 'A-2' already exists within the collection."); - } - - [Test] - public void OrchestratorProviders() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - var sp = sc.BuildServiceProvider(); - var o = new ReferenceDataOrchestrator(sp); - o.Register(); - - Assert.Throws(() => o.Register())!.Message.Should().StartWith("Type 'CoreEx.Test.Framework.RefData.RefData' cannot be added as name 'RefData' already associated with previously added Type 'CoreEx.Test.Framework.RefData.RefData'."); - - Assert.Multiple(() => - { - Assert.That(o[typeof(RefData)], Is.InstanceOf(typeof(RefDataCollection))); - Assert.That(o[typeof(RefDataEx)], Is.InstanceOf(typeof(RefDataExCollection))); - Assert.That(o[typeof(string)], Is.Null); - - Assert.That(o["refdata"], Is.InstanceOf(typeof(RefDataCollection))); - Assert.That(o[nameof(RefDataEx)], Is.InstanceOf(typeof(RefDataExCollection))); - Assert.That(o["bananas"], Is.Null); - }); - - // Simulate access. - Assert.Multiple(() => - { - Assert.That(o[typeof(RefData)]!.GetByCode("A")!.Id, Is.EqualTo(1)); - Assert.That(o[typeof(RefDataEx)]!.GetByCode("BBB")!.Id, Is.EqualTo("BB")); - }); - - // Provider not wired up to execution context should not throw an exception; all will be invalid. - var r = (RefData)1; - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.Id, Is.EqualTo(1)); - Assert.That(r.Code, Is.EqualTo(null)); - Assert.That(r.IsValid, Is.False); - }); - - r = (RefData)"A"; - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.Id, Is.EqualTo(0)); - Assert.That(r.Code, Is.EqualTo("A")); - Assert.That(r.IsValid, Is.False); - }); - } - - [Test] - public void RefData_ImplicitCast_Load() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(sp => new ReferenceDataOrchestrator(sp).Register()); - var sp = sc.BuildServiceProvider(); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - RefData? r = null; - RefData? r1 = null; - RefData? r2 = null; - for (int i = 0; i < 1000; i++) - { - r = (RefData)1; - r1 = (RefData)"B"; - r2 = (RefData)"C"; - } - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r!.Id, Is.EqualTo(1)); - Assert.That(r.Code, Is.EqualTo("A")); - Assert.That(r.IsValid, Is.True); - - Assert.That(r1, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(r1!.Id, Is.EqualTo(2)); - Assert.That(r1.Code, Is.EqualTo("B")); - Assert.That(r1.IsValid, Is.True); - - Assert.That(r2, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(r2!.Id, Is.EqualTo(0)); - Assert.That(r2.Code, Is.EqualTo("C")); - Assert.That(r2.IsValid, Is.False); - }); - } - - [Test] - public void RefenceDataIdConverter() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(sp => new ReferenceDataOrchestrator(sp).Register()); - var sp = sc.BuildServiceProvider(); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - var rd = (RefData)1; - new ReferenceDataIdConverter().ToDestination.Convert(rd).Should().Be(1); - new ReferenceDataIdConverter().ToDestination.Convert(null).Should().Be(0); - new ReferenceDataIdConverter().ToDestination.Convert(rd).Should().Be(1); - new ReferenceDataIdConverter().ToDestination.Convert(null).Should().BeNull(); - - new ReferenceDataIdConverter().ToSource.Convert(1).Should().NotBeNull().And.BeOfType().Which.Id.Should().Be(1); - new ReferenceDataIdConverter().ToSource.Convert(0).Should().NotBeNull().And.BeOfType().Which.Id.Should().Be(0); - new ReferenceDataIdConverter().ToSource.Convert(1).Should().NotBeNull().And.BeOfType().Which.Id.Should().Be(1); - new ReferenceDataIdConverter().ToSource.Convert(null).Should().BeNull(); - - Assert.Throws(() => new ReferenceDataIdConverter().ToSource.Convert(Guid.Empty)); - } - - [Test] - public void SidList() - { - var sl = new ReferenceDataCodeList("A", "B"); - Assert.Multiple(() => - { - Assert.That(sl[0].Code, Is.EqualTo("A")); - Assert.That(sl[0].Id, Is.EqualTo(0)); - Assert.That(sl[1].Code, Is.EqualTo("B")); - Assert.That(sl[1].Id, Is.EqualTo(0)); - }); - - var sids = new System.Collections.Generic.List() { "A" }; - sl = new ReferenceDataCodeList(ref sids); - Assert.That(sl, Has.Count.EqualTo(1)); - Assert.Multiple(() => - { - Assert.That(sl[0].Code, Is.EqualTo("A")); - Assert.That(sl[0].Id, Is.EqualTo(0)); - }); - - sids!.Add("B"); - Assert.That(sl, Has.Count.EqualTo(2)); - Assert.Multiple(() => - { - Assert.That(sl[0].Code, Is.EqualTo("A")); - Assert.That(sl[0].Id, Is.EqualTo(0)); - Assert.That(sl[1].Code, Is.EqualTo("B")); - Assert.That(sl[1].Id, Is.EqualTo(0)); - Assert.That(sl.HasInvalidItems, Is.True); - }); - - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(sp => new ReferenceDataOrchestrator(sp).Register()); - var sp = sc.BuildServiceProvider(); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - Assert.That(sl, Has.Count.EqualTo(2)); - Assert.Multiple(() => - { - Assert.That(sl[0].Code, Is.EqualTo("A")); - Assert.That(sl[0].Id, Is.EqualTo(1)); - Assert.That(sl[1].Code, Is.EqualTo("B")); - Assert.That(sl[1].Id, Is.EqualTo(2)); - Assert.That(sl.HasInvalidItems, Is.False); - }); - - sids = new System.Collections.Generic.List() { "A" }; - sl = new ReferenceDataCodeList(ref sids); - Assert.That(sl, Has.Count.EqualTo(1)); - - sl.Add((RefData)"B"); - Assert.Multiple(() => - { - Assert.That(sl, Has.Count.EqualTo(2)); - Assert.That(sids!, Has.Count.EqualTo(2)); - Assert.Multiple(() => - { - Assert.That(sids, Is.EqualTo(new string?[] { "A", "B" })); - }); - - Assert.That(sl.ToIdList(), Is.EqualTo(new int[] { 1, 2 })); - Assert.That(sl.ToCodeList(), Is.EqualTo(new string?[] { "A", "B" })); - }); - } - - [Test] - public void GetWithFilter() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - var o = scope.ServiceProvider.GetRequiredService(); - - Assert.Multiple(() => - { - Assert.That(o.GetWithFilterAsync().GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(new string[] { "AZ", "CO", "IL", "SC", "WA" })); - Assert.That(o.GetWithFilterAsync(null, null, false).GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(new string[] { "AZ", "CO", "IL", "SC", "WA" })); - - Assert.That(o.GetWithFilterAsync(new string[] { "AZ", "IL" }).GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(new string[] { "AZ", "IL" })); - Assert.That(o.GetWithFilterAsync(new string[] { "AZ", "IL", "XX" }).GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(new string[] { "AZ", "IL" })); - Assert.That(o.GetWithFilterAsync(new string[] { "AZ", "IL", "XX" }, includeInactive: true).GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(new string[] { "AZ", "IL", "XX" })); - - Assert.That(o.GetWithFilterAsync(text: "*IN*").GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(new string[] { "IL", "SC", "WA" })); - Assert.That(o.GetWithFilterAsync(text: "*in*").GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(new string[] { "IL", "SC", "WA" })); - Assert.That(o.GetWithFilterAsync(text: "*on").GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(new string[] { "WA" })); - Assert.That(o.GetWithFilterAsync(text: "pl*").GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(Array.Empty())); - Assert.That(o.GetWithFilterAsync(text: "pl*", includeInactive: true).GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(new string[] { "XX" })); - - Assert.That(o.GetWithFilterAsync(new string[] { "az", "il", "wa" }, text: "*in*").GetAwaiter().GetResult().Select(x => x.Code), Is.EqualTo(new string[] { "IL", "WA" })); - }); - } - - [Test] - public void GetNamed() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - var o = scope.ServiceProvider.GetRequiredService(); - - var mc = o.GetNamedAsync(new string[] { "state", "bananas", "suburb" }).GetAwaiter().GetResult(); - Assert.That(mc, Is.Not.Null); - Assert.That(mc, Has.Count.EqualTo(2)); - var mc1 = mc["State"]; - Assert.That(mc1.Select(x => x.Code), Is.EqualTo(new string[] { "AZ", "CO", "IL", "SC", "WA" })); - var mc2 = mc["Suburb"]; - Assert.That(mc2.Select(x => x.Code), Is.EqualTo(new string[] { "B", "H", "R" })); - - mc = o.GetNamedAsync(new string[] { "state", "bananas", "suburb" }, includeInactive: true).GetAwaiter().GetResult(); - Assert.That(mc, Is.Not.Null); - Assert.That(mc, Has.Count.EqualTo(2)); - mc1 = mc["State"]; - Assert.That(mc1.Select(x => x.Code), Is.EqualTo(new string[] { "AZ", "CO", "IL", "XX", "SC", "WA"})); - mc2 = mc["Suburb"]; - Assert.That(mc2.Select(x => x.Code), Is.EqualTo(new string[] { "B", "H", "R" })); - } - - [Test] - public void GetNamed_HttpRequest() - { - using var test = FunctionTester.Create().ReplaceScoped().ReplaceSingleton(sp => new ReferenceDataOrchestrator(sp).Register()); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/ref?names=state,bananas,suburb"); - var o = test.Services.GetRequiredService(); - - var mc = o.GetNamedAsync(hr.GetRequestOptions()).GetAwaiter().GetResult(); - Assert.That(mc, Is.Not.Null); - Assert.That(mc, Has.Count.EqualTo(2)); - var mc1 = mc["State"]; - Assert.That(mc1.Select(x => x.Code), Is.EqualTo(new string[] { "AZ", "CO", "IL", "SC", "WA" })); - var mc2 = mc["Suburb"]; - Assert.That(mc2.Select(x => x.Code), Is.EqualTo(new string[] { "B", "H", "R" })); - - hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/ref?state=co,il&suburb&state=xx"); - mc = o.GetNamedAsync(hr.GetRequestOptions()).GetAwaiter().GetResult(); - Assert.That(mc, Is.Not.Null); - Assert.That(mc, Has.Count.EqualTo(2)); - mc1 = mc["State"]; - Assert.That(mc1.Select(x => x.Code), Is.EqualTo(new string[] { "CO", "IL" })); - mc2 = mc["Suburb"]; - Assert.That(mc2.Select(x => x.Code), Is.EqualTo(new string[] { "B", "H", "R" })); - - hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/ref?state=co,il&suburb=h&state=xx&include-inactive&bananas"); - mc = o.GetNamedAsync(hr.GetRequestOptions()).GetAwaiter().GetResult(); - Assert.That(mc, Is.Not.Null); - Assert.That(mc, Has.Count.EqualTo(2)); - mc1 = mc["State"]; - Assert.That(mc1.Select(x => x.Code), Is.EqualTo(new string[] { "CO", "IL", "XX" })); - mc2 = mc["Suburb"]; - Assert.That(mc2.Select(x => x.Code), Is.EqualTo(new string[] { "H" })); - } - - [Test] - public async Task Caching() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - ReferenceDataOrchestrator.SetCurrent(sp.GetRequiredService()); - - // 1st time should take time and get cached. - var sw = Stopwatch.StartNew(); - IReferenceDataCollection? c = await ReferenceDataOrchestrator.Current.GetByTypeAsync().ConfigureAwait(false); - sw.Stop(); - Assert.Multiple(() => - { - Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(500)); - Assert.That(c, Is.Not.Null); - Assert.That(c!.ContainsCode("A"), Is.True); - }); - - sw = Stopwatch.StartNew(); - c = ReferenceDataOrchestrator.Current.GetByTypeAsync().GetAwaiter().GetResult(); - sw.Stop(); - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500)); - - sw = Stopwatch.StartNew(); - c = ReferenceDataOrchestrator.Current.GetByTypeAsync().GetAwaiter().GetResult(); - sw.Stop(); - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500)); - } - - [Test] - public async Task Caching_LoadA() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - ReferenceDataOrchestrator.SetCurrent(sp.GetRequiredService()); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - for (int i = 0; i < 1000; i++) - { - _ = await ReferenceDataOrchestrator.Current.GetByTypeAsync().ConfigureAwait(false); - } - } - - [Test] - public async Task Caching_Refresh() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(sp => new ReferenceDataOrchestrator(sp, cacheEntryConfig: new FixedExpirationCacheEntry(TimeSpan.FromMilliseconds(250))).Register()); - var sp = sc.BuildServiceProvider(); - - var rdo = sp.GetRequiredService(); - ReferenceDataOrchestrator.SetCurrent(rdo); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - // 1st time should take time and get cached. - var sw = Stopwatch.StartNew(); - IReferenceDataCollection? c = await ReferenceDataOrchestrator.Current.GetByTypeAsync().ConfigureAwait(false); - sw.Stop(); - Assert.Multiple(() => - { - Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(490)); - Assert.That(c, Is.Not.Null); - }); - Assert.That(c!.ContainsCode("A"), Is.True); - - // 2nd time should be fast-as from cache. - sw = Stopwatch.StartNew(); - c = ReferenceDataOrchestrator.Current.GetByTypeAsync().GetAwaiter().GetResult(); - sw.Stop(); - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500)); - - // Await longer than cache time. - await Task.Delay(500).ConfigureAwait(false); - - // 3rd time should take some time to cache again. - sw = Stopwatch.StartNew(); - c = await ReferenceDataOrchestrator.Current.GetByTypeAsync().ConfigureAwait(false); - sw.Stop(); - Assert.Multiple(() => - { - Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(490)); - Assert.That(c, Is.Not.Null); - Assert.That(c!.ContainsCode("A"), Is.True); - }); - - // 4th time should be fast-as from cache. - sw = Stopwatch.StartNew(); - c = ReferenceDataOrchestrator.Current.GetByTypeAsync().GetAwaiter().GetResult(); - sw.Stop(); - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(500)); - } - - [Test] - public void Caching_LoadB() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - ReferenceDataOrchestrator.SetCurrent(sp.GetRequiredService()); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - for (int i = 0; i < 1000; i++) - { - _ = ReferenceDataOrchestrator.Current.GetByTypeAsync().GetAwaiter().GetResult(); - } - } - - [Test] - public async Task Caching_Prefetch() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - ReferenceDataOrchestrator.SetCurrent(sp.GetRequiredService()); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - // Should load both in parallel. - var sw = Stopwatch.StartNew(); - await ReferenceDataOrchestrator.Current.PrefetchAsync(new string[] { "RefData", "RefDataEx" }).ConfigureAwait(false); - sw.Stop(); - Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(480)); - - sw = Stopwatch.StartNew(); - var c = await ReferenceDataOrchestrator.Current.GetByTypeAsync(); - sw.Stop(); - Assert.Multiple(() => - { - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(520)); - Assert.That(c, Is.Not.Null); - Assert.That(c!.ContainsCode("A"), Is.True); - }); - - sw = Stopwatch.StartNew(); - c = await ReferenceDataOrchestrator.Current.GetByTypeAsync(); - sw.Stop(); - Assert.Multiple(() => - { - Assert.That(sw.ElapsedMilliseconds, Is.LessThan(520)); - Assert.That(c, Is.Not.Null); - Assert.That(c!.ContainsId("BB"), Is.True); - }); - } - - [Test] - public async Task Caching_Concurrency() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - ReferenceDataOrchestrator.SetCurrent(sp.GetRequiredService()); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - var colls = new IReferenceDataCollection?[5]; - - var tasks = new Task[5]; - tasks[0] = Task.Run(() => colls[0] = ReferenceDataOrchestrator.Current.GetByType()); - tasks[1] = Task.Run(() => colls[1] = ReferenceDataOrchestrator.Current.GetByType()); - tasks[2] = Task.Run(() => colls[2] = ReferenceDataOrchestrator.Current.GetByType()); - tasks[3] = Task.Run(() => colls[3] = ReferenceDataOrchestrator.Current.GetByType()); - tasks[4] = Task.Run(() => colls[4] = ReferenceDataOrchestrator.Current.GetByType()); - - await Task.WhenAll(tasks).ConfigureAwait(false); - - for (int i = 0; i < colls.Length; i++) - { - Assert.That(colls[i], Is.Not.Null); - Assert.That(colls[i]!.Count, Is.EqualTo(2)); - } - - Assert.That(colls[4], Is.SameAs(colls[0])); // First and last should be same object ref. - } - - [Test] - public void Serialization_STJ_NoOrchestrator() - { - // Serialize. - var td = new TestData { Id = 1, Name = "Bob" }; - Assert.That(new Text.Json.JsonSerializer().Serialize(td), Is.EqualTo("{\"id\":1,\"name\":\"Bob\"}")); - - td.RefData = new RefData { Code = "a" }; - Assert.That(new Text.Json.JsonSerializer().Serialize(td), Is.EqualTo("{\"id\":1,\"name\":\"Bob\",\"refData\":\"a\"}")); - - // Deserialize. - td = new Text.Json.JsonSerializer().Deserialize("{\"id\":1,\"name\":\"Bob\"}"); - Assert.That(td, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(td!.Id, Is.EqualTo(1)); - Assert.That(td.Name, Is.EqualTo("Bob")); - }); - Assert.That(td.RefData, Is.Null); - - td = new Text.Json.JsonSerializer().Deserialize("{\"id\":1,\"name\":\"Bob\",\"refData\":\"a\"}"); - Assert.That(td, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(td!.Id, Is.EqualTo(1)); - Assert.That(td.Name, Is.EqualTo("Bob")); - Assert.That(td.RefData, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(td.RefData!.Code, Is.EqualTo("a")); - Assert.That(td.RefData.IsValid, Is.False); - }); - - var ex = Assert.Throws(() => new Text.Json.JsonSerializer().Deserialize("{\"id\":1,\"name\":\"Bob\",\"refData\":1}")); - Assert.That(ex!.Message, Is.EqualTo("The JSON value could not be converted to CoreEx.Test.Framework.RefData.RefData. Path: $.refData | LineNumber: 0 | BytePositionInLine: 32.")); - } - - [Test] - public void Serialization_STJ_WithOrchestrator() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - // Serialize. - var td = new TestData { Id = 1, Name = "Bob" }; - Assert.That(new Text.Json.JsonSerializer().Serialize(td), Is.EqualTo("{\"id\":1,\"name\":\"Bob\"}")); - - td.RefData = "a"; - Assert.That(new Text.Json.JsonSerializer().Serialize(td), Is.EqualTo("{\"id\":1,\"name\":\"Bob\",\"refData\":\"A\"}")); - - // Deserialize. - td = new Text.Json.JsonSerializer().Deserialize("{\"id\":1,\"name\":\"Bob\"}"); - Assert.That(td, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(td!.Id, Is.EqualTo(1)); - Assert.That(td.Name, Is.EqualTo("Bob")); - }); - Assert.That(td.RefData, Is.Null); - - td = new Text.Json.JsonSerializer().Deserialize("{\"id\":1,\"name\":\"Bob\",\"refData\":\"a\"}"); - Assert.That(td, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(td!.Id, Is.EqualTo(1)); - Assert.That(td.Name, Is.EqualTo("Bob")); - Assert.That(td.RefData, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(td.RefData!.Code, Is.EqualTo("A")); - Assert.That(td.RefData.IsValid, Is.True); - }); - } - - [Test] - public void Serialization_NSJ_NoOrchestrator() - { - // Serialize. - var td = new TestData { Id = 1, Name = "Bob" }; - Assert.That(new Newtonsoft.Json.JsonSerializer().Serialize(td), Is.EqualTo("{\"id\":1,\"name\":\"Bob\"}")); - - td.RefData = new RefData { Code = "a" }; - Assert.That(new Newtonsoft.Json.JsonSerializer().Serialize(td), Is.EqualTo("{\"id\":1,\"name\":\"Bob\",\"refData\":\"a\"}")); - - // Deserialize. - td = new Newtonsoft.Json.JsonSerializer().Deserialize("{\"id\":1,\"name\":\"Bob\"}"); - Assert.That(td, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(td!.Id, Is.EqualTo(1)); - Assert.That(td.Name, Is.EqualTo("Bob")); - }); - Assert.That(td.RefData, Is.Null); - - td = new Newtonsoft.Json.JsonSerializer().Deserialize("{\"id\":1,\"name\":\"Bob\",\"refData\":\"a\"}"); - Assert.That(td, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(td!.Id, Is.EqualTo(1)); - Assert.That(td.Name, Is.EqualTo("Bob")); - Assert.That(td.RefData, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(td.RefData!.Code, Is.EqualTo("a")); - Assert.That(td.RefData.IsValid, Is.False); - }); - - var ex = Assert.Throws(() => new Newtonsoft.Json.JsonSerializer().Deserialize("{\"id\":1,\"name\":\"Bob\",\"refData\":1}")); - Assert.That(ex!.Message, Is.EqualTo("Reference data value must be a string.")); - } - - [Test] - public void Serialization_NSJ_WithOrchestrator() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - // Serialize. - var td = new TestData { Id = 1, Name = "Bob" }; - Assert.That(new Newtonsoft.Json.JsonSerializer().Serialize(td), Is.EqualTo("{\"id\":1,\"name\":\"Bob\"}")); - - td.RefData = "a"; - Assert.That(new Newtonsoft.Json.JsonSerializer().Serialize(td), Is.EqualTo("{\"id\":1,\"name\":\"Bob\",\"refData\":\"A\"}")); - - // Deserialize. - td = new Newtonsoft.Json.JsonSerializer().Deserialize("{\"id\":1,\"name\":\"Bob\"}"); - Assert.That(td, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(td!.Id, Is.EqualTo(1)); - Assert.That(td.Name, Is.EqualTo("Bob")); - }); - Assert.That(td.RefData, Is.Null); - - td = new Newtonsoft.Json.JsonSerializer().Deserialize("{\"id\":1,\"name\":\"Bob\",\"refData\":\"a\"}"); - Assert.That(td, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(td!.Id, Is.EqualTo(1)); - Assert.That(td.Name, Is.EqualTo("Bob")); - Assert.That(td.RefData, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(td.RefData!.Code, Is.EqualTo("A")); - Assert.That(td.RefData.IsValid, Is.True); - }); - } - - [Test] - public void GetAllTypesInNamespace() - { - var types = ReferenceDataOrchestrator.GetAllTypesInNamespace().ToList(); - Assert.That(types, Has.Count.EqualTo(5)); - } - } - - public class RefData : ReferenceDataBaseEx - { - public static implicit operator RefData(int id) => ConvertFromId(id); - - [return: NotNullIfNotNull("code")] - public static implicit operator RefData?(string? code) => ConvertFromCode(code); - } - - public class RefDataSimple : ReferenceDataBase { } - - public class RefDataCollection : ReferenceDataCollection { } - - public class RefDataEx : ReferenceDataBaseEx - { - [return: NotNullIfNotNull("code")] - public static implicit operator RefDataEx?(string? code) => ConvertFromCode(code); - } - - public class RefDataExCollection : ReferenceDataCollection { } - - public class State : ReferenceDataBaseEx { } - - public class StateCollection : ReferenceDataCollection { } - - public class Suburb : ReferenceDataBaseEx { } - - public class SuburbCollection : ReferenceDataCollection { } - - public class RefDataProvider : IReferenceDataProvider - { - private readonly RefDataCollection _refData = new() { new RefData { Id = 1, Code = "A" }, new RefData { Id = 2, Code = "B" } }; - private readonly RefDataExCollection _refDataEx = new() { new RefDataEx { Id = "AA", Code = "AAA" }, new RefDataEx { Id = "BB", Code = "BBB" } }; - private readonly StateCollection _state = new() - { - new State { Id = 1, Code = "IL", Text = "Illinois" }, - new State { Id = 2, Code = "SC", Text = "South Carolina" }, - new State { Id = 3, Code = "AZ", Text = "Arizona" }, - new State { Id = 4, Code = "CO", Text = "Colorado" }, - new State { Id = 5, Code = "XX", Text = "Placeholder", IsActive = false }, - new State { Id = 6, Code = "WA", Text = "Washington" } - }; - private readonly SuburbCollection _suburb = new() - { - new Suburb { Id = "BB", Code = "B", Text = "Bardon" }, - new Suburb { Id = "RR", Code = "R", Text = "Redmond" }, - new Suburb { Id = "HH", Code = "H", Text = "Hataitai" } - }; - - public Type[] Types => new Type[] { typeof(RefData), typeof(RefDataEx), typeof(State), typeof(Suburb) }; - - public Task> GetAsync(Type type, CancellationToken cancellationToken = default) - { - IReferenceDataCollection coll = type switch - { - Type _ when type == typeof(RefData) => _refData, - Type _ when type == typeof(RefDataEx) => _refDataEx, - Type _ when type == typeof(State) => _state, - Type _ when type == typeof(Suburb) => _suburb, - _ => throw new InvalidOperationException() - }; - - return Task.FromResult(Result.Ok(coll)); - } - } - - public class RefDataProviderSlow : IReferenceDataProvider - { - private readonly RefDataCollection _refData = new() { new RefData { Id = 1, Code = "A" }, new RefData { Id = 2, Code = "B" } }; - private readonly RefDataExCollection _refDataEx = new() { new RefDataEx { Id = "AA", Code = "AAA" }, new RefDataEx { Id = "BB", Code = "BBB" } }; - - public Type[] Types => new Type[] { typeof(RefData), typeof(RefDataEx) }; - - public async Task> GetAsync(Type type, CancellationToken cancellationToken = default) - { - IReferenceDataCollection coll = type switch - { - Type _ when type == typeof(RefData) => _refData, - Type _ when type == typeof(RefDataEx) => _refDataEx, - _ => throw new InvalidOperationException() - }; - - await Task.Delay(500, cancellationToken).ConfigureAwait(false); - - return Result.Ok(coll); - } - } - - public class RefDataConcurrencyProvider : IReferenceDataProvider - { - private int _count; - - public Type[] Types => new Type[] { typeof(RefData) }; - - public async Task> GetAsync(Type type, CancellationToken cancellationToken = default) - { - System.Diagnostics.Debug.WriteLine($"GetAsync=>Enter({_count})"); - Interlocked.Increment(ref _count); - if (_count > 1) - //throw new InvalidOperationException("ReferenceData has loaded already; this should not occur as the ReferenceDataOrchestrator should ensure multi-load under concurrency does not occur."); - Assert.Fail("ReferenceData has loaded already; this should not occur as the ReferenceDataOrchestrator should ensure multi-load under concurrency does not occur."); - - var coll = new RefDataCollection() { new RefData { Id = 1, Code = "A" }, new RefData { Id = 2, Code = "B" } }; - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - System.Diagnostics.Debug.WriteLine($"GetAsync=>Exit({_count})"); - return coll; - } - } - - public class TestData : CoreEx.Entities.Extended.EntityBase - { - private int _id; - private string? _name; - private string? _gender; - - public int Id { get => _id; set => SetValue(ref _id, value); } - - public string? Name { get => _name; set => SetValue(ref _name, value); } - - public RefData? RefData { get => _gender; set => SetValue(ref _gender, value); } - - protected override IEnumerable GetPropertyValues() - { - yield return CreateProperty(nameof(Id), Id, v => Id = v); - yield return CreateProperty(nameof(Name), Name, v => Name = v); - yield return CreateProperty(nameof(RefData), RefData, v => RefData = v); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/AnyExtensionsTest.cs b/tests/CoreEx.Test/Framework/Results/AnyExtensionsTest.cs deleted file mode 100644 index 08ff4aee..00000000 --- a/tests/CoreEx.Test/Framework/Results/AnyExtensionsTest.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CoreEx.Results; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class AnyExtensionsTest - { - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/MatchExtensionsTest.cs b/tests/CoreEx.Test/Framework/Results/MatchExtensionsTest.cs deleted file mode 100644 index a9b9b18e..00000000 --- a/tests/CoreEx.Test/Framework/Results/MatchExtensionsTest.cs +++ /dev/null @@ -1,367 +0,0 @@ -using CoreEx.Results; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class MatchExtensionsTest - { - // [Test] - // public void Sync_Match_Result_Actions_Ok() - // { - // var r = Result.Success; - // var r2 = r.Match( - // ok: () => { Assert.Pass(); }, - // fail: e => { Assert.Fail(); }); - - // Assert.IsTrue(r2.IsSuccess); - // } - - // [Test] - // public void Sync_Match_Result_Actions_Fail() - // { - // var r = Result.Fail("bad"); - // var r2 = r.Match( - // ok: () => { Assert.Fail(); }, - // fail: e => { Assert.Pass(); }); - - // Assert.IsTrue(r2.IsFailure); - // } - - // [Test] - // public void Sync_Match_Result_Func_Return_Result_Ok() - // { - // var r = Result.Success; - // var r2 = r.Match( - // ok: () => Result.Fail("ok"), - // fail: e => Result.Fail("bad")); - - // Assert.That(r2.Error, Is.Not.Null.And.Message.EqualTo("ok")); - // } - - // [Test] - // public void Sync_Match_Result_Func_Return_Result_Fail() - // { - // var r = Result.Fail("test"); - // var r2 = r.Match( - // ok: () => Result.Fail("ok"), - // fail: e => Result.Fail("bad")); - - // Assert.That(r2.Error, Is.Not.Null.And.Message.EqualTo("bad")); - // } - - // [Test] - // public void Sync_Match_ResultT_Actions_Ok() - // { - // var r = Result.Ok(1); - // var r2 = r.Match( - // ok: v => { Assert.AreEqual(1, v); }, - // fail: e => { Assert.Fail(); }); - - // Assert.IsTrue(r2.IsSuccess); - // } - - // [Test] - // public void Sync_Match_ResultT_Actions_Fail() - // { - // var r = Result.Fail("test"); - // var r2 = r.Match( - // ok: v => { Assert.Fail(); }, - // fail: e => { Assert.Pass(); }); - - // Assert.IsTrue(r2.IsFailure); - // } - - // [Test] - // public void Sync_Match_ResultT_Func_Ok() - // { - // var r = Result.Ok(1); - // var r2 = r.Match( - // ok: v => Result.Ok(true), - // fail: e => false); - - // Assert.IsTrue(r2.Value); - // } - - // [Test] - // public void Sync_Match_ResultT_Func_Fail() - // { - // var r = Result.Fail("test"); - // var r2 = r.Match( - // ok: v => Result.Ok(true), - // fail: e => false); - - // Assert.IsFalse(r2.Value); - // } - - // /* AsyncResult */ - - // [Test] - // public async Task AsyncResult_Match_Result_Actions_Ok() - // { - // var r = Task.FromResult(Result.Success); - // var r2 = await r.Match( - // ok: () => { Assert.Pass(); }, - // fail: e => { Assert.Fail(); }); - - // Assert.IsTrue(r2.IsSuccess); - // } - - // [Test] - // public async Task AsyncResult_Match_Result_Actions_Fail() - // { - // var r = Task.FromResult(Result.Fail("bad")); - // var r2 = await r.Match( - // ok: () => { Assert.Fail(); }, - // fail: e => { Assert.Pass(); }); - - // Assert.IsTrue(r2.IsFailure); - // } - - // [Test] - // public async Task AsyncResult_Match_Result_Func_Return_Result_Ok() - // { - // var r = Task.FromResult(Result.Success); - // var r2 = await r.Match( - // ok: () => Result.Fail("ok"), - // fail: e => Result.Fail("bad")); - - // Assert.That(r2.Error, Is.Not.Null.And.Message.EqualTo("ok")); - // } - - // [Test] - // public async Task AsyncResult_Match_Result_Func_Return_Result_Fail() - // { - // var r = Task.FromResult(Result.Fail("test")); - // var r2 = await r.Match( - // ok: () => Result.Fail("ok"), - // fail: e => Result.Fail("bad")); - - // Assert.That(r2.Error, Is.Not.Null.And.Message.EqualTo("bad")); - // } - - // [Test] - // public async Task AsyncResult_Match_ResultT_Actions_Ok() - // { - // var r = Task.FromResult(Result.Ok(1)); - // var r2 = await r.Match( - // ok: v => { Assert.AreEqual(1, v); }, - // fail: e => { Assert.Fail(); }); - - // Assert.IsTrue(r2.IsSuccess); - // } - - // [Test] - // public async Task AsyncResult_Match_ResultT_Actions_Fail() - // { - // var r = Task.FromResult(Result.Fail("test")); - // var r2 = await r.Match( - // ok: v => { Assert.Fail(); }, - // fail: e => { Assert.Pass(); }); - - // Assert.IsTrue(r2.IsFailure); - // } - - // [Test] - // public async Task AsyncResult_Match_ResultT_Func_Ok() - // { - // var r = Task.FromResult(Result.Ok(1)); - // var r2 = await r.Match( - // ok: v => Result.Ok(true), - // fail: e => false); - - // Assert.IsTrue(r2.Value); - // } - - // [Test] - // public async Task AsyncResult_Match_ResultT_Func_Fail() - // { - // var r = Task.FromResult(Result.Fail("test")); - // var r2 = await r.Match( - // ok: v => Result.Ok(true), - // fail: e => false); - - // Assert.IsFalse(r2.Value); - // } - - // /* AsyncFunc */ - - // [Test] - // public async Task AsyncFunc_Match_Result_Actions_Ok() - // { - // var r = Result.Success; - // var r2 = await r.MatchAsync( - // ok: () => { Assert.Pass(); return Task.CompletedTask; }, - // fail: e => { Assert.Fail(); return Task.CompletedTask; }); - - // Assert.IsTrue(r2.IsSuccess); - // } - - // [Test] - // public async Task AsyncFunc_Match_Result_Actions_Fail() - // { - // var r = Result.Fail("bad"); - // var r2 = await r.MatchAsync( - // ok: () => { Assert.Fail(); return Task.CompletedTask; }, - // fail: e => { Assert.Pass(); return Task.CompletedTask; }); - - // Assert.IsTrue(r2.IsFailure); - // } - - // [Test] - // public async Task AsyncFunc_Match_Result_Func_Return_Result_Ok() - // { - // var r = Result.Success; - // var r2 = await r.MatchAsync( - // ok: () => Task.FromResult(Result.Fail("ok")), - // fail: e => Task.FromResult(Result.Fail("bad"))); - - // Assert.That(r2.Error, Is.Not.Null.And.Message.EqualTo("ok")); - // } - - // [Test] - // public async Task AsyncFunc_Match_Result_Func_Return_Result_Fail() - // { - // var r = Result.Fail("test"); - // var r2 = await r.MatchAsync( - // ok: () => Task.FromResult(Result.Fail("ok")), - // fail: e => Task.FromResult(Result.Fail("bad"))); - - // Assert.That(r2.Error, Is.Not.Null.And.Message.EqualTo("bad")); - // } - - // [Test] - // public async Task AsyncFunc_Match_ResultT_Actions_Ok() - // { - // var r = Result.Ok(1); - // var r2 = await r.MatchAsync( - // ok: v => { Assert.AreEqual(1, v); return Task.CompletedTask; }, - // fail: e => { Assert.Fail(); return Task.CompletedTask; }); - - // Assert.IsTrue(r2.IsSuccess); - // } - - // [Test] - // public async Task AsyncFunc_Match_ResultT_Actions_Fail() - // { - // var r = Result.Fail("test"); - // var r2 = await r.MatchAsync( - // ok: v => { Assert.Fail(); return Task.CompletedTask; }, - // fail: e => { Assert.Pass(); return Task.CompletedTask; }); - - // Assert.IsTrue(r2.IsFailure); - // } - - // [Test] - // public async Task AsyncFunc_Match_ResultT_Func_Ok() - // { - // var r = Result.Ok(1); - // var r2 = await r.MatchAsync( - // ok: v => Task.FromResult(Result.Ok(true)), - // fail: e => Task.FromResult(Result.Ok(false))); - - // Assert.IsTrue(r2.Value); - // } - - // [Test] - // public async Task AsyncFunc_Match_ResultT_Func_Fail() - // { - // var r = Result.Fail("test"); - // var r2 = await r.MatchAsync( - // ok: v => Task.FromResult(Result.Ok(true)), - // fail: e => Task.FromResult(Result.Ok(false))); - - // Assert.IsFalse(r2.Value); - // } - - // /* AsyncBoth */ - // [Test] - // public async Task AsyncBoth_Match_Result_Actions_Ok() - // { - // var r = Task.FromResult(Result.Success); - // var r2 = await r.MatchAsync( - // ok: () => { Assert.Pass(); return Task.CompletedTask; }, - // fail: e => { Assert.Fail(); return Task.CompletedTask; }); - - // Assert.IsTrue(r2.IsSuccess); - // } - - // [Test] - // public async Task AsyncBoth_Match_Result_Actions_Fail() - // { - // var r = Task.FromResult(Result.Fail("bad")); - // var r2 = await r.MatchAsync( - // ok: () => { Assert.Fail(); return Task.CompletedTask; }, - // fail: e => { Assert.Pass(); return Task.CompletedTask; }); - - // Assert.IsTrue(r2.IsFailure); - // } - - // [Test] - // public async Task AsyncBoth_Match_Result_Func_Return_Result_Ok() - // { - // var r = Task.FromResult(Result.Success); - // var r2 = await r.MatchAsync( - // ok: () => Task.FromResult(Result.Fail("ok")), - // fail: e => Task.FromResult(Result.Fail("bad"))); - - // Assert.That(r2.Error, Is.Not.Null.And.Message.EqualTo("ok")); - // } - - // [Test] - // public async Task AsyncBoth_Match_Result_Func_Return_Result_Fail() - // { - // var r = Task.FromResult(Result.Fail("test")); - // var r2 = await r.MatchAsync( - // ok: () => Task.FromResult(Result.Fail("ok")), - // fail: e => Task.FromResult(Result.Fail("bad"))); - - // Assert.That(r2.Error, Is.Not.Null.And.Message.EqualTo("bad")); - // } - - // [Test] - // public async Task AsyncBoth_Match_ResultT_Actions_Ok() - // { - // var r = Task.FromResult(Result.Ok(1)); - // var r2 = await r.MatchAsync( - // ok: v => { Assert.AreEqual(1, v); return Task.CompletedTask; }, - // fail: e => { Assert.Fail(); return Task.CompletedTask; }); - - // Assert.IsTrue(r2.IsSuccess); - // } - - // [Test] - // public async Task AsyncBoth_Match_ResultT_Actions_Fail() - // { - // var r = Task.FromResult(Result.Fail("test")); - // var r2 = await r.MatchAsync( - // ok: v => { Assert.Fail(); return Task.CompletedTask; }, - // fail: e => { Assert.Pass(); return Task.CompletedTask; }); - - // Assert.IsTrue(r2.IsFailure); - // } - - // [Test] - // public async Task AsyncBoth_Match_ResultT_Func_Ok() - // { - // var r = Task.FromResult(Result.Ok(1)); - // var r2 = await r.MatchAsync( - // ok: v => Task.FromResult(Result.Ok(true)), - // fail: e => Task.FromResult(Result.Ok(false))); - - // Assert.IsTrue(r2.Value); - // } - - // [Test] - // public async Task AsyncBoth_Match_ResultT_Func_Fail() - // { - // var r = Task.FromResult(Result.Fail("test")); - // var r2 = await r.MatchAsync( - // ok: v => Task.FromResult(Result.Ok(true)), - // fail: e => Task.FromResult(Result.Ok(false))); - - // Assert.IsFalse(r2.Value); - // } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/OnFailureExtensionsTest.cs b/tests/CoreEx.Test/Framework/Results/OnFailureExtensionsTest.cs deleted file mode 100644 index f64172ef..00000000 --- a/tests/CoreEx.Test/Framework/Results/OnFailureExtensionsTest.cs +++ /dev/null @@ -1,616 +0,0 @@ -using CoreEx.Results; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class OnFailureExtensionsTest - { - //[Test] - //public void Sync_Result_OnFailure_With_Action_Success() - //{ - // var i = 0; - // var r = Result.Success; - // var r2 = r.OnFailure(() => i = 1); - // Assert.AreEqual(0, i); - //} - - //[Test] - //public void Sync_Result_OnFailure_With_Action_Failure() - //{ - // var i = 0; - // var r = Result.Fail(new BusinessException()); - // var r2 = r.OnFailure(() => i = 1); - // Assert.AreEqual(1, i); - //} - - //[Test] - //public void Sync_Result_OnFailure_With_Func_Success() - //{ - // var r = Result.Success; - // var r2 = r.OnFailure(() => Result.Fail("Test")); - // Assert.True(r2.IsSuccess); - //} - - //[Test] - //public void Sync_Result_OnFailure_With_Func_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = r.OnFailure(() => Result.NotFoundError()); - // Assert.True(r2.IsFailure); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_Action_Success() - //{ - // var i = 0; - // var r = Result.Ok(); - // var r2 = r.OnFailure(() => { i = 1; }); - // Assert.AreEqual(0, i); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_Action_Failure() - //{ - // var i = 0; - // var r = Result.Fail(new BusinessException()); - // var r2 = r.OnFailure(() => { i = 1; }); - // Assert.AreEqual(1, i); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_Func_Value_Success() - //{ - // var r = Result.Ok(); - // var r2 = r.OnFailure(() => 1); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_Func_Value_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = r.OnFailure(() => 1); - // Assert.AreEqual(1, r2.Value); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_Func_Result_Success() - //{ - // var r = Result.Ok(); - // var r2 = r.OnFailure(() => Result.Fail("Test")); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_Func_Result_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = r.OnFailure(() => Result.NotFoundError()); - // Assert.IsTrue(r2.IsFailure); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_Func_Diff_Value_Success() - //{ - // var r = Result.Ok(); - // var r2 = r.OnFailure(() => true); - // Assert.AreEqual(false, r2.Value); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_Func_Diff_Value_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = r.OnFailure(() => true); - // Assert.AreEqual(true, r2.Value); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_Func_Result_Value_Success() - //{ - // var r = Result.Ok(); - // var r2 = r.OnFailure(() => Result.Fail("Test")); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_Func_Result_Value_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = r.OnFailure(() => Result.Fail(new NotFoundException())); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_FuncT_Result_Success() - //{ - // var r = Result.Ok(); - // var r2 = r.OnFailure(v => new Result(true)); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_FuncT_Result_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = r.OnFailure(v => new Result(true)); - // Assert.IsTrue(r2.Value); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_FuncT_ResultT_Success() - //{ - // var r = Result.Ok(); - // var r2 = r.OnFailure(v => true); - // Assert.IsFalse(r2.Value); - //} - - //[Test] - //public void Sync_ResultT_OnFailure_FuncT_ResultT_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = r.OnFailure(v => true); - // Assert.IsTrue(r2.Value); - //} - - ///* AsyncResult */ - - //[Test] - //public async Task AsyncResult_Result_OnFailure_With_Action_Success() - //{ - // var i = 0; - // var r = Task.FromResult(Result.Success); - // var r2 = await r.OnFailure(() => i = 1); - // Assert.AreEqual(0, i); - //} - - //[Test] - //public async Task AsyncResult_Result_OnFailure_With_Action_Failure() - //{ - // var i = 0; - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailure(() => i = 1); - // Assert.AreEqual(1, i); - //} - - //[Test] - //public async Task AsyncResult_Result_OnFailure_With_Func_Success() - //{ - // var r = Task.FromResult(Result.Success); - // var r2 = await r.OnFailure(() => Result.Fail("Test")); - // Assert.True(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncResult_Result_OnFailure_With_Func_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailure(() => Result.NotFoundError()); - // Assert.True(r2.IsFailure); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_Action_Success() - //{ - // var i = 0; - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailure(() => { i = 1; }); - // Assert.AreEqual(0, i); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_Action_Failure() - //{ - // var i = 0; - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailure(() => { i = 1; }); - // Assert.AreEqual(1, i); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_Func_Value_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailure(() => 1); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_Func_Value_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailure(() => 1); - // Assert.AreEqual(1, r2.Value); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_Func_Result_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailure(() => Result.Fail("Test")); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_Func_Result_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailure(() => Result.NotFoundError()); - // Assert.IsTrue(r2.IsFailure); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_Func_Diff_Value_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailure(() => true); - // Assert.AreEqual(false, r2.Value); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_Func_Diff_Value_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailure(() => true); - // Assert.AreEqual(true, r2.Value); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_Func_Result_Value_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailure(() => Result.Fail("Test")); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_Func_Result_Value_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailure(() => Result.Fail(new NotFoundException())); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_FuncT_Result_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailure(v => new Result(true)); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_FuncT_Result_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailure(v => new Result(true)); - // Assert.IsTrue(r2.Value); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_FuncT_ResultT_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailure(v => true); - // Assert.IsFalse(r2.Value); - //} - - //[Test] - //public async Task AsyncResult_ResultT_OnFailure_FuncT_ResultT_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailure(v => true); - // Assert.IsTrue(r2.Value); - //} - - ///* AsyncFunc */ - - //[Test] - //public async Task AsyncFunc_Result_OnFailure_With_Action_Success() - //{ - // var i = 0; - // var r = Result.Success; - // var r2 = await r.OnFailureAsync(() => { i = 1; return Task.CompletedTask; }); - // Assert.AreEqual(0, i); - //} - - //[Test] - //public async Task AsyncFunc_Result_OnFailure_With_Action_Failure() - //{ - // var i = 0; - // var r = Result.Fail(new BusinessException()); - // var r2 = await r.OnFailureAsync(() => { i = 1; return Task.CompletedTask; }); - // Assert.AreEqual(1, i); - //} - - //[Test] - //public async Task AsyncFunc_Result_OnFailure_With_Func_Success() - //{ - // var r = Result.Success; - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.Fail("Test"))); - // Assert.True(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncFunc_Result_OnFailure_With_Func_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.NotFoundError())); - // Assert.True(r2.IsFailure); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_Action_Success() - //{ - // var i = 0; - // var r = Result.Ok(); - // var r2 = await r.OnFailureAsync(() => { i = 1; return Task.CompletedTask; }); - // Assert.AreEqual(0, i); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_Action_Failure() - //{ - // var i = 0; - // var r = Result.Fail(new BusinessException()); - // var r2 = await r.OnFailureAsync(() => { i = 1; return Task.CompletedTask; }); - // Assert.AreEqual(1, i); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_Func_Value_Success() - //{ - // var r = Result.Ok(); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(1)); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_Func_Value_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(1)); - // Assert.AreEqual(1, r2.Value); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_Func_Result_Success() - //{ - // var r = Result.Ok(); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.Fail("Test"))); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_Func_Result_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.NotFoundError())); - // Assert.IsTrue(r2.IsFailure); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_Func_Diff_Value_Success() - //{ - // var r = Result.Ok(); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(true)); - // Assert.AreEqual(false, r2.Value); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_Func_Diff_Value_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(true)); - // Assert.AreEqual(true, r2.Value); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_Func_Result_Value_Success() - //{ - // var r = Result.Ok(); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.Fail("Test"))); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_Func_Result_Value_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.Fail(new NotFoundException()))); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_FuncT_Result_Success() - //{ - // var r = Result.Ok(); - // var r2 = await r.OnFailureAsync(v => Task.FromResult(new Result(true))); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_FuncT_Result_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = await r.OnFailureAsync(v => Task.FromResult(new Result(true))); - // Assert.IsTrue(r2.Value); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_FuncT_ResultT_Success() - //{ - // var r = Result.Ok(); - // var r2 = await r.OnFailureAsync(v => Task.FromResult(true)); - // Assert.IsFalse(r2.Value); - //} - - //[Test] - //public async Task AsyncFunc_ResultT_OnFailure_FuncT_ResultT_Failure() - //{ - // var r = Result.Fail(new BusinessException()); - // var r2 = await r.OnFailureAsync(v => Task.FromResult(true)); - // Assert.IsTrue(r2.Value); - //} - - ///* AsyncBoth */ - - //[Test] - //public async Task AsyncBoth_Result_OnFailure_With_Action_Success() - //{ - // var i = 0; - // var r = Task.FromResult(Result.Success); - // var r2 = await r.OnFailureAsync(() => { i = 1; return Task.CompletedTask; }); - // Assert.AreEqual(0, i); - //} - - //[Test] - //public async Task AsyncBoth_Result_OnFailure_With_Action_Failure() - //{ - // var i = 0; - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailureAsync(() => { i = 1; return Task.CompletedTask; }); - // Assert.AreEqual(1, i); - //} - - //[Test] - //public async Task AsyncBoth_Result_OnFailure_With_Func_Success() - //{ - // var r = Task.FromResult(Result.Success); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.Fail("Test"))); - // Assert.True(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncBoth_Result_OnFailure_With_Func_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.NotFoundError())); - // Assert.True(r2.IsFailure); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_Action_Success() - //{ - // var i = 0; - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailureAsync(() => { i = 1; return Task.CompletedTask; }); - // Assert.AreEqual(0, i); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_Action_Failure() - //{ - // var i = 0; - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailureAsync(() => { i = 1; return Task.CompletedTask; }); - // Assert.AreEqual(1, i); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_Func_Value_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(1)); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_Func_Value_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(1)); - // Assert.AreEqual(1, r2.Value); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_Func_Result_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.Fail("Test"))); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_Func_Result_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.NotFoundError())); - // Assert.IsTrue(r2.IsFailure); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_Func_Diff_Value_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(true)); - // Assert.AreEqual(false, r2.Value); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_Func_Diff_Value_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(true)); - // Assert.AreEqual(true, r2.Value); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_Func_Result_Value_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.Fail("Test"))); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_Func_Result_Value_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailureAsync(() => Task.FromResult(Result.Fail(new NotFoundException()))); - // Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_FuncT_Result_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailureAsync(v => Task.FromResult(new Result(true))); - // Assert.IsTrue(r2.IsSuccess); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_FuncT_Result_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailureAsync(v => Task.FromResult(new Result(true))); - // Assert.IsTrue(r2.Value); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_FuncT_ResultT_Success() - //{ - // var r = Task.FromResult(Result.Ok()); - // var r2 = await r.OnFailureAsync(v => Task.FromResult(true)); - // Assert.IsFalse(r2.Value); - //} - - //[Test] - //public async Task AsyncBoth_ResultT_OnFailure_FuncT_ResultT_Failure() - //{ - // var r = Task.FromResult(Result.Fail(new BusinessException())); - // var r2 = await r.OnFailureAsync(v => Task.FromResult(true)); - // Assert.IsTrue(r2.Value); - //} - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/ResultGoTest.cs b/tests/CoreEx.Test/Framework/Results/ResultGoTest.cs deleted file mode 100644 index 9153dc8c..00000000 --- a/tests/CoreEx.Test/Framework/Results/ResultGoTest.cs +++ /dev/null @@ -1,108 +0,0 @@ -using CoreEx.Results; -using CoreEx.TestFunction; -using NUnit.Framework; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using UnitTestEx; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class ResultGoTest - { - [Test] - public void Go_No_Args() - { - var r = Result.Go(); - Assert.That(r, Is.EqualTo(Result.Success)); - } - - [Test] - public void Go_With_Action() - { - var r = Result.Go(() => { }); - Assert.That(r, Is.EqualTo(Result.Success)); - } - - [Test] - public void Go_With_Result_Func() - { - var r = Result.Go(() => Result.Fail("Test")); - Assert.That(r.IsFailure, Is.True); - } - - [Test] - public void Go_Value_Func() - { - var r = Result.Go(() => 1); - Assert.That(r.Value, Is.EqualTo(1)); - } - - [Test] - public void Go_Value_Result_Func() - { - var r = Result.Go(() => Result.Ok(1)); - Assert.That(r.Value, Is.EqualTo(1)); - } - - [Test] - public void Go_Value_No_Args() - { - var r = Result.Go(); - Assert.That(r, Is.EqualTo(Result.None)); - } - - /* Go_Async */ - - [Test] - public async Task GoAsync_With_Action() - { - var r = await Result.GoAsync(() => Task.CompletedTask); - Assert.That(r, Is.EqualTo(Result.Success)); - } - - [Test] - public async Task GoAsync_With_Result_Func() - { - var r = await Result.GoAsync(() => Task.FromResult(Result.Fail("Test"))); - Assert.That(r.IsFailure, Is.True); - } - - [Test] - public async Task GoAsync_Value_Func() - { - var r = await Result.GoAsync(() => Task.FromResult(1)); - Assert.That(r.Value, Is.EqualTo(1)); - } - - [Test] - public async Task GoAsync_Value_Result_Func() - { - var r = await Result.GoAsync(() => Task.FromResult(Result.Ok(1))); - Assert.That(r.Value, Is.EqualTo(1)); - } - - [Test] - public void GoFromAsync_Http_Result_OK() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Get, "test").Respond.With("{\"Name\":\"Steve\"}", HttpStatusCode.OK); - - using var test = FunctionTester.Create(); - var r = test.ReplaceHttpClientFactory(mcf) - .UseJsonSerializer(new CoreEx.Text.Json.JsonSerializer()) // Required as the Result type needs to be deserialized using CoreEx. - .Type() - .Run(hc => Result.GoFromAsync(async () => await hc.GetAsync("test"))) - .AssertSuccess(); - - Assert.That(r.Result.Value.Name, Is.EqualTo("Steve")); - } - - public class Person - { - public string? Name { get; set; } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/ResultInvokerWithTest.cs b/tests/CoreEx.Test/Framework/Results/ResultInvokerWithTest.cs deleted file mode 100644 index f24b4973..00000000 --- a/tests/CoreEx.Test/Framework/Results/ResultInvokerWithTest.cs +++ /dev/null @@ -1,20 +0,0 @@ -using CoreEx.Invokers; -using CoreEx.Results; -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class ResultInvokerWithTest - { - [Test] - public void Manager_Sycn() - { - var r = Result.Go().Manager(this).With(r => r); - Assert.That(r.IsSuccess, Is.True); - - r = Result.Go().Manager(this).With(r => Result.Fail("boo hoo")); - Assert.That(r.Error, Is.TypeOf().And.Message.EqualTo("boo hoo")); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/ResultStateTest.cs b/tests/CoreEx.Test/Framework/Results/ResultStateTest.cs deleted file mode 100644 index 26bb3092..00000000 --- a/tests/CoreEx.Test/Framework/Results/ResultStateTest.cs +++ /dev/null @@ -1,196 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Results; -using NUnit.Framework; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class ResultStateTest - { - [Test] - public void Result_Success_No_Value() - { - var r = Result.Ok(); - Assert.That(r, Is.EqualTo(Result.None)); - } - - [Test] - public void Result_Success_With_Value() - { - var r = Result.Ok(1); - Assert.That(r, Is.EqualTo(Result.Ok(1))); - } - - [Test] - public void Result_Failure_With_Exception() - { - var r = Result.Fail(new BusinessException()); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Result_Failure_With_Message() - { - var r = Result.Fail("Test"); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf().And.Message.EqualTo("Test")); - }); - } - - [Test] - public void Result_Success() - { - var r = Result.Success; - Assert.That(r, Is.EqualTo(Result.Success)); - } - - [Test] - public void Result_Ok_With_Value() - { - var r = Result.Ok(1); - Assert.That(r, Is.EqualTo(Result.Ok(1))); - } - - [Test] - public void Result_Fail_With_Exception() - { - var r = Result.Fail(new BusinessException()); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Result_Fail_With_Message() - { - var r = Result.Fail("Test"); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf().And.Message.EqualTo("Test")); - }); - } - - [Test] - public void Result_ValidationError_With_Message() - { - var r = Result.ValidationError("Test"); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf().And.Message.EqualTo("Test")); - }); - } - - [Test] - public void Result_ValidationError_With_MessageItem() - { - var r = Result.ValidationError(new MessageItem { Type = MessageType.Error, Text = "Error" }); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - var ve = (ValidationException)r.Error; - Assert.That(ve.Messages, Is.Not.Null.And.Count.EqualTo(1)); - } - - [Test] - public void Result_ValidationError_With_MessageItems() - { - var r = Result.ValidationError(new MessageItemCollection { new MessageItem { Type = MessageType.Error, Text = "Error" }, new MessageItem { Type = MessageType.Error, Text = "Error2" } }); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - var ve = (ValidationException)r.Error; - Assert.That(ve.Messages, Is.Not.Null.And.Count.EqualTo(2)); - } - - [Test] - public void Result_ConflictError() - { - var r = Result.ConflictError(); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Result_ConcurrencyError() - { - var r = Result.ConcurrencyError(); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Result_DuplicateError() - { - var r = Result.DuplicateError(); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Result_NotFoundError() - { - var r = Result.NotFoundError(); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Result_TransientError() - { - var r = Result.TransientError(); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Result_AuthenticationError() - { - var r = Result.AuthenticationError(); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Result_AuthorizationError() - { - var r = Result.AuthorizationError(); - Assert.Multiple(() => - { - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/ResultTTest.cs b/tests/CoreEx.Test/Framework/Results/ResultTTest.cs deleted file mode 100644 index d34f7e32..00000000 --- a/tests/CoreEx.Test/Framework/Results/ResultTTest.cs +++ /dev/null @@ -1,315 +0,0 @@ -using NUnit.Framework; -using CoreEx.Results; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class ResultTTest - { - [Test] - public void Success_Property() - { - var r = Result.None; - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.True); - Assert.That(r.IsFailure, Is.False); - }); - } - - [Test] - public void Result_Success_Ctor() - { - var r = new Result(); - Assert.That(r, Is.EqualTo(Result.None)); - } - - [Test] - public void Success_Is_Success() - { - var r = Result.Ok(); - Assert.That(r, Is.EqualTo(Result.None)); - } - - [Test] - public void Success_Implicit_Conversion_From_Value() - { - var r = (Result) 1; - Assert.That(r.Value, Is.EqualTo(1)); - } - - [Test] - public void Success_Implicit_Conversion_From_Result() - { - int i = Result.Ok(1); - Assert.That(i, Is.EqualTo(1)); - } - - [Test] - public void Success_ThrowOnError() - { - Result.Ok().ThrowOnError(); - } - - [Test] - public void Failure_Ctor() - { - var r = new Result(new BusinessException()); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.False); - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Failure_Is_Failure() - { - var r = Result.Fail(new BusinessException()); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.False); - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Failure_Is_Failure_With_Message() - { - var r = Result.Fail("Test"); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.False); - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf().And.Message.EqualTo("Test")); - }); - } - - [Test] - public void Failure_Explicit_Conversion() - { - var r = (Result) new BusinessException(); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.False); - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Failure_ThrowOnError() - { - Assert.Throws(() => Result.Fail(new BusinessException()).ThrowOnError()); - } - - [Test] - public void Compare_Success_And_Failure() - { - Assert.That(Result.Fail(new BusinessException()), Is.Not.EqualTo(Result.Ok())); - } - - [Test] - public void Compare_Two_Same_Successes() - { - var r1 = Result.Ok(); - var r2 = Result.Ok(); - Assert.That(r2, Is.EqualTo(r1)); - } - - [Test] - public void Compare_Two_Same_Successes_With_Value() - { - var r1 = Result.Ok(1); - var r2 = Result.Ok(1); - Assert.That(r2, Is.EqualTo(r1)); - } - - [Test] - public void Compare_Two_Different_Successes() - { - var r1 = Result.Ok(); - var r2 = Result.Ok(1); - Assert.That(r2, Is.Not.EqualTo(r1)); - } - - [Test] - public void Compare_Two_Different_Successes_With_Value() - { - var r1 = Result.Ok(1); - var r2 = Result.Ok(2); - Assert.That(r2, Is.Not.EqualTo(r1)); - } - - [Test] - public void Compare_Two_Same_Failures() - { - var r1 = Result.Fail(new BusinessException()); - var r2 = Result.Fail(new BusinessException()); - Assert.That(r2, Is.EqualTo(r1)); - } - - [Test] - public void Compare_Two_Different_Failures() - { - var r1 = Result.Fail(new BusinessException()); - var r2 = Result.Fail(new BusinessException("Test")); - Assert.That(r2, Is.Not.EqualTo(r1)); - } - - [Test] - public void Compare_Two_Different_Type_Failures() - { - var r1 = Result.Fail(new ValidationException("Test")); - var r2 = Result.Fail(new BusinessException("Test")); - Assert.That(r2, Is.Not.EqualTo(r1)); - } - - [Test] - public void Result_Success_Explicit_Convert_To_Result() - { - Result r = Result.Ok(1); - Result r2 = (Result)r; - Assert.That(r2, Is.EqualTo(Result.Success)); - } - - [Test] - public void Result_Failure_Explicit_Convert_To_Result() - { - Result r = Result.Fail(new BusinessException()); - Result r2 = (Result)r; - Assert.Multiple(() => - { - Assert.That(r2.IsFailure, Is.True); - Assert.That(r2.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Success_Value_ToString() - { - var r = Result.Ok(1); - Assert.That(r.ToString(), Is.EqualTo("Success: 1")); - } - - [Test] - public void Success_Null_Value_ToString() - { - var r = Result.Ok(null); - Assert.That(r.ToString(), Is.EqualTo("Success: null")); - } - - [Test] - public void Failure_ToString() - { - var r = Result.Fail(new BusinessException("Test")); - Assert.That(r.ToString(), Is.EqualTo("Failure: Test")); - } - - [Test] - public async Task AsTask() - { - var r = await Result.Go(1).AsTask(); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.True); - Assert.That(r.Value, Is.EqualTo(1)); - }); - } - - [Test] - public void Failure_Value() - { - var ir = (IResult)Result.Fail("On no!"); - Assert.Throws(() => _ = ir.Value); - } - - [Test] - public void Adjusts() - { - var r = Result.Ok(new Person()); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.True); - Assert.That(r.Value.Id, Is.EqualTo(0)); - }); - - var r2 = r.Adjusts(v => v.Id = 2); - Assert.Multiple(() => - { - Assert.That(r2.IsSuccess, Is.True); - Assert.That(r2.Value.Id, Is.EqualTo(2)); - }); - - r = Result.Fail(new BusinessException()); - r2 = r.Adjusts(v => v.Id = 2); - Assert.That(r2.IsSuccess, Is.False); - } - - [Test] - public async Task AdjustsAsync() - { - var r = Result.Ok(new Person()); - var r2 = await r.AdjustsAsync(async v => - { - await Task.CompletedTask; - v.Id = 2; - }); - - Assert.Multiple(() => - { - Assert.That(r2.IsSuccess, Is.True); - Assert.That(r2.Value.Id, Is.EqualTo(2)); - }); - } - - [Test] - public async Task Adjusts2Async() - { - var r = Result.GoAsync(async () => - { - await Task.CompletedTask; - return new Person(); - }); - - var r2 = await r.AdjustsAsync(async v => - { - await Task.CompletedTask; - v.Id = 2; - }); - - Assert.Multiple(() => - { - Assert.That(r2.IsSuccess, Is.True); - Assert.That(r2.Value.Id, Is.EqualTo(2)); - }); - } - - [Test] - public async Task Adjusts2AsyncTightLoop() - { - for (int i = 0; i < 10000; i++) - { - var r = Result.GoAsync(async () => - { - await Task.CompletedTask; - return new Person(); - }); - - var r2 = await r.AdjustsAsync(async v => - { - await Task.CompletedTask; - v.Id = 2; - }); - } - } - - public class Person - { - public int Id { get; set; } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/ResultTest.cs b/tests/CoreEx.Test/Framework/Results/ResultTest.cs deleted file mode 100644 index 190ab57d..00000000 --- a/tests/CoreEx.Test/Framework/Results/ResultTest.cs +++ /dev/null @@ -1,159 +0,0 @@ -using NUnit.Framework; -using CoreEx.Results; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class ResultTest - { - [Test] - public void Success_Property() - { - var r = Result.Success; - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.True); - Assert.That(r.IsFailure, Is.False); - }); - } - - [Test] - public void Result_Success_Ctor() - { - var r = new Result(); - Assert.That(r, Is.EqualTo(Result.Success)); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public void Success_Is_Success() - { - var r = Result.Success; - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public void Success_ThrowOnError() - { - Result.Success.ThrowOnError(); - } - - [Test] - public void Failure_Ctor() - { - var r = new Result(new BusinessException()); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.False); - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Failure_Is_Failure() - { - var r = Result.Fail(new BusinessException()); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.False); - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Failure_Is_Failure_With_Message() - { - var r = Result.Fail("Test"); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.False); - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf().And.Message.EqualTo("Test")); - }); - } - - [Test] - public void Failure_Explicit_Conversion() - { - var r = (Result) new BusinessException(); - Assert.Multiple(() => - { - Assert.That(r.IsSuccess, Is.False); - Assert.That(r.IsFailure, Is.True); - Assert.That(r.Error, Is.Not.Null.And.InstanceOf()); - }); - } - - [Test] - public void Failure_ThrowOnError() - { - Assert.Throws(() => Result.Fail(new BusinessException()).ThrowOnError()); - } - - [Test] - public void Compare_Success_And_Failure() - { - Assert.That(Result.Fail(new BusinessException()), Is.Not.EqualTo(Result.Success)); - } - - [Test] - public void Compare_Two_Same_Failures() - { - var r1 = Result.Fail(new BusinessException()); - var r2 = Result.Fail(new BusinessException()); - Assert.That(r2, Is.EqualTo(r1)); - } - - [Test] - public void Compare_Two_Different_Failures() - { - var r1 = Result.Fail(new BusinessException()); - var r2 = Result.Fail(new BusinessException("Test")); - Assert.That(r2, Is.Not.EqualTo(r1)); - } - - [Test] - public void Compare_Two_Different_Types_Failures() - { - var r1 = Result.Fail(new ValidationException("Test")); - var r2 = Result.Fail(new BusinessException("Test")); - Assert.That(r2, Is.Not.EqualTo(r1)); - } - - [Test] - public void Success_ToString() - { - Assert.That(Result.Success.ToString(), Is.EqualTo("Success.")); - } - - [Test] - public void Failure_ToString() - { - Assert.That(Result.Fail(new BusinessException()).ToString(), Is.EqualTo("Failure: A business error occurred.")); - } - - [Test] - public async Task AsTask() - { - var r = await Result.Go().AsTask(); - Assert.That(r, Is.EqualTo(Result.Success)); - } - - [Test] - public void Success_Value() - { - var ir = (IResult)Result.Success; - Assert.That(ir.Value, Is.Null); - } - - [Test] - public void Failure_Value() - { - var ir = (IResult)Result.Fail("On no!"); - Assert.Throws(() => _ = ir.Value); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/ThenExtensionsTest.cs b/tests/CoreEx.Test/Framework/Results/ThenExtensionsTest.cs deleted file mode 100644 index 7b42f380..00000000 --- a/tests/CoreEx.Test/Framework/Results/ThenExtensionsTest.cs +++ /dev/null @@ -1,167 +0,0 @@ -using CoreEx.Results; -using NUnit.Framework; -using System; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class ThenExtensionsTest - { - internal static void AssertSuccess(Result result) => Assert.That(result.IsSuccess, Is.True); - internal static void AssertFailure(Result result) => Assert.That(result.IsFailure, Is.True); - - internal static T AssertSuccess(Result result) - { - Assert.That(result.IsSuccess, Is.True); - return result.Value; - } - - internal static void AssertFailure(Result result) => Assert.That(result.IsFailure, Is.True); - - - [Test] - public void Sync() - { - int j = 0; - AssertSuccess(Result.Go().Then(() => { j++; })); - Assert.That(j, Is.EqualTo(1)); - AssertFailure(Result.Fail(new BusinessException()).Then(() => { Assert.Fail(); })); - - AssertSuccess(Result.Go().Then(() => Result.Success)); - AssertFailure(Result.Fail(new BusinessException()).Then(() => Result.NotFoundError())); - - j = 0; - AssertSuccess(Result.Go(0).Then(_ => { j++; })); - Assert.That(j, Is.EqualTo(1)); - AssertFailure(Result.Go(new NotFoundException()).Then(_ => { Assert.Fail(); })); - - Assert.That(AssertSuccess(Result.Go(0).Then(i => ++i)), Is.EqualTo(1)); - AssertFailure(Result.Go(new NotFoundException()).Then(i => { Assert.Fail(); return +i; })); - - Assert.That(AssertSuccess(Result.Go(0).Then(i => Result.Ok(++i))), Is.EqualTo(1)); - AssertFailure(Result.Go(new NotFoundException()).Then(i => { Assert.Fail(); return Result.Ok(++i); })); - - Assert.That(AssertSuccess(Result.Go().ThenAs(() => 1)), Is.EqualTo(1)); - AssertFailure(Result.NotFoundError().ThenAs(() => { Assert.Fail(); return 1; })); - - Assert.That(AssertSuccess(Result.Go().ThenAs(() => Result.Ok(1))), Is.EqualTo(1)); - AssertFailure(Result.NotFoundError().ThenAs(() => { Assert.Fail(); return Result.Ok(1); })); - - AssertSuccess(Result.Go(1).ThenAs(_ => { })); - AssertFailure(Result.NotFoundError().ThenAs(_ => { Assert.Fail(); })); - - AssertSuccess(Result.Go(1).ThenAs(_ => Result.Success)); - AssertFailure(Result.NotFoundError().ThenAs(_ => Result.Success)); - - Assert.That(AssertSuccess(Result.Go(1).ThenAs(i => i + 1f)), Is.EqualTo(2f)); - AssertFailure(Result.Go(new NotFoundException()).ThenAs(i => { Assert.Fail(); return i + 1f; })); - - Assert.That(AssertSuccess(Result.Go(1).ThenAs(i => Result.Ok(i + 1f))), Is.EqualTo(2f)); - AssertFailure(Result.Go(new NotFoundException()).ThenAs(i => { Assert.Fail(); return Result.Ok(i + 1f); })); - - AssertSuccess(Result.Go().Then(() => { })); - AssertFailure(Result.NotFoundError().ThenFrom(() => { Assert.Fail(); return new IToResultTest(); })); - - Assert.That(AssertSuccess(Result.Go(0).ThenFrom(_ => new ITypedToResultTest())), Is.EqualTo(1)); - AssertFailure(Result.NotFoundError().ThenFrom(_ => { Assert.Fail(); return new ITypedToResultTest(); })); - - Assert.That(AssertSuccess(Result.Go(0).ThenFrom(_ => new IToResultIntTest())), Is.EqualTo(1)); - AssertFailure(Result.NotFoundError().ThenFrom(_ => { Assert.Fail(); return new IToResultIntTest(); })); - - Assert.That(AssertSuccess(Result.Go().ThenFromAs(() => new ITypedToResultTest())), Is.EqualTo(1)); - AssertFailure(Result.NotFoundError().ThenFromAs(() => { Assert.Fail(); return new ITypedToResultTest(); })); - - Assert.That(AssertSuccess(Result.Go().ThenFromAs(() => new IToResultIntTest())), Is.EqualTo(1)); - AssertFailure(Result.NotFoundError().ThenFromAs(() => { Assert.Fail(); return new IToResultIntTest(); })); - - Assert.That(AssertSuccess(Result.Go(1f).ThenFromAs(_ => new ITypedToResultTest())), Is.EqualTo(1)); - AssertFailure(Result.NotFoundError().ThenFromAs(_ => { Assert.Fail(); return new ITypedToResultTest(); })); - - Assert.That(AssertSuccess(Result.Go(1f).ThenFromAs(_ => new IToResultIntTest())), Is.EqualTo(1)); - AssertFailure(Result.NotFoundError().ThenFromAs(_ => { Assert.Fail(); return new IToResultIntTest(); })); - } - - [Test] - public async Task Async() - { - int j = 0; - AssertSuccess(await Result.Go().ThenAsync(() => { ++j; return Task.CompletedTask; })); - Assert.That(j, Is.EqualTo(1)); - AssertFailure(await Result.Fail(new BusinessException()).ThenAsync(() => { Assert.Fail(); return Task.CompletedTask; })); - - AssertSuccess(await Result.Go().ThenAsync(() => Task.FromResult(Result.Success))); - AssertFailure(await Result.Fail(new BusinessException()).ThenAsync(() => Task.FromResult(Result.NotFoundError()))); - - j = 0; - AssertSuccess(await Result.Go(0).ThenAsync(_ => { ++j; return Task.CompletedTask; })); - Assert.That(j, Is.EqualTo(1)); - AssertFailure(await Result.Go(new NotFoundException()).ThenAsync(_ => { Assert.Fail(); return Task.CompletedTask; })); - - Assert.That(AssertSuccess(await Result.Go(1).ThenAsync(i => Task.FromResult(++i))), Is.EqualTo(2)); - AssertFailure(await Result.Go(new NotFoundException()).ThenAsync(i => { Assert.Fail(); return Task.FromResult(++i); })); - - Assert.That(AssertSuccess(await Result.Go(1).ThenAsync(i => Task.FromResult(Result.Ok(++i)))), Is.EqualTo(2)); - AssertFailure(await Result.Go(new NotFoundException()).ThenAsync(i => { Assert.Fail(); return Task.FromResult(Result.Ok(++i)); })); - - Assert.That(AssertSuccess(await Result.Go().ThenAsAsync(() => Task.FromResult(1))), Is.EqualTo(1)); - AssertFailure(await Result.NotFoundError().ThenAsAsync(() => { Assert.Fail(); return Task.FromResult(1); })); - - Assert.That(AssertSuccess(await Result.Go().ThenAsAsync(() => Task.FromResult(Result.Ok(1)))), Is.EqualTo(1)); - AssertFailure(await Result.NotFoundError().ThenAsAsync(() => { Assert.Fail(); return Task.FromResult(Result.Ok(1)); })); - - AssertSuccess(await Result.Go(1).ThenAsAsync(_ => Task.CompletedTask)); - AssertFailure(await Result.NotFoundError().ThenAsAsync(_ => { Assert.Fail(); return Task.CompletedTask; })); - - AssertSuccess(await Result.Go(1).ThenAsAsync(_ => Task.FromResult(Result.Success))); - AssertFailure(await Result.NotFoundError().ThenAsAsync(_ => Task.FromResult(Result.Success))); - - Assert.That(AssertSuccess(await Result.Go(1).ThenAsAsync(i => Task.FromResult(i + 1f))), Is.EqualTo(2f)); - AssertFailure(await Result.Go(new NotFoundException()).ThenAsAsync(i => { Assert.Fail(); return Task.FromResult(i + 1f); })); - - Assert.That(AssertSuccess(await Result.Go(1).ThenAsAsync(i => Task.FromResult(Result.Ok(i + 1f)))), Is.EqualTo(2f)); - AssertFailure(await Result.Go(new NotFoundException()).ThenAsAsync(i => { Assert.Fail(); return Task.FromResult(Result.Ok(i + 1f)); })); - - AssertSuccess(await Result.Go().ThenFromAsync(async () => await Task.FromResult(new IToResultTest()))); - AssertFailure(await Result.NotFoundError().ThenFromAsync(async () => { Assert.Fail(); return await Task.FromResult(new IToResultTest()); })); - - Assert.That(AssertSuccess(await Result.Go(0).ThenFromAsync(async _ => await Task.FromResult(new ITypedToResultTest()))), Is.EqualTo(1)); - AssertFailure(await Result.NotFoundError().ThenFromAsync(async _ => { Assert.Fail(); return await Task.FromResult(new ITypedToResultTest()); })); - - Assert.That(AssertSuccess(await Result.Go(0).ThenFromAsync(async _ => await Task.FromResult(new IToResultIntTest()))), Is.EqualTo(1)); - AssertFailure(await Result.NotFoundError().ThenFromAsync(async _ => { Assert.Fail(); return await Task.FromResult(new IToResultIntTest()); })); - - Assert.That(AssertSuccess(await Result.Go().ThenFromAsAsync(async () => await Task.FromResult(new ITypedToResultTest()))), Is.EqualTo(1)); - AssertFailure(await Result.NotFoundError().ThenFromAsAsync(async () => { Assert.Fail(); return await Task.FromResult(new ITypedToResultTest()); })); - - Assert.That(AssertSuccess(await Result.Go().ThenFromAsAsync(async () => await Task.FromResult(new IToResultIntTest()))), Is.EqualTo(1)); - AssertFailure(await Result.NotFoundError().ThenFromAsAsync(async () => { Assert.Fail(); return await Task.FromResult(new IToResultIntTest()); })); - - Assert.That(AssertSuccess(await Result.Go(1f).ThenFromAsAsync(async _ => await Task.FromResult(new ITypedToResultTest()))), Is.EqualTo(1)); - AssertFailure(await Result.NotFoundError().ThenFromAsAsync(async _ => { Assert.Fail(); return await Task.FromResult(new ITypedToResultTest()); })); - - Assert.That(AssertSuccess(await Result.Go(1f).ThenFromAsAsync(async _ => await Task.FromResult(new IToResultIntTest()))), Is.EqualTo(1)); - AssertFailure(await Result.NotFoundError().ThenFromAsAsync(async _ => { Assert.Fail(); return await Task.FromResult(new IToResultIntTest()); })); - - Func>? func = null; - var t = Result.Go().ThenAsync(() => func?.Invoke(1) ?? Result.SuccessTask).Then(() => { }); - } - - public class IToResultTest : IToResult - { - public Result ToResult() => Result.Success; - } - - public class ITypedToResultTest : ITypedToResult - { - public Result ToResult() => Result.Ok((T)(object)1); - - public Result ToResult() => Result.Success; - } - - public class IToResultIntTest : IToResult - { - public Result ToResult() => Result.Ok(1); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/ValidationExtensionsTest.cs b/tests/CoreEx.Test/Framework/Results/ValidationExtensionsTest.cs deleted file mode 100644 index 32e8605c..00000000 --- a/tests/CoreEx.Test/Framework/Results/ValidationExtensionsTest.cs +++ /dev/null @@ -1,169 +0,0 @@ -using CoreEx.Invokers; -using CoreEx.Results; -using CoreEx.Validation; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class ValidationExtensionsTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public void Ensure_Success_NonDefaultValue() - { - var r = Result.Ok(1).Required(); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public void Ensure_Success_DefaultValue() - { - var r = Result.Ok(0).Required(); - Assert.That(r.Error, Is.Not.Null.And.Message.EqualTo("A data validation error occurred. [value: Value is required.]")); - } - - [Test] - public void Ensure_Failure() - { - var r = Result.Fail("bad").Required(); - Assert.That(r.Error, Is.Not.Null.And.Message.EqualTo("bad")); - } - - [Test] - public async Task Validation_Success_Entity_ValidationContext_Valid() - { - var r = await Result.Ok(new Person { Name = "Tom", Age = 18 }).ValidateAsync(() => _personValidator); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public async Task Validation_Success_Entity_ValidationContext_Invalid() - { - var r = await Result.Ok(new Person { Name = "Tom" }).ValidateAsync(_personValidator); - Assert.That(r.Error, Is.Not.Null.And.Message.EqualTo("A data validation error occurred. [Age: Age must be greater than 0.]")); - } - - [Test] - public async Task Validation_Failure_Entity_ValidationContext_Invalid() - { - var r = await Result.Fail("bad").ValidateAsync(_personValidator); - Assert.That(r.Error, Is.Not.Null.And.Message.EqualTo("bad")); - } - - [Test] - public async Task Validation_Success_Entity_IValidationResult_Valid() - { - var r = await Result.Ok(new Person { Name = "Tom", Age = 18 }).ValidateAsync(_personValidator); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public async Task Validation_Success_Entity_IValidationResult_InValid() - { - var r = await Result.Ok(new Person { Name = "Tom" }).ValidateAsync(v => v.Mandatory().Entity(_personValidator)); - Assert.That(r.Error, Is.Not.Null.And.Message.EqualTo("A data validation error occurred. [value.Age: Age must be greater than 0.]")); - } - - [Test] - public async Task Validation_Failure_Entity_IValidationResult_InValid() - { - var r = await Result.Fail("bad").ValidateAsync(v => v.Mandatory().Entity(_personValidator)); - Assert.That(r.Error, Is.Not.Null.And.Message.EqualTo("bad")); - } - - [Test] - public async Task Validation_Success_Other_Value() - { - var r = await Result.Go().ValidatesAsync(1, v => v.Mandatory().CompareValue(CompareOperator.LessThanEqual, 10)); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public async Task Validation_Failure_Other_Value() - { - var id = 88; - var r = await Result.Go().ValidatesAsync(id, v => v.Mandatory().CompareValue(CompareOperator.LessThanEqual, 10)); - Assert.That(r.Error, Is.Not.Null.And.Message.EqualTo("A data validation error occurred. [id: Identifier must be less than or equal to 10.]")); - } - - [Test] - public async Task Validation_Success_Other_String() - { - string? email = "a@b"; - var r = await Result.Go().ValidatesAsync(email, v => v.Email()); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public async Task Validation_Success_Other_String2() - { - string email = "a@b"; - var r = await Result.Go(email).ValidatesAsync(email, v => v.Email()); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public async Task Validation_Simulate_Ensure_And_Validation() - { - var value = new Person { Name = "Tom", Age = 18 }; - var id = 88; - - var r = await Result.Go().Manager(this, InvokerArgs.Create).WithAsAsync(r => - { - return Result.Go(value) - .Required() - .Then(v => v.Id = id) - .ThenAsync(v => v.Validate().Configure(c => c.Entity(_personValidator)).ValidateAsync()); - }); - - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public async Task Validation_MultiValidator_Success() - { - var value = new Person { Name = "Tom", Age = 18 }; - var r = await Result.Go().ValidateAsync(() => MultiValidator.Create().Add(value.Validate().Configure(c => c.Mandatory().Entity(_personValidator)))); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public async Task Validation_MultiValidator_Failure() - { - var value = new Person { Name = "Tom" }; - var r = await Result.Go().ValidateAsync(() => MultiValidator.Create().Add(value.Validate().Configure(c => c.Mandatory().Entity(_personValidator)))); - Assert.That(r.Error, Is.Not.Null.And.Message.EqualTo("A data validation error occurred. [value.Age: Age must be greater than 0.]")); - } - - [Test] - public async Task Validation_MultiValidator_Value_Success() - { - var value = new Person { Name = "Tom", Age = 18 }; - var r = await Result.Go(value).ValidateAsync(p => MultiValidator.Create().Add(p.Validate().Configure(c => c.Mandatory().Entity(_personValidator)))); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public async Task Validation_MultiValidator_Value_Failure() - { - var value = new Person { Name = "Tom" }; - var r = await Result.Go(value).ValidateAsync(_ => MultiValidator.Create().Add(value.Validate().Configure(c => c.Mandatory().Entity(_personValidator)))); - Assert.That(r.Error, Is.Not.Null.And.Message.EqualTo("A data validation error occurred. [value.Age: Age must be greater than 0.]")); - } - - private static readonly Validator _personValidator = Validator.Create() - .HasProperty(x => x.Name, p => p.Mandatory()) - .HasProperty(x => x.Age, p => p.CompareValue(CompareOperator.GreaterThan, 0)); - - public class Person - { - public int Id { get; set; } - public string? Name { get; set; } - public int Age { get; set; } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Results/WhenExtensionsTest.cs b/tests/CoreEx.Test/Framework/Results/WhenExtensionsTest.cs deleted file mode 100644 index d54063e3..00000000 --- a/tests/CoreEx.Test/Framework/Results/WhenExtensionsTest.cs +++ /dev/null @@ -1,144 +0,0 @@ -using CoreEx.Results; -using NUnit.Framework; -using System.Threading.Tasks; -using static CoreEx.Test.Framework.Results.ThenExtensionsTest; - -namespace CoreEx.Test.Framework.Results -{ - [TestFixture] - public class WhenExtensionsTest - { - internal static void AssertSuccess(Result result) => Assert.That(result.IsSuccess, Is.True); - internal static void AssertFailure(Result result) => Assert.That(result.IsFailure, Is.True); - - internal static T AssertSuccess(Result result) - { - Assert.That(result.IsSuccess, Is.True); - return result.Value; - } - - internal static void AssertFailure(Result result) => Assert.That(result.IsFailure, Is.True); - - [Test] - public void Sync() - { - int j = 0; - AssertSuccess(Result.Go().When(() => true, () => { j++; }).When(() => false, () => { j++; })); - Assert.That(j, Is.EqualTo(1)); - AssertFailure(Result.Fail(new BusinessException()).When(() => true, () => { Assert.Fail(); })); - - AssertSuccess(Result.Go().When(() => true, () => Result.Success).When(() => false, () => Result.Fail())); - AssertFailure(Result.Fail(new BusinessException()).When(() => true, () => Result.NotFoundError())); - - j = 0; - AssertSuccess(Result.Go(0).When(_ => true, _ => { j++; }).When(_ => false, _ => { Assert.Fail(); })); - Assert.That(j, Is.EqualTo(1)); - AssertFailure(Result.Go(new NotFoundException()).When(_ => true, _ => { Assert.Fail(); })); - - Assert.That(AssertSuccess(Result.Go(0).When(_ => true, i => ++i).When(_ => false, _ => Assert.Fail())), Is.EqualTo(1)); - AssertFailure(Result.Go(new NotFoundException()).When(_ => true, i => { Assert.Fail(); return +i; })); - - Assert.That(AssertSuccess(Result.Go(0).When(_ => true, i => Result.Ok(++i)).When(_ => false, i => Result.Ok(++i))), Is.EqualTo(1)); - AssertFailure(Result.Go(new NotFoundException()).When(_ => true, i => { Assert.Fail(); return Result.Ok(++i); })); - - Assert.Multiple(() => - { - Assert.That(AssertSuccess(Result.Go().WhenAs(() => true, () => 1)), Is.EqualTo(1)); - Assert.That(AssertSuccess(Result.Go().WhenAs(() => false, () => 1)), Is.EqualTo(0)); - }); - AssertFailure(Result.NotFoundError().WhenAs(() => true, () => { Assert.Fail(); return 1; })); - - Assert.Multiple(() => - { - Assert.That(AssertSuccess(Result.Go().WhenAs(() => true, () => Result.Ok(1))), Is.EqualTo(1)); - Assert.That(AssertSuccess(Result.Go().WhenAs(() => false, () => Result.Ok(1))), Is.EqualTo(0)); - }); - AssertFailure(Result.NotFoundError().WhenAs(() => true, () => { Assert.Fail(); return Result.Ok(1); })); - - AssertSuccess(Result.Go(1).WhenAs(_ => true, _ => { })); - AssertSuccess(Result.Go(1).WhenAs(_ => false, _ => { Assert.Fail(); })); - AssertFailure(Result.NotFoundError().WhenAs(_ => true, _ => { Assert.Fail(); })); - - AssertSuccess(Result.Go(1).WhenAs(_ => true, _ => Result.Success)); - AssertSuccess(Result.Go(1).WhenAs(_ => false, _ => Result.Fail())); - AssertFailure(Result.NotFoundError().WhenAs(_ => true, _ => Result.Success)); - - Assert.Multiple(() => - { - Assert.That(AssertSuccess(Result.Go(1).WhenAs(_ => true, i => i + 1f)), Is.EqualTo(2f)); - Assert.That(AssertSuccess(Result.Go(1).WhenAs(_ => false, i => i + 1f)), Is.EqualTo(0f)); - }); - AssertFailure(Result.Go(new NotFoundException()).WhenAs(_ => true, i => { Assert.Fail(); return i + 1f; })); - - Assert.Multiple(() => - { - Assert.That(AssertSuccess(Result.Go(1).WhenAs(_ => true, i => Result.Ok(i + 1f))), Is.EqualTo(2f)); - Assert.That(AssertSuccess(Result.Go(1).WhenAs(_ => false, i => Result.Ok(i + 1f))), Is.EqualTo(0f)); - }); - AssertFailure(Result.Go(new NotFoundException()).WhenAs(_ => true, i => { Assert.Fail(); return Result.Ok(i + 1f); })); - - AssertSuccess(Result.Go().WhenFrom(() => true, () => new IToResultTest())); - AssertSuccess(Result.Go().WhenFrom(() => false, () => { Assert.Fail(); return new IToResultTest(); })); - AssertFailure(Result.NotFoundError().WhenFrom(() => true, () => { Assert.Fail(); return new IToResultTest(); })); - - Assert.Multiple(() => - { - Assert.That(AssertSuccess(Result.Go(0).WhenFrom(_ => true, _ => new ITypedToResultTest())), Is.EqualTo(1)); - Assert.That(AssertSuccess(Result.Go(0).WhenFrom(_ => false, _ => new ITypedToResultTest())), Is.EqualTo(0)); - }); - AssertFailure(Result.NotFoundError().WhenFrom(_ => true, _ => { Assert.Fail(); return new ITypedToResultTest(); })); - - Assert.Multiple(() => - { - Assert.That(AssertSuccess(Result.Go(0).WhenFrom(_ => true, _ => new IToResultIntTest())), Is.EqualTo(1)); - Assert.That(AssertSuccess(Result.Go(0).WhenFrom(_ => false, _ => new IToResultIntTest())), Is.EqualTo(0)); - }); - AssertFailure(Result.NotFoundError().WhenFrom(_ => true, _ => { Assert.Fail(); return new IToResultIntTest(); })); - - Assert.Multiple(() => - { - Assert.That(AssertSuccess(Result.Go().WhenFromAs(() => true, () => new ITypedToResultTest())), Is.EqualTo(1)); - Assert.That(AssertSuccess(Result.Go().WhenFromAs(() => false, () => new ITypedToResultTest())), Is.EqualTo(0)); - }); - AssertFailure(Result.NotFoundError().WhenFromAs(() => true, () => { Assert.Fail(); return new ITypedToResultTest(); })); - - Assert.Multiple(() => - { - Assert.That(AssertSuccess(Result.Go().WhenFromAs(() => true, () => new IToResultIntTest())), Is.EqualTo(1)); - Assert.That(AssertSuccess(Result.Go().WhenFromAs(() => false, () => new IToResultIntTest())), Is.EqualTo(0)); - }); - AssertFailure(Result.NotFoundError().WhenFromAs(() => true, () => { Assert.Fail(); return new IToResultIntTest(); })); - - Assert.Multiple(() => - { - Assert.That(AssertSuccess(Result.Go(2.2f).WhenFromAs(_ => true, _ => new ITypedToResultTest())), Is.EqualTo(1)); - Assert.That(AssertSuccess(Result.Go(2.2f).WhenFromAs(_ => false, _ => new ITypedToResultTest())), Is.EqualTo(0)); - }); - AssertFailure(Result.NotFoundError().WhenFromAs(_ => true, _ => { Assert.Fail(); return new ITypedToResultTest(); })); - - Assert.Multiple(() => - { - Assert.That(AssertSuccess(Result.Go(2.2f).WhenFromAs(_ => true, _ => new IToResultIntTest())), Is.EqualTo(1)); - Assert.That(AssertSuccess(Result.Go(2.2f).WhenFromAs(_ => false, _ => new IToResultIntTest())), Is.EqualTo(0)); - }); - AssertFailure(Result.NotFoundError().WhenFromAs(_ => true, _ => { Assert.Fail(); return new IToResultIntTest(); })); - } - - [Test] - public void Sync_Otherwise() - { - int j = 0; - AssertSuccess(Result.Go().When(() => j == 0, () => { ++j; }, () => { j += 10; })); - Assert.That(j, Is.EqualTo(1)); - - AssertSuccess(Result.Go().When(() => j == 0, () => { ++j; }, () => { j += 10; })); - Assert.That(j, Is.EqualTo(11)); - - j = AssertSuccess(Result.Go(0).When(x => x == 0, x => ++x, x => x += 10)); - Assert.That(j, Is.EqualTo(1)); - - j = AssertSuccess(Result.Go(1).When(x => x == 0, x => ++x, x => x += 10)); - Assert.That(j, Is.EqualTo(11)); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Solace/PubSub/EventDataToPubSubConverterTest.cs b/tests/CoreEx.Test/Framework/Solace/PubSub/EventDataToPubSubConverterTest.cs deleted file mode 100644 index abfa2163..00000000 --- a/tests/CoreEx.Test/Framework/Solace/PubSub/EventDataToPubSubConverterTest.cs +++ /dev/null @@ -1,88 +0,0 @@ -using CoreEx.Events; -using CoreEx.Solace.PubSub; -using CoreEx.TestFunction.Models; -using NUnit.Framework; -using SolaceSystems.Solclient.Messaging; -using System; - -namespace CoreEx.Test.Framework.Solace.PubSub -{ - [TestFixture] - public class EventDataToPubSubConverterTest - { - [Test] - public void Convert_NoValue_Using_EventDataToPubSubMessageConverter() - { - var c = new EventDataToPubSubMessageConverter(); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY" }); - AssertPubSubMessage(m); - } - - [Test] - public void Convert_NoValue_WithPartitionKey_Using_EventDataToPubSubMessageConverter() - { - var c = new EventDataToPubSubMessageConverter(); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY", PartitionKey = "ZZZ" }); - AssertPubSubMessage(m); - } - - [Test] - public void Convert_WithValue_Using_EventDataToPubSubMessageConverter() - { - var c = new EventDataToPubSubMessageConverter(); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY", Value = new Product { Id = "X", Name = "Xxx", Price = 9.99m } }); - AssertPubSubMessage(m); - } - - [Test] - public void Convert_NoValue_Using_TextJsonEventSerialization() - { - var es = new CoreEx.Text.Json.EventDataSerializer { SerializeValueOnly = false }; - - var c = new EventDataToPubSubMessageConverter(es); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY" }); - AssertPubSubMessage(m); - } - - [Test] - public void Convert_WithValue_Using_TextJsonEventSerialization() - { - var es = new CoreEx.Text.Json.EventDataSerializer { SerializeValueOnly = false }; - - var c = new EventDataToPubSubMessageConverter(es); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY", Value = new Product { Id = "X", Name = "Xxx", Price = 9.99m } }); - AssertPubSubMessage(m); - } - - [Test] - public void Convert_NoValue_Using_TextJsonCloudEventSerialization() - { - var es = new CoreEx.Text.Json.CloudEventSerializer(); - - var c = new EventDataToPubSubMessageConverter(es); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Source = new Uri("xxx", UriKind.Relative), Action = "YYY" }); - AssertPubSubMessage(m); - } - - [Test] - public void Convert_WithValue_Using_TextJsonCloudEventSerialization() - { - var es = new CoreEx.Text.Json.CloudEventSerializer(); - - var c = new EventDataToPubSubMessageConverter(es); - var m = c.Convert(new EventData { Id = "123", Subject = "XXX", Action = "YYY", Source = new Uri("xxx", UriKind.Relative), Value = new Product { Id = "X", Name = "Xxx", Price = 9.99m } }); - AssertPubSubMessage(m); - } - - private static void AssertPubSubMessage(IMessage m) - { - Assert.That(m, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(m!.ApplicationMessageId, Is.EqualTo("123")); - Assert.That(m.UserPropertyMap.GetString("Subject"), Is.EqualTo("xxx")); - Assert.That(m.UserPropertyMap.GetString("Action"), Is.EqualTo("yyy")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/UnitTesting/AgentTest.cs b/tests/CoreEx.Test/Framework/UnitTesting/AgentTest.cs deleted file mode 100644 index d859c0c1..00000000 --- a/tests/CoreEx.Test/Framework/UnitTesting/AgentTest.cs +++ /dev/null @@ -1,88 +0,0 @@ -using CoreEx.TestApi; -using NUnit.Framework; -using UnitTestEx; -using Microsoft.Extensions.Logging; -using System.Net.Http; -using System.Threading.Tasks; -using System.Threading; -using CoreEx.Json; -using CoreEx.Configuration; -using CoreEx.Http; -using CoreEx.TestFunction.Models; -using UnitTestEx.Expectations; -using UnitTestEx.Assertors; - -namespace CoreEx.Test.Framework.UnitTesting -{ - [TestFixture] - public class AgentTest - { - [OneTimeSetUp] - public void SetUp() => TestSetUp.Default.JsonSerializer = new CoreEx.Text.Json.JsonSerializer().ToUnitTestEx(); - - [Test] - public void Get() - { - var test = ApiTester.Create(); - test.Agent().With() - .ExpectIdentifier() - .ExpectValue(new Product { Name = "Apple", Price = 0.79m }) - .Run(a => a.GetAsync("abc")) - .AssertOK(); - } - - [Test] - public void Update_Error() - { - var test = ApiTester.Create(); - var result = test.Agent().With() - .ExpectErrorType(CoreEx.Abstractions.ErrorType.ValidationError) - .ExpectError("Zed is dead.") - .Run(a => a.UpdateAsync(new Product { Name = "Apple", Price = 0.79m }, "Zed")) - .Result; - - Assert.That(result.Response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.BadRequest)); - } - - [Test] - public void Delete() - { - var test = ApiTester.Create(); - var res = test.Agent().With() - .Run(a => a.DeleteAsync("abc")) - .AssertNoContent(); - - var result = (HttpResultAssertor)res; - Assert.That(result.Response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.NoContent)); - } - - [Test] - public void Catalogue() - { - var test = ApiTester.Create(); - var res = test.Agent().With() - .Run(a => a.CatalogueAsync("abc")) - .AssertOK() - .AssertContentTypePlainText() - .AssertContent("Catalog for 'abc'."); - - var result = (HttpResultAssertor)res; - Assert.That(result.Response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK)); - } - - public class ProductAgent(HttpClient client, IJsonSerializer jsonSerializer, CoreEx.ExecutionContext executionContext) : CoreEx.Http.TypedHttpClientBase(client, jsonSerializer, executionContext) - { - public Task> GetAsync(string id, CoreEx.Http.HttpRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => GetAsync("products/{id}", requestOptions: requestOptions, args: new IHttpArg[] { new HttpArg("id", id) }, cancellationToken: cancellationToken); - - public Task DeleteAsync(string id, CoreEx.Http.HttpRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => DeleteAsync("products/{id}", requestOptions: requestOptions, args: new IHttpArg[] { new HttpArg("id", id) }, cancellationToken: cancellationToken); - - public Task> UpdateAsync(Product value, string id, CoreEx.Http.HttpRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => PutAsync("products/{id}", value, requestOptions: requestOptions, args: new IHttpArg[] { new HttpArg("id", id) }, cancellationToken: cancellationToken); - - public Task CatalogueAsync(string id, CoreEx.Http.HttpRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => GetAsync("products/{id}/catalogue", requestOptions: requestOptions, args: new IHttpArg[] { new HttpArg("id", id) }, cancellationToken: cancellationToken); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/UnitTesting/ExpectationsTest.cs b/tests/CoreEx.Test/Framework/UnitTesting/ExpectationsTest.cs deleted file mode 100644 index 94ed1893..00000000 --- a/tests/CoreEx.Test/Framework/UnitTesting/ExpectationsTest.cs +++ /dev/null @@ -1,184 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Http; -using NUnit.Framework; -using System; -using System.Net.Http; -using System.Threading.Tasks; -using UnitTestEx.Expectations; -using UnitTestEx; - -namespace CoreEx.Test.Framework.UnitTesting -{ - [TestFixture] - public class ExpectationsTest - { - [Test] - public void ExpectIdentifier() - { - var gt = GenericTester.CreateFor>().ExpectIdentifier(); - var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity())))!; - Assert.That(ex.Message, Does.Contain("Expected IIdentifier.Id to have a non-null value.")); - - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { Id = "x" })); - - gt = GenericTester.CreateFor>().ExpectIdentifier("y"); - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { Id = "x" })))!; - Assert.That(ex.Message, Does.Contain("Expected IIdentifier.Id value of 'y'; actual 'x'.")); - - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { Id = "y" })); - } - - [Test] - public void ExpectPrimaryKey() - { - var gt = GenericTester.CreateFor>().ExpectPrimaryKey(); - var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity2())))!; - Assert.That(ex.Message, Does.Contain("Expected IPrimaryKey.PrimaryKey.Args to have one or more non-default values.")); - - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity2 { Id = "x" })); - - gt = GenericTester.CreateFor>().ExpectPrimaryKey("y"); - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity2 { Id = "x" })))!; - Assert.That(ex.Message, Does.Contain("Expected IPrimaryKey.PrimaryKey value of 'y'; actual 'x'.")); - - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity2 { Id = "y" })); - } - - [Test] - public void ExpectETag() - { - var gt = GenericTester.CreateFor>().ExpectETag(); - var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity())))!; - Assert.That(ex.Message, Does.Contain("Expected IETag.ETag to have a non-null value.")); - - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ETag = "xxx" })); - - gt = GenericTester.CreateFor>().ExpectETag("yyy"); - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ETag = "yyy" })))!; - Assert.That(ex.Message, Does.Contain("Expected IETag.ETag value of 'yyy' to be different to actual.")); - - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ETag = "xxx" })); - } - - [Test] - public void ExpectChangeLogCreated() - { - var gt = GenericTester.CreateFor>().ExpectChangeLogCreated(); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { CreatedBy = "Anonymous", CreatedDate = DateTime.UtcNow } })); - - var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity())))!; - Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit) to have a non-null value.")); - - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog() })))!; - Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).CreatedBy value of 'Anonymous'; actual was null.")); - - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { CreatedBy = "Anonymous" } })))!; - Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).CreatedDate to have a non-null value.")); - - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { CreatedBy = "Anonymous", CreatedDate = DateTime.UtcNow.AddMinutes(-1) } })))!; - Assert.That(ex.Message.Contains("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).CreatedDate value of '") && ex.Message.Contains("' must be greater than or equal to expected."), Is.True); - - gt = GenericTester.CreateFor>().ExpectChangeLogCreated("Banana"); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { CreatedBy = "Banana", CreatedDate = DateTime.UtcNow } })); - - gt = GenericTester.CreateFor>().ExpectChangeLogCreated("b*"); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { CreatedBy = "Banana", CreatedDate = DateTime.UtcNow } })); - - gt = GenericTester.CreateFor>().ExpectChangeLogCreated("*"); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { CreatedBy = "Banana", CreatedDate = DateTime.UtcNow } })); - } - - [Test] - public void ExpectChangeLogUpdated() - { - var gt = GenericTester.CreateFor>().ExpectChangeLogUpdated(); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { UpdatedBy = "Anonymous", UpdatedDate = DateTime.UtcNow } })); - - var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity())))!; - Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit) to have a non-null value.")); - - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog() })))!; - Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).UpdatedBy value of 'Anonymous'; actual was null.")); - - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { UpdatedBy = "Anonymous" } })))!; - Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).UpdatedDate to have a non-null value.")); - - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { UpdatedBy = "Anonymous", UpdatedDate = DateTime.UtcNow.AddMinutes(-1) } })))!; - Assert.That(ex.Message.Contains("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).UpdatedDate value of '") && ex.Message.Contains("' must be greater than or equal to expected."), Is.True); - - gt = GenericTester.CreateFor>().ExpectChangeLogUpdated("Banana"); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { UpdatedBy = "Banana", UpdatedDate = DateTime.UtcNow } })); - - gt = GenericTester.CreateFor>().ExpectChangeLogUpdated("b*"); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { UpdatedBy = "Banana", UpdatedDate = DateTime.UtcNow } })); - - gt = GenericTester.CreateFor>().ExpectChangeLogUpdated("*"); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { UpdatedBy = "Banana", UpdatedDate = DateTime.UtcNow } })); - } - - [Test] - public void ExpectErrorType() - { - var gt = GenericTester.CreateFor>().ExpectErrorType(CoreEx.Abstractions.ErrorType.ValidationError); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new ValidationException())); - - var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new BusinessException())))!; - Assert.That(ex.Message, Does.Contain("Expected error type of 'ValidationError' but actual was 'BusinessError'.")); - - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new Exception())))!; - Assert.That(ex.Message, Does.Contain("Expected error type of 'ValidationError' but none was returned.")); - - var hr = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest); - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(gt.ExpectationsArranger.CreateArgs(null, null).AddExtra(hr))))!; - Assert.That(ex.Message, Does.Contain("Expected error type of 'ValidationError' but none was returned.")); - - hr.Headers.Add(HttpConsts.ErrorTypeHeaderName, "BusinessError"); - ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(gt.ExpectationsArranger.CreateArgs(null, null).AddExtra(hr))))!; - Assert.That(ex.Message, Does.Contain("Expected error type of 'ValidationError' but actual was 'BusinessError'.")); - - hr.Headers.Add(HttpConsts.ErrorTypeHeaderName, "ValidationError"); - ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(gt.ExpectationsArranger.CreateArgs(null, null).AddExtra(hr))); - } - - private static void ArrangerAssert(Func func) - { - try - { - var t = Task.Run(() => func()); - t.Wait(); - } - catch (AggregateException agex) when (agex.InnerException is not null && agex.InnerException is AssertionException aex) - { - throw new Exception(aex.Message, aex); - } - catch (AssertionException aex) - { - throw new Exception(aex.Message, aex); - } - } - - public class Entity : IIdentifier, IChangeLog, IETag where TId : IComparable, IEquatable - { - public TId? Id { get; set; } - - public string? Name { get; set; } - - public ChangeLog? ChangeLog { get; set; } - - public string? ETag { get; set; } - } - - public class Entity2 : IPrimaryKey, IChangeLog, IETag where TId : IComparable, IEquatable - { - public TId? Id { get; set; } - - public string? Name { get; set; } - - public CompositeKey PrimaryKey => new(Id); - - public ChangeLog? ChangeLog { get; set; } - - public string? ETag { get; set; } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/UnitTesting/ValidationTest.cs b/tests/CoreEx.Test/Framework/UnitTesting/ValidationTest.cs deleted file mode 100644 index f394a597..00000000 --- a/tests/CoreEx.Test/Framework/UnitTesting/ValidationTest.cs +++ /dev/null @@ -1,50 +0,0 @@ -using NUnit.Framework; -using UnitTestEx; -using UnitTestEx.Expectations; -using CoreEx; -using CoreEx.TestApi; -using CoreEx.TestApi.Validation; -using CoreEx.TestFunction.Models; -using CoreEx.Localization; -using CoreEx.Validation; - -namespace CoreEx.Test.Framework.UnitTesting -{ - [TestFixture] - public class ValidationTest - { - [Test] - public void Validate_Error() - { - GenericTester.Create() - .ExpectErrors("Name is required.", "Price must be between 0 and 100.") - .Validation().With(new Product { Price = 450.95m }) - .AssertErrors("Name is required.", "Price must be between 0 and 100."); - } - - [Test] - public void Validate_OK() - { - GenericTester.Create() - .ExpectSuccess() - .Validation().With(new Product { Id = "abc", Name = "xyz", Price = 50.95m }); - } - - [Test] - public void Validate_OK_Provide() - { - GenericTester.Create() - .ExpectSuccess() - .Validation().With(new ProductValidator(), new Product { Id = "abc", Name = "xyz", Price = 50.95m }); - } - - [Test] - public void Validate_Error_Provide() - { - GenericTester.Create() - .ReplaceSingleton() - .ExpectErrors("Name is required.", "Price must be between 0 and 100.") - .Validation().With(new ProductValidator(), new Product { Price = 450.95m }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/AbstractValidatorTest.cs b/tests/CoreEx.Test/Framework/Validation/AbstractValidatorTest.cs deleted file mode 100644 index 7b1a0a18..00000000 --- a/tests/CoreEx.Test/Framework/Validation/AbstractValidatorTest.cs +++ /dev/null @@ -1,91 +0,0 @@ -using NUnit.Framework; -using CoreEx.Entities; -using CoreEx.Validation; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation -{ - [TestFixture] - public class AbstractValidatorTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(null); - - [Test] - public async Task Abstract_And_RuleFor() - { - var r = await new TestDataValidator().ValidateAsync(new TestData()); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(2)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Please specify a text value.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - Assert.That(r.Messages![1].Text, Is.EqualTo("Date B is required.")); - Assert.That(r.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[1].Property, Is.EqualTo("DateB")); - }); - } - - [Test] - public async Task With_Message() - { - var r = await new TestDataValidator2().ValidateAsync(new TestData()); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(2)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Text is wonky.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - Assert.That(r.Messages![1].Text, Is.EqualTo("Date B null-as.")); - Assert.That(r.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[1].Property, Is.EqualTo("DateB")); - }); - } - - [Test] - public async Task IncludeSameType() - { - var r = await new TestDataValidator3().ValidateAsync(new TestData()); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(2)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Please specify a text value.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - Assert.That(r.Messages![1].Text, Is.EqualTo("Date B is required.")); - Assert.That(r.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[1].Property, Is.EqualTo("DateB")); - }); - } - } - - public class TestDataValidator : AbstractValidator - { - public TestDataValidator() - { - RuleFor(x => x.Text).NotEmpty().WithMessage("Please specify a text value."); - RuleFor(x => x.DateB).NotNull(); - } - } - - public class TestDataValidator2 : AbstractValidator - { - public TestDataValidator2() - { - RuleFor(x => x.Text).WithMessage("Text is wonky.").NotEmpty(); - RuleFor(x => x.DateB).WithMessage("Data B is wonky").NotNull().WithMessage("Date B null-as."); - } - } - - public class TestDataValidator3 : AbstractValidator - { - public TestDataValidator3() - { - Include(new TestDataValidator()); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Clauses/DependsOnClauseTest.cs b/tests/CoreEx.Test/Framework/Validation/Clauses/DependsOnClauseTest.cs deleted file mode 100644 index b8e6eec7..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Clauses/DependsOnClauseTest.cs +++ /dev/null @@ -1,43 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Clauses -{ - [TestFixture] - public class DependsOnClauseTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task DependsOn() - { - var v = Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory()) - .HasProperty(x => x.CountA, p => p.Between(1, 10).DependsOn(x => x.Text)); - - var td = new TestData { Text = null, CountA = 88 }; - var v1 = await td.Validate("value").Configure(c => c.Entity(v)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Property, Is.EqualTo("value.Text")); - }); - - td = new TestData { Text = "xxx", CountA = 88 }; - v1 = await td.Validate("value").Configure(c => c.Entity(v)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Property, Is.EqualTo("value.CountA")); - }); - - td = new TestData { Text = "xxx", CountA = 5 }; - v1 = await td.Validate("value").Configure(c => c.Entity(v)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Clauses/WhenClauseTest.cs b/tests/CoreEx.Test/Framework/Validation/Clauses/WhenClauseTest.cs deleted file mode 100644 index e89e8a52..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Clauses/WhenClauseTest.cs +++ /dev/null @@ -1,103 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Clauses -{ - [TestFixture] - public class WhenClauseTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task When() - { - var v1 = await 1.Validate(c => c.Between(2, 10).When(true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await 1.Validate(c => c.Between(2, 10).When(false)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate(c => c.Between(2, 10).When(() => true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await 1.Validate(c => c.Between(2, 10).When(() => false)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate(c => c.Between(2, 10).When(x => x.Value == 1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await 1.Validate(c => c.Between(2, 10).When(x => x.Value != 1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - var v = Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory()) - .HasProperty(x => x.CountA, p => p.Between(1, 10).When(x => x.Text == "xxx")); - - var td = new TestData { Text = "xxx", CountA = 88 }; - var v2 = await td.Validate().Configure(c => c.Entity(v)).ValidateAsync(); - Assert.That(v2.HasErrors, Is.True); - - td = new TestData { Text = "yyy", CountA = 88 }; - v2 = await td.Validate().Configure(c => c.Entity(v)).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - } - - [Test] - public async Task WhenValue() - { - var v1 = await 1.Validate(c => c.Between(2, 10).WhenValue(v => v == 1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await 1.Validate(c => c.Between(2, 10).WhenValue(v => v == 2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - var v = Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory()) - .HasProperty(x => x.CountA, p => p.Between(1, 10).WhenValue(v => v == 88)); - - var td = new TestData { Text = "xxx", CountA = 88 }; - var v2 = await td.Validate().Configure(c => c.Entity(v)).ValidateAsync(); - Assert.That(v2.HasErrors, Is.True); - - td = new TestData { Text = "xxx", CountA = 99 }; - v2 = await td.Validate().Configure(c => c.Entity(v)).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - } - - [Test] - public async Task WhenHasValue() - { - var v1 = await 1.Validate(c => c.Immutable().WhenHasValue()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await 0.Validate(c => c.Immutable().WhenHasValue()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task WhenOperation() - { - ExecutionContext.Current.OperationType = OperationType.Update; - - var v1 = await 1.Validate(c => c.Immutable().WhenOperation(OperationType.Update)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await 1.Validate(c => c.Immutable().WhenOperation(OperationType.Create)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task WhenNotOperation() - { - ExecutionContext.Current.OperationType = OperationType.Update; - - var v1 = await 1.Validate(c => c.Immutable().WhenNotOperation(OperationType.Create)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await 1.Validate(c => c.Immutable().WhenNotOperation(OperationType.Update)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/CommonValidatorTest.cs b/tests/CoreEx.Test/Framework/Validation/CommonValidatorTest.cs deleted file mode 100644 index 9bcf9c64..00000000 --- a/tests/CoreEx.Test/Framework/Validation/CommonValidatorTest.cs +++ /dev/null @@ -1,287 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Validation; -using CoreEx.Results; -using NUnit.Framework; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation -{ - [TestFixture] - public class CommonValidatorTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - private static readonly CommonValidator _cv = Validator.CreateFor(v => v.String(5).Must(x => x.Value != "XXXXX")); - private static readonly CommonValidator _cv2 = Validator.CreateFor(v => v.Mandatory().CompareValue(CompareOperator.NotEqual, 1)); - - [Test] - public async Task Validate() - { - var r = await "XXXXXX".Validate(_cv, null).ValidateAsync(); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Value must not exceed 5 characters in length.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("value")); - }); - - r = await "XXXXX".Validate(_cv, "Name").ValidateAsync(); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Name is invalid.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Name")); - }); - - r = await "XXX".Validate(_cv, "Name").ValidateAsync(); - Assert.That(r, Is.Not.Null); - Assert.That(r.HasErrors, Is.False); - } - - [Test] - public async Task Common() - { - var r = await Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory().Common(_cv)) - .ValidateAsync(new TestData { Text = "XXXXXX" }); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Text must not exceed 5 characters in length.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - }); - - r = await Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory().Common(_cv)) - .ValidateAsync(new TestData { Text = "XXXXX" }); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Text is invalid.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - }); - - r = await Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory().Common(_cv)) - .ValidateAsync(new TestData { Text = "XXX" }); - - Assert.That(r, Is.Not.Null); - Assert.That(r.HasErrors, Is.False); - } - - [Test] - public async Task Validate_Nullable() - { - int? v = 1; - var r = await v.Validate(_cv2, null).ValidateAsync(); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Value must not be equal to 1.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Common_Nullable() - { - var r = await Validator.Create() - .HasProperty(x => x.CountB, p => p.Mandatory().Common(_cv2)) - .ValidateAsync(new TestData { CountB = 1 }); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Count B must not be equal to 1.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("CountB")); - }); - } - - [Test] - public async Task Common_FailureResult_ViaAdditional() - { - var cv = Validator.CreateFor(v => v.String(5)).AdditionalAsync((c, _) => Task.FromResult(Result.NotFoundError())); - var r = await "abc".Validate(cv).ValidateAsync(); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.FailureResult, Is.Not.Null); - }); - Assert.That(r.FailureResult!.Value.Error, Is.Not.Null.And.TypeOf()); - Assert.Throws(() => r.ThrowOnError()); - } - - [Test] - public async Task Common_FailureResult_ViaCustom() - { - var cv = CommonValidator.Create(v => v.String(5).Custom(ctx => Result.NotFoundError())); - var r = await "abc".Validate(cv).ValidateAsync(); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.FailureResult, Is.Not.Null); - }); - Assert.That(r.FailureResult!.Value.Error, Is.Not.Null.And.TypeOf()); - Assert.Throws(() => r.ThrowOnError()); - } - - [Test] - public async Task Common_FailureResult_WithOwningValidator() - { - var cv = CommonValidator.Create(v => v.String(5).Custom(ctx => Result.NotFoundError())); - var pv = Validator.Create().HasProperty(x => x.Name, p => p.Common(cv)); - - var p = new Person { Name = "abc" }; - var r = await pv.ValidateAsync(p); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.FailureResult, Is.Not.Null); - }); - Assert.That(r.FailureResult!.Value.Error, Is.Not.Null.And.TypeOf()); - Assert.Throws(() => r.ThrowOnError()); - } - - [Test] - public async Task CreateFor() - { - var cv = Validator.CreateFor().Configure(v => v.MaximumLength(5)); - var r = await "abcdef".Validate(cv, null).ValidateAsync(); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Value must not exceed 5 characters in length.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task CommonExtensionMethod() - { - await CommonExtensionMethod(null, false); - await CommonExtensionMethod("12345", false); - await CommonExtensionMethod("1234567", true); - } - - private async Task CommonExtensionMethod(string? accountId, bool expectErrors) - { - var r = await accountId.Validate().Configure(cv => cv.Common(Validators.String5)).ValidateAsync(); - Assert.That(r.HasErrors, Is.EqualTo(expectErrors)); - } - - [Test] - public async Task CommonEntity() - { - var pv = new PersonValidator(); - var r = await pv.ValidateAsync(new Person()); - Assert.That(r.HasErrors, Is.False); - - r = await pv.ValidateAsync(new Person { Name = "12345" }); - Assert.That(r.HasErrors, Is.False); - - r = await pv.ValidateAsync(new Person { Name = "12345678" }); - Assert.That(r.HasErrors, Is.True); - } - - public static class Validators - { - public static CommonValidator String5 => CommonValidator.Create(c => c.String(5)); - } - - public class PersonValidator : Validator - { - public PersonValidator() => HasProperty(x => x.Name, p => p.Common(Validators.String5)); - } - - public class Person - { - public string? Name { get; set; } - } - - public class IntValidator : CommonValidator - { - public IntValidator() => this.Text("Count").Mandatory().CompareValue(CompareOperator.GreaterThanEqual, 10).CompareValue(CompareOperator.LessThanEqual, 20); - - protected override Task OnValidateAsync(PropertyContext, int> context, CancellationToken ct) - { - if (context.Value == 11) - context.CreateErrorMessage("{0} is not allowed to be eleven."); - - return Task.FromResult(Result.Success); - } - } - - [Test] - public async Task Inherited_Basic() - { - var iv = new IntValidator(); - var vr = await 8.Validate(iv, null).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(vr.HasErrors, Is.True); - Assert.That(vr.Messages!, Has.Count.EqualTo(1)); - Assert.That(vr.Messages![0].Text, Is.EqualTo("Count must be greater than or equal to 10.")); - Assert.That(vr.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Inherited_Basic2() - { - var iv = new IntValidator(); - var vr = await 28.Validate(iv, "length").ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(vr.HasErrors, Is.True); - Assert.That(vr.Messages!, Has.Count.EqualTo(1)); - Assert.That(vr.Messages![0].Text, Is.EqualTo("Count must be less than or equal to 20.")); - Assert.That(vr.Messages[0].Property, Is.EqualTo("length")); - }); - } - - [Test] - public async Task Inherited_OnValidate() - { - var iv = new IntValidator(); - var vr = await 11.Validate(iv, null).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(vr.HasErrors, Is.True); - Assert.That(vr.Messages!, Has.Count.EqualTo(1)); - Assert.That(vr.Messages![0].Text, Is.EqualTo("Count is not allowed to be eleven.")); - Assert.That(vr.Messages[0].Property, Is.EqualTo("value")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/MultiValidatorTest.cs b/tests/CoreEx.Test/Framework/Validation/MultiValidatorTest.cs deleted file mode 100644 index e1925c11..00000000 --- a/tests/CoreEx.Test/Framework/Validation/MultiValidatorTest.cs +++ /dev/null @@ -1,99 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Validation; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation -{ - [TestFixture] - public class MultiValidatorTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task MultiError() - { - var v1 = Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory().String(10)) - .HasProperty(x => x.CountB, p => p.Mandatory().CompareValue(CompareOperator.GreaterThan, 10)); - - var r = await MultiValidator.Create() - .Add(v1, new TestData { CountB = 0 }) - .Add(1.Validate("value").Configure(c => c.Between(10, 20))) - .ValidateAsync().ConfigureAwait(false); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - - Assert.That(r.Messages!, Has.Count.EqualTo(3)); - - Assert.That(r.Messages[0].Text, Is.EqualTo("Text is required.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - - Assert.That(r.Messages[1].Text, Is.EqualTo("Count B must be greater than 10.")); - Assert.That(r.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[1].Property, Is.EqualTo("CountB")); - - Assert.That(r.Messages[2].Text, Is.EqualTo("Value must be between 10 and 20.")); - Assert.That(r.Messages[2].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[2].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task MultiError2() - { - var v1 = Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory().String(10)) - .HasProperty(x => x.CountB, p => p.Mandatory().CompareValue(CompareOperator.GreaterThan, 10)); - - var r = await MultiValidator.Create() - .Add(new TestData { CountB = 0 }.Validate("value").Configure(c => c.Entity(v1))) - .Add(1.Validate("id").Configure(c => c.Between(10, 20))) - .ValidateAsync().ConfigureAwait(false); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - - Assert.That(r.Messages!, Has.Count.EqualTo(3)); - - Assert.That(r.Messages[0].Text, Is.EqualTo("Text is required.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("value.Text")); - - Assert.That(r.Messages[1].Text, Is.EqualTo("Count B must be greater than 10.")); - Assert.That(r.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[1].Property, Is.EqualTo("value.CountB")); - - Assert.That(r.Messages[2].Text, Is.EqualTo("Identifier must be between 10 and 20.")); - Assert.That(r.Messages[2].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[2].Property, Is.EqualTo("id")); - }); - - Assert.Throws(() => r.ThrowOnError()); - } - - [Test] - public async Task MultiSuccess() - { - var v1 = Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory().String(10)) - .HasProperty(x => x.CountB, p => p.Mandatory().CompareValue(CompareOperator.GreaterThan, 10)); - - var r = await MultiValidator.Create() - .Add(new TestData { Text = "XXXXXXXXXX", CountB = 11 }.Validate("value").Configure(c => c.Entity(v1))) - .Add(15.Validate("id").Configure(c => c.Between(10, 20))) - .ValidateAsync().ConfigureAwait(false); - - Assert.That(r, Is.Not.Null); - Assert.That(r.HasErrors, Is.False); - r.ThrowOnError(); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/ReferenceDataValidatorTest.cs b/tests/CoreEx.Test/Framework/Validation/ReferenceDataValidatorTest.cs deleted file mode 100644 index 64a54d4d..00000000 --- a/tests/CoreEx.Test/Framework/Validation/ReferenceDataValidatorTest.cs +++ /dev/null @@ -1,111 +0,0 @@ -using CoreEx.Entities; -using CoreEx.RefData.Extended; -using CoreEx.Validation; -using NUnit.Framework; -using System; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation -{ - [TestFixture, NonParallelizable] - public class ReferenceDataValidatorTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - public class Gender : ReferenceDataBaseEx { } - - public class GenderValidator : ReferenceDataValidatorBase { } - - [Test] - public async Task Validate_Null() - { - var r = await new ReferenceDataValidator().ValidateAsync(null!); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Value is required.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_Empty() - { - var r = await GenderValidator.Default.ValidateAsync(new Gender()); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(3)); - Assert.That(r.Messages![0].Property, Is.EqualTo("Id")); - Assert.That(r.Messages[1].Property, Is.EqualTo("Code")); - Assert.That(r.Messages[2].Property, Is.EqualTo("Text")); - }); - } - - [Test] - public async Task Validate_Dates() - { - var r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX", StartDate = new DateTime(2000, 01, 01), EndDate = new DateTime(1950, 01, 01) }); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("End Date must be greater than or equal to Start Date.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("EndDate")); - }); - } - - [Test] - public async Task Validate_SupportsDescription() - { - ReferenceDataValidation.SupportsDescription = true; - var r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX", Description = new string('x', 1001) }); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Description must not exceed 1000 characters in length.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Description")); - }); - - r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX", Description = new string('x', 500) }); - Assert.That(r, Is.Not.Null); - Assert.That(r.HasErrors, Is.False); - - r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX" }); - Assert.That(r, Is.Not.Null); - Assert.That(r.HasErrors, Is.False); - } - - [Test] - public async Task Validate_NoSupportsDescription() - { - ReferenceDataValidation.SupportsDescription = false; - var r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX", Description = "XXX" }); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Description must not be specified.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Description")); - }); - - r = await GenderValidator.Default.ValidateAsync(new Gender { Id = 1, Code = "X", Text = "XX" }); - Assert.That(r, Is.Not.Null); - Assert.That(r.HasErrors, Is.False); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/BetweenRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/BetweenRuleTest.cs deleted file mode 100644 index b01e7cef..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/BetweenRuleTest.cs +++ /dev/null @@ -1,193 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using CoreEx.Entities; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class BetweenRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate() - { - var v1 = await 1.Validate("value").Configure(c => c.Between(1, 10)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 5.Validate("value").Configure(c => c.Between(1, 10)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate("value").Configure(c => c.Between(2, 10)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between 2 and 10.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 10.Validate("value").Configure(c => c.Between(1, 10)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 11.Validate("value").Configure(c => c.Between(1, 10, "One", "Ten")).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between One and Ten.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 2.Validate("value").Configure(c => c.Between(1, 10, exclusiveBetween: true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 5.Validate("value").Configure(c => c.Between(1, 10, exclusiveBetween: true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate("value").Configure(c => c.Between(1, 10, exclusiveBetween: true)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between 1 and 10 (exclusive).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 9.Validate("value").Configure(c => c.Between(1, 10, exclusiveBetween: true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 10.Validate("value").Configure(c => c.Between(1, 10, "One", "Ten", exclusiveBetween: true)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between One and Ten (exclusive).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_Nullable() - { - int? v = null; - var v1 = await v.Validate("value").Configure(c => c.Between(1, 10)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between 1 and 10.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v = 1; - v1 = await v.Validate("value").Configure(c => c.Between(1, 10)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v = 5; - v1 = await v.Validate("value").Configure(c => c.Between(1, 10)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v = 1; - v1 = await v.Validate("value").Configure(c => c.Between(2, 10)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between 2 and 10.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v = 10; - v1 = await v.Validate("value").Configure(c => c.Between(1, 10)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v = 11; - v1 = await v.Validate("value").Configure(c => c.Between(1, 10, "One", "Ten")).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between One and Ten.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v = 2; - v1 = await v.Validate("value").Configure(c => c.Between(1, 10, exclusiveBetween: true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v = 5; - v1 = await v.Validate("value").Configure(c => c.Between(1, 10, exclusiveBetween: true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v = 1; - v1 = await v.Validate("value").Configure(c => c.Between(1, 10, exclusiveBetween: true)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between 1 and 10 (exclusive).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v = 9; - v1 = await v.Validate("value").Configure(c => c.Between(1, 10, exclusiveBetween: true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v = 10; - v1 = await v.Validate("value").Configure(c => c.Between(1, 10, "One", "Ten", exclusiveBetween: true)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between One and Ten (exclusive).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_InclusiveBetween() - { - var v1 = await 1.Validate("value").Configure(c => c.InclusiveBetween(2, 10)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between 2 and 10.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 10.Validate("value").Configure(c => c.InclusiveBetween(1, 10)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_ExclusiveBetween() - { - var v1 = await 2.Validate("value").Configure(c => c.ExclusiveBetween(2, 10)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be between 2 and 10 (exclusive).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 9.Validate("value").Configure(c => c.ExclusiveBetween(1, 10)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/CollectionRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/CollectionRuleTest.cs deleted file mode 100644 index 3e9e91d6..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/CollectionRuleTest.cs +++ /dev/null @@ -1,323 +0,0 @@ -using NUnit.Framework; -using CoreEx.Validation; -using System.Collections.Generic; -using CoreEx.Entities; -using CoreEx.Validation.Rules; -using System.Threading.Tasks; -using static CoreEx.Test.Framework.Validation.ValidatorTest; -using System; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class CollectionRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_Errors() - { - var v1 = await new int[] { 1 }.Validate("value").Configure(c => c.Collection(2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must have at least 2 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await new int[] { 1 }.Validate("value").Configure(c => c.Collection(1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new int[] { 1, 2, 3 }.Validate("value").Configure(c => c.Collection(maxCount: 2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not exceed 2 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await new int[] { 1, 2 }.Validate("value").Configure(c => c.Collection(maxCount: 2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await ((int[]?)null).Validate("value").Configure(c => c.Collection(1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await Array.Empty().Validate("value").Configure(c => c.Collection(1)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must have at least 1 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await new int[] { 1, 2, 3 }.Validate("value").Configure(c => c.Collection()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_MinCount() - { - var v1 = await new List { 1 }.Validate("value").Configure(c => c.Collection(2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must have at least 2 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_Item() - { - var iv = Validator.Create().HasProperty(x => x.Id, p => p.Mandatory()); - - var v1 = await Array.Empty().Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new TestItem[] { new() }.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Identifier is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value[0].Id")); - }); - } - - [Test] - public async Task Validate_ItemInt() - { - var iv = Validator.CreateFor(r => r.Text("Number").CompareValue(CompareOperator.LessThanEqual, 5)); - - var v1 = await new int[] { 1, 2, 3, 4, 5 }.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new int[] { 6, 2, 3, 4, 5 }.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Number must be less than or equal to 5.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value[0]")); - }); - } - - [Test] - public async Task Validate_ItemInt2() - { - var iv = Validator.CreateFor(r => r.Text("Number").LessThanOrEqualTo(5)); - - var v1 = await new int[] { 1, 2, 3, 4, 5 }.Validate("value").Configure(c => c.Collection(iv)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new int[] { 6, 2, 3, 4, 5 }.Validate("value").Configure(c => c.Collection(iv)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Number must be less than or equal to 5.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value[0]")); - }); - } - - [Test] - public async Task Validate_Item_Null() - { - var v1 = await new List { new() }.Validate("value").Configure(c => c.Collection()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new List() { null }.Validate("value").Configure(c => c.Collection()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value contains one or more items that are not specified.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await new List { null }.Validate("value").Configure(c => c.Collection(allowNullItems: true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_Item_Duplicates() - { - var iv = Validator.Create().HasProperty(x => x.Id, p => p.Mandatory()); - - var v1 = await Array.Empty().Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv).DuplicateCheck(x => x.Id))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - var tis = new TestItem[] { new() { Id = "ABC", Text = "Abc" }, new() { Id = "DEF", Text = "Def" }, new() { Id = "GHI", Text = "Ghi" } }; - - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv).DuplicateCheck(x => x.Id))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - tis[2].Id = "ABC"; - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv).DuplicateCheck(x => x.Id))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value contains duplicates; Identifier 'ABC' specified more than once.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_Item_Duplicates_PrimaryKey() - { - var iv = Validator.Create().HasProperty(x => x.Part1, p => p.Mandatory()).HasProperty(x => x.Part2, p => p.Mandatory()); - - var v1 = await Array.Empty().Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv).DuplicateCheck())).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - var tis = new TestItem2[] { new() { Part1 = "ABC", Part2 = 1, Text = "Abc" }, new() { Part1 = "DEF", Part2 = 1, Text = "Def" }, new() { Part1 = "GHI", Part2 = 1, Text = "Ghi" } }; - - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv).DuplicateCheck())).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - tis[2].Part1 = "ABC"; - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv).DuplicateCheck())).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value contains duplicates; Primary Key specified more than once.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_Item_Duplicates_Identifier() - { - var iv = Validator.Create().HasProperty(x => x.Id, p => p.Mandatory()); - - var v1 = await Array.Empty().Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv).DuplicateCheck(true))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - var tis = new TestItem[] { new() { Id = "ABC", Text = "Abc" }, new() { Id = "DEF", Text = "Def" }, new() { Id = "GHI", Text = "Ghi" } }; - - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv).DuplicateCheck(true))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - tis[2].Id = "ABC"; - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create(iv).DuplicateCheck(true))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value contains duplicates; Identifier 'ABC' specified more than once.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_Item_Duplicates_Identifier2() - { - var v1 = await Array.Empty().Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create().DuplicateCheck(true))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - var tis = new TestItem3[] { new() { Id = 1.ToGuid() }, new() { Id = 2.ToGuid() }, new() { Id = 3.ToGuid() } }; - - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create().DuplicateCheck(true))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - tis[2].Id = 1.ToGuid(); - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create().DuplicateCheck(true))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo($"Value contains duplicates; Identifier '{1.ToGuid()}' specified more than once.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - tis[2].Id = Guid.Empty; - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create().DuplicateCheck(true))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_Item_Duplicates_IgnoreInitial() - { - var v1 = await Array.Empty().Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create().DuplicateCheck(true))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - var tis = new TestItem3[] { new() { Id = Guid.Empty }, new() { Id = 2.ToGuid() }, new() { Id = Guid.Empty } }; - - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create().DuplicateCheck(true))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - tis[2].Id = 2.ToGuid(); - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create().DuplicateCheck(true))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo($"Value contains duplicates; Identifier '{2.ToGuid()}' specified more than once.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - tis[2].Id = Guid.Empty; - v1 = await tis.Validate("value").Configure(c => c.Collection(item: CollectionRuleItem.Create().DuplicateCheck(true))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_Ints_MinCount() - { - var v1 = await new int[] { 1, 2, 3, 4 }.Validate(name: "Array").Configure(c => c.MinimumCount(4)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new int[] { 1, 2, 3, 4 }.Validate(name: "Array").Configure(c => c.MinimumCount(5)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Array must have at least 5 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Array")); - }); - } - - [Test] - public async Task Validate_Ints2_MaxCount() - { - var v1 = await new int[] { 1, 2, 3, 4 }.Validate(name: "Array").Configure(c => c.MaximumCount(5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new int[] { 1, 2, 3, 4 }.Validate(name: "Array").Configure(c => c.MaximumCount(3)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Array must not exceed 3 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Array")); - }); - } - - public class TestItem3 : IIdentifier - { - public Guid Id { get; set; } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/ComparePropertyRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/ComparePropertyRuleTest.cs deleted file mode 100644 index ffd00794..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/ComparePropertyRuleTest.cs +++ /dev/null @@ -1,52 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Validation; -using NUnit.Framework; -using System; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class ComparePropertyRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate() - { - var v = Validator.Create() - .HasProperty(x => x.DateA, p => p.CompareValue(CompareOperator.GreaterThan, new DateTime(1950, 1, 1), "Minimum")) - .HasProperty(x => x.DateB, p => p.CompareProperty(CompareOperator.GreaterThanEqual, y => y.DateA)); - - // Date B will be bad. - var v1 = await v.ValidateAsync(new TestData { DateA = new DateTime(2000, 1, 1), DateB = new DateTime(1999, 1, 1) }); - Assert.That(v1, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Date B must be greater than or equal to Date A.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("DateB")); - }); - - // Date B should not validate as dependent DateA has already failed. - var v2 = await v.ValidateAsync(new TestData { DateA = new DateTime(1949, 1, 1), DateB = new DateTime(1939, 1, 1) }); - Assert.That(v2, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(v2.HasErrors, Is.True); - Assert.That(v2.Messages!, Has.Count.EqualTo(1)); - Assert.That(v2.Messages![0].Text, Is.EqualTo("Date A must be greater than Minimum.")); - Assert.That(v2.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v2.Messages[0].Property, Is.EqualTo("DateA")); - }); - - // All is a-ok. - var v3 = await v.ValidateAsync(new TestData { DateA = new DateTime(2001, 1, 1), DateB = new DateTime(2001, 1, 1) }); - Assert.That(v3, Is.Not.Null); - Assert.That(v3.HasErrors, Is.False); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/CompareValueRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/CompareValueRuleTest.cs deleted file mode 100644 index ed1f2f0d..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/CompareValueRuleTest.cs +++ /dev/null @@ -1,164 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using CoreEx.Entities; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class CompareValueRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate() - { - var v1 = await 1.Validate("value").Configure(c => c.CompareValue(CompareOperator.Equal, 1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate("value").Configure(c => c.CompareValue(CompareOperator.Equal, 2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be equal to 2.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 1.Validate("value").Configure(c => c.CompareValue(CompareOperator.GreaterThan, 0)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate("value").Configure(c => c.CompareValue(CompareOperator.GreaterThan, 2, "Two")).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be greater than Two.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_Nullable() - { - int? v = 1; - var v1 = await v.Validate("value").Configure(c => c.CompareValue(CompareOperator.Equal, 1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await v.Validate("value").Configure(c => c.CompareValue(CompareOperator.Equal, 2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be equal to 2.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_Equal() - { - var v1 = await 1.Validate("value").Configure(c => c.Equal(1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate("value").Configure(c => c.Equal(2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be equal to 2.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_NotEqual() - { - var v1 = await 1.Validate("value").Configure(c => c.NotEqual(2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate("value").Configure(c => c.NotEqual(1)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not be equal to 1.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_LessThan() - { - var v1 = await 1.Validate("value").Configure(c => c.LessThan(2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate("value").Configure(c => c.LessThan(1)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be less than 1.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_LessThanOrEqualTo() - { - var v1 = await 1.Validate("value").Configure(c => c.LessThanOrEqualTo(1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 2.Validate("value").Configure(c => c.LessThanOrEqualTo(1)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be less than or equal to 1.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_GreaterThan() - { - var v1 = await 2.Validate("value").Configure(c => c.GreaterThan(1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate("value").Configure(c => c.GreaterThan(2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be greater than 2.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_GreaterThanOrEqualTo() - { - var v1 = await 2.Validate("value").Configure(c => c.GreaterThanOrEqualTo(2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate("value").Configure(c => c.GreaterThanOrEqualTo(2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be greater than or equal to 2.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/CompareValuesRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/CompareValuesRuleTest.cs deleted file mode 100644 index 37651453..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/CompareValuesRuleTest.cs +++ /dev/null @@ -1,94 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using CoreEx.Entities; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class CompareValuesRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate() - { - var v1 = await 1.Validate("value").Configure(c => c.CompareValues(new int[] { 1, 2 })).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 1.Validate("value").Configure(c => c.CompareValues(new int[] { 2, 3 })).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_String() - { - var v1 = await "A".Validate("value").Configure(c => c.CompareValues(new string[] { "A", "B" })).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "C".Validate("value").Configure(c => c.CompareValues(new string[] { "A", "B" })).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await "a".Validate("value").Configure(c => c.CompareValues(new string[] { "A", "B" }, true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_String_Override() - { - var v = new Validator().HasProperty(x => x.Option, p => p.CompareValues(new string[] { "A", "B" }, true, true)); - - var cc = new CompareClass(); - var v1 = await v.ValidateAsync(cc); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.False); - Assert.That(cc.Option, Is.EqualTo(null)); - }); - - cc.Option = "A"; - v1 = await v.ValidateAsync(cc); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.False); - Assert.That(cc.Option, Is.EqualTo("A")); - }); - - cc.Option = "a"; - v1 = await v.ValidateAsync(cc); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.False); - Assert.That(cc.Option, Is.EqualTo("A")); - }); - - cc.Option = "c"; - v1 = await v.ValidateAsync(cc); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(cc.Option, Is.EqualTo("c")); - }); - } - - public class CompareClass - { - public string? Option { get; set; } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/CustomRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/CustomRuleTest.cs deleted file mode 100644 index 14833091..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/CustomRuleTest.cs +++ /dev/null @@ -1,29 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using CoreEx.Entities; -using System.Threading.Tasks; -using CoreEx.Results; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class CustomRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate() - { - var v1 = await "Abc".Validate("value").Configure(c => c.Custom(x => { x.CreateErrorMessage("Test"); return Result.Success; })).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Test")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/DecimalRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/DecimalRuleTest.cs deleted file mode 100644 index 8e554bd9..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/DecimalRuleTest.cs +++ /dev/null @@ -1,248 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using CoreEx.Entities; -using CoreEx.Validation.Rules; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class DecimalRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_AllowNegatives() - { - var v1 = await (123).Validate("value").Configure(c => c.Numeric()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (-123).Validate("value").Configure(c => c.Numeric()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not be negative.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await (-123).Validate("value").Configure(c => c.Numeric(true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - var v2 = await (123m).Validate("value").Configure(c => c.Numeric()).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - - v2 = await (-123m).Validate("value").Configure(c => c.Numeric()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v2.HasErrors, Is.True); - Assert.That(v2.Messages!, Has.Count.EqualTo(1)); - Assert.That(v2.Messages![0].Text, Is.EqualTo("Value must not be negative.")); - Assert.That(v2.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v2.Messages[0].Property, Is.EqualTo("value")); - }); - - v2 = await (-123m).Validate("value").Configure(c => c.Numeric(true)).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - } - - [Test] - public async Task Validate_MaxDigits() - { - var v1 = await (123).Validate("value").Configure(c => c.Numeric(maxDigits: 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (12345).Validate("value").Configure(c => c.Numeric(maxDigits: 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (123456).Validate("value").Configure(c => c.Numeric(maxDigits: 5)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not exceed 5 digits in total.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - var v2 = await (12.34m).Validate("value").Configure(c => c.Numeric(maxDigits: 5)).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - - v2 = await (12.345m).Validate("value").Configure(c => c.Numeric(maxDigits: 5)).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - - v2 = await (1.23456m).Validate("value").Configure(c => c.Numeric(maxDigits: 5)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v2.HasErrors, Is.True); - Assert.That(v2.Messages!, Has.Count.EqualTo(1)); - Assert.That(v2.Messages![0].Text, Is.EqualTo("Value must not exceed 5 digits in total.")); - Assert.That(v2.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v2.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_DecimalPlaces() - { - var v1 = await (12.3m).Validate("value").Configure(c => c.Numeric(decimalPlaces: 2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (123.400m).Validate("value").Configure(c => c.Numeric(decimalPlaces: 2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (0.123m).Validate("value").Configure(c => c.Numeric(decimalPlaces: 2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value exceeds the maximum specified number of decimal places (2).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_MaxDigits_And_DecimalPlaces() - { - var v1 = await (12.3m).Validate("value").Configure(c => c.Numeric(maxDigits: 5, decimalPlaces: 2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (123.400m).Validate("value").Configure(c => c.Numeric(maxDigits: 5, decimalPlaces: 2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (0.123m).Validate("value").Configure(c => c.Numeric(maxDigits: 5, decimalPlaces: 2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value exceeds the maximum specified number of decimal places (2).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await (1234.0m).Validate("value").Configure(c => c.Numeric(maxDigits: 5, decimalPlaces: 2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not exceed 5 digits in total.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_PrecisionScale() - { - var v1 = await (12.3m).Validate("value").Configure(c => c.PrecisionScale(precision: 5, scale: 2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (123.400m).Validate("value").Configure(c => c.PrecisionScale(precision: 5, scale: 2)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (0.123m).Validate("value").Configure(c => c.PrecisionScale(precision: 5, scale: 2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value exceeds the maximum specified number of decimal places (2).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await (1234.0m).Validate("value").Configure(c => c.PrecisionScale(precision: 5, scale: 2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not exceed 5 digits in total.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public void CalcIntegralLength() - { - Assert.Multiple(() => - { - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(0m), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(0.0000001m), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(0.9999999m), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(1.0000001m), Is.EqualTo(1)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(9.9999999m), Is.EqualTo(1)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(10.0000001m), Is.EqualTo(2)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(99.9999999m), Is.EqualTo(2)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(decimal.MaxValue), Is.EqualTo(29)); - - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(-0.0000001m), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(-0.9999999m), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(-1.0000001m), Is.EqualTo(1)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(-9.9999999m), Is.EqualTo(1)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(-10.0000001m), Is.EqualTo(2)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(-99.9999999m), Is.EqualTo(2)); - Assert.That(DecimalRuleHelper.CalcIntegerPartLength(decimal.MinValue), Is.EqualTo(29)); - }); - } - - [Test] - public void CalcDecimalPlaces() - { - Assert.Multiple(() => - { - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(0m), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(0.0000001m), Is.EqualTo(7)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(0.0001000m), Is.EqualTo(4)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(1.0000001m), Is.EqualTo(7)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(450.678m), Is.EqualTo(3)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(1500m), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(decimal.MaxValue), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(long.MaxValue + 1.0001m), Is.EqualTo(4)); - }); - - Assert.Multiple(() => - { - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(0m), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(-0.0000001m), Is.EqualTo(7)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(-0.0001000m), Is.EqualTo(4)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(-1.0000001m), Is.EqualTo(7)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(-450.678m), Is.EqualTo(3)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(-1500m), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(decimal.MinValue), Is.EqualTo(0)); - Assert.That(DecimalRuleHelper.CalcFractionalPartLength(long.MinValue - 1.0001m), Is.EqualTo(4)); - }); - } - - [Test] - public void CheckMaxDigits() - { - Assert.Multiple(() => - { - Assert.That(DecimalRuleHelper.CheckMaxDigits(0m, 5), Is.True); - Assert.That(DecimalRuleHelper.CheckMaxDigits(12345m, 5), Is.True); - Assert.That(DecimalRuleHelper.CheckMaxDigits(123.45m, 5), Is.True); - Assert.That(DecimalRuleHelper.CheckMaxDigits(1.2345m, 5), Is.True); - - Assert.That(DecimalRuleHelper.CheckMaxDigits(123456m, 5), Is.False); - Assert.That(DecimalRuleHelper.CheckMaxDigits(123.456m, 5), Is.False); - Assert.That(DecimalRuleHelper.CheckMaxDigits(1.23456m, 5), Is.False); - }); - } - - [Test] - public void CheckDecimalPlaces() - { - Assert.Multiple(() => - { - Assert.That(DecimalRuleHelper.CheckDecimalPlaces(0m, 2), Is.True); - Assert.That(DecimalRuleHelper.CheckDecimalPlaces(1.1m, 2), Is.True); - Assert.That(DecimalRuleHelper.CheckDecimalPlaces(1.12m, 2), Is.True); - Assert.That(DecimalRuleHelper.CheckDecimalPlaces(1.123m, 2), Is.False); - Assert.That(DecimalRuleHelper.CheckDecimalPlaces(1.1234m, 2), Is.False); - }); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/DictionaryRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/DictionaryRuleTest.cs deleted file mode 100644 index ec0ce8f7..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/DictionaryRuleTest.cs +++ /dev/null @@ -1,158 +0,0 @@ -using NUnit.Framework; -using CoreEx.Validation; -using System.Collections.Generic; -using CoreEx.Entities; -using CoreEx.Validation.Rules; -using System.Threading.Tasks; -using static CoreEx.Test.Framework.Validation.ValidatorTest; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class DictionaryRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate() - { - var v1 = await new Dictionary { { "k1", "v1" } }.Validate("Dict").Configure(c => c.Dictionary(2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Dict must have at least 2 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Dict")); - }); - - v1 = await new Dictionary { { "k1", "v1" } }.Validate("Dict").Configure(c => c.Dictionary(1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new Dictionary { { "k1", "v1" }, { "k2", "v2" }, { "k3", "v3" } }.Validate("Dict").Configure(c => c.Dictionary(maxCount: 2)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Dict must not exceed 2 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Dict")); - }); - - v1 = await new Dictionary { { "k1", "v1" }, { "k2", "v2" }, { "k3", "v3" } }.Validate("Dict").Configure(c => c.Dictionary(maxCount: 3)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await ((Dictionary?)null).Validate().Configure(c => c.Collection(1)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new Dictionary { }.Validate("Dict").Configure(c => c.Dictionary(1)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Dict must have at least 1 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Dict")); - }); - } - - [Test] - public async Task Validate_Value() - { - var iv = Validator.Create().HasProperty(x => x.Id, p => p.Mandatory()); - - var v1 = await new Dictionary().Validate("Dict").Configure(c => c.Dictionary(item: DictionaryRuleItem.Create(value: iv))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new Dictionary { { "k1", new TestItem() } }.Validate("Dict").Configure(c => c.Dictionary(item: DictionaryRuleItem.Create(value: iv))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Identifier is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Dict[k1].Id")); - }); - } - - [Test] - public async Task Validate_Null_Value() - { - var v1 = await new Dictionary { { "k1", new TestItem() } }.Validate("Dict").Configure(c => c.Dictionary()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new Dictionary { { "k1", null } }.Validate("Dict").Configure(c => c.Dictionary()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Dict contains one or more values that are not specified.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Dict")); - }); - - v1 = await new Dictionary { { "k1", null } }.Validate("Dict").Configure(c => c.Dictionary(allowNullValues: true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_Ints() - { - var v1 = await new Dictionary { { "k1", 1 }, { "k2", 2 }, { "k3", 3 }, { "k4", 4 } }.Validate("Dict").Configure(c => c.Dictionary(maxCount: 4)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new Dictionary { { "k1", 1 }, { "k2", 2 }, { "k3", 3 }, { "k4", 4 } }.Validate("Dict").Configure(c => c.Dictionary(maxCount: 3)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Dict must not exceed 3 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Dict")); - }); - } - - [Test] - public async Task Validate_Key() - { - var kv = Validator.CreateFor(r => r.Text("Key").Mandatory().String(2)); - - var v1 = await new Dictionary { { "k1", 1 }, { "k2", 2 } }.Validate("Dict").Configure(c => c.Dictionary(item: DictionaryRuleItem.Create(kv))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new Dictionary { { "k1", 1 }, { "k2x", 2 } }.Validate("Dict").Configure(c => c.Dictionary(kv, Validator.Null())).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Key must not exceed 2 characters in length.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Dict[k2x]")); - }); - } - - [Test] - public async Task Validate_KeyAndValue() - { - var kv = Validator.CreateFor(r => r.Text("Key").Mandatory().String(2)); - var vv = Validator.CreateFor(r => r.Mandatory().CompareValue(CompareOperator.LessThanEqual, 10)); - - var v1 = await new Dictionary { { "k1", 1 }, { "k2", 2 } }.Validate("Dict").Configure(c => c.Dictionary(item: DictionaryRuleItem.Create(kv, vv))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await new Dictionary { { "k1", 11 }, { "k2x", 2 } }.Validate("Dict").Configure(c => c.Dictionary(kv, vv)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(2)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be less than or equal to 10.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Dict[k1]")); - Assert.That(v1.Messages[1].Text, Is.EqualTo("Key must not exceed 2 characters in length.")); - Assert.That(v1.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[1].Property, Is.EqualTo("Dict[k2x]")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/DuplicateRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/DuplicateRuleTest.cs deleted file mode 100644 index eecdae0a..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/DuplicateRuleTest.cs +++ /dev/null @@ -1,44 +0,0 @@ -using NUnit.Framework; -using CoreEx.Validation; -using CoreEx.Entities; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class DuplicateRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_Value() - { - var v1 = await 123.Validate("value").Configure(c => c.Duplicate(x => false)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.Duplicate(x => true)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value already exists and would result in a duplicate.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 123.Validate("value").Configure(c => c.Duplicate(() => false)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.Duplicate(() => true)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value already exists and would result in a duplicate.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/EmailRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/EmailRuleTest.cs deleted file mode 100644 index 1f83e9e1..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/EmailRuleTest.cs +++ /dev/null @@ -1,69 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class EmailRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Email() - { - var v1 = await ((string?)null).Validate().Configure(c => c.Email()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "blah@.com".Validate().Configure(c => c.Email()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "blah.com".Validate().Configure(c => c.Email()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await "@blah.com".Validate().Configure(c => c.Email()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await "blah@".Validate().Configure(c => c.Email()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await $"mynameis@{new string('x', 250)}.com".Validate().Configure(c => c.Email()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await $"mynameis@{new string('x', 250)}.com".Validate().Configure(c => c.Email(null)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await $"mynameis@{new string('x', 500)}.com".Validate().Configure(c => c.Email(null)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task EmailAddress() - { - var v1 = await ((string?)null).Validate().Configure(c => c.EmailAddress()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "blah@.com".Validate().Configure(c => c.EmailAddress()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "blah.com".Validate().Configure(c => c.EmailAddress()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await "@blah.com".Validate().Configure(c => c.EmailAddress()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await "blah@".Validate().Configure(c => c.EmailAddress()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await $"mynameis@{new string('x', 250)}.com".Validate().Configure(c => c.EmailAddress()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); - - v1 = await $"mynameis@{new string('x', 250)}.com".Validate().Configure(c => c.EmailAddress(null)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await $"mynameis@{new string('x', 500)}.com".Validate().Configure(c => c.EmailAddress(null)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/EntityRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/EntityRuleTest.cs deleted file mode 100644 index 50c67100..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/EntityRuleTest.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Validation; -using NUnit.Framework; -using System.Threading.Tasks; -using static CoreEx.Test.Framework.Validation.ValidatorTest; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class EntityRuleTest - { - private static readonly IValidatorEx _tiv = Validator.Create().HasProperty(x => x.Id, p => p.Mandatory()); - private static readonly IValidatorEx _tev = Validator.Create().HasProperty(x => x.Item, p => p.Entity(_tiv)); - - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate() - { - var te = new TestEntity { Item = new TestItem() }; - var v1 = await te.Validate("entity", "EnTiTy").Configure(c => c.Entity(_tev)).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Identifier is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("entity.Item.Id")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/EnumRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/EnumRuleTest.cs deleted file mode 100644 index 7d04eb5e..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/EnumRuleTest.cs +++ /dev/null @@ -1,98 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Validation; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class EnumRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate() - { - var v1 = await ((AbcOption)1).Validate("value").Configure(c => c.Enum()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await ((AbcOption)88).Validate("value").Configure(c => c.Enum()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_NonNullable() - { - var ac = new AbcClass(); - var v = new Validator().HasProperty(x => x.Option, p => p.Enum()); - - var v1 = await v.ValidateAsync(ac); - Assert.That(v1.HasErrors, Is.False); - - ac.Option = AbcOption.B; - v1 = await v.ValidateAsync(ac); - Assert.That(v1.HasErrors, Is.False); - - ac.Option = ((AbcOption)404); - v1 = await v.ValidateAsync(ac); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Option is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Option")); - }); - } - - public class AbcClass - { - public AbcOption Option { get; set; } - } - - [Test] - public async Task Validate_Nullable() - { - var ac = new AbcClassN(); - var v = new Validator().HasProperty(x => x.Option, p => p.Enum()); - - var v1 = await v.ValidateAsync(ac); - Assert.That(v1.HasErrors, Is.False); - - ac.Option = AbcOption.B; - v1 = await v.ValidateAsync(ac); - Assert.That(v1.HasErrors, Is.False); - - ac.Option = ((AbcOption)404); - v1 = await v.ValidateAsync(ac); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Option is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("Option")); - }); - } - - public class AbcClassN - { - public AbcOption? Option { get; set; } - } - } - - public enum AbcOption - { - A, - B, - C - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/EnumValueRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/EnumValueRuleTest.cs deleted file mode 100644 index bdbe4536..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/EnumValueRuleTest.cs +++ /dev/null @@ -1,91 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Validation; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class EnumValueRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate() - { - var v1 = await "A".Validate("value").Configure(c => c.Enum().As()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "a".Validate("value").Configure(c => c.Enum().As()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await "a".Validate("value").Configure(c => c.Enum().As(true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "X".Validate("value").Configure(c => c.Enum().As()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await ((string?)null).Validate("value").Configure(c => c.Enum().As()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task ValidateAndOverride() - { - var v1 = await "someoptionwithcasing".Validate("value").Configure(c => c.Enum().As(true, false)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.False); - Assert.That(v1.Value, Is.EqualTo("someoptionwithcasing")); - }); - - var ec = new EnumClass { Option = "someoptionwithcasing" }; - var v2 = await new Validator().HasProperty(x => x.Option, p => p.Enum().As(true, false)).ValidateAsync(ec); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.False); - Assert.That(ec.Option, Is.EqualTo("someoptionwithcasing")); - }); - - v2 = await new Validator().HasProperty(x => x.Option, p => p.Enum().As(true, true)).ValidateAsync(ec); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.False); - Assert.That(ec.Option, Is.EqualTo("SomeOptionWithCasing")); - }); - - ec.Option = null; - v2 = await new Validator().HasProperty(x => x.Option, p => p.Enum().As(true, true)).ValidateAsync(ec); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.False); - Assert.That(ec.Option, Is.EqualTo(null)); - }); - } - - public class EnumClass - { - public string? Option { get; set; } - } - } - - public enum CaseOption - { - SomeOptionWithCasing - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/ExistsRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/ExistsRuleTest.cs deleted file mode 100644 index fc15893f..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/ExistsRuleTest.cs +++ /dev/null @@ -1,102 +0,0 @@ -using NUnit.Framework; -using CoreEx.Validation; -using CoreEx.Entities; -using System.Threading.Tasks; -using CoreEx.Results; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class ExistsRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_Value() - { - var v1 = await 123.Validate("value").Configure(c => c.Exists(x => true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.Exists(x => false)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is not found; a valid value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 123.Validate("value").Configure(c => c.Exists(true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.Exists(false)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is not found; a valid value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 123.Validate("value").Configure(c => c.ExistsAsync((_, __) => Task.FromResult(true))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.ExistsAsync((_, __) => Task.FromResult(false))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is not found; a valid value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 123.Validate("value").Configure(c => c.ValueExistsAsync((_, __) => Task.FromResult(new object()))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.ValueExistsAsync((_, __) => Task.FromResult((object?)null))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is not found; a valid value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 123.Validate("value").Configure(c => c.AgentExistsAsync((_, __) => CoreEx.Http.HttpResult.CreateAsync(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK), __))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.AgentExistsAsync((_, __) => CoreEx.Http.HttpResult.CreateAsync(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.NotFound), __))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is not found; a valid value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 123.Validate("value").Configure(c => c.AgentExistsAsync((_, __) => CoreEx.Http.HttpResult.CreateAsync(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK), cancellationToken: __))).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.AgentExistsAsync((_, __) => CoreEx.Http.HttpResult.CreateAsync(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.NotFound), cancellationToken: __))).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is not found; a valid value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 123.Validate("value").Configure(c => c.ValueExistsAsync(async (_, __) => await GetBlahAsync())).ValidateAsync(); - Assert.That(v1.HasErrors, Is.True); // Result.Success is not a valid value - } - - private static Task GetBlahAsync() => Task.FromResult(Result.Success); - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/ImmutableRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/ImmutableRuleTest.cs deleted file mode 100644 index aff7fa53..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/ImmutableRuleTest.cs +++ /dev/null @@ -1,44 +0,0 @@ -using NUnit.Framework; -using CoreEx.Validation; -using CoreEx.Entities; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class ImmutableRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_Value() - { - var v1 = await 123.Validate("value").Configure(c => c.Immutable(x => true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.Immutable(x => false)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is not allowed to change; please reset value.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 123.Validate("value").Configure(c => c.Immutable(() => true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.Immutable(() => false)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is not allowed to change; please reset value.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/MandatoryRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/MandatoryRuleTest.cs deleted file mode 100644 index dae5e463..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/MandatoryRuleTest.cs +++ /dev/null @@ -1,158 +0,0 @@ -using NUnit.Framework; -using CoreEx.Validation; -using CoreEx.Entities; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class MandatoryRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_String() - { - var v1 = await "XXX".Validate("value").Configure(c => c.Mandatory()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await ((string?)null).Validate("value").Configure(c => c.Mandatory()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await (string.Empty).Validate("value").Configure(c => c.Mandatory()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_Int32() - { - var v1 = await (123).Validate("value").Configure(c => c.Mandatory()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (0).Validate("value").Configure(c => c.Mandatory()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - var v2 = await ((int?)123).Validate("value").Configure(c => c.Mandatory()).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - - v2 = await ((int?)0).Validate("value").Configure(c => c.Mandatory()).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - - v2 = await ((int?)null).Validate("value").Configure(c => c.Mandatory()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v2.HasErrors, Is.True); - Assert.That(v2.Messages!, Has.Count.EqualTo(1)); - Assert.That(v2.Messages![0].Text, Is.EqualTo("Value is required.")); - Assert.That(v2.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v2.Messages[0].Property, Is.EqualTo("value")); - }); - } - - public class Foo - { - public string? Bar { get; set; } - } - - [Test] - public async Task Validate_Entity() - { - Foo? foo = new(); - var v1 = await foo.Validate("value").Configure(c => c.Mandatory()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - foo = null; - v1 = await foo.Validate("value").Configure(c => c.Mandatory()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_NotEmpty_String() - { - var v1 = await "XXX".Validate("value").Configure(c => c.NotEmpty()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await ((string?)null).Validate("value").Configure(c => c.NotEmpty()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await (string.Empty).Validate("value").Configure(c => c.NotEmpty()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_NotEmpty_Int32() - { - var v1 = await (123).Validate("value").Configure(c => c.NotEmpty()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (0).Validate("value").Configure(c => c.NotEmpty()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is required.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - var v2 = await ((int?)123).Validate("value").Configure(c => c.NotEmpty()).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - - v2 = await ((int?)0).Validate("value").Configure(c => c.NotEmpty()).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - - v2 = await ((int?)null).Validate("value").Configure(c => c.NotEmpty()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v2.HasErrors, Is.True); - Assert.That(v2.Messages!, Has.Count.EqualTo(1)); - Assert.That(v2.Messages![0].Text, Is.EqualTo("Value is required.")); - Assert.That(v2.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v2.Messages[0].Property, Is.EqualTo("value")); - }); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/MustRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/MustRuleTest.cs deleted file mode 100644 index 2d42e1e6..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/MustRuleTest.cs +++ /dev/null @@ -1,44 +0,0 @@ -using NUnit.Framework; -using CoreEx.Validation; -using CoreEx.Entities; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class MustRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_Value() - { - var v1 = await 123.Validate("value").Configure(c => c.Must(x => true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.Must(x => false)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await 123.Validate("value").Configure(c => c.Must(() => true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await 123.Validate("value").Configure(c => c.Must(() => false)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/NoneRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/NoneRuleTest.cs deleted file mode 100644 index b733bd67..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/NoneRuleTest.cs +++ /dev/null @@ -1,87 +0,0 @@ -using NUnit.Framework; -using CoreEx.Validation; -using CoreEx.Entities; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class NoneRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_String() - { - var v1 = await "XXX".Validate("value").Configure(c => c.None()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not be specified.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await ((string?)null).Validate("value").Configure(c => c.None()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (string.Empty).Validate("value").Configure(c => c.Validate()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await " ".Validate("value").Configure(c => c.None()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_Int32() - { - var v1 = await (123).Validate("value").Configure(c => c.None()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not be specified.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await (0).Validate("value").Configure(c => c.None()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - var v2 = await ((int?)123).Validate("value").Configure(c => c.None()).ValidateAsync(); - Assert.That(v2.HasErrors, Is.True); - - v2 = await ((int?)0).Validate("value").Configure(c => c.None()).ValidateAsync(); - Assert.That(v2.HasErrors, Is.True); - - v2 = await ((int?)null).Validate("value").Configure(c => c.None()).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - } - - public class Foo - { - public string? Bar { get; set; } - } - - [Test] - public async Task Validate_Entity() - { - Foo? foo = new(); - var v1 = await foo.Validate("value").Configure(c => c.None()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not be specified.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - foo = null; - v1 = await foo.Validate("value").Configure(c => c.None()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/NumericRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/NumericRuleTest.cs deleted file mode 100644 index 6b4c93ea..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/NumericRuleTest.cs +++ /dev/null @@ -1,50 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using CoreEx.Entities; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class NumericRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_AllowNegatives() - { - var v1 = await (123f).Validate("value").Configure(c => c.Numeric()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await (-123f).Validate("value").Configure(c => c.Numeric()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not be negative.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await (-123f).Validate("value").Configure(c => c.Numeric(true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - var v2 = await (123d).Validate("value").Configure(c => c.Numeric()).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - - v2 = await (-123d).Validate("value").Configure(c => c.Numeric()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v2.HasErrors, Is.True); - Assert.That(v2.Messages!, Has.Count.EqualTo(1)); - Assert.That(v2.Messages![0].Text, Is.EqualTo("Value must not be negative.")); - Assert.That(v2.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v2.Messages[0].Property, Is.EqualTo("value")); - }); - - v2 = await (-123d).Validate("value").Configure(c => c.Numeric(true)).ValidateAsync(); - Assert.That(v2.HasErrors, Is.False); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/OverrideRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/OverrideRuleTest.cs deleted file mode 100644 index abb26409..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/OverrideRuleTest.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using System; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class OverrideRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public void Validate_Value() - { - Assert.ThrowsAsync(async () => await 123.Validate().Configure(c => c.Override(456)).ValidateAsync()); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/ReferenceDataRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/ReferenceDataRuleTest.cs deleted file mode 100644 index 300db284..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/ReferenceDataRuleTest.cs +++ /dev/null @@ -1,182 +0,0 @@ -using CoreEx.Entities; -using CoreEx.RefData; -using CoreEx.Test.Framework.RefData; -using CoreEx.Validation; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - internal class ReferenceDataRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_RefData() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - ReferenceDataOrchestrator.SetCurrent(sp.GetRequiredService()); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - var v1 = await ((RefDataEx)"Aaa").Validate("value").Configure(c => c.IsValid()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await ((RefDataEx)"Abc").Validate("value").Configure(c => c.IsValid()).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Validate_Code() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - ReferenceDataOrchestrator.SetCurrent(sp.GetRequiredService()); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - var v1 = await "Aaa".Validate("value").Configure(c => c.RefDataCode().As()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "Abc".Validate("value").Configure(c => c.RefDataCode().As()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task SidList_Validate() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - ReferenceDataOrchestrator.SetCurrent(sp.GetRequiredService()); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - var sids = new ReferenceDataCodeList("Aaa", "Abc"); - var v1 = await sids.Validate("value").Configure(c => c.AreValid()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value contains one or more invalid items.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - sids = new ReferenceDataCodeList("Aaa", "Aaa"); - v1 = await sids.Validate("value").Configure(c => c.AreValid()).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value contains duplicates; Code 'AAA' specified more than once.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await sids.Validate("value").Configure(c => c.AreValid(allowDuplicates: true)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await sids.Validate("value").Configure(c => c.AreValid(true, 5)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must have at least 5 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await sids.Validate("value").Configure(c => c.AreValid(true, maxCount: 1)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not exceed 1 item(s).")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Entity_Sids() - { - IServiceCollection sc = new ServiceCollection(); - sc.AddLogging(); - sc.AddJsonSerializer(); - sc.AddExecutionContext(); - sc.AddScoped(); - sc.AddReferenceDataOrchestrator(); - var sp = sc.BuildServiceProvider(); - - ReferenceDataOrchestrator.SetCurrent(sp.GetRequiredService()); - - using var scope = sp.CreateScope(); - var ec = scope.ServiceProvider.GetService(); - - var rde = new RDEntity() { Refs = ["X"] }; - var v1 = await rde.Validate().Configure(c => c.Entity(new RDValidator())).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Refs contains one or more invalid items.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("rde.Refs")); - }); - } - - public class RDEntity - { - public ReferenceDataCodeList? Refs { get; set; } - } - - public class RDValidator : Validator - { - public RDValidator() - { - HasProperty(x => x.Refs, p => p.AreValid()); - } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/StringRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/StringRuleTest.cs deleted file mode 100644 index bb8d85c9..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/StringRuleTest.cs +++ /dev/null @@ -1,118 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using CoreEx.Entities; -using System.Text.RegularExpressions; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class StringRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Validate_MinLength() - { - var v1 = await "Abc".Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "Ab".Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "A".Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be at least 2 characters in length.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await string.Empty.Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await ((string?)null).Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_MaxLength() - { - var v1 = await "Abc".Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "Abcde".Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "Abcdef".Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must not exceed 5 characters in length.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await string.Empty.Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await ((string?)null).Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_Regex() - { - var r = new Regex("[a-zA-Z]$"); - var v1 = await "Abc".Validate("value").Configure(c => c.String(r)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "123".Validate("value").Configure(c => c.String(r)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value is invalid.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await string.Empty.Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await ((string?)null).Validate("value").Configure(c => c.String(2, 5)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - } - - [Test] - public async Task Validate_ExactLength() - { - var v1 = await "Abc".Validate("value").Configure(c => c.String(3, 3)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "A".Validate("value").Configure(c => c.String(3, 3)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be exactly 3 characters in length.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - - v1 = await "AAAA".Validate("value").Configure(c => c.Length(3)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value must be exactly 3 characters in length.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - } -} diff --git a/tests/CoreEx.Test/Framework/Validation/Rules/WildcardRuleTest.cs b/tests/CoreEx.Test/Framework/Validation/Rules/WildcardRuleTest.cs deleted file mode 100644 index c39c1a2f..00000000 --- a/tests/CoreEx.Test/Framework/Validation/Rules/WildcardRuleTest.cs +++ /dev/null @@ -1,44 +0,0 @@ -using CoreEx.Validation; -using NUnit.Framework; -using CoreEx.Entities; -using System.Threading.Tasks; -using CoreEx.Wildcards; - -namespace CoreEx.Test.Framework.Validation.Rules -{ - [TestFixture] - public class WildcardRuleTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task ValidateWildcard() - { - var v1 = await "xxxx".Validate("value").Configure(c => c.Wildcard()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "*xxxx".Validate("value").Configure(c => c.Wildcard()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "xxxx*".Validate("value").Configure(c => c.Wildcard()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "*xxxx*".Validate("value").Configure(c => c.Wildcard()).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "x*x".Validate("value").Configure(c => c.Wildcard(wildcard: Wildcard.MultiAll)).ValidateAsync(); - Assert.That(v1.HasErrors, Is.False); - - v1 = await "x?x".Validate("value").Configure(c => c.Wildcard(wildcard: Wildcard.MultiAll)).ValidateAsync(); - Assert.Multiple(() => - { - Assert.That(v1.HasErrors, Is.True); - Assert.That(v1.Messages!, Has.Count.EqualTo(1)); - Assert.That(v1.Messages![0].Text, Is.EqualTo("Value contains invalid or non-supported wildcard selection.")); - Assert.That(v1.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(v1.Messages[0].Property, Is.EqualTo("value")); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/TestData.cs b/tests/CoreEx.Test/Framework/Validation/TestData.cs deleted file mode 100644 index 0f3d88dc..00000000 --- a/tests/CoreEx.Test/Framework/Validation/TestData.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace CoreEx.Test.Framework.Validation -{ - public class TestDataBase - { - [JsonPropertyName("text")] - public string? Text { get; set; } - } - - public class TestData : TestDataBase - { - [JsonPropertyName("datefrom")] - public DateTime DateA { get; set; } - - [JsonPropertyName("dateto")] - public DateTime? DateB { get; set; } - - public int CountA { get; set; } - - public int? CountB { get; set; } - - public decimal AmountA { get; set; } - - public decimal? AmountB { get; set; } - - public double DoubleA { get; set; } - - public double? DoubleB { get; set; } - - public bool SwitchA { get; set; } - - public bool? SwitchB { get; set; } - - public List? Vals { get; set; } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/ValidatorTest.cs b/tests/CoreEx.Test/Framework/Validation/ValidatorTest.cs deleted file mode 100644 index 4a15be74..00000000 --- a/tests/CoreEx.Test/Framework/Validation/ValidatorTest.cs +++ /dev/null @@ -1,903 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Results; -using CoreEx.Validation; -using CoreEx.Validation.Rules; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation -{ - [TestFixture] - public class ValidatorTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Create_NewValidator() - { - var r = await Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory().String(10)) - .HasProperty(x => x.CountB, p => p.Mandatory().CompareValue(CompareOperator.GreaterThan, 10)) - .ValidateAsync(new TestData { CountB = 0 }); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - - Assert.That(r.Messages!, Has.Count.EqualTo(2)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Text is required.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - - Assert.That(r.Messages[1].Text, Is.EqualTo("Count B must be greater than 10.")); - Assert.That(r.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[1].Property, Is.EqualTo("CountB")); - }); - } - - [Test] - public async Task Create_NewValidator_WithIncludeBase() - { - var v = Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory().String(10)); - - var r = await Validator.Create() - .IncludeBase(v) - .HasProperty(x => x.CountB, p => p.Mandatory().CompareValue(CompareOperator.GreaterThan, 10)) - .ValidateAsync(new TestData { CountB = 0 }); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - - Assert.That(r.Messages!, Has.Count.EqualTo(2)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Text is required.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - - Assert.That(r.Messages[1].Text, Is.EqualTo("Count B must be greater than 10.")); - Assert.That(r.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[1].Property, Is.EqualTo("CountB")); - }); - } - - [Test] - public async Task Ruleset_UsingValidatorClass() - { - var r = await new TestItemValidator().ValidateAsync(new TestItem { Id = "A", Text = "X" }); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Description is invalid.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - }); - - r = await new TestItemValidator().ValidateAsync(new TestItem { Id = "A", Text = "A" }); - Assert.That(r.HasErrors, Is.False); - - r = await new TestItemValidator().ValidateAsync(new TestItem { Id = "B", Text = "X" }); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Description is invalid.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - }); - - r = await new TestItemValidator().ValidateAsync(new TestItem { Id = "B", Text = "B" }); - Assert.That(r.HasErrors, Is.False); - } - - [Test] - public async Task Ruleset_UsingInline() - { - var v = Validator.Create() - .HasRuleSet(x => x.Value!.Id == "A", y => - { - y.Property(x => x.Text).Mandatory().Must(x => x.Text == "A"); - }) - .HasRuleSet(x => x.Value!.Id == "B", (y) => - { - y.Property(x => x.Text).Mandatory().Must(x => x.Text == "B"); - }); - - var r = await v.ValidateAsync(new TestItem { Id = "A", Text = "X" }); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Description is invalid.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - }); - - r = await v.ValidateAsync(new TestItem { Id = "A", Text = "A" }); - Assert.That(r.HasErrors, Is.False); - - r = await v.ValidateAsync(new TestItem { Id = "B", Text = "X" }); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Description is invalid.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - }); - - r = await v.ValidateAsync(new TestItem { Id = "B", Text = "B" }); - Assert.That(r.HasErrors, Is.False); - } - - [Test] - public async Task CheckJsonNamesUsage() - { - var v = Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory()) - .HasProperty(x => x.DateA, p => p.Mandatory()) - .HasProperty(x => x.DateA, p => p.Mandatory()); - - var r = await v.ValidateAsync(new TestData(), new ValidationArgs { UseJsonNames = true }); - } - - [Test] - public async Task Override_OnValidate_WithCheckPredicate() - { - var r = await new TestItemValidator2().ValidateAsync(new TestItem(), new ValidationArgs { UseJsonNames = true }); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(2)); - - Assert.That(r.Messages![0].Text, Is.EqualTo("Identifier is invalid.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("id")); - - Assert.That(r.Messages[1].Text, Is.EqualTo("Description must not exceed 10 item(s).")); - Assert.That(r.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[1].Property, Is.EqualTo("Text")); - }); - } - - [Test] - public async Task Inline_OnValidate_WithWhen() - { - var r = await Validator.Create() - .AdditionalAsync((context, _) => - { - context.Check(x => x.Text, true, ValidatorStrings.MaxCountFormat, 10); - context.Check(x => x.Text, true, ValidatorStrings.MaxCountFormat, 10); - return Task.FromResult(Result.Success); - }).ValidateAsync(new TestItem()); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Description must not exceed 10 item(s).")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Text")); - }); - } - - [Test] - public async Task Multi_Common_Validator() - { - var cv1 = CommonValidator.Create(v => v.String(5).Must(x => x.Value != "XXXXX")); - var cv2 = CommonValidator.Create(v => v.String(2).Must(x => x.Value != "YYY")); - - var vx = Validator.Create() - .HasProperty(x => x.Id, p => p.Common(cv2)) - .HasProperty(x => x.Text, p => p.Common(cv1)); - - var r = await vx.ValidateAsync(new TestItem { Id = "YYY", Text = "XXXXX" }); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(2)); - }); - } - - [Test] - public async Task Entity_SubEntity_Mandatory() - { - var r = await Validator.Create() - .HasProperty(x => x.Items, (p) => p.Mandatory()) - .HasProperty(x => x.Item, (p) => p.Mandatory()) - .ValidateAsync(new TestEntity { Items = null }); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(2)); - }); - } - - [Test] - public async Task NonNullableString() - { - var v = Validator.Create().HasProperty(x => x.Name, p => p.Mandatory().String(10)); - var r = await v.ValidateAsync(new TestDataString("a")); - Assert.That(r, Is.Not.Null); - Assert.That(r.HasErrors, Is.False); - - r = await v.ValidateAsync(new TestDataString(null!)); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Name is required.")); - }); - } - - public class TestItemValidator : Validator - { - public TestItemValidator() - { - RuleSet(x => x.Value!.Id == "A", () => - { - Property(x => x.Text).Mandatory().Must(x => x.Text == "A"); - }); - - RuleSet(x => x.Value!.Id == "B", () => - { - Property(x => x.Text).Mandatory().Must(x => x.Text == "B"); - }); - } - } - - public class TestItemValidator2 : Validator - { - protected override Task OnValidateAsync(ValidationContext context, CancellationToken ct) - { - if (!context.HasError(x => x.Id)) - context.AddError(x => x.Id, ValidatorStrings.InvalidFormat); - - if (!context.HasError(x => x.Id)) - Assert.Fail(); - - context.Check(x => x.Text, (v) => string.IsNullOrEmpty(v), ValidatorStrings.MaxCountFormat, 10); - context.Check(x => x.Text, (v) => throw new NotFoundException(), ValidatorStrings.MaxCountFormat, 10); - return Task.FromResult(Result.Success); - } - } - - public class TestEntity - { - public List? Items { get; set; } = []; - - public TestItem? Item { get; set; } - - public Dictionary? Dict { get; set; } - - public Dictionary? Dict2 { get; set; } - } - - public class TestItem : IIdentifier - { - public string? Id { get; set; } - - [JsonPropertyName("Text")] - [System.ComponentModel.DataAnnotations.Display(Name = "Description")] - public string? Text { get; set; } - } - - public class TestItem2 : IPrimaryKey - { - public string? Part1 { get; set; } - - public int Part2 { get; set; } - - [JsonPropertyName("Text")] - [System.ComponentModel.DataAnnotations.Display(Name = "Description")] - public string? Text { get; set; } - - public CompositeKey PrimaryKey => new(Part1, Part2); - - } - - public class TestDataString(string name) - { - public string Name { get; set; } = name; - } - - [Test] - public async Task Create_NewValidator_CollectionDuplicate() - { - var e = new TestEntity(); - e.Items!.Add(new TestItem { Id = "ABC", Text = "Abc" }); - e.Items.Add(new TestItem { Id = "DEF", Text = "Abc" }); - e.Items.Add(new TestItem { Id = "ABC", Text = "Def" }); - e.Items.Add(new TestItem { Id = "XYZ", Text = "Xyz" }); - - var v = Validator.Create(); - - var r = await Validator.Create() - .HasProperty(x => x.Items, p => p.Collection(item: CollectionRuleItem.Create(v).DuplicateCheck(y => y.Id))) - .ValidateAsync(e); - - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Items contains duplicates; Identifier 'ABC' specified more than once.")); - Assert.That(r.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Property, Is.EqualTo("Items")); - }); - } - - [Test] - public void ThrowValidationException_NoArgs() - { - try - { - Validator.Create().ThrowValidationException(x => x.Id, "Some text."); - Assert.Fail(); - } - catch (ValidationException vex) - { - Assert.Multiple(() => - { - Assert.That(vex.Messages![0].Text, Is.EqualTo("Some text.")); - Assert.That(vex.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(vex.Messages[0].Property, Is.EqualTo("Id")); - }); - } - } - - [Test] - public void ThrowValidationException_WithArgs() - { - try - { - Validator.Create().ThrowValidationException(x => x.Id, "{0} {1} {2} Stuff.", "XXX", "ZZZ"); - Assert.Fail(); - } - catch (ValidationException vex) - { - Assert.Multiple(() => - { - Assert.That(vex.Messages![0].Text, Is.EqualTo("Identifier XXX ZZZ Stuff.")); - Assert.That(vex.Messages[0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(vex.Messages[0].Property, Is.EqualTo("Id")); - }); - } - } - - private class TestInject - { - public string? Text { get; set; } - public object? Value { get; set; } - } - - public class TestInjectChild - { - public int Code { get; set; } - } - - [Test] - public async Task ManualProperty_Inject() - { - var vx = await Validator.Create() - .HasProperty(x => x.Text, p => p.Mandatory()) - .HasProperty(x => x.Value, p => p.Mandatory().Custom(TestInjectValueValidate)) - .ValidateAsync(new TestInject { Text = "X", Value = new TestInjectChild { Code = 5 } }); - - Assert.Multiple(() => - { - Assert.That(vx.Messages!, Has.Count.EqualTo(1)); - Assert.That(vx.Messages![0].Text, Is.EqualTo("Code must be greater than 10.")); - Assert.That(vx.Messages[0].Property, Is.EqualTo("Value.Code")); - }); - } - - private Result TestInjectValueValidate(PropertyContext context) - { - var vxc = Validator.Create() - .HasProperty(x => x.Code, p => p.Mandatory().CompareValue(CompareOperator.GreaterThan, 10)); - - var type = vxc.GetType(); - var mi = type.GetMethod("ValidateAsync")!; - var vc = ((Task>)mi.Invoke(vxc, [context.Value, context.CreateValidationArgs(), System.Threading.CancellationToken.None])!).GetAwaiter().GetResult(); - context.Parent.MergeResult(vc); - return Result.Success; - } - - [Test] - public async Task Entity_ValueOverrideAndDefault() - { - var vc = CommonValidator.Create(v => v.Default(100)); - - var ti = new TestData { Text = "ABC", CountA = 1 }; - - var vx = await Validator.Create() - .HasProperty(x => x.Text, p => p.Override("XYZ")) - .HasProperty(x => x.CountA, p => p.Default(x => 10)) - .HasProperty(x => x.CountB, p => p.Default(x => 20)) - .HasProperty(x => x.AmountA, p => p.Common(vc)) - .ValidateAsync(ti); - - Assert.Multiple(() => - { - Assert.That(vx.HasErrors, Is.False); - Assert.That(ti.Text, Is.EqualTo("XYZ")); - Assert.That(ti.CountA, Is.EqualTo(1)); - Assert.That(ti.CountB, Is.EqualTo(20)); - Assert.That(ti.AmountA, Is.EqualTo(100)); - }); - } - - public class Employee - { - public string? FirstName { get; set; } - public string? LastName { get; set; } - public DateTime Birthdate { get; set; } - public decimal Salary { get; set; } - public int WorkingYears { get; set; } - } - - public class EmployeeValidator : Validator - { - public EmployeeValidator() - { - Property(x => x.FirstName).Mandatory().String(100); - Property(x => x.LastName).Mandatory().String(100); - Property(x => x.Birthdate).Mandatory().CompareValue(CompareOperator.LessThanEqual, DateTime.UtcNow, "today"); - Property(x => x.Salary).Mandatory().Numeric(allowNegatives: false, maxDigits: 10, decimalPlaces: 2); - Property(x => x.WorkingYears).Numeric(allowNegatives: false).CompareValue(CompareOperator.LessThanEqual, 50); - } - } - - [Test] - public void Entity_ValueCachePerfSync() - { - InstantiateValidators(); - } - - private static void InstantiateValidators() - { - for (int i = 0; i < 1000; i++) - { - _ = new EmployeeValidator(); - } - } - - [Test] - public void Entity_ValueCachePerfAsync() - { - var tasks = new Task[10]; - for (int i = 0; i < 10; i++) - { - tasks[i] = Task.Run(() => InstantiateValidators()); - } - - Task.WaitAll(tasks); - } - - [Test] - public async Task Coll_Validator_MaxCount() - { - var vxc = Validator.CreateForCollection>(minCount: 1, maxCount: 2, item: CollectionRuleItem.Create(new TestItemValidator())); - var tc = new List { new() { Id = "A", Text = "aaa" }, new() { Id = "B", Text = "bbb" }, new() { Id = "C", Text = "ccc" } }; - - var r = await tc.Validate(vxc, null).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(3)); - Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Text, Is.EqualTo("Description is invalid.")); - Assert.That(r.Messages[0].Property, Is.EqualTo("value[0].Text")); - Assert.That(r.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[1].Text, Is.EqualTo("Description is invalid.")); - Assert.That(r.Messages[1].Property, Is.EqualTo("value[1].Text")); - Assert.That(r.Messages[2].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[2].Text, Is.EqualTo("Value must not exceed 2 item(s).")); - Assert.That(r.Messages[2].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Coll_Validator_MinCount() - { - var vxc = Validator.CreateForCollection, TestItem>(new TestItemValidator(), minCount: 3); - var tc = new List { new() { Id = "A", Text = "A" }, new() { Id = "B", Text = "B" } }; - - var r = await tc.Validate(vxc, null).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Text, Is.EqualTo("Value must have at least 3 item(s).")); - Assert.That(r.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Coll_Validator_MinCount2() - { - var vxc = Validator.CreateFor>().Configure(c => c.Collection(new TestItemValidator(), minCount: 3)); - var tc = new List { new() { Id = "A", Text = "A" }, new() { Id = "B", Text = "B" } }; - - var r = await tc.Validate(vxc, null).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Text, Is.EqualTo("Value must have at least 3 item(s).")); - Assert.That(r.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Coll_Validator_Duplicate() - { - var vxc = Validator.CreateForCollection>(item: CollectionRuleItem.Create(new TestItemValidator()).DuplicateCheck(x => x.Id)); - var tc = new List { new() { Id = "A", Text = "A" }, new() { Id = "A", Text = "A" } }; - - var r = await tc.Validate(vxc, null).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Text, Is.EqualTo("Value contains duplicates; Identifier 'A' specified more than once.")); - Assert.That(r.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Coll_Validator_OK() - { - var vxc = Validator.CreateForCollection>(minCount: 1, maxCount: 2, item: CollectionRuleItem.Create(new TestItemValidator()).DuplicateCheck(x => x.Id)); - var tc = new List { new() { Id = "A", Text = "A" }, new() { Id = "B", Text = "B" } }; - - var r = await tc.Validate(vxc, null).ValidateAsync(); - - Assert.That(r.HasErrors, Is.False); - } - - [Test] - public async Task Coll_Validator_Int_OK() - { - var vxc = Validator.CreateForCollection>(minCount: 1, maxCount: 5); - var ic = new List { 1, 2, 3, 4, 5 }; - - var r = await ic.Validate(vxc, null).ValidateAsync(); - - Assert.That(r.HasErrors, Is.False); - } - - [Test] - public async Task Coll_Validator_Int_Error() - { - var vxc = Validator.CreateFor>(v => v.Collection(1, 3)); - var ic = new List { 1, 2, 3, 4, 5 }; - - var r = await ic.Validate(vxc, null).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Text, Is.EqualTo("Value must not exceed 3 item(s).")); - Assert.That(r.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Dict_Validator_MaxCount() - { - var vxd = Validator.CreateForDictionary>(minCount: 1, maxCount: 2, item: DictionaryRuleItem.Create(value: new TestItemValidator())); - var tc = new Dictionary { { "k1", new TestItem { Id = "A", Text = "aaa" } }, { "k2", new TestItem { Id = "B", Text = "bbb" } }, { "k3", new TestItem { Id = "C", Text = "ccc" } } }; - - var r = await tc.Validate(vxd, null).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(3)); - Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Text, Is.EqualTo("Description is invalid.")); - Assert.That(r.Messages[0].Property, Is.EqualTo("value[k1].Text")); - Assert.That(r.Messages[1].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[1].Text, Is.EqualTo("Description is invalid.")); - Assert.That(r.Messages[1].Property, Is.EqualTo("value[k2].Text")); - Assert.That(r.Messages[2].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[2].Text, Is.EqualTo("Value must not exceed 2 item(s).")); - Assert.That(r.Messages[2].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Dict_Validator_MinCount() - { - var vxd = Validator.CreateForDictionary>(minCount: 3, item: DictionaryRuleItem.Create(value: new TestItemValidator())); - var tc = new Dictionary { { "k1", new TestItem { Id = "A", Text = "A" } }, { "k2", new TestItem { Id = "B", Text = "B" } } }; - - var r = await tc.Validate(vxd, null).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Text, Is.EqualTo("Value must have at least 3 item(s).")); - Assert.That(r.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Dict_Validator_OK() - { - var vxd = Validator.CreateForDictionary, string, TestItem>(new TestItemValidator(), minCount: 2); - var tc = new Dictionary { { "k1", new TestItem { Id = "A", Text = "A" } }, { "k2", new TestItem { Id = "B", Text = "B" } } }; - - var r = await tc.Validate(vxd, null).ValidateAsync(); - - Assert.That(r.HasErrors, Is.False); - } - - [Test] - public async Task Dict_Validator_Int_OK() - { - var vxd = Validator.CreateForDictionary>(minCount: 1, maxCount: 5); - var id = new Dictionary { { "k1", 1 }, { "k2", 2 }, { "k3", 3 }, { "k4", 4 }, { "k5", 5 } }; - - var r = await id.Validate(vxd, null).ValidateAsync(); - - Assert.That(r.HasErrors, Is.False); - } - - [Test] - public async Task Dict_Validator_Int_Error() - { - var vxd = Validator.CreateForDictionary>(minCount: 1, maxCount: 3); - var id = new Dictionary { { "k1", 1 }, { "k2", 2 }, { "k3", 3 }, { "k4", 4 }, { "k5", 5 } }; - - var r = await id.Validate(vxd, null).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Text, Is.EqualTo("Value must not exceed 3 item(s).")); - Assert.That(r.Messages[0].Property, Is.EqualTo("value")); - }); - } - - [Test] - public async Task Dict_Validator_KeyError() - { - var kv = CommonValidator.Create(x => x.Text("Key").Mandatory().String(2)); - var vxd = Validator.CreateForDictionary, string, TestItem>(kv, new TestItemValidator(), minCount: 2); - var tc = new Dictionary { { "k1", new TestItem { Id = "A", Text = "A" } }, { "k2x", new TestItem { Id = "B", Text = "B" } } }; - - var r = await tc.Validate(vxd, null).ValidateAsync(); - - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Text, Is.EqualTo("Key must not exceed 2 characters in length.")); - Assert.That(r.Messages[0].Property, Is.EqualTo("value[k2x]")); - }); - } - - [Test] - public async Task Validator_Perf_SameValidator() - { - var ev = new EmployeeValidator(); - var v = new Employee { FirstName = "Speedy", LastName = "Fasti", Birthdate = new DateTime(1999, 10, 22), Salary = 51000m, WorkingYears = 20 }; - - await ev.ValidateAsync(v).ConfigureAwait(false); - - var sw = System.Diagnostics.Stopwatch.StartNew(); - - for (int i = 0; i < 100000; i++) - { - var r = await ev.ValidateAsync(v).ConfigureAwait(false); - r.ThrowOnError(); - } - - sw.Stop(); - System.Console.WriteLine($"100K validations - elapsed: {sw.Elapsed.TotalMilliseconds}ms (per {sw.Elapsed.TotalMilliseconds / 100000}ms)"); - } - - [Test] - public void Required() - { - var vex = Assert.Throws(() => 0.Required()); - Assert.That(vex, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(vex!.Messages, Is.Not.Null); - Assert.That(vex.Messages!, Has.Count.EqualTo(1)); - Assert.That(vex.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(vex.Messages[0].Text, Is.EqualTo("0 is required.")); - Assert.That(vex.Messages[0].Property, Is.EqualTo("0")); - }); - - var count = 0; - vex = Assert.Throws(() => count.Required()); - Assert.That(vex, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(vex!.Messages, Is.Not.Null); - Assert.That(vex.Messages!, Has.Count.EqualTo(1)); - Assert.That(vex.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(vex.Messages[0].Text, Is.EqualTo("Count is required.")); - Assert.That(vex.Messages[0].Property, Is.EqualTo("count")); - }); - - vex = Assert.Throws(() => 0.Required("count")); - Assert.That(vex, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(vex!.Messages, Is.Not.Null); - Assert.That(vex.Messages!, Has.Count.EqualTo(1)); - Assert.That(vex.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(vex.Messages[0].Text, Is.EqualTo("Count is required.")); - Assert.That(vex.Messages[0].Property, Is.EqualTo("count")); - }); - - vex = Assert.Throws(() => 0.Required("count", "Counter")); - Assert.That(vex, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(vex!.Messages, Is.Not.Null); - Assert.That(vex.Messages!, Has.Count.EqualTo(1)); - Assert.That(vex.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(vex.Messages[0].Text, Is.EqualTo("Counter is required.")); - Assert.That(vex.Messages[0].Property, Is.EqualTo("count")); - }); - - vex = Assert.Throws(() => 0.Required("numberOfPlayers")); - Assert.That(vex, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(vex!.Messages, Is.Not.Null); - Assert.That(vex.Messages!, Has.Count.EqualTo(1)); - Assert.That(vex.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(vex.Messages[0].Text, Is.EqualTo("Number Of Players is required.")); - Assert.That(vex.Messages[0].Property, Is.EqualTo("numberOfPlayers")); - }); - - vex = Assert.Throws(() => 0.Required("id")); - Assert.That(vex, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(vex!.Messages, Is.Not.Null); - Assert.That(vex.Messages!, Has.Count.EqualTo(1)); - Assert.That(vex.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(vex.Messages[0].Text, Is.EqualTo("Identifier is required.")); - Assert.That(vex.Messages[0].Property, Is.EqualTo("id")); - }); - - vex = Assert.Throws(() => 0.Required()); - Assert.That(vex, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(vex!.Messages, Is.Not.Null); - Assert.That(vex.Messages!, Has.Count.EqualTo(1)); - Assert.That(vex.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(vex.Messages[0].Text, Is.EqualTo("0 is required.")); - Assert.That(vex.Messages[0].Property, Is.EqualTo("0")); - - Assert.That(123.Required(), Is.EqualTo(123)); - }); - } - - [Test] - public async Task Validator_FailureResult() - { - var ev = new EmployeeValidator2(); - var v = new Employee { FirstName = "Speedy", LastName = "Fasti", Birthdate = new DateTime(1999, 10, 22), Salary = 51000m, WorkingYears = 20 }; - - var r = await ev.ValidateAsync(v).ConfigureAwait(false); - Assert.That(r, Is.Not.Null); - Assert.That(r.HasErrors, Is.False); - - v.Salary += 88000; - r = await ev.ValidateAsync(v).ConfigureAwait(false); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.FailureResult, Is.Not.Null); - }); - Assert.That(r.FailureResult!.Value.Error, Is.Not.Null.And.TypeOf()); - - Assert.Throws(() => r.ThrowOnError()); - } - - public class EmployeeValidator2 : Validator - { - public EmployeeValidator2() - { - Property(x => x.FirstName).Mandatory().String(100); - Property(x => x.LastName).Mandatory().String(100); - Property(x => x.Birthdate).Mandatory().CompareValue(CompareOperator.LessThanEqual, DateTime.UtcNow, "today"); - Property(x => x.Salary).Mandatory().Numeric(allowNegatives: false, maxDigits: 10, decimalPlaces: 2); - Property(x => x.WorkingYears).Numeric(allowNegatives: false).CompareValue(CompareOperator.LessThanEqual, 50); - } - - protected override async Task OnValidateAsync(ValidationContext context, CancellationToken cancellationToken) - { - if (context.Value.Salary > 88000m) - return Result.ConflictError("Highly paid individual already exists."); - - return await base.OnValidateAsync(context, cancellationToken); - } - } - - public class TeamLeader - { - public Employee? Person { get; set; } - - public string? TeamName { get; set; } - } - - public class TeamLeaderValidator : Validator - { - public TeamLeaderValidator() - { - Property(x => x.Person).Mandatory().Entity(new EmployeeValidator2()); - Property(x => x.TeamName).Mandatory().String(20); - } - } - - [Test] - public async Task Validator_Nested_FailureResult() - { - var tlv = new TeamLeaderValidator(); - var v = new TeamLeader { Person = new Employee { FirstName = "Speedy", LastName = "Fasti", Birthdate = new DateTime(1999, 10, 22), Salary = 51000m, WorkingYears = 20 }, TeamName = "Bananas" }; - - var r = await tlv.ValidateAsync(v).ConfigureAwait(false); - Assert.That(r, Is.Not.Null); - Assert.That(r.HasErrors, Is.False); - - v.TeamName += " and Oranges and Apples and Kiwi Fruit"; - r = await tlv.ValidateAsync(v).ConfigureAwait(false); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Type, Is.EqualTo(MessageType.Error)); - Assert.That(r.Messages[0].Text, Is.EqualTo("Team Name must not exceed 20 characters in length.")); - }); - - v.Person.Salary += 88000; - r = await tlv.ValidateAsync(v).ConfigureAwait(false); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.FailureResult, Is.Not.Null); - }); - Assert.That(r.FailureResult!.Value.Error, Is.Not.Null.And.TypeOf()); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Validation/ValueValidatorTest.cs b/tests/CoreEx.Test/Framework/Validation/ValueValidatorTest.cs deleted file mode 100644 index 0ca6a8a5..00000000 --- a/tests/CoreEx.Test/Framework/Validation/ValueValidatorTest.cs +++ /dev/null @@ -1,52 +0,0 @@ -using CoreEx.Results; -using CoreEx.Validation; -using NUnit.Framework; -using System.Threading.Tasks; - -namespace CoreEx.Test.Framework.Validation -{ - [TestFixture] - public class ValueValidatorTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => CoreEx.Localization.TextProvider.SetTextProvider(new ValidationTextProvider()); - - [Test] - public async Task Run_With_NotNull() - { - string name = "George"; - var r = await name.Validate().Configure(c => c.Mandatory().String(50)).ValidateAsync(); - Assert.That(r, Is.Not.Null); - Assert.That(r.HasErrors, Is.False); - } - - [Test] - public async Task Run_With_Null() - { - string? name = null; - var r = await name.Validate().Configure(c => c.Mandatory().String(50)).ValidateAsync(); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.Messages!, Has.Count.EqualTo(1)); - Assert.That(r.Messages![0].Text, Is.EqualTo("Name is required.")); - }); - } - - [Test] - public async Task Validate_With_FailureResult() - { - string name = "Bill"; - var r = await name.Validate().Configure(c => c.Mandatory().Custom(ctx => Result.Go().When(() => ctx.Value == "Bill", () => Result.NotFoundError())).String(5)).ValidateAsync(); - Assert.That(r, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(r.HasErrors, Is.True); - Assert.That(r.FailureResult, Is.Not.Null); - }); - Assert.That(r.FailureResult!.Value.Error, Is.Not.Null.And.TypeOf()); - Assert.Throws(() => r.ThrowOnError()); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiPublisherTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiPublisherTest.cs deleted file mode 100644 index 4311b20e..00000000 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiPublisherTest.cs +++ /dev/null @@ -1,567 +0,0 @@ -using CoreEx.Events; -using CoreEx.Results; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Models; -using CoreEx.AspNetCore.WebApis; -using NUnit.Framework; -using System.Net.Http; -using System.Threading.Tasks; -using UnitTestEx; -using CoreEx.Mapping; -using Microsoft.Extensions.DependencyInjection; -using CoreEx.Hosting.Work; -using System; -using Microsoft.AspNetCore.Mvc; -using System.Net; - -namespace CoreEx.Test.Framework.WebApis -{ - [TestFixture] - public class WebApiPublisherTest - { - [OneTimeSetUp] - public void OneTimeSetUp() => UnitTestEx.Abstractions.CoreExOneOffTestSetUp.ForceSetUp(); - - [Test] - public void PublishAsync_NoValue() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .Type() - .Run(f => f.PublishAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), new WebApiPublisherArgs("test"))) - .ToActionResultAssertor() - .AssertAccepted(); - - var qn = imp.GetNames(); - Assert.That(qn, Has.Length.EqualTo(1)); - Assert.That(qn[0], Is.EqualTo("test")); - - var ed = imp.GetEvents("test"); - Assert.That(ed, Has.Length.EqualTo(1)); - } - - [Test] - public void PublishAsync_Value_Success() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .Type() - .Run(f => f.PublishAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", new Product { Id = "A", Name = "B", Price = 1.99m }), new WebApiPublisherArgs("test"))) - .ToActionResultAssertor() - .AssertAccepted(); - - var qn = imp.GetNames(); - Assert.That(qn, Has.Length.EqualTo(1)); - Assert.That(qn[0], Is.EqualTo("test")); - - var ed = imp.GetEvents("test"); - Assert.That(ed, Has.Length.EqualTo(1)); - ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, ed[0].Value); - } - - [Test] - public void PublishAsync_Value_Error() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .Type() - .Run(f => f.PublishAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), new WebApiPublisherArgs("test"))) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: Value is mandatory."); - - var qn = imp.GetNames(); - Assert.That(qn, Is.Empty); - } - - [Test] - public void PublishAsync_Value_Mapper() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .ConfigureServices(sc => sc.AddMappers()) - .Type() - .Run(f => f.PublishAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", new Product { Id = "A", Name = "B", Price = 1.99m }), new WebApiPublisherArgs("test"))) - .ToActionResultAssertor() - .AssertAccepted(); - - var qn = imp.GetNames(); - Assert.That(qn, Has.Length.EqualTo(1)); - Assert.That(qn[0], Is.EqualTo("test")); - - var ed = imp.GetEvents("test"); - Assert.That(ed, Has.Length.EqualTo(1)); - ObjectComparer.Assert(new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m }, ed[0].Value); - } - - [Test] - public void PublishCollectionAsync_Success() - { - var products = new ProductCollection - { - new Product { Id = "Xyz", Name = "Widget", Price = 9.95m }, - new Product { Id = "Xyz2", Name = "Widget2", Price = 9.95m }, - new Product { Id = "Xyz3", Name = "Widget3", Price = 9.95m } - }; - - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .Type() - .Run(f => f.PublishCollectionAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", products), new WebApiPublisherCollectionArgs("test"))) - .ToActionResultAssertor() - .AssertAccepted(); - - var qn = imp.GetNames(); - Assert.That(qn, Has.Length.EqualTo(1)); - Assert.That(qn[0], Is.EqualTo("test")); - - var ed = imp.GetEvents("test"); - Assert.That(ed, Has.Length.EqualTo(3)); - ObjectComparer.Assert(products[0], ed[0].Value); - ObjectComparer.Assert(products[1], ed[1].Value); - ObjectComparer.Assert(products[2], ed[2].Value); - } - - [Test] - public void PublishCollectionAsync_Success_Mapper() - { - var products = new ProductCollection - { - new Product { Id = "Xyz", Name = "Widget", Price = 9.95m }, - new Product { Id = "Xyz2", Name = "Widget2", Price = 9.95m }, - new Product { Id = "Xyz3", Name = "Widget3", Price = 9.95m } - }; - - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .ConfigureServices(sc => sc.AddMappers()) - .Type() - .Run(f => f.PublishCollectionAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", products), new WebApiPublisherCollectionArgs("test"))) - .ToActionResultAssertor() - .AssertAccepted(); - - var qn = imp.GetNames(); - Assert.That(qn, Has.Length.EqualTo(1)); - Assert.That(qn[0], Is.EqualTo("test")); - - var ed = imp.GetEvents("test"); - Assert.That(ed, Has.Length.EqualTo(3)); - ObjectComparer.Assert(new BackendProduct { Code = "Xyz", Description = "Widget", RetailPrice = 9.95m }, ed[0].Value); - ObjectComparer.Assert(new BackendProduct { Code = "Xyz2", Description = "Widget2", RetailPrice = 9.95m }, ed[1].Value); - ObjectComparer.Assert(new BackendProduct { Code = "Xyz3", Description = "Widget3", RetailPrice = 9.95m }, ed[2].Value); - } - - [Test] - public void PublishCollectionAsync_Success_WithCorrelationId() - { - var products = new ProductCollection - { - new Product { Id = "Xyz", Name = "Widget", Price = 9.95m }, - new Product { Id = "Xyz2", Name = "Widget2", Price = 9.95m }, - new Product { Id = "Xyz3", Name = "Widget3", Price = 9.95m } - }; - - using var test = FunctionTester.Create(); - var imp = new InMemoryPublisher(); - var hr = test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", products); - hr.Headers.Add("x-correlation-id", "corr-id"); // Send through a known correlation id. - - test.ReplaceScoped(_ => imp) - .Type() - .Run(f => f.PublishCollectionAsync(hr, new WebApiPublisherCollectionArgs("test"))) - .ToActionResultAssertor() - .AssertAccepted(); - - var qn = imp.GetNames(); - Assert.That(qn, Has.Length.EqualTo(1)); - Assert.That(qn[0], Is.EqualTo("test")); - - var ed = imp.GetEvents("test"); - Assert.That(ed, Has.Length.EqualTo(3)); - ObjectComparer.Assert(products[0], ed[0].Value); - ObjectComparer.Assert(products[1], ed[1].Value); - ObjectComparer.Assert(products[2], ed[2].Value); - - Assert.Multiple(() => - { - // Assert the known correlation id. - Assert.That(ed[0].CorrelationId, Is.EqualTo("corr-id")); - Assert.That(ed[1].CorrelationId, Is.EqualTo("corr-id")); - Assert.That(ed[2].CorrelationId, Is.EqualTo("corr-id")); - }); - } - - [Test] - public void PublishCollectionAsync_SizeError() - { - var products = new ProductCollection - { - new Product { Id = "Xyz", Name = "Widget", Price = 9.95m }, - new Product { Id = "Xyz2", Name = "Widget2", Price = 9.95m }, - new Product { Id = "Xyz3", Name = "Widget3", Price = 9.95m } - }; - - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .Type() - .Run(f => f.PublishCollectionAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", products), new WebApiPublisherCollectionArgs("test") { MaxCollectionSize = 2 })) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("The publish collection contains 3 items where only a maximum size of 2 is supported."); - - var qn = imp.GetNames(); - Assert.That(qn, Is.Empty); - } - - [Test] - public void PublishAsync_BeforeError() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .Type() - .Run(f => f.PublishAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", new Product { Id = "Xyz", Name = "Widget", Price = 9.95m }), - new WebApiPublisherArgs { EventName = "test", OnBeforeEventAsync = (_, __) => throw new BusinessException("Nope, nope!") })) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("Nope, nope!"); - - var qn = imp.GetNames(); - Assert.That(qn, Is.Empty); - } - - [Test] - public void PublishAsync_BeforeErrorResult() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .Type() - .Run(f => f.PublishAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", new Product { Id = "Xyz", Name = "Widget", Price = 9.95m }), - new WebApiPublisherArgs { EventName = "test", OnBeforeEventAsync = (_, __) => Task.FromResult(Result.Fail("Nope, nope!")) })) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("Nope, nope!"); - - var qn = imp.GetNames(); - Assert.That(qn, Is.Empty); - } - - [Test] - public void PublishCollectionAsync_BeforeError() - { - var products = new ProductCollection - { - new Product { Id = "Xyz", Name = "Widget", Price = 9.95m }, - new Product { Id = "Xyz2", Name = "Widget2", Price = 9.95m }, - new Product { Id = "Xyz3", Name = "Widget3", Price = 9.95m } - }; - - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .Type() - .Run(f => f.PublishCollectionAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", products), new WebApiPublisherCollectionArgs("test") { OnBeforeEventAsync = (_, __) => throw new BusinessException("Nope, nope!") })) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("Nope, nope!"); - - var qn = imp.GetNames(); - Assert.That(qn, Is.Empty); - } - - [Test] - public void PublishCollectionAsync_BeforeErrorResult() - { - var products = new ProductCollection - { - new Product { Id = "Xyz", Name = "Widget", Price = 9.95m }, - new Product { Id = "Xyz2", Name = "Widget2", Price = 9.95m }, - new Product { Id = "Xyz3", Name = "Widget3", Price = 9.95m } - }; - - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - test.ReplaceScoped(_ => imp) - .Type() - .Run(f => f.PublishCollectionAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", products), new WebApiPublisherCollectionArgs("test") { OnBeforeEventAsync = (_, __) => Task.FromResult(Result.Fail("Nope, nope!")) })) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("Nope, nope!"); - - var qn = imp.GetNames(); - Assert.That(qn, Is.Empty); - } - - [Test] - public void Publish_With_Work_State() - { - var wso = new WorkStateOrchestrator(new InMemoryWorkStatePersistence()); - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - - var ws = test.ReplaceScoped(_ => imp) - .Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.PublishAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", new Product { Id = "A", Name = "B", Price = 1.99m }), - new WebApiPublisherArgs("test") { CreateLocation = (_, @event) => new Uri($"status/{@event.Id}", UriKind.Relative) }.WithWorkState()); - }) - .ToActionResultAssertor() - .AssertAccepted() - .AssertLocationHeaderContains("status/") - .GetValue(); - - var qn = imp.GetNames(); - Assert.That(qn, Has.Length.EqualTo(1)); - Assert.That(qn[0], Is.EqualTo("test")); - - var ed = imp.GetEvents("test"); - Assert.That(ed, Has.Length.EqualTo(1)); - ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, ed[0].Value); - - var ws2 = wso.GetAsync(ed[0].Id!).Result; - Assert.That(ws2, Is.Not.Null); - Assert.That(ws2!.Status, Is.EqualTo(WorkStatus.Created)); - - ObjectComparer.Assert(ws, ws2); - } - - [Test] - public void GetWorkStatus_NotFound() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => - { - f.WorkStateOrchestrator = new WorkStateOrchestrator(new InMemoryWorkStatePersistence()); - return f.GetWorkStatusAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherStatusArgs("test", "abc")); - }) - .ToActionResultAssertor() - .AssertNotFound(); - } - - [Test] - public void GetWorkStatus_InProgress_OK() - { - var wso = new WorkStateOrchestrator(new InMemoryWorkStatePersistence()); - var ws = wso.CreateAsync(new WorkStateArgs("test", "abc")).Result; - - using var test = FunctionTester.Create(); - - // Status of created. - var ara = test.Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.GetWorkStatusAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherStatusArgs("test", "abc")); - }) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(ws) - .AssertResultType(); - - var vcr = ara.Result as ValueContentResult; - Assert.That(vcr?.RetryAfter, Is.EqualTo(TimeSpan.FromSeconds(30))); - - // Status of started. - ws = wso.StartAsync(ws.Id!).Result; - test.Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.GetWorkStatusAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherStatusArgs("test", "abc")); - }) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(ws); - - // Status of indeterminate. - ws = wso.IndeterminateAsync(ws.Id!, "Oh no!").Result; - test.Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.GetWorkStatusAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherStatusArgs("test", "abc")); - }) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(ws); - } - - [Test] - public void GetWorkStatus_Fail_BadRequest() - { - var wso = new WorkStateOrchestrator(new InMemoryWorkStatePersistence()); - var ws = wso.CreateAsync(new WorkStateArgs("test", "abc")).Result; - ws = wso.StartAsync("abc").Result; - ws = wso.FailAsync("abc", "bad-request").Result; - - using var test = FunctionTester.Create(); - - test.Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.GetWorkStatusAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherStatusArgs("test", "abc")); - }) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertValue(ws); - } - - [Test] - public void GetWorkStatus_Completed() - { - var wso = new WorkStateOrchestrator(new InMemoryWorkStatePersistence()); - var ws = wso.CreateAsync(new WorkStateArgs("test", "abc")).Result; - ws = wso.StartAsync("abc").Result; - ws = wso.CompleteAsync("abc").Result; - - using var test = FunctionTester.Create(); - - var ara = test.Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.GetWorkStatusAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherStatusArgs("test", "abc") { CreateResultLocation = ws => new Uri($"/result/{ws.Id}", UriKind.Relative) }); - }) - .ToActionResultAssertor() - .Assert(HttpStatusCode.Redirect) - .AssertLocationHeader(new Uri($"/result/{ws.Id}", UriKind.Relative)); - } - - [Test] - public void GetWorkResult_NotFound() - { - var wso = new WorkStateOrchestrator(new InMemoryWorkStatePersistence()); - - using var test = FunctionTester.Create(); - - test.Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.GetWorkResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherResultArgs("test", "abc")); - }) - .ToActionResultAssertor() - .AssertNotFound(); - } - - [Test] - public void GetWorkResult_NoValue() - { - var wso = new WorkStateOrchestrator(new InMemoryWorkStatePersistence()); - var ws = wso.CreateAsync(new WorkStateArgs("test", "abc")).Result; - ws = wso.StartAsync("abc").Result; - ws = wso.CompleteAsync("abc").Result; - - using var test = FunctionTester.Create(); - - test.Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.GetWorkResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherResultArgs("test", "abc")); - }) - .ToActionResultAssertor() - .AssertNoContent(); - } - - [Test] - public void GetWorkResult_WithValue() - { - var p = new Product { Id = "A", Name = "B", Price = 1.99m }; - - var wso = new WorkStateOrchestrator(new InMemoryWorkStatePersistence()); - var ws = wso.CreateAsync(new WorkStateArgs("test", "abc")).Result; - ws = wso.StartAsync("abc").Result; - ws = wso.CompleteAsync("abc").Result; - wso.SetDataAsync(ws.Id!, p).Wait(); - - using var test = FunctionTester.Create(); - - test.Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.GetWorkResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherResultArgs("test", "abc")); - }) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(p); - } - - [Test] - public void CancelWorkStatus_AlreadyFailed() - { - var wso = new WorkStateOrchestrator(new InMemoryWorkStatePersistence()); - var ws = wso.CreateAsync(new WorkStateArgs("test", "abc")).Result; - ws = wso.StartAsync("abc").Result; - ws = wso.FailAsync("abc", "bad-request").Result; - - using var test = FunctionTester.Create(); - - // Status of created. - test.Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.CancelWorkStatusAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherCancelArgs("test", "abc")); - }) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("A cancellation can not be performed when the current status is Failed."); - } - - [Test] - public void CancelWorkStatus_Success() - { - var wso = new WorkStateOrchestrator(new InMemoryWorkStatePersistence()); - var ws = wso.CreateAsync(new WorkStateArgs("test", "abc")).Result; - ws = wso.StartAsync("abc").Result; - - using var test = FunctionTester.Create(); - - ws = test.Type() - .Run(f => - { - f.WorkStateOrchestrator = wso; - return f.CancelWorkStatusAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/status/abc"), new WebApiPublisherCancelArgs("test", "abc")); - }) - .ToActionResultAssertor() - .AssertOK() - .GetValue(); - - Assert.That(ws, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(ws!.Status, Is.EqualTo(WorkStatus.Canceled)); - Assert.That(ws.Reason, Is.EqualTo("No reason was specified.")); - }); - } - } - - // Demonstrates a hard-coded mapper. - public class ProductMapper : Mapper - { - protected override BackendProduct? OnMap(Product? s, BackendProduct? d, OperationTypes operationType) - { - if (s is null || d is null) - return d; - - d.Code = s.Id!; - d.Description = s.Name; - d.RetailPrice = s.Price; - return d; - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs deleted file mode 100644 index cf8459fe..00000000 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiRequestOptionsTest.cs +++ /dev/null @@ -1,104 +0,0 @@ -using CoreEx.AspNetCore.Http; -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.TestFunction; -using NUnit.Framework; -using System.Net.Http; -using UnitTestEx; -using HttpRequestOptions = CoreEx.Http.HttpRequestOptions; - -namespace CoreEx.Test.Framework.WebApis -{ - [TestFixture] - public class WebApiRequestOptionsTest - { - [Test] - public void GetRequestOptions_None() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest"); - - var wro = hr.GetRequestOptions(); - - Assert.That(wro, Is.Not.Null); - Assert.That(wro.Request, Is.SameAs(hr)); - Assert.Multiple(() => - { - Assert.That(wro.ETag, Is.Null); - Assert.That(wro.IncludeText, Is.False); - Assert.That(wro.IncludeInactive, Is.False); - Assert.That(wro.IncludeFields, Is.Null); - Assert.That(wro.ExcludeFields, Is.Null); - Assert.That(wro.Paging, Is.Null); - }); - } - - [Test] - public void GetRequestOptions_Configured() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest"); - var ro = new HttpRequestOptions() { ETag = "etag-value", IncludeText = true, IncludeInactive = true, UrlQueryString = "fruit=apples" }.WithPaging(PagingArgs.CreateSkipAndTake(20, 25, true)).Include("fielda", "fieldb").Exclude("fieldc"); - - hr.ApplyRequestOptions(ro); - Assert.That(hr.QueryString.Value, Is.EqualTo("?$skip=20&$take=25&$count=true&$fields=fielda,fieldb&$exclude=fieldc&$text=true&$inactive=true&fruit=apples")); - - var wro = hr.GetRequestOptions(); - - Assert.That(wro, Is.Not.Null); - Assert.That(wro.Request, Is.SameAs(hr)); - Assert.Multiple(() => - { - Assert.That(wro.ETag, Is.EqualTo("etag-value")); - Assert.That(wro.IncludeText, Is.True); - Assert.That(wro.IncludeInactive, Is.True); - Assert.That(wro.IncludeFields, Is.EqualTo(new string[] { "fielda", "fieldb" })); - Assert.That(wro.ExcludeFields, Is.EqualTo(new string[] { "fieldc" })); - Assert.That(wro.Paging, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(wro.Paging!.Skip, Is.EqualTo(20)); - Assert.That(wro.Paging.Take, Is.EqualTo(25)); - Assert.That(wro.Paging.IsGetCount, Is.True); - }); - } - - - [Test] - public void GetRequestOptions_Configured_TokenPaging() - { - PagingArgs.IsTokenSupported = true; - - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest"); - var ro = new HttpRequestOptions { ETag = "etag-value", IncludeText = true, IncludeInactive = true, UrlQueryString = "fruit=apples" }.WithPaging(PagingArgs.CreateTokenAndTake("token", 25, true)).Include("fielda", "fieldb").Exclude("fieldc"); - - hr.ApplyRequestOptions(ro); - Assert.That(hr.QueryString.Value, Is.EqualTo("?$token=token&$take=25&$count=true&$fields=fielda,fieldb&$exclude=fieldc&$text=true&$inactive=true&fruit=apples")); - - var wro = hr.GetRequestOptions(); - - Assert.That(wro, Is.Not.Null); - Assert.That(wro.Request, Is.SameAs(hr)); - Assert.Multiple(() => - { - Assert.That(wro.ETag, Is.EqualTo("etag-value")); - Assert.That(wro.IncludeText, Is.True); - Assert.That(wro.IncludeInactive, Is.True); - Assert.That(wro.IncludeFields, Is.EqualTo(new string[] { "fielda", "fieldb" })); - Assert.That(wro.ExcludeFields, Is.EqualTo(new string[] { "fieldc" })); - Assert.That(wro.Paging, Is.Not.Null); - }); - Assert.Multiple(() => - { - Assert.That(wro.Paging!.Token, Is.EqualTo("token")); - Assert.That(wro.Paging.Take, Is.EqualTo(25)); - Assert.That(wro.Paging.IsGetCount, Is.True); - }); - } - - [TearDown] - public void TearDown() => PagingArgs.IsTokenSupported = false; - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs deleted file mode 100644 index 16ebcf95..00000000 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs +++ /dev/null @@ -1,733 +0,0 @@ -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.Results; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Models; -using CoreEx.AspNetCore.WebApis; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using UnitTestEx; -using HttpRequestOptions = CoreEx.Http.HttpRequestOptions; -using CoreEx.AspNetCore.Http; - -namespace CoreEx.Test.Framework.WebApis -{ - [TestFixture] - public class WebApiTest - { - [Test] - public void RunAsync_Success() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest"), r => Task.FromResult((IActionResult)new StatusCodeResult(200)))) - .ToActionResultAssertor() - .AssertOK() - .Assert(HttpStatusCode.OK); - } - - [Test] - public void RunAsync_CorrelationId() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Post, "https://unittest"); - hr.Headers.Add("x-correlation-id", "corr-id"); - - test.Type() - .Run(f => f.RunAsync(hr, r => { Assert.That(ExecutionContext.Current.CorrelationId, Is.EqualTo("corr-id")); return Task.FromResult((IActionResult)new StatusCodeResult(200)); })) - .ToActionResultAssertor() - .AssertOK() - .Assert(HttpStatusCode.OK); - } - - [Test] - public void RunAsync_ValidationException1() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), _ => throw new ValidationException())) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertValue("A data validation error occurred."); // TODO: this is wonky! - } - - [Test] - public void RunAsync_ValidationException2() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), _ => - { - var mic = new MessageItemCollection(); - mic.AddPropertyError("Test", "Invalid."); - throw new ValidationException(mic); - })) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertErrors(new ApiError("Test", "Invalid.")); - } - - [Test] - public void RunAsync_ValidationException_NoCatchAndHandleExceptions() - { - using var test = FunctionTester.Create().ReplaceScoped(_ => new WebApiInvoker { CatchAndHandleExceptions = false }); - test.Type() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), _ => throw new ValidationException())) - .AssertException(); - } - - [Test] - public void RunAsync_TransientException() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), _ => throw new TransientException())) - .ToActionResultAssertor() - .Assert(HttpStatusCode.ServiceUnavailable); - } - - [Test] - public void RunAsync_UnhandledException() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), _ => throw new DivideByZeroException())) - .ToActionResultAssertor() - .Assert(HttpStatusCode.InternalServerError); - } - - [Test] - public void RunAsync_UnhandledException_NoCatchAndHandleExceptions() - { - using var test = FunctionTester.Create().ReplaceScoped(_ => new WebApiInvoker { CatchAndHandleExceptions = false }); - test.Type() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), _ => throw new DivideByZeroException())) - .AssertException(); - } - - [Test] - public void RunAsync_WithValue() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.RunAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", new { id = "A", name = "B", price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, r.Value); return Task.FromResult((IActionResult)new StatusCodeResult(201)); })) - .ToActionResultAssertor() - .AssertCreated(); - } - - [Test] - public void GetAsync_NoResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest"), r => Task.FromResult(null))) - .ToActionResultAssertor() - .Assert(HttpStatusCode.NotFound); - } - - [Test] - public void GetAsync_WithResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult("it-worked"))) - .ToActionResultAssertor() - .AssertOK() - .AssertValue("it-worked"); - } - - [Test] - public void GetAsync_WithETag() - { - using var test = FunctionTester.Create(); - var vcr = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) - .ToActionResultAssertor() - .AssertOK() - .Result as ValueContentResult; - - Assert.That(vcr, Is.Not.Null); - Assert.That(vcr!.ETag, Is.EqualTo("my-etag")); - - // Second time should be the same. - vcr = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) - .ToActionResultAssertor() - .AssertOK() - .Result as ValueContentResult; - - Assert.That(vcr, Is.Not.Null); - Assert.That(vcr!.ETag, Is.EqualTo("my-etag")); - - // However, if a query string, then etag will need to be generated, as it possibly can influence result. - vcr = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) - .ToActionResultAssertor() - .AssertOK() - .Result as ValueContentResult; - - Assert.That(vcr, Is.Not.Null); - Assert.That(vcr!.ETag, Is.Not.EqualTo("my-etag")); - - var vcr2 = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) - .ToActionResultAssertor() - .AssertOK() - .Result as ValueContentResult; - - Assert.That(vcr2, Is.Not.Null); - Assert.That(vcr2!.ETag, Is.EqualTo(vcr.ETag)); - } - - [Test] - public void GetAsync_WithETagValue() - { - using var test = FunctionTester.Create(); - var vcr = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) - .ToActionResultAssertor() - .AssertOK() - .Result as ValueContentResult; - - Assert.That(vcr, Is.Not.Null); - Assert.That(vcr!.ETag, Is.EqualTo("my-etag")); - } - - [Test] - public void GetAsync_WithETagValueNotModified() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"); - hr.Headers.Add(HeaderNames.IfMatch, "\\W\"my-etag\""); - - test.Type() - .Run(f => f.GetAsync(hr, r => Task.FromResult(new Person { Id = 1, Name = "Angela", ETag = "my-etag" }))) - .ToActionResultAssertor() - .AssertNotModified(); - } - - [Test] - public void GetAsync_WithGenETagValue() - { - using var test = FunctionTester.Create(); - var vcr = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(new Person { Id = 1, Name = "Angela" }))) - .ToActionResultAssertor() - .AssertOK() - .Result as ValueContentResult; - - Assert.That(vcr, Is.Not.Null); - Assert.That(vcr!.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); - - var p = test.JsonSerializer.Deserialize(vcr.Content!); - Assert.That(p, Is.Not.Null); - Assert.That(p!.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); - } - - [Test] - public void GetAsync_WithGenETagValue_QueryString() - { - using var test = FunctionTester.Create(); - var vcr = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new Person { Id = 1, Name = "Angela" }))) - .ToActionResultAssertor() - .AssertOK() - .Result as ValueContentResult; - - Assert.That(vcr, Is.Not.Null); - Assert.That(vcr!.ETag, Is.EqualTo("cpDn3xugV1xKSHF9AY4kQRNQ1yC/SU49xC66C92WZbE=")); - - var p = test.JsonSerializer.Deserialize(vcr.Content!); - Assert.That(p, Is.Not.Null); - Assert.That(p!.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); - } - - [Test] - public void GetAsync_WithGenETagValueNotModified() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"); - hr.Headers.Add(HeaderNames.IfMatch, "\\W\"cpDn3xugV1xKSHF9AY4kQRNQ1yC/SU49xC66C92WZbE=\""); - - test.Type() - .Run(f => f.GetAsync(hr, r => Task.FromResult(new Person { Id = 1, Name = "Angela" }))) - .ToActionResultAssertor() - .AssertNotModified(); - } - - [Test] - public void GetAsync_WithCollectionNull() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(null!), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertNoContent(); - } - - [Test] - public void GetAsync_WithCollectionEmpty() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new PersonCollection()), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection()); - } - - [Test] - public void GetAsync_WithCollectionResultNull() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"); - - test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(null!), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertNoContent(); - } - - [Test] - public void GetAsync_WithCollectionResultNullCollection() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new PersonCollectionResult()), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(Array.Empty()); - } - - [Test] - public void GetAsync_WithCollectionResultItems() - { - using var test = FunctionTester.Create(); - var r = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new PersonCollectionResult { Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } }), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Id = 1, Name = "Simon" } }); - } - - [Test] - public void GetAsync_WithCollectionResultItemsAndPaging() - { - using var test = FunctionTester.Create(); - var r = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new PersonCollectionResult { Paging = new PagingResult(PagingArgs.CreateSkipAndTake(2, 3), 20), Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } }), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Id = 1, Name = "Simon" } }); - - Assert.That(((ValueContentResult)r.Result).PagingResult, Is.EqualTo(new PagingResult(PagingArgs.CreateSkipAndTake(2, 3), 20))); - } - - [Test] - public void GetAsync_WithCollectionResultItems_ETagDiffQueryString() - { - using var test = FunctionTester.Create(); - var r = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(new PersonCollectionResult { Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } }), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Id = 1, Name = "Simon" } }); - - var r2 = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=oranges"), r => Task.FromResult(new PersonCollectionResult { Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } }), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Id = 1, Name = "Simon" } }); - - Assert.That(((ValueContentResult)r2.Result).ETag, Is.Not.EqualTo(((ValueContentResult)r.Result).ETag)); - } - - [Test] - public void GetAsync_WithCollection_FieldsInclude() - { - using var test = FunctionTester.Create(); - var r = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples&$fields=name"), r => Task.FromResult(new PersonCollectionResult { Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } }), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Name = "Simon" } }); - } - - [Test] - public void GetAsync_WithCollection_FieldsExclude() - { - using var test = FunctionTester.Create(); - var r = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples&$exclude=name"), r => Task.FromResult(new PersonCollectionResult { Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } }), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Id = 1 } }); - } - - [Test] - public void GetAsync_WithMessages_ErrorStatusCode() - { - using var test = FunctionTester.Create(); - var result = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest"), r => - { - ExecutionContext.Current.Messages.Add(MessageType.Warning, "Please renew licence."); - return Task.FromResult(null); - })) - .ToActionResultAssertor() - .Assert(HttpStatusCode.NotFound) - .Result as StatusCodeResult; - - Assert.That(result, Is.Not.Null); - } - - [Test] - public void GetAsync_WithMessages_SuccessStatusCode() - { - using var test = FunctionTester.Create(); - var result = test.Type() - .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest"), r => - { - ExecutionContext.Current.Messages.Add(MessageType.Warning, "Please renew licence."); - return Task.FromResult("This is ok."); - })) - .ToActionResultAssertor() - .Assert(HttpStatusCode.OK) - .Result as ValueContentResult; - - Assert.That(result, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(result!.Messages, Is.Not.Null); - Assert.That(result.Messages, Has.Count.EqualTo(1)); - }); - Assert.Multiple(() => - { - Assert.That(result!.Messages![0].Type, Is.EqualTo(MessageType.Warning)); - Assert.That(result.Messages[0].Text, Is.EqualTo("Please renew licence.")); - }); - } - - [Test] - public void PostAsync_NoValueNoResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PostAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), r => Task.CompletedTask)) - .ToActionResultAssertor() - .AssertOK(); - } - - [Test] - public void PostAsync_NoValueWithResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PostAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), r => Task.FromResult(new Product { Id = "A", Name = "B", Price = 1.99m }))) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Product { Id = "A", Name = "B", Price = 1.99m }); - } - - [Test] - public void PostAsync_WithValueNoResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PostAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", new { id = "A", name = "B", price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, r.Value); return Task.CompletedTask; })) - .ToActionResultAssertor() - .AssertOK(); - } - - [Test] - public void PostAsync_WithValueWithResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PostAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", new { id = "A", name = "B", price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, r.Value); return Task.FromResult(new Product { Id = "Y", Name = "Z", Price = 3.01m }); })) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Product { Id = "Y", Name = "Z", Price = 3.01m }); - } - - [Test] - public void PutAsync_WithValueNoResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PutAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest", new { id = "A", name = "B", price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, r.Value); return Task.CompletedTask; })) - .ToActionResultAssertor() - .AssertNoContent(); - } - - [Test] - public void PutAsync_WithValueWithResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PutAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest", new { id = "A", name = "B", price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, r.Value); return Task.FromResult(new Product { Id = "Y", Name = "Z", Price = 3.01m }); })) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Product { Id = "Y", Name = "Z", Price = 3.01m }); - } - - [Test] - public void PutAsync_AutoConcurrency_NoIfMatch() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PutAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest", new { id = "A", name = "B", price = 2.99m }), - r => Task.FromResult(new Product { Id = "A", Name = "B", Price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 2.99m }, r.Value); return Task.FromResult(new Product { Id = "Y", Name = "Z", Price = 3.99m }); }, - simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("An 'If-Match' header is required for an HTTP PUT where the underlying entity supports concurrency (ETag)."); - } - - [Test] - public void PutAsync_AutoConcurrency_NoMatch() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PutAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest", new { id = "A", name = "B", price = 2.99m }, new HttpRequestOptions { ETag = "bbb" }), - r => Task.FromResult(new Product { Id = "A", Name = "B", Price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 2.99m }, r.Value); return Task.FromResult(new Product { Id = "Y", Name = "Z", Price = 3.99m }); }, - simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("A concurrency error occurred; please refresh the data and try again."); - } - - [Test] - public void PutAsync_AutoConcurrency_Match() - { - using var test = FunctionTester.Create(); - - test.Type() - .Run(f => f.PutAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest", new { id = "A", name = "B", price = 2.99m }, new HttpRequestOptions { ETag = "98Oe+fRzgTuVae59mLwf0Mj+iKySTlgUxEQt18huJZg=" }), - r => Task.FromResult(new Product { Id = "A", Name = "B", Price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 2.99m }, r.Value); return Task.FromResult(new Product { Id = "Y", Name = "Z", Price = 3.99m }); }, - simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Product { Id = "Y", Name = "Z", Price = 3.99m }); - } - - [Test] - public void DeleteAsync() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.DeleteAsync(test.CreateHttpRequest(HttpMethod.Delete, "https://unittest"), _ => Task.CompletedTask)) - .ToActionResultAssertor() - .AssertNoContent(); - } - - [Test] - public void PatchAsync_WithInvalidContentType() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Patch, "https://unittest"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(null), put: _ => Task.FromResult(null!))) - .ToActionResultAssertor() - .Assert(HttpStatusCode.UnsupportedMediaType) - .AssertContent("Unsupported 'Content-Type' for a PATCH; only JSON Merge Patch is supported using either: 'application/merge-patch+json' or 'application/json'."); - } - - [Test] - public void PatchAsync_WithNullJson() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, null); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(null), put: _ => Task.FromResult(null!))) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: Value is mandatory."); - } - - [Test] - public void PatchAsync_WithBadJson() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, ""); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(null), put: _ => Task.FromResult(null!))) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("'<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0."); - } - - [Test] - public void PatchAsync_WithNoETag() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ }"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(new Person()), put: _ => Task.FromResult(null!))) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("An 'If-Match' header is required for an HTTP PATCH where the underlying entity supports concurrency (ETag)."); - } - - [Test] - public void PatchAsync_WithETagHeader_ThenNotFound() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ }", "aaaa"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(null), put: _ => Task.FromResult(null!))) - .ToActionResultAssertor() - .AssertNotFound(); - } - - [Test] - public void PatchAsync_WithETagProperty_ThenNotFound() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"etag\": \"aaa\"}"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(null), put: _ => Task.FromResult(null!))) - .ToActionResultAssertor() - .AssertNotFound(); - } - - [Test] - public void PatchAsync_WithETagHeader_NotMatched() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ }", "aaa"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(new Person { ETag = "bbb" }), put: _ => Task.FromResult(null!))) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("A concurrency error occurred; please refresh the data and try again."); - } - - [Test] - public void PatchAsync_WithETagProperty_NotMatched() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"etag\": \"aaa\"}"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(new Person { ETag = "bbb" }), put: _ => Task.FromResult(null!))) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("A concurrency error occurred; please refresh the data and try again."); - } - - [Test] - public void PatchAsync_WithETagHeader_PutConcurrency() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"bob\" }", "aaa"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(new Person { ETag = "aaa" }), put: _ => throw new ConcurrencyException())) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("A concurrency error occurred; please refresh the data and try again."); - } - - [Test] - public void PatchAsync_WithETagHeader_SimulateDuplicate_WasMergedWithChanges() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"bob\" }", "aaa"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(new Person { Name = "bobby", ETag = "aaa" }), put: _ => throw new DuplicateException())) - .ToActionResultAssertor() - .AssertConflict(); - } - - [Test] - public void PatchAsync_WithETagHeader_OK_WithNoMergeChanges() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"bob\" }", "aaa"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(new Person { Name = "bob", ETag = "aaa" }), put: _ => throw new ConcurrencyException())) - .ToActionResultAssertor() - .AssertOK(); - } - - [Test] - public void PatchAsync_WithETagHeader_OK_SimulateChanged() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"bobby\" }", "aaa"); - test.Type() - .Run(f => f.PatchAsync(hr, - get: _ => Task.FromResult(new Person { Name = "bob", ETag = "aaa" }), - put: p => { ObjectComparer.Assert(new Person { Name = "bobby", ETag = "aaa" }, p.Value); p.Value!.ETag = "bbb"; return Task.FromResult(p.Value!); })) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Person { Name = "bobby", ETag = "bbb" }) - .AssertETagHeader("bbb"); - } - - [Test] - public void PatchAsync_AutoConcurrency_NoIfMatchHeader() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"Gazza\" }"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(new Person { Id = 13, Name = "Deano" }), put: _ => Task.FromResult(null!), simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("An 'If-Match' header is required for an HTTP PATCH where the underlying entity supports concurrency (ETag)."); - } - - [Test] - public void PatchAsync_AutoConcurrency_NotMatched() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"Gazza\" }", etag: "bbb"); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(new Person { Id = 13, Name = "Deano" }), put: _ => Task.FromResult(null!), simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("A concurrency error occurred; please refresh the data and try again."); - } - - [Test] - public void PatchAsync_AutoConcurrency_Matched() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"Gazza\" }", etag: "Q8nNyU0hP+j7+1tDN0JzLGMcfPOX8OsLAh7lma4U0xo="); - test.Type() - .Run(f => f.PatchAsync(hr, get: _ => Task.FromResult(new Person { Id = 13, Name = "Deano" }), put: _ => Task.FromResult(new Person { Id = 13, Name = "Gazza" }), simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Person { Id = 13, Name = "Gazza", ETag = "tEEokPXk+4Q5MoiGqyAs1+6A00e2ww59Zm57LJgvBcg=" }); - } - - private static HttpRequest CreatePatchRequest(UnitTestEx.Azure.Functions.FunctionTester test, string? json, string? etag = null) - => test.CreateHttpRequest(HttpMethod.Patch, "https://unittest", json, HttpConsts.MergePatchMediaTypeName, hr => hr.ApplyRequestOptions(new HttpRequestOptions { ETag = etag })); - - private class Person : IIdentifier, IETag - { - public int Id { get; set; } - public string? Name { get; set; } - public string? ETag { get; set; } - } - - private class PersonCollection : List { } - - private class PersonCollectionResult : CollectionResult { } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs b/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs deleted file mode 100644 index 0547e0b7..00000000 --- a/tests/CoreEx.Test/Framework/WebApis/WebApiWithResultTest.cs +++ /dev/null @@ -1,618 +0,0 @@ -using CoreEx.AspNetCore.Http; -using CoreEx.AspNetCore.WebApis; -using CoreEx.Entities; -using CoreEx.Http; -using CoreEx.Results; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Models; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using UnitTestEx; -using HttpRequestOptions = CoreEx.Http.HttpRequestOptions; - -namespace CoreEx.Test.Framework.WebApis -{ - [TestFixture] - public class WebApiWithResultTest - { - [Test] - public void GetWithResultAsync_NoResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest"), r => Task.FromResult(Result.Ok(null)))) - .ToActionResultAssertor() - .Assert(HttpStatusCode.NotFound); - } - - [Test] - public void GetWithResultAsync_WithResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok("it-worked")))) - .ToActionResultAssertor() - .AssertOK() - .AssertValue("it-worked"); - } - - [Test] - public void GetWithResultAsync_WithETagValue() - { - using var test = FunctionTester.Create(); - var vcr = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela", ETag = "my-etag" })))) - .ToActionResultAssertor() - .AssertOK() - .Result as ValueContentResult; - - Assert.That(vcr, Is.Not.Null); - Assert.That(vcr!.ETag, Is.EqualTo("my-etag")); - } - - [Test] - public void GetWithResultAsync_WithETagValueNotModified() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"); - hr.Headers.Add(HeaderNames.IfMatch, "\\W\"my-etag\""); - - test.Type() - .Run(f => f.GetWithResultAsync(hr, r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela", ETag = "my-etag" })))) - .ToActionResultAssertor() - .AssertNotModified(); - } - - [Test] - public void GetWithResultAsync_WithGenETagValue() - { - using var test = FunctionTester.Create(); - var vcr = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela" })))) - .ToActionResultAssertor() - .AssertOK() - .Result as ValueContentResult; - - Assert.That(vcr, Is.Not.Null); - Assert.That(vcr!.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); - - var p = test.JsonSerializer.Deserialize(vcr.Content!); - Assert.That(p, Is.Not.Null); - Assert.That(p!.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); - } - - [Test] - public void GetWithResultAsync_WithGenETagValue_QueryString() - { - using var test = FunctionTester.Create(); - var vcr = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela" })))) - .ToActionResultAssertor() - .AssertOK() - .Result as ValueContentResult; - - Assert.That(vcr, Is.Not.Null); - Assert.That(vcr!.ETag, Is.EqualTo("cpDn3xugV1xKSHF9AY4kQRNQ1yC/SU49xC66C92WZbE=")); - - var p = test.JsonSerializer.Deserialize(vcr.Content!); - Assert.That(p, Is.Not.Null); - Assert.That(p!.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM=")); - } - - [Test] - public void GetWithResultAsync_WithGenETagValueNotModified() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"); - hr.Headers.Add(HeaderNames.IfMatch, "\\W\"cpDn3xugV1xKSHF9AY4kQRNQ1yC/SU49xC66C92WZbE=\""); - - test.Type() - .Run(f => f.GetWithResultAsync(hr, r => Task.FromResult(Result.Ok(new Person { Id = 1, Name = "Angela" })))) - .ToActionResultAssertor() - .AssertNotModified(); - } - - [Test] - public void GetWithResultAsync_WithCollectionNull() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(null!)), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertNoContent(); - } - - [Test] - public void GetWithResultAsync_WithCollectionEmpty() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(new PersonCollection())), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection()); - } - - [Test] - public void GetWithResultAsync_WithCollectionResultNull() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"); - - test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(null!)), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertNoContent(); - } - - [Test] - public void GetWithResultAsync_WithCollectionResultNullCollection() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(new PersonCollectionResult())), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(Array.Empty()); - } - - [Test] - public void GetWithResultAsync_WithCollectionResultItems() - { - using var test = FunctionTester.Create(); - var r = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(new PersonCollectionResult { Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } })), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Id = 1, Name = "Simon" } }); - } - - [Test] - public void GetWithResultAsync_WithCollectionResultItemsAndPaging() - { - using var test = FunctionTester.Create(); - var r = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(new PersonCollectionResult { Paging = new PagingResult(PagingArgs.CreateSkipAndTake(2, 3), 20), Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } })), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Id = 1, Name = "Simon" } }); - - Assert.That(((ValueContentResult)r.Result).PagingResult, Is.EqualTo(new PagingResult(PagingArgs.CreateSkipAndTake(2, 3), 20))); - } - - [Test] - public void GetWithResultAsync_WithCollectionResultItems_ETagDiffQueryString() - { - using var test = FunctionTester.Create(); - var r = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Ok(new PersonCollectionResult { Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } })), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Id = 1, Name = "Simon" } }); - - var r2 = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=oranges"), r => Task.FromResult(Result.Ok(new PersonCollectionResult { Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } })), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Id = 1, Name = "Simon" } }); - - Assert.That(((ValueContentResult)r2.Result).ETag, Is.Not.EqualTo(((ValueContentResult)r.Result).ETag)); - } - - [Test] - public void GetWithResultAsync_WithCollection_FieldsInclude() - { - using var test = FunctionTester.Create(); - var r = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples&$fields=name"), r => Task.FromResult(Result.Ok(new PersonCollectionResult { Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } })), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Name = "Simon" } }); - } - - [Test] - public void GetWithResultAsync_WithCollection_FieldsExclude() - { - using var test = FunctionTester.Create(); - var r = test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples&$exclude=name"), r => Task.FromResult(Result.Ok(new PersonCollectionResult { Items = new PersonCollection { new Person { Id = 1, Name = "Simon" } } })), alternateStatusCode: HttpStatusCode.NoContent)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new PersonCollection { new Person { Id = 1 } }); - } - - [Test] - public void GetWithResultAsync_OverrideIActionResult_OK() - { - static Task> Success(WebApiParam p) => Result.Ok(p.CreateActionResult("It works!", HttpStatusCode.OK)).AsTask(); - - using var test = FunctionTester.Create(); - var req = test.CreateHttpRequest(HttpMethod.Get, "https://unittest"); - req.ApplyETag("1JkYXLLxYY4Zw02qg/K2yVGH+J+oGshN7M/BlIH20/0="); - - test.Type() - .Run(f => f.GetWithResultAsync(req, Success)) - .ToActionResultAssertor() - .AssertOK() - .AssertContent("It works!"); - } - - [Test] - public void GetWithResultAsync_OverrideIActionResult_NotModified() - { - static Task> Success(WebApiParam p) => Result.Ok(p.CreateActionResult("It works!", HttpStatusCode.OK)).AsTask(); - - using var test = FunctionTester.Create(); - var req = test.CreateHttpRequest(HttpMethod.Get, "https://unittest"); - req.ApplyETag("1JkYXLLxYY4Zw02qg/J2yVGH+J+oGshN7M/BlIH20/0="); - - test.Type() - .Run(f => f.GetWithResultAsync(req, Success)) - .ToActionResultAssertor() - .AssertNotModified(); - } - - [Test] - public void PostWithResultAsync_NoValueNoResult() - { - static Task Success(WebApiParam p) => Task.FromResult(Result.Success); - - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PostWithResultAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), Success)) - .ToActionResultAssertor() - .AssertOK(); - } - - [Test] - public void PostWithResultAsync_NoValueWithResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PostWithResultAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), r => Task.FromResult(Result.Ok(new Product { Id = "A", Name = "B", Price = 1.99m })))) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Product { Id = "A", Name = "B", Price = 1.99m }); - } - - [Test] - public void PostWithResultAsync_WithValueNoResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PostWithResultAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", new { id = "A", name = "B", price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, r.Value); return Task.FromResult(Result.Success); })) - .ToActionResultAssertor() - .AssertOK(); - } - - [Test] - public void PostWithResultAsync_WithValueWithResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PostWithResultAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest", new { id = "A", name = "B", price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, r.Value); return Task.FromResult(Result.Ok(new Product { Id = "Y", Name = "Z", Price = 3.01m })); })) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Product { Id = "Y", Name = "Z", Price = 3.01m }); - } - - [Test] - public void PostWithResultAsync_OverrideIActionResult() - { - static Task> Success(WebApiParam p) => Result.Ok(p.CreateActionResult("It works!", HttpStatusCode.OK)).AsTask(); - - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PostWithResultAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest"), Success)) - .ToActionResultAssertor() - .AssertOK() - .AssertContent("It works!"); - } - - [Test] - public void PutWithResultAsync_WithValueNoResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PutWithResultAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest", new { id = "A", name = "B", price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, r.Value); return Task.FromResult(Result.Success); })) - .ToActionResultAssertor() - .AssertNoContent(); - } - - [Test] - public void PutWithResultAsync_WithValueWithResult() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PutWithResultAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest", new { id = "A", name = "B", price = 1.99m }), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, r.Value); return Task.FromResult(Result.Ok(new Product { Id = "Y", Name = "Z", Price = 3.01m })); })) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Product { Id = "Y", Name = "Z", Price = 3.01m }); - } - - [Test] - public void PutWithResultAsync_AutoConcurrency_NoIfMatch() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PutWithResultAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest", new { id = "A", name = "B", price = 2.99m }), - r => Task.FromResult>(Result.Ok(new Product { Id = "A", Name = "B", Price = 1.99m })), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 2.99m }, r.Value); return Task.FromResult(Result.Ok(new Product { Id = "Y", Name = "Z", Price = 3.99m })); }, - simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("An 'If-Match' header is required for an HTTP PUT where the underlying entity supports concurrency (ETag)."); - } - - [Test] - public void PutWithResultAsync_AutoConcurrency_NoMatch() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PutWithResultAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest", new { id = "A", name = "B", price = 2.99m }, new HttpRequestOptions { ETag = "bbb" }), - r => Task.FromResult>(Result.Ok(new Product { Id = "A", Name = "B", Price = 1.99m })), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 2.99m }, r.Value); return Task.FromResult(Result.Ok(new Product { Id = "Y", Name = "Z", Price = 3.99m })); }, - simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("A concurrency error occurred; please refresh the data and try again."); - } - - [Test] - public void PutWithResultAsync_AutoConcurrency_Match() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.PutWithResultAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest", new { id = "A", name = "B", price = 2.99m }, new HttpRequestOptions { ETag = "98Oe+fRzgTuVae59mLwf0Mj+iKySTlgUxEQt18huJZg=" }), - r => Task.FromResult>(Result.Ok(new Product { Id = "A", Name = "B", Price = 1.99m })), - r => { ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 2.99m }, r.Value); return Task.FromResult(Result.Ok(new Product { Id = "Y", Name = "Z", Price = 3.99m })); }, - simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Product { Id = "Y", Name = "Z", Price = 3.99m }); - } - - [Test] - public void DeleteWithResultAsync() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.DeleteWithResultAsync(test.CreateHttpRequest(HttpMethod.Delete, "https://unittest"), _ => Task.FromResult(Result.Success))) - .ToActionResultAssertor() - .AssertNoContent(); - } - - [Test] - public void DeleteWithResultAsync_NotFoundError() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.DeleteWithResultAsync(test.CreateHttpRequest(HttpMethod.Delete, "https://unittest"), _ => Task.FromResult(Result.NotFoundError()))) - .ToActionResultAssertor() - .AssertNoContent(); - } - - [Test] - public void DeleteWithResultAsync_AuthenticationError() - { - using var test = FunctionTester.Create(); - test.Type() - .Run(f => f.DeleteWithResultAsync(test.CreateHttpRequest(HttpMethod.Delete, "https://unittest"), _ => Task.FromResult(Result.AuthenticationError()))) - .ToActionResultAssertor() - .Assert(HttpStatusCode.Unauthorized); - } - - [Test] - public void PatchWithResultAsync_WithInvalidContentType() - { - using var test = FunctionTester.Create(); - var hr = test.CreateHttpRequest(HttpMethod.Patch, "https://unittest"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(null!)), put: _ => Task.FromResult(Result.Ok(null!)))) - .ToActionResultAssertor() - .Assert(HttpStatusCode.UnsupportedMediaType) - .AssertContent("Unsupported 'Content-Type' for a PATCH; only JSON Merge Patch is supported using either: 'application/merge-patch+json' or 'application/json'."); - } - - [Test] - public void PatchWithResultAsync_WithNullJson() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, null); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(null!)), put: _ => Task.FromResult(Result.Ok(null!)))) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: Value is mandatory."); - } - - [Test] - public void PatchWithResultAsync_WithBadJson() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, ""); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(null!)), put: _ => Task.FromResult(Result.Ok(null!)))) - .ToActionResultAssertor() - .AssertBadRequest() - .AssertContent("'<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0."); - } - - [Test] - public void PatchWithResultAsync_WithNoETag() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ }"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(new Person())), put: _ => Task.FromResult(Result.Ok(null!)))) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("An 'If-Match' header is required for an HTTP PATCH where the underlying entity supports concurrency (ETag)."); - } - - [Test] - public void PatchWithResultAsync_WithETagHeader_ThenNotFound() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ }", "aaaa"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(null!)), put: _ => Task.FromResult(Result.Ok(null!)))) - .ToActionResultAssertor() - .AssertNotFound(); - } - - [Test] - public void PatchWithResultAsync_WithETagProperty_ThenNotFound() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"etag\": \"aaa\"}"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(null!)), put: _ => Task.FromResult(Result.Ok(null!)))) - .ToActionResultAssertor() - .AssertNotFound(); - } - - [Test] - public void PatchWithResultAsync_WithETagHeader_NotMatched() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ }", "aaa"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(new Person { ETag = "bbb" })), put: _ => Task.FromResult(Result.Ok(null!)))) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("A concurrency error occurred; please refresh the data and try again."); - } - - [Test] - public void PatchWithResultAsync_WithETagProperty_NotMatched() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"etag\": \"aaa\"}"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(new Person { ETag = "bbb" })), put: _ => Task.FromResult(Result.Ok(null!)))) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("A concurrency error occurred; please refresh the data and try again."); - } - - [Test] - public void PatchWithResultAsync_WithETagHeader_PutConcurrency() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"bob\" }", "aaa"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(new Person { ETag = "aaa" })), put: _ => throw new ConcurrencyException())) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("A concurrency error occurred; please refresh the data and try again."); - } - - [Test] - public void PatchWithResultAsync_WithETagHeader_SimulateDuplicate_WasMergedWithChanges() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"bob\" }", "aaa"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(new Person { Name = "bobby", ETag = "aaa" })), put: _ => throw new DuplicateException())) - .ToActionResultAssertor() - .AssertConflict(); - } - - [Test] - public void PatchWithResultAsync_WithETagHeader_OK_WithNoMergeChanges() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"bob\" }", "aaa"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(new Person { Name = "bob", ETag = "aaa" })), put: _ => throw new ConcurrencyException())) - .ToActionResultAssertor() - .AssertOK(); - } - - [Test] - public void PatchWithResultAsync_WithETagHeader_OK_SimulateChanged() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"bobby\" }", "aaa"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, - get: _ => Task.FromResult(Result.Ok(new Person { Name = "bob", ETag = "aaa" })), - put: p => { ObjectComparer.Assert(new Person { Name = "bobby", ETag = "aaa" }, p.Value); p.Value!.ETag = "bbb"; return Task.FromResult(Result.Ok(p.Value!)); })) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Person { Name = "bobby", ETag = "bbb" }) - .AssertETagHeader("bbb"); - } - - [Test] - public void PatchWithResultAsync_AutoConcurrency_NoIfMatchHeader() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"Gazza\" }"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(new Person { Id = 13, Name = "Deano" })), put: _ => Task.FromResult(Result.Ok(null!)), simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("An 'If-Match' header is required for an HTTP PATCH where the underlying entity supports concurrency (ETag)."); - } - - [Test] - public void PatchWithResultAsync_AutoConcurrency_NotMatched() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"Gazza\" }", etag: "bbb"); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(new Person { Id = 13, Name = "Deano" })), put: _ => Task.FromResult(Result.Ok(null!)), simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertPreconditionFailed() - .AssertContent("A concurrency error occurred; please refresh the data and try again."); - } - - [Test] - public void PatchWithResultAsync_AutoConcurrency_Matched() - { - using var test = FunctionTester.Create(); - var hr = CreatePatchRequest(test, "{ \"name\": \"Gazza\" }", etag: "Q8nNyU0hP+j7+1tDN0JzLGMcfPOX8OsLAh7lma4U0xo="); - test.Type() - .Run(f => f.PatchWithResultAsync(hr, get: _ => Task.FromResult(Result.Ok(new Person { Id = 13, Name = "Deano" })), put: _ => Task.FromResult(Result.Ok(new Person { Id = 13, Name = "Gazza" })), simulatedConcurrency: true)) - .ToActionResultAssertor() - .AssertOK() - .AssertValue(new Person { Id = 13, Name = "Gazza", ETag = "tEEokPXk+4Q5MoiGqyAs1+6A00e2ww59Zm57LJgvBcg=" }); - } - - [Test] - public void RunAsync_ValidationException_NoCatchAndHandleExceptions() - { - using var test = FunctionTester.Create().ReplaceScoped(_ => new WebApiInvoker { CatchAndHandleExceptions = false }); - test.Type() - .Run(f => f.GetWithResultAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples"), r => Task.FromResult(Result.Fail("it-failed")))) - .AssertException(); - } - - private static HttpRequest CreatePatchRequest(UnitTestEx.Azure.Functions.FunctionTester test, string? json, string? etag = null) - => test.CreateHttpRequest(HttpMethod.Patch, "https://unittest", json, HttpConsts.MergePatchMediaTypeName, hr => hr.ApplyRequestOptions(new HttpRequestOptions { ETag = etag })); - - private class Person : IIdentifier, IETag - { - public int Id { get; set; } - public string? Name { get; set; } - public string? ETag { get; set; } - } - - private class PersonCollection : List { } - - private class PersonCollectionResult : CollectionResult { } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Wildcards/WildcardTest.cs b/tests/CoreEx.Test/Framework/Wildcards/WildcardTest.cs deleted file mode 100644 index 347e017d..00000000 --- a/tests/CoreEx.Test/Framework/Wildcards/WildcardTest.cs +++ /dev/null @@ -1,425 +0,0 @@ -using CoreEx.Wildcards; -using FluentAssertions; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CoreEx.Test.Framework.Wildcards -{ - [TestFixture] - public class WildcardTest - { - #region Determine - - [Test] - public void Parse_1_NoneOrEqual() - { - var wc = Wildcard.BothAll; - Check(WildcardSelection.None, null, wc.Parse(null)); - Check(WildcardSelection.None, null, wc.Parse(string.Empty)); - Check(WildcardSelection.None, null, wc.Parse(" ")); - Check(WildcardSelection.Equal, "X", wc.Parse("X")); - Check(WildcardSelection.Equal, "XX", wc.Parse("XX")); - Check(WildcardSelection.Equal, "XXX", wc.Parse("XXX")); - Check(WildcardSelection.Equal, "XXX", wc.Parse(" XXX ")); - } - - [Test] - public void Parse_2_Single() - { - var wc = Wildcard.BothAll; - Check(WildcardSelection.Single | WildcardSelection.MultiWildcard, "*", wc.Parse("*")); - Check(WildcardSelection.Single | WildcardSelection.MultiWildcard, "*", wc.Parse("**")); - Check(WildcardSelection.Single | WildcardSelection.MultiWildcard, "*", wc.Parse("***")); - Check(WildcardSelection.Single | WildcardSelection.SingleWildcard, "?", wc.Parse("?")); - Check(WildcardSelection.StartsWith | WildcardSelection.EndsWith | WildcardSelection.SingleWildcard | WildcardSelection.AdjacentWildcards, "??", wc.Parse("??")); - Check(WildcardSelection.StartsWith | WildcardSelection.EndsWith | WildcardSelection.Embedded | WildcardSelection.SingleWildcard | WildcardSelection.AdjacentWildcards, "???", wc.Parse("???")); - } - - [Test] - public void Parse_3_StartsAndEndsWithOrContains() - { - var wc = Wildcard.BothAll; - Check(WildcardSelection.EndsWith | WildcardSelection.MultiWildcard, "*X", wc.Parse("*X")); - Check(WildcardSelection.EndsWith | WildcardSelection.SingleWildcard, "?X", wc.Parse("?X")); - Check(WildcardSelection.EndsWith | WildcardSelection.SingleWildcard, "?XX", wc.Parse("?XX")); - Check(WildcardSelection.StartsWith | WildcardSelection.MultiWildcard, "X*", wc.Parse("X*")); - Check(WildcardSelection.StartsWith | WildcardSelection.SingleWildcard, "X?", wc.Parse("X?")); - Check(WildcardSelection.StartsWith | WildcardSelection.SingleWildcard, "XX?", wc.Parse("XX?")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Contains, "*X*", wc.Parse("*X*")); - Check(WildcardSelection.SingleWildcard | WildcardSelection.Contains, "?X?", wc.Parse("?X?")); - } - - [Test] - public void Parse_4_EmbeddedOrContains() - { - var wc = Wildcard.BothAll; - Check(WildcardSelection.Embedded | WildcardSelection.MultiWildcard, "X*X", wc.Parse("X*X")); - Check(WildcardSelection.Embedded | WildcardSelection.MultiWildcard, "XX*XX", wc.Parse("XX*XX")); - Check(WildcardSelection.Embedded | WildcardSelection.SingleWildcard, "XX?XX", wc.Parse("XX?XX")); - Check(WildcardSelection.Embedded | WildcardSelection.SingleWildcard, "XX?XX", wc.Parse("XX?XX")); - Check(WildcardSelection.Embedded | WildcardSelection.MultiWildcard, "X*X*XX", wc.Parse("X*X*XX")); - Check(WildcardSelection.Embedded | WildcardSelection.MultiWildcard, "X*XX", wc.Parse("X**XX")); - Check(WildcardSelection.Embedded | WildcardSelection.MultiWildcard | WildcardSelection.StartsWith, "XX*XX*", wc.Parse("XX*XX*")); - - Check(WildcardSelection.Contains | WildcardSelection.MultiWildcard, "*X*", wc.Parse("*X*")); - Check(WildcardSelection.Contains | WildcardSelection.MultiWildcard | WildcardSelection.SingleWildcard, "*X?", wc.Parse("*X?")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Contains, "*X*", wc.Parse("**X*")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Contains, "*X*", wc.Parse("*X**")); - } - - [Test] - public void Parse_5_InvalidCharacters() - { - var wc = new Wildcard(WildcardSelection.BothAll, singleWildcard: '_', charactersNotAllowed: new char[] { '?' }); - Check(WildcardSelection.SingleWildcard | WildcardSelection.InvalidCharacter | WildcardSelection.StartsWith, "X?_", wc.Parse("X?_")); - } - - [Test] - public void Parse_6_SpaceTreatment() - { - var wc = new Wildcard(WildcardSelection.MultiAll, spaceTreatment: WildcardSpaceTreatment.Compress); - Check(WildcardSelection.Equal, "X X", wc.Parse("X X")); - Check(WildcardSelection.Equal, "X X", wc.Parse("X X")); - Check(WildcardSelection.Equal, "X X X", wc.Parse("X X X")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.EndsWith, "*X X", wc.Parse("*X X")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.StartsWith, "X X*", wc.Parse("X X*")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Embedded, "X X* X", wc.Parse("X X* X")); - - wc = new Wildcard(WildcardSelection.MultiAll, spaceTreatment: WildcardSpaceTreatment.MultiWildcardAlways); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Embedded, "X*X", wc.Parse("X X")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Embedded, "X*X", wc.Parse("X X")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Embedded, "X*X*X", wc.Parse("X X X")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Embedded | WildcardSelection.EndsWith, "*X*X", wc.Parse("*X X")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Embedded | WildcardSelection.StartsWith, "X*X*", wc.Parse("X X*")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Embedded, "X*X*X", wc.Parse("X X* X")); - - wc = new Wildcard(WildcardSelection.MultiAll, spaceTreatment: WildcardSpaceTreatment.MultiWildcardWhenOthers); - Check(WildcardSelection.Equal, "X X", wc.Parse("X X")); - Check(WildcardSelection.Equal, "X X", wc.Parse("X X")); - Check(WildcardSelection.Equal, "X X X", wc.Parse("X X X")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Embedded | WildcardSelection.EndsWith, "*X*X", wc.Parse("*X X")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Embedded | WildcardSelection.StartsWith, "X*X*", wc.Parse("X X*")); - Check(WildcardSelection.MultiWildcard | WildcardSelection.Embedded, "X*X*X", wc.Parse("X X* X")); - } - - private void Check(WildcardSelection selection, string? text, WildcardResult result) - { - Assert.Multiple(() => - { - Assert.That(result.Selection, Is.EqualTo(selection)); - Assert.That(result.Text, Is.EqualTo(text)); - }); - } - - #endregion - - #region Validate - - [Test] - public void Validate_1_Default() - { - var wc = Wildcard.BothAll; - Assert.Multiple(() => - { - Assert.That(wc.Validate(null), Is.True); - Assert.That(wc.Validate(string.Empty), Is.True); - Assert.That(wc.Validate("X"), Is.True); - Assert.That(wc.Validate("*"), Is.True); - Assert.That(wc.Validate("?"), Is.True); - Assert.That(wc.Validate("XX"), Is.True); - Assert.That(wc.Validate("*X"), Is.True); - Assert.That(wc.Validate("?X"), Is.True); - Assert.That(wc.Validate("X*"), Is.True); - Assert.That(wc.Validate("X?"), Is.True); - Assert.That(wc.Validate("XXX"), Is.True); - Assert.That(wc.Validate("X*X"), Is.True); - Assert.That(wc.Validate("X?X"), Is.True); - Assert.That(wc.Validate("*?*"), Is.True); - Assert.That(wc.Validate("*X*"), Is.True); - }); - } - - [Test] - public void Validate_2_CharactersNotAllowed() - { - var wc = new Wildcard(WildcardSelection.BothAll, multiWildcard: '%', singleWildcard: '_', charactersNotAllowed: new char[] { '*', '?' }); - Assert.Multiple(() => - { - Assert.That(wc.Validate(null), Is.True); - Assert.That(wc.Validate(string.Empty), Is.True); - Assert.That(wc.Validate("X"), Is.True); - Assert.That(wc.Validate("*"), Is.False); - Assert.That(wc.Validate("?"), Is.False); - Assert.That(wc.Validate("XX"), Is.True); - Assert.That(wc.Validate("*X"), Is.False); - Assert.That(wc.Validate("?X"), Is.False); - Assert.That(wc.Validate("X*"), Is.False); - Assert.That(wc.Validate("X?"), Is.False); - Assert.That(wc.Validate("XXX"), Is.True); - Assert.That(wc.Validate("X*X"), Is.False); - Assert.That(wc.Validate("X?X"), Is.False); - Assert.That(wc.Validate("*?*"), Is.False); - Assert.That(wc.Validate("*X*"), Is.False); - }); - } - - [Test] - public void Validate_3_EndWildcardOnly() - { - var wc = new Wildcard(WildcardSelection.EndsWith | WildcardSelection.MultiWildcard | WildcardSelection.SingleWildcard, singleWildcard: Wildcard.SingleWildcardCharacter); - Assert.Multiple(() => - { - Assert.That(wc.Validate(null), Is.False); - Assert.That(wc.Validate(string.Empty), Is.False); - Assert.That(wc.Validate("X"), Is.False); - Assert.That(wc.Validate("*"), Is.False); - Assert.That(wc.Validate("?"), Is.False); - Assert.That(wc.Validate("XX"), Is.False); - Assert.That(wc.Validate("*X"), Is.True); - Assert.That(wc.Validate("?X"), Is.True); - Assert.That(wc.Validate("X*"), Is.False); - Assert.That(wc.Validate("X?"), Is.False); - Assert.That(wc.Validate("XXX"), Is.False); - Assert.That(wc.Validate("X*X"), Is.False); - Assert.That(wc.Validate("X?X"), Is.False); - Assert.That(wc.Validate("*?*"), Is.False); - Assert.That(wc.Validate("*X*"), Is.False); - }); - } - - [Test] - public void Validate_4_StartWildcardOnly() - { - var wc = new Wildcard(WildcardSelection.StartsWith | WildcardSelection.MultiWildcard | WildcardSelection.SingleWildcard, singleWildcard: Wildcard.SingleWildcardCharacter); - Assert.Multiple(() => - { - Assert.That(wc.Validate(null), Is.False); - Assert.That(wc.Validate(string.Empty), Is.False); - Assert.That(wc.Validate("X"), Is.False); - Assert.That(wc.Validate("*"), Is.False); - Assert.That(wc.Validate("?"), Is.False); - Assert.That(wc.Validate("XX"), Is.False); - Assert.That(wc.Validate("*X"), Is.False); - Assert.That(wc.Validate("?X"), Is.False); - Assert.That(wc.Validate("X*"), Is.True); - Assert.That(wc.Validate("X?"), Is.True); - Assert.That(wc.Validate("XXX"), Is.False); - Assert.That(wc.Validate("X*X"), Is.False); - Assert.That(wc.Validate("X?X"), Is.False); - Assert.That(wc.Validate("*?*"), Is.False); - Assert.That(wc.Validate("*X*"), Is.False); - }); - } - - [Test] - public void Validate_5_EmbeddedWildcardOnly() - { - var wc = new Wildcard(WildcardSelection.Embedded | WildcardSelection.MultiWildcard | WildcardSelection.SingleWildcard, singleWildcard: Wildcard.SingleWildcardCharacter); - Assert.Multiple(() => - { - Assert.That(wc.Validate(null), Is.False); - Assert.That(wc.Validate(string.Empty), Is.False); - Assert.That(wc.Validate("X"), Is.False); - Assert.That(wc.Validate("*"), Is.False); - Assert.That(wc.Validate("?"), Is.False); - Assert.That(wc.Validate("XX"), Is.False); - Assert.That(wc.Validate("*X"), Is.False); - Assert.That(wc.Validate("?X"), Is.False); - Assert.That(wc.Validate("X*"), Is.False); - Assert.That(wc.Validate("X?"), Is.False); - Assert.That(wc.Validate("XXX"), Is.False); - Assert.That(wc.Validate("X*X"), Is.True); - Assert.That(wc.Validate("X?X"), Is.True); - Assert.That(wc.Validate("*?*"), Is.False); - Assert.That(wc.Validate("*X*"), Is.False); - }); - } - - [Test] - public void Validate_6_SingleOrMultiWildcard() - { - var wc = new Wildcard(WildcardSelection.Embedded | WildcardSelection.MultiWildcard, singleWildcard: Wildcard.SingleWildcardCharacter); - Assert.Multiple(() => - { - Assert.That(wc.Validate("X*X"), Is.True); - Assert.That(wc.Validate("X?X"), Is.False); - }); - - wc = new Wildcard(WildcardSelection.Embedded | WildcardSelection.SingleWildcard, singleWildcard: Wildcard.SingleWildcardCharacter); - Assert.Multiple(() => - { - Assert.That(wc.Validate("X*X"), Is.False); - Assert.That(wc.Validate("X?X"), Is.True); - }); - } - - [Test] - public void Validate_7_NoneAndEqual() - { - var wc = new Wildcard(WildcardSelection.None | WildcardSelection.Equal, singleWildcard: Wildcard.SingleWildcardCharacter); - Assert.Multiple(() => - { - Assert.That(wc.Validate(null), Is.True); - Assert.That(wc.Validate(string.Empty), Is.True); - Assert.That(wc.Validate("X"), Is.True); - Assert.That(wc.Validate("*"), Is.False); - Assert.That(wc.Validate("?"), Is.False); - Assert.That(wc.Validate("XX"), Is.True); - Assert.That(wc.Validate("*X"), Is.False); - Assert.That(wc.Validate("?X"), Is.False); - Assert.That(wc.Validate("X*"), Is.False); - Assert.That(wc.Validate("X?"), Is.False); - Assert.That(wc.Validate("XXX"), Is.True); - Assert.That(wc.Validate("X*X"), Is.False); - Assert.That(wc.Validate("X?X"), Is.False); - Assert.That(wc.Validate("*?*"), Is.False); - Assert.That(wc.Validate("*X*"), Is.False); - }); - } - - #endregion - - #region WhereWildcard - - private class Person - { - public string? First { get; set; } - public string? Last { get; set; } - } - - private List GetPeople() - { - return new List - { - new() { First = "Amy", Last = "Johnson" }, - new() { First = "Jenny", Last = "Smith" }, - new() { First = "Gerry", Last = "McQuire" }, - new() { First = "Gary", Last = "Lawson" }, - new() { First = "Simon", Last = "Reynolds" }, - new() { First = "Amanada", Last = "Gray" }, - new() { First = "B", Last = "P" }, - new() { First = null, Last = null } - }; - } - - [Test] - public void WhereWildcard_IEnumerableExtensions() - { - Assert.Multiple(() => - { - // None (all). - Assert.That(GetPeople().WhereWildcard(x => x.First, null).Select(x => x.Last).Count(), Is.EqualTo(8)); - Assert.That(GetPeople().WhereWildcard(x => x.First, "").Select(x => x.Last).Count(), Is.EqualTo(8)); - - // Equal. - Assert.That(GetPeople().WhereWildcard(x => x.First, "SIMON").Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); - Assert.That(GetPeople().WhereWildcard(x => x.First, "SIMON", ignoreCase: false).SingleOrDefault(), Is.Null); - - // Single (all). - Assert.That(GetPeople().WhereWildcard(x => x.First, "*").Select(x => x.Last).Count(), Is.EqualTo(8)); - - // Starts with. - Assert.That(GetPeople().WhereWildcard(x => x.First, "SI*").Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); - Assert.That(GetPeople().WhereWildcard(x => x.First, "SI*", ignoreCase: false).SingleOrDefault(), Is.Null); - - // Ends with. - Assert.That(GetPeople().WhereWildcard(x => x.First, "*ON").Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); - Assert.That(GetPeople().WhereWildcard(x => x.First, "*ON", ignoreCase: false).SingleOrDefault(), Is.Null); - - // Contains. - Assert.That(GetPeople().WhereWildcard(x => x.First, "*IM*").Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); - Assert.That(GetPeople().WhereWildcard(x => x.First, "*IM*", ignoreCase: false).SingleOrDefault(), Is.Null); - - // Regex-based: embedded. - Assert.That(GetPeople().WhereWildcard(x => x.First, "S*N", wildcard: Wildcard.MultiAll).Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); - Assert.That(GetPeople().WhereWildcard(x => x.First, "S*N", ignoreCase: false, wildcard: Wildcard.MultiAll).Select(x => x.Last).SingleOrDefault(), Is.Null); - - // Regex-based: single-char match. - Assert.That(GetPeople().WhereWildcard(x => x.First, "G?RY", wildcard: Wildcard.BothAll).Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Lawson")); - Assert.That(GetPeople().WhereWildcard(x => x.First, "G?RY", ignoreCase: false, wildcard: Wildcard.BothAll).Select(x => x.Last).SingleOrDefault(), Is.Null); - - // Regex-based: single-char all. - Assert.That(GetPeople().Where(x => true).WhereWildcard(x => x.First, " ? ", wildcard: new Wildcard(WildcardSelection.MultiAll, singleWildcard: char.MinValue)).Select(x => x.Last).SingleOrDefault(), Is.Null); - Assert.That(GetPeople().Where(x => true).WhereWildcard(x => x.First, " ? ", wildcard: Wildcard.BothAll).Select(x => x.Last).SingleOrDefault(), Is.EqualTo("P")); - }); - } - - [Test] - public void WhereWildcard_IQueryableExtensions() - { - Assert.Multiple(() => - { - // None(all). - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, null).Select(x => x.Last).Count(), Is.EqualTo(8)); - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, "").Select(x => x.Last).Count(), Is.EqualTo(8)); - - // Equal. - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, "SIMON").Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, "SIMON", ignoreCase: false).SingleOrDefault(), Is.Null); - - // Single (all). - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, "*").Select(x => x.Last).Count(), Is.EqualTo(8)); - - // Starts with. - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, "SI*").Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, "SI*", ignoreCase: false).SingleOrDefault(), Is.Null); - - // Ends with. - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, "*ON").Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, "*ON", ignoreCase: false).SingleOrDefault(), Is.Null); - - // Contains. - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, "*IM*").Select(x => x.Last).SingleOrDefault(), Is.EqualTo("Reynolds")); - Assert.That(GetPeople().AsQueryable().WhereWildcard(x => x.First, "*IM*", ignoreCase: false).SingleOrDefault(), Is.Null); - }); - - // Embedded. - Assert.Throws(() => GetPeople().AsQueryable().WhereWildcard(x => x.First, "S*N").Select(x => x.Last).SingleOrDefault())!.Message.Should().Be("Wildcard selection text is not supported."); - Assert.Throws(() => GetPeople().AsQueryable().WhereWildcard(x => x.First, "S*N", ignoreCase: false).Select(x => x.Last).SingleOrDefault())!.Message.Should().Be("Wildcard selection text is not supported."); - - Assert.Multiple(() => - { - // Single-char all; '?' is ignored. - Assert.That(GetPeople().AsQueryable().Where(x => true).WhereWildcard(x => x.First, " ? ", wildcard: new Wildcard(WildcardSelection.MultiAll, singleWildcard: char.MinValue)).Select(x => x.Last).SingleOrDefault(), Is.Null); - Assert.That(GetPeople().AsQueryable().Where(x => true).WhereWildcard(x => x.First, " ? ", ignoreCase: false, wildcard: new Wildcard(WildcardSelection.MultiAll, singleWildcard: char.MinValue)).Select(x => x.Last).SingleOrDefault(), Is.Null); - }); - } - - [Test] - public void WhereWildcard_IQueryableExtensions_Load_Skippable() - { - var p = GetPeople(); - - for (int i = 0; i < 100; i++) - { - p.AsQueryable().WhereWildcard(x => x.First, null).Select(x => x.Last).Count(); - p.AsQueryable().WhereWildcard(x => x.First, "").Select(x => x.Last).Count(); - p.AsQueryable().WhereWildcard(x => x.First, "*").Select(x => x.Last).Count(); - } - } - - [Test] - public void WhereWildcard_IQueryableExtensions_Load_WithExpressions() - { - var p = GetPeople(); - - for (int i = 0; i < 100; i++) - { - p.AsQueryable().WhereWildcard(x => x.First, "SIMON").Select(x => x.Last).SingleOrDefault(); - p.AsQueryable().WhereWildcard(x => x.First, "SIMON", ignoreCase: false).SingleOrDefault(); - p.AsQueryable().WhereWildcard(x => x.First, "SI*").Select(x => x.Last).SingleOrDefault(); - p.AsQueryable().WhereWildcard(x => x.First, "*ON", ignoreCase: false).SingleOrDefault(); - p.AsQueryable().WhereWildcard(x => x.First, "*IM*").Select(x => x.Last).SingleOrDefault(); - p.AsQueryable().WhereWildcard(x => x.First, "*IM*", ignoreCase: false).SingleOrDefault(); - p.AsQueryable().Where(x => true).WhereWildcard(x => x.First, " ? ", wildcard: new Wildcard(WildcardSelection.MultiAll, singleWildcard: char.MinValue)).Select(x => x.Last).SingleOrDefault(); - p.AsQueryable().Where(x => true).WhereWildcard(x => x.First, " ? ", ignoreCase: false, wildcard: new Wildcard(WildcardSelection.MultiAll, singleWildcard: char.MinValue)).Select(x => x.Last).SingleOrDefault(); - } - - // Calc as t (time) / 100 (iterations) / 8 (queries) = avg per invocation cost :: this builds run-time expression to execute - super flexible but will not be uber fast. - } - - #endregion - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/HealthChecks/TypedHttpClientCoreHealthCheckTests.cs b/tests/CoreEx.Test/HealthChecks/TypedHttpClientCoreHealthCheckTests.cs deleted file mode 100644 index 4400becf..00000000 --- a/tests/CoreEx.Test/HealthChecks/TypedHttpClientCoreHealthCheckTests.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using CoreEx.Http.HealthChecks; -using CoreEx.TestFunction; -using FluentAssertions; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Moq; -using NUnit.Framework; -using UnitTestEx; - -namespace CoreEx.Test.HealthChecks -{ - - [TestFixture, NonParallelizable] - public class TypedHttpClientCoreHealthCheckTests - { - [Test] - public async Task CheckHealthAsync_Should_Succeed_When_SampleApiOK() - { - // Arrange - var mcf = MockHttpClientFactory.Create(); - mcf.CreateClient("Backend", "https://backend/").Request(HttpMethod.Head, string.Empty).Respond.With(statusCode: HttpStatusCode.OK); - using var test = FunctionTester.Create() - .ReplaceHttpClientFactory(mcf); - var mock = new Mock(); - - var context = new HealthCheckContext() - { - Registration = new HealthCheckRegistration("test", mock.Object, null, null) - }; - - // Act - var result = await test.Type>() - .RunAsync(x => x.CheckHealthAsync(context, CancellationToken.None)); - - // Assert - result.Result.Status.Should().Be(HealthStatus.Healthy, because: "Sample API is OK"); - } - - [Test] - public async Task CheckHealthAsync_Should_Fail_When_SampleApiDown() - { - // Arrange - var mcf = MockHttpClientFactory.Create(); - mcf.CreateClient("Backend", "https://backend/").Request(HttpMethod.Head, string.Empty).Respond.With(statusCode: HttpStatusCode.ServiceUnavailable); - using var test = FunctionTester.Create() - .UseJsonSerializer(new CoreEx.Text.Json.JsonSerializer()) // Required as the Result type needs to be deserialized using CoreEx. - .ReplaceHttpClientFactory(mcf); - var mock = new Mock(); - - var context = new HealthCheckContext() - { - Registration = new HealthCheckRegistration("test", mock.Object, null, null) - }; - - // Act - var result = await test.Type>() - .RunAsync(x => x.CheckHealthAsync(context, CancellationToken.None)); - - // Assert - result.Result.Status.Should().Be(HealthStatus.Unhealthy, because: "Sample API return 502"); - } - - [Test] - public async Task CheckHealthAsync_Should_Fail_When_SampleApiThrowsException() - { - // Arrange - var mcf = MockHttpClientFactory.Create(); - mcf.CreateClient("Backend", "https://backend/").Request(HttpMethod.Head, string.Empty).Respond.With(string.Empty, response: x => throw new Exception("Sample API is down")); - using var test = FunctionTester.Create() - .UseJsonSerializer(new CoreEx.Text.Json.JsonSerializer()) // Required as the Result type needs to be deserialized using CoreEx. - .ReplaceHttpClientFactory(mcf); - var mock = new Mock(); - - var context = new HealthCheckContext() - { - Registration = new HealthCheckRegistration("test", mock.Object, null, null) - }; - - // Act - var result = await test.Type>() - .RunAsync(x => x.CheckHealthAsync(context, CancellationToken.None)); - - // Assert - result.Result.Status.Should().Be(HealthStatus.Unhealthy, because: "Sample API is Down"); - result.Result.Exception.Should().NotBeNull(); - } - - [Test] - public async Task CheckHealthAsync_Should_Fail_When_NoHttpClientInjected() - { - // Arrange - var target = new TypedHttpClientCoreHealthCheck(null!); - var context = new HealthCheckContext() - { - Registration = new HealthCheckRegistration("test", new Mock().Object, null, null) - }; - - // Act - var result = await target.CheckHealthAsync(context, CancellationToken.None); - - // Assert - result.Status.Should().Be(HealthStatus.Unhealthy, because: "No HttpClient injected."); - result.Description.Should().Be("Typed Http client dependency for 'CoreEx.TestFunction.BackendHttpClient' not resolved."); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/HealthChecks/TypedHttpClientHealthCheckTest.cs b/tests/CoreEx.Test/HealthChecks/TypedHttpClientHealthCheckTest.cs deleted file mode 100644 index 832e6c56..00000000 --- a/tests/CoreEx.Test/HealthChecks/TypedHttpClientHealthCheckTest.cs +++ /dev/null @@ -1,127 +0,0 @@ -using CoreEx.Http; -using CoreEx.Http.HealthChecks; -using CoreEx.Json; -using CoreEx.TestFunction; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Moq; -using NUnit.Framework; -using System; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using UnitTestEx; - -namespace CoreEx.Test.HealthChecks -{ - [TestFixture, NonParallelizable] - public class TypedHttpClientHealthCheckTest - { - public class TestHttpClient(HttpClient client, IJsonSerializer jsonSerializer, CoreEx.ExecutionContext executionContext) : TypedHttpClientBase(client, jsonSerializer, executionContext) - { - public override Task HealthCheckAsync(CancellationToken cancellationToken = default) - { - return base.HeadAsync("/health", null, null, new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token); - } - } - - [Test] - public async Task CheckHealthAsync_Should_Succeed_When_TestBackendOK() - { - // Arrange - var mcf = MockHttpClientFactory.Create(); - mcf.CreateClient("Test", "https://testing/").Request(HttpMethod.Head, "health").Respond.With(HttpStatusCode.OK); - - using var test = FunctionTester.Create() - .ReplaceHttpClientFactory(mcf) - .ConfigureServices(sc => sc.AddHttpClient("Test", c => c.BaseAddress = new Uri("https://testing/"))); - - var mock = new Mock(); - var context = new HealthCheckContext() - { - Registration = new HealthCheckRegistration("test", mock.Object, null, null) - }; - - // Act - var result = await test.Type>() - .RunAsync(x => x.CheckHealthAsync(context, CancellationToken.None)); - - // Assert - result.Result.Status.Should().Be(HealthStatus.Healthy, because: "TestBackend is OK"); - } - - [Test] - public async Task CheckHealthAsync_Should_Fail_When_TestBackendDown() - { - // Arrange - var mcf = MockHttpClientFactory.Create(); - mcf.CreateClient("Test", "https://testing/").Request(HttpMethod.Head, "health").Respond.With(HttpStatusCode.ServiceUnavailable); - - using var test = FunctionTester.Create() - .UseJsonSerializer(new CoreEx.Text.Json.JsonSerializer()) // Required as the Result type needs to be deserialized using CoreEx. - .ReplaceHttpClientFactory(mcf) - .ConfigureServices(sc => sc.AddHttpClient("Test", c => c.BaseAddress = new Uri("https://testing/"))); - - var mock = new Mock(); - var context = new HealthCheckContext() - { - Registration = new HealthCheckRegistration("test", mock.Object, null, null) - }; - - // Act - var result = await test.Type>() - .RunAsync(x => x.CheckHealthAsync(context, CancellationToken.None)); - - // Assert - result.Result.Status.Should().Be(HealthStatus.Unhealthy, because: "Testing service returned 502"); - } - - [Test] - public async Task CheckHealthAsync_Should_Fail_When_TestBackendThrowsException() - { - // Arrange - var mcf = MockHttpClientFactory.Create(); - mcf.CreateClient("Test", "https://testing/").Request(HttpMethod.Head, "health") - .Respond.With(string.Empty, response: x => throw new Exception("Testing service is down")); - - using var test = FunctionTester.Create() - .UseJsonSerializer(new CoreEx.Text.Json.JsonSerializer()) // Required as the Result type needs to be deserialized using CoreEx. - .ReplaceHttpClientFactory(mcf) - .ConfigureServices(sc => sc.AddHttpClient("Test", c => c.BaseAddress = new Uri("https://testing/"))); - - var mock = new Mock(); - var context = new HealthCheckContext() - { - Registration = new HealthCheckRegistration("test", mock.Object, null, null) - }; - - // Act - var result = await test.Type>() - .RunAsync(x => x.CheckHealthAsync(context, CancellationToken.None)); - - // Assert - result.Result.Status.Should().Be(HealthStatus.Unhealthy, because: "Testing service is Down"); - result.Result.Exception.Should().NotBeNull(); - } - - [Test] - public async Task CheckHealthAsync_Should_Fail_When_NoHttpClientInjected() - { - // Arrange - var target = new TypedHttpClientHealthCheck(null!); - var context = new HealthCheckContext() - { - Registration = new HealthCheckRegistration("test", new Mock().Object, null, null) - }; - - // Act - var result = await target.CheckHealthAsync(context, CancellationToken.None); - - // Assert - result.Status.Should().Be(HealthStatus.Unhealthy, because: "No HttpClient injected."); - result.Description.Should().Be("Typed Http client dependency for 'CoreEx.Test.HealthChecks.TypedHttpClientHealthCheckTest+TestHttpClient' not resolved."); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/TestFunction/HttpTriggerFunctionTest.cs b/tests/CoreEx.Test/TestFunction/HttpTriggerFunctionTest.cs deleted file mode 100644 index 30c3589e..00000000 --- a/tests/CoreEx.Test/TestFunction/HttpTriggerFunctionTest.cs +++ /dev/null @@ -1,126 +0,0 @@ -using CoreEx.Json; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Functions; -using CoreEx.TestFunction.Models; -using NUnit.Framework; -using System.Net.Http; -using UnitTestEx; - -namespace CoreEx.Test.TestFunction -{ - [TestFixture] - public class HttpTriggerFunctionTest - { - [Test] - public void NoBody() - { - using var test = FunctionTester.Create(); - test.ReplaceScoped() - .HttpTrigger() - .Run(f => f.PostAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest/products"))) - .AssertBadRequest() - .AssertErrors("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: Value is mandatory."); - } - - [Test] - public void InvalidBody() - { - using var test = FunctionTester.Create(); - var r = test.ReplaceScoped() - .HttpTrigger() - .Run(f => f.PostAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest/products", ""))) - .AssertBadRequest() - .AssertErrors("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: '<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0."); - } - - [Test] - public void InvalidBody_Newtonsoft() - { - using var test = FunctionTester.Create(); - test.ReplaceScoped() - .HttpTrigger() - .Run(f => f.PostAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest/products", ""))) - .AssertBadRequest() - .AssertErrors("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: Unexpected character encountered while parsing value: <. Path '', line 0, position 0."); - } - - [Test] - public void InvalidJson() - { - using var test = FunctionTester.Create(); - test.ReplaceScoped() - .HttpTrigger() - .Run(f => f.PostAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest/products", "{\"price\": \"xx.xx\"}"))) - .AssertBadRequest() - .AssertErrors("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: The JSON value could not be converted to System.Decimal. Path: $.price | LineNumber: 0 | BytePositionInLine: 17."); - } - - [Test] - public void InvalidJson_Newtonsoft() - { - using var test = FunctionTester.Create(); - test.ReplaceScoped() - .HttpTrigger() - .Run(f => f.PostAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest/products", "{\"price\": \"xx.xx\"}"))) - .AssertBadRequest() - .AssertErrors("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: Could not convert string to decimal: xx.xx. Path 'price', line 1, position 17."); - } - - [Test] - public void InvalidValue() - { - using var test = FunctionTester.Create(); - test.ReplaceScoped() - .HttpTrigger() - .Run(f => f.PostAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest/products", new { id = "A", price = 1.99m }))) - .AssertBadRequest() - .AssertErrors(new ApiError("Name", "'Name' must not be empty.")); - } - - [Test] - public void InvalidValue_Newtonsoft() - { - using var test = FunctionTester.Create(); - test.ReplaceScoped() - .HttpTrigger() - .Run(f => f.PostAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest/products", new { id = "A", price = 1.99m }))) - .AssertBadRequest() - .AssertErrors(new ApiError("Name", "'Name' must not be empty.")); - } - - [Test] - public void Success() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/products"); - mc.Request(HttpMethod.Post, "").WithJsonBody(new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m }).Respond.WithJson(new BackendProduct { Code = "AX", Description = "BX", RetailPrice = 10.99m }); - - using var test = FunctionTester.Create(); - test.ReplaceHttpClientFactory(mcf) - .HttpTrigger() - .Run(f => f.PutAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest/products", new { id = "A", name = "B", price = 1.99m }))) - .AssertOK() - .AssertValue(new Product { Id = "AX", Name = "BX", Price = 10.99m }); - - mcf.VerifyAll(); - } - - [Test] - public void Success_Newtonsoft() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/products"); - mc.Request(HttpMethod.Post, "").WithJsonBody(new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m }).Respond.WithJson(new BackendProduct { Code = "AX", Description = "BX", RetailPrice = 10.99m }); - - using var test = FunctionTester.Create(); - test.ReplaceHttpClientFactory(mcf) - .ReplaceScoped() - .HttpTrigger() - .Run(f => f.PutAsync(test.CreateJsonHttpRequest(HttpMethod.Put, "https://unittest/products", new { id = "A", name = "B", price = 1.99m }))) - .AssertOK() - .AssertValue(new Product { Id = "AX", Name = "BX", Price = 10.99m }); - - mcf.VerifyAll(); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/TestFunction/HttpTriggerPublishFunctionTest.cs b/tests/CoreEx.Test/TestFunction/HttpTriggerPublishFunctionTest.cs deleted file mode 100644 index c7f1cd23..00000000 --- a/tests/CoreEx.Test/TestFunction/HttpTriggerPublishFunctionTest.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.Linq; -using System.Net.Http; -using CoreEx.Events; -using CoreEx.Json; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Functions; -using CoreEx.TestFunction.Models; -using NUnit.Framework; -using UnitTestEx; - -namespace CoreEx.Test.TestFunction -{ - [TestFixture] - public class HttpTriggerPublishFunctionTest - { - [Test] - public void NoBody() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - - test.ReplaceScoped(_ => imp) - .HttpTrigger() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest/products/publish"))) - .AssertBadRequest() - .AssertErrors("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: Value is mandatory."); - - Assert.That(imp.GetNames(), Is.Empty); - } - - [Test] - public void InvalidBody() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - - test.ReplaceScoped(_ => imp) - .HttpTrigger() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest/products/publish", ""))) - .AssertBadRequest() - .AssertErrors("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: '<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0."); - - Assert.That(imp.GetNames(), Is.Empty); - } - - [Test] - public void InvalidBody_Newtonsoft() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - - test.ConfigureServices(sc => sc.ReplaceScoped()) - .HttpTrigger() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest/products/publish", ""))) - .AssertBadRequest() - .AssertErrors("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: Unexpected character encountered while parsing value: <. Path '', line 0, position 0."); - - Assert.That(imp.GetNames(), Is.Empty); - } - - [Test] - public void InvalidJson() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - - test.ReplaceScoped(_ => imp) - .HttpTrigger() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest/products/publish", "{\"price\": \"xx.xx\"}"))) - .AssertBadRequest() - .AssertErrors("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: The JSON value could not be converted to System.Decimal. Path: $.price | LineNumber: 0 | BytePositionInLine: 17."); - - Assert.That(imp.GetNames(), Is.Empty); - } - - [Test] - public void InvalidJson_Newtonsoft() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - - test.ReplaceScoped(_ => imp) - .ConfigureServices(sc => sc.ReplaceScoped()) - .HttpTrigger() - .Run(f => f.RunAsync(test.CreateHttpRequest(HttpMethod.Post, "https://unittest/products/publish", "{\"price\": \"xx.xx\"}"))) - .AssertBadRequest() - .AssertErrors("Invalid request: content was not provided, contained invalid JSON, or was incorrectly formatted: Could not convert string to decimal: xx.xx. Path 'price', line 1, position 17."); - - Assert.That(imp.GetNames(), Is.Empty); - } - - [Test] - public void InvalidValue() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - - test.ReplaceScoped(_ => imp) - .HttpTrigger() - .Run(f => f.RunAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest/products/publish", new { id = "A", price = 1.99m }))) - .AssertBadRequest() - .AssertErrors(new ApiError("Name", "'Name' must not be empty.")); - - Assert.That(imp.GetNames(), Is.Empty); - } - - [Test] - public void InvalidValue_Newtonsoft() - { - var imp = new InMemoryPublisher(); - using var test = FunctionTester.Create(); - - test.ReplaceScoped(_ => imp) - .ConfigureServices(sc => sc.ReplaceScoped()) - .HttpTrigger() - .Run(f => f.RunAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest/products/publish", new { id = "A", price = 1.99m }))) - .AssertBadRequest() - .AssertErrors(new ApiError("Name", "'Name' must not be empty.")); - - Assert.That(imp.GetNames(), Is.Empty); - } - - [Test] - public void Success() - { - using var test = FunctionTester.Create(); - var imp = new InMemoryPublisher(test.Logger); - - test.ReplaceScoped(_ => imp) - .HttpTrigger() - .Run(f => f.RunAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest/products/publish", new { id = "A", name = "B", price = 1.99m }))) - .AssertAccepted(); - - Assert.That(imp.GetNames(), Has.Length.EqualTo(1)); - Assert.That(imp.GetNames().First(), Is.EqualTo("test-queue")); - var events = imp.GetEvents("test-queue"); - Assert.That(events.Count(), Is.EqualTo(1)); - ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, events[0].Value); - } - - [Test] - public void Success_Newtonsoft() - { - using var test = FunctionTester.Create(); - var imp = new InMemoryPublisher(test.Logger, new CoreEx.Newtonsoft.Json.JsonSerializer()); - - test.ReplaceScoped(_ => imp) - .ConfigureServices(sc => sc.ReplaceScoped()) - .HttpTrigger() - .Run(f => f.RunAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "https://unittest/products/publish", new { id = "A", name = "B", price = 1.99m }))) - .AssertAccepted(); - - Assert.That(imp.GetNames(), Has.Length.EqualTo(1)); - Assert.That(imp.GetNames().First(), Is.EqualTo("test-queue")); - var events = imp.GetEvents("test-queue"); - Assert.That(events.Count(), Is.EqualTo(1)); - ObjectComparer.Assert(new Product { Id = "A", Name = "B", Price = 1.99m }, events[0].Value); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/TestFunction/ServiceBusOrchestratedTriggerFunctionTest.cs b/tests/CoreEx.Test/TestFunction/ServiceBusOrchestratedTriggerFunctionTest.cs deleted file mode 100644 index 24ed6494..00000000 --- a/tests/CoreEx.Test/TestFunction/ServiceBusOrchestratedTriggerFunctionTest.cs +++ /dev/null @@ -1,144 +0,0 @@ -using Azure.Core.Amqp; -using CoreEx.Abstractions; -using CoreEx.Azure.ServiceBus; -using CoreEx.Events; -using CoreEx.Events.Subscribing; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Functions; -using CoreEx.TestFunction.Models; -using CoreEx.TestFunction.Subscribers; -using Microsoft.Azure.WebJobs.ServiceBus; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using UnitTestEx.Abstractions; -using UnitTestEx; -using Az = Azure.Messaging.ServiceBus; - -namespace CoreEx.Test.TestFunction -{ - [TestFixture] - public class ServiceBusOrchestratedTriggerFunctionTest - { - [Test] - public void NotSubscribed() - { - using var test = FunctionTester.Create(); - test.ReplaceScoped(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromEvent(new EventData { Id = 1.ToGuid().ToString(), Subject = "my.unknown" }); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), EventSubscriberExceptionSource.OrchestratorNotSubscribed.ToString(), It.IsNotNull(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void NoValue_Success() - { - using var test = FunctionTester.Create(); - test.ReplaceScoped(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromEvent(new EventData { Id = 101.ToGuid().ToString(), Subject = "my.novalue" }); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.CompleteMessageAsync(message, default), Times.Once); - Assert.That(NoValueSubscriber.EventIds.Contains(101.ToGuid().ToString()), Is.True); - } - - [Test] - public void Product_ValueIsRequired() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromEvent(new EventData { Id = 201.ToGuid().ToString(), Subject = "my.Product", Value = null! }); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), ErrorType.ValidationError.ToString(), It.IsNotNull(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void Product_ValueIsInvalid() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromEvent(new EventData { Id = 201.ToGuid().ToString(), Subject = "my.Product", Value = new Product() }); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), ErrorType.ValidationError.ToString(), It.IsNotNull(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void Product_Success() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromEvent(new EventData { Id = 201.ToGuid().ToString(), Subject = "my.Product", Value = new Product { Id = "XBX", Name = "XBox Series X", Price = 999.99m } }); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.CompleteMessageAsync(message, default), Times.Once); - Assert.That(ProductSubscriber.EventIds.Contains(201.ToGuid().ToString()), Is.True); - } - - [Test] - public void Product_Transient() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromEvent(new EventData { Id = 202.ToGuid().ToString(), Subject = "my.Product", Value = new Product { Id = "PS5", Name = "Sony Playstation 5", Price = 1099.99m } }); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertException("Sony Playstation 5 is currently not permissable; please try again later."); - - actionsMock.VerifyNoOtherCalls(); - Assert.That(ProductSubscriber.EventIds.Contains(202.ToGuid().ToString()), Is.True); - } - } - - public static class EM - { - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Needed for now!")] - public static Az.ServiceBusReceivedMessage CreateServiceBusMessageFromAmqp(this TesterBase tester, AmqpAnnotatedMessage message) - { - if (message == null) throw new ArgumentNullException("message"); - - message.Header.DeliveryCount = 1; - message.Header.Durable = true; - message.Header.Priority = 1; - message.Header.TimeToLive = TimeSpan.FromSeconds(60); - - var t = typeof(Az.ServiceBusReceivedMessage); - var c = t.GetConstructor(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, null, new Type[] { typeof(AmqpAnnotatedMessage) }, null); - return c == null - ? throw new InvalidOperationException($"'{typeof(Az.ServiceBusReceivedMessage).Name}' constructor that accepts Type '{typeof(AmqpAnnotatedMessage).Name}' parameter was not found.") - : (Az.ServiceBusReceivedMessage)c.Invoke(new object?[] { message }); - } - - public static Az.ServiceBusReceivedMessage CreateServiceBusMessageFromEvent(this TesterBase tester, EventData @event) - { - if (@event == null) throw new ArgumentNullException("@event"); - var message = (tester.Services.GetService() ?? new EventDataToServiceBusConverter()).Convert(@event); - return CreateServiceBusMessageFromAmqp(tester, message.GetRawAmqpMessage()); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/TestFunction/ServiceBusSubsciberTest.cs b/tests/CoreEx.Test/TestFunction/ServiceBusSubsciberTest.cs deleted file mode 100644 index 051cc3f7..00000000 --- a/tests/CoreEx.Test/TestFunction/ServiceBusSubsciberTest.cs +++ /dev/null @@ -1,361 +0,0 @@ -using CoreEx.Azure.ServiceBus; -using CoreEx.Events; -using CoreEx.Results; -using CoreEx.TestFunction; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; -using System; -using System.Diagnostics; -using System.Threading.Tasks; -using UnitTestEx; - -namespace CoreEx.Test.TestFunction -{ - [TestFixture] - public class ServiceBusSubsciberTest - { - [Test] - public void NoAbandonOnTransient() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = false; - - Assert.ThrowsAsync(() => sbs.ReceiveAsync(message, actions, (_, _) => throw new TransientException("Retry again please."))); - - actions.AssertRenew(0); - actions.AssertNone(); - } - - [Test] - public void NoAbandonOnTransient_WithResult() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = false; - - Assert.ThrowsAsync(() => sbs.ReceiveAsync(message, actions, (_, _) => Task.FromResult(Result.TransientError("Retry again please.")))); - - actions.AssertRenew(0); - actions.AssertNone(); - } - - [Test] - public async Task AbandonOnTransient() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - - await sbs.ReceiveAsync(message, actions, (_, _) => throw new TransientException("Retry again please.")); - - actions.AssertRenew(0); - actions.AssertAbandon(); - - Assert.Multiple(() => - { - Assert.That(actions.PropertiesModified, Is.Not.Null); - Assert.That(actions.PropertiesModified!["SubscriberAbandonReason"], Is.EqualTo("Retry again please.")); - }); - } - - [Test] - public async Task AbandonOnTransient_WithResult() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - - await sbs.ReceiveAsync(message, actions, (_, _) => Task.FromResult(Result.TransientError("Retry again please."))); - - actions.AssertRenew(0); - actions.AssertAbandon(); - - Assert.Multiple(() => - { - Assert.That(actions.PropertiesModified, Is.Not.Null); - Assert.That(actions.PropertiesModified!["SubscriberAbandonReason"], Is.EqualTo("Retry again please.")); - }); - } - - [Test] - public async Task RetryDelay_DeliveryCount1() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - sbs.RetryDelay = TimeSpan.FromSeconds(1); - - var sw = Stopwatch.StartNew(); - await sbs.ReceiveAsync(message, actions, (_, _) => throw new TransientException("Retry again please.")); - sw.Stop(); - - Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(950)); - - actions.AssertRenew(0); // Renew is no longer supported; hence 0. - actions.AssertAbandon(); - - Assert.Multiple(() => - { - Assert.That(actions.PropertiesModified, Is.Not.Null); - Assert.That(actions.PropertiesModified!["SubscriberAbandonReason"], Is.EqualTo("Retry again please.")); - }); - } - - [Test] - public async Task RetryDelay_DeliveryCount1_WithResult() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - sbs.RetryDelay = TimeSpan.FromSeconds(1); - - var sw = Stopwatch.StartNew(); - await sbs.ReceiveAsync(message, actions, (_, _) => Task.FromResult(Result.TransientError("Retry again please."))); - sw.Stop(); - - Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(950)); - - actions.AssertRenew(0); // Renew is no longer supported; hence 0. - actions.AssertAbandon(); - - Assert.Multiple(() => - { - Assert.That(actions.PropertiesModified, Is.Not.Null); - Assert.That(actions.PropertiesModified!["SubscriberAbandonReason"], Is.EqualTo("Retry again please.")); - }); - } - - [Test] - public async Task RetryDelay_DeliveryCount2() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }, m => m.Header.DeliveryCount = 2); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - sbs.RetryDelay = TimeSpan.FromSeconds(1); - - var sw = Stopwatch.StartNew(); - await sbs.ReceiveAsync(message, actions, (_, _) => throw new TransientException("Retry again please.")); - sw.Stop(); - - Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(1950)); - - actions.AssertRenew(0); // Renew is no longer supported; hence 0. - actions.AssertAbandon(); - - Assert.Multiple(() => - { - Assert.That(actions.PropertiesModified, Is.Not.Null); - Assert.That(actions.PropertiesModified!["SubscriberAbandonReason"], Is.EqualTo("Retry again please.")); - }); - } - - [Test] - public async Task RetryDelay_DeliveryCount2_WithMax() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }, m => m.Header.DeliveryCount = 2); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - sbs.RetryDelay = TimeSpan.FromSeconds(1); - sbs.MaxRetryDelay = TimeSpan.FromMilliseconds(1100); - - var sw = Stopwatch.StartNew(); - await sbs.ReceiveAsync(message, actions, (_, _) => throw new TransientException("Retry again please.")); - sw.Stop(); - - Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(1050)); - - actions.AssertRenew(0); // Renew is no longer supported; hence 0. - actions.AssertAbandon(); - - Assert.Multiple(() => - { - Assert.That(actions.PropertiesModified, Is.Not.Null); - Assert.That(actions.PropertiesModified!["SubscriberAbandonReason"], Is.EqualTo("Retry again please.")); - }); - } - - [Test] - public async Task RetryDelay_DeliveryCount2_WithMaxOnly() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }, m => m.Header.DeliveryCount = 2); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - sbs.MaxRetryDelay = TimeSpan.FromMilliseconds(600); - - var sw = Stopwatch.StartNew(); - await sbs.ReceiveAsync(message, actions, (_, _) => throw new TransientException("Retry again please.")); - sw.Stop(); - - Assert.That(sw.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(550)); - - actions.AssertRenew(0); // Renew is no longer supported; hence 0. - actions.AssertAbandon(); - - Assert.Multiple(() => - { - Assert.That(actions.PropertiesModified, Is.Not.Null); - Assert.That(actions.PropertiesModified!["SubscriberAbandonReason"], Is.EqualTo("Retry again please.")); - }); - } - - [Test] - public async Task MaxDeliveryCount_LessThan() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }, m => m.Header.DeliveryCount = 2); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - sbs.MaxDeliveryCount = 3; - - await sbs.ReceiveAsync(message, actions, (_, _) => throw new TransientException("Retry again please.")); - - actions.AssertRenew(0); - actions.AssertAbandon(); - - Assert.Multiple(() => - { - Assert.That(actions.PropertiesModified, Is.Not.Null); - Assert.That(actions.PropertiesModified!["SubscriberAbandonReason"], Is.EqualTo("Retry again please.")); - }); - } - - [Test] - public async Task MaxDeliveryCount_EqualTo() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }, m => m.Header.DeliveryCount = 3); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - sbs.MaxDeliveryCount = 3; - - await sbs.ReceiveAsync(message, actions, (_, _) => throw new TransientException("Retry again please.")); - - actions.AssertRenew(0); - actions.AssertDeadLetter("MaxDeliveryCountExceeded", "Message could not be consumed after 3 attempts (as defined by ServiceBusSubscriber)."); - Assert.That(actions.PropertiesModified, Is.Not.Null); - Assert.That(actions.PropertiesModified!["SubscriberException"], Is.Not.Null); - } - - [Test] - public async Task Complete() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }, m => m.Header.DeliveryCount = 3); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - - await sbs.ReceiveAsync(message, actions, (_, _) => Task.CompletedTask); - - actions.AssertRenew(0); - actions.AssertComplete(); - } - - [Test] - public async Task Complete_Result() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }, m => m.Header.DeliveryCount = 3); - - var sbs = test.Services.GetRequiredService(); - sbs.AbandonOnTransient = true; - - await sbs.ReceiveAsync(message, actions, (_, _) => Result.SuccessTask); - - actions.AssertRenew(0); - actions.AssertComplete(); - } - - [Test] - public async Task Unhandled_Throw_DeadLetter() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - var sbs = test.Services.GetRequiredService(); - sbs.UnhandledHandling = Events.Subscribing.ErrorHandling.HandleBySubscriber; - - await sbs.ReceiveAsync(message, actions, (_, _) => throw new DivideByZeroException("Zero is bad dude!")); - - actions.AssertRenew(0); - actions.AssertDeadLetter("UnhandledError", "Zero is bad dude!"); - } - - [Test] - public async Task Unhandled_None_Bubble() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - var sbs = test.Services.GetRequiredService(); - sbs.UnhandledHandling = Events.Subscribing.ErrorHandling.HandleByHost; - - try - { - await sbs.ReceiveAsync(message, actions, (_, _) => throw new DivideByZeroException("Zero is bad dude!")); - } - catch (DivideByZeroException) - { - } - catch (Exception ex) - { - Assert.Fail($"Expected {nameof(DivideByZeroException)} but got {ex.GetType().Name}."); - } - - actions.AssertRenew(0); - actions.AssertNone(); - } - - [Test] - public async Task Unhandled_ContinueAsSilent_Complete() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWebJobsServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - var sbs = test.Services.GetRequiredService(); - sbs.UnhandledHandling = Events.Subscribing.ErrorHandling.CompleteAsSilent; - - await sbs.ReceiveAsync(message, actions, (_, _) => throw new DivideByZeroException("Zero is bad dude!")); - - actions.AssertRenew(0); - actions.AssertComplete(); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/TestFunction/ServiceBusTriggerTest.cs b/tests/CoreEx.Test/TestFunction/ServiceBusTriggerTest.cs deleted file mode 100644 index 83908ee9..00000000 --- a/tests/CoreEx.Test/TestFunction/ServiceBusTriggerTest.cs +++ /dev/null @@ -1,188 +0,0 @@ -using CoreEx.Abstractions; -using CoreEx.Events; -using CoreEx.Events.Subscribing; -using CoreEx.Json; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Functions; -using CoreEx.TestFunction.Models; -using Microsoft.Azure.WebJobs.ServiceBus; -using Moq; -using NUnit.Framework; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using UnitTestEx; - -namespace CoreEx.Test.TestFunction -{ - [TestFixture] - public class ServiceBusTriggerTest - { - [Test] - public void NullMessage() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(null!); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), ErrorType.ValidationError.ToString(), It.IsNotNull(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void InvalidMessage() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(""); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), EventSubscriberExceptionSource.EventDataDeserialization.ToString(), It.IsNotNull(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void InvalidMessage_Newtonsoft() - { - using var test = FunctionTester.Create() - .ReplaceScoped() - .ReplaceScoped(); - - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(""); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), EventSubscriberExceptionSource.EventDataDeserialization.ToString(), It.IsNotNull(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void InvalidValue() - { - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", price = 1.99m }); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), ErrorType.ValidationError.ToString(), It.IsNotNull(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void InvalidValue_Newtonsoft() - { - using var test = FunctionTester.Create() - .ReplaceScoped() - .ReplaceScoped(); - - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", price = 1.99m }); - - test.ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), ErrorType.ValidationError.ToString(), It.IsNotNull(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void TransientError() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "products").WithJsonBody(new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m }).Respond.With(HttpStatusCode.InternalServerError); - - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - test.ReplaceHttpClientFactory(mcf) - .ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertException("Response status code was InternalServerError >= 500."); - - mc.Verify(); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void UnhandledError() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "products").WithJsonBody(new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m }).Respond.With(HttpStatusCode.Ambiguous); - - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - test.ReplaceHttpClientFactory(mcf) - .ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - mc.Verify(); - actionsMock.Verify(m => m.DeadLetterMessageAsync(message, It.IsAny>(), ErrorType.UnhandledError.ToString(), It.IsNotNull(), default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void Success() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "products").WithJsonBody(new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m }).Respond.WithJson(new BackendProduct { Code = "AX", Description = "BX", RetailPrice = 10.99m }); - - using var test = FunctionTester.Create(); - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - test.ReplaceHttpClientFactory(mcf) - .ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - mc.Verify(); - actionsMock.Verify(m => m.CompleteMessageAsync(message, default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - - [Test] - public void Success_Newtonsoft() - { - var mcf = MockHttpClientFactory.Create(); - var mc = mcf.CreateClient("Backend", "https://backend/"); - mc.Request(HttpMethod.Post, "products").WithJsonBody(new BackendProduct { Code = "A", Description = "B", RetailPrice = 1.99m }).Respond.WithJson(new BackendProduct { Code = "AX", Description = "BX", RetailPrice = 10.99m }); - - using var test = FunctionTester.Create() - .ReplaceScoped() - .ReplaceScoped(); - - var actionsMock = new Mock(); - var message = test.CreateServiceBusMessageFromValue(new { id = "A", name = "B", price = 1.99m }); - - test.ReplaceHttpClientFactory(mcf) - .ServiceBusTrigger() - .Run(f => f.RunAsync(message, actionsMock.Object)) - .AssertSuccess(); - - mc.Verify(); - actionsMock.Verify(m => m.CompleteMessageAsync(message, default), Times.Once); - actionsMock.VerifyNoOtherCalls(); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test/appsettings.unittest.json b/tests/CoreEx.Test/appsettings.unittest.json deleted file mode 100644 index 957e73ba..00000000 --- a/tests/CoreEx.Test/appsettings.unittest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "ServiceBusConnection": "test-connection-string", - "TestQueueName": "unit-test-queue", - "TestTopicName": "unit-test-topic", - "TestSubscriptionName": "unit-test-subscription", - "TestQueueNameSessions": "unit-test-queue-sessions" -} diff --git a/tests/CoreEx.Test2/CoreEx.Test2.csproj b/tests/CoreEx.Test2/CoreEx.Test2.csproj deleted file mode 100644 index 54e6d529..00000000 --- a/tests/CoreEx.Test2/CoreEx.Test2.csproj +++ /dev/null @@ -1,37 +0,0 @@ - - - - net8.0 - enable - enable - preview - false - true - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - diff --git a/tests/CoreEx.Test2/Framework/Validation/ValidationExtensionsTest.cs b/tests/CoreEx.Test2/Framework/Validation/ValidationExtensionsTest.cs deleted file mode 100644 index 9a5a2b38..00000000 --- a/tests/CoreEx.Test2/Framework/Validation/ValidationExtensionsTest.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CoreEx.Results; -using CoreEx.Validation; - -namespace CoreEx.Test2.Framework.Validation -{ - [TestFixture] - public class ValidationExtensionsTest - { - [Test] - public async Task Validation_Success_Other_String() - { - string? email = "a@b"; - var r = await Result.Go(email).ValidatesAsync(email, v => v.Email()); - Assert.That(r.IsSuccess, Is.True); - } - - [Test] - public async Task Validation_Success_Other_String2() - { - string? email = "a@b"; - var r = await ValidateAsync(email); - Assert.That(r.IsSuccess, Is.True); - } - - private static async Task> ValidateAsync(string? email2) - { - return await Result.Go().ValidatesAsync(email2, v => - { - var v2 = v; - var v3 = v2.Email(); - }); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test2/GlobalUsings.cs b/tests/CoreEx.Test2/GlobalUsings.cs deleted file mode 100644 index cefced49..00000000 --- a/tests/CoreEx.Test2/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using NUnit.Framework; \ No newline at end of file diff --git a/tests/CoreEx.Test2/TestFunctionIso/HttpFunctionTest.cs b/tests/CoreEx.Test2/TestFunctionIso/HttpFunctionTest.cs deleted file mode 100644 index 80b143b6..00000000 --- a/tests/CoreEx.Test2/TestFunctionIso/HttpFunctionTest.cs +++ /dev/null @@ -1,19 +0,0 @@ -using CoreEx.TestFunctionIso; -using UnitTestEx; - -namespace CoreEx.Test2.TestFunctionIso -{ - [TestFixture] - public class HttpFunctionTest - { - [Test] - public void Get() - { - using var test = FunctionTester.Create(); - test.HttpTrigger() - .Run(f => f.Run(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/blah"), "blah")) - .AssertOK() - .AssertJson("{\"message\":\"Hello blah\"}"); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Test2/TestFunctionIso/ServiceBusTest.cs b/tests/CoreEx.Test2/TestFunctionIso/ServiceBusTest.cs deleted file mode 100644 index 84470217..00000000 --- a/tests/CoreEx.Test2/TestFunctionIso/ServiceBusTest.cs +++ /dev/null @@ -1,78 +0,0 @@ -using CoreEx.Hosting.Work; -using CoreEx.TestFunctionIso; -using Microsoft.Extensions.DependencyInjection; -using UnitTestEx.Expectations; -using UnitTestEx; - -namespace CoreEx.Test2.TestFunctionIso -{ - [TestFixture] - public class ServiceBusTest - { - [Test] - public async Task InvalidMessage() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWorkerServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue(new { Blah = true }); - - var wo = test.Services.GetRequiredService(); - await wo.CreateAsync(new WorkStateArgs("test") { Id = message.MessageId }); - - test.ServiceBusTrigger() - .Run(f => f.Run(message, actions)) - .AssertSuccess(); - - actions.AssertDeadLetter("EventDataDeserialization", "Invalid message; body was not provided, contained invalid JSON, or was incorrectly formatted: The JSON value could not be converted to System.String"); - - var ws = await wo.GetAsync(message.MessageId); - Assert.That(ws, Is.Not.Null); - Assert.That(ws!.Status, Is.EqualTo(WorkStatus.Failed)); - } - - [Test] - public async Task Complete_WorkStatus_Cancelled() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWorkerServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue("Bob"); - - var wo = test.Services.GetRequiredService(); - await wo.CreateAsync(new WorkStateArgs("test") { Id = message.MessageId }); - await wo.CancelAsync(message.MessageId, "No longer needed."); - - test.ServiceBusTrigger() - .ExpectLogContains("warn: Unable to process message as corresponding work state status is Canceled: No longer needed.") - .Run(f => f.Run(message, actions)) - .AssertSuccess(); - - actions.AssertComplete(); - - var ws = await wo.GetAsync(message.MessageId); - Assert.That(ws, Is.Not.Null); - Assert.That(ws!.Status, Is.EqualTo(WorkStatus.Canceled)); - } - - [Test] - public async Task Success() - { - using var test = FunctionTester.Create(); - var actions = test.CreateWorkerServiceBusMessageActions(); - var message = test.CreateServiceBusMessageFromValue("Bob"); - - var wo = test.Services.GetRequiredService(); - await wo.CreateAsync(new WorkStateArgs("test") { Id = message.MessageId }); - - test.ServiceBusTrigger() - .ExpectLogContains("Received message: Bob") - .Run(f => f.Run(message, actions)) - .AssertSuccess(); - - actions.AssertComplete(); - - var ws = await wo.GetAsync(message.MessageId); - Assert.That(ws, Is.Not.Null); - Assert.That(ws!.Status, Is.EqualTo(WorkStatus.Completed)); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestApi/Controllers/ProductController.cs b/tests/CoreEx.TestApi/Controllers/ProductController.cs deleted file mode 100644 index 24fb2501..00000000 --- a/tests/CoreEx.TestApi/Controllers/ProductController.cs +++ /dev/null @@ -1,44 +0,0 @@ -using CoreEx.FluentValidation; -using CoreEx.AspNetCore.WebApis; -using CoreEx.TestFunction.Models; -using CoreEx.TestFunction.Services; -using CoreEx.TestFunction.Validators; -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; -using System; - -namespace CoreEx.TestApi.Controllers -{ - [ApiController] - [Route("products")] - public class ProductController : ControllerBase - { - private readonly WebApi _webApi; - private readonly ProductService _service; - - public ProductController(WebApi webApi, ProductService service) - { - _webApi = webApi; - _service = service; - } - - [HttpGet] - [Route("{id}")] - public Task GetAsync(string id) => _webApi.GetAsync(Request, _ => _service.GetProductAsync(id)); - - [HttpPost] - public Task PostAsync() => _webApi.PostAsync(Request, r => _service.AddProductAsync(r.Value), validator: new ProductValidator().Wrap()); - - [HttpPut] - [Route("{id}")] - public Task PutAsync(string id) => _webApi.PutAsync(Request, r => _service.UpdateProductAsync(r.Value, id)); - - [HttpDelete] - [Route("{id}")] - public Task DeleteAsync(string id) => _webApi.DeleteAsync(Request, _ => _service.DeleteProductAsync(id)); - - [HttpGet] - [Route("{id}/catalogue")] - public Task GetCatalogueAsync(string id) => _webApi.GetAsync(Request, _ => _service.GetCatalogueAsync(id)); - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestApi/CoreEx.TestApi.csproj b/tests/CoreEx.TestApi/CoreEx.TestApi.csproj deleted file mode 100644 index 994c8466..00000000 --- a/tests/CoreEx.TestApi/CoreEx.TestApi.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net8.0 - - - - - - - - - - - - - diff --git a/tests/CoreEx.TestApi/Program.cs b/tests/CoreEx.TestApi/Program.cs deleted file mode 100644 index d510a39c..00000000 --- a/tests/CoreEx.TestApi/Program.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace CoreEx.TestApi -{ - public class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } -} diff --git a/tests/CoreEx.TestApi/Startup.cs b/tests/CoreEx.TestApi/Startup.cs deleted file mode 100644 index 6c5dd7e0..00000000 --- a/tests/CoreEx.TestApi/Startup.cs +++ /dev/null @@ -1,67 +0,0 @@ -using CoreEx.Configuration; -using CoreEx.Events; -using CoreEx.Json; -using CoreEx.TestFunction; -using CoreEx.TestFunction.Services; -using CoreEx.AspNetCore.WebApis; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using CoreEx.TestApi.Validation; - -namespace CoreEx.TestApi -{ - public class Startup - { - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - // Register the core services. - services - .AddSettings() - .AddExecutionContext() - .AddJsonSerializer() - .AddEventDataSerializer() - .AddNullEventPublisher() - .AddValidationTextProvider() - .AddScoped(); - - // Register the typed backend http client. - services.AddTypedHttpClient("Backend", (sp, hc) => - { - var settings = sp.GetService(); - hc.BaseAddress = settings.BackendBaseAddress; - }); - - // Register the underlying function services. - services - .AddAutoMapper(typeof(ProductService).Assembly) - .AddScoped() - .AddScoped(); - - services.AddControllers(); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseHttpsRedirection(); - - app.UseRouting(); - - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }); - } - } -} diff --git a/tests/CoreEx.TestApi/Validators/ProductValidator.cs b/tests/CoreEx.TestApi/Validators/ProductValidator.cs deleted file mode 100644 index 6a3917f7..00000000 --- a/tests/CoreEx.TestApi/Validators/ProductValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CoreEx.TestFunction.Models; -using CoreEx.Validation; - -namespace CoreEx.TestApi.Validation -{ - public class ProductValidator : Validator - { - public ProductValidator() - { - Property(p => p.Name).Mandatory(); - Property(p => p.Price).Between(0, 100); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestApi/appsettings.Development.json b/tests/CoreEx.TestApi/appsettings.Development.json deleted file mode 100644 index 8983e0fc..00000000 --- a/tests/CoreEx.TestApi/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/tests/CoreEx.TestApi/appsettings.json b/tests/CoreEx.TestApi/appsettings.json deleted file mode 100644 index 4a5a3f5a..00000000 --- a/tests/CoreEx.TestApi/appsettings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AllowedHosts": "*", - "Test/BackendBaseAddress": "https://backend/" -} diff --git a/tests/CoreEx.TestFunction/BackendHttpClient.cs b/tests/CoreEx.TestFunction/BackendHttpClient.cs deleted file mode 100644 index 26ccf624..00000000 --- a/tests/CoreEx.TestFunction/BackendHttpClient.cs +++ /dev/null @@ -1,8 +0,0 @@ -using CoreEx.Http; -using CoreEx.Json; -using System.Net.Http; - -namespace CoreEx.TestFunction -{ - public class BackendHttpClient(HttpClient client, IJsonSerializer jsonSerializer, ExecutionContext executionContext) : TypedHttpClientCore(client, jsonSerializer, executionContext) { } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj b/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj deleted file mode 100644 index 8684ed09..00000000 --- a/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - net8.0 - v4 - enable - latest - - - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - Never - - - diff --git a/tests/CoreEx.TestFunction/Functions/HttpTriggerFunction.cs b/tests/CoreEx.TestFunction/Functions/HttpTriggerFunction.cs deleted file mode 100644 index ee3a2040..00000000 --- a/tests/CoreEx.TestFunction/Functions/HttpTriggerFunction.cs +++ /dev/null @@ -1,30 +0,0 @@ -using CoreEx.FluentValidation; -using CoreEx.TestFunction.Services; -using CoreEx.TestFunction.Validators; -using CoreEx.AspNetCore.WebApis; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Extensions.Http; -using System.Threading.Tasks; - -namespace CoreEx.TestFunction.Functions -{ - public class HttpTriggerFunction(WebApi webApi, ProductService service) - { - private readonly WebApi _webApi = webApi; - private readonly ProductService _service = service; - - [FunctionName("HttpTriggerProductGet")] - public Task GetAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "products/{id}")] HttpRequest request, string id) - => _webApi.GetAsync(request, (_, __) => _service.GetProductAsync(id)); - - [FunctionName("HttpTriggerProductPost")] - public Task PostAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "products")] HttpRequest request) - => _webApi.PostAsync(request, (r, _) => _service.AddProductAsync(r.Value!), validator: new ProductValidator().Wrap()); - - [FunctionName("HttpTriggerProductPut")] - public Task PutAsync([HttpTrigger(AuthorizationLevel.Function, "put", Route = "products")] HttpRequest request) - => _webApi.PutAsync(request, (r, _) => _service.UpdateProductAsync(r.Value!, r.Value!.Id!), validator: new ProductValidator().Wrap()); - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Functions/HttpTriggerPublishFunction.cs b/tests/CoreEx.TestFunction/Functions/HttpTriggerPublishFunction.cs deleted file mode 100644 index 3652d3bc..00000000 --- a/tests/CoreEx.TestFunction/Functions/HttpTriggerPublishFunction.cs +++ /dev/null @@ -1,26 +0,0 @@ -using CoreEx.FluentValidation; -using CoreEx.TestFunction.Models; -using CoreEx.TestFunction.Validators; -using CoreEx.AspNetCore.WebApis; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Extensions.Http; -using System.Threading.Tasks; - -namespace CoreEx.TestFunction.Functions -{ - public class HttpTriggerPublishFunction - { - private readonly WebApiPublisher _webApiPublisher; - - public HttpTriggerPublishFunction(WebApiPublisher webApiPublisher) - { - _webApiPublisher = webApiPublisher; - } - - [FunctionName("HttpTriggerPublishFunction")] - public Task RunAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "products/publish")] HttpRequest request) - => _webApiPublisher.PublishAsync(request, new WebApiPublisherArgs("test-queue", new ProductValidator().Wrap())); - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Functions/ServiceBusOrchestratedTriggerFunction.cs b/tests/CoreEx.TestFunction/Functions/ServiceBusOrchestratedTriggerFunction.cs deleted file mode 100644 index 6965b276..00000000 --- a/tests/CoreEx.TestFunction/Functions/ServiceBusOrchestratedTriggerFunction.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Azure.Messaging.ServiceBus; -using CoreEx.Azure.ServiceBus; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.ServiceBus; -using System.Threading.Tasks; - -namespace CoreEx.TestFunction.Functions -{ - public class ServiceBusOrchestratedTriggerFunction - { - private readonly ServiceBusOrchestratedSubscriber _subscriber; - - public ServiceBusOrchestratedTriggerFunction(ServiceBusOrchestratedSubscriber subscriber) - => _subscriber = subscriber; - - [FunctionName("ServiceBusOrchestratedFunction")] - [ExponentialBackoffRetry(3, "00:02:00", "00:30:00")] - public Task RunAsync([ServiceBusTrigger("%" + nameof(TestSettings.OrchestratedQueueName) + "%", Connection = nameof(TestSettings.ServiceBusConnection))] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions) - => _subscriber.ReceiveAsync(message, messageActions); - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Functions/ServiceBusTriggerFunction.cs b/tests/CoreEx.TestFunction/Functions/ServiceBusTriggerFunction.cs deleted file mode 100644 index 5895cd1d..00000000 --- a/tests/CoreEx.TestFunction/Functions/ServiceBusTriggerFunction.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Azure.Messaging.ServiceBus; -using CoreEx.FluentValidation; -using CoreEx.Azure.ServiceBus; -using CoreEx.TestFunction.Models; -using CoreEx.TestFunction.Services; -using CoreEx.TestFunction.Validators; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.ServiceBus; -using System.Threading.Tasks; - -namespace CoreEx.TestFunction.Functions -{ - public class ServiceBusTriggerFunction - { - private readonly ServiceBusSubscriber _subscriber; - private readonly ProductService _service; - - public ServiceBusTriggerFunction(ServiceBusSubscriber subscriber, ProductService service) - { - _subscriber = subscriber; - _service = service; - } - - [FunctionName("ServiceBusFunction")] - [ExponentialBackoffRetry(3, "00:02:00", "00:30:00")] - public Task RunAsync([ServiceBusTrigger("%" + nameof(TestSettings.QueueName) + "%", Connection = nameof(TestSettings.ServiceBusConnection))] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions) - => _subscriber.ReceiveAsync(message, messageActions, (ed, _) => _service.UpdateProductAsync(ed.Value!, ed.Value.Id!), validator: new ProductValidator().Wrap()); - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Mappers/ProductMapperProfile.cs b/tests/CoreEx.TestFunction/Mappers/ProductMapperProfile.cs deleted file mode 100644 index bd9c9fdf..00000000 --- a/tests/CoreEx.TestFunction/Mappers/ProductMapperProfile.cs +++ /dev/null @@ -1,17 +0,0 @@ -using AutoMapper; -using CoreEx.TestFunction.Models; - -namespace CoreEx.TestFunction.Mappers -{ - internal class ProductMapperProfile : Profile - { - public ProductMapperProfile() - { - CreateMap() - .ForMember(d => d.Code, o => o.MapFrom(s => s.Id)) - .ForMember(d => d.Description, o => o.MapFrom(s => s.Name)) - .ForMember(d => d.RetailPrice, o => o.MapFrom(s => s.Price)) - .ReverseMap(); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Models/Product.cs b/tests/CoreEx.TestFunction/Models/Product.cs deleted file mode 100644 index d376cda4..00000000 --- a/tests/CoreEx.TestFunction/Models/Product.cs +++ /dev/null @@ -1,51 +0,0 @@ -using CoreEx.Entities; -using System.Collections.Generic; -using Nsj = Newtonsoft.Json; -using Stj = System.Text.Json.Serialization; - -namespace CoreEx.TestFunction.Models -{ - public class Product : IIdentifier - { - [Nsj.JsonProperty("id")] - [Stj.JsonPropertyName("id")] - public string? Id { get; set; } - - [Nsj.JsonProperty("name")] - [Stj.JsonPropertyName("name")] - public string? Name { get; set; } - - [Nsj.JsonProperty("price")] - [Stj.JsonPropertyName("price")] - public decimal Price { get; set; } - } - - public class ProductCollection : List { } - - public class ProductCollectionResult : CollectionResult { } - - public class BackendProduct : IPrimaryKey - { - [Nsj.JsonProperty("code")] - [Stj.JsonPropertyName("code")] - public string Code { get; set; } = string.Empty; - - [Nsj.JsonProperty("description")] - [Stj.JsonPropertyName("description")] - public string? Description { get; set; } - - [Nsj.JsonProperty("retailPrice")] - [Stj.JsonPropertyName("retailPrice")] - public decimal RetailPrice { get; set; } - - [Nsj.JsonIgnore] - [Stj.JsonIgnore] - public string? Secret { get; set; } - - [Nsj.JsonIgnore] - [Stj.JsonIgnore] - public CompositeKey PrimaryKey => new(Code); - - public string? ETag { get; set; } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Properties/serviceDependencies.json b/tests/CoreEx.TestFunction/Properties/serviceDependencies.json deleted file mode 100644 index df4dcc9d..00000000 --- a/tests/CoreEx.TestFunction/Properties/serviceDependencies.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "dependencies": { - "appInsights1": { - "type": "appInsights" - }, - "storage1": { - "type": "storage", - "connectionId": "AzureWebJobsStorage" - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Properties/serviceDependencies.local.json b/tests/CoreEx.TestFunction/Properties/serviceDependencies.local.json deleted file mode 100644 index b804a289..00000000 --- a/tests/CoreEx.TestFunction/Properties/serviceDependencies.local.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "dependencies": { - "appInsights1": { - "type": "appInsights.sdk" - }, - "storage1": { - "type": "storage.emulator", - "connectionId": "AzureWebJobsStorage" - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Services/ProductService.cs b/tests/CoreEx.TestFunction/Services/ProductService.cs deleted file mode 100644 index 02c351e0..00000000 --- a/tests/CoreEx.TestFunction/Services/ProductService.cs +++ /dev/null @@ -1,65 +0,0 @@ -using AutoMapper; -using CoreEx.TestFunction.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System.Collections.Generic; -using System.Text; -using System.Threading.Tasks; - -namespace CoreEx.TestFunction.Services -{ - public class ProductService - { - private readonly BackendHttpClient _backend; - private readonly IMapper _mapper; - private readonly ILogger _logger; - - public ProductService(BackendHttpClient backend, IMapper mapper, ILogger logger) - { - _backend = backend; - _mapper = mapper; - _logger = logger; - } - - public Task GetProductAsync(string id) - { - if (id == "Zed") - return Task.FromResult(null!); - - return Task.FromResult(new Product { Id = id, Name = "Apple", Price = 0.79m }); - } - - public Task AddProductAsync(Product product) - { - product.Id = "new"; - return Task.FromResult(product); - } - - public async Task UpdateProductAsync(Product product, string id) - { - product.Id = id; - using (_logger.BeginScope(new Dictionary() { { "ProductId", product.Id } })) - { - if (product.Id == "Zed") - throw new ValidationException("Zed is dead."); - - var bep = _mapper.Map(product); - var r = await _backend.ThrowTransientException().ThrowKnownException().EnsureSuccess().PostAsync("products", bep).ConfigureAwait(false); - - return _mapper.Map(r.Value); - } - } - - public Task DeleteProductAsync(string id) - { - _logger.LogInformation($"Deleting product {id}."); - return Task.CompletedTask; - } - - public Task GetCatalogueAsync(string id) - { - var fcr = new FileContentResult(Encoding.ASCII.GetBytes($"Catalog for '{id}'."), "text/plain"); - return Task.FromResult(fcr); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Startup.cs b/tests/CoreEx.TestFunction/Startup.cs deleted file mode 100644 index ef8ac3ae..00000000 --- a/tests/CoreEx.TestFunction/Startup.cs +++ /dev/null @@ -1,65 +0,0 @@ -using CoreEx.Azure.ServiceBus; -using CoreEx.TestFunction.Services; -using CoreEx.AspNetCore.HealthChecks; -using Microsoft.Azure.Functions.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection; -using CoreEx.Events.Subscribing; -using CoreEx.TestFunction.Subscribers; -using CoreEx.AspNetCore.WebApis; -using CoreEx.Http.HealthChecks; - -[assembly: FunctionsStartup(typeof(CoreEx.TestFunction.Startup))] - -namespace CoreEx.TestFunction -{ - public class Startup : FunctionsStartup - { - public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder) - { - } - - public override void Configure(IFunctionsHostBuilder builder) - { - // Register the core services. - builder.Services - .AddSettings() - .AddExecutionContext() - .AddJsonSerializer() - //.AddNewtonsoftJsonSerializer() - .AddEventDataSerializer() - //.AddCloudEventSerializer() - //.AddNewtonsoftEventDataSerializer() - //.AddNewtonsoftCloudEventSerializer() - //.AddScoped() - // replace by your own implementation of IEventPublisher to send events to e.g. service bus - .AddNullEventPublisher() - .AddScoped() - .AddJsonMergePatch() - .AddScoped() - .AddScoped(); - - // Register orchestrated. - builder.Services - .AddEventSubscribers() - .AddEventSubscriberOrchestrator((_, eso) => eso.AddSubscribers(EventSubscriberOrchestrator.GetSubscribers())) - .AddAzureServiceBusOrchestratedSubscriber(); - - // Register the health checks. - builder.Services - .AddHealthChecks() - .AddTypeActivatedCheck>("Backend"); - - // Register the typed backend http client. - builder.Services.AddTypedHttpClient("Backend", (sp, hc) => - { - var settings = sp.GetRequiredService(); - hc.BaseAddress = settings.BackendBaseAddress; - }); - - // Register the underlying function services. - builder.Services - .AddAutoMapper(typeof(ProductService).Assembly) - .AddScoped(); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Subscribers/NoValueSubscriber.cs b/tests/CoreEx.TestFunction/Subscribers/NoValueSubscriber.cs deleted file mode 100644 index eccfabd6..00000000 --- a/tests/CoreEx.TestFunction/Subscribers/NoValueSubscriber.cs +++ /dev/null @@ -1,27 +0,0 @@ -using CoreEx.Events; -using CoreEx.Events.Subscribing; -using CoreEx.Results; -using Microsoft.Extensions.Logging; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.TestFunction.Subscribers -{ - [EventSubscriber("my.novalue")] - public class NoValueSubscriber : SubscriberBase - { - private readonly ILogger _logger; - - public static HashSet EventIds { get; } = new HashSet(); - - public NoValueSubscriber(ILogger logger) => _logger = logger; - - public override Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) - { - EventIds.Add(@event.Id!); - _logger.LogInformation($"Message {@event.Id} was received."); - return Task.FromResult(Result.Success); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Subscribers/ProductSubscriber.cs b/tests/CoreEx.TestFunction/Subscribers/ProductSubscriber.cs deleted file mode 100644 index 072ef907..00000000 --- a/tests/CoreEx.TestFunction/Subscribers/ProductSubscriber.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CoreEx.Events; -using CoreEx.Events.Subscribing; -using CoreEx.FluentValidation; -using CoreEx.Results; -using CoreEx.TestFunction.Models; -using CoreEx.TestFunction.Validators; -using Microsoft.Extensions.Logging; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace CoreEx.TestFunction.Subscribers -{ - [EventSubscriber("my.product")] - public class ProductSubscriber(ILogger logger) : SubscriberBase(new ProductValidator().Wrap()) - { - private readonly ILogger _logger = logger; - - public static HashSet EventIds { get; } = []; - - public async override Task ReceiveAsync(EventData @event, EventSubscriberArgs args, CancellationToken cancellationToken) - { - await Task.CompletedTask.ConfigureAwait(false); - EventIds.Add(@event.Id!); - _logger.LogInformation($"Message {@event.Id} for Product {@event.Value.Id} was received."); - - if (@event.Value.Id == "PS5") - return Result.TransientError($"{@event.Value.Name} is currently not permissable; please try again later."); - - return Result.Success; - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/TestSettings.cs b/tests/CoreEx.TestFunction/TestSettings.cs deleted file mode 100644 index 17f7c295..00000000 --- a/tests/CoreEx.TestFunction/TestSettings.cs +++ /dev/null @@ -1,58 +0,0 @@ -using CoreEx.Configuration; -using Microsoft.Extensions.Configuration; -using System; - -namespace CoreEx.TestFunction -{ - public class TestSettings : SettingsBase - { - /// - /// Gets the setting prefixes in order of precedence. - /// - public static string[] Prefixes { get; } = { "Test/", "Common/" }; - - /// - /// Initializes a new instance of the class. - /// - /// The . - public TestSettings(IConfiguration configuration) : base(configuration, Prefixes) { } - - /// - /// Gets the base endpoint/address URI. - /// - public Uri BackendBaseAddress - { - get - { - var uri = GetRequiredValue(nameof(BackendBaseAddress)); - if (Uri.IsWellFormedUriString(uri, UriKind.Absolute)) - return new Uri(uri, UriKind.Absolute); - else - throw new InvalidOperationException($"Configuration key '{nameof(BackendBaseAddress)}' is not a valid URI: {uri}"); - } - } - - /// - /// The Azure Service Bus connection string used for Publishing when service bus is used. - /// - /// It defaults to managed identity connection string used by triggers 'ServiceBusConnection__fullyQualifiedNamespace' - public string ServiceBusConnection => GetValue(defaultValue: ServiceBusConnection__fullyQualifiedNamespace); - - /// - /// The Azure Service Bus connection string used by Triggers using managed identity. - /// - /// Caution this key is used implicitly by function triggers when 'ServiceBusConnection' is not set. - /// Underscores in environment variables are replaced by semicolon ':' in configuration object, hence lookup also replaces '__' with ':' - public string ServiceBusConnection__fullyQualifiedNamespace => GetValue(); - - /// - /// Name of the queue used for function trigger - /// - public string? QueueName { get; set; } - - /// - /// Name of the queue used for function trigger - /// - public string? OrchestratedQueueName { get; set; } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/Validators/ProductValidator.cs b/tests/CoreEx.TestFunction/Validators/ProductValidator.cs deleted file mode 100644 index 2c3d006e..00000000 --- a/tests/CoreEx.TestFunction/Validators/ProductValidator.cs +++ /dev/null @@ -1,27 +0,0 @@ -using CoreEx.TestFunction.Models; -using FluentValidation; -using System.Collections.Generic; - -namespace CoreEx.TestFunction.Validators -{ - public class ProductValidator : AbstractValidator - { - public ProductValidator() - { - RuleFor(x => x.Id).NotEmpty(); - RuleFor(x => x.Name).NotEmpty().Length(0, 100); - RuleFor(x => x.Price).NotEmpty().PrecisionScale(10, 2, true).GreaterThan(0).Custom((v, ctx) => - { - if (ctx.InstanceToValidate.Name == "Widget" && v >= 100) - ctx.AddFailure($"'{ctx.DisplayName}' must be less than $100.00 for a 'Widget'."); - else if (ctx.InstanceToValidate.Name == "DeLorean" && ctx.InstanceToValidate.Price == 88m) - ctx.AddFailure(nameof(Product.Name), "A DeLorean cannot be priced at 88 as that could cause a chain reaction that would unravel the very fabric of the space-time continuum and destroy the entire universe."); - }); - } - } - - public class ProductsValidator : AbstractValidator> - { - public ProductsValidator() => RuleForEach(value => value).SetValidator(new ProductValidator()); - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/appsettings.unittest.json b/tests/CoreEx.TestFunction/appsettings.unittest.json deleted file mode 100644 index 6eb6a280..00000000 --- a/tests/CoreEx.TestFunction/appsettings.unittest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "Test/BackendBaseAddress": "https://backend/" -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/host.json b/tests/CoreEx.TestFunction/host.json deleted file mode 100644 index beb2e402..00000000 --- a/tests/CoreEx.TestFunction/host.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "2.0", - "logging": { - "applicationInsights": { - "samplingSettings": { - "isEnabled": true, - "excludedTypes": "Request" - } - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunction/local.settings.json b/tests/CoreEx.TestFunction/local.settings.json deleted file mode 100644 index 4c5a3118..00000000 --- a/tests/CoreEx.TestFunction/local.settings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "FUNCTIONS_WORKER_RUNTIME": "dotnet", - "ServiceBusConnection": "supersecretconnectionstring", - "Test/BackendBaseAddress": "https://backend/", - "BindingRedirects": "[ { \"ShortName\": \"System.Memory.Data\", \"RedirectToVersion\": \"6.0.0.0\", \"PublicKeyToken\": \"cc7b13ffcd2ddd51\" } ]", - - "Deployment.By": "me", - "Deployment.Build": "build no", - "Deployment.Name": "my deployment", - "Deployment.Version": "1.0.0", - "Deployment.Date": "today" - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunctionIso/.gitignore b/tests/CoreEx.TestFunctionIso/.gitignore deleted file mode 100644 index ff5b00c5..00000000 --- a/tests/CoreEx.TestFunctionIso/.gitignore +++ /dev/null @@ -1,264 +0,0 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. - -# Azure Functions localsettings file -local.settings.json - -# User-specific files -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ - -# Visual Studio 2015 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# DNX -project.lock.json -project.fragment.lock.json -artifacts/ - -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings -# but database connection strings (with potential passwords) will be unencrypted -#*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# The packages folder can be ignored because of Package Restore -**/packages/* -# except build/, which is used as an MSBuild target. -!**/packages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config -# NuGet v3's project.json files produces more ignoreable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -node_modules/ -orleans.codegen.cs - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files -*.mdf -*.ldf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml - -# CodeRush -.cr/ - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc \ No newline at end of file diff --git a/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj b/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj deleted file mode 100644 index 7f01b965..00000000 --- a/tests/CoreEx.TestFunctionIso/CoreEx.TestFunctionIso.csproj +++ /dev/null @@ -1,38 +0,0 @@ - - - net8.0 - v4 - Exe - enable - enable - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - Never - - - - - - \ No newline at end of file diff --git a/tests/CoreEx.TestFunctionIso/HttpFunction.cs b/tests/CoreEx.TestFunctionIso/HttpFunction.cs deleted file mode 100644 index 949d92eb..00000000 --- a/tests/CoreEx.TestFunctionIso/HttpFunction.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CoreEx.AspNetCore.WebApis; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.Functions.Worker; - -namespace CoreEx.TestFunctionIso -{ - public class HttpFunction(WebApi webApi) - { - private readonly WebApi _webApi = webApi; - - [Function("HttpFunction")] - public Task Run([HttpTrigger(AuthorizationLevel.Function, "get", Route = "{id}")] HttpRequest req, string id) - => _webApi.GetAsync(req, _ => Task.FromResult(new { Message = $"Hello {id}" })); - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunctionIso/Program.cs b/tests/CoreEx.TestFunctionIso/Program.cs deleted file mode 100644 index 613cb31d..00000000 --- a/tests/CoreEx.TestFunctionIso/Program.cs +++ /dev/null @@ -1,22 +0,0 @@ -//using Azure.Monitor.OpenTelemetry.AspNetCore; -using CoreEx.Hosting; -using CoreEx.TestFunctionIso; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using OpenTelemetry.Trace; -using Azure.Monitor.OpenTelemetry.Exporter; - -new HostBuilder() - .ConfigureFunctionsWebApplication() - .ConfigureServices(services => - { - services.AddApplicationInsightsTelemetryWorkerService(); - services.ConfigureFunctionsApplicationInsights(); - services.AddOpenTelemetry().WithTracing(b => b.AddSource("CoreEx.*").AddAzureMonitorTraceExporter()); - //services.AddOpenTelemetry().UseAzureMonitor(); - //services.ConfigureOpenTelemetryTracerProvider(tpb => tpb.AddSource("CoreEx.*")); - }) - .ConfigureHostStartup() - .Build() - .Run(); \ No newline at end of file diff --git a/tests/CoreEx.TestFunctionIso/Properties/launchSettings.json b/tests/CoreEx.TestFunctionIso/Properties/launchSettings.json deleted file mode 100644 index 2c217e9a..00000000 --- a/tests/CoreEx.TestFunctionIso/Properties/launchSettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "profiles": { - "CoreEx.TestFunctionIso": { - "commandName": "Project", - "commandLineArgs": "--port 7073", - "launchBrowser": false - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunctionIso/Properties/serviceDependencies.json b/tests/CoreEx.TestFunctionIso/Properties/serviceDependencies.json deleted file mode 100644 index df4dcc9d..00000000 --- a/tests/CoreEx.TestFunctionIso/Properties/serviceDependencies.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "dependencies": { - "appInsights1": { - "type": "appInsights" - }, - "storage1": { - "type": "storage", - "connectionId": "AzureWebJobsStorage" - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunctionIso/Properties/serviceDependencies.local.json b/tests/CoreEx.TestFunctionIso/Properties/serviceDependencies.local.json deleted file mode 100644 index b804a289..00000000 --- a/tests/CoreEx.TestFunctionIso/Properties/serviceDependencies.local.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "dependencies": { - "appInsights1": { - "type": "appInsights.sdk" - }, - "storage1": { - "type": "storage.emulator", - "connectionId": "AzureWebJobsStorage" - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunctionIso/ServiceBusFunction.cs b/tests/CoreEx.TestFunctionIso/ServiceBusFunction.cs deleted file mode 100644 index bb3e579b..00000000 --- a/tests/CoreEx.TestFunctionIso/ServiceBusFunction.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Azure.Messaging.ServiceBus; -using CoreEx.Azure.ServiceBus; -using CoreEx.Results; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Extensions.Logging; - -namespace CoreEx.TestFunctionIso -{ - public class ServiceBusFunction(ServiceBusSubscriber subscriber, ILogger logger) - { - private readonly ServiceBusSubscriber _subscriber = subscriber; - private readonly ILogger _logger = logger; - - [Function(nameof(ServiceBusFunction))] - public Task Run([ServiceBusTrigger("test-queue", Connection = "ServiceBusConnectionString")] ServiceBusReceivedMessage message, ServiceBusMessageActions sbma) - => _subscriber.ReceiveAsync(message, sbma, (@event, args) => - { - if (@event.Value == "not-found") - return Result.NotFoundError().AsTask(); - - _logger.LogInformation($"Received message: {@event.Value}"); - return Result.SuccessTask; - }); - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunctionIso/Startup.cs b/tests/CoreEx.TestFunctionIso/Startup.cs deleted file mode 100644 index 4a12956f..00000000 --- a/tests/CoreEx.TestFunctionIso/Startup.cs +++ /dev/null @@ -1,28 +0,0 @@ -using CoreEx.Hosting; -using CoreEx.Hosting.Work; -using Microsoft.Extensions.DependencyInjection; - -namespace CoreEx.TestFunctionIso -{ - public class Startup : HostStartup - { - public override void ConfigureServices(IServiceCollection services) - { - services - .AddDefaultSettings() - .AddExecutionContext() - .AddJsonSerializer() - .AddWebApi() - .AddEventDataSerializer() - .AddAzureServiceBusSubscriber((sp, s) => - { - s.WorkStateAlreadyFinishedHandling = Events.Subscribing.ErrorHandling.CompleteWithWarning; - s.WorkStateOrchestrator = sp.GetRequiredService(); - }); - - services - .AddSingleton() - .AddSingleton(); - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.TestFunctionIso/host.json b/tests/CoreEx.TestFunctionIso/host.json deleted file mode 100644 index ee5cf5f8..00000000 --- a/tests/CoreEx.TestFunctionIso/host.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "2.0", - "logging": { - "applicationInsights": { - "samplingSettings": { - "isEnabled": true, - "excludedTypes": "Request" - }, - "enableLiveMetricsFilters": true - } - } -} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Clauses/DependsOnClauseTests.cs b/tests/CoreEx.Validation.Test.Unit/Clauses/DependsOnClauseTests.cs new file mode 100644 index 00000000..8030bdd5 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Clauses/DependsOnClauseTests.cs @@ -0,0 +1,23 @@ +namespace CoreEx.Validation.Test.Unit.Clauses; + +[TestFixture] +public class DependsOnClauseTests +{ + [Test] + public void DependsOn() + { + var v = Validator.Create().HasProperty(p => p.FirstName, c => c.Length(4).DependsOn(p => p.LastName)); + + v.ValidateAsSuccess(new Person()); + v.ValidateAsSuccess(new Person { FirstName = "Joh" }); + v.ValidateAsSuccess(new Person { FirstName = "John", LastName = "Doe" }); + + v.ValidateAsError(new Person { FirstName = "Joh", LastName = "Doe" }, "firstName", "First name must be exactly 4 character(s) in length."); + } + + private class Person + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Clauses/WhenClauseTests.cs b/tests/CoreEx.Validation.Test.Unit/Clauses/WhenClauseTests.cs new file mode 100644 index 00000000..e1a211dc --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Clauses/WhenClauseTests.cs @@ -0,0 +1,25 @@ +using CoreEx.Results; + +namespace CoreEx.Validation.Test.Unit.Clauses; + +[TestFixture] +public class WhenClauseTests +{ + [Test] + public void When() + { + 0.Validator(c => c.Mandatory().WhenEntity(e => e.Value == 0)).ValidateAsError("is required."); + 0.Validator(c => c.Mandatory().WhenValue(v => v == 0)).ValidateAsError("is required."); + 0.Validator(c => c.Mandatory().When(true)).ValidateAsError("is required."); + 0.Validator(c => c.Mandatory().When(() => true)).ValidateAsError("is required."); + 0.Validator(c => c.Mandatory().When((c, _) => Task.FromResult(true))).ValidateAsError("is required."); + 1.Validator(c => c.Mandatory().WhenHasValue()).ValidateAsSuccess(); + + 1.Validator(c => c.Mandatory().WhenEntity(e => e.Value == 0)).ValidateAsSuccess(); + 1.Validator(c => c.Mandatory().WhenValue(v => v == 0)).ValidateAsSuccess(); + 1.Validator(c => c.Mandatory().When(false)).ValidateAsSuccess(); + 1.Validator(c => c.Mandatory().When(() => false)).ValidateAsSuccess(); + 1.Validator(c => c.Mandatory().When((c, _) => Task.FromResult(false))).ValidateAsSuccess(); + ((int?)null).Validator(c => c.Mandatory().WhenHasValue()).ValidateAsSuccess(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/CommonValidatorTests.cs b/tests/CoreEx.Validation.Test.Unit/CommonValidatorTests.cs new file mode 100644 index 00000000..b6ac48b2 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/CommonValidatorTests.cs @@ -0,0 +1,12 @@ +namespace CoreEx.Validation.Test.Unit; + +[TestFixture] +public class CommonValidatorTests +{ + [Test] + public void Create() + { + var cv = Validator.CreateCommon(c => c.Mandatory().MaximumLength(20)); + var cv2 = Validator.CreateCommon(c => c.Mandatory().Between(0, 20)); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/CoreEx.Validation.Test.Unit.csproj b/tests/CoreEx.Validation.Test.Unit/CoreEx.Validation.Test.Unit.csproj new file mode 100644 index 00000000..9c892470 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/CoreEx.Validation.Test.Unit.csproj @@ -0,0 +1,37 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + CA1861;SYSLIB1045 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CoreEx.Validation.Test.Unit/Helper.cs b/tests/CoreEx.Validation.Test.Unit/Helper.cs new file mode 100644 index 00000000..fce5aba3 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Helper.cs @@ -0,0 +1,105 @@ +using CoreEx.Entities; +using CoreEx.Invokers; +using CoreEx.Validation.Abstractions; +using System.Text.Json; + +namespace CoreEx.Validation.Test.Unit; + +internal static class Helper +{ + private static JsonSerializerOptions? _jsonSerializerOptions; + + private static JsonSerializerOptions GetJsonSerializerOptions() + { + if (_jsonSerializerOptions is not null) + return _jsonSerializerOptions; + + _jsonSerializerOptions = new JsonSerializerOptions { WriteIndented = true }; + _jsonSerializerOptions.Converters.Add(new IgnoreTypeConverter()); + _jsonSerializerOptions.Converters.Add(new IgnoreTypeConverter()); + _jsonSerializerOptions.Converters.Add(new IgnoreTypeConverter()); + _jsonSerializerOptions.Converters.Add(new IgnoreTypeConverter()); + return _jsonSerializerOptions; + } + + public static IValidationResult> ValidateAsSuccess(this IValueValidator validator, ValidationArgs? args = null) + { + var r = Invoker.RunSync(() => validator.ThrowIfNull().ValidateAsync(args)); + r.Should().NotBeNull(); + + Console.WriteLine(JsonSerializer.Serialize(r, GetJsonSerializerOptions())); + Console.WriteLine("----"); + + r.HasErrors.Should().BeFalse(); + return r; + } + + public static IValidationResult> ValidateAsError(this IValueValidator validator, string containsErrorText, ValidationArgs? args = null) + { + var r = Invoker.RunSync(() => validator.ThrowIfNull().ValidateAsync(args)); + r.Should().NotBeNull(); + + Console.WriteLine(JsonSerializer.Serialize(r, GetJsonSerializerOptions())); + Console.WriteLine("----"); + + r.HasErrors.Should().BeTrue(); + r.Messages.Should().NotBeNull().And.HaveCount(1); + r.Messages[0].Should().NotBeNull(); + r.Messages[0].Type.Should().Be(MessageType.Error); + r.Messages[0].Text.ToString().Should().Contain(containsErrorText); + return r; + } + + public static IValidationResult> ValidateAsError(this IValueValidator validator, string propertyName, string containsErrorText) + { + var r = Invoker.RunSync(() => validator.ThrowIfNull().ValidateAsync()); + + Console.WriteLine(JsonSerializer.Serialize(r, GetJsonSerializerOptions())); + Console.WriteLine("----"); + + r.Should().NotBeNull(); + r.HasErrors.Should().BeTrue(); + r.Messages.Should().NotBeNull().And.HaveCount(1); + r.Messages[0].Should().NotBeNull(); + r.Messages[0].Type.Should().Be(MessageType.Error); + r.Messages[0].Property.Should().Be(propertyName); + r.Messages[0].Text.ToString().Should().Contain(containsErrorText); + return r; + } + + public static IValidationResult ValidateAsSuccess(this Validator validator, T value) where T : class + { + var r = Invoker.RunSync(() => validator.ThrowIfNull().ValidateAsync(value)); + r.Should().NotBeNull(); + + Console.WriteLine(JsonSerializer.Serialize(r, GetJsonSerializerOptions())); + Console.WriteLine("----"); + + r.HasErrors.Should().BeFalse(); + return r; + } + + public static IValidationResult ValidateAsError(this Validator validator, T value, string propertyName, string containsErrorText) where T : class + { + var r = Invoker.RunSync(() => validator.ThrowIfNull().ValidateAsync(value)); + r.Should().NotBeNull(); + + Console.WriteLine(JsonSerializer.Serialize(r, GetJsonSerializerOptions())); + Console.WriteLine("----"); + + r.HasErrors.Should().BeTrue(); + r.Messages.Should().NotBeNull().And.HaveCount(1); + r.Messages[0].Should().NotBeNull(); + r.Messages[0].Type.Should().Be(MessageType.Error); + r.Messages[0].Property.Should().Be(propertyName); + r.Messages[0].Text.ToString().Should().Contain(containsErrorText); + return r; + } + + private sealed class IgnoreTypeConverter : System.Text.Json.Serialization.JsonConverter + { + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => default; + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => writer.WriteNullValue(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/BetweenRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/BetweenRuleTests.cs new file mode 100644 index 00000000..cd57a13a --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/BetweenRuleTests.cs @@ -0,0 +1,52 @@ +using CoreEx.Results; + +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class BetweenRuleTests +{ + [Test] + public void Between_Values() + { + ((int?)null).Validator(c => c.Between(1, 3)).ValidateAsSuccess(); + 1.Validator(c => c.Between(1, 3)).ValidateAsSuccess(); + 2.Validator(c => c.Between(1, 3)).ValidateAsSuccess(); + ((int?)3).Validator(c => c.Between(1, 3)).ValidateAsSuccess(); + ((string?)null).Validator(c => c.Between("a", "g")).ValidateAsSuccess(); + "b".Validator(c => c.Between("a", "g")).ValidateAsSuccess(); + + 0.Validator(c => c.Between(1, 3)).ValidateAsError(" must be between '1' and '3'."); + 4.Validator(c => c.Between(1, 3)).ValidateAsError(" must be between '1' and '3'."); + ((int?)0).Validator(c => c.Between(1, 3)).ValidateAsError(" must be between '1' and '3'."); + ((int?)4).Validator(c => c.Between(1, 3)).ValidateAsError(" must be between '1' and '3'."); + + ((int?)4).Validator(c => c.Between(1, 3, "One", "Three")).ValidateAsError(" must be between One and Three."); + } + + [Test] + public void Between_Funcs() + { + ((int?)null).Validator(c => c.Between(_ => 1, _ => 3)).ValidateAsSuccess(); + 1.Validator(c => c.Between(_ => 1, _ => 3)).ValidateAsSuccess(); + 2.Validator(c => c.Between(_ => 1, _ => 3)).ValidateAsSuccess(); + ((int?)3).Validator(c => c.Between(_ => 1, _ => 3)).ValidateAsSuccess(); + ((string?)null).Validator(c => c.Between(_ => "a", _ => "g")).ValidateAsSuccess(); + "b".Validator(c => c.Between(_ => "a", _ => "g")).ValidateAsSuccess(); + + 0.Validator(c => c.Between(_ => 1, _ => 3)).ValidateAsError(" must be between '1' and '3'."); + 4.Validator(c => c.Between(_ => 1, _ => 3)).ValidateAsError(" must be between '1' and '3'."); + ((int?)0).Validator(c => c.Between(_ => 1, _ => 3)).ValidateAsError(" must be between '1' and '3'."); + ((int?)4).Validator(c => c.Between(_ => 1, _ => 3)).ValidateAsError(" must be between '1' and '3'."); + + ((int?)4).Validator(c => c.Between(_ => 1, _ => 3, _ => "One", _ => "Three")).ValidateAsError(" must be between One and Three."); + } + + [Test] + public void Between_Funcs_Args() + { + var args = new ValidationArgs { Parameters = new Dictionary { { "Min", 1 }, { "Max", 3 } } }; + 1.Validator(c => c.Between((c) => Result.Ok((int)c.Parameters!["Min"]!), (c) => Result.Ok((int)c.Parameters!["Max"]!))).ValidateAsSuccess(args); + + 5.Validator(c => c.Between((c) => Result.Ok((int)c.Parameters!["Min"]!), (c) => Result.Ok((int)c.Parameters!["Max"]!))).ValidateAsError(" must be between '1' and '3'.", args); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/CollectionRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/CollectionRuleTests.cs new file mode 100644 index 00000000..e8a61f92 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/CollectionRuleTests.cs @@ -0,0 +1,268 @@ +using CoreEx.Entities; +using System.Globalization; + +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class CollectionRuleTests +{ + [Test] + public void MaxCount() + { + new int[] { 1, 2 }.Validator(c => c.Collection(2)).ValidateAsSuccess(); + ((IEnumerable)[1, 2]).Validator(c => c.Collection(2)).ValidateAsSuccess(); + Array.Empty().Validator(c => c.Collection(2)).ValidateAsSuccess(); + Enumerable.Empty().Validator(c => c.Collection(2)).ValidateAsSuccess(); + ((IEnumerable?)null).Validator(c => c.Collection(2)).ValidateAsSuccess(); + + new int[] { 1, 2, 3 }.Validator(c => c.Collection(2)).ValidateAsError("must not exceed 2 item(s)."); + ((IEnumerable)[1, 2, 3]).Validator(c => c.Collection(2)).ValidateAsError("must not exceed 2 item(s)."); + } + + [Test] + public void MaxCount_Func() + { + new int[] { 1, 2 }.Validator(c => c.Collection(_ => 2)).ValidateAsSuccess(); + ((IEnumerable)[1, 2]).Validator(c => c.Collection(_ => 2)).ValidateAsSuccess(); + Array.Empty().Validator(c => c.Collection(_ => 2)).ValidateAsSuccess(); + Enumerable.Empty().Validator(c => c.Collection(_ => 2)).ValidateAsSuccess(); + ((IEnumerable?)null).Validator(c => c.Collection(_ => 2)).ValidateAsSuccess(); + + new int[] { 1, 2, 3 }.Validator(c => c.Collection(_ => 2)).ValidateAsError("must not exceed 2 item(s)."); + ((IEnumerable)[1, 2, 3]).Validator(c => c.Collection(_ => 2)).ValidateAsError("must not exceed 2 item(s)."); + } + + [Test] + public void MinCount() + { + new int[] { 1, 2 }.Validator(c => c.Collection(minCount: 2, maxCount: null)).ValidateAsSuccess(); + ((IEnumerable)[1, 2]).Validator(c => c.Collection(minCount: 2, maxCount: null)).ValidateAsSuccess(); + + List? list = null; + list.Validator(c => c.Collection(minCount: 2, maxCount: null)).ValidateAsSuccess(); + + Array.Empty().Validator(c => c.Collection(minCount: 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + Enumerable.Empty().Validator(c => c.Collection(minCount: 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + new int[] { 1 }.Validator(c => c.Collection(minCount: 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + ((IEnumerable)[1]).Validator(c => c.Collection(minCount: 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + } + + [Test] + public void MinCount_Func() + { + new int[] { 1, 2 }.Validator(c => c.Collection(minCount: _ => 2, maxCount: null)).ValidateAsSuccess(); + ((IEnumerable)[1, 2]).Validator(c => c.Collection(minCount: _ => 2, maxCount: null)).ValidateAsSuccess(); + + List? list = null; + list.Validator(c => c.Collection(minCount: _ => 2, maxCount: null)).ValidateAsSuccess(); + + Array.Empty().Validator(c => c.Collection(minCount: _ => 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + Enumerable.Empty().Validator(c => c.Collection(minCount: _ => 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + new int[] { 1 }.Validator(c => c.Collection(minCount: _ => 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + ((IEnumerable)[1]).Validator(c => c.Collection(minCount: _ => 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + } + + [Test] + public void CommonValidator() + { + var ic = Validator.CreateCommon(v => v.Between(10, 20)); + + new int[] { 11, 18 }.Validator(c => c.Collection(w => w.WithItemValidator(v => v.Between(10, 20)))).ValidateAsSuccess(); + new int?[] { 11, null, 18 }.Validator(c => c.Collection(w => w.WithItemValidator(v => v.Between(10, 20)).AllowNullItems())).ValidateAsSuccess(); + new int[] { 11, 18 }.Validator(c => c.Collection(w => w.WithItemValidator(v => v.Common(ic)))).ValidateAsSuccess(); + new int?[] { 11, null, 18 }.Validator(c => c.Collection(w => w.WithItemValidator(v => v.Common(ic)).AllowNullItems())).ValidateAsSuccess(); + + new List { 11, 18 }.Validator(c => c.Collection(w => w.WithItemValidator(v => v.Between(10, 20)))).ValidateAsSuccess(); + new List { 11, null, 18 }.Validator(c => c.Collection(w => w.WithItemValidator(v => v.Between(10, 20)).AllowNullItems())).ValidateAsSuccess(); + + new string[] { "AAA", "BBB" }.Validator(c => c.Collection(w => w.WithItemValidator(v => v.Length(3)))).ValidateAsSuccess(); + new string?[] { "AAA", null, "BBB" }.Validator(c => c.Collection(w => w.WithItemValidator(v => v.Length(3)).AllowNullItems())).ValidateAsSuccess(); + + new string?[] { "AAA", null, "BBB" }.Validator(c => c.Collection(w => w.WithItemValidator(v => v.Length(3)))).ValidateAsError("contains one or more items that are not specified."); + } + + [Test] + public void DuplicateCheck_IEquatable() + { + new int[] { 1, 2, 3 }.Validator(c => c.Collection(w => w.WithDuplicateCheck())).ValidateAsSuccess(); + new int[] { 1, 2, 1 }.Validator(c => c.Collection(w => w.WithDuplicateCheck())).ValidateAsError("contains duplicates; Item specified more than once."); + } + + [Test] + public void DuplicateCheck_KeySelector() + { + new int[] { 1, 2, 3 }.Validator(c => c.Collection(w => w.WithDuplicateCheck(i => i))).ValidateAsSuccess(); + new int[] { 1, 2, 1 }.Validator(c => c.Collection(w => w.WithDuplicateCheck(i => i))).ValidateAsError("contains duplicates; Item specified more than once."); + } + + [Test] + public void DuplicateIdCheck() + { + var list = new List + { + new() { Id = "A", Name = "Bob" }, + new() { Id = "B", Name = "Kate" } + }; + + list[0].Id = "B"; + list.Validator(c => c.Collection(w => w.WithDuplicateIdCheck())).ValidateAsError("contains duplicates; Identifier specified more than once."); + } + + [Test] + public void DuplicateKeyCheck() + { + var list = new List + { + new() { Id = "A", Name = "Bob" }, + new() { Id = "B", Name = "Kate" } + }; + + list.Validator(c => c.Collection(w => w.WithDuplicateKeyCheck())).ValidateAsSuccess(); + + list[0].Id = "B"; + list.Validator(c => c.Collection(w => w.WithDuplicateKeyCheck())).ValidateAsError("contains duplicates; Key specified more than once."); + } + + [Test] + public void DuplicatePropertyCheck() + { + var list = new List + { + new() { Id = "A", Name = "Bob" }, + new() { Id = "B", Name = "Kate" } + }; + + list.Validator(c => c.Collection(w => w.WithDuplicatePropertyCheck(p => p.Name))).ValidateAsSuccess(); + + list[0].Name = "Kate"; + list.Validator(c => c.Collection(w => w.WithDuplicatePropertyCheck(p => p.Name))).ValidateAsError("contains duplicates; Name specified more than once."); + } + + [Test] + public void Entity() + { + var av = Validator.Create
() + .HasProperty(p => p.Street, c => c.Mandatory().MaximumLength(20)); + + var pv = Validator.Create() + .HasProperty(p => p.Id, c => c.Mandatory()) + .HasProperty(p => p.Name, c => c.Mandatory()) + .HasProperty(p => p.Addresses, c => c.Mandatory().Collection(c => c.WithItemValidator(av).WithDuplicatePropertyCheck(a => a.Street))); + + var p = new Person + { + Id = "1", + Name = "John", + Addresses = + [ + new Address { Street = "1 St" }, + new Address { Street = "2 St" } + ] + }; + + pv.ValidateAsSuccess(p); + + p.Addresses.Add(new Address()); + pv.ValidateAsError(p, "addresses[2].street", "Street is required."); + + p.Addresses[2]!.Street = "1 St"; + pv.ValidateAsError(p, "addresses", "Addresses contains duplicates; Street specified more than once."); + } + + [Test] + public void EnclosingCollection() + { + var av = Validator.Create
().HasProperty(p => p.Street, c => c.Mandatory().MaximumLength(20)); + var pv = Validator.Create() + .HasProperty(p => p.Id, c => c.Mandatory()) + .HasProperty(p => p.Name, c => c.Mandatory()) + .HasProperty(p => p.Addresses, c => c.Mandatory().Collection(c => c.WithItemValidator(av).WithDuplicatePropertyCheck(a => a.Street))); + + var pc = new PersonCollection + { + new Person + { + Id = "1", + Name = "John", + Addresses = + [ + new Address { Street = "1 St" }, + new Address { Street = "2 St" } + ] + }, + new Person + { + Id = "2", + Name = "Jane", + Addresses = + [ + new Address { Street = "A St" }, + new Address { Street = "B St" } + ] + } + }; + + var pcv = Validator.Create().Self(c => c.WithText("Collection").Collection(c => c.WithItemValidator(pv).WithDuplicateIdCheck())); + + pcv.ValidateAsSuccess(pc); + pc.Validator(c => c.Collection(c => c.WithItemValidator(pv).WithDuplicateIdCheck())).ValidateAsSuccess(); + + pc[1].Addresses!.Add(new Address()); + pc.Validator(c => c.Collection(c => c.WithItemValidator(pv).WithDuplicateIdCheck())).ValidateAsError("pc[1].addresses[2].street", "Street is required."); + + pcv.ValidateAsError(pc, "[1].addresses[2].street", "Street is required."); + + pc[1].Addresses![2]!.Street = "A St"; + pc.Validator(c => c.Collection(c => c.WithItemValidator(pv).WithDuplicateIdCheck())).ValidateAsError("pc[1].addresses", "Addresses contains duplicates; Street specified more than once."); + + pcv.ValidateAsError(pc, "[1].addresses", "Addresses contains duplicates; Street specified more than once."); + + + pc[1].Addresses![2]!.Street = "C St"; + + pc.Add(new Person + { + Id = "1", + Name = "Jake", + Addresses = + [ + new Address { Street = "X St" }, + new Address { Street = "Y St" } + ] + }); + + pc.Validator(c => c.Collection(c => c.WithItemValidator(pv).WithDuplicateIdCheck())).ValidateAsError("pc", "Pc contains duplicates; Identifier specified more than once."); + + pcv.ValidateAsError(pc, "", "Collection contains duplicates; Identifier specified more than once."); + } + + [Test] + public void GetCollectionIndex() + { + var pc = new PersonCollection + { + new Person + { + Id = "1", + Name = "John" + } + }; + + pc.Validator(c => c.Collection(w => w.WithItemValidator(iv => iv.NotFound().When(ctx => ctx.GetCollectionIndex() != 0)))).ValidateAsSuccess(); + } + + public class PersonCollection : List { } + + public class Person : IIdentifier + { + public string? Id { get; set; } + public string? Name { get; set; } + public int? Age { get; set; } + public List
? Addresses { get; set; } + } + + public class Address + { + public string? Street { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/CommonRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/CommonRuleTests.cs new file mode 100644 index 00000000..ed348ce6 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/CommonRuleTests.cs @@ -0,0 +1,65 @@ +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class CommonRuleTests +{ + [Test] + public void Common_String() + { + var cv = Validator.CreateCommon(c => c.Mandatory().MaximumLength(5)); + + "abc".Validator(c => c.Common(cv)).ValidateAsSuccess(); + "abcdef".Validator(c => c.Common(cv)).ValidateAsError("must not exceed 5 character(s) in length."); + ((string?)null).Validator(c => c.Common(cv)).ValidateAsError("is required."); + } + + [Test] + public void Common_Int32() + { + var cv = Validator.CreateCommon(c => c.Mandatory().Between(0, 10)); + + ((int?)5).Validator(c => c.Common(cv)).ValidateAsSuccess(); + ((int?)20).Validator(c => c.Common(cv)).ValidateAsError("must be between '0' and '10'."); + ((int?)null).Validator(c => c.Common(cv)).ValidateAsError("is required."); + + 5.Validator(c => c.Common(cv)).ValidateAsSuccess(); + 20.Validator(c => c.Common(cv)).ValidateAsError("must be between '0' and '10'."); + + } + + [Test] + public void Common_Entity() + { + var cvs = Validator.CreateCommon(c => c.Mandatory().MaximumLength(5)); + var cvi = Validator.CreateCommon(c => c.Mandatory().Between(0, 10)); + + var pv = Validator.Create() + .HasProperty(p => p.Name, c => c.Common(cvs)) + .HasProperty(p => p.Age, c => c.Common(cvi)) + .HasProperty(p => p.Address, c => c.Entity(new AddressValidator())); + + pv.ValidateAsSuccess(new Person { Name = "John", Age = 5, Address = new Address { Street = "1 St" } }); + pv.ValidateAsError(new Person { Name = "John", Age = -5, Address = new Address { Street = "1 St" } }, "age", "Age must be between '0' and '10'."); + pv.ValidateAsError(new Person { Name = "John", Age = 5, Address = new Address { Street = "1 Street" } }, "address.street", "Street must not exceed 5 character(s) in length."); + } + + public class Person + { + public string? Name { get; set; } + public int? Age { get; set; } + public Address? Address { get; set; } + } + + public class Address + { + public string? Street { get; set; } + } + + public class AddressValidator : Validator
+ { + public AddressValidator() + { + Property(a => a.Street).Mandatory().MaximumLength(5); + } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/ComparePropertyRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/ComparePropertyRuleTests.cs new file mode 100644 index 00000000..ad9f4bc9 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/ComparePropertyRuleTests.cs @@ -0,0 +1,55 @@ +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class ComparePropertyRuleTests +{ + [Test] + public void CompareProperty() + { + var v = Validator.Create() + .HasProperty(p => p.ToNumber, c => c.CompareProperty(CompareOperator.GreaterThanOrEqualTo, p => p.FromNumber)) + .HasProperty(p => p.ToText, c => c.CompareProperty(CompareOperator.GreaterThanOrEqualTo, p => p.FromText)); + + v.ValidateAsSuccess(new Ranges()); + v.ValidateAsSuccess(new Ranges { FromNumber = null, ToNumber = 2 }); + v.ValidateAsSuccess(new Ranges { FromNumber = 1, ToNumber = null }); + v.ValidateAsSuccess(new Ranges { FromNumber = 1, ToNumber = 2 }); + v.ValidateAsError(new Ranges { FromNumber = 2, ToNumber = 1 }, "toNumber", "To number must be greater than or equal to '2'."); + + v.ValidateAsSuccess(new Ranges()); + v.ValidateAsSuccess(new Ranges { FromText = null, ToText = "b" }); + v.ValidateAsSuccess(new Ranges { FromText = "a", ToText = null }); + v.ValidateAsSuccess(new Ranges { FromText = "a", ToText = "b" }); + v.ValidateAsError(new Ranges { FromText = "b", ToText = "a" }, "toText", "To text must be greater than or equal to 'b'."); + } + + [Test] + public void CompareProperty_CompareToText() + { + var v = Validator.Create().HasProperty(p => p.ToNumber, c => c.CompareProperty(CompareOperator.GreaterThanOrEqualTo, p => p.FromNumber, _ => "Two")); + v.ValidateAsError(new Ranges { FromNumber = 2, ToNumber = 1 }, "toNumber", "To number must be greater than or equal to Two."); + } + + [Test] + public void CompareProperty_Cast_Exception() + { + var v = Validator.Create().HasProperty(p => p.ToNumber, c => c.CompareProperty(CompareOperator.GreaterThanOrEqualTo, p => p.FromText!)); + var ex = Assert.ThrowsAsync(async () => await v.ValidateAsync(new Ranges { FromNumber = 2, ToNumber = 1, FromText = "a", ToText = "b" })); + ex.Message.Should().Contain("Property 'FromText' and 'ToNumber' are incompatible for a comparison: The input string 'a' was not in a correct format."); + } + + [Test] + public void CompareProperty_WithMessage() + { + var v = Validator.Create().HasProperty(p => p.ToNumber, c => c.CompareProperty(CompareOperator.GreaterThanOrEqualTo, p => p.FromNumber).WithMessage("Oh no!")); + v.ValidateAsError(new Ranges { FromNumber = 2, ToNumber = 1 }, "toNumber", "Oh no!"); + } + + private class Ranges + { + public int? FromNumber { get; set; } + public int? ToNumber { get; set; } + public string? FromText { get; set; } + public string? ToText { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/CompareValueRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/CompareValueRuleTests.cs new file mode 100644 index 00000000..a6bce573 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/CompareValueRuleTests.cs @@ -0,0 +1,195 @@ +using CoreEx.Results; + +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class CompareValueRuleTests +{ + [Test] + public void Compare_Int32() + { + ((int?)null).Validator(c => c.Compare(CompareOperator.Equal, 1)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.Equal, 1)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.NotEqual, 2)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.GreaterThan, 0)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, 1)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.LessThan, 2)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, 1)).ValidateAsSuccess(); + + 1.Validator(c => c.Equal(1)).ValidateAsSuccess(); + 1.Validator(c => c.NotEqual(2)).ValidateAsSuccess(); + 1.Validator(c => c.GreaterThan(0)).ValidateAsSuccess(); + 1.Validator(c => c.GreaterThanOrEqualTo(1)).ValidateAsSuccess(); + 1.Validator(c => c.LessThan(2)).ValidateAsSuccess(); + 1.Validator(c => c.LessThanOrEqualTo(1)).ValidateAsSuccess(); + + 1.Validator(c => c.Compare(CompareOperator.Equal, 2)).ValidateAsError(" must be equal to '2'."); + 1.Validator(c => c.Compare(CompareOperator.NotEqual, 1)).ValidateAsError(" must not be equal to '1'."); + 1.Validator(c => c.Compare(CompareOperator.GreaterThan, 1)).ValidateAsError(" must be greater than '1'."); + 1.Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, 2)).ValidateAsError(" must be greater than or equal to '2'."); + 1.Validator(c => c.Compare(CompareOperator.LessThan, 1)).ValidateAsError(" must be less than '1'."); + 1.Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, 0)).ValidateAsError(" must be less than or equal to '0'."); + + 1.Validator(c => c.Equal(2)).ValidateAsError(" must be equal to '2'."); + 1.Validator(c => c.NotEqual(1)).ValidateAsError(" must not be equal to '1'."); + 1.Validator(c => c.GreaterThan(1)).ValidateAsError(" must be greater than '1'."); + 1.Validator(c => c.GreaterThanOrEqualTo(2)).ValidateAsError(" must be greater than or equal to '2'."); + 1.Validator(c => c.LessThan(1)).ValidateAsError(" must be less than '1'."); + 1.Validator(c => c.LessThanOrEqualTo(0)).ValidateAsError(" must be less than or equal to '0'."); + } + + [Test] + public void Compare_Nullable_Int32() + { + ((int?)1).Validator(c => c.Compare(CompareOperator.Equal, 1)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.NotEqual, 2)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.GreaterThan, 0)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, 1)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.LessThan, 2)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, 1)).ValidateAsSuccess(); + + ((int?)1).Validator(c => c.Equal(1)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.NotEqual(2)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.GreaterThan(0)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.GreaterThanOrEqualTo(1)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.LessThan(2)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.LessThanOrEqualTo(1)).ValidateAsSuccess(); + + ((int?)1).Validator(c => c.Compare(CompareOperator.Equal, 2)).ValidateAsError(" must be equal to '2'."); + ((int?)1).Validator(c => c.Compare(CompareOperator.NotEqual, 1)).ValidateAsError(" must not be equal to '1'."); + ((int?)1).Validator(c => c.Compare(CompareOperator.GreaterThan, 1)).ValidateAsError(" must be greater than '1'."); + ((int?)1).Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, 2)).ValidateAsError(" must be greater than or equal to '2'."); + ((int?)1).Validator(c => c.Compare(CompareOperator.LessThan, 1)).ValidateAsError(" must be less than '1'."); + ((int?)1).Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, 0)).ValidateAsError(" must be less than or equal to '0'."); + + ((int?)1).Validator(c => c.Equal(2)).ValidateAsError(" must be equal to '2'."); + ((int?)1).Validator(c => c.NotEqual(1)).ValidateAsError(" must not be equal to '1'."); + ((int?)1).Validator(c => c.GreaterThan(1)).ValidateAsError(" must be greater than '1'."); + ((int?)1).Validator(c => c.GreaterThanOrEqualTo(2)).ValidateAsError(" must be greater than or equal to '2'."); + ((int?)1).Validator(c => c.LessThan(1)).ValidateAsError(" must be less than '1'."); + ((int?)1).Validator(c => c.LessThanOrEqualTo(0)).ValidateAsError(" must be less than or equal to '0'."); + } + + [Test] + public void Compare_String() + { + ((string?)null).Validator(c => c.Compare(CompareOperator.Equal, "a")).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.Equal, "a")).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.Equal, "A", comparer: StringComparer.OrdinalIgnoreCase)).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.NotEqual, "b")).ValidateAsSuccess(); + "b".Validator(c => c.Compare(CompareOperator.GreaterThan, "a")).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, "a")).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.LessThan, "b")).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, "a")).ValidateAsSuccess(); + + "a".Validator(c => c.Equal("a")).ValidateAsSuccess(); + "a".Validator(c => c.Equal("A", comparer: StringComparer.OrdinalIgnoreCase)).ValidateAsSuccess(); + "a".Validator(c => c.NotEqual("b")).ValidateAsSuccess(); + "b".Validator(c => c.GreaterThan("a")).ValidateAsSuccess(); + "a".Validator(c => c.GreaterThanOrEqualTo("a")).ValidateAsSuccess(); + "a".Validator(c => c.LessThan("b")).ValidateAsSuccess(); + "a".Validator(c => c.LessThanOrEqualTo("a")).ValidateAsSuccess(); + + "a".Validator(c => c.Compare(CompareOperator.Equal, "b")).ValidateAsError(" must be equal to 'b'."); + "a".Validator(c => c.Compare(CompareOperator.NotEqual, "a")).ValidateAsError(" must not be equal to 'a'."); + "a".Validator(c => c.Compare(CompareOperator.GreaterThan, "a")).ValidateAsError(" must be greater than 'a'."); + "a".Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, "b")).ValidateAsError(" must be greater than or equal to 'b'."); + "a".Validator(c => c.Compare(CompareOperator.LessThan, "a")).ValidateAsError(" must be less than 'a'."); + "b".Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, "a", comparer: StringComparer.OrdinalIgnoreCase)).ValidateAsError(" must be less than or equal to 'a'."); + + "a".Validator(c => c.Equal("b")).ValidateAsError(" must be equal to 'b'."); + "a".Validator(c => c.NotEqual("a")).ValidateAsError(" must not be equal to 'a'."); + "a".Validator(c => c.GreaterThan("a")).ValidateAsError(" must be greater than 'a'."); + "a".Validator(c => c.GreaterThanOrEqualTo("b")).ValidateAsError(" must be greater than or equal to 'b'."); + "a".Validator(c => c.LessThan("a")).ValidateAsError(" must be less than 'a'."); + "b".Validator(c => c.LessThanOrEqualTo("a", comparer: StringComparer.OrdinalIgnoreCase)).ValidateAsError(" must be less than or equal to 'a'."); + } + + [Test] + public void Compare_Int32_Func() + { + ((int?)null).Validator(c => c.Compare(CompareOperator.Equal, _ => 1)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.Equal, _ => 1)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.NotEqual, _ => 2)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.GreaterThan, _ => 0)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, _ => 1)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.LessThan, _ => 2)).ValidateAsSuccess(); + 1.Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, _ => 1)).ValidateAsSuccess(); + + 1.Validator(c => c.Equal(_ => 1)).ValidateAsSuccess(); + 1.Validator(c => c.NotEqual(_ => 2)).ValidateAsSuccess(); + 1.Validator(c => c.GreaterThan(_ => 0)).ValidateAsSuccess(); + 1.Validator(c => c.GreaterThanOrEqualTo(_ => 1)).ValidateAsSuccess(); + 1.Validator(c => c.LessThan(_ => 2)).ValidateAsSuccess(); + 1.Validator(c => c.LessThanOrEqualTo(_ => 1)).ValidateAsSuccess(); + + 1.Validator(c => c.Compare(CompareOperator.Equal, _ => 2)).ValidateAsError(" must be equal to '2'."); + 1.Validator(c => c.Compare(CompareOperator.NotEqual, _ => 1)).ValidateAsError(" must not be equal to '1'."); + 1.Validator(c => c.Compare(CompareOperator.GreaterThan, _ => 1)).ValidateAsError(" must be greater than '1'."); + 1.Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, _ => 2)).ValidateAsError(" must be greater than or equal to '2'."); + 1.Validator(c => c.Compare(CompareOperator.LessThan, _ => 1)).ValidateAsError(" must be less than '1'."); + 1.Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, _ => 0)).ValidateAsError(" must be less than or equal to '0'."); + + 1.Validator(c => c.Equal(_ => 2)).ValidateAsError(" must be equal to '2'."); + 1.Validator(c => c.NotEqual(_ => 1)).ValidateAsError(" must not be equal to '1'."); + 1.Validator(c => c.GreaterThan(_ => 1)).ValidateAsError(" must be greater than '1'."); + 1.Validator(c => c.GreaterThanOrEqualTo(_ => 2)).ValidateAsError(" must be greater than or equal to '2'."); + 1.Validator(c => c.LessThan(_ => 1)).ValidateAsError(" must be less than '1'."); + 1.Validator(c => c.LessThanOrEqualTo(_ => 0)).ValidateAsError(" must be less than or equal to '0'."); + } + + [Test] + public void Compare_Nullable_Int32_Func() + { + ((int?)null).Validator(c => c.Compare(CompareOperator.Equal, _ => 1)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.Equal, _ => 1)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.NotEqual, _ => 2)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.GreaterThan, _ => 0)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, _ => 1)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.LessThan, _ => 2)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, _ => 1)).ValidateAsSuccess(); + + ((int?)1).Validator(c => c.Equal(_ => 1)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.NotEqual(_ => 2)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.GreaterThan(_ => 0)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.GreaterThanOrEqualTo(_ => 1)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.LessThan(_ => 2)).ValidateAsSuccess(); + ((int?)1).Validator(c => c.LessThanOrEqualTo(_ => 1)).ValidateAsSuccess(); + + ((int?)1).Validator(c => c.Compare(CompareOperator.Equal, _ => 2)).ValidateAsError(" must be equal to '2'."); + ((int?)1).Validator(c => c.Compare(CompareOperator.NotEqual, _ => 1)).ValidateAsError(" must not be equal to '1'."); + ((int?)1).Validator(c => c.Compare(CompareOperator.GreaterThan, _ => 1)).ValidateAsError(" must be greater than '1'."); + ((int?)1).Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, _ => 2)).ValidateAsError(" must be greater than or equal to '2'."); + ((int?)1).Validator(c => c.Compare(CompareOperator.LessThan, _ => 1)).ValidateAsError(" must be less than '1'."); + ((int?)1).Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, _ => 0)).ValidateAsError(" must be less than or equal to '0'."); + + ((int?)1).Validator(c => c.Equal(_ => 2)).ValidateAsError(" must be equal to '2'."); + ((int?)1).Validator(c => c.NotEqual(_ => 1)).ValidateAsError(" must not be equal to '1'."); + ((int?)1).Validator(c => c.GreaterThan(_ => 1)).ValidateAsError(" must be greater than '1'."); + ((int?)1).Validator(c => c.GreaterThanOrEqualTo(_ => 2)).ValidateAsError(" must be greater than or equal to '2'."); + ((int?)1).Validator(c => c.LessThan(_ => 1)).ValidateAsError(" must be less than '1'."); + ((int?)1).Validator(c => c.LessThanOrEqualTo(_ => 0)).ValidateAsError(" must be less than or equal to '0'."); + } + + [Test] + public void Compare_String_Func() + { + ((string?)null).Validator(c => c.Compare(CompareOperator.Equal, _ => "a")).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.Equal, _ => "a")).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.Equal, _ => "A", comparer: StringComparer.OrdinalIgnoreCase)).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.NotEqual, _ => "b")).ValidateAsSuccess(); + "b".Validator(c => c.Compare(CompareOperator.GreaterThan, _ => "a")).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, _ => "a")).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.LessThan, _ => "b")).ValidateAsSuccess(); + "a".Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, _ => "a")).ValidateAsSuccess(); + + "a".Validator(c => c.Compare(CompareOperator.Equal, _ => "b")).ValidateAsError(" must be equal to 'b'."); + "a".Validator(c => c.Compare(CompareOperator.NotEqual, _ => "a")).ValidateAsError(" must not be equal to 'a'."); + "a".Validator(c => c.Compare(CompareOperator.GreaterThan, _ => "a")).ValidateAsError(" must be greater than 'a'."); + "a".Validator(c => c.Compare(CompareOperator.GreaterThanOrEqualTo, _ => "b")).ValidateAsError(" must be greater than or equal to 'b'."); + "a".Validator(c => c.Compare(CompareOperator.LessThan, _ => "a")).ValidateAsError(" must be less than 'a'."); + "b".Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, _ => "a", _ => "Aye", StringComparer.OrdinalIgnoreCase)).ValidateAsError(" must be less than or equal to Aye."); + + "b".Validator(c => c.Compare(CompareOperator.LessThanOrEqualTo, _ => "a", _ => "Aye", StringComparer.OrdinalIgnoreCase).WithMessage("Oh no!")).ValidateAsError("Oh no!"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/CompareValuesRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/CompareValuesRuleTests.cs new file mode 100644 index 00000000..64de57b9 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/CompareValuesRuleTests.cs @@ -0,0 +1,78 @@ +using CoreEx.Results; + +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class CompareValuesRuleTests +{ + [Test] + public void CompareValues_Int32() + { + var vals = new[] { 1, 2, 3 }; + + ((int?)null).Validator(c => c.CompareValues(vals)).ValidateAsSuccess(); + 1.Validator(c => c.CompareValues(vals)).ValidateAsSuccess(); + + 4.Validator(c => c.CompareValues(vals)).ValidateAsError("is invalid."); + } + + [Test] + public void CompareValues_String() + { + var vals = new[] { "a", "b", "c" }; + + ((string?)null).Validator(c => c.CompareValues(vals)).ValidateAsSuccess(); + "c".Validator(c => c.CompareValues(vals)).ValidateAsSuccess(); + "C".Validator(c => c.CompareValues(vals, StringComparer.OrdinalIgnoreCase)).ValidateAsSuccess(); + + "C".Validator(c => c.CompareValues(vals)).ValidateAsError("is invalid."); + "C".Validator(c => c.CompareValues(vals).WithMessage("Oh no!")).ValidateAsError("Oh no!"); + } + + [Test] + public void CompareValues_Int32_Func() + { + var vals = new[] { 1, 2, 3 }; + + ((int?)null).Validator(c => c.CompareValues(_ => vals)).ValidateAsSuccess(); + 1.Validator(c => c.CompareValues(_ => vals)).ValidateAsSuccess(); + + 4.Validator(c => c.CompareValues(_ => vals)).ValidateAsError("is invalid."); + } + + [Test] + public void CompareValues_String_Func() + { + var vals = new[] { "a", "b", "c" }; + + ((string?)null).Validator(c => c.CompareValues(_ => vals)).ValidateAsSuccess(); + "c".Validator(c => c.CompareValues(_ => vals)).ValidateAsSuccess(); + "C".Validator(c => c.CompareValues(_ => vals, StringComparer.OrdinalIgnoreCase)).ValidateAsSuccess(); + + "C".Validator(c => c.CompareValues(_ => vals)).ValidateAsError("is invalid."); + "C".Validator(c => c.CompareValues(_ => vals).WithMessage("Oh no!")).ValidateAsError("Oh no!"); + } + + [Test] + public void CompareValues_Override() + { + var vals = new[] { "A", "B", "C" }; + var v = Validator.Create() + .HasProperty(p => p.Id, p => p.CompareValues(vals, StringComparer.OrdinalIgnoreCase, true)) + .HasProperty(p => p.Code, p => p.CompareValues(vals, StringComparer.OrdinalIgnoreCase, true)); + + v.ValidateAsSuccess(new Person("A")); + + var r = v.ValidateAsSuccess(new Person("A") { Code = "a" }); + r.Value!.Code.Should().NotBeNull().And.Be("A"); + + var ex = Assert.ThrowsAsync(async () => await v.ValidateAsync(new Person("a"))); + ex.Message.Should().Be("The property 'Id' is read-only and cannot be overridden."); + } + + public class Person(string id) + { + public string Id { get; } = id; + public string? Code { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/DecimalRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/DecimalRuleTests.cs new file mode 100644 index 00000000..764cc667 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/DecimalRuleTests.cs @@ -0,0 +1,256 @@ +using CoreEx.Validation.Abstractions; + +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class DecimalRuleTests +{ + [Test] + public void Decimal() + { + 1.0m.Validator(c => c.Decimal(5, 2, false)).ValidateAsSuccess(); + 1.20m.Validator(c => c.Decimal(5, 2, false)).ValidateAsSuccess(); + 1.230m.Validator(c => c.Decimal(5, 2, false)).ValidateAsSuccess(); + 12.34m.Validator(c => c.Decimal(5, 2, false)).ValidateAsSuccess(); + 123.45m.Validator(c => c.Decimal(5, 2, false)).ValidateAsSuccess(); + (-1.0m).Validator(c => c.Decimal(5, 2)).ValidateAsSuccess(); + (-1.20m).Validator(c => c.Decimal(5, 2)).ValidateAsSuccess(); + (-1.230m).Validator(c => c.Decimal(5, 2)).ValidateAsSuccess(); + (-12.34m).Validator(c => c.Decimal(5, 2)).ValidateAsSuccess(); + (-123.45m).Validator(c => c.Decimal(5, 2)).ValidateAsSuccess(); + + 1234.56m.Validator(c => c.Decimal(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + 12345.78m.Validator(c => c.Decimal(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + 12345.789m.Validator(c => c.Decimal(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + (-1234.56m).Validator(c => c.Decimal(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + (-12345.78m).Validator(c => c.Decimal(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + (-12345.789m).Validator(c => c.Decimal(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + + 123.456m.Validator(c => c.Decimal(5, 2, false)).ValidateAsError("exceeds the maximum decimal places (2)."); + 0.123m.Validator(c => c.Decimal(5, 2, false)).ValidateAsError("exceeds the maximum decimal places (2)."); + (-123.456m).Validator(c => c.Decimal(5, 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + (-0.123m).Validator(c => c.Decimal(5, 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + + (-1.0m).Validator(c => c.Decimal(5, 2, false)).ValidateAsError("must not be negative."); + } + + [Test] + public void Decimal_Func() + { + 1.0m.Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + 1.20m.Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + 1.230m.Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + 12.34m.Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + 123.45m.Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + (-1.0m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsSuccess(); + (-1.20m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsSuccess(); + (-1.230m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsSuccess(); + (-12.34m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsSuccess(); + (-123.45m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsSuccess(); + + 1234.56m.Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum digits (5)."); + 12345.78m.Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum digits (5)."); + 12345.789m.Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum digits (5)."); + (-1234.56m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum digits (5)."); + (-12345.78m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum digits (5)."); + (-12345.789m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum digits (5)."); + + 123.456m.Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum decimal places (2)."); + 0.123m.Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum decimal places (2)."); + (-123.456m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + (-0.123m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + + (-1.0m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("must not be negative."); + } + + [Test] + public void Decimal_Nullable() + { + ((decimal?)1.0m).Validator(c => c.Decimal(5, 2, false)).ValidateAsSuccess(); + ((decimal?)1.20m).Validator(c => c.Decimal(5, 2, false)).ValidateAsSuccess(); + ((decimal?)1.230m).Validator(c => c.Decimal(5, 2, false)).ValidateAsSuccess(); + ((decimal?)12.34m).Validator(c => c.Decimal(5, 2, false)).ValidateAsSuccess(); + ((decimal?)123.45m).Validator(c => c.Decimal(5, 2, false)).ValidateAsSuccess(); + ((decimal?)-1.0m).Validator(c => c.Decimal(5, 2)).ValidateAsSuccess(); + ((decimal?)-1.20m).Validator(c => c.Decimal(5, 2)).ValidateAsSuccess(); + ((decimal?)-1.230m).Validator(c => c.Decimal(5, 2)).ValidateAsSuccess(); + ((decimal?)-12.34m).Validator(c => c.Decimal(5, 2)).ValidateAsSuccess(); + ((decimal?)-123.45m).Validator(c => c.Decimal(5, 2)).ValidateAsSuccess(); + + ((decimal?)1234.56m).Validator(c => c.Decimal(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)12345.78m).Validator(c => c.Decimal(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)12345.789m).Validator(c => c.Decimal(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-1234.56m).Validator(c => c.Decimal(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-12345.78m).Validator(c => c.Decimal(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-12345.789m).Validator(c => c.Decimal(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + + ((decimal?)123.456m).Validator(c => c.Decimal(5, 2, false)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)0.123m).Validator(c => c.Decimal(5, 2, false)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)-123.456m).Validator(c => c.Decimal(5, 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)-0.123m).Validator(c => c.Decimal(5, 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + + ((decimal?)-1.0m).Validator(c => c.Decimal(5, 2, false)).ValidateAsError("must not be negative."); + } + + [Test] + public void Decimal_Nullable_Func() + { + ((decimal?)1.0m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + ((decimal?)1.20m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + ((decimal?)1.230m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + ((decimal?)12.34m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + ((decimal?)123.45m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + ((decimal?)-1.0m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsSuccess(); + ((decimal?)-1.20m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsSuccess(); + ((decimal?)-1.230m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsSuccess(); + ((decimal?)-12.34m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsSuccess(); + ((decimal?)-123.45m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsSuccess(); + + ((decimal?)1234.56m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)12345.78m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)12345.789m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-1234.56m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-12345.78m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-12345.789m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum digits (5)."); + + ((decimal?)123.456m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)0.123m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)-123.456m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)-0.123m).Validator(c => c.Decimal(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + + ((decimal?)-1.0m).Validator(c => c.Decimal(_ => 5, _ => 2, _ => false)).ValidateAsError("must not be negative."); + } + + [Test] + public void PrecisionScale() + { + 1.0m.Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsSuccess(); + 1.20m.Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsSuccess(); + 1.230m.Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsSuccess(); + 12.34m.Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsSuccess(); + 123.45m.Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsSuccess(); + (-1.0m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsSuccess(); + (-1.20m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsSuccess(); + (-1.230m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsSuccess(); + (-12.34m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsSuccess(); + (-123.45m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsSuccess(); + + 1234.56m.Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + 12345.78m.Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + 12345.789m.Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + (-1234.56m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + (-12345.78m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + (-12345.789m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + + 123.456m.Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("exceeds the maximum decimal places (2)."); + 0.123m.Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("exceeds the maximum decimal places (2)."); + (-123.456m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + (-0.123m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + + (-1.0m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("must not be negative."); + } + + [Test] + public void PrecisionScale_Nullable() + { + ((decimal?)1.0m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsSuccess(); + ((decimal?)1.20m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsSuccess(); + ((decimal?)1.230m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsSuccess(); + ((decimal?)12.34m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsSuccess(); + ((decimal?)123.45m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsSuccess(); + ((decimal?)-1.0m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsSuccess(); + ((decimal?)-1.20m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsSuccess(); + ((decimal?)-1.230m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsSuccess(); + ((decimal?)-12.34m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsSuccess(); + ((decimal?)-123.45m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsSuccess(); + + ((decimal?)1234.56m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)12345.78m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)12345.789m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-1234.56m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-12345.78m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-12345.789m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsError("exceeds the maximum digits (5)."); + + ((decimal?)123.456m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)0.123m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)-123.456m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)-0.123m).Validator(c => c.PrecisionScale(5, 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + + ((decimal?)-1.0m).Validator(c => c.PrecisionScale(5, 2, false)).ValidateAsError("must not be negative."); + } + + [Test] + public void PrecisionScale_Nullable_Func() + { + ((decimal?)1.0m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + ((decimal?)1.20m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + ((decimal?)1.230m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + ((decimal?)12.34m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + ((decimal?)123.45m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsSuccess(); + ((decimal?)-1.0m).Validator(c => c.PrecisionScale(_ => 5, _ => 2)).ValidateAsSuccess(); + ((decimal?)-1.20m).Validator(c => c.PrecisionScale(_ => 5, _ => 2)).ValidateAsSuccess(); + ((decimal?)-1.230m).Validator(c => c.PrecisionScale(_ => 5, _ => 2)).ValidateAsSuccess(); + ((decimal?)-12.34m).Validator(c => c.PrecisionScale(_ => 5, _ => 2)).ValidateAsSuccess(); + ((decimal?)-123.45m).Validator(c => c.PrecisionScale(_ => 5, _ => 2)).ValidateAsSuccess(); + + ((decimal?)1234.56m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)12345.78m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)12345.789m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-1234.56m).Validator(c => c.PrecisionScale(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-12345.78m).Validator(c => c.PrecisionScale(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum digits (5)."); + ((decimal?)-12345.789m).Validator(c => c.PrecisionScale(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum digits (5)."); + + ((decimal?)123.456m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)0.123m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)-123.456m).Validator(c => c.PrecisionScale(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + ((decimal?)-0.123m).Validator(c => c.PrecisionScale(_ => 5, _ => 2)).ValidateAsError("exceeds the maximum decimal places (2)."); + + ((decimal?)-1.0m).Validator(c => c.PrecisionScale(_ => 5, _ => 2, _ => false)).ValidateAsError("must not be negative."); + } + + [Test] + public void CalcIntegralPartLength() + { + DecimalRuleHelper.CalcIntegralPartLength(0m).Should().Be(0); + DecimalRuleHelper.CalcIntegralPartLength(0.0000001m).Should().Be(0); + DecimalRuleHelper.CalcIntegralPartLength(0.9999999m).Should().Be(0); + DecimalRuleHelper.CalcIntegralPartLength(1.0000001m).Should().Be(1); + DecimalRuleHelper.CalcIntegralPartLength(9.9999999m).Should().Be(1); + DecimalRuleHelper.CalcIntegralPartLength(10.0000001m).Should().Be(2); + DecimalRuleHelper.CalcIntegralPartLength(99.9999999m).Should().Be(2); + DecimalRuleHelper.CalcIntegralPartLength(decimal.MaxValue).Should().Be(29); + + DecimalRuleHelper.CalcIntegralPartLength(-0.0000001m).Should().Be(0); + DecimalRuleHelper.CalcIntegralPartLength(-0.9999999m).Should().Be(0); + DecimalRuleHelper.CalcIntegralPartLength(-1.0000001m).Should().Be(1); + DecimalRuleHelper.CalcIntegralPartLength(-9.9999999m).Should().Be(1); + DecimalRuleHelper.CalcIntegralPartLength(-10.0000001m).Should().Be(2); + DecimalRuleHelper.CalcIntegralPartLength(-99.9999999m).Should().Be(2); + DecimalRuleHelper.CalcIntegralPartLength(decimal.MinValue).Should().Be(29); + } + + [Test] + public void CalcFractionalPartLength() + { + DecimalRuleHelper.CalcFractionalPartLength(0m).Should().Be(0); + DecimalRuleHelper.CalcFractionalPartLength(0.0000000m).Should().Be(0); + DecimalRuleHelper.CalcFractionalPartLength(0.0001000m).Should().Be(4); + DecimalRuleHelper.CalcFractionalPartLength(0.0000001m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(0.9999999m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(1.0000001m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(9.9999999m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(10.0000001m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(99.9999999m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(decimal.MaxValue).Should().Be(0); + + DecimalRuleHelper.CalcFractionalPartLength(-0.0000000m).Should().Be(0); + DecimalRuleHelper.CalcFractionalPartLength(-0.0001000m).Should().Be(4); + DecimalRuleHelper.CalcFractionalPartLength(-0.0000001m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(-0.9999999m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(-1.0000001m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(-9.9999999m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(-10.0000001m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(-99.9999999m).Should().Be(7); + DecimalRuleHelper.CalcFractionalPartLength(decimal.MinValue).Should().Be(0); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/DictionaryRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/DictionaryRuleTests.cs new file mode 100644 index 00000000..ba63a72c --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/DictionaryRuleTests.cs @@ -0,0 +1,133 @@ +using CoreEx.Entities; + +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class DictionaryRuleTests +{ + [Test] + public void MinCount() + { + new Dictionary { { "abc", "mnop" }, { "def", "qrst" } }.Validator(c => c.Dictionary(minCount: 2, maxCount: null)).ValidateAsSuccess(); + new Dictionary { { "abc", "mnop" } }.Validator(c => c.Dictionary(minCount: 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + new Dictionary().Validator(c => c.Dictionary(minCount: 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + ((Dictionary?)null).Validator(c => c.Dictionary(minCount: 2, maxCount: null)).ValidateAsSuccess(); + } + + [Test] + public void MinCount_Func() + { + new Dictionary { { "abc", "mnop" }, { "def", "qrst" } }.Validator(c => c.Dictionary(minCount: _ => 2, maxCount: null)).ValidateAsSuccess(); + new Dictionary { { "abc", "mnop" } }.Validator(c => c.Dictionary(minCount: _ => 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + new Dictionary().Validator(c => c.Dictionary(minCount: _ => 2, maxCount: null)).ValidateAsError("must have at least 2 item(s)."); + ((Dictionary?)null).Validator(c => c.Dictionary(minCount: _ => 2, maxCount: null)).ValidateAsSuccess(); + } + + [Test] + public void MaxCount() + { + new Dictionary { { "abc", "mnop" }, { "def", "qrst" } }.Validator(c => c.Dictionary(2)).ValidateAsSuccess(); + new Dictionary { { "abc", "mnop" } }.Validator(c => c.Dictionary(2)).ValidateAsSuccess(); + new Dictionary().Validator(c => c.Dictionary(2)).ValidateAsSuccess(); + ((Dictionary?)null).Validator(c => c.Dictionary(2)).ValidateAsSuccess(); + new Dictionary { { "abc", "mnop" }, { "def", "qrst" }, { "ghi", "uvwx" } }.Validator(c => c.Dictionary(2)).ValidateAsError("must not exceed 2 item(s)."); + } + + [Test] + public void MaxCount_Func() + { + new Dictionary { { "abc", "mnop" }, { "def", "qrst" } }.Validator(c => c.Dictionary(_ => 2)).ValidateAsSuccess(); + new Dictionary { { "abc", "mnop" } }.Validator(c => c.Dictionary(_ => 2)).ValidateAsSuccess(); + new Dictionary().Validator(c => c.Dictionary(_ => 2)).ValidateAsSuccess(); + ((Dictionary?)null).Validator(c => c.Dictionary(_ => 2)).ValidateAsSuccess(); + new Dictionary { { "abc", "mnop" }, { "def", "qrst" }, { "ghi", "uvwx" } }.Validator(c => c.Dictionary(_ => 2)).ValidateAsError("must not exceed 2 item(s)."); + } + + [Test] + public void CommonValidator() + { + new Dictionary { { "abc", "mnop" } }.Validator(c => c.Dictionary(c => c.WithKeyValidator(k => k.MaximumLength(3)).WithValueValidator(v => v.MaximumLength(4)))).ValidateAsSuccess(); + new Dictionary { { "abcd", "mnop" } }.Validator(c => c.Dictionary(c => c.WithKeyValidator(k => k.MaximumLength(3)).WithValueValidator(v => v.MaximumLength(4)))).ValidateAsError("Key must not exceed 3 character(s) in length."); + new Dictionary { { "abd", "mnopq" } }.Validator(c => c.Dictionary(c => c.WithKeyValidator(k => k.MaximumLength(3)).WithValueValidator(v => v.MaximumLength(4)))).ValidateAsError("Value must not exceed 4 character(s) in length."); + } + + [Test] + public void Entity() + { + var av = Validator.Create
().HasProperty(p => p.Street, c => c.Mandatory().MaximumLength(20)); + var pv = Validator.Create() + .HasProperty(p => p.Id, c => c.Mandatory()) + .HasProperty(p => p.Name, c => c.Mandatory()) + .HasProperty(p => p.Addresses, c => c.Mandatory().Dictionary(c => c.WithKeyValidator("Address code", k => k.Mandatory().MaximumLength(4)).WithValueValidator(av))); + + var p = new Person + { + Id = "1", + Name = "John", + Addresses = new Dictionary + { + { "home", new Address { Street = "1 St" } }, + { "post", new Address { Street = "2 St" } } + } + }; + + pv.ValidateAsSuccess(p); + + p.Addresses.Clear(); + p.Addresses.Add("other", new Address { Street = "3 St" }); + var vr = pv.ValidateAsError(p, "addresses.other", "Address code must not exceed 4 character(s) in length."); + + p.Addresses.Remove("other"); + + p.Addresses.Add("othr", new Address()); + pv.ValidateAsError(p, "addresses.othr.street", "Street is required."); + + p.Addresses["othr"] = new Address { Street = "This street address is way too long" }; + pv.ValidateAsError(p, "addresses.othr.street", "Street must not exceed 20 character(s) in length."); + + p.Addresses["othr"] = null!; + pv.ValidateAsError(p, "addresses", "Addresses contains one or more values that are not specified."); + + p.Addresses.Remove("othr"); + p.Addresses.Add("", new Address { Street = "33rd rd" }); + pv.ValidateAsError(p, "addresses.key", "Address code is required."); + } + + [Test] + public void GetDictionaryKey() + { + var av = Validator.Create
().HasProperty(p => p.Street, c + => c.Mandatory().MaximumLength(20).NotFound().When(ctx => ctx.GetDictionaryKey() != "home" && ctx.GetDictionaryKey() != "post")); + + var pv = Validator.Create() + .HasProperty(p => p.Id, c => c.Mandatory()) + .HasProperty(p => p.Name, c => c.Mandatory()) + .HasProperty(p => p.Addresses, c => c.Mandatory().Dictionary(c => c.WithKeyValidator("Address code", k => k.Mandatory().MaximumLength(4)).WithValueValidator(av))); + + var p = new Person + { + Id = "1", + Name = "John", + Addresses = new Dictionary + { + { "home", new Address { Street = "1 St" } }, + { "post", new Address { Street = "2 St" } } + } + }; + + pv.ValidateAsSuccess(p); + } + + public class Person : IIdentifier + { + public string? Id { get; set; } + public string? Name { get; set; } + public int? Age { get; set; } + public Dictionary? Addresses { get; set; } + } + + public class Address + { + public string? Street { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/EmailRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/EmailRuleTests.cs new file mode 100644 index 00000000..7fbacefd --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/EmailRuleTests.cs @@ -0,0 +1,23 @@ +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class EmailRuleTests +{ + [Test] + public void Email() + { + ((string?)null).Validator(c => c.Email()).ValidateAsSuccess(); + "blah@domain".Validator(c => c.Email()).ValidateAsSuccess(); + "blah@domain.co.nz".Validator(c => c.Email()).ValidateAsSuccess(); + $"mynameis@{new string('x', 250)}.com".Validator(c => c.Email()).ValidateAsSuccess(); + $"mynameis@{new string('x', 250)}.com".Validator(c => c.Email(null)).ValidateAsSuccess(); + + "".Validator(c => c.Email()).ValidateAsError("is an invalid e-mail address."); + ((string?)"blah").Validator(c => c.Email()).ValidateAsError("is an invalid e-mail address."); + "blah@".Validator(c => c.Email()).ValidateAsError("is an invalid e-mail address."); + "blah@.com".Validator(c => c.Email()).ValidateAsError("is an invalid e-mail address."); + $"mynameis@{new string('x', 250)}.com".Validator(c => c.Email(100)).ValidateAsError("must not exceed 100 character(s) in length."); + + $"mynameis@{new string('x', 250)}.com".Validator(c => c.Email(_ => 100)).ValidateAsError("must not exceed 100 character(s) in length."); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/EnumRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/EnumRuleTests.cs new file mode 100644 index 00000000..45a88431 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/EnumRuleTests.cs @@ -0,0 +1,59 @@ +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class EnumRuleTests +{ + [Test] + public void Enum() + { + DayOfWeek.Monday.Validator(c => c.Enum()).ValidateAsSuccess(); + ((DayOfWeek)100).Validator(c => c.Enum()).ValidateAsError("is invalid."); + ((DayOfWeek)(-1)).Validator(c => c.Enum()).ValidateAsError("is invalid."); + + DayOfWeek.Monday.Validator(c => c.Enum((List)null!)).ValidateAsSuccess(); + DayOfWeek.Monday.Validator(c => c.Enum(DayOfWeek.Monday, DayOfWeek.Tuesday)).ValidateAsSuccess(); + DayOfWeek.Tuesday.Validator(c => c.Enum(new List { DayOfWeek.Monday, DayOfWeek.Tuesday })).ValidateAsSuccess(); + DayOfWeek.Wednesday.Validator(c => c.Enum(DayOfWeek.Monday, DayOfWeek.Tuesday)).ValidateAsError("is invalid."); + DayOfWeek.Wednesday.Validator(c => c.Enum(new List { DayOfWeek.Monday, DayOfWeek.Tuesday })).ValidateAsError("is invalid."); + + ((DayOfWeek?)DayOfWeek.Monday).Validator(c => c.Enum()).ValidateAsSuccess(); + ((DayOfWeek?)DayOfWeek.Monday).Validator(c => c.Enum(DayOfWeek.Monday, DayOfWeek.Tuesday)).ValidateAsSuccess(); + ((DayOfWeek?)DayOfWeek.Wednesday).Validator(c => c.Enum(DayOfWeek.Monday, DayOfWeek.Tuesday)).ValidateAsError("is invalid."); + + DayOfWeek? dow = null; + dow.Validator(c => c.Enum()).ValidateAsSuccess(); + + dow = DayOfWeek.Monday; + dow.Validator(c => c.Enum()).ValidateAsSuccess(); + dow.Validator(c => c.Enum(new List { DayOfWeek.Wednesday, DayOfWeek.Thursday })).ValidateAsError("is invalid."); + } + + [Test] + public void EnumString() + { + "Monday".Validator(c => c.Enum(e => e.With())).ValidateAsSuccess(); + "monday".Validator(c => c.Enum(e => e.With())).ValidateAsError("is invalid."); + "monday".Validator(c => c.Enum(e => e.With().IgnoreCase())).ValidateAsSuccess(); + "monday".Validator(c => c.Enum(e => e.With().IgnoreCase())).ValidateAsSuccess(); + + ((string?)null).Validator(c => c.Enum(e => e.With())).ValidateAsSuccess(); + ((string?)"monday").Validator(c => c.Enum(e => e.With(DayOfWeek.Monday, DayOfWeek.Tuesday).IgnoreCase())).ValidateAsSuccess(); + "friday".Validator(c => c.Enum(e => e.With(DayOfWeek.Monday, DayOfWeek.Tuesday).IgnoreCase())).ValidateAsError("is invalid."); + } + + public class AppointmentValidator : Validator + { + public AppointmentValidator() + { + Property(p => p.DayOfWeek).Enum(); + //Property(p => p.AlternateDay).Enum(); + } + } + + public class Appointment + { + public DayOfWeek DayOfWeek { get; set; } + + public DayOfWeek? AlternateDay { get; set; } + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/ErrorRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/ErrorRuleTests.cs new file mode 100644 index 00000000..64559a79 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/ErrorRuleTests.cs @@ -0,0 +1,45 @@ +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class ErrorRuleTests +{ + [Test] + public void Error() + { + 0.Validator(c => c.Error("An error occurred.")).ValidateAsError("An error occurred."); + 0.Validator(c => c.Error("An error occurred.").WhenValue(v => v == 0)).ValidateAsError("An error occurred."); + 1.Validator(c => c.Error("An error occurred.").WhenValue(v => v == 0)).ValidateAsSuccess(); + } + + [Test] + public void Duplicate() + { + 0.Validator(c => c.Duplicate()).ValidateAsError("0 already exists and would result in a duplicate."); + 0.Validator(c => c.Duplicate().WhenValue(v => v == 0)).ValidateAsError("0 already exists and would result in a duplicate."); + 1.Validator(c => c.Duplicate().WhenValue(v => v == 0)).ValidateAsSuccess(); + } + + [Test] + public void NotFound() + { + 0.Validator(c => c.NotFound()).ValidateAsError("0 was not found."); + 0.Validator(c => c.NotFound().WhenValue(v => v == 0)).ValidateAsError("0 was not found."); + 1.Validator(c => c.NotFound().WhenValue(v => v == 0)).ValidateAsSuccess(); + } + + [Test] + public void Invalid() + { + 0.Validator(c => c.Invalid()).ValidateAsError("0 is invalid."); + 0.Validator(c => c.Invalid().WhenValue(v => v == 0)).ValidateAsError("0 is invalid."); + 1.Validator(c => c.Invalid().WhenValue(v => v == 0)).ValidateAsSuccess(); + } + + [Test] + public void Immutable() + { + 0.Validator(c => c.Immutable()).ValidateAsError("0 is not allowed to change; please reset value."); + 0.Validator(c => c.Immutable().WhenValue(v => v == 0)).ValidateAsError("0 is not allowed to change; please reset value."); + 1.Validator(c => c.Immutable().WhenValue(v => v == 0)).ValidateAsSuccess(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/MandatoryRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/MandatoryRuleTests.cs new file mode 100644 index 00000000..cf0672fc --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/MandatoryRuleTests.cs @@ -0,0 +1,74 @@ +using System.Collections; + +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class MandatoryRuleTests +{ + private const string _errorIsRequired = " is required."; + + [Test] + public void Mandatory() + { + new object().Validator(c => c.Mandatory()).ValidateAsSuccess(); + "XXX".Validator(c => c.Mandatory()).ValidateAsSuccess(); + 1.Validator(c => c.Mandatory()).ValidateAsSuccess(); + ((int?)1).Validator(c => c.Mandatory()).ValidateAsSuccess(); + new int[] { 1, 2 }.Validator(c => c.Mandatory()).ValidateAsSuccess(); + Enumerable.Range(1, 2).Validator(c => c.Mandatory()).ValidateAsSuccess(); + + ((int?)0).Validator(c => c.Mandatory()).ValidateAsError(_errorIsRequired); + ((object)null!).Validator(c => c.Mandatory()).ValidateAsError(_errorIsRequired); + ((string?)null).Validator(c => c.Mandatory()).ValidateAsError(_errorIsRequired); + "".Validator(c => c.Mandatory()).ValidateAsError(_errorIsRequired); + " ".Validator(c => c.Mandatory()).ValidateAsError(_errorIsRequired); + 0.Validator(c => c.Mandatory()).ValidateAsError(_errorIsRequired); + ((int?)null).Validator(c => c.Mandatory()).ValidateAsError(_errorIsRequired); + Array.Empty().Validator(c => c.Mandatory()).ValidateAsError(_errorIsRequired); + Enumerable.Empty().Validator(c => c.Mandatory()).ValidateAsError(_errorIsRequired); + } + + [Test] + public void NotEmpty() + { + new object().Validator(c => c.NotEmpty()).ValidateAsSuccess(); + "XXX".Validator(c => c.NotEmpty()).ValidateAsSuccess(); + 1.Validator(c => c.NotEmpty()).ValidateAsSuccess(); + ((int?)1).Validator(c => c.NotEmpty()).ValidateAsSuccess(); + new int[] { 1, 2 }.Validator(c => c.NotEmpty()).ValidateAsSuccess(); + Enumerable.Range(1, 2).Validator(c => c.NotEmpty()).ValidateAsSuccess(); + + ((int?)0).Validator(c => c.Mandatory()).ValidateAsError(_errorIsRequired); + ((object)null!).Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + ((string?)null).Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + "".Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + " ".Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + 0.Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + ((int?)null).Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + Array.Empty().Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + Enumerable.Empty().Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + } + + [Test] + public void NotNull() + { + new object().Validator(c => c.NotNull()).ValidateAsSuccess(); + "XXX".Validator(c => c.NotNull()).ValidateAsSuccess(); + "".Validator(c => c.NotNull()).ValidateAsSuccess(); + " ".Validator(c => c.NotNull()).ValidateAsSuccess(); + 0.Validator(c => c.NotNull()).ValidateAsSuccess(); + 1.Validator(c => c.NotNull()).ValidateAsSuccess(); + ((int?)0).Validator(c => c.NotNull()).ValidateAsSuccess(); + ((int?)1).Validator(c => c.NotNull()).ValidateAsSuccess(); + new int[] { 1, 2 }.Validator(c => c.NotNull()).ValidateAsSuccess(); + Enumerable.Range(1, 2).Validator(c => c.NotNull()).ValidateAsSuccess(); + Array.Empty().Validator(c => c.NotNull()).ValidateAsSuccess(); + Enumerable.Empty().Validator(c => c.NotNull()).ValidateAsSuccess(); + + ((object)null!).Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + ((string?)null).Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + ((int?)null).Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + ((ICollection)null!).Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + ((IEnumerable)null!).Validator(c => c.NotEmpty()).ValidateAsError(_errorIsRequired); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/NullNoneEmptyRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/NullNoneEmptyRuleTests.cs new file mode 100644 index 00000000..c57bec8f --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/NullNoneEmptyRuleTests.cs @@ -0,0 +1,60 @@ +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class NullNoneEmptyRuleTests +{ + private const string _errorContains = " must not be specified."; + + [Test] + public void Null() + { + ((object?)null!).Validator(c => c.Null()).ValidateAsSuccess(); + ((object)null!).Validator(c => c.Null()).ValidateAsSuccess(); + ((string?)null).Validator(c => c.Null()).ValidateAsSuccess(); + ((string)null!).Validator(c => c.Null()).ValidateAsSuccess(); + ((int?)null).Validator(c => c.Null()).ValidateAsSuccess(); + + new object().Validator(c => c.Null()).ValidateAsError(_errorContains); + ((string)"XXX").Validator(c => c.Null()).ValidateAsError(_errorContains); + ((string?)"XXX").Validator(c => c.Null()).ValidateAsError(_errorContains); + ((int?)0).Validator(c => c.Null()).ValidateAsError(_errorContains); + } + + [Test] + public void None() + { + new object().Validator(c => c.None()).ValidateAsError(_errorContains); + 1.Validator(c => c.None()).ValidateAsError(_errorContains); + ((int?)1).Validator(c => c.None()).ValidateAsError(_errorContains); + string.Empty.Validator(c => c.None()).ValidateAsError(_errorContains); + " ".Validator(c => c.None()).ValidateAsError(_errorContains); + Array.Empty().Validator(c => c.None()).ValidateAsError(_errorContains); + new int[] { 1, 2 }.Validator(c => c.None()).ValidateAsError(_errorContains); + Enumerable.Empty().Validator(c => c.None()).ValidateAsError(_errorContains); + Enumerable.Range(1, 2).Validator(c => c.None()).ValidateAsError(_errorContains); + + ((string?)null).Validator(c => c.None()).ValidateAsSuccess(); + 0.Validator(c => c.None()).ValidateAsSuccess(); + ((int?)0).Validator(c => c.None()).ValidateAsSuccess(); + ((int[])null!).Validator(c => c.None()).ValidateAsSuccess(); + ((IEnumerable)null!).Validator(c => c.None()).ValidateAsSuccess(); + } + + [Test] + public void Empty() + { + new object().Validator(c => c.Empty()).ValidateAsError(_errorContains); + "XXX".Validator(c => c.Empty()).ValidateAsError(_errorContains); + ((string?)"XXX").Validator(c => c.Empty()).ValidateAsError(_errorContains); + new int[] { 1, 2 }.Validator(c => c.Empty()).ValidateAsError(_errorContains); + Enumerable.Range(1, 2).Validator(c => c.Empty()).ValidateAsError(_errorContains); + + ((string?)null).Validator(c => c.Empty()).ValidateAsSuccess(); + string.Empty.Validator(c => c.Empty()).ValidateAsSuccess(); + " ".Validator(c => c.Empty()).ValidateAsSuccess(); + ((int[])null!).Validator(c => c.Empty()).ValidateAsSuccess(); + Array.Empty().Validator(c => c.Empty()).ValidateAsSuccess(); + ((IEnumerable)null!).Validator(c => c.Empty()).ValidateAsSuccess(); + Enumerable.Empty().Validator(c => c.Empty()).ValidateAsSuccess(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/NumericRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/NumericRuleTests.cs new file mode 100644 index 00000000..d6f48ce8 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/NumericRuleTests.cs @@ -0,0 +1,117 @@ +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class NumericRuleTests +{ + [Test] + public void Numeric() + { + 0.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + 123.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + (-123).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + int.MinValue.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + int.MaxValue.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + 0L.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + 123L.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + (-123L).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + long.MinValue.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + long.MaxValue.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + 0.0M.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + 123.45M.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + (-123.45M).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + decimal.MinValue.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + decimal.MaxValue.Validator(c => c.Numeric(true)).ValidateAsSuccess(); + + ((int?)0).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((int?)123).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((int?)-123).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((int?)int.MinValue).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((int?)int.MaxValue).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((long?)null).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((long?)0).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((long?)123).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((long?)-123).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((long?)long.MinValue).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((long?)long.MaxValue).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((decimal?)null).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((decimal?)0).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((decimal?)123.45M).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((decimal?)-123.45M).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((decimal?)decimal.MinValue).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + ((decimal?)decimal.MaxValue).Validator(c => c.Numeric(true)).ValidateAsSuccess(); + + 0.Validator(c => c.Numeric(false)).ValidateAsSuccess(); + 123.Validator(c => c.Numeric(false)).ValidateAsSuccess(); + (-123).Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + int.MinValue.Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + int.MaxValue.Validator(c => c.Numeric(false)).ValidateAsSuccess(); + 0L.Validator(c => c.Numeric(false)).ValidateAsSuccess(); + 123L.Validator(c => c.Numeric(false)).ValidateAsSuccess(); + (-123L).Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + long.MinValue.Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + long.MaxValue.Validator(c => c.Numeric(false)).ValidateAsSuccess(); + 0.0M.Validator(c => c.Numeric(false)).ValidateAsSuccess(); + 123.45M.Validator(c => c.Numeric(false)).ValidateAsSuccess(); + (-123.45M).Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + decimal.MinValue.Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + decimal.MaxValue.Validator(c => c.Numeric(false)).ValidateAsSuccess(); + + ((int?)null).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((int?)0).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((int?)123).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((int?)-123).Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + ((int?)int.MinValue).Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + ((int?)int.MaxValue).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((long?)null).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((long?)0).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((long?)123).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((long?)-123).Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + ((long?)long.MinValue).Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + ((long?)long.MaxValue).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((decimal?)null).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((decimal?)0).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((decimal?)123.45M).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + ((decimal?)-123.45M).Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + ((decimal?)decimal.MinValue).Validator(c => c.Numeric(false)).ValidateAsError("must not be negative."); + ((decimal?)decimal.MaxValue).Validator(c => c.Numeric(false)).ValidateAsSuccess(); + } + + [Test] + public void Positive() + { + 0.Validator(c => c.Positive()).ValidateAsSuccess(); + 123.Validator(c => c.Positive()).ValidateAsSuccess(); + (-123).Validator(c => c.Positive()).ValidateAsError("must not be negative."); + int.MinValue.Validator(c => c.Positive()).ValidateAsError("must not be negative."); + int.MaxValue.Validator(c => c.Positive()).ValidateAsSuccess(); + 0L.Validator(c => c.Positive()).ValidateAsSuccess(); + 123L.Validator(c => c.Positive()).ValidateAsSuccess(); + (-123L).Validator(c => c.Positive()).ValidateAsError("must not be negative."); + long.MinValue.Validator(c => c.Positive()).ValidateAsError("must not be negative."); + long.MaxValue.Validator(c => c.Positive()).ValidateAsSuccess(); + 0.0M.Validator(c => c.Positive()).ValidateAsSuccess(); + 123.45M.Validator(c => c.Positive()).ValidateAsSuccess(); + (-123.45M).Validator(c => c.Positive()).ValidateAsError("must not be negative."); + decimal.MinValue.Validator(c => c.Positive()).ValidateAsError("must not be negative."); + decimal.MaxValue.Validator(c => c.Positive()).ValidateAsSuccess(); + + ((int?)null).Validator(c => c.Positive()).ValidateAsSuccess(); + ((int?)0).Validator(c => c.Positive()).ValidateAsSuccess(); + ((int?)123).Validator(c => c.Positive()).ValidateAsSuccess(); + ((int?)-123).Validator(c => c.Positive()).ValidateAsError("must not be negative."); + ((int?)int.MinValue).Validator(c => c.Positive()).ValidateAsError("must not be negative."); + ((int?)int.MaxValue).Validator(c => c.Positive()).ValidateAsSuccess(); + ((long?)null).Validator(c => c.Positive()).ValidateAsSuccess(); + ((long?)0).Validator(c => c.Positive()).ValidateAsSuccess(); + ((long?)123).Validator(c => c.Positive()).ValidateAsSuccess(); + ((long?)-123).Validator(c => c.Positive()).ValidateAsError("must not be negative."); + ((long?)long.MinValue).Validator(c => c.Positive()).ValidateAsError("must not be negative."); + ((long?)long.MaxValue).Validator(c => c.Positive()).ValidateAsSuccess(); + ((decimal?)null).Validator(c => c.Positive()).ValidateAsSuccess(); + ((decimal?)0).Validator(c => c.Positive()).ValidateAsSuccess(); + ((decimal?)123.45M).Validator(c => c.Positive()).ValidateAsSuccess(); + ((decimal?)-123.45M).Validator(c => c.Positive()).ValidateAsError("must not be negative."); + ((decimal?)decimal.MinValue).Validator(c => c.Positive()).ValidateAsError("must not be negative."); + ((decimal?)decimal.MaxValue).Validator(c => c.Positive()).ValidateAsSuccess(); + } +} diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/StringRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/StringRuleTests.cs new file mode 100644 index 00000000..67c50c38 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/StringRuleTests.cs @@ -0,0 +1,109 @@ +using System.Text.RegularExpressions; + +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class StringRuleTests +{ + [Test] + public void String_MaximumLength() + { + ((string?)null).Validator(c => c.String(3)).ValidateAsSuccess(); + "".Validator(c => c.String(3)).ValidateAsSuccess(); + "abc".Validator(c => c.String(3)).ValidateAsSuccess(); + + "abcd".Validator(c => c.String(3)).ValidateAsError(" must not exceed 3 character(s) in length."); + } + + [Test] + public void String_MinimumLength() + { + ((string?)null).Validator(c => c.String(1, null)).ValidateAsSuccess(); + "abc".Validator(c => c.String(2, null)).ValidateAsSuccess(); + "ab".Validator(c => c.String(2, null)).ValidateAsSuccess(); + + "".Validator(c => c.String(1, null)).ValidateAsError(" must be at least 1 character(s) in length."); + "a".Validator(c => c.String(2, null)).ValidateAsError(" must be at least 2 character(s) in length."); + } + + [Test] + public void String_MinimumMaximumLength() + { + ((string?)null).Validator(c => c.String(1, 3)).ValidateAsSuccess(); + "a".Validator(c => c.String(1, 3)).ValidateAsSuccess(); + "ab".Validator(c => c.String(1, 3)).ValidateAsSuccess(); + "abc".Validator(c => c.String(1, 3)).ValidateAsSuccess(); + + "".Validator(c => c.String(1, 3)).ValidateAsError(" must be at least 1 character(s) in length."); + "abcd".Validator(c => c.String(1, 3)).ValidateAsError(" must not exceed 3 character(s) in length."); + } + + [Test] + public void String_ExactLength() + { + ((string?)null).Validator(c => c.String(3, 3)).ValidateAsSuccess(); + "abc".Validator(c => c.String(3, 3)).ValidateAsSuccess(); + + "".Validator(c => c.String(3, 3)).ValidateAsError("must be exactly 3 character(s) in length."); + "ab".Validator(c => c.String(3, 3)).ValidateAsError("must be exactly 3 character(s) in length."); + "abcd".Validator(c => c.String(3, 3)).ValidateAsError("must be exactly 3 character(s) in length."); + } + + [Test] + public void String_Regex() + { + var regex = new Regex(@"^\w+$"); + + ((string?)null).Validator(c => c.String(regex)).ValidateAsSuccess(); + "abc123".Validator(c => c.String(regex)).ValidateAsSuccess(); + + "".Validator(c => c.String(regex)).ValidateAsError(" is invalid."); + "abc 123".Validator(c => c.String(regex)).ValidateAsError(" is invalid."); + "abc 123".Validator(c => c.String(regex).WithMessage("No matchy matchy!")).ValidateAsError("No matchy matchy!"); + } + + [Test] + public void MaximumLength() + { + ((string?)null).Validator(c => c.MaximumLength(3)).ValidateAsSuccess(); + "".Validator(c => c.MaximumLength(3)).ValidateAsSuccess(); + "abc".Validator(c => c.MaximumLength(3)).ValidateAsSuccess(); + + "abcd".Validator(c => c.MaximumLength(3)).ValidateAsError(" must not exceed 3 character(s) in length."); + } + + [Test] + public void MinimumLength() + { + ((string?)null).Validator(c => c.MinimumLength(1)).ValidateAsSuccess(); + "abc".Validator(c => c.MinimumLength(2)).ValidateAsSuccess(); + "ab".Validator(c => c.MinimumLength(2)).ValidateAsSuccess(); + + "".Validator(c => c.MinimumLength(1)).ValidateAsError(" must be at least 1 character(s) in length."); + "a".Validator(c => c.MinimumLength(2)).ValidateAsError(" must be at least 2 character(s) in length."); + } + + [Test] + public void Length() + { + ((string?)null).Validator(c => c.Length(3)).ValidateAsSuccess(); + "abc".Validator(c => c.Length(3)).ValidateAsSuccess(); + + "".Validator(c => c.Length(3)).ValidateAsError("must be exactly 3 character(s) in length."); + "ab".Validator(c => c.Length(3)).ValidateAsError("must be exactly 3 character(s) in length."); + "abcd".Validator(c => c.Length(3)).ValidateAsError("must be exactly 3 character(s) in length."); + } + + [Test] + public void Matches() + { + var regex = new Regex(@"^\w+$"); + + ((string?)null).Validator(c => c.Matches(regex)).ValidateAsSuccess(); + "abc123".Validator(c => c.Matches(regex)).ValidateAsSuccess(); + + "".Validator(c => c.Matches(regex)).ValidateAsError(" is invalid."); + "abc 123".Validator(c => c.Matches(regex)).ValidateAsError(" is invalid."); + "abc 123".Validator(c => c.Matches(regex).WithMessage("No matchy matchy!")).ValidateAsError("No matchy matchy!"); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/Rules/WildcardRuleTests.cs b/tests/CoreEx.Validation.Test.Unit/Rules/WildcardRuleTests.cs new file mode 100644 index 00000000..e253cb63 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/Rules/WildcardRuleTests.cs @@ -0,0 +1,20 @@ +namespace CoreEx.Validation.Test.Unit.Rules; + +[TestFixture] +public class WildcardRuleTests +{ + [Test] + public void Wildcard() + { + ((string?)null).Validator(c => c.Wildcard()).ValidateAsSuccess(); + "xxx".Validator(c => c.Wildcard()).ValidateAsSuccess(); + "*xxx".Validator(c => c.Wildcard()).ValidateAsSuccess(); + "xxx*".Validator(c => c.Wildcard()).ValidateAsSuccess(); + "*xxx*".Validator(c => c.Wildcard()).ValidateAsSuccess(); + + "x*x".Validator(c => c.Wildcard()).ValidateAsError("contains invalid or non-supported wildcard selection."); + "x*x".Validator(c => c.Wildcard(Wildcards.Wildcard.MultiAll)).ValidateAsSuccess(); + "x?x".Validator(c => c.Wildcard(_ => Wildcards.Wildcard.MultiAll)).ValidateAsError("contains invalid or non-supported wildcard selection."); + "x?x".Validator(c => c.Wildcard(Wildcards.Wildcard.BothAll)).ValidateAsSuccess(); + } +} \ No newline at end of file diff --git a/tests/CoreEx.Validation.Test.Unit/ValidatorTests.cs b/tests/CoreEx.Validation.Test.Unit/ValidatorTests.cs new file mode 100644 index 00000000..9be999d8 --- /dev/null +++ b/tests/CoreEx.Validation.Test.Unit/ValidatorTests.cs @@ -0,0 +1,142 @@ +using CoreEx.Entities; +using CoreEx.Localization; +using System.ComponentModel.DataAnnotations; + +namespace CoreEx.Validation.Test.Unit; + +public class ValidatorTests +{ + [Test] + public void BaseValidator_Include() + { + var p = new Person(1) { Name = "a", Code = "b", Age = 2 }; + var pv = new PersonValidator(); + + pv.ValidateAsSuccess(p); + + // Verify base validator. + p.Name = "c"; + pv.ValidateAsError(p, "name", "Name must be between 'a' and 'b'."); + + // Verify main validator. + p.Name = "a"; + p.Code = "c"; + pv.ValidateAsError(p, "code", "Code must be between 'a' and 'b'."); + } + + [Test] + public void InlineValidator_Include() + { + var p = new Person(1) { Name = "a", Code = "b", Age = 2 }; + + var pb = Validator.Create() + .HasProperty(p => p.Id, p => p.Between(1, 2)) + .HasProperty(p => p.Name, p => p.MaximumLength(10).Between("a", "b")); + + var pv = Validator.Create() + .Include(pb) + .HasProperty(p => p.Code, p => p.Mandatory().Between("a", "b")) + .HasProperty(p => p.Age, p => p.Mandatory().Between(1, 4).DependsOn(p => p.Code).WhenValue(v => v == 2).Between(1, 4)); + + pv.ValidateAsSuccess(p); + + // Verify base validator. + p.Name = "c"; + pv.ValidateAsError(p, "name", "Name must be between 'a' and 'b'."); + + // Verify main validator. + p.Name = "a"; + p.Code = "c"; + pv.ValidateAsError(p, "code", "Code must be between 'a' and 'b'."); + } + + [Test] + public void WithText() + { + var pv = Validator.Create().HasProperty(p => p.Name, c => c.WithText("Fullname").Mandatory()); + pv.ValidateAsError(new Person(1), "name", "Fullname is required."); + + // Verify Include also works. + var pb = Validator.Create().HasProperty(p => p.Name, c => c.WithText("Fullname").Mandatory()); + pv = Validator.Create().Include(pb); + pv.ValidateAsError(new Person(1), "name", "Fullname is required."); + } + + [Test] + public void WithFormat_And_Localization() + { + var pv = Validator.Create().HasProperty(p => p.Salary, c => c.Between(100000m, 200000m)); + pv.ValidateAsError(new Person(1) { Salary = 99999m }, "salary", "Monies must be between '100000' and '200000'."); + + pv = Validator.Create().HasProperty(p => p.Salary, c => c.WithFormat("C").Between(100000m, 200000m)); + pv.ValidateAsError(new Person(1) { Salary = 99999m }, "salary", "Monies must be between '$100,000.00' and '$200,000.00'."); + + pv = Validator.Create().HasProperty(p => p.Salary, c => c.WithFormat("{0:C}").Between(100000m, 200000m)); + pv.ValidateAsError(new Person(1) { Salary = 99999m }, "salary", "Monies must be between '$100,000.00' and '$200,000.00'."); + } + + [Test] + public void WithFormat_Reflection() + { + var pv = Validator.Create().HasProperty(p => p.Age, c => c.Between(18, 60)); + pv.ValidateAsError(new Person(1) { Age = 12 }, "age", "Age must be between '018' and '060'."); + } + + [Test] + public void RuleSet() + { + var pv = Validator.Create() + .HasRuleSet(x => x.Value.Code == "A", c => + { + c.HasProperty(p => p.Age, c => c.Equal(18)); + }) + .HasRuleSet(x => x.Value.Code == "B", c => + { + c.HasProperty(p => p.Age, c => c.Equal(19, _ => "nineteen")); + }); + + pv.ValidateAsSuccess(new Person(1) { Code = "A", Age = 18 }); + pv.ValidateAsSuccess(new Person(1) { Code = "B", Age = 19 }); + pv.ValidateAsSuccess(new Person(1) { Code = "C", Age = 20 }); + + pv.ValidateAsError(new Person(1) { Code = "A", Age = 19 }, "age", "Age must be equal to '018'."); + pv.ValidateAsError(new Person(1) { Code = "B", Age = 18 }, "age", "Age must be equal to nineteen."); + } + + private class PersonBase(int id) : IReadOnlyIdentifier + { + public int Id { get; } = id; + + public string? Name { get; set; } + } + + private class Person(int id) : PersonBase(id) + { + public string Code { get; set; } = "A"; + + [DisplayFormat(DataFormatString = "{0:D3}")] + public int? Age { get; set; } + + [Localization("Monies")] + public decimal Salary { get; init; } + } + + private class PersonBaseValidator : Validator + { + public PersonBaseValidator() + { + Property(p => p.Id).Between(1, 2); + Property(p => p.Name).MaximumLength(10).Between("a", "b"); + } + } + + private class PersonValidator : Validator + { + public PersonValidator() + { + Include(new PersonBaseValidator()); + Property(p => p.Code).Mandatory().Between("a", "b"); + Property(p => p.Age).Between(1, 4).Mandatory().DependsOn(p => p.Code).WhenValue(v => v == 2).Between(1, 4); + } + } +} \ No newline at end of file From cd15fec44eb2f22d3f8237a1689ab902eb5e6afa Mon Sep 17 00:00:00 2001 From: spruit-avanade <86080272+spruit-avanade@users.noreply.github.com> Date: Tue, 5 May 2026 08:12:34 -0700 Subject: [PATCH 2/9] Azure Build & Deploy with azd (#138) * Azure Build & Deploy with azd and TF infra build * Azure Build & Deploy with azd and TF infra build --- .gitignore | 16 +- azure/AGENTS.md | 117 ++ azure/README.md | 300 ++++ azure/azure.yaml | 56 + azure/infra/.gitignore | 1 + azure/infra/main.bicep | 191 +++ azure/infra/main.dev.bicepparam | 34 + azure/infra/main.dev.parameters.json | 67 + azure/infra/main.json | 1222 +++++++++++++++++ azure/infra/main.prod.bicepparam | 34 + azure/infra/main.test.bicepparam | 34 + azure/infra/modules/app-service-plan.bicep | 24 + azure/infra/modules/app-services.bicep | 246 ++++ .../infra/modules/application-insights.bicep | 20 + azure/infra/modules/aspire-dashboard.bicep | 54 + azure/infra/modules/database.bicep | 64 + azure/infra/modules/key-vault.bicep | 25 + azure/infra/modules/redis.bicep | 41 + azure/infra/modules/service-bus.bicep | 69 + azure/infra/scripts/store-secrets.ps1 | 60 + azure/infra/scripts/store-secrets.sh | 65 + azure/infra/scripts/use-dev-params.ps1 | 72 + azure/infra/scripts/use-dev-params.sh | 74 + azure/infra/test.bicep | 20 + azure/scripts/ensure-sql-firewall-rule.ps1 | 101 ++ azure/scripts/ensure-sql-firewall-rule.sh | 86 ++ azure/scripts/package-dotnet-service.sh | 19 + azure/scripts/run-products-db-migrations.ps1 | 93 ++ azure/scripts/run-products-db-migrations.sh | 93 ++ azure/terraform/README.md | 212 +++ azure/terraform/apply.sh | 123 ++ azure/terraform/dev.tfvars | 32 + azure/terraform/main.tf | 676 +++++++++ azure/terraform/outputs.tf | 64 + azure/terraform/prod.tfvars | 31 + azure/terraform/terraform.tfvars.example | 29 + azure/terraform/test.tfvars | 31 + azure/terraform/variables.tf | 116 ++ azure/terraform/versions.tf | 28 + src/Directory.Build.props | 6 + 40 files changed, 4645 insertions(+), 1 deletion(-) create mode 100644 azure/AGENTS.md create mode 100644 azure/README.md create mode 100644 azure/azure.yaml create mode 100644 azure/infra/.gitignore create mode 100644 azure/infra/main.bicep create mode 100644 azure/infra/main.dev.bicepparam create mode 100644 azure/infra/main.dev.parameters.json create mode 100644 azure/infra/main.json create mode 100644 azure/infra/main.prod.bicepparam create mode 100644 azure/infra/main.test.bicepparam create mode 100644 azure/infra/modules/app-service-plan.bicep create mode 100644 azure/infra/modules/app-services.bicep create mode 100644 azure/infra/modules/application-insights.bicep create mode 100644 azure/infra/modules/aspire-dashboard.bicep create mode 100644 azure/infra/modules/database.bicep create mode 100644 azure/infra/modules/key-vault.bicep create mode 100644 azure/infra/modules/redis.bicep create mode 100644 azure/infra/modules/service-bus.bicep create mode 100644 azure/infra/scripts/store-secrets.ps1 create mode 100644 azure/infra/scripts/store-secrets.sh create mode 100644 azure/infra/scripts/use-dev-params.ps1 create mode 100644 azure/infra/scripts/use-dev-params.sh create mode 100644 azure/infra/test.bicep create mode 100644 azure/scripts/ensure-sql-firewall-rule.ps1 create mode 100644 azure/scripts/ensure-sql-firewall-rule.sh create mode 100644 azure/scripts/package-dotnet-service.sh create mode 100644 azure/scripts/run-products-db-migrations.ps1 create mode 100644 azure/scripts/run-products-db-migrations.sh create mode 100644 azure/terraform/README.md create mode 100644 azure/terraform/apply.sh create mode 100644 azure/terraform/dev.tfvars create mode 100644 azure/terraform/main.tf create mode 100644 azure/terraform/outputs.tf create mode 100644 azure/terraform/prod.tfvars create mode 100644 azure/terraform/terraform.tfvars.example create mode 100644 azure/terraform/test.tfvars create mode 100644 azure/terraform/variables.tf create mode 100644 azure/terraform/versions.tf diff --git a/.gitignore b/.gitignore index ead2755b..75042d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -416,4 +416,18 @@ FodyWeavers.xsd *.msi *.msix *.msm -*.msp \ No newline at end of file +*.msp + +# NTFS to Ext4 file system migration files +*.Identifier + +# Azd and Azure CLI for .NET project files +*.azdproj +.azd/ +.azure/ + +# Terraform files +*.tfstate +*.tfstate.backup +.terraform/ +.terraform.lock.hcl \ No newline at end of file diff --git a/azure/AGENTS.md b/azure/AGENTS.md new file mode 100644 index 00000000..1ad7023a --- /dev/null +++ b/azure/AGENTS.md @@ -0,0 +1,117 @@ +# AGENTS.md — Azure Deployment + +Operational guide for AI agents working in the `azure/` folder of this repository. This deploys the Contoso sample services (under `samples/src/`) to Azure using either **Azure Developer CLI (azd) + Bicep** or **Terraform**. + +## Scope + +This file applies to anything under `azure/`. For application code, see the relevant `samples/` projects. Companion human-facing docs: + +- [README.md](README.md) — azd + Bicep workflow. +- [terraform/README.md](terraform/README.md) — Terraform workflow. + +## Folder layout + +- [azure.yaml](azure.yaml) — azd project manifest. Declares the 6 services and the pre/post hooks. +- [infra/](infra/) — Bicep templates (primary IaC for `azd`). + - [infra/main.bicep](infra/main.bicep) — Entry template. + - [infra/modules/](infra/modules/) — Per-resource modules (`app-service-plan`, `app-services`, `aspire-dashboard`, `database`, `service-bus`, `redis`, `key-vault`, `application-insights`). + - [infra/scripts/](infra/scripts/) — Hook scripts (`use-dev-params.*`, `store-secrets.*`). + - `main.{dev,test,prod}.bicepparam` — Environment parameter files. +- [terraform/](terraform/) — Terraform implementation that mirrors the Bicep deployment (parity must be maintained when changing one or the other). +- [scripts/](scripts/) — Higher-level deployment helper scripts (DB migrations, SQL firewall, packaging). + +## What gets deployed + +Both Bicep and Terraform provision the same resource set: + +- Linux App Service Plan. +- 7 Web Apps: `aspire-dashboard`, `products-api`, `shopping-api`, `products-outbox-relay`, `shopping-outbox-relay`, `products-subscribe`, `shopping-subscribe`. +- Azure SQL Server + Database (with firewall rules). +- Azure Service Bus (Standard) — namespace + topic + subscriptions. +- Azure Managed Redis. +- Application Insights. +- Key Vault (per-deployment unique name; stores E2E secrets). + +## Conventions + +- Comments end with a period/fullstop (repo-wide rule from [.github/copilot-instructions.md](../.github/copilot-instructions.md)). +- Keep Bicep and Terraform in sync. Any new resource, parameter, or wiring change in `infra/` must have an equivalent change in `terraform/` (and vice versa). Cross-reference by env: `main.dev.bicepparam` ↔ `dev.tfvars`, etc. +- Resource naming, SKUs, and per-environment values are driven by the parameter/tfvars files — do not hardcode environment-specific values in templates. +- Key Vault name is generated uniquely per deployment in [infra/main.bicep](infra/main.bicep). Do not assume a fixed name. +- Multi-targeted .NET projects must be published with a single TFM. The TFM is sourced from `AZD_DOTNET_TARGET_FRAMEWORK` (preferred) or `DOTNET_TARGET_FRAMEWORK` and mapped to App Service Linux `DOTNETCORE|` by the preprovision hook. + +## azd hooks (defined in [azure.yaml](azure.yaml)) + +- `preprovision` → `infra/scripts/use-dev-params.{sh,ps1}` — selects the dev parameter file, injects `AZURE_LOCATION` and `AZURE_SQL_ADMIN_PASSWORD` into `main.parameters.json`, maps the .NET TFM to the App Service runtime. +- `predeploy` → `scripts/run-products-db-migrations.{sh,ps1}` — runs DB migrations against the provisioned SQL DB before app code deploys. +- `postprovision` → `infra/scripts/store-secrets.{sh,ps1}` — grants the provisioning user `Key Vault Administrator` and stores `sql-admin-password`, `sql-connection-string`, `service-bus-connection-string` in Key Vault. + +When editing hook scripts, keep the bash and PowerShell variants behaviorally identical. + +## Required environment variables + +Set in the azd environment (`azd env set `): + +- `AZURE_SUBSCRIPTION_ID` — target subscription. +- `AZURE_LOCATION` — e.g. `eastus2`. +- `AZURE_SQL_ADMIN_PASSWORD` — strong password; consumed by hooks, never committed. +- `AZD_DOTNET_TARGET_FRAMEWORK` — one of `net8.0`, `net9.0`, `net10.0`. + +Load into the current shell before running ad-hoc `az` / `terraform` commands: + +```bash +set -a && eval "$(azd env get-values)" && set +a +``` + +## Common workflows + +### azd + Bicep + +```bash +cd azure +azd provision --preview --no-prompt # plan. +azd package --all --no-prompt # build & package services. +azd up --no-prompt # full provision + deploy. +azd deploy --all --no-prompt # code-only redeploy. +azd down --force --purge --no-prompt # tear down. +``` + +### Terraform + +```bash +cd azure/terraform +./apply.sh dev plan +./apply.sh dev apply +``` + +`apply.sh` loads `azd env` values, resolves the runner public IP for SQL firewall, and maps `AZD_DOTNET_TARGET_FRAMEWORK` to `app_service_linux_fx_version`. + +## Validating changes + +Before declaring an infra change complete: + +1. **Bicep**: `azd provision --preview --no-prompt` from `azure/`, or run `az deployment group what-if` against `infra/main.bicep` with `infra/main.parameters.json` after the preprovision hook has populated it. +2. **Terraform**: `./apply.sh plan` and confirm no unintended destroy/replace operations. +3. **Parity**: diff the resource set between Bicep `what-if` and Terraform `plan` when changing either side. +4. **Hooks**: if you touched `infra/scripts/` or `scripts/`, run the bash and PowerShell variants (or read them carefully) to confirm parity. + +Do not run `azd up`, `azd down`, `terraform apply`, or `terraform destroy` without explicit user approval — these touch live Azure resources. + +## Secrets and safety + +- Never commit `AZURE_SQL_ADMIN_PASSWORD`, generated parameter files containing secrets, `terraform.tfstate*`, or any Key Vault content. +- The post-provision hook is the canonical source for runtime secrets in Key Vault. Do not duplicate secret-storage logic elsewhere. +- Treat `terraform.tfstate` as sensitive; do not print or echo it. +- Avoid destructive Azure operations (`az group delete`, `azd down`, `terraform destroy`, dropping SQL DBs) unless the user has confirmed in this turn. + +## Troubleshooting cheatsheet + +- **Multi-target publish error (NETSDK1129)** — set `AZD_DOTNET_TARGET_FRAMEWORK` and reload env. +- **SQL password missing** — set `AZURE_SQL_ADMIN_PASSWORD` before `azd provision` / `terraform apply`. +- **API returns 404 at `/`** — expected; probe `/api/...`, `/health/ready/detailed`, or `/swagger`. +- **Aspire Dashboard requires token** — fetch from `az webapp log tail` (see [README.md](README.md#accessing-the-aspire-dashboard)). +- **`azd init` says no project** — run from `azure/`, not the repo root. + +## E2E tests against deployed services + +Endpoints, SQL, and Service Bus connection strings can be wired into [../samples/tests/Contoso.E2E.Runner/appsettings.json](../samples/tests/Contoso.E2E.Runner/appsettings.json). Pull connection strings from Key Vault (populated by the postprovision hook) rather than reconstructing them. See the "Running E2E Tests" section of [README.md](README.md) for exact commands. diff --git a/azure/README.md b/azure/README.md new file mode 100644 index 00000000..ce4820ff --- /dev/null +++ b/azure/README.md @@ -0,0 +1,300 @@ +# CoreEx Azure Deployment with azd + +This folder contains the Azure Developer CLI (azd) project for deploying the Contoso sample services in this repository. + +## What this deploys + +Infrastructure (Bicep): +- App Service Plan (Linux). +- 6 Web Apps: + - aspire-dashboard + - products-api + - shopping-api + - products-outbox-relay + - shopping-outbox-relay + - products-subscribe + - shopping-subscribe +- Azure SQL Database. +- Azure Service Bus (Standard). +- Azure Managed Redis. +- Application Insights. +- Key Vault. + +## Important behaviors + +- Dev parameters are applied automatically by a `preprovision` hook in [azure.yaml](azure.yaml). +- The `postprovision` hook grants the provisioning user `Key Vault Administrator` on the deployed Key Vault and stores E2E connection secrets. +- Deployment `location` is sourced from `AZURE_LOCATION` and injected by the preprovision hook. +- SQL admin password is injected at runtime from `AZURE_SQL_ADMIN_PASSWORD` by [infra/scripts/use-dev-params.sh](infra/scripts/use-dev-params.sh). +- Key Vault name is unique per deployment (generated in [infra/main.bicep](infra/main.bicep)). +- Multi-targeted .NET projects use a configurable publish framework via environment variable: + - `AZD_DOTNET_TARGET_FRAMEWORK` (preferred), or + - `DOTNET_TARGET_FRAMEWORK`. + +## Prerequisites + +- Azure CLI (`az`). +- Azure Developer CLI (`azd`). +- .NET SDK installed. +- Access to an Azure subscription. +- Outbound port 1433 access to run DB updates. + +## One-time setup + +From this folder: + +```bash +cd ./CoreEx/azure +``` + +Authenticate: + +```bash +az login +azd auth login +``` + +Create/select azd environment if needed: + +```bash +azd env new +# or +azd env select +``` + +Set required values: + +```bash +azd env set AZURE_SUBSCRIPTION_ID +azd env set AZURE_LOCATION eastus2 +azd env set AZURE_SQL_ADMIN_PASSWORD '' +azd env set AZD_DOTNET_TARGET_FRAMEWORK 'net10.0' +``` + +Note: Region availability and quota can vary by SKU. `AZD_DOTNET_TARGET_FRAMEWORK` can be set to `net8.0`, `net9.0`, or `net10.0` depending on your requirements. + +Load environment variables into your current bash session: + +```bash +set -a && eval "$(azd env get-values)" && set +a +``` + +The preprovision hook maps the selected target framework to the App Service Linux runtime automatically: +- `net8.0` -> `DOTNETCORE|8.0` +- `net9.0` -> `DOTNETCORE|9.0` +- `net10.0` -> `DOTNETCORE|10.0` + +## Validate before deploy + +Preview infra changes: + +```bash +azd provision --preview --no-prompt +``` + +Note: If you need the full planned resource list, run: + +```bash +set -a && eval "$(azd env get-values)" && set +a +./infra/scripts/use-dev-params.sh +az deployment group what-if --resource-group --template-file ./infra/main.bicep --parameters ./infra/main.parameters.json --no-pretty-print +``` + +Package all services: + +```bash +azd package --all --no-prompt +``` + +## Deploy + +Provision infra + deploy services: + +```bash +azd up --no-prompt +``` + +Redeploy code only: + +```bash +azd deploy --all --no-prompt +``` + +Re-run infra only: + +```bash +azd provision --no-prompt +``` + +## Accessing the Aspire Dashboard + +After deployment, the Aspire Dashboard is publicly accessible from a dedicated HTTPS-enabled App Service. The six deployed services are configured to export OTLP telemetry to it automatically. + +Find the dashboard URL: + +```bash +az webapp show --resource-group --name --query defaultHostName -o tsv +``` + +Open `https://` in a browser to view the Aspire Dashboard. + +If the dashboard prompts for a browser token, retrieve it from the container logs: + +```bash +az webapp log tail --resource-group --name +``` + +Extract only the token (bash): + +```bash +TOKEN=$(az webapp log tail --resource-group --name 2>&1 | grep -oE 'login\?t=[A-Za-z0-9]+' | head -n1 | cut -d= -f2) +echo "$TOKEN" +``` + +Print a ready-to-open login URL: + +```bash +HOST=$(az webapp show --resource-group --name --query defaultHostName -o tsv) +TOKEN=$(az webapp log tail --resource-group --name 2>&1 | grep -oE 'login\?t=[A-Za-z0-9]+' | head -n1 | cut -d= -f2) +echo "https://${HOST}/login?t=${TOKEN}" +``` + +The standalone dashboard displays telemetry views such as: +- Service topology and dependencies +- Health status and logs +- Traces and metrics received over OTLP + +Note: standalone Aspire Dashboard mode does not provide the full Aspire resource model UI that the local AppHost provides. + +## Running E2E Tests + +After deploying with `azd up`, you can run the E2E test runner against the deployed services. + +### Get deployed endpoint URLs + +Retrieve the deployed App Service endpoints: + +```bash +az webapp list --resource-group --query "[].hostNames[0]" -o tsv +``` + +This will show URLs like: +- `app-products-api-dev-{suffix}.azurewebsites.net` +- `app-shopping-api-dev-{suffix}.azurewebsites.net` + +Validate the deployed APIs using API/health/swagger paths (not root `/`): + +```bash +# Products API +curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/api/products" + +# Shopping API +curl -i "https://app-shopping-api-dev-{suffix}.azurewebsites.net/api/baskets" + +# Common liveness and docs paths +curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/health/ready/detailed" +curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/swagger" +``` + +### Retrieve connection strings + +After `azd provision` or `azd up`, the postprovision hook automatically stores the following secrets in Key Vault: +- `sql-admin-password` +- `sql-connection-string` +- `service-bus-connection-string` + +Retrieve them: + +```bash +# Get Key Vault name +KV=$(az keyvault list --resource-group --query '[0].name' -o tsv) + +# SQL connection string +az keyvault secret show --vault-name $KV --name sql-connection-string -o tsv --query value + +# Service Bus connection string +az keyvault secret show --vault-name $KV --name service-bus-connection-string -o tsv --query value +``` + +### Update E2E configuration + +Edit [../samples/tests/Contoso.E2E.Runner/appsettings.json](../samples/tests/Contoso.E2E.Runner/appsettings.json) with the deployed endpoints and connection strings: + +```json +{ + "E2E": { + "Products": { + "BaseAddress": "https://app-products-api-dev-{suffix}.azurewebsites.net", + "ConnectionString": "Server=sql-dev-{suffix}.database.windows.net;Database=Contoso;User Id=sqladmin;Password={password};Encrypt=true;TrustServerCertificate=false;", + "ServiceBus": "Endpoint=sb://sb-dev-{suffix}.servicebus.windows.net/;SharedAccessKeyName=rootManageSharedAccessKey;SharedAccessKey={key};" + }, + "Shopping": { + "BaseAddress": "https://app-shopping-api-dev-{suffix}.azurewebsites.net", + "ConnectionString": "Server=sql-dev-{suffix}.database.windows.net;Database=Contoso;User Id=sqladmin;Password={password};Encrypt=true;TrustServerCertificate=false;", + "ServiceBus": "Endpoint=sb://sb-dev-{suffix}.servicebus.windows.net/;SharedAccessKeyName=rootManageSharedAccessKey;SharedAccessKey={key};" + } + } +} +``` + +### Run E2E scenarios + +From the repository root: + +```bash +cd samples/tests/Contoso.E2E.Runner +dotnet run --framework "${AZD_DOTNET_TARGET_FRAMEWORK:-$DOTNET_TARGET_FRAMEWORK}" +``` + +This launches an interactive CLI menu to select and execute test scenarios or run load simulations against the deployed APIs. + + +## Troubleshooting + +No project exists / run azd init: + +```bash +cd ./CoreEx/azure +azd init +``` + +No subscriptions found: + +```bash +azd auth login --tenant-id +``` + +SQL password missing: +- Ensure `AZURE_SQL_ADMIN_PASSWORD` is set before `azd provision` or `azd up`. + +Multi-target publish error (NETSDK1129): +- Ensure `AZD_DOTNET_TARGET_FRAMEWORK` is set in your azd environment: `azd env set AZD_DOTNET_TARGET_FRAMEWORK net10.0`. +- Load it into your current shell: `et -a && eval "$(azd env get-values)" && set +a`. + +`command not found` while loading environment values: +- This usually means `azd env get-values` was run outside the azd project folder and returned `ERROR: no project exists...`. +- Run from `./CoreEx/azure`, or use `azd -C /path/to/CoreEx/azure env get-values`. + +API returns 404 at `/`: +- This is expected for these services. +- Use `/api/...`, `/health`, or `/swagger` paths to validate the deployment. + +## Useful commands + +Show current environment values: + +```bash +azd env get-values +``` + +List environments: + +```bash +azd env list +``` + +Delete deployed resources: + +```bash +azd down --force --purge --no-prompt +``` diff --git a/azure/azure.yaml b/azure/azure.yaml new file mode 100644 index 00000000..1999ea15 --- /dev/null +++ b/azure/azure.yaml @@ -0,0 +1,56 @@ +name: coreex-contoso +metadata: + template: coreex-azd-infra + +services: + products-api: + project: ../samples/src/Contoso.Products.Api + language: csharp + host: appservice + shopping-api: + project: ../samples/src/Contoso.Shopping.Api + language: csharp + host: appservice + products-outbox-relay: + project: ../samples/src/Contoso.Products.Outbox.Relay + language: csharp + host: appservice + shopping-outbox-relay: + project: ../samples/src/Contoso.Shopping.Outbox.Relay + language: csharp + host: appservice + products-subscribe: + project: ../samples/src/Contoso.Products.Subscribe + language: csharp + host: appservice + shopping-subscribe: + project: ../samples/src/Contoso.Shopping.Subscribe + language: csharp + host: appservice + +infra: + provider: bicep + path: infra + +hooks: + preprovision: + - posix: + shell: sh + run: ./infra/scripts/use-dev-params.sh + windows: + shell: pwsh + run: ./infra/scripts/use-dev-params.ps1 + predeploy: + - posix: + shell: sh + run: bash ./scripts/run-products-db-migrations.sh + windows: + shell: pwsh + run: ./scripts/run-products-db-migrations.ps1 + postprovision: + - posix: + shell: sh + run: ./infra/scripts/store-secrets.sh + windows: + shell: pwsh + run: ./infra/scripts/store-secrets.ps1 diff --git a/azure/infra/.gitignore b/azure/infra/.gitignore new file mode 100644 index 00000000..f94ddfd4 --- /dev/null +++ b/azure/infra/.gitignore @@ -0,0 +1 @@ +main.parameters.json diff --git a/azure/infra/main.bicep b/azure/infra/main.bicep new file mode 100644 index 00000000..e967ac54 --- /dev/null +++ b/azure/infra/main.bicep @@ -0,0 +1,191 @@ +targetScope = 'resourceGroup' + +@allowed([ + 'dev' + 'test' + 'prod' +]) +@description('Deployment environment.') +param environmentType string + +@description('Location for all resources.') +param location string = resourceGroup().location + +@description('Unique suffix for globally unique resource names. Use a short lowercase token, e.g. a1b2c3.') +param nameSuffix string + +@description('Tags applied to all resources.') +param tags object = {} + +@description('App Service Plan SKU name.') +param appServicePlanSkuName string + +@description('App Service Plan tier.') +param appServicePlanTier string + +@description('App Service Plan instance count.') +param appServicePlanCapacity int + +@description('App Service Linux runtime stack. Example: DOTNETCORE|10.0.') +param appServiceLinuxFxVersion string + +@description('Service Bus namespace SKU. Basic, Standard, or Premium.') +param serviceBusSkuName string + +@description('Azure SQL administrator login name.') +param sqlAdminLogin string + +@secure() +@description('Azure SQL administrator password.') +param sqlAdminPassword string + +@description('Azure SQL database name.') +param sqlDatabaseName string + +@description('Current runner public IPv4 address to allow through the Azure SQL firewall.') +param sqlFirewallClientIp string = '' + +@description('Azure SQL database SKU name. Example: GP_S_Gen5_1.') +param sqlSkuName string + +@description('Azure SQL database tier. Example: GeneralPurpose.') +param sqlTier string + +@description('Azure SQL minimum vCores for serverless, represented as a JSON number string (for example 0.5).') +param sqlMinCapacity string + +@description('Azure SQL auto-pause delay in minutes. Set to -1 to disable.') +param sqlAutoPauseDelay int + +@description('Azure Managed Redis SKU name. Example: Balanced_B0.') +param redisSkuName string + +@allowed([ + 'Enabled' + 'Disabled' +]) +@description('Azure Managed Redis high availability mode.') +param redisHighAvailability string + +var suffix = toLower(nameSuffix) +var keyVaultName = take('kv${environmentType}${suffix}${uniqueString(deployment().name, resourceGroup().id)}', 24) +var mergedTags = union(tags, { + environment: environmentType + managedBy: 'azd' + 'azd-env-name': environmentType +}) + +module appInsights './modules/application-insights.bicep' = { + name: 'appInsightsDeploy' + params: { + location: location + name: 'appi-${environmentType}-${suffix}' + tags: mergedTags + } +} + +module keyVault './modules/key-vault.bicep' = { + name: 'keyVaultDeploy' + params: { + location: location + name: keyVaultName + tags: mergedTags + } +} + +module appServicePlan './modules/app-service-plan.bicep' = { + name: 'appServicePlanDeploy' + params: { + location: location + name: 'asp-${environmentType}-${suffix}' + skuName: appServicePlanSkuName + skuTier: appServicePlanTier + capacity: appServicePlanCapacity + tags: mergedTags + } +} + +module serviceBus './modules/service-bus.bicep' = { + name: 'serviceBusDeploy' + params: { + location: location + namespaceName: 'sb-${environmentType}-${suffix}' + skuName: serviceBusSkuName + tags: mergedTags + } +} + +module redis './modules/redis.bicep' = { + name: 'redisDeploy' + params: { + location: location + cacheName: 'redis-${environmentType}-${suffix}' + skuName: redisSkuName + highAvailability: redisHighAvailability + tags: mergedTags + } +} + +module sql './modules/database.bicep' = { + name: 'sqlDeploy' + params: { + location: location + serverName: 'sql-${environmentType}-${suffix}' + databaseName: sqlDatabaseName + adminLogin: sqlAdminLogin + adminPassword: sqlAdminPassword + clientIp: sqlFirewallClientIp + skuName: sqlSkuName + skuTier: sqlTier + minCapacity: sqlMinCapacity + autoPauseDelay: sqlAutoPauseDelay + tags: mergedTags + } +} + +module appServices './modules/app-services.bicep' = { + name: 'appServicesDeploy' + params: { + location: location + appServicePlanId: appServicePlan.outputs.id + appServiceLinuxFxVersion: appServiceLinuxFxVersion + environmentType: environmentType + suffix: suffix + tags: mergedTags + appInsightsConnectionString: appInsights.outputs.connectionString + appInsightsResourceId: appInsights.outputs.id + appInsightsInstrumentationKey: appInsights.outputs.instrumentationKey + sqlConnectionString: sql.outputs.connectionString + redisConnectionString: redis.outputs.connectionString + serviceBusConnectionString: serviceBus.outputs.connectionString + otlpHttpEndpoint: aspireDashboard.outputs.otlpHttpEndpoint + } +} + +module aspireDashboard './modules/aspire-dashboard.bicep' = { + name: 'aspireDashboardDeploy' + params: { + location: location + appServicePlanId: appServicePlan.outputs.id + environmentType: environmentType + suffix: suffix + tags: mergedTags + } +} + +output appServicePlanName string = appServicePlan.outputs.name +output appInsightsConnectionString string = appInsights.outputs.connectionString +output keyVaultName string = keyVault.outputs.name +output serviceBusNamespaceName string = serviceBus.outputs.namespaceName +output redisHostName string = redis.outputs.hostName +output sqlServerName string = sql.outputs.serverName +output sqlDatabaseName string = sql.outputs.databaseName +output productsApiAppName string = appServices.outputs.productsApiName +output shoppingApiAppName string = appServices.outputs.shoppingApiName +output productsOutboxRelayAppName string = appServices.outputs.productsOutboxRelayName +output shoppingOutboxRelayAppName string = appServices.outputs.shoppingOutboxRelayName +output productsSubscribeAppName string = appServices.outputs.productsSubscribeName +output shoppingSubscribeAppName string = appServices.outputs.shoppingSubscribeName +output aspireDashboardAppName string = aspireDashboard.outputs.appName +output aspireDashboardUri string = aspireDashboard.outputs.dashboardUri +output aspireDashboardOtlpGrpcEndpoint string = aspireDashboard.outputs.otlpGrpcEndpoint diff --git a/azure/infra/main.dev.bicepparam b/azure/infra/main.dev.bicepparam new file mode 100644 index 00000000..f82f70f1 --- /dev/null +++ b/azure/infra/main.dev.bicepparam @@ -0,0 +1,34 @@ +using './main.bicep' + +param environmentType = 'dev' +param location = readEnvironmentVariable('AZURE_LOCATION') +param nameSuffix = 'dev01' + +param tags = { + workload: 'coreex' + environment: 'dev' + costProfile: 'minimum-practical' +} + +// Basic B2 plan for multi-service web apps. +param appServicePlanSkuName = 'B2' +param appServicePlanTier = 'Basic' +param appServicePlanCapacity = 1 +param appServiceLinuxFxVersion = 'DOTNETCORE|${replace(readEnvironmentVariable('AZD_DOTNET_TARGET_FRAMEWORK', readEnvironmentVariable('DOTNET_TARGET_FRAMEWORK', 'net8.0')), 'net', '')}' + +// Requested by user: keep Service Bus on Standard. +param serviceBusSkuName = 'Standard' + +param sqlAdminLogin = 'coreexadmin' +param sqlAdminPassword = readEnvironmentVariable('AZURE_SQL_ADMIN_PASSWORD') +param sqlDatabaseName = 'coreexdev' + +// Requested by user: SQL serverless with 60-minute auto-pause. +param sqlSkuName = 'GP_S_Gen5_1' +param sqlTier = 'GeneralPurpose' +param sqlMinCapacity = '0.5' +param sqlAutoPauseDelay = 60 + +// Entry Azure Managed Redis tier. +param redisSkuName = 'Balanced_B0' +param redisHighAvailability = 'Disabled' diff --git a/azure/infra/main.dev.parameters.json b/azure/infra/main.dev.parameters.json new file mode 100644 index 00000000..f5efaa4a --- /dev/null +++ b/azure/infra/main.dev.parameters.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentType": { + "value": "dev" + }, + "location": { + "value": "__AZURE_LOCATION__" + }, + "nameSuffix": { + "value": "dev01" + }, + "tags": { + "value": { + "workload": "coreex", + "environment": "dev", + "costProfile": "minimum-practical" + } + }, + "appServicePlanSkuName": { + "value": "B2" + }, + "appServicePlanTier": { + "value": "Basic" + }, + "appServicePlanCapacity": { + "value": 1 + }, + "appServiceLinuxFxVersion": { + "value": "__APP_SERVICE_LINUX_FX_VERSION__" + }, + "serviceBusSkuName": { + "value": "Standard" + }, + "sqlAdminLogin": { + "value": "coreexadmin" + }, + "sqlAdminPassword": { + "value": "__AZURE_SQL_ADMIN_PASSWORD__" + }, + "sqlDatabaseName": { + "value": "coreexdev" + }, + "sqlFirewallClientIp": { + "value": "__AZURE_CLIENT_IP__" + }, + "sqlSkuName": { + "value": "GP_S_Gen5_1" + }, + "sqlTier": { + "value": "GeneralPurpose" + }, + "sqlMinCapacity": { + "value": "0.5" + }, + "sqlAutoPauseDelay": { + "value": 60 + }, + "redisSkuName": { + "value": "Balanced_B0" + }, + "redisHighAvailability": { + "value": "Disabled" + } + } +} diff --git a/azure/infra/main.json b/azure/infra/main.json new file mode 100644 index 00000000..200cce04 --- /dev/null +++ b/azure/infra/main.json @@ -0,0 +1,1222 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.42.1.51946", + "templateHash": "11332895255838187634" + } + }, + "parameters": { + "environmentType": { + "type": "string", + "allowedValues": [ + "dev", + "test", + "prod" + ], + "metadata": { + "description": "Deployment environment." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for all resources." + } + }, + "nameSuffix": { + "type": "string", + "metadata": { + "description": "Unique suffix for globally unique resource names. Use a short lowercase token, e.g. a1b2c3." + } + }, + "tags": { + "type": "object", + "defaultValue": {}, + "metadata": { + "description": "Tags applied to all resources." + } + }, + "appServicePlanSkuName": { + "type": "string", + "metadata": { + "description": "App Service Plan SKU name." + } + }, + "appServicePlanTier": { + "type": "string", + "metadata": { + "description": "App Service Plan tier." + } + }, + "appServicePlanCapacity": { + "type": "int", + "metadata": { + "description": "App Service Plan instance count." + } + }, + "serviceBusSkuName": { + "type": "string", + "metadata": { + "description": "Service Bus namespace SKU. Basic, Standard, or Premium." + } + }, + "sqlAdminLogin": { + "type": "string", + "metadata": { + "description": "Azure SQL administrator login name." + } + }, + "sqlAdminPassword": { + "type": "securestring", + "metadata": { + "description": "Azure SQL administrator password." + } + }, + "sqlDatabaseName": { + "type": "string", + "metadata": { + "description": "Azure SQL database name." + } + }, + "sqlSkuName": { + "type": "string", + "metadata": { + "description": "Azure SQL database SKU name. Example: GP_S_Gen5_1." + } + }, + "sqlTier": { + "type": "string", + "metadata": { + "description": "Azure SQL database tier. Example: GeneralPurpose." + } + }, + "sqlMinCapacity": { + "type": "string", + "metadata": { + "description": "Azure SQL minimum vCores for serverless, represented as a JSON number string (for example 0.5)." + } + }, + "sqlAutoPauseDelay": { + "type": "int", + "metadata": { + "description": "Azure SQL auto-pause delay in minutes. Set to -1 to disable." + } + }, + "redisSkuName": { + "type": "string", + "metadata": { + "description": "Redis SKU name. Basic, Standard, or Premium." + } + }, + "redisSkuFamily": { + "type": "string", + "metadata": { + "description": "Redis SKU family. Typically C for Basic/Standard." + } + }, + "redisSkuCapacity": { + "type": "int", + "metadata": { + "description": "Redis SKU capacity." + } + } + }, + "variables": { + "suffix": "[toLower(parameters('nameSuffix'))]", + "keyVaultName": "[take(format('kv{0}{1}{2}', parameters('environmentType'), variables('suffix'), uniqueString(deployment().name, resourceGroup().id)), 24)]", + "mergedTags": "[union(parameters('tags'), createObject('environment', parameters('environmentType'), 'managedBy', 'azd', 'azd-env-name', parameters('environmentType')))]" + }, + "resources": [ + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "appInsightsDeploy", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "name": { + "value": "[format('appi-{0}-{1}', parameters('environmentType'), variables('suffix'))]" + }, + "tags": { + "value": "[variables('mergedTags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.42.1.51946", + "templateHash": "13606231085913354784" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + } + }, + "resources": [ + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "web", + "properties": { + "Application_Type": "web", + "Flow_Type": "Bluefield", + "Request_Source": "rest" + } + } + ], + "outputs": { + "name": { + "type": "string", + "value": "[parameters('name')]" + }, + "connectionString": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Insights/components', parameters('name')), '2020-02-02').ConnectionString]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "keyVaultDeploy", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "name": { + "value": "[variables('keyVaultName')]" + }, + "tags": { + "value": "[variables('mergedTags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.42.1.51946", + "templateHash": "2576417288351020778" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2023-07-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "sku": { + "family": "A", + "name": "standard" + }, + "tenantId": "[subscription().tenantId]", + "enableRbacAuthorization": true, + "enabledForDeployment": true, + "enabledForTemplateDeployment": true, + "enabledForDiskEncryption": false, + "publicNetworkAccess": "Enabled" + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.KeyVault/vaults', parameters('name'))]" + }, + "name": { + "type": "string", + "value": "[parameters('name')]" + }, + "vaultUri": { + "type": "string", + "value": "[reference(resourceId('Microsoft.KeyVault/vaults', parameters('name')), '2023-07-01').vaultUri]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "appServicePlanDeploy", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "name": { + "value": "[format('asp-{0}-{1}', parameters('environmentType'), variables('suffix'))]" + }, + "skuName": { + "value": "[parameters('appServicePlanSkuName')]" + }, + "skuTier": { + "value": "[parameters('appServicePlanTier')]" + }, + "capacity": { + "value": "[parameters('appServicePlanCapacity')]" + }, + "tags": { + "value": "[variables('mergedTags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.42.1.51946", + "templateHash": "12329281375890776410" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "skuName": { + "type": "string" + }, + "skuTier": { + "type": "string" + }, + "capacity": { + "type": "int" + }, + "tags": { + "type": "object", + "defaultValue": {} + } + }, + "resources": [ + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2023-12-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "linux", + "sku": { + "name": "[parameters('skuName')]", + "tier": "[parameters('skuTier')]", + "capacity": "[parameters('capacity')]" + }, + "properties": { + "reserved": true + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Web/serverfarms', parameters('name'))]" + }, + "name": { + "type": "string", + "value": "[parameters('name')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "serviceBusDeploy", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "namespaceName": { + "value": "[format('sb-{0}-{1}', parameters('environmentType'), variables('suffix'))]" + }, + "skuName": { + "value": "[parameters('serviceBusSkuName')]" + }, + "tags": { + "value": "[variables('mergedTags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.42.1.51946", + "templateHash": "14328657583515942296" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "namespaceName": { + "type": "string" + }, + "skuName": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + } + }, + "resources": [ + { + "type": "Microsoft.ServiceBus/namespaces", + "apiVersion": "2023-01-01-preview", + "name": "[parameters('namespaceName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "[parameters('skuName')]", + "tier": "[parameters('skuName')]" + }, + "properties": { + "publicNetworkAccess": "Enabled", + "minimumTlsVersion": "1.2" + } + }, + { + "type": "Microsoft.ServiceBus/namespaces/topics", + "apiVersion": "2023-01-01-preview", + "name": "[format('{0}/{1}', parameters('namespaceName'), 'contoso')]", + "properties": { + "defaultMessageTimeToLive": "P14D", + "requiresDuplicateDetection": true, + "duplicateDetectionHistoryTimeWindow": "PT10M" + }, + "dependsOn": [ + "[resourceId('Microsoft.ServiceBus/namespaces', parameters('namespaceName'))]" + ] + }, + { + "type": "Microsoft.ServiceBus/namespaces/AuthorizationRules", + "apiVersion": "2023-01-01-preview", + "name": "[format('{0}/{1}', parameters('namespaceName'), 'app')]", + "properties": { + "rights": [ + "Listen", + "Send", + "Manage" + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.ServiceBus/namespaces', parameters('namespaceName'))]" + ] + } + ], + "outputs": { + "namespaceName": { + "type": "string", + "value": "[parameters('namespaceName')]" + }, + "id": { + "type": "string", + "value": "[resourceId('Microsoft.ServiceBus/namespaces', parameters('namespaceName'))]" + }, + "topicName": { + "type": "string", + "value": "contoso" + }, + "connectionString": { + "type": "string", + "value": "[listKeys(resourceId('Microsoft.ServiceBus/namespaces/AuthorizationRules', parameters('namespaceName'), 'app'), '2023-01-01-preview').primaryConnectionString]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "redisDeploy", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "cacheName": { + "value": "[format('redis-{0}-{1}', parameters('environmentType'), variables('suffix'))]" + }, + "skuName": { + "value": "[parameters('redisSkuName')]" + }, + "skuFamily": { + "value": "[parameters('redisSkuFamily')]" + }, + "skuCapacity": { + "value": "[parameters('redisSkuCapacity')]" + }, + "tags": { + "value": "[variables('mergedTags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.42.1.51946", + "templateHash": "15208332006810395159" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "cacheName": { + "type": "string" + }, + "skuName": { + "type": "string" + }, + "skuFamily": { + "type": "string" + }, + "skuCapacity": { + "type": "int" + }, + "tags": { + "type": "object", + "defaultValue": {} + } + }, + "resources": [ + { + "type": "Microsoft.Cache/redis", + "apiVersion": "2023-08-01", + "name": "[parameters('cacheName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "enableNonSslPort": false, + "minimumTlsVersion": "1.2", + "publicNetworkAccess": "Enabled", + "sku": { + "name": "[parameters('skuName')]", + "family": "[parameters('skuFamily')]", + "capacity": "[parameters('skuCapacity')]" + } + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Cache/redis', parameters('cacheName'))]" + }, + "name": { + "type": "string", + "value": "[parameters('cacheName')]" + }, + "hostName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Cache/redis', parameters('cacheName')), '2023-08-01').hostName]" + }, + "connectionString": { + "type": "string", + "value": "[format('{0}:6380,password={1},ssl=True,abortConnect=False', reference(resourceId('Microsoft.Cache/redis', parameters('cacheName')), '2023-08-01').hostName, listKeys(resourceId('Microsoft.Cache/redis', parameters('cacheName')), '2023-08-01').primaryKey)]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "sqlDeploy", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "serverName": { + "value": "[format('sql-{0}-{1}', parameters('environmentType'), variables('suffix'))]" + }, + "databaseName": { + "value": "[parameters('sqlDatabaseName')]" + }, + "adminLogin": { + "value": "[parameters('sqlAdminLogin')]" + }, + "adminPassword": { + "value": "[parameters('sqlAdminPassword')]" + }, + "skuName": { + "value": "[parameters('sqlSkuName')]" + }, + "skuTier": { + "value": "[parameters('sqlTier')]" + }, + "minCapacity": { + "value": "[parameters('sqlMinCapacity')]" + }, + "autoPauseDelay": { + "value": "[parameters('sqlAutoPauseDelay')]" + }, + "tags": { + "value": "[variables('mergedTags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.42.1.51946", + "templateHash": "14902580470469121549" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "serverName": { + "type": "string" + }, + "databaseName": { + "type": "string" + }, + "adminLogin": { + "type": "string" + }, + "adminPassword": { + "type": "securestring" + }, + "skuName": { + "type": "string" + }, + "skuTier": { + "type": "string" + }, + "minCapacity": { + "type": "string" + }, + "autoPauseDelay": { + "type": "int" + }, + "tags": { + "type": "object", + "defaultValue": {} + } + }, + "resources": [ + { + "type": "Microsoft.Sql/servers", + "apiVersion": "2023-08-01-preview", + "name": "[parameters('serverName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "administratorLogin": "[parameters('adminLogin')]", + "administratorLoginPassword": "[parameters('adminPassword')]", + "version": "12.0", + "publicNetworkAccess": "Enabled", + "minimalTlsVersion": "1.2" + } + }, + { + "type": "Microsoft.Sql/servers/firewallRules", + "apiVersion": "2023-08-01-preview", + "name": "[format('{0}/{1}', parameters('serverName'), 'AllowAzureServices')]", + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers', parameters('serverName'))]" + ] + }, + { + "type": "Microsoft.Sql/servers/databases", + "apiVersion": "2023-08-01-preview", + "name": "[format('{0}/{1}', parameters('serverName'), parameters('databaseName'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "[parameters('skuName')]", + "tier": "[parameters('skuTier')]" + }, + "properties": { + "collation": "SQL_Latin1_General_CP1_CI_AS", + "minCapacity": "[json(parameters('minCapacity'))]", + "autoPauseDelay": "[parameters('autoPauseDelay')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers', parameters('serverName'))]" + ] + } + ], + "outputs": { + "serverName": { + "type": "string", + "value": "[parameters('serverName')]" + }, + "databaseName": { + "type": "string", + "value": "[parameters('databaseName')]" + }, + "fullyQualifiedDomainName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Sql/servers', parameters('serverName')), '2023-08-01-preview').fullyQualifiedDomainName]" + }, + "connectionString": { + "type": "string", + "value": "[format('Data Source=tcp:{0},1433;Initial Catalog={1};User id={2};Password={3};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;', reference(resourceId('Microsoft.Sql/servers', parameters('serverName')), '2023-08-01-preview').fullyQualifiedDomainName, parameters('databaseName'), parameters('adminLogin'), parameters('adminPassword'))]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "appServicesDeploy", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appServicePlanId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicePlanDeploy'), '2025-04-01').outputs.id.value]" + }, + "environmentType": { + "value": "[parameters('environmentType')]" + }, + "suffix": { + "value": "[variables('suffix')]" + }, + "tags": { + "value": "[variables('mergedTags')]" + }, + "appInsightsConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy'), '2025-04-01').outputs.connectionString.value]" + }, + "sqlConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'sqlDeploy'), '2025-04-01').outputs.connectionString.value]" + }, + "redisConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'redisDeploy'), '2025-04-01').outputs.connectionString.value]" + }, + "serviceBusConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'serviceBusDeploy'), '2025-04-01').outputs.connectionString.value]" + }, + "otlpGrpcEndpoint": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy'), '2025-04-01').outputs.otlpGrpcEndpoint.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.42.1.51946", + "templateHash": "432677751176830928" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appServicePlanId": { + "type": "string" + }, + "environmentType": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "appInsightsConnectionString": { + "type": "string" + }, + "sqlConnectionString": { + "type": "string" + }, + "redisConnectionString": { + "type": "string" + }, + "serviceBusConnectionString": { + "type": "string" + }, + "otlpGrpcEndpoint": { + "type": "string" + } + }, + "variables": { + "sharedAppSettings": [ + { + "name": "ASPNETCORE_ENVIRONMENT", + "value": "Development" + }, + { + "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", + "value": "[parameters('appInsightsConnectionString')]" + }, + { + "name": "Aspire__Microsoft__Data__SqlClient__ConnectionString", + "value": "[parameters('sqlConnectionString')]" + }, + { + "name": "Aspire__StackExchange__Redis__ConnectionString", + "value": "[parameters('redisConnectionString')]" + }, + { + "name": "Aspire__Azure__Messaging__ServiceBus__ConnectionString", + "value": "[parameters('serviceBusConnectionString')]" + }, + { + "name": "OTEL_EXPORTER_OTLP_PROTOCOL", + "value": "grpc" + }, + { + "name": "OTEL_EXPORTER_OTLP_ENDPOINT", + "value": "[parameters('otlpGrpcEndpoint')]" + } + ] + }, + "resources": [ + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-api'))]", + "kind": "app,linux", + "properties": { + "serverFarmId": "[parameters('appServicePlanId')]", + "httpsOnly": true, + "siteConfig": { + "linuxFxVersion": "DOTNET|8.0", + "alwaysOn": true, + "appSettings": "[variables('sharedAppSettings')]" + } + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-api'))]", + "kind": "app,linux", + "properties": { + "serverFarmId": "[parameters('appServicePlanId')]", + "httpsOnly": true, + "siteConfig": { + "linuxFxVersion": "DOTNET|8.0", + "alwaysOn": true, + "appSettings": "[concat(variables('sharedAppSettings'), createArray(createObject('name', 'ProductsApi__BaseAddress', 'value', format('https://{0}', reference(resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01').defaultHostName))))]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" + ] + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-outbox-relay'))]", + "kind": "app,linux", + "properties": { + "serverFarmId": "[parameters('appServicePlanId')]", + "httpsOnly": true, + "siteConfig": { + "linuxFxVersion": "DOTNET|8.0", + "alwaysOn": true, + "appSettings": "[variables('sharedAppSettings')]" + } + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[format('app-shopping-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-outbox-relay'))]", + "kind": "app,linux", + "properties": { + "serverFarmId": "[parameters('appServicePlanId')]", + "httpsOnly": true, + "siteConfig": { + "linuxFxVersion": "DOTNET|8.0", + "alwaysOn": true, + "appSettings": "[variables('sharedAppSettings')]" + } + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[format('app-products-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-subscribe'))]", + "kind": "app,linux", + "properties": { + "serverFarmId": "[parameters('appServicePlanId')]", + "httpsOnly": true, + "siteConfig": { + "linuxFxVersion": "DOTNET|8.0", + "alwaysOn": true, + "appSettings": "[variables('sharedAppSettings')]" + } + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[format('app-shopping-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-subscribe'))]", + "kind": "app,linux", + "properties": { + "serverFarmId": "[parameters('appServicePlanId')]", + "httpsOnly": true, + "siteConfig": { + "linuxFxVersion": "DOTNET|8.0", + "alwaysOn": true, + "appSettings": "[variables('sharedAppSettings')]" + } + } + } + ], + "outputs": { + "productsApiName": { + "type": "string", + "value": "[format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))]" + }, + "shoppingApiName": { + "type": "string", + "value": "[format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))]" + }, + "productsOutboxRelayName": { + "type": "string", + "value": "[format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))]" + }, + "shoppingOutboxRelayName": { + "type": "string", + "value": "[format('app-shopping-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))]" + }, + "productsSubscribeName": { + "type": "string", + "value": "[format('app-products-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))]" + }, + "shoppingSubscribeName": { + "type": "string", + "value": "[format('app-shopping-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy')]", + "[resourceId('Microsoft.Resources/deployments', 'appServicePlanDeploy')]", + "[resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy')]", + "[resourceId('Microsoft.Resources/deployments', 'redisDeploy')]", + "[resourceId('Microsoft.Resources/deployments', 'serviceBusDeploy')]", + "[resourceId('Microsoft.Resources/deployments', 'sqlDeploy')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "aspireDashboardDeploy", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "environmentType": { + "value": "[parameters('environmentType')]" + }, + "suffix": { + "value": "[variables('suffix')]" + }, + "tags": { + "value": "[variables('mergedTags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.42.1.51946", + "templateHash": "1908226983178580210" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "environmentType": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "tags": { + "type": "object", + "defaultValue": {} + } + }, + "variables": { + "dashboardName": "[format('aci-aspire-dashboard-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", + "dnsLabel": "[take(toLower(format('aspire{0}{1}{2}', parameters('environmentType'), parameters('suffix'), uniqueString(resourceGroup().id, deployment().name))), 63)]" + }, + "resources": [ + { + "type": "Microsoft.ContainerInstance/containerGroups", + "apiVersion": "2023-05-01", + "name": "[variables('dashboardName')]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('role', 'aspire-dashboard'))]", + "properties": { + "osType": "Linux", + "restartPolicy": "Always", + "ipAddress": { + "type": "Public", + "dnsNameLabel": "[variables('dnsLabel')]", + "ports": [ + { + "protocol": "TCP", + "port": 18888 + }, + { + "protocol": "TCP", + "port": 18889 + }, + { + "protocol": "TCP", + "port": 18890 + } + ] + }, + "containers": [ + { + "name": "aspire-dashboard", + "properties": { + "image": "mcr.microsoft.com/dotnet/aspire-dashboard:latest", + "environmentVariables": [ + { + "name": "ASPNETCORE_URLS", + "value": "http://0.0.0.0:18888" + }, + { + "name": "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", + "value": "http://0.0.0.0:18889" + }, + { + "name": "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL", + "value": "http://0.0.0.0:18890" + }, + { + "name": "DASHBOARD__UI__DISABLERESOURCEGRAPH", + "value": "true" + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 18888 + }, + { + "protocol": "TCP", + "port": 18889 + }, + { + "protocol": "TCP", + "port": 18890 + } + ], + "resources": { + "requests": { + "cpu": 1, + "memoryInGB": 2 + } + } + } + } + ] + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.ContainerInstance/containerGroups', variables('dashboardName'))]" + }, + "containerGroupName": { + "type": "string", + "value": "[variables('dashboardName')]" + }, + "dashboardUri": { + "type": "string", + "value": "[format('http://{0}:18888', reference(resourceId('Microsoft.ContainerInstance/containerGroups', variables('dashboardName')), '2023-05-01').ipAddress.fqdn)]" + }, + "otlpGrpcEndpoint": { + "type": "string", + "value": "[format('http://{0}:18889', reference(resourceId('Microsoft.ContainerInstance/containerGroups', variables('dashboardName')), '2023-05-01').ipAddress.fqdn)]" + }, + "otlpHttpEndpoint": { + "type": "string", + "value": "[format('http://{0}:18890', reference(resourceId('Microsoft.ContainerInstance/containerGroups', variables('dashboardName')), '2023-05-01').ipAddress.fqdn)]" + } + } + } + } + } + ], + "outputs": { + "appServicePlanName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicePlanDeploy'), '2025-04-01').outputs.name.value]" + }, + "appInsightsConnectionString": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy'), '2025-04-01').outputs.connectionString.value]" + }, + "keyVaultName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'keyVaultDeploy'), '2025-04-01').outputs.name.value]" + }, + "serviceBusNamespaceName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'serviceBusDeploy'), '2025-04-01').outputs.namespaceName.value]" + }, + "redisHostName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'redisDeploy'), '2025-04-01').outputs.hostName.value]" + }, + "sqlServerName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'sqlDeploy'), '2025-04-01').outputs.serverName.value]" + }, + "sqlDatabaseName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'sqlDeploy'), '2025-04-01').outputs.databaseName.value]" + }, + "productsApiAppName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicesDeploy'), '2025-04-01').outputs.productsApiName.value]" + }, + "shoppingApiAppName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicesDeploy'), '2025-04-01').outputs.shoppingApiName.value]" + }, + "productsOutboxRelayAppName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicesDeploy'), '2025-04-01').outputs.productsOutboxRelayName.value]" + }, + "shoppingOutboxRelayAppName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicesDeploy'), '2025-04-01').outputs.shoppingOutboxRelayName.value]" + }, + "productsSubscribeAppName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicesDeploy'), '2025-04-01').outputs.productsSubscribeName.value]" + }, + "shoppingSubscribeAppName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicesDeploy'), '2025-04-01').outputs.shoppingSubscribeName.value]" + }, + "aspireDashboardContainerGroupName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy'), '2025-04-01').outputs.containerGroupName.value]" + }, + "aspireDashboardUri": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy'), '2025-04-01').outputs.dashboardUri.value]" + }, + "aspireDashboardOtlpGrpcEndpoint": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy'), '2025-04-01').outputs.otlpGrpcEndpoint.value]" + } + } +} \ No newline at end of file diff --git a/azure/infra/main.prod.bicepparam b/azure/infra/main.prod.bicepparam new file mode 100644 index 00000000..f9a3d933 --- /dev/null +++ b/azure/infra/main.prod.bicepparam @@ -0,0 +1,34 @@ +using './main.bicep' + +// Placeholder only. Fill concrete production values during hardening. +param environmentType = 'prod' +param location = 'eastus' +param nameSuffix = 'prod01' + +param tags = { + workload: 'coreex' + environment: 'prod' +} + +// TODO: Replace with production-ready sizing. +param appServicePlanSkuName = 'B1' +param appServicePlanTier = 'Basic' +param appServicePlanCapacity = 1 +param appServiceLinuxFxVersion = 'DOTNETCORE|${replace(readEnvironmentVariable('AZD_DOTNET_TARGET_FRAMEWORK', readEnvironmentVariable('DOTNET_TARGET_FRAMEWORK', 'net8.0')), 'net', '')}' + +// TODO: Confirm production messaging tier. +param serviceBusSkuName = 'Standard' + +param sqlAdminLogin = 'coreexadmin' +param sqlAdminPassword = readEnvironmentVariable('AZURE_SQL_ADMIN_PASSWORD') +param sqlDatabaseName = 'coreexprod' + +// TODO: Replace with production-grade SQL settings. +param sqlSkuName = 'GP_S_Gen5_1' +param sqlTier = 'GeneralPurpose' +param sqlMinCapacity = '0.5' +param sqlAutoPauseDelay = 60 + +// TODO: Replace with production-grade cache tier. +param redisSkuName = 'Balanced_B0' +param redisHighAvailability = 'Enabled' diff --git a/azure/infra/main.test.bicepparam b/azure/infra/main.test.bicepparam new file mode 100644 index 00000000..02458129 --- /dev/null +++ b/azure/infra/main.test.bicepparam @@ -0,0 +1,34 @@ +using './main.bicep' + +// Placeholder only. Fill concrete test values when test environment is approved. +param environmentType = 'test' +param location = 'eastus' +param nameSuffix = 'test01' + +param tags = { + workload: 'coreex' + environment: 'test' +} + +// TODO: Confirm test sizing. +param appServicePlanSkuName = 'B1' +param appServicePlanTier = 'Basic' +param appServicePlanCapacity = 1 +param appServiceLinuxFxVersion = 'DOTNETCORE|${replace(readEnvironmentVariable('AZD_DOTNET_TARGET_FRAMEWORK', readEnvironmentVariable('DOTNET_TARGET_FRAMEWORK', 'net8.0')), 'net', '')}' + +// TODO: Confirm messaging tier for test. +param serviceBusSkuName = 'Standard' + +param sqlAdminLogin = 'coreexadmin' +param sqlAdminPassword = readEnvironmentVariable('AZURE_SQL_ADMIN_PASSWORD') +param sqlDatabaseName = 'coreextest' + +// TODO: Confirm SQL model for test. +param sqlSkuName = 'GP_S_Gen5_1' +param sqlTier = 'GeneralPurpose' +param sqlMinCapacity = '0.5' +param sqlAutoPauseDelay = 60 + +// TODO: Confirm cache tier for test. +param redisSkuName = 'Balanced_B0' +param redisHighAvailability = 'Enabled' diff --git a/azure/infra/modules/app-service-plan.bicep b/azure/infra/modules/app-service-plan.bicep new file mode 100644 index 00000000..f7d41799 --- /dev/null +++ b/azure/infra/modules/app-service-plan.bicep @@ -0,0 +1,24 @@ +param location string +param name string +param skuName string +param skuTier string +param capacity int +param tags object = {} + +resource plan 'Microsoft.Web/serverfarms@2023-12-01' = { + name: name + location: location + tags: tags + kind: 'linux' + sku: { + name: skuName + tier: skuTier + capacity: capacity + } + properties: { + reserved: true + } +} + +output id string = plan.id +output name string = plan.name diff --git a/azure/infra/modules/app-services.bicep b/azure/infra/modules/app-services.bicep new file mode 100644 index 00000000..9d3e6642 --- /dev/null +++ b/azure/infra/modules/app-services.bicep @@ -0,0 +1,246 @@ +param location string +param appServicePlanId string +param appServiceLinuxFxVersion string +param environmentType string +param suffix string +param tags object = {} +param appInsightsConnectionString string +param appInsightsResourceId string +param appInsightsInstrumentationKey string +param sqlConnectionString string +param redisConnectionString string +param serviceBusConnectionString string +param otlpHttpEndpoint string + +var sharedAppSettings = [ + { + name: 'ASPNETCORE_ENVIRONMENT' + value: 'Development' + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: appInsightsConnectionString + } + { + name: 'APPINSIGHTS_INSTRUMENTATIONKEY' + value: appInsightsInstrumentationKey + } + { + name: 'ApplicationInsightsAgent_EXTENSION_VERSION' + value: '~3' + } + { + name: 'XDT_MicrosoftApplicationInsights_Mode' + value: 'recommended' + } + { + name: 'XDT_MicrosoftApplicationInsights_PreemptSdk' + value: 'disabled' + } + { + name: 'DiagnosticServices_EXTENSION_VERSION' + value: '~3' + } + { + name: 'APPINSIGHTS_PROFILERFEATURE_VERSION' + value: '1.0.0' + } + { + name: 'APPINSIGHTS_SNAPSHOTFEATURE_VERSION' + value: '1.0.0' + } + { + name: 'Aspire__Microsoft__Data__SqlClient__ConnectionString' + value: sqlConnectionString + } + { + name: 'Aspire__StackExchange__Redis__ConnectionString' + value: redisConnectionString + } + { + name: 'Aspire__Azure__Messaging__ServiceBus__ConnectionString' + value: serviceBusConnectionString + } + { + name: 'OTEL_EXPORTER_OTLP_PROTOCOL' + value: 'http/protobuf' + } + { + name: 'OTEL_EXPORTER_OTLP_ENDPOINT' + value: otlpHttpEndpoint + } +] + +resource productsApi 'Microsoft.Web/sites@2023-12-01' = { + name: 'app-products-api-${environmentType}-${suffix}' + location: location + tags: union(tags, { + 'azd-service-name': 'products-api' + 'hidden-link: /app-insights-resource-id': appInsightsResourceId + }) + kind: 'app,linux' + properties: { + serverFarmId: appServicePlanId + httpsOnly: true + sshEnabled: false + endToEndEncryptionEnabled: true + siteConfig: { + linuxFxVersion: appServiceLinuxFxVersion + minTlsVersion: '1.3' + minTlsCipherSuite: 'TLS_AES_256_GCM_SHA384' + scmMinTlsVersion: '1.3' + netFrameworkVersion: '' + ftpsState: 'Disabled' + http20Enabled: true + alwaysOn: true + appSettings: sharedAppSettings + } + } +} + +resource shoppingApi 'Microsoft.Web/sites@2023-12-01' = { + name: 'app-shopping-api-${environmentType}-${suffix}' + location: location + tags: union(tags, { + 'azd-service-name': 'shopping-api' + 'hidden-link: /app-insights-resource-id': appInsightsResourceId + }) + kind: 'app,linux' + properties: { + serverFarmId: appServicePlanId + httpsOnly: true + sshEnabled: false + endToEndEncryptionEnabled: true + siteConfig: { + linuxFxVersion: appServiceLinuxFxVersion + minTlsVersion: '1.3' + minTlsCipherSuite: 'TLS_AES_256_GCM_SHA384' + scmMinTlsVersion: '1.3' + netFrameworkVersion: '' + ftpsState: 'Disabled' + http20Enabled: true + alwaysOn: true + appSettings: concat(sharedAppSettings, [ + { + name: 'ProductsApi__BaseAddress' + value: 'https://${productsApi.properties.defaultHostName}' + } + ]) + } + } +} + +resource productsOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { + name: 'app-products-outbox-relay-${environmentType}-${suffix}' + location: location + tags: union(tags, { + 'azd-service-name': 'products-outbox-relay' + 'hidden-link: /app-insights-resource-id': appInsightsResourceId + }) + kind: 'app,linux' + properties: { + serverFarmId: appServicePlanId + httpsOnly: true + sshEnabled: false + endToEndEncryptionEnabled: true + siteConfig: { + linuxFxVersion: appServiceLinuxFxVersion + minTlsVersion: '1.3' + minTlsCipherSuite: 'TLS_AES_256_GCM_SHA384' + scmMinTlsVersion: '1.3' + netFrameworkVersion: '' + ftpsState: 'Disabled' + http20Enabled: true + alwaysOn: true + appSettings: sharedAppSettings + } + } +} + +resource shoppingOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { + name: 'app-shopping-outbox-relay-${environmentType}-${suffix}' + location: location + tags: union(tags, { + 'azd-service-name': 'shopping-outbox-relay' + 'hidden-link: /app-insights-resource-id': appInsightsResourceId + }) + kind: 'app,linux' + properties: { + serverFarmId: appServicePlanId + httpsOnly: true + sshEnabled: false + endToEndEncryptionEnabled: true + siteConfig: { + linuxFxVersion: appServiceLinuxFxVersion + minTlsVersion: '1.3' + minTlsCipherSuite: 'TLS_AES_256_GCM_SHA384' + scmMinTlsVersion: '1.3' + netFrameworkVersion: '' + ftpsState: 'Disabled' + http20Enabled: true + alwaysOn: true + appSettings: sharedAppSettings + } + } +} + +resource productsSubscribe 'Microsoft.Web/sites@2023-12-01' = { + name: 'app-products-subscribe-${environmentType}-${suffix}' + location: location + tags: union(tags, { + 'azd-service-name': 'products-subscribe' + 'hidden-link: /app-insights-resource-id': appInsightsResourceId + }) + kind: 'app,linux' + properties: { + serverFarmId: appServicePlanId + httpsOnly: true + sshEnabled: false + endToEndEncryptionEnabled: true + siteConfig: { + linuxFxVersion: appServiceLinuxFxVersion + minTlsVersion: '1.3' + minTlsCipherSuite: 'TLS_AES_256_GCM_SHA384' + scmMinTlsVersion: '1.3' + netFrameworkVersion: '' + ftpsState: 'Disabled' + http20Enabled: true + alwaysOn: true + appSettings: sharedAppSettings + } + } +} + +resource shoppingSubscribe 'Microsoft.Web/sites@2023-12-01' = { + name: 'app-shopping-subscribe-${environmentType}-${suffix}' + location: location + tags: union(tags, { + 'azd-service-name': 'shopping-subscribe' + 'hidden-link: /app-insights-resource-id': appInsightsResourceId + }) + kind: 'app,linux' + properties: { + serverFarmId: appServicePlanId + httpsOnly: true + sshEnabled: false + endToEndEncryptionEnabled: true + siteConfig: { + linuxFxVersion: appServiceLinuxFxVersion + minTlsVersion: '1.3' + minTlsCipherSuite: 'TLS_AES_256_GCM_SHA384' + scmMinTlsVersion: '1.3' + netFrameworkVersion: '' + ftpsState: 'Disabled' + http20Enabled: true + alwaysOn: true + appSettings: sharedAppSettings + } + } +} + +output productsApiName string = productsApi.name +output shoppingApiName string = shoppingApi.name +output productsOutboxRelayName string = productsOutboxRelay.name +output shoppingOutboxRelayName string = shoppingOutboxRelay.name +output productsSubscribeName string = productsSubscribe.name +output shoppingSubscribeName string = shoppingSubscribe.name diff --git a/azure/infra/modules/application-insights.bicep b/azure/infra/modules/application-insights.bicep new file mode 100644 index 00000000..53b4017d --- /dev/null +++ b/azure/infra/modules/application-insights.bicep @@ -0,0 +1,20 @@ +param location string +param name string +param tags object = {} + +resource appInsights 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + Flow_Type: 'Bluefield' + Request_Source: 'rest' + } +} + +output name string = appInsights.name +output connectionString string = appInsights.properties.ConnectionString +output id string = appInsights.id +output instrumentationKey string = appInsights.properties.InstrumentationKey diff --git a/azure/infra/modules/aspire-dashboard.bicep b/azure/infra/modules/aspire-dashboard.bicep new file mode 100644 index 00000000..f4d8c1d7 --- /dev/null +++ b/azure/infra/modules/aspire-dashboard.bicep @@ -0,0 +1,54 @@ +param location string +param appServicePlanId string +param environmentType string +param suffix string +param tags object = {} + +var dashboardName = 'app-aspire-dashboard-${environmentType}-${suffix}' + +resource aspireDashboard 'Microsoft.Web/sites@2023-12-01' = { + name: dashboardName + location: location + tags: union(tags, { + role: 'aspire-dashboard' + }) + kind: 'app,linux,container' + properties: { + serverFarmId: appServicePlanId + httpsOnly: true + siteConfig: { + linuxFxVersion: 'DOCKER|mcr.microsoft.com/dotnet/aspire-dashboard:latest' + ftpsState: 'Disabled' + alwaysOn: true + http20Enabled: true + appSettings: [ + { + name: 'WEBSITES_PORT' + value: '18888' + } + { + name: 'ASPNETCORE_URLS' + value: 'http://0.0.0.0:18888' + } + { + name: 'ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL' + value: 'http://0.0.0.0:18889' + } + { + name: 'ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL' + value: 'http://0.0.0.0:18888' + } + { + name: 'DASHBOARD__UI__DISABLERESOURCEGRAPH' + value: 'true' + } + ] + } + } +} + +output id string = aspireDashboard.id +output appName string = aspireDashboard.name +output dashboardUri string = 'https://${aspireDashboard.properties.defaultHostName}' +output otlpGrpcEndpoint string = 'https://${aspireDashboard.properties.defaultHostName}' +output otlpHttpEndpoint string = 'https://${aspireDashboard.properties.defaultHostName}' diff --git a/azure/infra/modules/database.bicep b/azure/infra/modules/database.bicep new file mode 100644 index 00000000..b6ba8a99 --- /dev/null +++ b/azure/infra/modules/database.bicep @@ -0,0 +1,64 @@ +param location string +param serverName string +param databaseName string +param adminLogin string +@secure() +param adminPassword string +param clientIp string = '' +param skuName string +param skuTier string +param minCapacity string +param autoPauseDelay int +param tags object = {} + +resource server 'Microsoft.Sql/servers@2023-08-01-preview' = { + name: serverName + location: location + tags: tags + properties: { + administratorLogin: adminLogin + administratorLoginPassword: adminPassword + version: '12.0' + publicNetworkAccess: 'Enabled' + minimalTlsVersion: '1.2' + } +} + +resource azureFirewallRule 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = { + parent: server + name: 'AllowAzureServices' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +resource clientFirewallRule 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = if (!empty(clientIp)) { + parent: server + name: 'AllowCurrentRunner-${replace(clientIp, '.', '-')}' + properties: { + startIpAddress: clientIp + endIpAddress: clientIp + } +} + +resource db 'Microsoft.Sql/servers/databases@2023-08-01-preview' = { + parent: server + name: databaseName + location: location + tags: tags + sku: { + name: skuName + tier: skuTier + } + properties: { + collation: 'SQL_Latin1_General_CP1_CI_AS' + minCapacity: json(minCapacity) + autoPauseDelay: autoPauseDelay + } +} + +output serverName string = server.name +output databaseName string = db.name +output fullyQualifiedDomainName string = server.properties.fullyQualifiedDomainName +output connectionString string = 'Data Source=tcp:${server.properties.fullyQualifiedDomainName},1433;Initial Catalog=${databaseName};User id=${adminLogin};Password=${adminPassword};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;' diff --git a/azure/infra/modules/key-vault.bicep b/azure/infra/modules/key-vault.bicep new file mode 100644 index 00000000..07a2407a --- /dev/null +++ b/azure/infra/modules/key-vault.bicep @@ -0,0 +1,25 @@ +param location string +param name string +param tags object = {} + +resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: name + location: location + tags: tags + properties: { + sku: { + family: 'A' + name: 'standard' + } + tenantId: subscription().tenantId + enableRbacAuthorization: true + enabledForDeployment: true + enabledForTemplateDeployment: true + enabledForDiskEncryption: false + publicNetworkAccess: 'Enabled' + } +} + +output id string = vault.id +output name string = vault.name +output vaultUri string = vault.properties.vaultUri diff --git a/azure/infra/modules/redis.bicep b/azure/infra/modules/redis.bicep new file mode 100644 index 00000000..51b9e6b4 --- /dev/null +++ b/azure/infra/modules/redis.bicep @@ -0,0 +1,41 @@ +param location string +param cacheName string +param skuName string +@allowed([ + 'Enabled' + 'Disabled' +]) +param highAvailability string +param tags object = {} + +resource redis 'Microsoft.Cache/redisEnterprise@2025-07-01' = { + name: cacheName + location: location + tags: tags + sku: { + name: skuName + } + properties: { + highAvailability: highAvailability + minimumTlsVersion: '1.2' + publicNetworkAccess: 'Enabled' + encryption: {} + } +} + +resource defaultDatabase 'Microsoft.Cache/redisEnterprise/databases@2025-04-01' = { + parent: redis + name: 'default' + properties: { + clientProtocol: 'Encrypted' + clusteringPolicy: 'OSSCluster' + evictionPolicy: 'VolatileLRU' + modules: [] + port: 10000 + } +} + +output id string = redis.id +output name string = redis.name +output hostName string = redis.properties.hostName +output connectionString string = '${redis.properties.hostName}:10000,password=${listKeys(defaultDatabase.id, defaultDatabase.apiVersion).primaryKey},ssl=True,abortConnect=False' diff --git a/azure/infra/modules/service-bus.bicep b/azure/infra/modules/service-bus.bicep new file mode 100644 index 00000000..45627803 --- /dev/null +++ b/azure/infra/modules/service-bus.bicep @@ -0,0 +1,69 @@ +param location string +param namespaceName string +param skuName string +param tags object = {} + +resource ns 'Microsoft.ServiceBus/namespaces@2023-01-01-preview' = { + name: namespaceName + location: location + tags: tags + sku: { + name: skuName + tier: skuName + } + properties: { + publicNetworkAccess: 'Enabled' + minimumTlsVersion: '1.2' + } +} + +resource topic 'Microsoft.ServiceBus/namespaces/topics@2023-01-01-preview' = { + parent: ns + name: 'contoso' + properties: { + defaultMessageTimeToLive: 'P14D' + requiresDuplicateDetection: true + duplicateDetectionHistoryTimeWindow: 'PT10M' + } +} + +resource productsSubscription 'Microsoft.ServiceBus/namespaces/topics/subscriptions@2023-01-01-preview' = { + parent: topic + name: 'products' + properties: { + requiresSession: true + maxDeliveryCount: 10 + lockDuration: 'PT5M' + deadLetteringOnMessageExpiration: true + } +} + +resource shoppingSubscription 'Microsoft.ServiceBus/namespaces/topics/subscriptions@2023-01-01-preview' = { + parent: topic + name: 'shopping' + properties: { + requiresSession: true + maxDeliveryCount: 10 + lockDuration: 'PT5M' + deadLetteringOnMessageExpiration: true + } +} + +resource authRule 'Microsoft.ServiceBus/namespaces/AuthorizationRules@2023-01-01-preview' = { + parent: ns + name: 'app' + properties: { + rights: [ + 'Listen' + 'Send' + 'Manage' + ] + } +} + +output namespaceName string = ns.name +output id string = ns.id +output topicName string = topic.name +output productsSubscriptionName string = productsSubscription.name +output shoppingSubscriptionName string = shoppingSubscription.name +output connectionString string = listKeys(authRule.id, authRule.apiVersion).primaryConnectionString diff --git a/azure/infra/scripts/store-secrets.ps1 b/azure/infra/scripts/store-secrets.ps1 new file mode 100644 index 00000000..5a2ce805 --- /dev/null +++ b/azure/infra/scripts/store-secrets.ps1 @@ -0,0 +1,60 @@ +# Populates Key Vault with connection secrets needed for E2E testing. +# Runs automatically as a postprovision hook via azure.yaml. +$ErrorActionPreference = 'Stop' + +$rg = $env:AZURE_RESOURCE_GROUP +if ([string]::IsNullOrWhiteSpace($rg)) { + throw 'AZURE_RESOURCE_GROUP is not set.' +} + +$sqlPassword = $env:AZURE_SQL_ADMIN_PASSWORD +if ([string]::IsNullOrWhiteSpace($sqlPassword)) { + throw 'AZURE_SQL_ADMIN_PASSWORD is not set.' +} + +Write-Host "Locating Key Vault in resource group '$rg'..." +$kvName = (az keyvault list --resource-group $rg --query '[0].name' -o tsv) +$kvId = (az keyvault show --name $kvName --resource-group $rg --query id -o tsv) + +# Grant the current signed-in user Key Vault Administrator so secrets can be managed without manual IAM steps. +# Silently skipped when running as a service principal that does not support az ad signed-in-user. +try { + $currentOid = (az ad signed-in-user show --query id -o tsv 2>$null) + if (-not [string]::IsNullOrWhiteSpace($currentOid)) { + Write-Host "Assigning Key Vault Administrator role to current user..." + az role assignment create --role 'Key Vault Administrator' --assignee $currentOid --scope $kvId --only-show-errors 2>$null + # Allow RBAC propagation before writing secrets. + Start-Sleep -Seconds 15 + } +} +catch { + # Non-fatal: user may already have the role assigned. +} + +Write-Host 'Storing sql-admin-password...' +az keyvault secret set --vault-name $kvName --name 'sql-admin-password' --value $sqlPassword --output none + +Write-Host 'Locating SQL Server...' +$sqlServer = (az sql server list --resource-group $rg --query '[0].name' -o tsv) +$sqlLogin = (az sql server show --resource-group $rg --name $sqlServer --query administratorLogin -o tsv) +$sqlDb = (az sql db list --resource-group $rg --server $sqlServer --query "[?name!='master'].name | [0]" -o tsv) +$sqlConn = "Server=tcp:${sqlServer}.database.windows.net,1433;Database=${sqlDb};User Id=${sqlLogin};Password=${sqlPassword};Encrypt=true;TrustServerCertificate=false;" + +Write-Host 'Storing sql-connection-string...' +az keyvault secret set --vault-name $kvName --name 'sql-connection-string' --value $sqlConn --output none + +Write-Host 'Locating Service Bus namespace...' +$sbName = (az servicebus namespace list --resource-group $rg --query '[0].name' -o tsv) +$sbConn = (az servicebus namespace authorization-rule keys list ` + --resource-group $rg ` + --namespace-name $sbName ` + --name 'app' ` + --query primaryConnectionString -o tsv) + +Write-Host 'Storing service-bus-connection-string...' +az keyvault secret set --vault-name $kvName --name 'service-bus-connection-string' --value $sbConn --output none + +Write-Host "Secrets stored successfully in Key Vault '$kvName':" +Write-Host " - sql-admin-password" +Write-Host " - sql-connection-string" +Write-Host " - service-bus-connection-string" diff --git a/azure/infra/scripts/store-secrets.sh b/azure/infra/scripts/store-secrets.sh new file mode 100644 index 00000000..c53be7bc --- /dev/null +++ b/azure/infra/scripts/store-secrets.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Populates Key Vault with connection secrets needed for E2E testing. +# Runs automatically as a postprovision hook via azure.yaml. +set -euo pipefail + +rg="${AZURE_RESOURCE_GROUP:?AZURE_RESOURCE_GROUP is not set}" +sql_password="${AZURE_SQL_ADMIN_PASSWORD:?AZURE_SQL_ADMIN_PASSWORD is not set}" + +echo "Locating Key Vault in resource group '${rg}'..." +kv_name=$(az keyvault list --resource-group "${rg}" --query "[0].name" -o tsv) +kv_id=$(az keyvault show --name "${kv_name}" --resource-group "${rg}" --query id -o tsv) + +# Grant the current signed-in user Key Vault Administrator so secrets can be managed without manual IAM steps. +# Silently skipped when running as a service principal that does not support az ad signed-in-user. +current_oid=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true) +if [[ -n "${current_oid}" ]]; then + echo "Assigning Key Vault Administrator role to current user..." + az role assignment create \ + --role "Key Vault Administrator" \ + --assignee "${current_oid}" \ + --scope "${kv_id}" \ + --only-show-errors 2>/dev/null || true + # Allow RBAC propagation before writing secrets. + sleep 15 +fi + +echo "Storing sql-admin-password..." +az keyvault secret set \ + --vault-name "${kv_name}" \ + --name "sql-admin-password" \ + --value "${sql_password}" \ + --output none + +echo "Locating SQL Server..." +sql_server=$(az sql server list --resource-group "${rg}" --query "[0].name" -o tsv) +sql_login=$(az sql server show --resource-group "${rg}" --name "${sql_server}" --query administratorLogin -o tsv) +sql_db=$(az sql db list --resource-group "${rg}" --server "${sql_server}" --query "[?name!='master'].name | [0]" -o tsv) +sql_conn="Server=tcp:${sql_server}.database.windows.net,1433;Database=${sql_db};User Id=${sql_login};Password=${sql_password};Encrypt=true;TrustServerCertificate=false;" + +echo "Storing sql-connection-string..." +az keyvault secret set \ + --vault-name "${kv_name}" \ + --name "sql-connection-string" \ + --value "${sql_conn}" \ + --output none + +echo "Locating Service Bus namespace..." +sb_name=$(az servicebus namespace list --resource-group "${rg}" --query "[0].name" -o tsv) +sb_conn=$(az servicebus namespace authorization-rule keys list \ + --resource-group "${rg}" \ + --namespace-name "${sb_name}" \ + --name "app" \ + --query primaryConnectionString -o tsv) + +echo "Storing service-bus-connection-string..." +az keyvault secret set \ + --vault-name "${kv_name}" \ + --name "service-bus-connection-string" \ + --value "${sb_conn}" \ + --output none + +echo "Secrets stored successfully in Key Vault '${kv_name}':" +echo " - sql-admin-password" +echo " - sql-connection-string" +echo " - service-bus-connection-string" diff --git a/azure/infra/scripts/use-dev-params.ps1 b/azure/infra/scripts/use-dev-params.ps1 new file mode 100644 index 00000000..6544adf8 --- /dev/null +++ b/azure/infra/scripts/use-dev-params.ps1 @@ -0,0 +1,72 @@ +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$infraDir = Resolve-Path (Join-Path $scriptDir '..') + +if ([string]::IsNullOrWhiteSpace($env:AZURE_SQL_ADMIN_PASSWORD)) { + throw 'AZURE_SQL_ADMIN_PASSWORD is not set. Set it before running azd provision.' +} + +if ([string]::IsNullOrWhiteSpace($env:AZURE_LOCATION)) { + throw "AZURE_LOCATION is not set. Set it via 'azd env set AZURE_LOCATION ' before running azd provision." +} + +$clientIp = '' +foreach ($ipLookup in @('https://api.ipify.org', 'https://ifconfig.me/ip')) { + try { + $clientIp = (Invoke-RestMethod -Uri $ipLookup -TimeoutSec 15).ToString().Trim() + } + catch { + $clientIp = '' + } + + if (-not [string]::IsNullOrWhiteSpace($clientIp)) { + break + } +} + +if ([string]::IsNullOrWhiteSpace($clientIp)) { + throw 'Unable to determine the current public IP address for the Azure SQL firewall rule.' +} + +if ($clientIp -notmatch '^([0-9]{1,3}\.){3}[0-9]{1,3}$') { + throw "Resolved public IP '$clientIp' is not a valid IPv4 address." +} + +$targetFramework = if ($env:AZD_DOTNET_TARGET_FRAMEWORK) { + $env:AZD_DOTNET_TARGET_FRAMEWORK +} +elseif ($env:DOTNET_TARGET_FRAMEWORK) { + $env:DOTNET_TARGET_FRAMEWORK +} +else { + 'net8.0' +} + +$appServiceLinuxFxVersion = switch ($targetFramework) { + 'net8.0' { 'DOTNETCORE|8.0' } + 'net9.0' { 'DOTNETCORE|9.0' } + 'net10.0' { 'DOTNETCORE|10.0' } + default { throw "Unsupported target framework '$targetFramework'. Expected net8.0, net9.0, or net10.0." } +} + +$templatePath = Join-Path $infraDir 'main.dev.parameters.json' +$outputPath = Join-Path $infraDir 'main.parameters.json' + +# Try using ConvertFrom-Json / ConvertTo-Json for safe JSON processing +try { + $json = Get-Content -Raw -Path $templatePath | ConvertFrom-Json + $json.parameters.location.value = $env:AZURE_LOCATION + $json.parameters.sqlAdminPassword.value = $env:AZURE_SQL_ADMIN_PASSWORD + $json.parameters.appServiceLinuxFxVersion.value = $appServiceLinuxFxVersion + $json.parameters.sqlFirewallClientIp.value = $clientIp + $json | ConvertTo-Json -Depth 100 | Set-Content -Path $outputPath -NoNewline +} catch { + # Fallback: direct string replacement + $content = Get-Content -Raw -Path $templatePath + $content = $content.Replace('__AZURE_LOCATION__', $env:AZURE_LOCATION) + $content = $content.Replace('__AZURE_SQL_ADMIN_PASSWORD__', $env:AZURE_SQL_ADMIN_PASSWORD) + $content = $content.Replace('__APP_SERVICE_LINUX_FX_VERSION__', $appServiceLinuxFxVersion) + $content = $content.Replace('__AZURE_CLIENT_IP__', $clientIp) + Set-Content -Path $outputPath -Value $content -NoNewline +} diff --git a/azure/infra/scripts/use-dev-params.sh b/azure/infra/scripts/use-dev-params.sh new file mode 100644 index 00000000..764d434d --- /dev/null +++ b/azure/infra/scripts/use-dev-params.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +infra_dir="$(cd "${script_dir}/.." && pwd)" + +if [[ -z "${AZURE_SQL_ADMIN_PASSWORD:-}" ]]; then + echo "AZURE_SQL_ADMIN_PASSWORD is not set. Export it before running azd provision." >&2 + exit 1 +fi + +if [[ -z "${AZURE_LOCATION:-}" ]]; then + echo "AZURE_LOCATION is not set. Set it via 'azd env set AZURE_LOCATION ' before running azd provision." >&2 + exit 1 +fi + +client_ip="$(curl -fsS https://api.ipify.org 2>/dev/null || true)" +if [[ -z "${client_ip}" ]]; then + client_ip="$(curl -fsS https://ifconfig.me/ip 2>/dev/null || true)" +fi + +if [[ -z "${client_ip}" ]]; then + echo "Unable to determine the current public IP address for the Azure SQL firewall rule." >&2 + exit 1 +fi + +if [[ ! "${client_ip}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + echo "Resolved public IP '${client_ip}' is not a valid IPv4 address." >&2 + exit 1 +fi + +target_framework="${AZD_DOTNET_TARGET_FRAMEWORK:-${DOTNET_TARGET_FRAMEWORK:-net8.0}}" +case "${target_framework}" in + net8.0) + app_service_linux_fx_version='DOTNETCORE|8.0' + ;; + net9.0) + app_service_linux_fx_version='DOTNETCORE|9.0' + ;; + net10.0) + app_service_linux_fx_version='DOTNETCORE|10.0' + ;; + *) + echo "Unsupported target framework '${target_framework}'. Expected net8.0, net9.0, or net10.0." >&2 + exit 1 + ;; +esac + +# Use jq if available for safe JSON processing, otherwise fallback to sed +if command -v jq &> /dev/null; then + jq \ + --arg pwd "${AZURE_SQL_ADMIN_PASSWORD}" \ + --arg loc "${AZURE_LOCATION}" \ + --arg fx "${app_service_linux_fx_version}" \ + --arg ip "${client_ip}" \ + '.parameters.location.value = $loc | .parameters.sqlAdminPassword.value = $pwd | .parameters.appServiceLinuxFxVersion.value = $fx | .parameters.sqlFirewallClientIp.value = $ip' \ + "${infra_dir}/main.dev.parameters.json" > "${infra_dir}/main.parameters.json" +else + # Fallback: use printf to safely escape and substitute + escaped_location=$(printf '%s\n' "${AZURE_LOCATION}" | sed -e 's/[&/\\]/\\&/g; s/$/\\/') + escaped_location=${escaped_location%\\} + escaped_password=$(printf '%s\n' "${AZURE_SQL_ADMIN_PASSWORD}" | sed -e 's/[&/\\]/\\&/g; s/$/\\/') + escaped_password=${escaped_password%\\} + escaped_fx=$(printf '%s\n' "${app_service_linux_fx_version}" | sed -e 's/[&/\\]/\\&/g; s/$/\\/') + escaped_fx=${escaped_fx%\\} + escaped_ip=$(printf '%s\n' "${client_ip}" | sed -e 's/[&/\\]/\\&/g; s/$/\\/') + escaped_ip=${escaped_ip%\\} + sed \ + -e "s/__AZURE_LOCATION__/${escaped_location}/g" \ + -e "s/__AZURE_SQL_ADMIN_PASSWORD__/${escaped_password}/g" \ + -e "s/__APP_SERVICE_LINUX_FX_VERSION__/${escaped_fx}/g" \ + -e "s/__AZURE_CLIENT_IP__/${escaped_ip}/g" \ + "${infra_dir}/main.dev.parameters.json" > "${infra_dir}/main.parameters.json" +fi diff --git a/azure/infra/test.bicep b/azure/infra/test.bicep new file mode 100644 index 00000000..7746ce7f --- /dev/null +++ b/azure/infra/test.bicep @@ -0,0 +1,20 @@ +targetScope = 'resourceGroup' + +param location string = 'eastus' +param nameSuffix string = 'test01' + +resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = { + name: 'asp-dev-${nameSuffix}' + location: location + kind: 'linux' + sku: { + name: 'B1' + tier: 'Basic' + capacity: 1 + } + properties: { + reserved: true + } +} + +output appServicePlanId string = appServicePlan.id diff --git a/azure/scripts/ensure-sql-firewall-rule.ps1 b/azure/scripts/ensure-sql-firewall-rule.ps1 new file mode 100644 index 00000000..d6a4ae7f --- /dev/null +++ b/azure/scripts/ensure-sql-firewall-rule.ps1 @@ -0,0 +1,101 @@ +$ErrorActionPreference = 'Stop' + +# Ensures the current runner public IP has an Azure SQL firewall rule. + +if (-not (Get-Command azd -ErrorAction SilentlyContinue)) { + throw "The 'azd' command is required to resolve environment values." +} + +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw "The 'az' command is required to manage Azure SQL firewall rules." +} + +function Invoke-WithRetry { + param( + [Parameter(Mandatory = $true)] + [int] $Attempts, + [Parameter(Mandatory = $true)] + [int] $DelaySeconds, + [Parameter(Mandatory = $true)] + [scriptblock] $ScriptBlock, + [Parameter(Mandatory = $true)] + [string] $Description + ) + + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + try { + & $ScriptBlock + return + } + catch { + if ($attempt -ge $Attempts) { + throw + } + + Write-Host "Waiting on $Description ($attempt/$Attempts); retrying in $DelaySeconds seconds." + Start-Sleep -Seconds $DelaySeconds + } + } +} + +$sqlServer = (azd env get-value sqlServerName).Trim() +$azureResourceGroup = (azd env get-value AZURE_RESOURCE_GROUP).Trim() +$azureSubscriptionId = (azd env get-value AZURE_SUBSCRIPTION_ID).Trim() +$azureEnvName = (azd env get-value AZURE_ENV_NAME).Trim() + +if ([string]::IsNullOrWhiteSpace($sqlServer) -or [string]::IsNullOrWhiteSpace($azureResourceGroup)) { + throw 'Unable to resolve sqlServerName/AZURE_RESOURCE_GROUP from the active azd environment.' +} + +$clientIp = '' +foreach ($ipLookup in @('https://api.ipify.org', 'https://ifconfig.me/ip')) { + try { + $clientIp = (Invoke-RestMethod -Uri $ipLookup -TimeoutSec 15).ToString().Trim() + } + catch { + $clientIp = '' + } + + if (-not [string]::IsNullOrWhiteSpace($clientIp)) { + break + } +} + +if ([string]::IsNullOrWhiteSpace($clientIp)) { + throw 'Unable to determine the public IP address for the current runner.' +} + +if ($clientIp -notmatch '^([0-9]{1,3}\.){3}[0-9]{1,3}$') { + throw "Resolved public IP '$clientIp' is not a valid IPv4 address." +} + +$effectiveEnvName = if ([string]::IsNullOrWhiteSpace($azureEnvName)) { 'env' } else { $azureEnvName } +$firewallRuleName = "azd-$effectiveEnvName-$($clientIp -replace '\.', '-')" +$azServerArgs = @('--resource-group', $azureResourceGroup, '--name', $sqlServer) +$azFirewallArgs = @('--resource-group', $azureResourceGroup, '--server', $sqlServer) +if (-not [string]::IsNullOrWhiteSpace($azureSubscriptionId)) { + $azServerArgs += @('--subscription', $azureSubscriptionId) + $azFirewallArgs += @('--subscription', $azureSubscriptionId) +} + +if ($env:AZD_SQL_FIREWALL_WAIT_FOR_SERVER -eq '1') { + Write-Host "Waiting for Azure SQL server '$sqlServer' to become available." + Invoke-WithRetry -Attempts 12 -DelaySeconds 10 -Description 'Azure SQL server readiness' -ScriptBlock { + az sql server show @azServerArgs | Out-Null + } +} +else { + az sql server show @azServerArgs | Out-Null +} + +az sql server firewall-rule show @azFirewallArgs --name $firewallRuleName *> $null +if ($LASTEXITCODE -eq 0) { + Write-Host "Updating Azure SQL firewall rule '$firewallRuleName' for $clientIp." + az sql server firewall-rule update @azFirewallArgs --name $firewallRuleName --start-ip-address $clientIp --end-ip-address $clientIp | Out-Null +} +else { + Write-Host "Creating Azure SQL firewall rule '$firewallRuleName' for $clientIp." + az sql server firewall-rule create @azFirewallArgs --name $firewallRuleName --start-ip-address $clientIp --end-ip-address $clientIp | Out-Null +} + +Write-Host "Azure SQL firewall rule '$firewallRuleName' is ready." \ No newline at end of file diff --git a/azure/scripts/ensure-sql-firewall-rule.sh b/azure/scripts/ensure-sql-firewall-rule.sh new file mode 100644 index 00000000..c0845b27 --- /dev/null +++ b/azure/scripts/ensure-sql-firewall-rule.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ensures the current runner public IP has an Azure SQL firewall rule. + +retry_command() { + local attempts="${1:?attempt count is required}" + local delay_seconds="${2:?delay is required}" + local description="${3:?description is required}" + shift 3 + + local attempt=1 + while true; do + if "$@"; then + return 0 + fi + + if (( attempt >= attempts )); then + return 1 + fi + + echo "Waiting on ${description} (${attempt}/${attempts}); retrying in ${delay_seconds}s." >&2 + attempt=$((attempt + 1)) + sleep "${delay_seconds}" + done +} + +if ! command -v azd >/dev/null 2>&1; then + echo "The 'azd' command is required to resolve environment values." >&2 + exit 1 +fi + +if ! command -v az >/dev/null 2>&1; then + echo "The 'az' command is required to manage Azure SQL firewall rules." >&2 + exit 1 +fi + +sql_server="$(azd env get-value sqlServerName | tr -d '\r')" +azure_resource_group="$(azd env get-value AZURE_RESOURCE_GROUP | tr -d '\r')" +azure_subscription_id="$(azd env get-value AZURE_SUBSCRIPTION_ID | tr -d '\r')" +azure_env_name="$(azd env get-value AZURE_ENV_NAME | tr -d '\r')" + +if [[ -z "${sql_server}" || -z "${azure_resource_group}" ]]; then + echo "Unable to resolve sqlServerName/AZURE_RESOURCE_GROUP from the active azd environment." >&2 + exit 1 +fi + +client_ip="$(curl -fsS https://api.ipify.org 2>/dev/null || true)" +if [[ -z "${client_ip}" ]]; then + client_ip="$(curl -fsS https://ifconfig.me/ip 2>/dev/null || true)" +fi + +if [[ -z "${client_ip}" ]]; then + echo "Unable to determine the public IP address for the current runner." >&2 + exit 1 +fi + +if [[ ! "${client_ip}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + echo "Resolved public IP '${client_ip}' is not a valid IPv4 address." >&2 + exit 1 +fi + +firewall_rule_name="azd-${azure_env_name:-env}-$(echo "${client_ip}" | tr '.' '-')" +az_server_args=(--resource-group "${azure_resource_group}" --name "${sql_server}") +az_firewall_args=(--resource-group "${azure_resource_group}" --server "${sql_server}") +if [[ -n "${azure_subscription_id}" ]]; then + az_server_args+=(--subscription "${azure_subscription_id}") + az_firewall_args+=(--subscription "${azure_subscription_id}") +fi + +if [[ "${AZD_SQL_FIREWALL_WAIT_FOR_SERVER:-0}" == "1" ]]; then + echo "Waiting for Azure SQL server '${sql_server}' to become available." + retry_command 12 10 "Azure SQL server readiness" az sql server show "${az_server_args[@]}" >/dev/null +else + az sql server show "${az_server_args[@]}" >/dev/null +fi + +if az sql server firewall-rule show "${az_firewall_args[@]}" --name "${firewall_rule_name}" >/dev/null 2>&1; then + echo "Updating Azure SQL firewall rule '${firewall_rule_name}' for ${client_ip}." + az sql server firewall-rule update "${az_firewall_args[@]}" --name "${firewall_rule_name}" --start-ip-address "${client_ip}" --end-ip-address "${client_ip}" >/dev/null +else + echo "Creating Azure SQL firewall rule '${firewall_rule_name}' for ${client_ip}." + az sql server firewall-rule create "${az_firewall_args[@]}" --name "${firewall_rule_name}" --start-ip-address "${client_ip}" --end-ip-address "${client_ip}" >/dev/null +fi + +echo "Azure SQL firewall rule '${firewall_rule_name}' is ready." \ No newline at end of file diff --git a/azure/scripts/package-dotnet-service.sh b/azure/scripts/package-dotnet-service.sh new file mode 100644 index 00000000..28022fa3 --- /dev/null +++ b/azure/scripts/package-dotnet-service.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +service_name="${1:?service name is required}" +project_file="${2:?project file is required}" +target_framework="${3:-${AZD_DOTNET_TARGET_FRAMEWORK:-${DOTNET_TARGET_FRAMEWORK:-}}}" + +if [[ -z "${target_framework}" ]]; then + echo "Target framework is required. Set AZD_DOTNET_TARGET_FRAMEWORK (or DOTNET_TARGET_FRAMEWORK), or pass a third argument." >&2 + exit 1 +fi + +azure_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +output_dir="${azure_dir}/.azd/packages/${service_name}" + +rm -rf "${output_dir}" +mkdir -p "${output_dir}" + +dotnet publish "${project_file}" -c Release -f "${target_framework}" -o "${output_dir}" diff --git a/azure/scripts/run-products-db-migrations.ps1 b/azure/scripts/run-products-db-migrations.ps1 new file mode 100644 index 00000000..f014af46 --- /dev/null +++ b/azure/scripts/run-products-db-migrations.ps1 @@ -0,0 +1,93 @@ +$ErrorActionPreference = 'Stop' + +# Runs all Contoso *.Database DbEx migrations against the provisioned Azure SQL database. + +$scriptDir = Split-Path -Parent $PSCommandPath +$azureDir = Resolve-Path (Join-Path $scriptDir '..') +$repoRoot = Resolve-Path (Join-Path $azureDir '..') + +if (-not (Get-Command azd -ErrorAction SilentlyContinue)) { + throw "The 'azd' command is required to resolve environment values." +} + +$targetFramework = if ($env:AZD_DOTNET_TARGET_FRAMEWORK) { + $env:AZD_DOTNET_TARGET_FRAMEWORK +} +elseif ($env:DOTNET_TARGET_FRAMEWORK) { + $env:DOTNET_TARGET_FRAMEWORK +} +elseif ((dotnet --list-runtimes) -match 'Microsoft\.NETCore\.App 10\.') { + 'net10.0' +} +elseif ((dotnet --list-runtimes) -match 'Microsoft\.NETCore\.App 9\.') { + 'net9.0' +} +else { + 'net8.0' +} +$sqlServer = (azd env get-value sqlServerName).Trim() +$sqlDatabase = (azd env get-value sqlDatabaseName).Trim() +$sqlAdminLogin = if ($env:AZURE_SQL_ADMIN_LOGIN) { $env:AZURE_SQL_ADMIN_LOGIN } else { 'coreexadmin' } +$sqlPassword = $env:AZURE_SQL_ADMIN_PASSWORD + +if ([string]::IsNullOrWhiteSpace($sqlServer) -or [string]::IsNullOrWhiteSpace($sqlDatabase)) { + throw 'Unable to resolve sqlServerName/sqlDatabaseName from the active azd environment.' +} + +if ([string]::IsNullOrWhiteSpace($sqlPassword)) { + $sqlPassword = (azd env get-value AZURE_SQL_ADMIN_PASSWORD).Trim() +} + +if ([string]::IsNullOrWhiteSpace($sqlPassword)) { + throw 'AZURE_SQL_ADMIN_PASSWORD is required to run DbEx migrations.' +} + +& (Join-Path $scriptDir 'ensure-sql-firewall-rule.ps1') + +$connectionString = "Server=tcp:$sqlServer.database.windows.net,1433;Initial Catalog=$sqlDatabase;User ID=$sqlAdminLogin;Password=$sqlPassword;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" +$projects = Get-ChildItem -LiteralPath (Join-Path $repoRoot 'samples/src') -Recurse -File -Filter 'Contoso.*.Database.csproj' | + Sort-Object FullName + +if ($projects.Count -eq 0) { + throw 'No Contoso database projects were found under samples/src.' +} + +Write-Host "Running DbEx migrations for $($projects.Count) database project(s) using framework '$targetFramework' against database '$sqlDatabase'." +foreach ($project in $projects) { + $projectDir = $project.Directory.FullName + $projectName = [System.IO.Path]::GetFileNameWithoutExtension($project.Name) + $domainName = $projectName -replace '\\.Database$', '' + $testCommonProject = Join-Path $repoRoot "samples/tests/$domainName.Test.Common/$domainName.Test.Common.csproj" + $migrationCommand = 'Migrate' + $extraArgs = @() + $promptArgs = @() + + # Remove Windows zone marker sidecar files to avoid embedding/executing them as migration resources on Linux. + $zoneFiles = Get-ChildItem -LiteralPath $projectDir -Recurse -File | Where-Object { $_.Name -like '*:Zone.Identifier' } + if ($zoneFiles.Count -gt 0) { + Write-Host "Removing $($zoneFiles.Count) Zone.Identifier sidecar file(s) from $projectName." + $zoneFiles | Remove-Item -Force + } + + if (Test-Path -LiteralPath $testCommonProject) { + $testCommonDir = Split-Path -Parent $testCommonProject + $testCommonName = [System.IO.Path]::GetFileNameWithoutExtension($testCommonProject) + + $zoneFiles = Get-ChildItem -LiteralPath $testCommonDir -Recurse -File | Where-Object { $_.Name -like '*:Zone.Identifier' } + if ($zoneFiles.Count -gt 0) { + Write-Host "Removing $($zoneFiles.Count) Zone.Identifier sidecar file(s) from $testCommonName." + $zoneFiles | Remove-Item -Force + } + + dotnet build $testCommonProject -c Release -f $targetFramework | Out-Null + $testCommonAssembly = Join-Path $testCommonDir "bin/Release/$targetFramework/$testCommonName.dll" + if (Test-Path -LiteralPath $testCommonAssembly) { + $migrationCommand = 'ResetAndAll' + $extraArgs = @('--assembly', $testCommonAssembly) + $promptArgs = @('--accept-prompts') + } + } + + Write-Host "Running $projectName migrations ($migrationCommand)." + dotnet run --project $project.FullName -c Release -f $targetFramework -- --connection-string $connectionString @promptArgs @extraArgs $migrationCommand +} diff --git a/azure/scripts/run-products-db-migrations.sh b/azure/scripts/run-products-db-migrations.sh new file mode 100644 index 00000000..0bfe7941 --- /dev/null +++ b/azure/scripts/run-products-db-migrations.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs all Contoso *.Database DbEx migrations against the provisioned Azure SQL database. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +azure_dir="$(cd "${script_dir}/.." && pwd)" +repo_root="$(cd "${azure_dir}/.." && pwd)" + +if ! command -v azd >/dev/null 2>&1; then + echo "The 'azd' command is required to resolve environment values." >&2 + exit 1 +fi + +target_framework="${AZD_DOTNET_TARGET_FRAMEWORK:-${DOTNET_TARGET_FRAMEWORK:-}}" +if [[ -z "${target_framework}" ]]; then + if dotnet --list-runtimes | grep -q "Microsoft.NETCore.App 10\."; then + target_framework="net10.0" + elif dotnet --list-runtimes | grep -q "Microsoft.NETCore.App 9\."; then + target_framework="net9.0" + else + target_framework="net8.0" + fi +fi +sql_server="$(azd env get-value sqlServerName | tr -d '\r')" +sql_database="$(azd env get-value sqlDatabaseName | tr -d '\r')" +sql_admin_login="${AZURE_SQL_ADMIN_LOGIN:-coreexadmin}" +sql_password="${AZURE_SQL_ADMIN_PASSWORD:-}" + +if [[ -z "${sql_server}" || -z "${sql_database}" ]]; then + echo "Unable to resolve sqlServerName/sqlDatabaseName from the active azd environment." >&2 + exit 1 +fi + +if [[ -z "${sql_password}" ]]; then + sql_password="$(azd env get-value AZURE_SQL_ADMIN_PASSWORD | tr -d '\r')" +fi + +if [[ -z "${sql_password}" ]]; then + echo "AZURE_SQL_ADMIN_PASSWORD is required to run DbEx migrations." >&2 + exit 1 +fi + +bash "${script_dir}/ensure-sql-firewall-rule.sh" + +connection_string="Server=tcp:${sql_server}.database.windows.net,1433;Initial Catalog=${sql_database};User ID=${sql_admin_login};Password=${sql_password};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" + +readarray -t projects < <(find "${repo_root}/samples/src" -maxdepth 2 -type f -name 'Contoso.*.Database.csproj' | sort) +if [[ ${#projects[@]} -eq 0 ]]; then + echo "No Contoso database projects were found under samples/src." >&2 + exit 1 +fi + +echo "Running DbEx migrations for ${#projects[@]} database project(s) using framework '${target_framework}' against database '${sql_database}'." +for project in "${projects[@]}"; do + project_dir="$(dirname "${project}")" + project_file_name="$(basename "${project}")" + project_name="${project_file_name%.csproj}" + domain_name="${project_name%.Database}" + test_common_project="${repo_root}/samples/tests/${domain_name}.Test.Common/${domain_name}.Test.Common.csproj" + migration_command="Migrate" + extra_args=() + prompt_args=() + + # Remove Windows zone marker sidecar files to avoid embedding/executing them as migration resources on Linux. + zone_file_count="$(find "${project_dir}" -type f -name '*:Zone.Identifier' | wc -l | tr -d ' ')" + if [[ "${zone_file_count}" != "0" ]]; then + echo "Removing ${zone_file_count} Zone.Identifier sidecar file(s) from ${project_name}." + find "${project_dir}" -type f -name '*:Zone.Identifier' -delete + fi + + if [[ -f "${test_common_project}" ]]; then + test_common_dir="$(dirname "${test_common_project}")" + test_common_name="$(basename "${test_common_project}" .csproj)" + + zone_file_count="$(find "${test_common_dir}" -type f -name '*:Zone.Identifier' | wc -l | tr -d ' ')" + if [[ "${zone_file_count}" != "0" ]]; then + echo "Removing ${zone_file_count} Zone.Identifier sidecar file(s) from ${test_common_name}." + find "${test_common_dir}" -type f -name '*:Zone.Identifier' -delete + fi + + dotnet build "${test_common_project}" -c Release -f "${target_framework}" >/dev/null + test_common_assembly="${test_common_dir}/bin/Release/${target_framework}/${test_common_name}.dll" + if [[ -f "${test_common_assembly}" ]]; then + migration_command="ResetAndAll" + extra_args=(--assembly "${test_common_assembly}") + prompt_args=(--accept-prompts) + fi + fi + + echo "Running ${project_name} migrations (${migration_command})." + dotnet run --project "${project}" -c Release -f "${target_framework}" -- --connection-string "${connection_string}" "${prompt_args[@]}" "${extra_args[@]}" "${migration_command}" +done diff --git a/azure/terraform/README.md b/azure/terraform/README.md new file mode 100644 index 00000000..44c6998e --- /dev/null +++ b/azure/terraform/README.md @@ -0,0 +1,212 @@ +# Terraform Equivalent of Azure Bicep Deployment + +This folder provides a Terraform implementation that mirrors the current resources provisioned by `azure/infra` Bicep templates. + +> [!NOTE] +> This does NOT deploy any application code or run DB migrations. It only deploys the base infrastructure. +> The AZD command in azure/infra deploys both the infrastructure and the code. + +## Prerequisites + +Before running `terraform plan` (or `./apply.sh plan`), ensure all of the following are complete. + +### Tooling + +- Terraform CLI installed (compatible with [versions.tf](versions.tf)). +- Azure CLI (`az`) installed. +- `curl` available (required by `apply.sh` to resolve public IP). +- Azure Developer CLI (`azd`) installed (required by `apply.sh` to load environment values). + +### Azure Authentication and Subscription + +- Logged in to Azure CLI: + +```bash +az login +``` + +- Correct subscription selected: + +```bash +az account set --subscription +``` + +### Required Identity Permissions + +The deploying identity must be able to create/update all resources in scope and perform RBAC/secret operations, including: + +- Create/update role assignments (for Key Vault Administrator assignment on the deployed vault). +- Set Key Vault secrets. +- Create/update App Service, SQL, Service Bus, Redis, Application Insights, and Key Vault resources. + +### Required Input Values + +- `AZURE_SQL_ADMIN_PASSWORD` must be set before running `./apply.sh`. +- Environment tfvars file must exist for your target environment (`dev.tfvars`, `test.tfvars`, or `prod.tfvars`). + +Example: + +```bash +export AZURE_SQL_ADMIN_PASSWORD='' +``` + +### Framework Selection (for `apply.sh`) + +`apply.sh` derives `app_service_linux_fx_version` from one of: + +- `AZD_DOTNET_TARGET_FRAMEWORK` (preferred), or +- `DOTNET_TARGET_FRAMEWORK`. + +Supported values: `net8.0`, `net9.0`, `net10.0`. + +Example: + +```bash +export AZD_DOTNET_TARGET_FRAMEWORK='net10.0' +``` + +### Terraform Initialization + +Initialize providers in the Terraform folder before planning: + +```bash +cd azure/terraform +terraform init +``` + +Optional validation: + +```bash +terraform fmt -recursive +terraform validate +``` + +## What It Deploys + +- Linux App Service Plan. +- 6 Linux Web Apps: + - `products-api` + - `shopping-api` + - `products-outbox-relay` + - `shopping-outbox-relay` + - `products-subscribe` + - `shopping-subscribe` +- Aspire Dashboard Linux container Web App. +- Application Insights. +- Key Vault. +- Service Bus namespace + topic + subscriptions. +- Azure SQL server + database + firewall rules. +- Azure Managed Redis (redisEnterprise) + default database. + +## Environment Files + +- `dev.tfvars` matches `azure/infra/main.dev.bicepparam` values. +- `test.tfvars` matches `azure/infra/main.test.bicepparam` values. +- `prod.tfvars` matches `azure/infra/main.prod.bicepparam` values. + +## Usage + +```bash +cd azure/terraform +./apply.sh dev plan +./apply.sh dev apply +``` + +Or run Terraform manually with one of the environment files: + +```bash +cd azure/terraform +export TF_VAR_sql_admin_password="$AZURE_SQL_ADMIN_PASSWORD" +terraform init +terraform plan -var-file=dev.tfvars +terraform apply -var-file=dev.tfvars +``` + +## Notes + +- This deployment creates/manages the resource group named by `resource_group_name` if it does not already exist. +- The `apply.sh` script loads `azd env` values (if available), resolves current public runner IP, and maps `AZD_DOTNET_TARGET_FRAMEWORK` to `app_service_linux_fx_version`. +- Sensitive values such as `sql_admin_password` should come from secure sources and are injected via `TF_VAR_sql_admin_password`. +- Naming and wiring are aligned with the Bicep setup in `azure/infra`. + +## Running E2E Tests + +After deploying with `azd up`, you can run the E2E test runner against the deployed services. + +### Get deployed endpoint URLs + +Retrieve the deployed App Service endpoints: + +```bash +az webapp list --resource-group --query "[].hostNames[0]" -o tsv +``` + +This will show URLs like: +- `app-products-api-dev-{suffix}.azurewebsites.net` +- `app-shopping-api-dev-{suffix}.azurewebsites.net` + +Validate the deployed APIs using API/health/swagger paths (not root `/`): + +```bash +# Products API +curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/api/products" + +# Shopping API +curl -i "https://app-shopping-api-dev-{suffix}.azurewebsites.net/api/baskets" + +# Common liveness and docs paths +curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/health/ready/detailed" +curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/swagger" +``` + +### Retrieve connection strings + +After `azd provision` or `azd up`, the postprovision hook automatically stores the following secrets in Key Vault: +- `sql-admin-password` +- `sql-connection-string` +- `service-bus-connection-string` + +Retrieve them: + +```bash +# Get Key Vault name +KV=$(az keyvault list --resource-group --query '[0].name' -o tsv) + +# SQL connection string +az keyvault secret show --vault-name $KV --name sql-connection-string -o tsv --query value + +# Service Bus connection string +az keyvault secret show --vault-name $KV --name service-bus-connection-string -o tsv --query value +``` + +### Update E2E configuration + +Edit [../samples/tests/Contoso.E2E.Runner/appsettings.json](../samples/tests/Contoso.E2E.Runner/appsettings.json) with the deployed endpoints and connection strings: + +```json +{ + "E2E": { + "Products": { + "BaseAddress": "https://app-products-api-dev-{suffix}.azurewebsites.net", + "ConnectionString": "Server=sql-dev-{suffix}.database.windows.net;Database=Contoso;User Id=sqladmin;Password={password};Encrypt=true;TrustServerCertificate=false;", + "ServiceBus": "Endpoint=sb://sb-dev-{suffix}.servicebus.windows.net/;SharedAccessKeyName=rootManageSharedAccessKey;SharedAccessKey={key};" + }, + "Shopping": { + "BaseAddress": "https://app-shopping-api-dev-{suffix}.azurewebsites.net", + "ConnectionString": "Server=sql-dev-{suffix}.database.windows.net;Database=Contoso;User Id=sqladmin;Password={password};Encrypt=true;TrustServerCertificate=false;", + "ServiceBus": "Endpoint=sb://sb-dev-{suffix}.servicebus.windows.net/;SharedAccessKeyName=rootManageSharedAccessKey;SharedAccessKey={key};" + } + } +} +``` + +### Run E2E scenarios + +From the repository root: + +```bash +cd samples/tests/Contoso.E2E.Runner +dotnet run +``` + +This launches an interactive CLI menu to select and execute test scenarios or run load simulations against the deployed APIs. diff --git a/azure/terraform/apply.sh b/azure/terraform/apply.sh new file mode 100644 index 00000000..f70c9062 --- /dev/null +++ b/azure/terraform/apply.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${script_dir}" + +usage() { + cat < [plan|apply|destroy] + +Examples: + ./apply.sh dev plan + ./apply.sh dev apply + ./apply.sh test apply + ./apply.sh prod destroy +EOF +} + +if [[ $# -lt 1 || $# -gt 2 ]]; then + usage + exit 1 +fi + +env_name="$1" +action="${2:-apply}" + +case "${env_name}" in + dev|test|prod) ;; + *) + echo "Invalid environment '${env_name}'. Use dev, test, or prod." >&2 + exit 1 + ;; +esac + +case "${action}" in + plan|apply|destroy) ;; + *) + echo "Invalid action '${action}'. Use plan, apply, or destroy." >&2 + exit 1 + ;; +esac + +# Load azd environment values when available. +if command -v azd >/dev/null 2>&1; then + if azd env get-values >/dev/null 2>&1; then + set -a + eval "$(azd env get-values)" + set +a + fi +fi + +if [[ -z "${AZURE_SQL_ADMIN_PASSWORD:-}" ]]; then + echo "AZURE_SQL_ADMIN_PASSWORD is required." >&2 + exit 1 +fi + +target_framework="${AZD_DOTNET_TARGET_FRAMEWORK:-${DOTNET_TARGET_FRAMEWORK:-net10.0}}" +case "${target_framework}" in + net8.0) + app_service_linux_fx_version='DOTNETCORE|8.0' + ;; + net9.0) + app_service_linux_fx_version='DOTNETCORE|9.0' + ;; + net10.0) + app_service_linux_fx_version='DOTNETCORE|10.0' + ;; + *) + echo "Unsupported target framework '${target_framework}'." >&2 + exit 1 + ;; +esac + +client_ip="$(curl -fsS https://api.ipify.org 2>/dev/null || true)" +if [[ -z "${client_ip}" ]]; then + client_ip="$(curl -fsS https://ifconfig.me/ip 2>/dev/null || true)" +fi + +if [[ -z "${client_ip}" ]]; then + echo "Unable to determine public IPv4 address." >&2 + exit 1 +fi + +if [[ ! "${client_ip}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + echo "Resolved public IP '${client_ip}' is not a valid IPv4 address." >&2 + exit 1 +fi + +tfvars_file="${env_name}.tfvars" +if [[ ! -f "${tfvars_file}" ]]; then + echo "Missing tfvars file '${tfvars_file}'." >&2 + exit 1 +fi + +export TF_VAR_sql_admin_password="${AZURE_SQL_ADMIN_PASSWORD}" + +terraform fmt -recursive +terraform init +terraform validate + +case "${action}" in + plan) + terraform plan \ + -var-file="${tfvars_file}" \ + -var "app_service_linux_fx_version=${app_service_linux_fx_version}" \ + -var "sql_firewall_client_ip=${client_ip}" \ + -var "key_vault_firewall_client_ip=${client_ip}" + ;; + apply) + terraform apply -auto-approve \ + -var-file="${tfvars_file}" \ + -var "app_service_linux_fx_version=${app_service_linux_fx_version}" \ + -var "sql_firewall_client_ip=${client_ip}" \ + -var "key_vault_firewall_client_ip=${client_ip}" + ;; + destroy) + terraform destroy -auto-approve \ + -var-file="${tfvars_file}" \ + -var "app_service_linux_fx_version=${app_service_linux_fx_version}" \ + -var "sql_firewall_client_ip=${client_ip}" \ + -var "key_vault_firewall_client_ip=${client_ip}" + ;; +esac diff --git a/azure/terraform/dev.tfvars b/azure/terraform/dev.tfvars new file mode 100644 index 00000000..c04128b5 --- /dev/null +++ b/azure/terraform/dev.tfvars @@ -0,0 +1,32 @@ +resource_group_name = "rg-dev" +environment_type = "dev" +location = "westus3" +name_suffix = "dev01" + +tags = { + workload = "coreex" + environment = "dev" + costProfile = "minimum-practical" +} + +app_service_plan_sku_name = "B2" +app_service_plan_sku_tier = "Basic" +app_service_plan_capacity = 1 + +# Derived from AZD_DOTNET_TARGET_FRAMEWORK by apply script. +app_service_linux_fx_version = "DOTNETCORE|10.0" + +service_bus_sku_name = "Standard" + +sql_admin_login = "coreexadmin" +sql_database_name = "coreexdev" +sql_sku_name = "GP_S_Gen5_1" +sql_sku_tier = "GeneralPurpose" +sql_min_capacity = 0.5 +sql_auto_pause_delay = 60 + +# Derived from current public IP by apply script. +sql_firewall_client_ip = "" + +redis_sku_name = "Balanced_B0" +redis_high_availability = "Disabled" diff --git a/azure/terraform/main.tf b/azure/terraform/main.tf new file mode 100644 index 00000000..77078af0 --- /dev/null +++ b/azure/terraform/main.tf @@ -0,0 +1,676 @@ +resource "azurerm_resource_group" "rg" { + name = var.resource_group_name + location = var.location + tags = merge(var.tags, { + environment = var.environment_type + managedBy = "azd" + "azd-env-name" = var.environment_type + }) +} + +resource "random_id" "kv" { + byte_length = 6 + keepers = { + environment = var.environment_type + suffix = var.name_suffix + } +} + +locals { + suffix = lower(var.name_suffix) + + merged_tags = merge(var.tags, { + environment = var.environment_type + managedBy = "azd" + "azd-env-name" = var.environment_type + }) + + app_service_plan_name = "asp-${var.environment_type}-${local.suffix}" + app_insights_name = "appi-${var.environment_type}-${local.suffix}" + service_bus_name = "sb-${var.environment_type}-${local.suffix}" + redis_name = "redis-${var.environment_type}-${local.suffix}" + sql_server_name = "sql-${var.environment_type}-${local.suffix}" + dashboard_name = "app-aspire-dashboard-${var.environment_type}-${local.suffix}" + + key_vault_name = substr("kv${var.environment_type}${local.suffix}${random_id.kv.hex}", 0, 24) +} + +resource "azapi_resource" "app_insights" { + type = "Microsoft.Insights/components@2020-02-02" + parent_id = azurerm_resource_group.rg.id + name = local.app_insights_name + location = var.location + tags = local.merged_tags + + body = { + kind = "web" + properties = { + Application_Type = "web" + Flow_Type = "Bluefield" + Request_Source = "rest" + } + } + + response_export_values = [ + "id", + "name", + "properties.ConnectionString", + "properties.InstrumentationKey" + ] +} + +locals { + app_insights_output = azapi_resource.app_insights.output +} + +resource "azapi_resource" "key_vault" { + type = "Microsoft.KeyVault/vaults@2023-07-01" + parent_id = azurerm_resource_group.rg.id + name = local.key_vault_name + location = var.location + tags = local.merged_tags + + body = { + properties = { + sku = { + family = "A" + name = "standard" + } + tenantId = data.azurerm_client_config.current.tenant_id + enableRbacAuthorization = true + enabledForDeployment = true + enabledForTemplateDeployment = true + enabledForDiskEncryption = false + publicNetworkAccess = "Enabled" + networkAcls = { + bypass = "AzureServices" + defaultAction = var.key_vault_firewall_client_ip == "" ? "Allow" : "Deny" + ipRules = var.key_vault_firewall_client_ip == "" ? [] : [ + { + value = var.key_vault_firewall_client_ip + } + ] + virtualNetworkRules = [] + } + } + } + + response_export_values = ["id", "name", "properties.vaultUri"] +} + +resource "azurerm_role_assignment" "key_vault_admin" { + scope = azapi_resource.key_vault.id + role_definition_name = "Key Vault Administrator" + principal_id = data.azurerm_client_config.current.object_id +} + +resource "time_sleep" "wait_for_key_vault_rbac" { + depends_on = [azurerm_role_assignment.key_vault_admin] + create_duration = "20s" +} + +resource "azapi_resource" "app_service_plan" { + type = "Microsoft.Web/serverfarms@2023-12-01" + parent_id = azurerm_resource_group.rg.id + name = local.app_service_plan_name + location = var.location + tags = local.merged_tags + + body = { + kind = "linux" + sku = { + name = var.app_service_plan_sku_name + tier = var.app_service_plan_sku_tier + capacity = var.app_service_plan_capacity + } + properties = { + reserved = true + } + } +} + +resource "azapi_resource" "service_bus" { + type = "Microsoft.ServiceBus/namespaces@2023-01-01-preview" + parent_id = azurerm_resource_group.rg.id + name = local.service_bus_name + location = var.location + tags = local.merged_tags + + body = { + sku = { + name = var.service_bus_sku_name + tier = var.service_bus_sku_name + } + properties = { + publicNetworkAccess = "Enabled" + minimumTlsVersion = "1.2" + } + } +} + +resource "azapi_resource" "service_bus_topic" { + type = "Microsoft.ServiceBus/namespaces/topics@2023-01-01-preview" + parent_id = azapi_resource.service_bus.id + name = "contoso" + + body = { + properties = { + defaultMessageTimeToLive = "P14D" + requiresDuplicateDetection = true + duplicateDetectionHistoryTimeWindow = "PT10M" + } + } +} + +resource "azapi_resource" "service_bus_subscription_products" { + type = "Microsoft.ServiceBus/namespaces/topics/subscriptions@2023-01-01-preview" + parent_id = azapi_resource.service_bus_topic.id + name = "products" + + body = { + properties = { + requiresSession = true + maxDeliveryCount = 10 + lockDuration = "PT5M" + deadLetteringOnMessageExpiration = true + } + } +} + +resource "azapi_resource" "service_bus_subscription_shopping" { + type = "Microsoft.ServiceBus/namespaces/topics/subscriptions@2023-01-01-preview" + parent_id = azapi_resource.service_bus_topic.id + name = "shopping" + + body = { + properties = { + requiresSession = true + maxDeliveryCount = 10 + lockDuration = "PT5M" + deadLetteringOnMessageExpiration = true + } + } +} + +resource "azapi_resource" "service_bus_auth_rule" { + type = "Microsoft.ServiceBus/namespaces/AuthorizationRules@2023-01-01-preview" + parent_id = azapi_resource.service_bus.id + name = "app" + + body = { + properties = { + rights = ["Listen", "Send", "Manage"] + } + } +} + +resource "azapi_resource_action" "service_bus_keys" { + type = "Microsoft.ServiceBus/namespaces/AuthorizationRules@2023-01-01-preview" + resource_id = azapi_resource.service_bus_auth_rule.id + action = "listKeys" + method = "POST" + + response_export_values = ["primaryConnectionString"] +} + +locals { + service_bus_keys_output = azapi_resource_action.service_bus_keys.output +} + +resource "azapi_resource" "redis" { + type = "Microsoft.Cache/redisEnterprise@2025-07-01" + parent_id = azurerm_resource_group.rg.id + name = local.redis_name + location = var.location + tags = local.merged_tags + + body = { + sku = { + name = var.redis_sku_name + } + properties = { + highAvailability = var.redis_high_availability + minimumTlsVersion = "1.2" + publicNetworkAccess = "Enabled" + encryption = {} + } + } + + response_export_values = ["properties.hostName"] +} + +resource "azapi_resource" "redis_default_db" { + type = "Microsoft.Cache/redisEnterprise/databases@2025-04-01" + parent_id = azapi_resource.redis.id + name = "default" + + body = { + properties = { + clientProtocol = "Encrypted" + clusteringPolicy = "OSSCluster" + evictionPolicy = "VolatileLRU" + modules = [] + port = 10000 + } + } +} + +resource "azapi_resource_action" "redis_keys" { + type = "Microsoft.Cache/redisEnterprise/databases@2025-04-01" + resource_id = azapi_resource.redis_default_db.id + action = "listKeys" + method = "POST" + + response_export_values = ["primaryKey"] +} + +locals { + redis_output = azapi_resource.redis.output + redis_keys_output = azapi_resource_action.redis_keys.output + + redis_connection_string = "${local.redis_output.properties.hostName}:10000,password=${local.redis_keys_output.primaryKey},ssl=True,abortConnect=False" +} + +resource "azapi_resource" "sql_server" { + type = "Microsoft.Sql/servers@2023-08-01-preview" + parent_id = azurerm_resource_group.rg.id + name = local.sql_server_name + location = var.location + tags = local.merged_tags + + body = { + properties = { + administratorLogin = var.sql_admin_login + administratorLoginPassword = var.sql_admin_password + version = "12.0" + publicNetworkAccess = "Enabled" + minimalTlsVersion = "1.2" + } + } +} + +resource "azapi_resource" "sql_firewall_azure" { + type = "Microsoft.Sql/servers/firewallRules@2023-08-01-preview" + parent_id = azapi_resource.sql_server.id + name = "AllowAzureServices" + + body = { + properties = { + startIpAddress = "0.0.0.0" + endIpAddress = "0.0.0.0" + } + } +} + +resource "azapi_resource" "sql_firewall_client" { + count = var.sql_firewall_client_ip == "" ? 0 : 1 + type = "Microsoft.Sql/servers/firewallRules@2023-08-01-preview" + parent_id = azapi_resource.sql_server.id + name = "AllowCurrentRunner-${replace(var.sql_firewall_client_ip, ".", "-")}" + + body = { + properties = { + startIpAddress = var.sql_firewall_client_ip + endIpAddress = var.sql_firewall_client_ip + } + } +} + +resource "azapi_resource" "sql_database" { + type = "Microsoft.Sql/servers/databases@2023-08-01-preview" + parent_id = azapi_resource.sql_server.id + name = var.sql_database_name + location = var.location + tags = local.merged_tags + + body = { + sku = { + name = var.sql_sku_name + tier = var.sql_sku_tier + } + properties = { + collation = "SQL_Latin1_General_CP1_CI_AS" + minCapacity = var.sql_min_capacity + autoPauseDelay = var.sql_auto_pause_delay + } + } +} + +locals { + sql_fqdn = "${local.sql_server_name}.database.windows.net" + sql_connection_string = "Data Source=tcp:${local.sql_fqdn},1433;Initial Catalog=${var.sql_database_name};User id=${var.sql_admin_login};Password=${var.sql_admin_password};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" +} + +resource "azurerm_key_vault_secret" "sql_admin_password" { + name = "sql-admin-password" + value = var.sql_admin_password + key_vault_id = azapi_resource.key_vault.id + + depends_on = [time_sleep.wait_for_key_vault_rbac] +} + +resource "azurerm_key_vault_secret" "sql_connection_string" { + name = "sql-connection-string" + value = local.sql_connection_string + key_vault_id = azapi_resource.key_vault.id + + depends_on = [time_sleep.wait_for_key_vault_rbac] +} + +resource "azurerm_key_vault_secret" "service_bus_connection_string" { + name = "service-bus-connection-string" + value = local.service_bus_keys_output.primaryConnectionString + key_vault_id = azapi_resource.key_vault.id + + depends_on = [time_sleep.wait_for_key_vault_rbac] +} + +resource "azapi_resource" "aspire_dashboard" { + type = "Microsoft.Web/sites@2023-12-01" + parent_id = azurerm_resource_group.rg.id + name = local.dashboard_name + location = var.location + tags = merge(local.merged_tags, { + role = "aspire-dashboard" + }) + + body = { + kind = "app,linux,container" + properties = { + serverFarmId = azapi_resource.app_service_plan.id + httpsOnly = true + siteConfig = { + linuxFxVersion = "DOCKER|mcr.microsoft.com/dotnet/aspire-dashboard:latest" + ftpsState = "Disabled" + alwaysOn = true + http20Enabled = true + appSettings = [ + { + name = "WEBSITES_PORT" + value = "18888" + }, + { + name = "ASPNETCORE_URLS" + value = "http://0.0.0.0:18888" + }, + { + name = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL" + value = "http://0.0.0.0:18889" + }, + { + name = "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL" + value = "http://0.0.0.0:18888" + }, + { + name = "DASHBOARD__UI__DISABLERESOURCEGRAPH" + value = "true" + } + ] + } + } + } + + response_export_values = ["properties.defaultHostName"] +} + +locals { + aspire_dashboard_output = azapi_resource.aspire_dashboard.output + otlp_http_endpoint = "https://${local.aspire_dashboard_output.properties.defaultHostName}" + app_services_common_settings = [ + { + name = "ASPNETCORE_ENVIRONMENT" + value = "Development" + }, + { + name = "APPLICATIONINSIGHTS_CONNECTION_STRING" + value = local.app_insights_output.properties.ConnectionString + }, + { + name = "APPINSIGHTS_INSTRUMENTATIONKEY" + value = local.app_insights_output.properties.InstrumentationKey + }, + { + name = "ApplicationInsightsAgent_EXTENSION_VERSION" + value = "~3" + }, + { + name = "XDT_MicrosoftApplicationInsights_Mode" + value = "recommended" + }, + { + name = "XDT_MicrosoftApplicationInsights_PreemptSdk" + value = "disabled" + }, + { + name = "DiagnosticServices_EXTENSION_VERSION" + value = "~3" + }, + { + name = "APPINSIGHTS_PROFILERFEATURE_VERSION" + value = "1.0.0" + }, + { + name = "APPINSIGHTS_SNAPSHOTFEATURE_VERSION" + value = "1.0.0" + }, + { + name = "Aspire__Microsoft__Data__SqlClient__ConnectionString" + value = local.sql_connection_string + }, + { + name = "Aspire__StackExchange__Redis__ConnectionString" + value = local.redis_connection_string + }, + { + name = "Aspire__Azure__Messaging__ServiceBus__ConnectionString" + value = local.service_bus_keys_output.primaryConnectionString + }, + { + name = "OTEL_EXPORTER_OTLP_PROTOCOL" + value = "http/protobuf" + }, + { + name = "OTEL_EXPORTER_OTLP_ENDPOINT" + value = local.otlp_http_endpoint + } + ] +} + +resource "azapi_resource" "products_api" { + type = "Microsoft.Web/sites@2023-12-01" + parent_id = azurerm_resource_group.rg.id + name = "app-products-api-${var.environment_type}-${local.suffix}" + location = var.location + tags = merge(local.merged_tags, { + "azd-service-name" = "products-api" + "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id + }) + + body = { + kind = "app,linux" + properties = { + serverFarmId = azapi_resource.app_service_plan.id + httpsOnly = true + endToEndEncryptionEnabled = true + siteConfig = { + linuxFxVersion = var.app_service_linux_fx_version + minTlsVersion = "1.3" + minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" + scmMinTlsVersion = "1.3" + netFrameworkVersion = "" + ftpsState = "Disabled" + http20Enabled = true + alwaysOn = true + appSettings = local.app_services_common_settings + } + } + } + + response_export_values = ["properties.defaultHostName"] +} + +locals { + products_api_output = azapi_resource.products_api.output +} + +resource "azapi_resource" "shopping_api" { + type = "Microsoft.Web/sites@2023-12-01" + parent_id = azurerm_resource_group.rg.id + name = "app-shopping-api-${var.environment_type}-${local.suffix}" + location = var.location + tags = merge(local.merged_tags, { + "azd-service-name" = "shopping-api" + "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id + }) + + body = { + kind = "app,linux" + properties = { + serverFarmId = azapi_resource.app_service_plan.id + httpsOnly = true + endToEndEncryptionEnabled = true + siteConfig = { + linuxFxVersion = var.app_service_linux_fx_version + minTlsVersion = "1.3" + minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" + scmMinTlsVersion = "1.3" + netFrameworkVersion = "" + ftpsState = "Disabled" + http20Enabled = true + alwaysOn = true + appSettings = concat(local.app_services_common_settings, [ + { + name = "ProductsApi__BaseAddress" + value = "https://${local.products_api_output.properties.defaultHostName}" + } + ]) + } + } + } +} + +resource "azapi_resource" "products_outbox_relay" { + type = "Microsoft.Web/sites@2023-12-01" + parent_id = azurerm_resource_group.rg.id + name = "app-products-outbox-relay-${var.environment_type}-${local.suffix}" + location = var.location + tags = merge(local.merged_tags, { + "azd-service-name" = "products-outbox-relay" + "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id + }) + + body = { + kind = "app,linux" + properties = { + serverFarmId = azapi_resource.app_service_plan.id + httpsOnly = true + endToEndEncryptionEnabled = true + siteConfig = { + linuxFxVersion = var.app_service_linux_fx_version + minTlsVersion = "1.3" + minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" + scmMinTlsVersion = "1.3" + netFrameworkVersion = "" + ftpsState = "Disabled" + http20Enabled = true + alwaysOn = true + appSettings = local.app_services_common_settings + } + } + } +} + +resource "azapi_resource" "shopping_outbox_relay" { + type = "Microsoft.Web/sites@2023-12-01" + parent_id = azurerm_resource_group.rg.id + name = "app-shopping-outbox-relay-${var.environment_type}-${local.suffix}" + location = var.location + tags = merge(local.merged_tags, { + "azd-service-name" = "shopping-outbox-relay" + "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id + }) + + body = { + kind = "app,linux" + properties = { + serverFarmId = azapi_resource.app_service_plan.id + httpsOnly = true + endToEndEncryptionEnabled = true + siteConfig = { + linuxFxVersion = var.app_service_linux_fx_version + minTlsVersion = "1.3" + minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" + scmMinTlsVersion = "1.3" + netFrameworkVersion = "" + ftpsState = "Disabled" + http20Enabled = true + alwaysOn = true + appSettings = local.app_services_common_settings + } + } + } +} + +resource "azapi_resource" "products_subscribe" { + type = "Microsoft.Web/sites@2023-12-01" + parent_id = azurerm_resource_group.rg.id + name = "app-products-subscribe-${var.environment_type}-${local.suffix}" + location = var.location + tags = merge(local.merged_tags, { + "azd-service-name" = "products-subscribe" + "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id + }) + + body = { + kind = "app,linux" + properties = { + serverFarmId = azapi_resource.app_service_plan.id + httpsOnly = true + endToEndEncryptionEnabled = true + siteConfig = { + linuxFxVersion = var.app_service_linux_fx_version + minTlsVersion = "1.3" + minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" + scmMinTlsVersion = "1.3" + netFrameworkVersion = "" + ftpsState = "Disabled" + http20Enabled = true + alwaysOn = true + appSettings = local.app_services_common_settings + } + } + } +} + +resource "azapi_resource" "shopping_subscribe" { + type = "Microsoft.Web/sites@2023-12-01" + parent_id = azurerm_resource_group.rg.id + name = "app-shopping-subscribe-${var.environment_type}-${local.suffix}" + location = var.location + tags = merge(local.merged_tags, { + "azd-service-name" = "shopping-subscribe" + "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id + }) + + body = { + kind = "app,linux" + properties = { + serverFarmId = azapi_resource.app_service_plan.id + httpsOnly = true + endToEndEncryptionEnabled = true + siteConfig = { + linuxFxVersion = var.app_service_linux_fx_version + minTlsVersion = "1.3" + minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" + scmMinTlsVersion = "1.3" + netFrameworkVersion = "" + ftpsState = "Disabled" + http20Enabled = true + alwaysOn = true + appSettings = local.app_services_common_settings + } + } + } +} + +data "azurerm_client_config" "current" {} diff --git a/azure/terraform/outputs.tf b/azure/terraform/outputs.tf new file mode 100644 index 00000000..6a7ed241 --- /dev/null +++ b/azure/terraform/outputs.tf @@ -0,0 +1,64 @@ +output "app_service_plan_name" { + value = azapi_resource.app_service_plan.name +} + +output "app_insights_connection_string" { + value = local.app_insights_output.properties.ConnectionString + sensitive = true +} + +output "key_vault_name" { + value = azapi_resource.key_vault.name +} + +output "service_bus_namespace_name" { + value = azapi_resource.service_bus.name +} + +output "redis_host_name" { + value = local.redis_output.properties.hostName +} + +output "sql_server_name" { + value = azapi_resource.sql_server.name +} + +output "sql_database_name" { + value = azapi_resource.sql_database.name +} + +output "products_api_app_name" { + value = azapi_resource.products_api.name +} + +output "shopping_api_app_name" { + value = azapi_resource.shopping_api.name +} + +output "products_outbox_relay_app_name" { + value = azapi_resource.products_outbox_relay.name +} + +output "shopping_outbox_relay_app_name" { + value = azapi_resource.shopping_outbox_relay.name +} + +output "products_subscribe_app_name" { + value = azapi_resource.products_subscribe.name +} + +output "shopping_subscribe_app_name" { + value = azapi_resource.shopping_subscribe.name +} + +output "aspire_dashboard_app_name" { + value = azapi_resource.aspire_dashboard.name +} + +output "aspire_dashboard_uri" { + value = "https://${local.aspire_dashboard_output.properties.defaultHostName}" +} + +output "aspire_dashboard_otlp_http_endpoint" { + value = "https://${local.aspire_dashboard_output.properties.defaultHostName}" +} diff --git a/azure/terraform/prod.tfvars b/azure/terraform/prod.tfvars new file mode 100644 index 00000000..2a4a8fed --- /dev/null +++ b/azure/terraform/prod.tfvars @@ -0,0 +1,31 @@ +resource_group_name = "rg-prod" +environment_type = "prod" +location = "eastus" +name_suffix = "prod01" + +tags = { + workload = "coreex" + environment = "prod" +} + +app_service_plan_sku_name = "B1" +app_service_plan_sku_tier = "Basic" +app_service_plan_capacity = 1 + +# Derived from AZD_DOTNET_TARGET_FRAMEWORK by apply script. +app_service_linux_fx_version = "DOTNETCORE|10.0" + +service_bus_sku_name = "Standard" + +sql_admin_login = "coreexadmin" +sql_database_name = "coreexprod" +sql_sku_name = "GP_S_Gen5_1" +sql_sku_tier = "GeneralPurpose" +sql_min_capacity = 0.5 +sql_auto_pause_delay = 60 + +# Derived from current public IP by apply script. +sql_firewall_client_ip = "" + +redis_sku_name = "Balanced_B0" +redis_high_availability = "Enabled" diff --git a/azure/terraform/terraform.tfvars.example b/azure/terraform/terraform.tfvars.example new file mode 100644 index 00000000..71c79a7d --- /dev/null +++ b/azure/terraform/terraform.tfvars.example @@ -0,0 +1,29 @@ +resource_group_name = "rg-dev" +environment_type = "dev" +location = "westus3" +name_suffix = "dev01" + +tags = { + workload = "coreex" + environment = "dev" + costProfile = "minimum-practical" +} + +app_service_plan_sku_name = "B2" +app_service_plan_sku_tier = "Basic" +app_service_plan_capacity = 1 +app_service_linux_fx_version = "DOTNETCORE|10.0" + +service_bus_sku_name = "Standard" + +sql_admin_login = "coreexadmin" +sql_admin_password = "" +sql_database_name = "coreexdev" +sql_firewall_client_ip = "" +sql_sku_name = "GP_S_Gen5_1" +sql_sku_tier = "GeneralPurpose" +sql_min_capacity = 0.5 +sql_auto_pause_delay = 60 + +redis_sku_name = "Balanced_B0" +redis_high_availability = "Disabled" diff --git a/azure/terraform/test.tfvars b/azure/terraform/test.tfvars new file mode 100644 index 00000000..4215135b --- /dev/null +++ b/azure/terraform/test.tfvars @@ -0,0 +1,31 @@ +resource_group_name = "rg-test" +environment_type = "test" +location = "eastus" +name_suffix = "test01" + +tags = { + workload = "coreex" + environment = "test" +} + +app_service_plan_sku_name = "B1" +app_service_plan_sku_tier = "Basic" +app_service_plan_capacity = 1 + +# Derived from AZD_DOTNET_TARGET_FRAMEWORK by apply script. +app_service_linux_fx_version = "DOTNETCORE|10.0" + +service_bus_sku_name = "Standard" + +sql_admin_login = "coreexadmin" +sql_database_name = "coreextest" +sql_sku_name = "GP_S_Gen5_1" +sql_sku_tier = "GeneralPurpose" +sql_min_capacity = 0.5 +sql_auto_pause_delay = 60 + +# Derived from current public IP by apply script. +sql_firewall_client_ip = "" + +redis_sku_name = "Balanced_B0" +redis_high_availability = "Enabled" diff --git a/azure/terraform/variables.tf b/azure/terraform/variables.tf new file mode 100644 index 00000000..cfe30934 --- /dev/null +++ b/azure/terraform/variables.tf @@ -0,0 +1,116 @@ +variable "resource_group_name" { + description = "Existing resource group where all resources will be deployed." + type = string +} + +variable "environment_type" { + description = "Deployment environment." + type = string + validation { + condition = contains(["dev", "test", "prod"], var.environment_type) + error_message = "environment_type must be one of: dev, test, prod." + } +} + +variable "location" { + description = "Azure region for all resources." + type = string +} + +variable "name_suffix" { + description = "Short lowercase suffix used in resource names." + type = string +} + +variable "tags" { + description = "Base tags applied to all resources." + type = map(string) + default = {} +} + +variable "app_service_plan_sku_name" { + description = "App Service Plan SKU name." + type = string +} + +variable "app_service_plan_sku_tier" { + description = "App Service Plan SKU tier." + type = string +} + +variable "app_service_plan_capacity" { + description = "App Service Plan instance count." + type = number +} + +variable "app_service_linux_fx_version" { + description = "Linux runtime stack for code-based app services. Example: DOTNETCORE|10.0." + type = string +} + +variable "service_bus_sku_name" { + description = "Service Bus namespace SKU name." + type = string +} + +variable "sql_admin_login" { + description = "SQL admin username." + type = string +} + +variable "sql_admin_password" { + description = "SQL admin password." + type = string + sensitive = true +} + +variable "sql_database_name" { + description = "SQL database name." + type = string +} + +variable "sql_firewall_client_ip" { + description = "Optional public IPv4 for runner firewall allow rule." + type = string + default = "" +} + +variable "key_vault_firewall_client_ip" { + description = "Optional public IPv4 to allow through Key Vault firewall." + type = string + default = "" +} + +variable "sql_sku_name" { + description = "SQL database SKU name." + type = string +} + +variable "sql_sku_tier" { + description = "SQL database SKU tier." + type = string +} + +variable "sql_min_capacity" { + description = "SQL serverless min capacity." + type = number +} + +variable "sql_auto_pause_delay" { + description = "SQL auto-pause delay in minutes." + type = number +} + +variable "redis_sku_name" { + description = "Azure Managed Redis SKU name. Example: Balanced_B0." + type = string +} + +variable "redis_high_availability" { + description = "Azure Managed Redis high availability mode." + type = string + validation { + condition = contains(["Enabled", "Disabled"], var.redis_high_availability) + error_message = "redis_high_availability must be Enabled or Disabled." + } +} diff --git a/azure/terraform/versions.tf b/azure/terraform/versions.tf new file mode 100644 index 00000000..bd4ad390 --- /dev/null +++ b/azure/terraform/versions.tf @@ -0,0 +1,28 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.112.0" + } + azapi = { + source = "Azure/azapi" + version = ">= 1.14.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.6.0" + } + time = { + source = "hashicorp/time" + version = ">= 0.12.0" + } + } +} + +provider "azurerm" { + features {} +} + +provider "azapi" {} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index d50a69f6..5ab46248 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -8,4 +8,10 @@ true true + + + $(AZD_DOTNET_TARGET_FRAMEWORK) + $(DOTNET_TARGET_FRAMEWORK) + $(AzdPublishTargetFramework) + \ No newline at end of file From b151a314ac48d79dbfefaa5f26d6b65041591f40 Mon Sep 17 00:00:00 2001 From: Israel Date: Mon, 11 May 2026 16:25:42 -0700 Subject: [PATCH 3/9] Agentic dco fix 3 (#142) * Merging from agentic separate branch/repo Signed-off-by: Israel Slobodkin * updates for durable task Signed-off-by: Israel Slobodkin * setting samples to target .net 10 only Signed-off-by: Israel Slobodkin * making E2E target net10 Signed-off-by: Israel Slobodkin * fixing repo query Signed-off-by: Israel Slobodkin * refactor: replace FluentAssertions with AwesomeAssertions in test projects Signed-off-by: Israel Slobodkin * adding dts emulator to compose Signed-off-by: Israel Slobodkin * feat: update test conventions and scaffold checklist to use AwesomeAssertions Co-authored-by: Copilot Signed-off-by: Israel Slobodkin * fix: apply PR review feedback - namespace, validation, item sync, routes, docs Agent-Logs-Url: https://github.com/Avanade/CoreEx/sessions/6bd00f38-1dfa-43d7-bb5b-ffebc0d1b4a7 Co-authored-by: israels <3219628+israels@users.noreply.github.com> Signed-off-by: Israel Slobodkin * Merging from agentic separate branch/repo Signed-off-by: Israel Slobodkin * updates for durable task Signed-off-by: Israel Slobodkin * setting samples to target .net 10 only Signed-off-by: Israel Slobodkin * making E2E target net10 Signed-off-by: Israel Slobodkin * fixing repo query Signed-off-by: Israel Slobodkin * refactor: replace FluentAssertions with AwesomeAssertions in test projects Signed-off-by: Israel Slobodkin * adding dts emulator to compose Signed-off-by: Israel Slobodkin * feat: update test conventions and scaffold checklist to use AwesomeAssertions Co-authored-by: Copilot Signed-off-by: Israel Slobodkin * fix: apply PR review feedback - namespace, validation, item sync, routes, docs Agent-Logs-Url: https://github.com/Avanade/CoreEx/sessions/6bd00f38-1dfa-43d7-bb5b-ffebc0d1b4a7 Co-authored-by: israels <3219628+israels@users.noreply.github.com> Signed-off-by: Israel Slobodkin * chore: update documentation and guidelines across various instruction files for clarity and consistency Co-authored-by: Copilot * Refactor host setup instructions and enhance capability for messaging integration - Cleaned up host setup instructions by removing unnecessary lines and improving clarity. - Updated API host section to emphasize key registrations and middleware usage. - Streamlined Subscribe host instructions to focus on essential configurations. - Simplified Outbox Relay host guidance, highlighting minimal requirements. - Created new authoring standards for instruction files to ensure consistency and maintainability. - Established detailed workflows for adding capabilities and generating domains, enhancing user guidance. - Introduced structured references for skills to improve discoverability and organization. Co-authored-by: Copilot * feat: add comprehensive guide for CoreEx agent skills and workflows * feat: update agent tools and enhance project configurations for multi-targeting support Added some fixes for issues with failing tests running in parallel * Removing accidental included deprecated files during merge. * fix: correct project references and update null handling in OrderMapper * fixing proper generator references in docs * fix: update Order handling to preserve client-supplied identities and enhance validation * fix: update Order query to ignore auto-includes for improved performance * feat: implement IOrderWorkflowClient in order to mock and test orchestrated workflow * copilot suggested fixes: update various files for consistency and improved clarity in code structure --------- Signed-off-by: Israel Slobodkin Co-authored-by: Copilot Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: israels <3219628+israels@users.noreply.github.com> --- .github/INSTRUCTION_AUTHORING.md | 133 ++ .github/SKILL_AUTHORING.md | 152 +++ .github/agents/coreex-expert.agent.md | 47 + .github/copilot-instructions.md | 125 +- .../api-controllers.instructions.md | 122 ++ .../application-services.instructions.md | 172 +++ .../instructions/contracts.instructions.md | 156 +++ .../database-project.instructions.md | 90 ++ .../event-subscribers.instructions.md | 122 ++ .../instructions/host-setup.instructions.md | 123 ++ .../instructions/repositories.instructions.md | 162 +++ .github/instructions/tests.instructions.md | 186 +++ .../instructions/validators.instructions.md | 134 ++ .github/prompts/init.prompt.md | 33 + .../scaffold-domain-from-templates.prompt.md | 141 +++ .github/prompts/setup.prompt.md | 33 + .../acquire-codebase-knowledge/SKILL.md | 175 +++ .../assets/templates/ARCHITECTURE.md | 49 + .../assets/templates/CONCERNS.md | 56 + .../assets/templates/CONVENTIONS.md | 52 + .../assets/templates/INTEGRATIONS.md | 48 + .../assets/templates/STACK.md | 56 + .../assets/templates/STRUCTURE.md | 44 + .../assets/templates/TESTING.md | 57 + .../references/inquiry-checkpoints.md | 70 ++ .../references/stack-detection.md | 131 ++ .../scripts/scan.py | 712 +++++++++++ .github/skills/add-capability/SKILL.md | 50 + .../messaging-retrofit-checklist.md | 46 + .../messaging-retrofit-checkpoints.md | 84 ++ .../add-capability/references/workflow.md | 93 ++ .github/skills/aspire/SKILL.md | 108 ++ .github/skills/generate-domain/SKILL.md | 38 + .../generate-domain/references/workflow.md | 119 ++ .../Controllers/EntityController.cs.template | 38 + .../EntityReadController.cs.template | 20 + .../ReferenceDataController.cs.template | 12 + .../domain/Api/Domain.Api.csproj.template | 19 + .../domain/Api/GlobalUsing.cs.template | 14 + .../templates/domain/Api/Program.cs.template | 78 ++ .../domain/Api/appsettings.json.template | 20 + .../Domain.Application.csproj.template | 9 + .../Application/EntityReadService.cs.template | 12 + .../Application/EntityService.cs.template | 56 + .../Application/GlobalUsing.cs.template | 14 + .../Interfaces/IEntityReadService.cs.template | 8 + .../Interfaces/IEntityService.cs.template | 12 + .../ReferenceDataService.cs.template | 18 + .../IEntityRepository.cs.template | 14 + .../IReferenceDataRepository.cs.template | 6 + .../Validators/EntityValidator.cs.template | 16 + .../domain/Contracts/ChildEntity.cs.template | 14 + .../Domain.Contracts.csproj.template | 9 + .../domain/Contracts/Entity.cs.template | 11 + .../domain/Contracts/EntityBase.cs.template | 16 + .../domain/Contracts/EntityLite.cs.template | 17 + .../domain/Contracts/EntityStatus.cs.template | 6 + .../domain/Contracts/GlobalUsing.cs.template | 5 + .../Database/Data/ref-data.yaml.template | 5 + .../Database/Domain.Database.csproj.template | 21 + .../000001-create-schema.sql.template | 1 + .../000101-create-entitystatus.sql.template | 18 + .../000201-create-entity.sql.template | 16 + .../000202-create-childentity.sql.template | 17 + .../000301-create-outbox-tables.sql.template | 29 + .../domain/Database/Program.cs.template | 26 + .../spOutboxBatchCancel.g.sql.template | 46 + .../spOutboxBatchClaim.g.sql.template | 96 ++ .../spOutboxBatchComplete.g.sql.template | 50 + .../spOutboxEnqueue.g.sql.template | 18 + .../spOutboxLeaseAcquire.g.sql.template | 49 + .../spOutboxLeaseRelease.g.sql.template | 29 + .../domain/Database/dbex.yaml.template | 8 + .../domain/DomainScaffold.checklist.md | 74 ++ .../Domain.Infrastructure.csproj.template | 12 + .../Infrastructure/GlobalUsing.cs.template | 19 + .../Mapping/EntityMapper.cs.template | 33 + .../Mapping/EntityStatusMapper.cs.template | 16 + .../Persistence/ChildEntity.cs.template | 12 + .../Persistence/Entity.cs.template | 10 + .../Persistence/EntityStatus.cs.template | 3 + .../Repositories/DomainDbContext.cs.template | 64 + .../Repositories/DomainEfDb.cs.template | 9 + .../DomainOutboxPublisher.cs.template | 7 + .../Repositories/EntityRepository.cs.template | 38 + .../ReferenceDataRepository.cs.template | 10 + CoreEx.sln | 180 +++ Directory.Packages.props | 3 + README.md | 6 +- azure/AGENTS.md | 6 + docker-compose.yml | 10 +- docs/JsonDataReader-vs-JsonNodeDataReader.md | 189 +++ docs/agent-interaction-guide.md | 277 ++++ docs/agent-prompt-recipes.md | 337 +++++ docs/agent-skills-workflow-guide.md | 368 ++++++ docs/application-scaffolding-guide.md | 361 ++++++ docs/capabilities.md | 1109 +++++++++++++++++ docs/codebase/ARCHITECTURE.md | 65 + docs/codebase/CONCERNS.md | 63 + docs/codebase/CONVENTIONS.md | 51 + docs/codebase/INTEGRATIONS.md | 54 + docs/codebase/STACK.md | 73 ++ docs/codebase/STRUCTURE.md | 56 + docs/codebase/TESTING.md | 53 + docs/orchestration.md | 557 +++++++++ .../coreex-agentic-scaffolding-slides.md | 437 +++++++ .../coreex-agentic-scaffolding-slides.pptx | Bin 0 -> 2292709 bytes .../presentations/export-markdown-to-pptx.ps1 | 261 ++++ gen/CoreEx.Generator/CoreEx.Generator.csproj | 2 +- samples/Directory.Build.props | 6 + samples/README.md | 395 ++++++ samples/aspire.config.json | 5 + samples/aspire/Contoso.Aspire/AppHost.cs | 6 + .../Contoso.Aspire/Contoso.Aspire.csproj | 5 +- .../Properties/launchSettings.json | 1 + .../Contoso.Order.Workflow.Client.csproj | 15 + .../DurableTaskConnectionStringFactory.cs | 16 + .../DurableTaskSchedulerOptions.cs | 10 + .../IOrderWorkflowClient.cs | 11 + .../OrderWorkflowClient.cs | 38 + .../ServiceCollectionExtensions.cs | 30 + .../Contoso.Order.Workflow.Worker.csproj | 17 + .../Contoso.Order.Workflow.Worker/Program.cs | 65 + .../Properties/launchSettings.json | 12 + .../appsettings.Development.json | 6 + .../appsettings.json | 12 + .../Activities/SubmitOrderActivity.cs | 15 + .../Activities/ValidateOrderActivity.cs | 17 + .../Contoso.Order.Workflow.Workflow.csproj | 11 + .../Contracts/OrderWorkflowContracts.cs | 9 + .../OrderWorkflowOrchestration.cs | 27 + .../Contoso.Orders.Api.csproj | 26 + .../Controllers/OrderController.cs | 55 + .../Controllers/OrderReadController.cs | 22 + .../Controllers/ReferenceDataController.cs | 12 + samples/src/Contoso.Orders.Api/GlobalUsing.cs | 14 + samples/src/Contoso.Orders.Api/Program.cs | 82 ++ .../Properties/launchSettings.json | 13 + .../appsettings.Development.json | 36 + .../src/Contoso.Orders.Api/appsettings.json | 20 + .../Contoso.Orders.Application.csproj | 9 + .../Contoso.Orders.Application/GlobalUsing.cs | 14 + .../Interfaces/IOrderReadService.cs | 8 + .../Interfaces/IOrderService.cs | 12 + .../OrderReadService.cs | 12 + .../OrderService.cs | 56 + .../ReferenceDataService.cs | 18 + .../Repositories/IOrderRepository.cs | 14 + .../Repositories/IReferenceDataRepository.cs | 6 + .../Validators/OrderValidator.cs | 17 + .../Contoso.Orders.Contracts.csproj | 9 + .../Contoso.Orders.Contracts/GlobalUsing.cs | 5 + samples/src/Contoso.Orders.Contracts/Order.cs | 11 + .../src/Contoso.Orders.Contracts/OrderBase.cs | 16 + .../src/Contoso.Orders.Contracts/OrderItem.cs | 13 + .../src/Contoso.Orders.Contracts/OrderLite.cs | 17 + .../Contoso.Orders.Contracts/OrderStatus.cs | 6 + .../Contoso.Orders.Database.csproj | 21 + .../Data/ref-data.yaml | 5 + .../20260101-000001-create-orders-schema.sql | 1 + ...60101-000101-create-orders-orderstatus.sql | 18 + .../20260101-000201-create-orders-order.sql | 16 + ...0260101-000202-create-orders-orderitem.sql | 17 + ...101-000301-create-orders-outbox-tables.sql | 33 + .../src/Contoso.Orders.Database/Program.cs | 40 + .../spOutboxBatchCancel.g.sql | 50 + .../spOutboxBatchClaim.g.sql | 100 ++ .../spOutboxBatchComplete.g.sql | 54 + .../Stored Procedures/spOutboxEnqueue.g.sql | 36 + .../spOutboxLeaseAcquire.g.sql | 60 + .../spOutboxLeaseRelease.g.sql | 33 + samples/src/Contoso.Orders.Database/dbex.yaml | 8 + .../Contoso.Orders.Infrastructure.csproj | 12 + .../GlobalUsing.cs | 19 + .../Mapping/OrderMapper.cs | 33 + .../Mapping/OrderStatusMapper.cs | 16 + .../Persistence/Order.cs | 10 + .../Persistence/OrderItem.cs | 12 + .../Persistence/OrderStatus.cs | 3 + .../Repositories/OrderRepository.cs | 93 ++ .../Repositories/OrdersDbContext.cs | 66 + .../Repositories/OrdersEfDb.cs | 8 + .../Repositories/OrdersOutboxPublisher.cs | 7 + .../Repositories/ReferenceDataRepository.cs | 10 + .../Contoso.E2E.Runner.csproj | 3 +- .../Scenarios/DatabaseMigrationSetup.cs | 16 +- .../tests/Contoso.E2E.Runner/appsettings.json | 3 + .../Contoso.Orders.Test.Api.csproj | 29 + .../Contoso.Orders.Test.Api/GlobalUsing.cs | 13 + .../OrderMutateTests.Create.cs | 87 ++ .../OrderMutateTests.Delete.cs | 35 + .../OrderMutateTests.Patch.cs | 61 + .../OrderMutateTests.Update.cs | 81 ++ .../OrderMutateTests.cs | 34 + .../OtherTests.Health.cs | 39 + .../OtherTests.ReferenceData.cs | 18 + .../OtherTests.Swagger.cs | 32 + .../Contoso.Orders.Test.Api/OtherTests.cs | 15 + .../ReadTests.OrderGet.cs | 42 + .../Contoso.Orders.Test.Api/ReadTests.cs | 15 + .../appsettings.unittest.json | 26 + .../Contoso.Orders.Test.Common.csproj | 16 + .../Contoso.Orders.Test.Common/Data/data.yaml | 3 + .../Contoso.Orders.Test.Common/TestData.cs | 6 + .../Contoso.Orders.Test.Unit.csproj | 27 + .../Controllers/OrderControllerTests.cs | 83 ++ .../Contoso.Orders.Test.Unit/EntryPoint.cs | 28 + .../Contoso.Orders.Test.Unit/GlobalUsing.cs | 12 + .../Validators/OrderValidatorTests.cs | 37 + samples/tests/Directory.Build.props | 11 + samples/tests/NUnitAssemblyInfo.cs | 3 + ...eEx.Database.SqlServer.Test.Console.csproj | 2 +- 212 files changed, 12416 insertions(+), 11 deletions(-) create mode 100644 .github/INSTRUCTION_AUTHORING.md create mode 100644 .github/SKILL_AUTHORING.md create mode 100644 .github/agents/coreex-expert.agent.md create mode 100644 .github/instructions/api-controllers.instructions.md create mode 100644 .github/instructions/application-services.instructions.md create mode 100644 .github/instructions/contracts.instructions.md create mode 100644 .github/instructions/database-project.instructions.md create mode 100644 .github/instructions/event-subscribers.instructions.md create mode 100644 .github/instructions/host-setup.instructions.md create mode 100644 .github/instructions/repositories.instructions.md create mode 100644 .github/instructions/tests.instructions.md create mode 100644 .github/instructions/validators.instructions.md create mode 100644 .github/prompts/init.prompt.md create mode 100644 .github/prompts/scaffold-domain-from-templates.prompt.md create mode 100644 .github/prompts/setup.prompt.md create mode 100644 .github/skills/acquire-codebase-knowledge/SKILL.md create mode 100644 .github/skills/acquire-codebase-knowledge/assets/templates/ARCHITECTURE.md create mode 100644 .github/skills/acquire-codebase-knowledge/assets/templates/CONCERNS.md create mode 100644 .github/skills/acquire-codebase-knowledge/assets/templates/CONVENTIONS.md create mode 100644 .github/skills/acquire-codebase-knowledge/assets/templates/INTEGRATIONS.md create mode 100644 .github/skills/acquire-codebase-knowledge/assets/templates/STACK.md create mode 100644 .github/skills/acquire-codebase-knowledge/assets/templates/STRUCTURE.md create mode 100644 .github/skills/acquire-codebase-knowledge/assets/templates/TESTING.md create mode 100644 .github/skills/acquire-codebase-knowledge/references/inquiry-checkpoints.md create mode 100644 .github/skills/acquire-codebase-knowledge/references/stack-detection.md create mode 100644 .github/skills/acquire-codebase-knowledge/scripts/scan.py create mode 100644 .github/skills/add-capability/SKILL.md create mode 100644 .github/skills/add-capability/references/messaging-retrofit-checklist.md create mode 100644 .github/skills/add-capability/references/messaging-retrofit-checkpoints.md create mode 100644 .github/skills/add-capability/references/workflow.md create mode 100644 .github/skills/aspire/SKILL.md create mode 100644 .github/skills/generate-domain/SKILL.md create mode 100644 .github/skills/generate-domain/references/workflow.md create mode 100644 .github/templates/domain/Api/Controllers/EntityController.cs.template create mode 100644 .github/templates/domain/Api/Controllers/EntityReadController.cs.template create mode 100644 .github/templates/domain/Api/Controllers/ReferenceDataController.cs.template create mode 100644 .github/templates/domain/Api/Domain.Api.csproj.template create mode 100644 .github/templates/domain/Api/GlobalUsing.cs.template create mode 100644 .github/templates/domain/Api/Program.cs.template create mode 100644 .github/templates/domain/Api/appsettings.json.template create mode 100644 .github/templates/domain/Application/Domain.Application.csproj.template create mode 100644 .github/templates/domain/Application/EntityReadService.cs.template create mode 100644 .github/templates/domain/Application/EntityService.cs.template create mode 100644 .github/templates/domain/Application/GlobalUsing.cs.template create mode 100644 .github/templates/domain/Application/Interfaces/IEntityReadService.cs.template create mode 100644 .github/templates/domain/Application/Interfaces/IEntityService.cs.template create mode 100644 .github/templates/domain/Application/ReferenceDataService.cs.template create mode 100644 .github/templates/domain/Application/Repositories/IEntityRepository.cs.template create mode 100644 .github/templates/domain/Application/Repositories/IReferenceDataRepository.cs.template create mode 100644 .github/templates/domain/Application/Validators/EntityValidator.cs.template create mode 100644 .github/templates/domain/Contracts/ChildEntity.cs.template create mode 100644 .github/templates/domain/Contracts/Domain.Contracts.csproj.template create mode 100644 .github/templates/domain/Contracts/Entity.cs.template create mode 100644 .github/templates/domain/Contracts/EntityBase.cs.template create mode 100644 .github/templates/domain/Contracts/EntityLite.cs.template create mode 100644 .github/templates/domain/Contracts/EntityStatus.cs.template create mode 100644 .github/templates/domain/Contracts/GlobalUsing.cs.template create mode 100644 .github/templates/domain/Database/Data/ref-data.yaml.template create mode 100644 .github/templates/domain/Database/Domain.Database.csproj.template create mode 100644 .github/templates/domain/Database/Migrations/000001-create-schema.sql.template create mode 100644 .github/templates/domain/Database/Migrations/000101-create-entitystatus.sql.template create mode 100644 .github/templates/domain/Database/Migrations/000201-create-entity.sql.template create mode 100644 .github/templates/domain/Database/Migrations/000202-create-childentity.sql.template create mode 100644 .github/templates/domain/Database/Migrations/000301-create-outbox-tables.sql.template create mode 100644 .github/templates/domain/Database/Program.cs.template create mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql.template create mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql.template create mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql.template create mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql.template create mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql.template create mode 100644 .github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql.template create mode 100644 .github/templates/domain/Database/dbex.yaml.template create mode 100644 .github/templates/domain/DomainScaffold.checklist.md create mode 100644 .github/templates/domain/Infrastructure/Domain.Infrastructure.csproj.template create mode 100644 .github/templates/domain/Infrastructure/GlobalUsing.cs.template create mode 100644 .github/templates/domain/Infrastructure/Mapping/EntityMapper.cs.template create mode 100644 .github/templates/domain/Infrastructure/Mapping/EntityStatusMapper.cs.template create mode 100644 .github/templates/domain/Infrastructure/Persistence/ChildEntity.cs.template create mode 100644 .github/templates/domain/Infrastructure/Persistence/Entity.cs.template create mode 100644 .github/templates/domain/Infrastructure/Persistence/EntityStatus.cs.template create mode 100644 .github/templates/domain/Infrastructure/Repositories/DomainDbContext.cs.template create mode 100644 .github/templates/domain/Infrastructure/Repositories/DomainEfDb.cs.template create mode 100644 .github/templates/domain/Infrastructure/Repositories/DomainOutboxPublisher.cs.template create mode 100644 .github/templates/domain/Infrastructure/Repositories/EntityRepository.cs.template create mode 100644 .github/templates/domain/Infrastructure/Repositories/ReferenceDataRepository.cs.template create mode 100644 docs/JsonDataReader-vs-JsonNodeDataReader.md create mode 100644 docs/agent-interaction-guide.md create mode 100644 docs/agent-prompt-recipes.md create mode 100644 docs/agent-skills-workflow-guide.md create mode 100644 docs/application-scaffolding-guide.md create mode 100644 docs/capabilities.md create mode 100644 docs/codebase/ARCHITECTURE.md create mode 100644 docs/codebase/CONCERNS.md create mode 100644 docs/codebase/CONVENTIONS.md create mode 100644 docs/codebase/INTEGRATIONS.md create mode 100644 docs/codebase/STACK.md create mode 100644 docs/codebase/STRUCTURE.md create mode 100644 docs/codebase/TESTING.md create mode 100644 docs/orchestration.md create mode 100644 docs/presentations/coreex-agentic-scaffolding-slides.md create mode 100644 docs/presentations/coreex-agentic-scaffolding-slides.pptx create mode 100644 docs/presentations/export-markdown-to-pptx.ps1 create mode 100644 samples/README.md create mode 100644 samples/aspire.config.json create mode 100644 samples/src/Contoso.Order.Workflow.Client/Contoso.Order.Workflow.Client.csproj create mode 100644 samples/src/Contoso.Order.Workflow.Client/DurableTaskConnectionStringFactory.cs create mode 100644 samples/src/Contoso.Order.Workflow.Client/DurableTaskSchedulerOptions.cs create mode 100644 samples/src/Contoso.Order.Workflow.Client/IOrderWorkflowClient.cs create mode 100644 samples/src/Contoso.Order.Workflow.Client/OrderWorkflowClient.cs create mode 100644 samples/src/Contoso.Order.Workflow.Client/ServiceCollectionExtensions.cs create mode 100644 samples/src/Contoso.Order.Workflow.Worker/Contoso.Order.Workflow.Worker.csproj create mode 100644 samples/src/Contoso.Order.Workflow.Worker/Program.cs create mode 100644 samples/src/Contoso.Order.Workflow.Worker/Properties/launchSettings.json create mode 100644 samples/src/Contoso.Order.Workflow.Worker/appsettings.Development.json create mode 100644 samples/src/Contoso.Order.Workflow.Worker/appsettings.json create mode 100644 samples/src/Contoso.Order.Workflow.Workflow/Activities/SubmitOrderActivity.cs create mode 100644 samples/src/Contoso.Order.Workflow.Workflow/Activities/ValidateOrderActivity.cs create mode 100644 samples/src/Contoso.Order.Workflow.Workflow/Contoso.Order.Workflow.Workflow.csproj create mode 100644 samples/src/Contoso.Order.Workflow.Workflow/Contracts/OrderWorkflowContracts.cs create mode 100644 samples/src/Contoso.Order.Workflow.Workflow/OrderWorkflowOrchestration.cs create mode 100644 samples/src/Contoso.Orders.Api/Contoso.Orders.Api.csproj create mode 100644 samples/src/Contoso.Orders.Api/Controllers/OrderController.cs create mode 100644 samples/src/Contoso.Orders.Api/Controllers/OrderReadController.cs create mode 100644 samples/src/Contoso.Orders.Api/Controllers/ReferenceDataController.cs create mode 100644 samples/src/Contoso.Orders.Api/GlobalUsing.cs create mode 100644 samples/src/Contoso.Orders.Api/Program.cs create mode 100644 samples/src/Contoso.Orders.Api/Properties/launchSettings.json create mode 100644 samples/src/Contoso.Orders.Api/appsettings.Development.json create mode 100644 samples/src/Contoso.Orders.Api/appsettings.json create mode 100644 samples/src/Contoso.Orders.Application/Contoso.Orders.Application.csproj create mode 100644 samples/src/Contoso.Orders.Application/GlobalUsing.cs create mode 100644 samples/src/Contoso.Orders.Application/Interfaces/IOrderReadService.cs create mode 100644 samples/src/Contoso.Orders.Application/Interfaces/IOrderService.cs create mode 100644 samples/src/Contoso.Orders.Application/OrderReadService.cs create mode 100644 samples/src/Contoso.Orders.Application/OrderService.cs create mode 100644 samples/src/Contoso.Orders.Application/ReferenceDataService.cs create mode 100644 samples/src/Contoso.Orders.Application/Repositories/IOrderRepository.cs create mode 100644 samples/src/Contoso.Orders.Application/Repositories/IReferenceDataRepository.cs create mode 100644 samples/src/Contoso.Orders.Application/Validators/OrderValidator.cs create mode 100644 samples/src/Contoso.Orders.Contracts/Contoso.Orders.Contracts.csproj create mode 100644 samples/src/Contoso.Orders.Contracts/GlobalUsing.cs create mode 100644 samples/src/Contoso.Orders.Contracts/Order.cs create mode 100644 samples/src/Contoso.Orders.Contracts/OrderBase.cs create mode 100644 samples/src/Contoso.Orders.Contracts/OrderItem.cs create mode 100644 samples/src/Contoso.Orders.Contracts/OrderLite.cs create mode 100644 samples/src/Contoso.Orders.Contracts/OrderStatus.cs create mode 100644 samples/src/Contoso.Orders.Database/Contoso.Orders.Database.csproj create mode 100644 samples/src/Contoso.Orders.Database/Data/ref-data.yaml create mode 100644 samples/src/Contoso.Orders.Database/Migrations/20260101-000001-create-orders-schema.sql create mode 100644 samples/src/Contoso.Orders.Database/Migrations/20260101-000101-create-orders-orderstatus.sql create mode 100644 samples/src/Contoso.Orders.Database/Migrations/20260101-000201-create-orders-order.sql create mode 100644 samples/src/Contoso.Orders.Database/Migrations/20260101-000202-create-orders-orderitem.sql create mode 100644 samples/src/Contoso.Orders.Database/Migrations/20260101-000301-create-orders-outbox-tables.sql create mode 100644 samples/src/Contoso.Orders.Database/Program.cs create mode 100644 samples/src/Contoso.Orders.Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql create mode 100644 samples/src/Contoso.Orders.Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql create mode 100644 samples/src/Contoso.Orders.Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql create mode 100644 samples/src/Contoso.Orders.Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql create mode 100644 samples/src/Contoso.Orders.Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql create mode 100644 samples/src/Contoso.Orders.Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql create mode 100644 samples/src/Contoso.Orders.Database/dbex.yaml create mode 100644 samples/src/Contoso.Orders.Infrastructure/Contoso.Orders.Infrastructure.csproj create mode 100644 samples/src/Contoso.Orders.Infrastructure/GlobalUsing.cs create mode 100644 samples/src/Contoso.Orders.Infrastructure/Mapping/OrderMapper.cs create mode 100644 samples/src/Contoso.Orders.Infrastructure/Mapping/OrderStatusMapper.cs create mode 100644 samples/src/Contoso.Orders.Infrastructure/Persistence/Order.cs create mode 100644 samples/src/Contoso.Orders.Infrastructure/Persistence/OrderItem.cs create mode 100644 samples/src/Contoso.Orders.Infrastructure/Persistence/OrderStatus.cs create mode 100644 samples/src/Contoso.Orders.Infrastructure/Repositories/OrderRepository.cs create mode 100644 samples/src/Contoso.Orders.Infrastructure/Repositories/OrdersDbContext.cs create mode 100644 samples/src/Contoso.Orders.Infrastructure/Repositories/OrdersEfDb.cs create mode 100644 samples/src/Contoso.Orders.Infrastructure/Repositories/OrdersOutboxPublisher.cs create mode 100644 samples/src/Contoso.Orders.Infrastructure/Repositories/ReferenceDataRepository.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/Contoso.Orders.Test.Api.csproj create mode 100644 samples/tests/Contoso.Orders.Test.Api/GlobalUsing.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/OrderMutateTests.Create.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/OrderMutateTests.Delete.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/OrderMutateTests.Patch.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/OrderMutateTests.Update.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/OrderMutateTests.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/OtherTests.Health.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/OtherTests.ReferenceData.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/OtherTests.Swagger.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/OtherTests.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/ReadTests.OrderGet.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/ReadTests.cs create mode 100644 samples/tests/Contoso.Orders.Test.Api/appsettings.unittest.json create mode 100644 samples/tests/Contoso.Orders.Test.Common/Contoso.Orders.Test.Common.csproj create mode 100644 samples/tests/Contoso.Orders.Test.Common/Data/data.yaml create mode 100644 samples/tests/Contoso.Orders.Test.Common/TestData.cs create mode 100644 samples/tests/Contoso.Orders.Test.Unit/Contoso.Orders.Test.Unit.csproj create mode 100644 samples/tests/Contoso.Orders.Test.Unit/Controllers/OrderControllerTests.cs create mode 100644 samples/tests/Contoso.Orders.Test.Unit/EntryPoint.cs create mode 100644 samples/tests/Contoso.Orders.Test.Unit/GlobalUsing.cs create mode 100644 samples/tests/Contoso.Orders.Test.Unit/Validators/OrderValidatorTests.cs create mode 100644 samples/tests/Directory.Build.props create mode 100644 samples/tests/NUnitAssemblyInfo.cs diff --git a/.github/INSTRUCTION_AUTHORING.md b/.github/INSTRUCTION_AUTHORING.md new file mode 100644 index 00000000..bfb1a8c9 --- /dev/null +++ b/.github/INSTRUCTION_AUTHORING.md @@ -0,0 +1,133 @@ +--- +description: "Standards for creating and maintaining .instructions.md files" +applyTo: "**/*.instructions.md" +tags: ["authoring", "standards", "instructions", "documentation"] +--- + +# Instruction File Authoring Standards + +When creating or updating any Copilot instruction Markdown file (`.instructions.md`), follow these rules to keep guidance durable, easy to review, and maintainable. + +## Purpose + +Instruction files define scoped AI guidance for specific file types or code areas. They must be predictable and machine-readable. + +## General Authoring Rules + +- Prefer precise, testable directives over vague guidance. +- Avoid overlapping or conflicting instructions across files. +- Keep content reusable and not tied to one temporary task. +- Use imperative language ("Use", "Prefer", "Do not", "Validate"). +- If a rule is scoped to a subset of files, use a path-specific `.instructions.md` file rather than adding it to the global file. +- Do not restate general rules in multiple files unless required for clarity. +- When unsure, produce fewer, clearer rules. + +## Required Format + +All `.instructions.md` files must begin with YAML frontmatter and follow this section order: + +```yaml +--- +description: "Short, concrete summary of what the file governs" +applyTo: "src/**/*.cs" +tags: ["tag1", "tag2"] +--- + +# Purpose + +[One paragraph explaining why this guidance exists] + +## Scope + +[What files and scenarios this applies to] + +## Required Rules + +[MUST / MUST NOT directives, phrased imperatively] + +## Preferred Patterns + +[SHOULD directives, repeated patterns, best practices] + +## Validation + +[Concrete checks: build, test, lint, docs validation] + +## Examples + +[Optional: "Preferred" and "Avoid" patterns] +``` + +## Frontmatter Rules + +- `description` must be one sentence and concrete. +- `applyTo` must use explicit glob patterns with the narrowest safe scope. Examples: + - `src/**/*.cs` — all C# files in source + - `tests/**/*.cs` — all test files + - `**/Program.cs` — program entry points +- `applyTo` must **not** be `**` unless the file is intentionally repository-wide. +- If multiple globs are needed, keep them explicit and readable, separated by semicolons or as separate lines. +- `tags` should reflect the instruction's domain (e.g., `["validation", "dependency-injection", "testing"]`). + +## Content Rules + +- **Required rules** must be phrased as MUST / MUST NOT / SHOULD where possible. +- **Validation section** must include concrete checks (build, test, lint, docs validation) when applicable. +- **Examples** must show "preferred" and "avoid" patterns when useful. +- Do not include secrets, tokens, or environment-specific sensitive values. +- Keep sections short and scannable; each section should fit on one screen without scrolling. + +## Conflict Resolution + +When multiple instruction files might apply to the same file: + +- Repository-wide instructions define defaults. +- Path-specific instruction files define narrower, stronger rules for matching files. +- If a new file would conflict with an existing instruction file, revise the narrow file instead of creating duplicate policy. +- Always document the relationship between overlapping instruction files in cross-references. + +## Cross-Referencing + +Link between instruction files using relative paths or workspace absolute paths: + +- Relative: `../other-instruction.md` +- Absolute: `/.github/instructions/host-setup.instructions.md` +- Always verify links work before committing. + +## Example Structure + +A well-formed instruction file: + +```yaml +--- +description: "Controller conventions for CoreEx API hosts" +applyTo: "**/Controllers/**/*.cs" +tags: ["controllers", "api", "dependency-injection"] +--- + +# Purpose + +Controllers define HTTP endpoints for CoreEx API hosts. This guidance ensures consistent routing, dependency injection, and use of CoreEx WebApi helpers. + +## Scope + +Applies to all `Controllers/` directories in API projects (`.Api` projects). + +## Required Rules + +- MUST inherit from `WebApiControllerBase` or `WebApiControllerBase`. +- MUST use `[Route("api/v1/...")]` and follow REST conventions. +- MUST NOT inject `IUnitOfWork` directly; receive it only through application service dependency. + +## Preferred Patterns + +- Prefer `PostAsync()`, `PutAsync()`, `PatchAsync()` from `WebApi` helpers over manual response building. +- Prefer explicit dependency injection over service locator patterns. +- Prefer PATCH with `application/merge-patch+json` for partial updates. + +## Validation + +- Build the project: `dotnet build` +- Run tests: `dotnet test` +- Check inheritance with: `grep "class.*Controller" Controllers/*.cs` +``` diff --git a/.github/SKILL_AUTHORING.md b/.github/SKILL_AUTHORING.md new file mode 100644 index 00000000..9a30c34d --- /dev/null +++ b/.github/SKILL_AUTHORING.md @@ -0,0 +1,152 @@ +--- +description: "Standards for creating and maintaining SKILL.md files" +applyTo: "**/.github/skills/**/SKILL.md" +tags: ["authoring", "standards", "skills", "documentation"] +--- + +# Skill File Authoring Standards + +When creating or updating any skill (SKILL.md file), follow this organization pattern to keep the main file lean and context-efficient. + +## Purpose + +Skill files guide AI agents through complex, multi-step tasks. They must be discoverable, brief, and provide clear pointers to detailed workflows rather than embedding all content inline. + +## Skill Directory Structure + +``` +skills/{skill-name}/ + SKILL.md # Main entry point (lean, <300 lines) + references/ + workflow.md # Detailed step-by-step workflows + checklists.md # Completion gates, validation criteria + patterns.md # Code patterns, templates, conventions + troubleshooting.md # Known issues and solutions + assets/ + templates/ # Code templates, boilerplate files + examples/ # Real working examples from the repo +``` + +Each file serves one purpose. Keep files focused and scannable. + +## SKILL.md Content Rules + +The main SKILL.md must include: + +1. **YAML frontmatter** with `name`, `description`, `argument-hint`, `tags` +2. **One-sentence purpose statement** — what the skill does and when to use it +3. **"When to Use" section** — bullet points, not prose; concrete triggers +4. **"When Not to Use" section** — prevents misuse and clarifies boundaries +5. **Quick reference** — CLI commands, key steps, or summary table (if applicable) +6. **Pointer to detailed workflows** — "For step-by-step guidance, see `references/workflow.md`" +7. **Key References** — links to relevant instructions, samples, or external docs + +**Maximum: 300 lines** including frontmatter. If you exceed this, move content to `references/`. + +## references/ Subdirectory + +Detailed, procedural content lives in `references/`: + +- **workflow.md** — full step-by-step phases, sub-steps, decision trees; 100–200 lines +- **checklists.md** — completion gates, validation steps, sign-off criteria; one page +- **patterns.md** — recurring code patterns, naming conventions, architectural decisions; reference material +- **troubleshooting.md** — known issues, debugging strategies, error messages and fixes; searchable format + +Each file stays focused on one concern. No file should exceed what is readable in one screen scroll without getting lost. + +## assets/ Subdirectory + +Reusable templates and examples: + +- **assets/templates/** — boilerplate code, project structures (copy-and-fill files) +- **assets/examples/** — concrete working examples from the repo (links only, no duplicates) + +**Important**: Never maintain duplicate copies of sample code. Always link to the canonical source in `samples/` or other repository locations. + +## Cross-Referencing + +When skills reference each other, instructions, or samples: + +- **Relative paths**: `../other-skill/references/...` (for other skills) +- **Absolute workspace paths**: `/.github/instructions/host-setup.instructions.md`, `/samples/src/Contoso.Products.Api/Program.cs` +- Always verify links work before committing +- Prefer workspace-relative links for durability + +## Frontmatter Requirements + +All SKILL.md files must include: + +```yaml +--- +name: skill-id +description: "Concise description of when and why to use this skill" +argument-hint: "What user should provide, e.g. 'Optional: domain name and entities'" +tags: ["tag1", "tag2", "tag3"] +--- +``` + +**Tag guidance**: Reflect the skill's domain and primary use cases. Examples: +- `["scaffolding", "microservice", "code-generation"]` +- `["orchestration", "cli", "distributed-apps"]` +- `["retrofit", "integration", "messaging"]` + +## Lean SKILL.md Example + +```yaml +--- +name: generate-domain +description: "Scaffold a new CoreEx domain across all layers following framework conventions" +argument-hint: "Domain name, entity fields, business rules (optional)" +tags: ["scaffolding", "domain", "code-generation"] +--- + +# Generate Domain + +Guides you through creating a new CoreEx domain from scratch. Asks about entity shape, validation, messaging needs, and generates code tailored to your domain model. + +## When to Use + +- Creating a new bounded context or microservice +- Entity has custom fields, business rules, or complex validation +- You want the agent to reason about conventions and event naming +- Scaffolding Products, Orders, Shopping, or similar sample domains + +## When Not to Use + +- Entity fits a standard template shape — use `/scaffold-domain-from-templates` instead +- Domain already exists — use `/add-capability` to retrofit messaging/integration +- You need just one file (a contract or service) — manually create it + +## Workflow Overview + +1. **Load Context** — examine existing domains and conventions +2. **Gather Inputs** — domain name, entity fields, validation rules, events +3. **Contracts** — define DTOs with source-generation markers +4. **Application Services** — validation, unit-of-work patterns, event publishing +5. **Infrastructure** — repositories, mappers, database access +6. **API Host** — controllers, registration, middleware +7. **Database** — migrations, schema, outbox tables +8. **Tests** — integration and API test scaffolding + +For detailed step-by-step guidance, see [`references/workflow.md`](references/workflow.md). + +## Key References + +- [Application Services Instructions](/.github/instructions/application-services.instructions.md) +- [Contracts Instructions](/.github/instructions/contracts.instructions.md) +- [Host Setup Instructions](/.github/instructions/host-setup.instructions.md) +- [Sample Domains](./samples/src/Contoso.Products/) +- [Roslyn Source Generation](./docs/capabilities.md) +``` + +## Quality Gates + +Before completing a skill: + +- [ ] SKILL.md is <300 lines (excluding examples) +- [ ] All `references/` files exist and are linked +- [ ] All links (relative and absolute) are verified +- [ ] YAML frontmatter is valid +- [ ] No inline workflows or checklists in main SKILL.md +- [ ] Cross-references to instructions are correct +- [ ] Example links point to real, canonical code locations diff --git a/.github/agents/coreex-expert.agent.md b/.github/agents/coreex-expert.agent.md new file mode 100644 index 00000000..ab7c7d82 --- /dev/null +++ b/.github/agents/coreex-expert.agent.md @@ -0,0 +1,47 @@ +--- +name: CoreEx Expert +description: "Use when you need to explain, understand, or decide how CoreEx works. Triggers: explain CoreEx, how does CoreEx, which pattern, which capability, which shape, plan a feature, review a design, compare samples, architecture guidance, coding patterns, layering, host setup, validation, repository conventions, eventing, outbox relay, subscriber design, sample-aligned decisions." +tools: [vscode/getProjectSetupInfo, vscode/installExtension, vscode/memory, vscode/newWorkspace, vscode/resolveMemoryFileUri, vscode/runCommand, vscode/vscodeAPI, vscode/extensions, vscode/askQuestions, execute/runNotebookCell, execute/getTerminalOutput, execute/killTerminal, execute/sendToTerminal, execute/createAndRunTask, execute/runInTerminal, execute/runTests, read/getNotebookSummary, read/problems, read/readFile, read/viewImage, read/terminalSelection, read/terminalLastCommand, agent/runSubagent, edit/createDirectory, edit/createFile, edit/createJupyterNotebook, edit/editFiles, edit/editNotebook, edit/rename, search/changes, search/codebase, search/fileSearch, search/listDirectory, search/textSearch, search/usages, web/fetch, web/githubRepo, web/githubTextSearch, browser/openBrowserPage, browser/readPage, browser/screenshotPage, browser/navigatePage, browser/clickElement, browser/dragElement, browser/hoverElement, browser/typeInPage, browser/runPlaywrightCode, browser/handleDialog, todo] +user-invocable: true +argument-hint: Ask for CoreEx pattern guidance, architecture decisions, or sample-aligned implementation advice. +--- +You are the CoreEx Expert for this repository. + +Your mission: +- Provide authoritative, repo-grounded guidance on CoreEx architecture, patterns, and practices. +- Prefer CoreEx-native primitives and conventions over generic .NET advice. +- Keep recommendations aligned with existing layering and sample implementations. + +Primary sources of truth: +- .github/copilot-instructions.md +- docs/agent-interaction-guide.md +- docs/agent-prompt-recipes.md +- .github/instructions/api-controllers.instructions.md +- .github/instructions/application-services.instructions.md +- .github/instructions/contracts.instructions.md +- .github/instructions/repositories.instructions.md +- .github/instructions/event-subscribers.instructions.md +- .github/instructions/host-setup.instructions.md +- .github/instructions/tests.instructions.md +- .github/instructions/validators.instructions.md + +Operating rules: +- Always inspect current code before recommending changes. +- Give sample-backed guidance where possible. +- Favor smallest safe change and preserve existing structure. +- Separate explanation, plan, and implementation guidance clearly. +- For mutable entities, call out ETag, changelog, validation, and idempotency implications where relevant. +- For messaging, explicitly distinguish API-only, API plus relay, API plus subscribe, and orchestration shapes. + +Decision routing: +- If request is greenfield domain scaffolding, advise using /generate-domain. +- If request is deterministic template scaffolding, advise using /scaffold-domain-from-templates. +- If request is retrofit capability on an existing domain, advise using /add-capability. +- If request is repo mapping or onboarding documentation, advise using acquire-codebase-knowledge. + +Response format: +1. Recommendation. +2. Why this fits CoreEx. +3. Evidence from repo files. +4. Risks and tradeoffs. +5. Minimal next steps. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5d40a046..b3f71dbb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,125 @@ +--- +description: "Project-wide guidelines and conventions for CoreEx development" +tags: ["guidelines", "conventions", "comments"] +--- + # Copilot Instructions -## Project Guidelines -- All code comments should end with a period/fullstop, as they are sentences. \ No newline at end of file +## Purpose +CoreEx is a modular .NET framework for enterprise APIs and distributed services. Favor CoreEx-native primitives, patterns, and extensions over ad-hoc implementations. + +## Repository Shape +- `CoreEx.sln`: main solution for framework + samples. +- `src\`: reusable CoreEx libraries (AspNetCore, Database, EntityFrameworkCore, Events, Validation, DomainDriven, RefData, Caching, etc.). +- `gen\CoreEx.Generator\`: Roslyn source generator for contracts. +- `tests\`: framework-level tests. +- `samples\src\Contoso.*\`: sample domains split by layer/host. +- `samples\aspire\AppHost.cs`: orchestration entrypoint. +- `coreex-starter\`: separate starter template repo — ignore unless user wants starter changes. + +## Build, Test, and Run +- **Build**: `dotnet build CoreEx.sln` +- **Test**: `dotnet test CoreEx.sln` or target specific projects. +- **Single test**: `dotnet test --filter "FullyQualifiedName~"` +- **Samples**: docker-compose infrastructure + dotnet run for Database projects + Aspire AppHost. +- **Linting**: No separate `dotnet format`. Build is the lint pass (nullable, LangVersion=preview, TreatWarningsAsErrors in `src\Directory.Build.props`). +- **Formatting**: 4 spaces for `*.cs`, 2 spaces for `*.json|*.xml|*.yaml|*.props|*.csproj|*.sln|*.sql` per `.editorconfig`. + +## Architecture +- **Two roles**: framework packages (`src\`) + sample reference implementations (`samples\`). +- **Domain layers**: `*.Contracts` → `*.Application` → `*.Infrastructure` → `*.Api`, plus `*.Database`, `*.Outbox.Relay`, `*.Subscribe` (messaging). +- **Sample flow**: Controllers → `WebApi` helpers → Application services (validate + `IUnitOfWork`) → Infrastructure repositories (EF + explicit mappers) → outbox events → relay publishes to Service Bus → subscribers consume. +- **Primary domains**: Products and Shopping complete; Orders WIP. See `samples\README.md` for topology. +- **Aspire**: orchestrates sample hosts in `samples\aspire\Contoso.Aspire\AppHost.cs`. + +## Key Conventions That Matter in This Repo + +### CoreEx-First Patterns +- Prefer CoreEx primitives before introducing external libraries that overlap with framework capabilities. +- Prefer CoreEx exception types (`NotFoundException`, `ValidationException`, `BusinessException`, `ConcurrencyException`, etc.) and CoreEx `Result`/`Result` flows over custom error wrappers. +- Do not introduce AutoMapper unless the user explicitly requests it. Repositories and services use explicit mapping helpers/classes. + +### Contracts and Source Generation +- Contracts are commonly declared as `[Contract] public partial class ...`. +- Mutable contracts often implement `IIdentifier`, `IETag`, and `IChangeLog`. +- Use `[ReadOnly(true)]` for server-managed fields and `[ReferenceData]` for reference-data-backed code properties. +- Canonical casing transformations belong in property setters when already established by the model (for example `Sku` uppercasing in `ProductBase`). +- Favor the existing source-generation approach; do not hand-write members that are meant to be generated. + +### Dependency Injection and Layering +- Services and repositories commonly self-register with `[ScopedService<...>]`. +- Hosts use `AddDynamicServicesUsing()` to discover and register services instead of manually wiring every type. +- Keep interface/implementation layering intact: + - application interfaces live in `Application\Interfaces\` or `Application\Repositories\`; + - infrastructure implementations live in `Infrastructure\`. + +### Application-Service Shape +- Application services follow a repeated pattern: + 1. guard/normalize inputs; + 2. validate with CoreEx validators; + 3. load current state where needed; + 4. run mutations inside `_unitOfWork.ExecuteAsync(...)`; + 5. add `EventData` within the same unit-of-work scope. +- Use exception-based flows for straightforward CRUD-style services. +- Use `Result` pipelines for aggregate-oriented flows and multi-step orchestration, especially in Shopping. +- When working in application or infrastructure code, follow `.github\instructions\application-services.instructions.md`, `.github\instructions\repositories.instructions.md`, and related scoped instruction files. + +### Host Composition +- `Program.cs` files follow a predictable CoreEx host shape: + - `builder.AddHostSettings();` + - `AddExecutionContext()` + - `AddMvcWebApi()` and `AddHttpWebApi()` + - host-specific SQL Server / Redis / Service Bus / outbox registrations + - `PostConfigureAllHealthChecks()` + - NSwag/OpenAPI registration + - OpenTelemetry wiring + - middleware order with `UseCoreExExceptionHandler()`, `UseExecutionContext()`, and host-specific additions such as `UseIdempotencyKey()` or `MapHostedServices()`. +- API hosts, subscriber hosts, and outbox relay hosts intentionally have different startup shapes. Do not collapse them into one generic startup unless the user explicitly asks for that refactor. + +### Controllers and HTTP +- Use CoreEx `WebApi` helpers (`PostAsync`, `PutAsync`, `PatchAsync`, `DeleteAsync`). +- PATCH: `application/merge-patch+json`. +- POST: use `[IdempotencyKey]`. +- OpenAPI/health endpoints standard in hosts. + +### Data and Messaging +- SQL Server + outbox + Azure Service Bus are first-class patterns. +- Shopping: synchronous HTTP reservation + transactional outbox + async event publishing. Preserve this split. + +### Testing +- Framework: NUnit + FluentAssertions. +- Sample: `WithGenericTester` (unit) or `WithApiTester` (API/Subscribe/Relay). +- Integration tests: `Data\data.yaml` (Test.Common) + `Resources\` JSON expectations + `ExpectSqlServerOutboxEvents(...)`. +- Mock downstream HTTP calls; do not assume live APIs. + +### House Rules +- Code comments end with a period/full stop. +- Use `GlobalUsing.cs` per project; do not scatter `using` directives. +- Always use `.ConfigureAwait(false)` in service/repository code. + +## Key Docs to Read Before Large Changes +- `README.md` for repo-level positioning and top-level commands. +- `samples\README.md` for the runnable Contoso architecture and local setup. +- `docs\capabilities.md` for the deeper CoreEx capability/pattern explanations. +- `.github\instructions\*.instructions.md` for area-specific rules when editing `Program.cs`, contracts, application services, repositories, validators, subscribers, or tests. + +## Agent Customizations (Prompts and Skills) + +The following prompts and skills are available in this repository. Type `/` in chat to invoke them. + +| Command | Type | When to use | +|---------|------|-------------| +| `/generate-domain` | Skill | Guided scaffolding of a new CoreEx domain across all 5 layers. Use when your entity has custom fields, business rules, or you want the agent to reason about conventions, validation, and event naming. The agent will ask for inputs and generate code tailored to your domain model. | +| `/add-capability` | Skill | Retrofit an existing CoreEx domain with additional capabilities. Use when a domain already exists and you want to add messaging/integration features such as `Outbox.Relay`, `Subscribe`, Azure Service Bus wiring, or initial subscriber scaffolding without regenerating the domain. | +| `/scaffold-domain-from-templates` | Prompt | Fast, deterministic domain scaffolding by cloning and materializing the canonical templates in `.github\templates\domain\` with placeholder substitution. Use when your entity fits the standard template shape and you want exact output with no creative generation. | +| `/init` | Prompt | Initialize a new CoreEx solution or workspace. | +| `/setup` | Prompt | Configure an existing CoreEx solution with standard tooling and settings. | + +## Guidance for Authoring Instructions and Skills + +When creating or maintaining Copilot instruction files and skills: + +- **Instruction files** (`.instructions.md`) — see [INSTRUCTION_AUTHORING.md](.github/INSTRUCTION_AUTHORING.md) for standards on YAML frontmatter, section order, and content rules. +- **Skill files** (`SKILL.md`) — see [SKILL_AUTHORING.md](.github/SKILL_AUTHORING.md) for the directory structure pattern (`references/`, `assets/`), lean main file rules (<300 lines), and cross-referencing guidelines. + +Both documents define durable patterns for creating guidance that is discoverable, maintainable, and context-efficient. \ No newline at end of file diff --git a/.github/instructions/api-controllers.instructions.md b/.github/instructions/api-controllers.instructions.md new file mode 100644 index 00000000..57d09026 --- /dev/null +++ b/.github/instructions/api-controllers.instructions.md @@ -0,0 +1,122 @@ +--- +applyTo: "**/Controllers/**/*.cs" +description: "API controller conventions for CoreEx: inheritance, routing, dependency injection, CQRS separation, and WebApi integration" +tags: ["controllers", "api", "routing", "cqrs", "dependency-injection"] +--- + +# API Controller Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.AspNetCore` | `WebApi`, `[IdempotencyKey]`, `[Accepts]`, `[ProducesNotFoundProblem]`, `[Query]`, `[Paging]`, `HttpNames`, `app.UseCoreExExceptionHandler()`, `app.UseExecutionContext()`, `app.UseIdempotencyKey()`, `app.MapHealthChecks()` | +| `CoreEx.AspNetCore.NSwag` | `[OpenApiTag]`, `app.UseOpenApi()`, `app.UseSwaggerUi()`, `s.AddCoreExConfiguration()` | +| `CoreEx` | `WebApplicationBuilderExtensions.AddHostSettings()`, `AddExecutionContext()` | + +## Structure + +- Inherit from `ControllerBase`. Never inherit from `Controller` (that brings View support). +- Decorate with `[ApiController]` and `[Route("...")]` on the class. +- Add `[OpenApiTag("TagName")]` to group endpoints in the generated OpenAPI document. +- Inject `WebApi` and the relevant service interface via primary constructor. Guard with `.ThrowIfNull()`. +- Split read operations and write operations into separate controller classes (`ProductController` for mutations, `ProductReadController` for queries) following CQRS conventions. + +```csharp +[ApiController, Route("/api/products"), OpenApiTag("Products")] +public class ProductController(WebApi webApi, IProductService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly IProductService _service = service.ThrowIfNull(); +} +``` + +## Method Signatures + +All action methods return `Task` using the `WebApi` helper. Do not return typed `ActionResult` directly. + +| HTTP Verb | WebApi helper | Notes | +|---|---|---| +| `GET` / `HEAD` | `_webApi.GetAsync(...)` | Use both attributes together | +| `POST` | `_webApi.PostAsync(...)` or `PostWithResultAsync` | Add `[IdempotencyKey]` for safe POST | +| `PUT` | `_webApi.PutAsync(...)` | Include ETag via `IF-MATCH` header | +| `PATCH` | `_webApi.PatchAsync(...)` | Requires `get:` and `put:` lambdas | +| `DELETE` | `_webApi.DeleteAsync(...)` | Returns 204 No Content | + +## Route Parameters + +Validate route parameters inline using `.Required()`: + +```csharp +[HttpGet("{id}"), HttpHead("{id}")] +public Task GetAsync(string id) => + _webApi.GetAsync(Request, (_, _) => _service.GetAsync(id.Required())); +``` + +## POST — Create with Location Header + +Use `ro.WithLocationUri(...)` to set the `Location` response header: + +```csharp +[HttpPost] +[Accepts] +[ProducesResponseType(201)] +[IdempotencyKey] +public Task PostAsync() => _webApi.PostAsync(Request, (ro, _) => +{ + ro.WithLocationUri(p => new Uri($"/api/products/{p.Id}", UriKind.Relative)); + return _service.CreateAsync(ro.Value); +}); +``` + +## PATCH — Merge-Patch + +Always supply both `get:` and `put:` delegates. PATCH merges the incoming patch document over the fetched entity and calls `put`: + +```csharp +[HttpPatch("{id}")] +[Accepts(HttpNames.MergePatchJsonMediaTypeName)] +public Task PatchAsync(string id) => _webApi.PatchAsync(Request, + get: (ro, _) => _service.GetAsync(id.Required()), + put: (ro, _) => _service.UpdateAsync(ro.Value.Adjust(p => p.Id = id))); +``` + +## Query Endpoints + +Expose `QueryArgs` and `PagingArgs` via `[Query]` and `[Paging]` action attributes. Access them via the request options object (`ro`): + +```csharp +[HttpGet] +[Query(supportsOrderBy: true), Paging(supportsCount: true)] +public Task QueryAsync() => + _webApi.GetAsync(Request, (ro, _) => _service.QueryAsync(ro.QueryArgs, ro.PagingArgs)); +``` + +## Reference Data Endpoints + +Delegate to `ReferenceDataOrchestrator.Current.GetWithFilterAsync()`. Support `codes`, `text`, and `isIncludeInactive` filter parameters: + +```csharp +[HttpGet("categories")] +public Task GetCategoriesAsync([FromQuery] IEnumerable? codes = default, string? text = default) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync(codes, text, ro.IsIncludeInactive, ct)); +``` + +## Response Metadata Attributes + +Decorate actions with standard response metadata attributes: + +- `[ProducesResponseType(StatusCodes.Status201Created)]` +- `[ProducesNotFoundProblem()]` on GET/PUT/PATCH/DELETE where not-found is expected. +- `[Accepts]` to document the consumed media type. + +## Result-Based Services + +When the service returns `Result` (Shopping-style domain services), use the `PostWithResultAsync` / `GetWithResultAsync` variants: + +```csharp +[HttpPost("{basketId}/checkout")] +public Task CheckoutAsync(string basketId) => + _webApi.PostWithResultAsync(Request, (_, _) => + _service.CheckoutAsync(basketId.Required()), HttpStatusCode.OK); +``` diff --git a/.github/instructions/application-services.instructions.md b/.github/instructions/application-services.instructions.md new file mode 100644 index 00000000..85e064b2 --- /dev/null +++ b/.github/instructions/application-services.instructions.md @@ -0,0 +1,172 @@ +--- +applyTo: "**/Application/**/*.cs" +description: "Application service conventions: ScopedService registration, dependency injection, validation, unit of work patterns, and business logic structure" +tags: ["services", "application-layer", "dependency-injection", "validation", "unit-of-work"] +--- + +# Application Service Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx` | `[ScopedService]`, `IUnitOfWork`, `Runtime`, `NotFoundException`, `BusinessException`, `ValidationException`, `.ThrowIfNull()`, `.ThrowIfNullOrEmpty()` | +| `CoreEx.Data` | `DataResult`, `ItemsResult`, `QueryArgs`, `PagingArgs` | +| `CoreEx.Events` | `EventData`, `EventAction` | +| `CoreEx.Validation` | `Validator`, `.ValidateAndThrowAsync()`, `.ValidateWithResultAsync()` | +| `CoreEx.Results` | `Result`, `Result.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()` | +| `CoreEx.RefData` | `ReferenceDataOrchestrator` | + +## Structure + +- Define a public interface (e.g., `IProductService`) in the Application project. +- Implement with `[ScopedService]` attribute so it registers itself via dynamic DI — no manual registration required. +- Inject dependencies via primary constructor and guard every injected parameter with `.ThrowIfNull()`. + +```csharp +[ScopedService] +public class ProductService(IUnitOfWork unitOfWork, IProductRepository repository) : IProductService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork.ThrowIfNull(); + private readonly IProductRepository _repository = repository.ThrowIfNull(); +} +``` + +## Guard Clauses + +Use CoreEx null/empty guards at the top of each method before any logic: + +```csharp +public async Task UpdateAsync(Product product) +{ + product.ThrowIfNull(); + product.Id.ThrowIfNullOrEmpty(); + // ... +} +``` + +## Validation + +Call the validator before any persistence operations. Throw on first error set: + +```csharp +await ProductValidator.Default.ValidateAndThrowAsync(product); +``` + +For `Result` style, use `ValidateWithResultAsync` and propagate with `ThenAs`: + +```csharp +var result = await Result.GoAsync(() => MyValidator.Default.ValidateWithResultAsync(value)); +if (result.IsFailure) return result.AsResult(); +``` + +## Not Found Handling + +After loading an entity, throw immediately if it does not exist: + +```csharp +var current = await _repository.GetAsync(id).ConfigureAwait(false); +NotFoundException.ThrowIfDefault(current); +``` + +## Business Rule Exceptions + +Use `BusinessException` for domain rule violations that are the caller's fault but are not validation errors: + +```csharp +if (!product.IsInactive) + throw new BusinessException("A product must first be deactivated before it can be deleted."); +``` + +## Unit of Work and Events + +Wrap all side-effectful database operations in `_unitOfWork.ExecuteAsync(...)`. Add integration events inside that scope so event and data writes are atomic: + +```csharp +return await _unitOfWork.ExecuteAsync(async () => +{ + var dr = await _repository.CreateAsync(product).ConfigureAwait(false); + return dr.WhereMutated(v => + _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created))); +}).ConfigureAwait(false); +``` + +- `WhereMutated(action)` — executes `action` only when the data result has a mutation; add the event inside this callback. +- `EventData.CreateEventWith(value, action)` — creates a typed event from the entity. +- `EventAction.Created`, `EventAction.Updated`, `EventAction.Deleted` — use the standard constants. + +For delete where the entity value is gone, carry the ID via `.WithKey(id)`: + +```csharp +_unitOfWork.Events.Add( + EventData.CreateEventWith(default, EventAction.Deleted).WithKey(id)); +``` + +## Result Style (Domain-Aggregate Services) + +For services operating on DDD aggregates (e.g., Shopping Basket), use `Result` chains instead of exceptions for expected failures. Compose with `Result.GoAsync`, `.ThenAs`, `.ThenAsAsync`: + +```csharp +public Task> CreateAsync(string customerId) +{ + var aggregate = Domain.Basket.CreateNew(customerId.ThrowIfNullOrEmpty()); + + return _unitOfWork.ExecuteAsync(async () => + { + var br = await _repository.CreateAsync(aggregate).ConfigureAwait(false); + return br.ThenAs(b => + { + var contract = BasketMapper.Map(b); + _unitOfWork.Events.Add(EventData.CreateEventWith(contract, EventAction.Created)); + return contract; + }); + }); +} +``` + +For multi-step orchestration with early exit: + +```csharp +var pr = await Result.GoAsync(() => SomeValidator.Default.ValidateWithResultAsync(input)) + .ThenAsAsync(v => _someAdapter.EnsureExistsAsync(v.Id!)); + +if (pr.IsFailure) + return pr.AsResult(); +``` + +## Read Services + +Split read operations into a separate service with an `IXxxReadService` interface when the project follows CQRS. Read services do not use UnitOfWork and do not publish events: + +```csharp +[ScopedService] +public class ProductReadService(IProductRepository repository) : IProductReadService +{ + private readonly IProductRepository _repository = repository.ThrowIfNull(); + + public Task GetAsync(string id) => _repository.GetAsync(id); + public Task> QueryAsync(QueryArgs? query, PagingArgs? paging) + => _repository.QueryAsync(query, paging); +} +``` + +## Anti-Corruption Layer (Adapters) + +When a service needs to call another domain's API, inject an adapter interface (e.g., `IProductAdapter`) rather than calling `HttpClient` directly. Implement the adapter in the Infrastructure layer using `ProductsHttpClient`: + +```csharp +// Application layer — interface only +public interface IProductAdapter +{ + Task GetAsync(string id); + Task ReserveInventoryAsync(MovementRequest request); +} + +// Infrastructure layer — implementation +[ScopedService] +public class ProductAdapter(ProductsHttpClient httpClient) : IProductAdapter { ... } +``` + +## ConfigureAwait + +Always call `.ConfigureAwait(false)` on every `await` inside service and repository methods. diff --git a/.github/instructions/contracts.instructions.md b/.github/instructions/contracts.instructions.md new file mode 100644 index 00000000..fd1d256c --- /dev/null +++ b/.github/instructions/contracts.instructions.md @@ -0,0 +1,156 @@ +--- +applyTo: "**/Contracts/**/*.cs" +description: "Contract (DTO) conventions: source generation, marker attributes, reference data, ETag, and ChangeLog support" +tags: ["contracts", "dto", "source-generation", "reference-data", "etag"] +--- + +# Contract (DTO) Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx` | `[Contract]`, `IIdentifier`, `ICompositeKey`, `IETag`, `IChangeLog`, `ChangeLog`, `[ReadOnly]`, `[Localization]` | +| `CoreEx.RefData` | `ReferenceData`, `ReferenceDataCollection`, `[ReferenceData]`, `[ReferenceData]`, `ReferenceDataSortOrder` | +| `CoreEx.Generator` | Roslyn source generator — add as `OutputItemType="Analyzer" ReferenceOutputAssembly="false"` | + +```xml + + + + + +``` + +## Source Generation + +Mark contract classes with the `[Contract]` attribute and declare them `partial`. Roslyn source generation fills in serialization, equality, and change-tracking code. Never manually implement the generated members. + +```csharp +[Contract] +public partial class Product : ProductBase, IETag, IChangeLog { } +``` + +## Interfaces + +Implement the appropriate CoreEx marker interfaces depending on the entity's behavior: + +| Interface | When to use | +|---|---| +| `IIdentifier` | Entity has a single primary key | +| `ICompositeKey` | Entity has a multi-part key | +| `IETag` | Entity participates in optimistic concurrency / IF-MATCH | +| `IChangeLog` | Entity records created/updated audit metadata | + +All three are typically combined on mutable entities: + +```csharp +[Contract] +public partial class Product : ProductBase, IIdentifier, IETag, IChangeLog +{ + [ReadOnly(true)] + public string? Id { get; set; } + + [ReadOnly(true)] + public string? ETag { get; set; } + + [ReadOnly(true)] + public ChangeLog? ChangeLog { get; set; } +} +``` + +## ReadOnly Properties + +Decorate server-assigned properties with `[ReadOnly(true)]` to signal that clients cannot supply them. Common examples: `Id`, `ETag`, `ChangeLog`, `CategoryCode` (derived from SubCategory). + +## Reference Data Properties + +Use `[ReferenceData]` on code properties that back a reference data relationship. Declare the property `partial` so source generation can emit the navigation accessor: + +```csharp +[ReferenceData] +[Localization("Sub-category")] +public partial string? SubCategoryCode { get; set; } + +[ReferenceData] +[Localization("Unit-of-measure")] +public partial string? UnitOfMeasureCode { get; set; } +``` + +The generated code exposes a strongly-typed `SubCategory` property alongside the raw code. + +## Localization Labels + +Decorate properties with `[Localization("Human label")]` when the default property name would produce a poor validation error message: + +```csharp +[Localization("Sub-category")] +public partial string? SubCategoryCode { get; set; } +// Validation error: "Sub-category is required." (not "SubCategoryCode is required.") +``` + +## Inheritance for Shared Fields + +Extract shared fields into an abstract `XxxBase` class when multiple contracts share the same core properties. This keeps validation and mapping code DRY: + +```csharp +[Contract] +public abstract partial class ProductBase : IIdentifier +{ + public string? Id { get; set; } + public string? Sku { get; set; } + public string? Text { get; set; } + public decimal Price { get; set; } +} + +[Contract] +public partial class Product : ProductBase, IETag, IChangeLog { /* additions only */ } + +[Contract] +public partial class ProductLite : ProductBase { /* subset for list queries */ } +``` + +## Reference Data Contracts + +Reference data types inherit from `ReferenceData` and use `[ReferenceData]` attribute. Pair each type with a typed collection class: + +```csharp +[ReferenceData] +public partial class Category : ReferenceData { } + +public class CategoryCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } +``` + +For reference data that carries additional fields (e.g., `UnitOfMeasure.Scale`), add those as plain properties and mark computed ones with `[JsonIgnore]`: + +```csharp +[ReferenceData] +public partial class UnitOfMeasure : ReferenceData +{ + public int Scale { get; init; } + + [JsonIgnore] + public int Precision => 16 - Scale; +} +``` + +## Casing Transformations + +Apply casing transforms in the property setter, not in the validator, when a field has a canonical form: + +```csharp +public string? Sku { get => field; set => field = value?.ToUpper(); } +``` + +## JsonIgnore + +Use `[JsonIgnore]` for computed or internal properties that must not appear in the API response or request body: + +```csharp +[JsonIgnore] +public bool IsQuantityValidForKind => KindCode switch { ... }; +``` + +## No Business Logic in Contracts + +Contracts are data transfer objects. Keep them free of domain rules, validation logic, and service calls. Computed helpers (like the `IsQuantityValidForKind` example above) are acceptable read-only shorthands but must not mutate state. diff --git a/.github/instructions/database-project.instructions.md b/.github/instructions/database-project.instructions.md new file mode 100644 index 00000000..8c932226 --- /dev/null +++ b/.github/instructions/database-project.instructions.md @@ -0,0 +1,90 @@ +--- +applyTo: "**/*.Database/**" +description: "Database project structure: migrations, DbEx YAML, reference data seeding, stored procedures, and outbox support" +tags: ["database", "migrations", "dbex", "reference-data", "outbox"] +--- + +# Database Project Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `DbEx.SqlServer` | `SqlServerMigrationConsole`, migration host runner, YAML data parsing | +| `CoreEx.Database` | `SqlStatement` helpers, outbox integration support | + +## Project Shape + +Each domain database project must contain: + +- `Program.cs` with `ConfigureMigrationArgs`. +- `dbex.yaml` listing reference and transactional tables. +- `Migrations/` ordered SQL scripts. +- `Schema/Stored Procedures/` outbox relay procedures. +- `Data/ref-data.yaml` seed reference data. + +## Program.cs Pattern + +- Use `SqlServerMigrationConsole.Create(defaultConnectionString)`. +- Configure `.IncludeExtendedSchemaScripts()`. +- Add default ref-data columns: + - `SortOrder = 0`. + - `Scale = 0`. +- Set `DataResetFilterPredicate` to the domain schema only. + +```csharp +args.DataResetFilterPredicate = ts => ts.Schema == "{Domain}"; +``` + +## Migration Naming + +Use timestamp-prefixed, ordered scripts: + +- `20260101-000001-create-{domain}-schema.sql`. +- `20260101-000101-create-{domain}-.sql`. +- `20260101-000201-create-{domain}-.sql`. +- `20260101-000202-create-{domain}-.sql`. +- `20260101-000301-create-{domain}-outbox-tables.sql`. + +## SQL Conventions + +- Wrap each migration in `BEGIN TRANSACTION ... COMMIT TRANSACTION`. +- Use explicit schema-qualified names (`[{Domain}].[Table]`). +- Include `CreatedBy`, `CreatedOn`, `UpdatedBy`, `UpdatedOn` columns on aggregate and reference-data tables. +- Use `TIMESTAMP`/`ROWVERSION` for concurrency columns mapped to `ETag`. +- Add FK constraints for child tables. + +## Outbox Requirements + +Create both tables: + +- `[{Domain}].[Outbox]`. +- `[{Domain}].[OutboxLease]`. + +Create all required procedures: + +- `spOutboxEnqueue.g.sql`. +- `spOutboxLeaseAcquire.g.sql`. +- `spOutboxLeaseRelease.g.sql`. +- `spOutboxBatchClaim.g.sql`. +- `spOutboxBatchComplete.g.sql`. +- `spOutboxBatchCancel.g.sql`. + +Procedure naming and schema must match the domain schema and outbox publisher configuration in Infrastructure. + +## Data Seed Conventions + +- Keep reference data in `Data/ref-data.yaml`. +- Root node should be the schema/domain name. +- Use concise status/code values with clear text. +- Include required reference data used by validators. + +Example: + +```yaml +Orders: + - $^OrderStatus: + - P: Pending + - C: Confirmed + - X: Cancelled +``` diff --git a/.github/instructions/event-subscribers.instructions.md b/.github/instructions/event-subscribers.instructions.md new file mode 100644 index 00000000..83199423 --- /dev/null +++ b/.github/instructions/event-subscribers.instructions.md @@ -0,0 +1,122 @@ +--- +applyTo: "**/Subscribe/**/*.cs" +description: "Event subscriber conventions: SubscribedBase inheritance, Service Bus integration, error handling, and scoped service registration" +tags: ["subscribers", "messaging", "service-bus", "event-handling", "integration"] +--- + +# Event Subscriber Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.Azure.Messaging.ServiceBus` | `SubscribedBase`, `[Subscribe(...)]`, `EventSubscriberArgs`, `ErrorHandler`, `ErrorHandling`, `ServiceBusSessionReceiverOptions`, `AzureServiceBusReceiving()`, `.WithSessionReceiver()`, `.WithSubscribedSubscriber()`, `.WithHostedService()` | +| `CoreEx.Events` | `EventData`, `EventData.Key`, `.ToData()` | +| `CoreEx.Results` | `Result`, `Result.Success` | +| `CoreEx` | `[ScopedService]`, `.ThrowIfNull()`, `.Required()` | + +## Structure + +- Subscriber classes inherit from `SubscribedBase`. +- Decorate with `[ScopedService]` and `[Subscribe("subject.pattern")]`. +- Inject service dependencies via constructor and guard with `.ThrowIfNull()`. +- Override `OnReceiveAsync` — return `Result.Success` on completion. + +```csharp +[ScopedService, Subscribe("contoso.products.reservation.confirm")] +public class ReservationConfirmSubscriber : SubscribedBase +{ + private readonly IMovementService _service; + + public ReservationConfirmSubscriber(IMovementService service) + { + _service = service.ThrowIfNull(); + } + + protected async override Task OnReceiveAsync( + EventData @event, + EventSubscriberArgs args, + CancellationToken cancellationToken = default) + { + var referenceId = @event.Key.Required(); + await _service.ConfirmReservationAsync(referenceId).ConfigureAwait(false); + return Result.Success; + } +} +``` + +## Subject Naming + +Use dot-separated lowercase subject strings in the format: + +``` +{solution}.{domain}.{entity}.{action} +``` + +Examples: +- `contoso.products.product.created.v1` +- `contoso.products.product.updated.v1` +- `contoso.products.reservation.confirm` +- `contoso.products.reservation.cancel` +- `contoso.shopping.basket.checkedout.v1` + +Versioned event subjects (published from the domain outbox) include `.v1`. Command subjects (point-to-point) do not include a version suffix. + +## Error Handling + +Define a static `ErrorHandler` when certain known exceptions should be swallowed or handled differently. Assign it to `this.ErrorHandler` in the constructor: + +```csharp +internal static readonly ErrorHandler DefaultErrorHandler = new ErrorHandler() + .Add(ex => + ex.ErrorCode == "pending-reservation-not-found" + ? ErrorHandling.CompleteAsInformation + : null); + +public ReservationConfirmSubscriber(IMovementService service) +{ + _service = service.ThrowIfNull(); + ErrorHandler = DefaultErrorHandler; +} +``` + +- `ErrorHandling.CompleteAsInformation` — consume the message without error; log as informational. +- `null` return — fall through to default error handling (retry / dead-letter). + +Share the same `ErrorHandler` instance across related subscribers (e.g., both Confirm and Cancel use the same handler). + +## Accessing Event Data + +Extract the key and optional data from `EventData`: + +```csharp +var referenceId = @event.Key.Required(); // Message key (partition/session key) +var data = @event.ToData(); // Deserialize typed payload +``` + +Use `.Required()` on the key to throw a descriptive error if it is missing rather than a null reference exception. + +## Service Bus Registration + +In `Program.cs`, register subscribers using `AddSubscribersUsing()` to discover all subscriber classes in the same assembly: + +```csharp +builder.Services.AddSubscribedManager((_, c) => c.AddSubscribersUsing()); + +builder.Services.AzureServiceBusReceiving() + .WithSessionReceiver(_ => + { + var o = ServiceBusSessionReceiverOptions.CreateForTopicSubscription(); + o.SessionProcessorOptions.MaxConcurrentSessions = 4; + return o; + }) + .WithSubscribedSubscriber() + .WithHostedService() + .Build(); +``` + +## Integration-Events Only + +- Subscribers react to integration events published over the broker. +- Do not use MediatR or in-process domain event dispatchers. +- Keep subscriber logic thin — delegate to the Application service layer; do not embed business logic directly in the subscriber. diff --git a/.github/instructions/host-setup.instructions.md b/.github/instructions/host-setup.instructions.md new file mode 100644 index 00000000..0e138e2f --- /dev/null +++ b/.github/instructions/host-setup.instructions.md @@ -0,0 +1,123 @@ +--- +applyTo: "**/Program.cs" +description: "Host setup conventions for Program.cs: API host, Subscribe host, middleware, service registration, and distributed caching" +tags: ["program-cs", "host-setup", "middleware", "dependency-registration", "caching"] +--- + +# Host Setup Conventions (Program.cs) + +## NuGet / Project References by Host Type + +### API Host + +| Package | Key registrations | +|---|---| +| `CoreEx.AspNetCore` | `AddMvcWebApi()`, `AddHttpWebApi()`, `AddExecutionContext()`, `UseCoreExExceptionHandler()`, `UseExecutionContext()`, `UseIdempotencyKey()`, `MapHealthChecks()` | +| `CoreEx.AspNetCore.NSwag` | `AddOpenApiDocument()`, `AddCoreExConfiguration()`, `UseOpenApi()`, `UseSwaggerUi()` | +| `CoreEx.Caching.FusionCache` | `AddFusionCache()`, `AddFusionHybridCache()`, `AddDefaultCacheKeyProvider()`, `AddHybridCacheIdempotencyProvider()` | +| `CoreEx.Database.SqlServer` | `AddSqlServerDatabase()`, `AddSqlServerUnitOfWork()`, `AddSqlServerOutboxPublisher()`, `AddSqlServerClient("SqlServer")` | +| `CoreEx.EntityFrameworkCore` | `AddDbContext()`, `AddEfDb()` | +| `CoreEx.Events` | `AddEventFormatter()` | +| `CoreEx.RefData` | `AddReferenceDataOrchestrator()` | +| `Aspire.StackExchange.Redis.DistributedCaching` | `AddRedisDistributedCache("redis")` | +| `FusionCache.Backplane.StackExchangeRedis` | `RedisBackplane`, `RedisBackplaneOptions` | +| `OpenTelemetry.*` | `WithCoreExTelemetry()`, `WithCoreExSqlServerTelemetry()`, `UseOtlpExporter()` | + +### Subscribe Host + +All of the above **plus**: + +| Package | Key registrations | +|---|---| +| `CoreEx.Azure.Messaging.ServiceBus` | `AddAzureServiceBusClient("ServiceBus")`, `AddSubscribedManager()`, `AzureServiceBusReceiving()`, `AddHostedServiceManager()`, `MapHostedServices()`, `WithCoreExServiceBusTelemetry()` | + +### Outbox Relay Host + +| Package | Key registrations | +|---|---| +| `CoreEx.AspNetCore` | `AddMvcWebApi()`, `AddHttpWebApi()`, `AddExecutionContext()`, `UseCoreExExceptionHandler()` | +| `CoreEx.Database.SqlServer` | `AddSqlServerDatabase()`, `AddSqlServerUnitOfWork()`, `AddSqlServerOutboxRelay()`, `AddSqlServerOutboxRelayHostedService()` | +| `CoreEx.Azure.Messaging.ServiceBus` | `AddAzureServiceBusClient()`, `AddAzureServiceBusPublisher()`, `ServiceBusSessionStrategy` | +| `OpenTelemetry.*` | `WithCoreExTelemetry()`, `WithCoreExSqlServerTelemetry()`, `WithCoreExServiceBusTelemetry()`, `UseOtlpExporter()` | + +--- + +There are three host types in a CoreEx solution. Each follows the same skeleton but adds type-specific registrations. + +--- + +## Shared Skeleton (All Host Types) + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.AddHostSettings(); +builder.Services + .AddExecutionContext() + .AddMvcWebApi() + .AddHttpWebApi(); + +// ... type-specific registrations follow ... + +builder.Services.PostConfigureAllHealthChecks(); +builder.Services.AddControllers(); +builder.Services.AddOpenApiDocument(s => { + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); +}); + +var app = builder.Build(); +app.UseCoreExExceptionHandler(); +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.UseExecutionContext(); +app.MapControllers(); +app.UseOpenApi(); +app.UseSwaggerUi(); +app.MapHealthChecks(); +app.Run(); +``` + +--- + +## API Host + +Add: reference data, SQL Server, FusionCache, outbox publisher, idempotency. + +Key registrations: +- `.AddReferenceDataOrchestrator()` +- `.AddDynamicServicesUsing<...>()` +- `.AddFusionCache()` + `.WithRegisteredDistributedCache()` + `.WithBackplane(...)` +- `.AddSqlServerDatabase()` + `.AddSqlServerUnitOfWork()` + `.AddSqlServerOutboxPublisher()` +- `.AddEventFormatter()` +- Middleware: `.UseIdempotencyKey()` after `.UseExecutionContext()` + +--- + +## Subscribe Host + +All of API host **plus**: + +Key registrations: +- `.AddHostedServiceManager()` +- `.AddSubscribedManager((_, c) => c.AddSubscribersUsing())` +- `.AzureServiceBusReceiving()` → `.WithSessionReceiver(...)` → `.WithSubscribedSubscriber()` → `.WithHostedService()` → `.Build()` + +Middleware addition: +- `app.MapHostedServices()` (after `.MapHealthChecks()`) + +--- + +## Outbox Relay Host + +Minimal: SQL Server, Service Bus publisher, relay background service only. + +Key registrations: +- `.AddHostedServiceManager()` +- `.AddSqlServerOutboxRelay((_, c) => { ... })` +- `.AddSqlServerOutboxRelayHostedService()` +- `.AddAzureServiceBusPublisher((_, c) => { c.SessionIdStrategy = ...; })` + +No: reference data, FusionCache, idempotency, controllers, Swagger. + +Middleware: minimal (no `.MapControllers()`, no `.UseOpenApi()`). diff --git a/.github/instructions/repositories.instructions.md b/.github/instructions/repositories.instructions.md new file mode 100644 index 00000000..75e4b9d7 --- /dev/null +++ b/.github/instructions/repositories.instructions.md @@ -0,0 +1,162 @@ +--- +applyTo: "**/Infrastructure/**/*.cs" +description: "Repository and infrastructure conventions: EFCore, ADO.NET patterns, ScopedService registration, and data access layers" +tags: ["repositories", "infrastructure", "data-access", "efcore", "ado-net"] +--- + +# Repository & Infrastructure Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx` | `[ScopedService]`, `.ThrowIfNull()` | +| `CoreEx.EntityFrameworkCore` | `EfDb`, `EfDbSet`, `.GetAsync()`, `.CreateAsync()`, `.UpdateAsync()`, `.DeleteAsync()`, `.GetWithResultAsync()`, `.CreateWithResultAsync()`, `.UpdateWithResultAsync()` | +| `CoreEx.Database.SqlServer` | SQL Server outbox publisher, ADO.NET command/parameter helpers | +| `CoreEx.Data` | `DataResult`, `ItemsResult`, `QueryArgsConfig`, `QueryFilterOperator`, `.Where(parsed)`, `.OrderBy(parsed)`, `.ToMappedItemsResultAsync()` | +| `CoreEx.Results` | `Result`, `.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()` | + +## Structure + +- Define the interface in the Application project under `Application/Repositories/`. +- Implement in the Infrastructure project. Register with `[ScopedService]` attribute. +- Inject the EF `*EfDb` (or ADO.NET database) via primary constructor and guard with `.ThrowIfNull()`. + +```csharp +[ScopedService] +public class ProductRepository(ProductsEfDb ef) : IProductRepository +{ + private readonly ProductsEfDb _ef = ef.ThrowIfNull(); +} +``` + +## Return Types + +| Operation | Return type | Notes | +|---|---|---| +| Single entity lookup | `Task` | Returns `null` when not found; service checks | +| Create / Update | `Task>` | Includes mutation flag for event decisions | +| Delete | `Task` | Carries mutation flag only | +| Collection query | `Task>` | Includes items + optional total count | +| Domain aggregate | `Task>` | Shopping-style — wraps `DataResult` with mapping | + +## Dynamic Query Configuration + +Define a `static readonly QueryArgsConfig _queryConfig` per repository for OData-style filtering and ordering. Build it once at class (not method) level: + +```csharp +private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(filter => filter + .WithDefaultModelPrefix("Product") + .AddField(nameof(ProductBase.Sku), c => c + .WithOperators(QueryFilterOperator.EqualityOperators | QueryFilterOperator.StartsWith) + .AsUpperCase()) + .AddField(nameof(ProductBase.Text), c => c + .WithOperators(QueryFilterOperator.StringFunctions) + .AsUpperCase()) + .AddReferenceDataField(nameof(ProductBase.Category), "CategoryCode", + c => c.WithModelPrefix(null))) + .WithOrderBy(orderby => orderby + .WithDefaultModelPrefix("Product") + .AddField(nameof(ProductBase.Sku), c => c.WithDefault().WithAlwaysInclude()) + .AddField(nameof(ProductBase.Text)) + .AddField(nameof(ProductBase.Brand))); +``` + +Apply in the query method: + +```csharp +public async Task> QueryAsync(QueryArgs? query, PagingArgs? paging) +{ + var parsed = _queryConfig.Parse(query).ThrowOnError(); + + var products = _ef.Products.Model.Query(); + + return await products + .Where(parsed) + .OrderBy(parsed) + .ToMappedItemsResultAsync(x => new ProductLite + { + Id = x.Product.Id, + Sku = x.Product.Sku, + Text = x.Product.Text, + }, paging); +} +``` + +## EF Delegate Shortcuts + +Use the built-in EF delegate methods for single-entity CRUD — do not write raw `DbContext` queries for simple operations: + +```csharp +public Task GetAsync(string id) => _ef.Products.GetAsync(id); +public Task> CreateAsync(Product product) => _ef.Products.CreateAsync(product); +public Task> UpdateAsync(Product product) => _ef.Products.UpdateAsync(product); +public Task DeleteAsync(string id) => _ef.Products.DeleteAsync(id); +``` + +## Domain-Aggregate Repositories (Result Pattern) + +For Shopping-style aggregate repositories, chain `Result` operations using `.GoAsync` / `.ThenAs` / `.ThenAsAsync`. Map between persistence models and domain aggregates using explicit mappers: + +```csharp +public Task> GetAsync(string id) => Result + .GoAsync(() => _ef.Baskets.GetWithResultAsync(id)) + .ThenAs(model => BasketMapper.Map(model)); + +public Task> CreateAsync(Domain.Basket basket) => Result + .Go(() => + { + var model = new Persistence.Basket(); + BasketIntoMapper.MapInto(basket, model); + return SynchronizeItems(basket, model); + }) + .ThenAsAsync(model => _ef.Baskets.CreateWithResultAsync(model)) + .ThenAs(b => BasketMapper.Map(b)); +``` + +## Explicit Mapping — No AutoMapper + +Write explicit mapper classes or static methods. Do not introduce AutoMapper: + +```csharp +public static class BasketMapper +{ + public static Domain.Basket Map(Persistence.Basket model) + { + // explicit property assignment + } +} + +public static class BasketIntoMapper +{ + public static void MapInto(Domain.Basket src, Persistence.Basket dest) + { + dest.Id = src.Id; + dest.CustomerId = src.CustomerId; + // ... + } +} +``` + +## ConfigureAwait + +Always call `.ConfigureAwait(false)` on every awaited call inside repository methods. + +## HTTP Client Adapters + +Infrastructure adapters that wrap downstream APIs should use a typed `HttpClient` registered under a named key. The adapter interface lives in Application; the implementation lives in Infrastructure: + +```csharp +[ScopedService] +public class ProductAdapter(ProductsHttpClient httpClient) : IProductAdapter +{ + private readonly ProductsHttpClient _httpClient = httpClient.ThrowIfNull(); + + public Task GetAsync(string id) + => _httpClient.GetAsync($"api/products/{id}"); + + public Task ReserveInventoryAsync(MovementRequest request) + => _httpClient.PostAsync("api/inventory/reserve", request); +} +``` diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md new file mode 100644 index 00000000..871b453e --- /dev/null +++ b/.github/instructions/tests.instructions.md @@ -0,0 +1,186 @@ +--- +applyTo: "**/*.Test*/**/*.cs" +description: "Test conventions: test project types (Api/Unit/Subscribe/Relay), base classes, one-time setup patterns, and assertion helpers" +tags: ["testing", "unit-tests", "integration-tests", "test-helpers", "nunit"] +--- + +# Test Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.UnitTesting` | `WithApiTester`, `WithGenericTester`, `Test.Http()`, `Test.Http()`, `Test.MigrateSqlServerDataAsync()`, `Test.ClearFusionCacheAsync()`, `Test.UseExpectedSqlServerOutboxPublisher()`, `Test.UseExpectedAzureServiceBusPublisher()`, `Test.ReplaceHttpClientFactory()`, `.ExpectIdentifier()`, `.ExpectETag()`, `.ExpectChangeLogCreated()`, `.ExpectJsonFromResource()`, `.ExpectSqlServerOutboxEvents()`, `.ExpectNoSqlServerOutboxEvents()`, `.AssertCreated()`, `.AssertOK()`, `.AssertBadRequest()`, `.AssertErrors()`, `.AssertJsonFromResource()`, `.AssertLocationHeader()`, `Test.Scoped()` | +| `UnitTestEx` | `MockHttpClientFactory`, `MockHttpClientRequest`, `.WithJsonResourceBody()`, `.WithAnyBody()`, `.Respond.With()`, `.Respond.WithJsonResource()`, `.Verify()` | +| `NUnit` | `[TestFixture]`, `[Test]`, `[OneTimeSetUp]` | +| `AwesomeAssertions` | `.Should()`, `.Be()` | + +## Project Types + +| Project suffix | Base class | Scope | +|---|---|---| +| `*.Test.Api` | `WithApiTester` | Full integration — real DB, cache, events, HTTP | +| `*.Test.Unit` | `WithGenericTester` | Component/unit — isolated, no infrastructure | +| `*.Test.Subscribe` | `WithApiTester` | Integration over subscriber host | +| `*.Test.Outbox.Relay` | `WithApiTester` | Integration over relay host | + +## One-Time Setup + +Every integration test class must have a `[OneTimeSetUp]` method that runs once before the suite. Order of operations is fixed: + +1. Migrate + seed the database. +2. Clear the hybrid cache. +3. Set up event capture publishers. +4. Set up HTTP client mocks (where applicable). + +```csharp +[OneTimeSetUp] +public async Task OneTimeSetUpAsync() +{ + await Test.MigrateSqlServerDataAsync(DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + Test.UseExpectedSqlServerOutboxPublisher(); + Test.UseExpectedAzureServiceBusPublisher(); // Shopping only + + var mcf = MockHttpClientFactory.Create(); + _mockHttpReserveRequest = mcf.CreateClient("ProductsApi") + .Request(HttpMethod.Post, "api/inventory/reserve"); + Test.ReplaceHttpClientFactory(mcf); +} +``` + +## Test Data (data.yaml) + +Test data lives in `Data/data.yaml` in the `*.Test.Common` project. The `TestData` marker class in that project is the assembly locator — do not rename or move it. + +IDs are written as integers in the YAML file and resolved to GUIDs at load time via `n.ToGuid()`. Use the same helper in test code to reference those IDs: + +```csharp +var product = Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}") + .AssertOK(); +``` + +## Fluent Test Pattern + +Always use the `Test.Http()` / `Test.Http()` fluent chain: + +1. **Set expectations** (before calling `.Run`). +2. **Execute** with `.Run(method, path, body?)`. +3. **Assert** the response. + +```csharp +// Simple GET +Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}") + .AssertOK() + .AssertJsonFromResource("ReadTests.Product_Get_Found.res.json", "etag", "changelog"); + +// POST with event assertion +var created = Test.Http() + .ExpectIdentifier() + .ExpectETag() + .ExpectChangeLogCreated() + .ExpectJsonFromResource("ProductMutateTests.Create_Success.res.json") + .ExpectSqlServerOutboxEvents(e => e + .AssertWithValue("contoso", "contoso.products.product.created.v1")) + .Run(HttpMethod.Post, "/api/products", product) + .AssertCreated() + .AssertLocationHeader(r => new Uri($"/api/products/{r!.Id}", UriKind.Relative)) + .Value!; + +// Validation error +Test.Http() + .Run(HttpMethod.Post, "/api/products", invalidProduct) + .AssertBadRequest() + .AssertErrors("Text is required.", "Price must be greater than or equal to zero."); + +// Verify no events published +Test.Http() + .ExpectNoSqlServerOutboxEvents() + .Run(HttpMethod.Post, $"/api/baskets/{basketId}/checkout") + .AssertBadRequest(); +``` + +## Resource-Based JSON Assertions + +Expected response bodies are stored as `.res.json` files in `Resources/`. Reference them by their dot-separated path within the Resources folder. Exclude volatile fields (etag, changelog timestamps, traceId) by passing them as additional params: + +```csharp +.AssertJsonFromResource("ReadTests.Product_Get_Found.res.json", "etag", "changelog"); +.AssertJsonFromResource("Basket_Checkout_Insufficient_Quantity.products.res.json", "traceid"); +``` + +## HTTP Client Mocking + +Define the mock request field at class level and configure its response inside each test method. Always call `.Verify()` after the test action to confirm the mock was actually invoked: + +```csharp +// Class level +private MockHttpClientRequest _mockHttpReserveRequest = null!; + +// OneTimeSetUp +_mockHttpReserveRequest = mcf.CreateClient("ProductsApi") + .Request(HttpMethod.Post, "api/inventory/reserve"); + +// In test — success path +_mockHttpReserveRequest + .WithJsonResourceBody("Basket_Checkout_Success.products.req.json") + .Respond.With(HttpStatusCode.OK); + +// In test — error path +_mockHttpReserveRequest.WithAnyBody() + .Respond.WithJsonResource( + "Basket_Checkout_Insufficient_Quantity.products.res.json", + HttpStatusCode.BadRequest, + System.Net.Mime.MediaTypeNames.Application.ProblemJson); + +// After action +_mockHttpReserveRequest.Verify(); +``` + +## Event Publisher Expectations + +Use `ExpectSqlServerOutboxEvents` and `ExpectAzureServiceBusEvents` before `.Run` to assert that the operation produces the expected events: + +```csharp +.ExpectSqlServerOutboxEvents(e => e + .AssertWithValue("contoso", "contoso.products.product.created.v1")) +``` + +Use `ExpectNoSqlServerOutboxEvents()` when the operation must not produce any events (e.g., a failed checkout). + +## Unit Tests + +Unit tests use `Test.Scoped(test => { ... })` to get an isolated execution context: + +```csharp +[Test] +public void Empty_Required() => Test.Scoped(test => +{ + var p = new Product(); + new ProductValidator().AssertErrors(p, + ("sku", "Sku is required."), + ("text", "Text is required."), + ("subCategory", "Sub-category is required."), + ("unitOfMeasure", "Unit-of-measure is required.")); +}); +``` + +## NUnit Attributes + +Use `[TestFixture]` on the class (inherited from base when using `WithApiTester`) and `[Test]` on individual test methods. Do not use `[TestCase]` for integration tests — use separate named methods for clarity. + +## Naming Tests + +Name test methods as `{Entity}_{Action}_{Outcome}`: + +``` +Product_Get_Found +Product_Get_NotFound +Product_Create_Success +Product_Create_Bad_Data +Basket_Checkout_Success +Basket_Checkout_Insufficient_Quantity +``` diff --git a/.github/instructions/validators.instructions.md b/.github/instructions/validators.instructions.md new file mode 100644 index 00000000..fe050158 --- /dev/null +++ b/.github/instructions/validators.instructions.md @@ -0,0 +1,134 @@ +--- +applyTo: "**/*Validator*.cs" +description: "Validator conventions: fluent validation API, rule definition, singleton pattern, and CoreEx validation framework usage" +tags: ["validators", "validation", "fluent-api", "rules", "error-handling"] +--- + +# Validator Conventions + +## NuGet / Project References + +| Package | Key types provided | +|---|---| +| `CoreEx.Validation` | `Validator`, `Validator.Create()`, `.Mandatory()`, `.MaximumLength()`, `.IsValid()`, `.PrecisionScale()`, `.GreaterThanOrEqualTo()`, `.LessThanOrEqualTo()`, `.Equal()`, `.NotFound()`, `.WhenValue()`, `.Error()`, `.DependsOn()`, `.Entity()`, `.Dictionary()`, `ValidationContext`, `.ValidateFurtherAsync()`, `.ValidateAndThrowAsync()`, `.ValidateWithResultAsync()`, `.AssertErrors()` (test helper) | +| `CoreEx.Localization` | `[Localization(...)]` attribute on contract properties | + +## Base Class + +Use `Validator` from `CoreEx.Validation`. Expose a static `Default` singleton instance: + +```csharp +public class ProductValidator : Validator +{ + public ProductValidator() + { + Property(p => p.Sku).Mandatory().MaximumLength(50); + Property(p => p.Text).Mandatory().MaximumLength(250); + Property(p => p.SubCategory).Mandatory().IsValid(); + Property(p => p.UnitOfMeasure).Mandatory().IsValid(); + Property(p => p.Price).PrecisionScale(null, 2).GreaterThanOrEqualTo(0, _ => "zero"); + } +} +``` + +Do **not** use FluentValidation unless the project already depends on it. + +## Static Default Instance + +The `Validator` base provides a `Default` singleton. Call `ValidateAndThrowAsync` or `ValidateWithResultAsync` without instantiating manually: + +```csharp +// Exception style (services) +await ProductValidator.Default.ValidateAndThrowAsync(product); + +// Result style (domain-aggregate services) +var result = await ProductValidator.Default.ValidateWithResultAsync(product); +``` + +## Common Rules + +| Rule | Method | +|---|---| +| Required | `.Mandatory()` | +| Max string length | `.MaximumLength(n)` | +| Reference data validity | `.IsValid()` | +| Decimal precision | `.PrecisionScale(precision, scale)` | +| Greater than or equal to | `.GreaterThanOrEqualTo(value)` | +| Less than or equal to | `.LessThanOrEqualTo(value)` | +| Equals | `.Equal(value)` | +| Not found (for key lookup) | `.NotFound()` | +| Conditional rule | `.WhenValue(predicate)` | +| Custom error text | `.Error("message")` | + +## Reference Data Fields + +Use `.IsValid()` on `ReferenceData`-typed properties to validate that the code is a known active value: + +```csharp +Property(p => p.SubCategory).Mandatory().IsValid(); +Property(p => p.UnitOfMeasure).Mandatory().IsValid(); +``` + +## Nested / Collection Validators + +For entities with nested objects, create a separate `Validator.Create()` for the nested type and reference it via `.Entity(validator)` or `.Dictionary(...)`: + +```csharp +private static readonly Validator _productValidator = Validator.Create() + .HasProperty(x => x.UnitOfMeasure, c => c.Mandatory().IsValid()) + .HasProperty(x => x.Quantity, c => c.GreaterThanOrEqualTo(0).DependsOn(x => x.UnitOfMeasure)); + +// In parent validator: +Property(x => x.Products).Mandatory().Dictionary(c => c + .WithKeyValidator("Product", k => k.Mandatory().MaximumLength(50)) + .WithValueValidator(v => v.Mandatory().Entity(_productValidator))); +``` + +## Async Validation (Database Checks) + +Override `OnValidateAsync` for validators that need to query the database. Check `context.HasErrors` first to skip expensive async work if earlier rules already failed: + +```csharp +protected async override Task OnValidateAsync( + ValidationContext context, + CancellationToken cancellationToken) +{ + if (context.HasErrors) + return; + + var ids = context.Value.Products!.Keys.ToArray(); + var products = await _repository.GetForReservationAsync(ids).ConfigureAwait(false); + + await context.ValidateFurtherAsync(c => c + .HasProperty(x => x.Products, c => c.Dictionary(c => c + .WithKeyValidator("Product", k => k + .NotFound().WhenValue(v => !products.ContainsKey(v)) + .Error("{0} is non-stocked.").WhenValue(v => products[v].IsNonStocked)) + )), cancellationToken).ConfigureAwait(false); +} +``` + +## Localization Labels + +Property names in error messages use the property name by default. Override with `[Localization("...")]` on the contract property or pass a custom label into the rule: + +```csharp +// Contract +[Localization("Sub-category")] +public partial string? SubCategoryCode { get; set; } + +// Produces: "Sub-category is required." (not "SubCategoryCode is required.") +``` + +## DependsOn for Conditional Precision + +Use `.DependsOn(x => x.OtherProp)` to skip a rule when a dependent property is already invalid: + +```csharp +Property(x => x.Quantity, c => c + .GreaterThanOrEqualTo(0) + .PrecisionScale( + ctx => ctx.Entity.UnitOfMeasure!.Precision, + ctx => ctx.Entity.UnitOfMeasure!.Scale) + .DependsOn(x => x.UnitOfMeasure)); +``` diff --git a/.github/prompts/init.prompt.md b/.github/prompts/init.prompt.md new file mode 100644 index 00000000..7ca2644f --- /dev/null +++ b/.github/prompts/init.prompt.md @@ -0,0 +1,33 @@ +--- +agent: agent +tools: ['execute/runInTerminal', 'read', 'search', 'todo'] +--- +Run the repository initialization checklist. + +Goals: +- Verify required local dependencies for the samples: + - Podman. + - Podman Desktop. + - Podman Compose. + - .NET 10 SDK. +- If any dependency is missing, attempt installation automatically using `winget`. +- Re-run verification and report final status. + +Execution steps: +1. Verify dependencies using terminal commands: + - Podman: `podman --version`. + - Podman Compose: `podman compose version`. + - .NET 10 SDK: `dotnet --list-sdks` and confirm at least one SDK starts with `10.`. + - Podman Desktop: + - First try `winget list --id RedHat.Podman-Desktop --exact --accept-source-agreements`. + - If needed, also check for `Program Files\Podman Desktop\Podman Desktop.exe`. +2. If `winget` is available, install missing dependencies: + - Podman: `winget install --id RedHat.Podman --exact --accept-package-agreements --accept-source-agreements`. + - Podman Desktop: `winget install --id RedHat.Podman-Desktop --exact --accept-package-agreements --accept-source-agreements`. + - .NET 10 SDK: `winget install --id Microsoft.DotNet.SDK.10 --exact --accept-package-agreements --accept-source-agreements`. +3. If Podman is present but Compose is missing, run a Podman upgrade: + - `winget upgrade --id RedHat.Podman --exact --accept-package-agreements --accept-source-agreements`. +4. Re-run all verification checks. +5. Summarize what was installed and what still requires manual intervention. + +If `winget` is unavailable, report that manual install is required and list the dependency names exactly. diff --git a/.github/prompts/scaffold-domain-from-templates.prompt.md b/.github/prompts/scaffold-domain-from-templates.prompt.md new file mode 100644 index 00000000..ea75eb4a --- /dev/null +++ b/.github/prompts/scaffold-domain-from-templates.prompt.md @@ -0,0 +1,141 @@ +--- +agent: agent +tools: ['create', 'read', 'search', 'todo'] +description: "Fast-path domain scaffolding: clone and materialize the canonical templates in .github/templates/domain/ with placeholder substitution. Use when you want exact template output with no creative generation — entity fields match the template shape exactly. For custom entity fields or reasoning about your domain model, use /generate-domain instead." +--- + +Scaffold a new CoreEx domain by cloning and materializing files from `/.github/templates/domain/**`. + +## Purpose + +Use this prompt when: +- **Speed is the priority** and the entity fields match the template shape (Id, ETag, ChangeLog, one status ref-data, optional child entity). +- You want **exact, deterministic output** — every file is copied verbatim from the templates with placeholder substitution, no reasoning or generation. +- You do not need the agent to inspect existing sample source code. + +Use the `/generate-domain` skill instead when: +- Your entity has **custom fields, types, or business rules** that go beyond what the templates express. +- You want the agent to **reason about your domain model** and apply conventions (validation rules, event naming, query config) appropriately. +- You are unsure which operations or patterns to include and want guided scaffolding. + +## Inputs Required + +If not supplied, ask for: + +1. `Solution` (e.g. `Contoso`). +2. `Domain` (e.g. `Orders`). +3. `Entity` (e.g. `Order`). +4. `ChildEntity` (e.g. `OrderItem`). +5. `targetRoot` (default: `samples/src`). +6. `testsRoot` (default: `samples/tests`). + +## Naming Helper (Auto-Derive) + +Derive naming values from `Entity` unless the user explicitly overrides them: + +- `EntityPlural` = English plural form of `Entity`. + - Default rule: append `s`. + - If ends with `y` preceded by a consonant: replace `y` with `ies`. + - If ends with `s`, `x`, `z`, `ch`, `sh`: append `es`. + - Preserve casing (e.g. `Order` -> `Orders`, `Category` -> `Categories`). +- `entityKebab` = kebab-case of `Entity`. +- `entityPluralKebab` = kebab-case of `EntityPlural`. +- `EntityPluralVar` = `EntityPlural` unless overridden. + +Example: + +- `Entity = Order` -> `EntityPlural = Orders`, `entityKebab = order`, `entityPluralKebab = orders`. +- `Entity = Category` -> `EntityPlural = Categories`, `entityKebab = category`, `entityPluralKebab = categories`. + +## Placeholders to Replace + +For every template file, replace all placeholders: + +- `{Solution}` +- `{Domain}` +- `{Entity}` +- `{ChildEntity}` +- `{EntityPlural}` +- `{EntityPluralKebab}` where present +- `{entityKebab}` +- `{entityPluralKebab}` +- `{EntityPlural}` in class/type names +- `{EntityPlural}` / `{EntityPluralVar}` in repository/EfDb property names + +If `EntityPluralVar` is not supplied, default to `{EntityPlural}`. + +## Output Projects + +Create these projects under `{targetRoot}`: + +- `{Solution}.{Domain}.Contracts` +- `{Solution}.{Domain}.Application` +- `{Solution}.{Domain}.Infrastructure` +- `{Solution}.{Domain}.Api` +- `{Solution}.{Domain}.Database` + +Create these test projects under `{testsRoot}`: + +- `{Solution}.{Domain}.Test.Unit` +- `{Solution}.{Domain}.Test.Api` + +## Materialization Rules + +1. Copy each `.template` file into the corresponding project location. +2. Remove `.template` suffix from output files. +3. Rename `Domain.*.csproj.template` to `{Solution}.{Domain}.*.csproj`. +4. Rename `Entity*` files to use concrete entity names. +5. Keep folder structure identical to template tree. +6. Preserve line endings and indentation. + +## Required Post-Generation Adjustments + +After template materialization: + +1. In API controllers: +- Ensure routes use concrete kebab-case paths. +- Verify OpenApi tags use `{EntityPlural}`. + +2. In Database seed data: +- If status model is used, ensure `Pending`, `Confirmed`, `Cancelled` values are present unless the caller supplied alternatives. + +3. In Infrastructure repository: +- Ensure EfDb mapped model property uses concrete plural entity name. + +4. In Program files: +- Ensure namespaces match generated project names. + +5. In test projects: +- Ensure test namespaces and project names match `{Solution}.{Domain}.Test.Unit` and `{Solution}.{Domain}.Test.Api`. +- Ensure Unit tests follow `WithGenericTester` patterns. +- Ensure Api tests follow `WithApiTester<{Solution}.{Domain}.Api.Program>` patterns. +- Ensure assertions use AwesomeAssertions (not FluentAssertions). + +6. In solution structure: +- Add all generated domain and test projects to the Visual Studio solution. +- Group all generated domain and test projects under a solution folder named `{Domain}`. + +## Validation + +Run `dotnet build` for all generated projects to check for compilation errors: + +- `{targetRoot}/{Solution}.{Domain}.Contracts` +- `{targetRoot}/{Solution}.{Domain}.Application` +- `{targetRoot}/{Solution}.{Domain}.Infrastructure` +- `{targetRoot}/{Solution}.{Domain}.Api` +- `{targetRoot}/{Solution}.{Domain}.Database` +- `{testsRoot}/{Solution}.{Domain}.Test.Unit` +- `{testsRoot}/{Solution}.{Domain}.Test.Api` + +Run tests and ensure they pass: + +- `dotnet test {testsRoot}/{Solution}.{Domain}.Test.Unit` +- `dotnet test {testsRoot}/{Solution}.{Domain}.Test.Api` + +If errors are found, fix them before completing. + +If tests fail, fix the generated code/tests and rerun until both Unit and Api test projects pass. + +## Completion Gate + +Use `/.github/templates/domain/DomainScaffold.checklist.md` as the final acceptance checklist. Do not finish until all applicable items are satisfied. diff --git a/.github/prompts/setup.prompt.md b/.github/prompts/setup.prompt.md new file mode 100644 index 00000000..7c23fd2f --- /dev/null +++ b/.github/prompts/setup.prompt.md @@ -0,0 +1,33 @@ +--- +agent: agent +description: Get my development workspace ready +tools: ['browser', 'execute/runInTerminal', 'read', 'search', 'todo'] +--- + +Goals: +- Start the docker-compose dependencies for Aspire only if not already running. +- Start the Aspire host without the debugger so all sample services start. +- Run the Contoso E2E runner to validate behavior. + +## checklist + +- [ ] Start the docker-compose dependencies for Aspire if not already running (which is at root of repo): + - `podman compose -f docker-compose.yml up -d` + - Wait for all containers to report healthy status by polling `podman ps` output. + - If any container fails to start or becomes unhealthy, capture key log lines with `podman logs` and report failure with remediation suggestions. + +- [ ] Start Aspire without debugger in a dedicated terminal: + - `dotnet run --project samples/aspire/Contoso.Aspire` + - Keep this terminal running and do not await for any user input + - Wait for readiness by polling output until: + - startup/readiness messages indicate services are running, and + - no fatal startup exception is present. + - If readiness is not reached within a reasonable timeout, report failure with key log lines. + +- [ ] Run the Contoso E2E runner to validate behavior: + - `dotnet run --project samples/tests/Contoso.E2E.Runner` + - Wait for all scenarios to complete. + - If any scenario fails, capture the failure output and report with remediation suggestions. + +Failure handling: +- If any command fails, capture the key error lines and include a concise remediation suggestion. \ No newline at end of file diff --git a/.github/skills/acquire-codebase-knowledge/SKILL.md b/.github/skills/acquire-codebase-knowledge/SKILL.md new file mode 100644 index 00000000..5ac5289f --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/SKILL.md @@ -0,0 +1,175 @@ +--- +name: acquire-codebase-knowledge +description: 'Use this skill when the user explicitly asks to map, document, or onboard into an existing codebase. Trigger for prompts like "map this codebase", "document this architecture", "onboard me to this repo", or "create codebase docs". Do not trigger for routine feature implementation, bug fixes, or narrow code edits unless the user asks for repository-level discovery.' +license: MIT +compatibility: 'Cross-platform. Requires Python 3.8+ and git. Run scripts/scan.py from the target project root.' +metadata: + version: "1.3" + enhancements: + - Multi-language manifest detection (25+ languages supported) + - CI/CD pipeline detection (10+ platforms) + - Container & orchestration detection + - Code metrics by language + - Security & compliance config detection + - Performance testing markers +argument-hint: 'Optional: specific area to focus on, e.g. "architecture only", "testing and concerns"' +tags: ["codebase", "documentation", "onboarding", "discovery", "architecture"] +--- + +# Acquire Codebase Knowledge + +Produces seven populated documents in `docs/codebase/` covering everything needed to work effectively on the project. Only document what is verifiable from files or terminal output — never infer or assume. + +## Output Contract (Required) + +Before finishing, all of the following must be true: + +1. Exactly these files exist in `docs/codebase/`: `STACK.md`, `STRUCTURE.md`, `ARCHITECTURE.md`, `CONVENTIONS.md`, `INTEGRATIONS.md`, `TESTING.md`, `CONCERNS.md`. +2. Every claim is traceable to source files, config, or terminal output. +3. Unknowns are marked as `[TODO]`; intent-dependent decisions are marked `[ASK USER]`. +4. Every document includes a short "evidence" list with concrete file paths. +5. Final response includes numbered `[ASK USER]` questions and intent-vs-reality divergences. + +## Workflow + +Copy and track this checklist: + +``` +- [ ] Phase 1: Run scan, read intent documents +- [ ] Phase 2: Investigate each documentation area +- [ ] Phase 3: Populate all seven docs in docs/codebase/ +- [ ] Phase 4: Validate docs, present findings, resolve all [ASK USER] items +``` + +## Focus Area Mode + +If the user supplies a focus area (for example: "architecture only" or "testing and concerns"): + +1. Always run Phase 1 in full. +2. Fully complete focus-area documents first. +3. For non-focus documents not yet analyzed, keep required sections present and mark unknowns as `[TODO]`. +4. Still run the Phase 4 validation loop on all seven documents before final output. + +### Phase 1: Scan and Read Intent + +1. Run the scan script from the target project root: + ```bash + python3 "$SKILL_ROOT/scripts/scan.py" --output docs/codebase/.codebase-scan.txt + ``` + Where `$SKILL_ROOT` is the absolute path to the skill folder. Works on Windows, macOS, and Linux. + + **Quick start:** If you have the path inline: + ```bash + python3 /absolute/path/to/skills/acquire-codebase-knowledge/scripts/scan.py --output docs/codebase/.codebase-scan.txt + ``` + +2. Search for `PRD`, `TRD`, `README`, `ROADMAP`, `SPEC`, `DESIGN` files and read them. +3. Summarise the stated project intent before reading any source code. + +### Phase 2: Investigate + +Use the scan output to answer questions for each of the seven templates. Load [`references/inquiry-checkpoints.md`](references/inquiry-checkpoints.md) for the full per-template question list. + +If the stack is ambiguous (multiple manifest files, unfamiliar file types, no `package.json`), load [`references/stack-detection.md`](references/stack-detection.md). + +### Phase 3: Populate Templates + +Copy each template from `assets/templates/` into `docs/codebase/`. Fill in this order: + +1. [STACK.md](assets/templates/STACK.md) — language, runtime, frameworks, all dependencies +2. [STRUCTURE.md](assets/templates/STRUCTURE.md) — directory layout, entry points, key files +3. [ARCHITECTURE.md](assets/templates/ARCHITECTURE.md) — layers, patterns, data flow +4. [CONVENTIONS.md](assets/templates/CONVENTIONS.md) — naming, formatting, error handling, imports +5. [INTEGRATIONS.md](assets/templates/INTEGRATIONS.md) — external APIs, databases, auth, monitoring +6. [TESTING.md](assets/templates/TESTING.md) — frameworks, file organization, mocking strategy +7. [CONCERNS.md](assets/templates/CONCERNS.md) — tech debt, bugs, security risks, perf bottlenecks + +Use `[TODO]` for anything that cannot be determined from code. Use `[ASK USER]` where the right answer requires team intent. + +### Phase 4: Validate, Repair, Verify + +Run this mandatory validation loop before finalizing: + +1. Validate each doc against `references/inquiry-checkpoints.md`. +2. For each non-trivial claim, confirm at least one evidence reference exists. +3. If any required section is missing or unsupported: + - Fix the document. + - Re-run validation. +4. Repeat until all seven docs pass. + +Then present a summary of all seven documents, list every `[ASK USER]` item as a numbered question, and highlight any Intent vs. Reality divergences from Phase 1. + +Validation pass criteria: + +- No unsupported claims. +- No empty required sections. +- Unknowns use `[TODO]` rather than assumptions. +- Team-intent gaps are explicitly marked `[ASK USER]`. + +--- + +## Gotchas + +**Monorepos:** Root `package.json` may have no source — check for `workspaces`, `packages/`, or `apps/` directories. Each workspace may have independent dependencies and conventions. Map each sub-package separately. + +**Outdated README:** README often describes intended architecture, not the current one. Cross-reference with actual file structure before treating any README claim as fact. + +**TypeScript path aliases:** `tsconfig.json` `paths` config means imports like `@/foo` don't map directly to the filesystem. Map aliases to real paths before documenting structure. + +**Generated/compiled output:** Never document patterns from `dist/`, `build/`, `generated/`, `.next/`, `out/`, or `__pycache__/`. These are artefacts — document source conventions only. + +**`.env.example` reveals required config:** Secrets are never committed. Read `.env.example`, `.env.template`, or `.env.sample` to discover required environment variables. + +**`devDependencies` ≠ production stack:** Only `dependencies` (or equivalent, e.g. `[tool.poetry.dependencies]`) runs in production. Document linters, formatters, and test frameworks separately as dev tooling. + +**Test TODOs ≠ production debt:** TODOs inside `test/`, `tests/`, `__tests__/`, or `spec/` are coverage gaps, not production technical debt. Separate them in `CONCERNS.md`. + +**High-churn files = fragile areas:** Files appearing most in recent git history have the highest modification rate and likely hidden complexity. Always note them in `CONCERNS.md`. + +--- + +## Anti-Patterns + +| ❌ Don't | ✅ Do instead | +|---------|--------------| +| "Uses Clean Architecture with Domain/Data layers." (when no such directories exist) | State only what directory structure actually shows. | +| "This is a Next.js project." (without checking `package.json`) | Check `dependencies` first. State what's actually there. | +| Guess the database from a variable name like `dbUrl` | Check manifest for `pg`, `mysql2`, `mongoose`, `prisma`, etc. | +| Document `dist/` or `build/` naming patterns as conventions | Source files only. | + +--- + +## Enhanced Scan Output Sections + +The `scan.py` script now produce the following sections in addition to the original output: + +- **CODE METRICS** — Total files, lines of code by language, largest files (complexity signals) +- **CI/CD PIPELINES** — Detected GitHub Actions, GitLab CI, Jenkins, CircleCI, etc. +- **CONTAINERS & ORCHESTRATION** — Docker, Docker Compose, Kubernetes, Vagrant configs +- **SECURITY & COMPLIANCE** — Snyk, Dependabot, SECURITY.md, SBOM, security policies +- **PERFORMANCE & TESTING** — Benchmark configs, profiling markers, load testing tools + +Use these sections during Phase 2 to inform investigation questions and identify tool-specific patterns. + +--- + +## Bundled Assets + +| Asset | When to load | +|-------|-------------| +| [`scripts/scan.py`](scripts/scan.py) | Phase 1 — run first, before reading any code (Python 3.8+ required) | + +| [`references/inquiry-checkpoints.md`](references/inquiry-checkpoints.md) | Phase 2 — load for per-template investigation questions | +| [`references/stack-detection.md`](references/stack-detection.md) | Phase 2 — only if stack is ambiguous | +| [`assets/templates/STACK.md`](assets/templates/STACK.md) | Phase 3 step 1 | +| [`assets/templates/STRUCTURE.md`](assets/templates/STRUCTURE.md) | Phase 3 step 2 | +| [`assets/templates/ARCHITECTURE.md`](assets/templates/ARCHITECTURE.md) | Phase 3 step 3 | +| [`assets/templates/CONVENTIONS.md`](assets/templates/CONVENTIONS.md) | Phase 3 step 4 | +| [`assets/templates/INTEGRATIONS.md`](assets/templates/INTEGRATIONS.md) | Phase 3 step 5 | +| [`assets/templates/TESTING.md`](assets/templates/TESTING.md) | Phase 3 step 6 | +| [`assets/templates/CONCERNS.md`](assets/templates/CONCERNS.md) | Phase 3 step 7 | + +Template usage mode: + +- Default mode: complete only the "Core Sections (Required)" in each template. +- Extended mode: add optional sections only when the repo complexity justifies them. diff --git a/.github/skills/acquire-codebase-knowledge/assets/templates/ARCHITECTURE.md b/.github/skills/acquire-codebase-knowledge/assets/templates/ARCHITECTURE.md new file mode 100644 index 00000000..26f575e2 --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/assets/templates/ARCHITECTURE.md @@ -0,0 +1,49 @@ +# Architecture + +## Core Sections (Required) + +### 1) Architectural Style + +- Primary style: [layered/feature/event-driven/other] +- Why this classification: [short evidence-backed rationale] +- Primary constraints: [2-3 constraints that shape design] + +### 2) System Flow + +```text +[entry] -> [processing] -> [domain logic] -> [data/integration] -> [response/output] +``` + +Describe the flow in 4-6 steps using file-backed evidence. + +### 3) Layer/Module Responsibilities + +| Layer or module | Owns | Must not own | Evidence | +|-----------------|------|--------------|----------| +| [name] | [responsibility] | [non-responsibility] | [file] | + +### 4) Reused Patterns + +| Pattern | Where found | Why it exists | +|---------|-------------|---------------| +| [singleton/repository/adapter/etc] | [path] | [reason] | + +### 5) Known Architectural Risks + +- [Risk 1 + impact] +- [Risk 2 + impact] + +### 6) Evidence + +- [path/to/entrypoint] +- [path/to/main-layer-files] +- [path/to/data-or-integration-layer] + +## Extended Sections (Optional) + +Add only when needed: + +- Startup or initialization order details +- Async/event topology diagrams +- Anti-pattern catalog with refactoring paths +- Failure-mode analysis and resilience posture diff --git a/.github/skills/acquire-codebase-knowledge/assets/templates/CONCERNS.md b/.github/skills/acquire-codebase-knowledge/assets/templates/CONCERNS.md new file mode 100644 index 00000000..d41e13ab --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/assets/templates/CONCERNS.md @@ -0,0 +1,56 @@ +# Codebase Concerns + +## Core Sections (Required) + +### 1) Top Risks (Prioritized) + +| Severity | Concern | Evidence | Impact | Suggested action | +|----------|---------|----------|--------|------------------| +| [high/med/low] | [issue] | [file or scan output] | [impact] | [next action] | + +### 2) Technical Debt + +List the most important debt items only. + +| Debt item | Why it exists | Where | Risk if ignored | Suggested fix | +|-----------|---------------|-------|-----------------|---------------| +| [item] | [reason] | [path] | [risk] | [fix] | + +### 3) Security Concerns + +| Risk | OWASP category (if applicable) | Evidence | Current mitigation | Gap | +|------|--------------------------------|----------|--------------------|-----| +| [risk] | [A01/A03/etc or N/A] | [path] | [what exists] | [what is missing] | + +### 4) Performance and Scaling Concerns + +| Concern | Evidence | Current symptom | Scaling risk | Suggested improvement | +|---------|----------|-----------------|-------------|-----------------------| +| [issue] | [path/metric] | [symptom] | [risk] | [action] | + +### 5) Fragile/High-Churn Areas + +| Area | Why fragile | Churn signal | Safe change strategy | +|------|-------------|-------------|----------------------| +| [path] | [reason] | [recent churn evidence] | [approach] | + +### 6) `[ASK USER]` Questions + +Add unresolved intent-dependent questions as a numbered list. + +1. [ASK USER] [question] + +### 7) Evidence + +- [scan output section reference] +- [path/to/code-file] +- [path/to/config-or-history-evidence] + +## Extended Sections (Optional) + +Add only when needed: + +- Full bug inventory +- Component-level remediation roadmap +- Cost/effort estimates by concern +- Dependency-risk and ownership mapping diff --git a/.github/skills/acquire-codebase-knowledge/assets/templates/CONVENTIONS.md b/.github/skills/acquire-codebase-knowledge/assets/templates/CONVENTIONS.md new file mode 100644 index 00000000..5a29453c --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/assets/templates/CONVENTIONS.md @@ -0,0 +1,52 @@ +# Coding Conventions + +## Core Sections (Required) + +### 1) Naming Rules + +| Item | Rule | Example | Evidence | +|------|------|---------|----------| +| Files | [RULE] | [EXAMPLE] | [FILE] | +| Functions/methods | [RULE] | [EXAMPLE] | [FILE] | +| Types/interfaces | [RULE] | [EXAMPLE] | [FILE] | +| Constants/env vars | [RULE] | [EXAMPLE] | [FILE] | + +### 2) Formatting and Linting + +- Formatter: [TOOL + CONFIG FILE] +- Linter: [TOOL + CONFIG FILE] +- Most relevant enforced rules: [RULE_1], [RULE_2], [RULE_3] +- Run commands: [COMMANDS] + +### 3) Import and Module Conventions + +- Import grouping/order: [RULE] +- Alias vs relative import policy: [RULE] +- Public exports/barrel policy: [RULE] + +### 4) Error and Logging Conventions + +- Error strategy by layer: [SHORT SUMMARY] +- Logging style and required context fields: [SUMMARY] +- Sensitive-data redaction rules: [SUMMARY] + +### 5) Testing Conventions + +- Test file naming/location rule: [RULE] +- Mocking strategy norm: [RULE] +- Coverage expectation: [RULE or TODO] + +### 6) Evidence + +- [path/to/lint-config] +- [path/to/format-config] +- [path/to/representative-source-file] + +## Extended Sections (Optional) + +Add only for large or inconsistent codebases: + +- Layer-specific error handling matrix +- Language-specific strictness options +- Repo-specific commit/branching conventions +- Known convention violations to clean up diff --git a/.github/skills/acquire-codebase-knowledge/assets/templates/INTEGRATIONS.md b/.github/skills/acquire-codebase-knowledge/assets/templates/INTEGRATIONS.md new file mode 100644 index 00000000..f62039ff --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/assets/templates/INTEGRATIONS.md @@ -0,0 +1,48 @@ +# External Integrations + +## Core Sections (Required) + +### 1) Integration Inventory + +| System | Type (API/DB/Queue/etc) | Purpose | Auth model | Criticality | Evidence | +|--------|---------------------------|---------|------------|-------------|----------| +| [name] | [type] | [purpose] | [auth] | [high/med/low] | [file] | + +### 2) Data Stores + +| Store | Role | Access layer | Key risk | Evidence | +|-------|------|--------------|----------|----------| +| [db/cache/etc] | [role] | [module] | [risk] | [file] | + +### 3) Secrets and Credentials Handling + +- Credential sources: [env/secrets manager/config] +- Hardcoding checks: [result] +- Rotation or lifecycle notes: [known/unknown] + +### 4) Reliability and Failure Behavior + +- Retry/backoff behavior: [implemented/none/partial] +- Timeout policy: [where configured] +- Circuit-breaker or fallback behavior: [if any] + +### 5) Observability for Integrations + +- Logging around external calls: [yes/no + where] +- Metrics/tracing coverage: [yes/no + where] +- Missing visibility gaps: [list] + +### 6) Evidence + +- [path/to/integration-wrapper] +- [path/to/config-or-env-template] +- [path/to/monitoring-or-logging-config] + +## Extended Sections (Optional) + +Add only when needed: + +- Endpoint-by-endpoint catalog +- Auth flow sequence diagrams +- SLA/SLO per integration +- Region/failover topology notes diff --git a/.github/skills/acquire-codebase-knowledge/assets/templates/STACK.md b/.github/skills/acquire-codebase-knowledge/assets/templates/STACK.md new file mode 100644 index 00000000..2520677c --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/assets/templates/STACK.md @@ -0,0 +1,56 @@ +# Technology Stack + +## Core Sections (Required) + +### 1) Runtime Summary + +| Area | Value | Evidence | +|------|-------|----------| +| Primary language | [VALUE] | [FILE_PATH] | +| Runtime + version | [VALUE] | [FILE_PATH] | +| Package manager | [VALUE] | [FILE_PATH] | +| Module/build system | [VALUE] | [FILE_PATH] | + +### 2) Production Frameworks and Dependencies + +List only high-impact production dependencies (frameworks, data, transport, auth). + +| Dependency | Version | Role in system | Evidence | +|------------|---------|----------------|----------| +| [NAME] | [VERSION] | [ROLE] | [FILE_PATH] | + +### 3) Development Toolchain + +| Tool | Purpose | Evidence | +|------|---------|----------| +| [TOOL] | [LINT/FORMAT/TEST/BUILD] | [FILE_PATH] | + +### 4) Key Commands + +```bash +[install command] +[build command] +[test command] +[lint command] +``` + +### 5) Environment and Config + +- Config sources: [LIST FILES] +- Required env vars: [VAR_1], [VAR_2], [TODO] +- Deployment/runtime constraints: [SHORT NOTE] + +### 6) Evidence + +- [path/to/manifest] +- [path/to/runtime-config] +- [path/to/build-or-ci-config] + +## Extended Sections (Optional) + +Add only when needed for complex repos: + +- Full dependency taxonomy by category +- Detailed compiler/runtime flags +- Environment matrix (dev/stage/prod) +- Process manager and container runtime details diff --git a/.github/skills/acquire-codebase-knowledge/assets/templates/STRUCTURE.md b/.github/skills/acquire-codebase-knowledge/assets/templates/STRUCTURE.md new file mode 100644 index 00000000..89e9c28f --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/assets/templates/STRUCTURE.md @@ -0,0 +1,44 @@ +# Codebase Structure + +## Core Sections (Required) + +### 1) Top-Level Map + +List only meaningful top-level directories and files. + +| Path | Purpose | Evidence | +|------|---------|----------| +| [path/] | [purpose] | [source] | + +### 2) Entry Points + +- Main runtime entry: [FILE] +- Secondary entry points (worker/cli/jobs): [FILES or NONE] +- How entry is selected (script/config): [NOTE] + +### 3) Module Boundaries + +| Boundary | What belongs here | What must not be here | +|----------|-------------------|------------------------| +| [module/layer] | [responsibility] | [forbidden logic] | + +### 4) Naming and Organization Rules + +- File naming pattern: [kebab/camel/Pascal + examples] +- Directory organization pattern: [feature/layer/domain] +- Import aliasing or path conventions: [RULE] + +### 5) Evidence + +- [path/to/root-tree-source] +- [path/to/entry-config] +- [path/to/key-module] + +## Extended Sections (Optional) + +Add only when repository complexity requires it: + +- Subdirectory deep maps by feature/layer +- Middleware/boot order details +- Generated-vs-source layout boundaries +- Monorepo workspace-level structure maps diff --git a/.github/skills/acquire-codebase-knowledge/assets/templates/TESTING.md b/.github/skills/acquire-codebase-knowledge/assets/templates/TESTING.md new file mode 100644 index 00000000..8e0e7028 --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/assets/templates/TESTING.md @@ -0,0 +1,57 @@ +# Testing Patterns + +## Core Sections (Required) + +### 1) Test Stack and Commands + +- Primary test framework: [NAME + VERSION] +- Assertion/mocking tools: [TOOLS] +- Commands: + +```bash +[run all tests] +[run unit tests] +[run integration/e2e tests] +[run coverage] +``` + +### 2) Test Layout + +- Test file placement pattern: [co-located/tests folder/etc] +- Naming convention: [pattern] +- Setup files and where they run: [paths] + +### 3) Test Scope Matrix + +| Scope | Covered? | Typical target | Notes | +|-------|----------|----------------|-------| +| Unit | [yes/no] | [modules/services] | [notes] | +| Integration | [yes/no] | [API/data boundaries] | [notes] | +| E2E | [yes/no] | [user flows] | [notes] | + +### 4) Mocking and Isolation Strategy + +- Main mocking approach: [module/class/network] +- Isolation guarantees: [what is reset and when] +- Common failure mode in tests: [short note] + +### 5) Coverage and Quality Signals + +- Coverage tool + threshold: [value or TODO] +- Current reported coverage: [value or TODO] +- Known gaps/flaky areas: [list] + +### 6) Evidence + +- [path/to/test-config] +- [path/to/representative-test-file] +- [path/to/ci-or-coverage-config] + +## Extended Sections (Optional) + +Add only when needed: + +- Framework-specific suite patterns +- Detailed mock recipes per dependency type +- Historical flaky test catalog +- Test performance bottlenecks and optimization ideas diff --git a/.github/skills/acquire-codebase-knowledge/references/inquiry-checkpoints.md b/.github/skills/acquire-codebase-knowledge/references/inquiry-checkpoints.md new file mode 100644 index 00000000..02430e76 --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/references/inquiry-checkpoints.md @@ -0,0 +1,70 @@ +# Inquiry Checkpoints + +Per-template investigation questions for Phase 2 of the acquire-codebase-knowledge workflow. For each template area, look for answers in the scan output first, then read source files to fill gaps. + +--- + +## 1. STACK.md — Tech Stack + +- What is the primary language and exact version? (check `.nvmrc`, `go.mod`, `pyproject.toml`, Docker `FROM` line) +- What package manager is used? (`npm`, `yarn`, `pnpm`, `go mod`, `pip`, `uv`) +- What are the core runtime frameworks? (web server, ORM, DI container) +- What do `dependencies` (production) vs `devDependencies` (dev tooling) contain? +- Is there a Docker image and what base image does it use? +- What are the key scripts in `package.json` / `Makefile` / `pyproject.toml`? + +## 2. STRUCTURE.md — Directory Layout + +- Where does source code live? (usually `src/`, `lib/`, or project root for Go) +- What are the entry points? (check `main` in `package.json`, `scripts.start`, `cmd/main.go`, `app.py`) +- What is the stated purpose of each top-level directory? +- Are there non-obvious directories (e.g., `eng/`, `platform/`, `infra/`)? +- Are there hidden config directories (`.github/`, `.vscode/`, `.husky/`)? +- What naming conventions do directories follow? (camelCase, kebab-case, domain-based vs layer-based) + +## 3. ARCHITECTURE.md — Patterns + +- Is the code organized by layer (controllers → services → repos) or by feature? +- What is the primary data flow? Trace one request or command from entry to data store. +- Are there singletons, dependency injection patterns, or explicit initialization order requirements? +- Are there background workers, queues, or event-driven components? +- What design patterns appear repeatedly? (Factory, Repository, Decorator, Strategy) + +## 4. CONVENTIONS.md — Coding Standards + +- What is the file naming convention? (check 10+ files — camelCase, kebab-case, PascalCase) +- What is the function and variable naming convention? +- Are private methods/fields prefixed (e.g., `_methodName`, `#field`)? +- What linter and formatter are configured? (check `.eslintrc`, `.prettierrc`, `golangci.yml`) +- What are the TypeScript strictness settings? (`strict`, `noImplicitAny`, etc.) +- How are errors handled at each layer? (throw vs. return structured error) +- What logging library is used and what is the log message format? +- How are imports organized? (barrel exports, path aliases, grouping rules) + +## 5. INTEGRATIONS.md — External Services + +- What external APIs are called? (search for `axios.`, `fetch(`, `http.Get(`, base URLs in constants) +- How are credentials stored and accessed? (`.env`, secrets manager, env vars) +- What databases are connected? (check manifest for `pg`, `mongoose`, `prisma`, `typeorm`, `sqlalchemy`) +- Is there an API gateway, service mesh, or proxy between the app and external services? +- What monitoring or observability tools are used? (APM, Prometheus, logging pipeline) +- Are there message queues or event buses? (Kafka, RabbitMQ, SQS, Pub/Sub) + +## 6. TESTING.md — Test Setup + +- What test runner is configured? (check `scripts.test` in `package.json`, `pytest.ini`, `go test`) +- Where are test files located? (alongside source, in `tests/`, in `__tests__/`) +- What assertion library is used? (Jest expect, Chai, pytest assert) +- How are external dependencies mocked? (jest.mock, dependency injection, fixtures) +- Are there integration tests that hit real services vs. unit tests with mocks? +- Is there a coverage threshold enforced? (check `jest.config.js`, `.nycrc`, `pyproject.toml`) + +## 7. CONCERNS.md — Known Issues + +- How many TODOs/FIXMEs/HACKs are in production code? (see scan output) +- Which files have the highest git churn in the last 90 days? (see scan output) +- Are there any files over 500 lines that mix multiple responsibilities? +- Do any services make sequential calls that could be parallelized? +- Are there hardcoded values (URLs, IDs, magic numbers) that should be config? +- What security risks exist? (missing input validation, raw error messages exposed to clients, missing auth checks) +- Are there performance patterns that don't scale? (N+1 queries, in-memory caches in multi-instance setups) diff --git a/.github/skills/acquire-codebase-knowledge/references/stack-detection.md b/.github/skills/acquire-codebase-knowledge/references/stack-detection.md new file mode 100644 index 00000000..01ccfd7d --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/references/stack-detection.md @@ -0,0 +1,131 @@ +# Stack Detection Reference + +Load this file when the tech stack is ambiguous — e.g., multiple manifest files present, unfamiliar file extensions, or no obvious `package.json` / `go.mod`. + +--- + +## Manifest File → Ecosystem + +| File | Ecosystem | Key fields to read | +|------|-----------|--------------------| +| `package.json` | Node.js / JavaScript / TypeScript | `dependencies`, `devDependencies`, `scripts`, `main`, `type`, `engines` | +| `go.mod` | Go | Module path, Go version, `require` block | +| `requirements.txt` | Python (pip) | Package list with pinned versions | +| `Pipfile` | Python (pipenv) | `[packages]`, `[dev-packages]`, `[requires]` python version | +| `pyproject.toml` | Python (poetry / uv / hatch) | `[tool.poetry.dependencies]`, `[project]`, `[build-system]` | +| `setup.py` / `setup.cfg` | Python (setuptools, legacy) | `install_requires`, `python_requires` | +| `Cargo.toml` | Rust | `[dependencies]`, `[[bin]]`, `[lib]` | +| `pom.xml` | Java / Kotlin (Maven) | ``, ``, ``, `` | +| `build.gradle` / `build.gradle.kts` | Java / Kotlin (Gradle) | `dependencies {}`, `sourceCompatibility` | +| `composer.json` | PHP | `require`, `require-dev` | +| `Gemfile` | Ruby | `gem` declarations, `ruby` version constraint | +| `mix.exs` | Elixir | `deps/0`, `elixir: "~> X.Y"` | +| `pubspec.yaml` | Dart / Flutter | `dependencies`, `dev_dependencies`, `environment.sdk` | +| `*.csproj` | .NET / C# | ``, `` | +| `*.sln` | .NET solution | References multiple `.csproj` projects | +| `deno.json` / `deno.jsonc` | Deno (TypeScript runtime) | `imports`, `tasks` | +| `bun.lockb` | Bun (JavaScript runtime) | Binary lockfile — check `package.json` for deps | + +--- + +## Language Runtime Version Detection + +| Language | Where to find the version | +|----------|--------------------------| +| Node.js | `.nvmrc`, `.node-version`, `engines.node` in `package.json`, Docker `FROM node:X` | +| Python | `.python-version`, `pyproject.toml [requires-python]`, Docker `FROM python:X` | +| Go | First line of `go.mod` (`go 1.21`) | +| Java | `` in `pom.xml`, `sourceCompatibility` in `build.gradle`, Docker `FROM eclipse-temurin:X` | +| Ruby | `.ruby-version`, `Gemfile` `ruby 'X.Y.Z'` | +| Rust | `rust-toolchain.toml`, `rust-toolchain` file | +| .NET | `` in `.csproj` (e.g., `net8.0`) | + +--- + +## Framework Detection (Node.js / TypeScript) + +| Dependency in `package.json` | Framework | +|-----------------------------|-----------| +| `express` | Express.js (minimal HTTP server) | +| `fastify` | Fastify (high-performance HTTP server) | +| `next` | Next.js (SSR/SSG React — check for `pages/` or `app/` directory) | +| `nuxt` | Nuxt.js (SSR/SSG Vue) | +| `@nestjs/core` | NestJS (opinionated Node.js framework with DI) | +| `koa` | Koa (middleware-focused, no built-in router) | +| `@hapi/hapi` | Hapi | +| `@trpc/server` | tRPC (type-safe API without REST/GraphQL schemas) | +| `routing-controllers` | routing-controllers (decorator-based Express wrapper) | +| `typeorm` | TypeORM (SQL ORM with decorators) | +| `prisma` | Prisma (type-safe ORM, check `prisma/schema.prisma`) | +| `mongoose` | Mongoose (MongoDB ODM) | +| `sequelize` | Sequelize (SQL ORM) | +| `drizzle-orm` | Drizzle (lightweight SQL ORM) | +| `react` without `next` | Vanilla React SPA (check for `react-router-dom`) | +| `vue` without `nuxt` | Vanilla Vue SPA | + +--- + +## Framework Detection (Python) + +| Package | Framework | +|---------|-----------| +| `fastapi` | FastAPI (async REST, auto OpenAPI docs) | +| `flask` | Flask (minimal WSGI web framework) | +| `django` | Django (batteries-included, check `settings.py`) | +| `starlette` | Starlette (ASGI, often used as FastAPI base) | +| `aiohttp` | aiohttp (async HTTP client and server) | +| `sqlalchemy` | SQLAlchemy (SQL ORM; check for `alembic` migrations) | +| `alembic` | Alembic (SQLAlchemy migration tool) | +| `pydantic` | Pydantic (data validation; core to FastAPI) | +| `celery` | Celery (distributed task queue) | + +--- + +## Monorepo Detection + +Check these signals in order: + +1. `pnpm-workspace.yaml` — pnpm workspaces +2. `lerna.json` — Lerna monorepo +3. `nx.json` — Nx monorepo (also check `workspace.json`) +4. `turbo.json` — Turborepo +5. `rush.json` — Rush (Microsoft monorepo manager) +6. `moon.yml` — Moon +7. `package.json` with `"workspaces": [...]` — npm/yarn workspaces +8. Presence of `packages/`, `apps/`, `libs/`, or `services/` directories with their own `package.json` + +If monorepo is detected: each workspace may have **independent** dependencies and conventions. Map each sub-package separately in `STACK.md` and note the monorepo structure in `STRUCTURE.md`. + +--- + +## TypeScript Path Alias Detection + +If `tsconfig.json` has a `paths` key, imports with non-relative prefixes are aliases. Map them before documenting structure. + +```json +// tsconfig.json example +"paths": { + "@/*": ["./src/*"], + "@components/*": ["./src/components/*"], + "@utils/*": ["./src/utils/*"] +} +``` + +Imports like `import { foo } from '@/utils/bar'` resolve to `src/utils/bar`. Document as `src/utils/bar`, not `@/utils/bar`. + +--- + +## Docker Base Image → Runtime + +If no manifest file is present but a `Dockerfile` exists, the `FROM` line reveals the runtime: + +| FROM line pattern | Runtime | +|------------------|---------| +| `FROM node:X` | Node.js X | +| `FROM python:X` | Python X | +| `FROM golang:X` | Go X | +| `FROM eclipse-temurin:X` | Java X (Eclipse Temurin JDK) | +| `FROM mcr.microsoft.com/dotnet/aspnet:X` | .NET X | +| `FROM ruby:X` | Ruby X | +| `FROM rust:X` | Rust X | +| `FROM alpine` (alone) | Check what's installed via `RUN apk add` | diff --git a/.github/skills/acquire-codebase-knowledge/scripts/scan.py b/.github/skills/acquire-codebase-knowledge/scripts/scan.py new file mode 100644 index 00000000..15e17a28 --- /dev/null +++ b/.github/skills/acquire-codebase-knowledge/scripts/scan.py @@ -0,0 +1,712 @@ +#!/usr/bin/env python3 +""" +scan.py — Collect project discovery information for the acquire-codebase-knowledge skill. +Run from the project root directory. + +Usage: python3 scan.py [OPTIONS] + +Options: + --output FILE Write output to FILE instead of stdout + --help Show this message and exit + +Exit codes: + 0 Success + 1 Usage error +""" + +import os +import sys +import argparse +import subprocess +import json +from pathlib import Path +from typing import List, Set +import re + +TREE_LIMIT = 200 +TREE_MAX_DEPTH = 3 +TODO_LIMIT = 60 +MANIFEST_PREVIEW_LINES = 80 +RECENT_COMMITS_LIMIT = 20 +CHURN_LIMIT = 20 + +EXCLUDE_DIRS = { + "node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", + "__pycache__", ".venv", "venv", ".tox", "target", "vendor", + "coverage", ".nyc_output", "generated", ".cache", ".turbo", + ".yarn", ".pnp", "bin", "obj" +} + +MANIFESTS = [ + # JavaScript/Node.js + "package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", + "deno.json", "deno.jsonc", + # Python + "requirements.txt", "Pipfile", "Pipfile.lock", "pyproject.toml", "setup.py", "setup.cfg", + "poetry.lock", "pdm.lock", "uv.lock", + # Go + "go.mod", "go.sum", + # Rust + "Cargo.toml", "Cargo.lock", + # Java/Kotlin + "pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", + "gradle.properties", + # PHP/Composer + "composer.json", "composer.lock", + # Ruby + "Gemfile", "Gemfile.lock", "*.gemspec", + # Elixir + "mix.exs", "mix.lock", + # Dart/Flutter + "pubspec.yaml", "pubspec.lock", + # .NET/C# + "*.csproj", "*.sln", "*.slnx", "global.json", "packages.config", + # Swift + "Package.swift", "Package.resolved", + # Scala + "build.sbt", "scala-cli.yml", + # Haskell + "*.cabal", "stack.yaml", "cabal.project", "cabal.project.local", + # OCaml + "dune-project", "opam", "opam.lock", + # Nim + "*.nimble", "nim.cfg", + # Crystal + "shard.yml", "shard.lock", + # R + "DESCRIPTION", "renv.lock", + # Julia + "Project.toml", "Manifest.toml", + # Build systems + "CMakeLists.txt", "Makefile", "GNUmakefile", + "SConstruct", "build.xml", + "BUILD", "BUILD.bazel", "WORKSPACE", "bazel.lock", + "justfile", ".justfile", "Taskfile.yml", + "tox.ini", "Vagrantfile" +] + +ENTRY_CANDIDATES = [ + # JavaScript/Node.js/TypeScript + "src/index.ts", "src/index.js", "src/index.mjs", + "src/main.ts", "src/main.js", "src/main.py", + "src/app.ts", "src/app.js", + "src/server.ts", "src/server.js", + "index.ts", "index.js", "app.ts", "app.js", + "lib/index.ts", "lib/index.js", + # Go + "main.go", "cmd/main.go", "cmd/*/main.go", + # Python + "main.py", "app.py", "server.py", "run.py", "cli.py", + "src/main.py", "src/__main__.py", + # .NET/C# + "Program.cs", "src/Program.cs", "Main.cs", + # Java + "Main.java", "Application.java", "App.java", + "src/main/java/Main.java", + # Kotlin + "Main.kt", "Application.kt", "App.kt", + # Rust + "src/main.rs", "src/lib.rs", + # Swift + "main.swift", "Package.swift", "Sources/main.swift", + # Ruby + "app.rb", "main.rb", "lib/app.rb", + # PHP + "index.php", "app.php", "public/index.php", + # Go + "cmd/*/main.go", + # Scala + "src/main/scala/Main.scala", + # Haskell + "Main.hs", "app/Main.hs", + # Clojure + "src/core.clj", "-main.clj", + # Elixir + "lib/application.ex", "mix.exs", +] + +LINT_FILES = [ + ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yml", ".eslintrc.yaml", + "eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", + ".prettierrc", ".prettierrc.json", ".prettierrc.js", ".prettierrc.yml", + "prettier.config.js", "prettier.config.mjs", + ".editorconfig", + "tsconfig.json", "tsconfig.base.json", "tsconfig.build.json", + ".golangci.yml", ".golangci.yaml", + "setup.cfg", ".flake8", ".pylintrc", "mypy.ini", + ".rubocop.yml", "phpcs.xml", "phpstan.neon", + "biome.json", "biome.jsonc" +] + +ENV_TEMPLATES = [".env.example", ".env.template", ".env.sample", ".env.defaults", ".env.local.example"] + +SOURCE_EXTS = [ + "ts", "tsx", "js", "jsx", "mjs", "cjs", + "py", "go", "java", "kt", "rb", "php", + "rs", "cs", "cpp", "c", "h", "ex", "exs", + "swift", "scala", "clj", "cljs", "lua", + "vim", "vim", "hs", "ml", "ml", "nim", "cr", + "r", "jl", "groovy", "gradle", "xml", "json" +] + +MONOREPO_FILES = ["pnpm-workspace.yaml", "lerna.json", "nx.json", "rush.json", "turbo.json", "moon.yml"] +MONOREPO_DIRS = ["packages", "apps", "libs", "services", "modules"] + +CI_CD_CONFIGS = { + ".github/workflows": "GitHub Actions", + ".gitlab-ci.yml": "GitLab CI", + "Jenkinsfile": "Jenkins", + ".circleci/config.yml": "CircleCI", + ".travis.yml": "Travis CI", + "azure-pipelines.yml": "Azure Pipelines", + "appveyor.yml": "AppVeyor", + ".drone.yml": "Drone CI", + ".woodpecker.yml": "Woodpecker CI", + "bitbucket-pipelines.yml": "Bitbucket Pipelines" +} + +CONTAINER_FILES = [ + "Dockerfile", "docker-compose.yml", "docker-compose.yaml", + ".dockerignore", "Dockerfile.*", + "k8s", "kustomization.yaml", "Chart.yaml", + "Vagrantfile", "podman-compose.yml" +] + +SECURITY_CONFIGS = [ + ".snyk", "security.txt", "SECURITY.md", + ".dependabot.yml", ".whitesource", + "sbom.json", "sbom.spdx", ".bandit.yaml" +] + +PERFORMANCE_MARKERS = [ + "benchmark", "bench", "perf.data", ".prof", + "k6.js", "locustfile.py", "jmeter.jmx" +] + + +def parse_args(): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Scan the current directory (project root) and output discovery information " + "for the acquire-codebase-knowledge skill.", + add_help=True + ) + parser.add_argument( + "--output", + type=str, + help="Write output to FILE instead of stdout" + ) + return parser.parse_args() + + +def should_exclude(path: Path) -> bool: + """Check if a path should be excluded from scanning.""" + return any(part in EXCLUDE_DIRS for part in path.parts) + + +def get_directory_tree(max_depth: int = TREE_MAX_DEPTH) -> List[str]: + """Get directory tree up to max_depth.""" + files = [] + + def walk(path: Path, depth: int): + if depth > max_depth or should_exclude(path): + return + try: + for item in sorted(path.iterdir()): + if should_exclude(item): + continue + rel_path = item.relative_to(Path.cwd()) + files.append(str(rel_path)) + if item.is_dir(): + walk(item, depth + 1) + except (PermissionError, OSError): + pass + + walk(Path.cwd(), 0) + return files[:TREE_LIMIT] + + +def find_manifest_files() -> List[str]: + """Find manifest files matching patterns.""" + found = [] + for pattern in MANIFESTS: + if "*" in pattern: + # Handle glob patterns + for path in Path.cwd().glob(pattern): + if path.is_file() and not should_exclude(path): + found.append(path.name) + else: + path = Path.cwd() / pattern + if path.is_file(): + found.append(pattern) + return sorted(set(found)) + + +def read_file_preview(filepath: Path, max_lines: int = MANIFEST_PREVIEW_LINES) -> str: + """Read file with line limit.""" + try: + with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + + if not lines: + return "None found." + + preview = ''.join(lines[:max_lines]) + if len(lines) > max_lines: + preview += f"\n[TRUNCATED] Showing first {max_lines} of {len(lines)} lines." + return preview + except Exception as e: + return f"[Error reading file: {e}]" + + +def find_entry_points() -> List[str]: + """Find entry point candidates.""" + found = [] + for candidate in ENTRY_CANDIDATES: + if Path(candidate).exists(): + found.append(candidate) + return found + + +def find_lint_config() -> List[str]: + """Find linting and formatting config files.""" + found = [] + for filename in LINT_FILES: + if Path(filename).exists(): + found.append(filename) + return found + + +def find_env_templates() -> List[tuple]: + """Find environment variable templates.""" + found = [] + for filename in ENV_TEMPLATES: + path = Path(filename) + if path.exists(): + found.append((filename, path)) + return found + + +def search_todos() -> List[str]: + """Search for TODO/FIXME/HACK comments.""" + todos = [] + patterns = ["TODO", "FIXME", "HACK"] + exclude_dirs_str = "|".join(EXCLUDE_DIRS | {"test", "tests", "__tests__", "spec", "__mocks__", "fixtures"}) + + try: + for root, dirs, files in os.walk(Path.cwd()): + # Remove excluded directories from dirs to prevent os.walk from descending + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS and d not in {"test", "tests", "__tests__", "spec", "__mocks__", "fixtures"}] + + for file in files: + # Check file extension + ext = Path(file).suffix.lstrip('.') + if ext not in SOURCE_EXTS: + continue + + filepath = Path(root) / file + try: + with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + for line_num, line in enumerate(f, 1): + for pattern in patterns: + if pattern in line: + rel_path = filepath.relative_to(Path.cwd()) + todos.append(f"{rel_path}:{line_num}: {line.strip()}") + except Exception: + pass + except Exception: + pass + + return todos[:TODO_LIMIT] + + +def get_git_commits() -> List[str]: + """Get recent git commits.""" + try: + result = subprocess.run( + ["git", "log", "--oneline", "-n", str(RECENT_COMMITS_LIMIT)], + capture_output=True, + text=True, + cwd=Path.cwd() + ) + if result.returncode == 0: + return result.stdout.strip().split('\n') if result.stdout.strip() else [] + return [] + except Exception: + return [] + + +def get_git_churn() -> List[str]: + """Get high-churn files from last 90 days.""" + try: + result = subprocess.run( + ["git", "log", "--since=90 days ago", "--name-only", "--pretty=format:"], + capture_output=True, + text=True, + cwd=Path.cwd() + ) + if result.returncode == 0: + files = [f.strip() for f in result.stdout.split('\n') if f.strip()] + # Count occurrences + from collections import Counter + counts = Counter(files) + churn = sorted(counts.items(), key=lambda x: x[1], reverse=True) + return [f"{count:4d} {filename}" for filename, count in churn[:CHURN_LIMIT]] + return [] + except Exception: + return [] + + +def is_git_repo() -> bool: + """Check if current directory is a git repository.""" + try: + subprocess.run( + ["git", "rev-parse", "--git-dir"], + capture_output=True, + cwd=Path.cwd(), + timeout=2 + ) + return True + except Exception: + return False + + +def detect_monorepo() -> List[str]: + """Detect monorepo signals.""" + signals = [] + + for filename in MONOREPO_FILES: + if Path(filename).exists(): + signals.append(f"Monorepo tool detected: {filename}") + + for dirname in MONOREPO_DIRS: + if Path(dirname).is_dir(): + signals.append(f"Sub-package directory found: {dirname}/") + + # Check package.json workspaces + if Path("package.json").exists(): + try: + with open("package.json", 'r') as f: + content = f.read() + if '"workspaces"' in content: + signals.append("package.json has 'workspaces' field (npm/yarn workspaces monorepo)") + except Exception: + pass + + return signals + + +def detect_ci_cd_pipelines() -> List[str]: + """Detect CI/CD pipeline configurations.""" + pipelines = [] + + for config_path, pipeline_name in CI_CD_CONFIGS.items(): + path = Path(config_path) + if path.is_file(): + pipelines.append(f"CI/CD: {pipeline_name}") + elif path.is_dir(): + # Check for workflow files in directory + try: + if list(path.glob("*.yml")) or list(path.glob("*.yaml")): + pipelines.append(f"CI/CD: {pipeline_name}") + except Exception: + pass + + return pipelines + + +def detect_containers() -> List[str]: + """Detect containerization and orchestration configs.""" + containers = [] + + for config in CONTAINER_FILES: + path = Path(config) + if path.is_file(): + if "Dockerfile" in config: + containers.append("Container: Docker found") + elif "docker-compose" in config: + containers.append("Orchestration: Docker Compose found") + elif config.endswith(".yaml") or config.endswith(".yml"): + containers.append(f"Container/Orchestration: {config}") + elif path.is_dir(): + if config in ["k8s", "kubernetes"]: + containers.append("Orchestration: Kubernetes configs found") + try: + if list(path.glob("*.yml")) or list(path.glob("*.yaml")): + containers.append(f"Container/Orchestration: {config}/ directory found") + except Exception: + pass + + return containers + + +def detect_security_configs() -> List[str]: + """Detect security and compliance configurations.""" + security = [] + + for config in SECURITY_CONFIGS: + if Path(config).exists(): + config_name = config.replace(".yml", "").replace(".yaml", "").lstrip(".") + security.append(f"Security: {config_name}") + + return security + + +def detect_performance_markers() -> List[str]: + """Detect performance testing and profiling markers.""" + performance = [] + + for marker in PERFORMANCE_MARKERS: + if Path(marker).exists(): + performance.append(f"Performance: {marker} found") + else: + # Check for directories + try: + if Path(marker).is_dir(): + performance.append(f"Performance: {marker}/ directory found") + except Exception: + pass + + return performance + + +def collect_code_metrics() -> dict: + """Collect code metrics: file counts by extension, total LOC.""" + metrics = { + "total_files": 0, + "by_extension": {}, + "by_language": {}, + "total_lines": 0, + "largest_files": [] + } + + # Language mapping + lang_map = { + "ts": "TypeScript", "tsx": "TypeScript/React", "js": "JavaScript", + "jsx": "JavaScript/React", "py": "Python", "go": "Go", + "java": "Java", "kt": "Kotlin", "rs": "Rust", + "cs": "C#", "rb": "Ruby", "php": "PHP", + "swift": "Swift", "scala": "Scala", "ex": "Elixir", + "cpp": "C++", "c": "C", "h": "C Header", + "clj": "Clojure", "lua": "Lua", "hs": "Haskell" + } + + file_sizes = [] + + try: + for root, dirs, files in os.walk(Path.cwd()): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + + for file in files: + filepath = Path(root) / file + ext = filepath.suffix.lstrip('.') + + if not ext or ext in {"pyc", "o", "a", "so"}: + continue + + try: + size = filepath.stat().st_size + file_sizes.append((filepath.relative_to(Path.cwd()), size)) + + metrics["total_files"] += 1 + metrics["by_extension"][ext] = metrics["by_extension"].get(ext, 0) + 1 + + lang = lang_map.get(ext, "Other") + metrics["by_language"][lang] = metrics["by_language"].get(lang, 0) + 1 + + # Count lines for text files + if ext in SOURCE_EXTS and size < 1_000_000: # Skip huge files + try: + with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + metrics["total_lines"] += len(f.readlines()) + except Exception: + pass + except Exception: + pass + + # Top 10 largest files + file_sizes.sort(key=lambda x: x[1], reverse=True) + metrics["largest_files"] = [ + f"{str(f)}: {s/1024:.1f}KB" for f, s in file_sizes[:10] + ] + + except Exception: + pass + + return metrics + + +def print_section(title: str, content: List[str], output_file=None) -> None: + """Print a section with title and content.""" + lines = [f"\n=== {title} ==="] + + if isinstance(content, list): + lines.extend(content if content else ["None found."]) + elif isinstance(content, str): + lines.append(content) + + text = '\n'.join(lines) + '\n' + + if output_file: + output_file.write(text) + else: + print(text, end='') + + +def main(): + """Main entry point.""" + args = parse_args() + + output_file = None + if args.output: + output_dir = Path(args.output).parent + output_dir.mkdir(parents=True, exist_ok=True) + output_file = open(args.output, 'w', encoding='utf-8') + print(f"Writing output to: {args.output}", file=sys.stderr) + + try: + # Directory tree + print_section( + f"DIRECTORY TREE (max depth {TREE_MAX_DEPTH}, source files only)", + get_directory_tree(), + output_file + ) + + # Stack detection + manifests = find_manifest_files() + if manifests: + manifest_content = [""] + for manifest in manifests: + manifest_path = Path(manifest) + manifest_content.append(f"--- {manifest} ---") + if manifest == "bun.lockb": + manifest_content.append("[Binary lockfile — see package.json for dependency details.]") + else: + manifest_content.append(read_file_preview(manifest_path)) + print_section("STACK DETECTION (manifest files)", manifest_content, output_file) + else: + print_section("STACK DETECTION (manifest files)", ["No recognized manifest files found in project root."], output_file) + + # Entry points + entries = find_entry_points() + if entries: + entry_content = [f"Found: {e}" for e in entries] + print_section("ENTRY POINTS", entry_content, output_file) + else: + print_section("ENTRY POINTS", ["No common entry points found. Check 'main' or 'scripts.start' in manifest files above."], output_file) + + # Linting config + lint = find_lint_config() + if lint: + lint_content = [f"Found: {l}" for l in lint] + print_section("LINTING AND FORMATTING CONFIG", lint_content, output_file) + else: + print_section("LINTING AND FORMATTING CONFIG", ["No linting or formatting config files found in project root."], output_file) + + # Environment templates + envs = find_env_templates() + if envs: + env_content = [] + for filename, filepath in envs: + env_content.append(f"--- {filename} ---") + env_content.append(read_file_preview(filepath)) + print_section("ENVIRONMENT VARIABLE TEMPLATES", env_content, output_file) + else: + print_section("ENVIRONMENT VARIABLE TEMPLATES", ["No .env.example or .env.template found. Identify required environment variables by searching the code and config for environment variable reads."], output_file) + + # TODOs + todos = search_todos() + if todos: + print_section("TODO / FIXME / HACK (production code only, test dirs excluded)", todos, output_file) + else: + print_section("TODO / FIXME / HACK (production code only, test dirs excluded)", ["None found."], output_file) + + # Git info + if is_git_repo(): + commits = get_git_commits() + if commits: + print_section("GIT RECENT COMMITS (last 20)", commits, output_file) + else: + print_section("GIT RECENT COMMITS (last 20)", ["No commits found."], output_file) + + churn = get_git_churn() + if churn: + print_section("HIGH-CHURN FILES (last 90 days, top 20)", churn, output_file) + else: + print_section("HIGH-CHURN FILES (last 90 days, top 20)", ["None found."], output_file) + else: + print_section("GIT RECENT COMMITS (last 20)", ["Not a git repository or no commits yet."], output_file) + print_section("HIGH-CHURN FILES (last 90 days, top 20)", ["Not a git repository."], output_file) + + # Monorepo detection + monorepo = detect_monorepo() + if monorepo: + print_section("MONOREPO SIGNALS", monorepo, output_file) + else: + print_section("MONOREPO SIGNALS", ["No monorepo signals detected."], output_file) + + # Code metrics + metrics = collect_code_metrics() + metrics_output = [ + f"Total files scanned: {metrics['total_files']}", + f"Total lines of code: {metrics['total_lines']}", + "" + ] + if metrics["by_language"]: + metrics_output.append("Files by language:") + for lang, count in sorted(metrics["by_language"].items(), key=lambda x: x[1], reverse=True): + metrics_output.append(f" {lang}: {count}") + if metrics["largest_files"]: + metrics_output.append("") + metrics_output.append("Top 10 largest files:") + metrics_output.extend(metrics["largest_files"]) + print_section("CODE METRICS", metrics_output, output_file) + + # CI/CD Detection + ci_cd = detect_ci_cd_pipelines() + if ci_cd: + print_section("CI/CD PIPELINES", ci_cd, output_file) + else: + print_section("CI/CD PIPELINES", ["No CI/CD pipelines detected."], output_file) + + # Container Detection + containers = detect_containers() + if containers: + print_section("CONTAINERS & ORCHESTRATION", containers, output_file) + else: + print_section("CONTAINERS & ORCHESTRATION", ["No containerization configs detected."], output_file) + + # Security Configs + security = detect_security_configs() + if security: + print_section("SECURITY & COMPLIANCE", security, output_file) + else: + print_section("SECURITY & COMPLIANCE", ["No security configs detected."], output_file) + + # Performance Markers + performance = detect_performance_markers() + if performance: + print_section("PERFORMANCE & TESTING", performance, output_file) + else: + print_section("PERFORMANCE & TESTING", ["No performance testing configs detected."], output_file) + + # Final message + final_msg = "\n=== SCAN COMPLETE ===\n" + if output_file: + output_file.write(final_msg) + else: + print(final_msg, end='') + + return 0 + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + finally: + if output_file: + output_file.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/add-capability/SKILL.md b/.github/skills/add-capability/SKILL.md new file mode 100644 index 00000000..390a6503 --- /dev/null +++ b/.github/skills/add-capability/SKILL.md @@ -0,0 +1,50 @@ +--- +name: add-capability +description: "Retrofit an existing CoreEx domain or service with additional capabilities. Use when: adding Outbox.Relay, Subscribe, Azure Service Bus integration, subscriber scaffolding, or aligning messaging and integration wiring for an existing domain." +argument-hint: "Optional: solution, domain, and requested capability — e.g. 'Contoso Products add relay and subscribers'" +tags: ["retrofit", "messaging", "service-bus", "outbox-relay", "subscribers", "integration"] +--- + +# Add Capability + +Retrofitting an existing domain with messaging and integration support. Choose only the missing pieces. + +## When to Use + +- Add `Outbox.Relay` to publish integration events reliably. +- Add `Subscribe` to consume integration events from other services. +- Add or align Azure Service Bus wiring. +- Add initial subscriber classes and registration. + +## When Not to Use + +- Creating a new domain from scratch — use `/generate-domain`. +- Bootstrapping a new solution — use the starter bootstrap workflow. +- Non-CoreEx brownfield migrations. + +## MVP Assumptions + +- Existing CoreEx-style domain shape (Contracts, Application, Infrastructure, Api, Database). +- SQL Server for outbox support. +- Azure Service Bus for publish/subscribe. + +If different backends are needed, ask before making changes. + +## Workflow + +1. **Load context**: Read host-setup, event-subscribers, application-services, database-project instructions + sample hosts. +2. **Inspect domain state**: Detect existing hosts, database support, messaging packages, event subjects. +3. **Clarify**: Ask only what cannot be inferred (which domain, which capability, topics/payloads if needed). +4. **Choose mode**: A (relay), B (subscribe), C (both), or D (subscribers only). +5. **Apply changes**: Targeted edits only — reuse patterns, don't regenerate. +6. **Validate**: Run checklist, confirm clean build. + +For detailed step-by-step workflow, see [`references/workflow.md`](references/workflow.md). + +## Key References + +- [Host Setup Conventions](/.github/instructions/host-setup.instructions.md) +- [Event Subscriber Conventions](/.github/instructions/event-subscribers.instructions.md) +- [Application Service Conventions](/.github/instructions/application-services.instructions.md) +- [Database Project Conventions](/.github/instructions/database-project.instructions.md) +- Sample hosts: `samples/src/Contoso.Products.Api/Program.cs`, `samples/src/Contoso.Products.Subscribe/Program.cs`, `samples/src/Contoso.Products.Outbox.Relay/Program.cs` diff --git a/.github/skills/add-capability/references/messaging-retrofit-checklist.md b/.github/skills/add-capability/references/messaging-retrofit-checklist.md new file mode 100644 index 00000000..bb11fbc6 --- /dev/null +++ b/.github/skills/add-capability/references/messaging-retrofit-checklist.md @@ -0,0 +1,46 @@ +# Messaging Retrofit Checklist + +Use this checklist as the completion gate for `/add-capability` messaging and integration retrofits. + +## Discovery + +- [ ] Identified the target domain and its existing project/host shape. +- [ ] Determined whether API, Database, Outbox.Relay, and Subscribe projects already exist. +- [ ] Determined whether SQL Server/outbox and Azure Service Bus are already present, missing, or intentionally not used. +- [ ] Confirmed any user choices that could not be inferred safely. + +## Project and Package Alignment + +- [ ] Added only the missing projects required by the requested retrofit. +- [ ] Added only the missing package and project references required by the affected hosts. +- [ ] Preserved the existing layered references and naming conventions. + +## Relay Retrofit + +- [ ] Relay host was added or aligned when requested. +- [ ] Relay `Program.cs` uses the expected CoreEx host setup, SQL Server relay wiring, Service Bus publisher wiring, health checks, and telemetry. +- [ ] API host has event formatter and outbox publisher wiring when the domain is expected to publish integration events. +- [ ] Database project contains required outbox tables and stored procedures when relay support is added. + +## Subscribe Retrofit + +- [ ] Subscribe host was added or aligned when requested. +- [ ] Subscribe `Program.cs` uses hosted service manager, subscribed manager, Service Bus receiver, hosted service mapping, health checks, and telemetry. +- [ ] Subscriber classes inherit from `SubscribedBase`. +- [ ] Subscriber classes use `[ScopedService]` and `[Subscribe("...")]`. +- [ ] Subscriber logic delegates to Application services rather than embedding business logic. +- [ ] Shared subscriber error handling is added where needed. + +## Host and Convention Alignment + +- [ ] Middleware order follows repo conventions. +- [ ] Dynamic service registration is used where expected. +- [ ] OpenTelemetry-compatible wiring is preserved or aligned for the affected hosts. +- [ ] Health endpoints and hosted service mapping are present where applicable. + +## Validation + +- [ ] Affected projects build or pass diagnostics. +- [ ] Any related tests were added or updated where practical. +- [ ] The final summary distinguishes completed retrofits from any blocked or intentionally deferred items. +- [ ] Any remaining user decisions are listed explicitly as follow-up items. diff --git a/.github/skills/add-capability/references/messaging-retrofit-checkpoints.md b/.github/skills/add-capability/references/messaging-retrofit-checkpoints.md new file mode 100644 index 00000000..e97110c9 --- /dev/null +++ b/.github/skills/add-capability/references/messaging-retrofit-checkpoints.md @@ -0,0 +1,84 @@ +# Messaging Retrofit Checkpoints + +Use these checkpoints when inspecting an existing domain before adding messaging and integration capabilities. + +## 1. Domain Shape Detection + +Look for these project patterns first: + +- `{Solution}.{Domain}.Api` +- `{Solution}.{Domain}.Application` +- `{Solution}.{Domain}.Infrastructure` +- `{Solution}.{Domain}.Database` +- `{Solution}.{Domain}.Outbox.Relay` +- `{Solution}.{Domain}.Subscribe` + +If the domain does not follow a recognizable CoreEx-style layered shape, treat the retrofit as ambiguous and ask before proceeding. + +## 2. Host Detection Signals + +| Capability or host | Evidence to inspect | Positive signal | +|---|---|---| +| API host | `Program.cs`, controllers, `*.Api.csproj` | `AddMvcWebApi`, `AddHttpWebApi`, controllers, OpenAPI setup | +| Relay host | `*.Outbox.Relay\\Program.cs`, relay csproj | `AddSqlServerOutboxRelay`, `AddSqlServerOutboxRelayHostedService`, `AddAzureServiceBusPublisher` | +| Subscribe host | `*.Subscribe\\Program.cs`, `Subscribe\\**\\*.cs` | `AddSubscribedManager`, `AzureServiceBusReceiving`, `MapHostedServices`, subscriber classes | +| Outbox publisher in API | API `Program.cs`, infrastructure repository/publisher files | `AddEventFormatter`, `AddSqlServerOutboxPublisher` | +| Service Bus support | affected host `Program.cs`, csproj references | `AddAzureServiceBusClient("ServiceBus")`, `CoreEx.Azure.Messaging.ServiceBus` | +| Telemetry alignment | `Program.cs` | `WithCoreExTelemetry`, `WithCoreExSqlServerTelemetry`, `WithCoreExServiceBusTelemetry`, `UseOtlpExporter` | + +## 3. Database and Outbox Detection + +When adding a relay or reliable publication support, inspect for: + +- `*.Database` project. +- outbox migrations. +- outbox stored procedures: + - `spOutboxEnqueue.g.sql` + - `spOutboxLeaseAcquire.g.sql` + - `spOutboxLeaseRelease.g.sql` + - `spOutboxBatchClaim.g.sql` + - `spOutboxBatchComplete.g.sql` + - `spOutboxBatchCancel.g.sql` +- database `Program.cs` and `dbex.yaml`. + +If relay is requested and these assets are missing, plan to add them or stop and ask if the domain is intentionally non-SQL/outbox-based. + +## 4. Subscriber Detection + +Inspect subscriber code for: + +- `[ScopedService]` +- `[Subscribe("...")]` +- inheritance from `SubscribedBase` +- `OnReceiveAsync` +- optional shared `ErrorHandler` +- delegation to Application services rather than embedded business logic + +## 5. Recommended MVP Retrofit Modes + +| Current state | Requested need | Recommended retrofit | +|---|---|---| +| API + Database, no relay | reliable integration-event publishing | Add `Outbox.Relay`, align API outbox publisher wiring | +| API + Database, no subscribe | consume external events | Add `Subscribe` host and initial subscribers | +| API + Database, no relay, no subscribe | publish and consume | Add both relay and subscribe | +| Subscribe host exists | new subjects or handlers | Add subscriber classes and registration only | +| API exists, no recognizable database/outbox shape | relay | Ask before proceeding; MVP assumes SQL Server/outbox path | + +## 6. Ambiguity Triggers + +Ask before changing anything when: + +- multiple similarly named domains could match the request. +- there is already partial relay or subscribe wiring that does not match the sample conventions. +- the domain appears to use non-SQL Server persistence for write workflows. +- the domain appears to use a broker other than Azure Service Bus. +- event subjects, payload contracts, or application service entry points are unclear. + +## 7. Default Initial Assumptions + +Unless the user says otherwise, the MVP retrofit assistant should assume: + +- SQL Server for outbox-backed write workflows. +- Azure Service Bus for publish/subscribe integration. +- OpenTelemetry-compatible host telemetry wiring should be preserved or aligned. +- relay and subscribe hosts should mirror the sample architecture, not invent a new host style. diff --git a/.github/skills/add-capability/references/workflow.md b/.github/skills/add-capability/references/workflow.md new file mode 100644 index 00000000..11e2bb21 --- /dev/null +++ b/.github/skills/add-capability/references/workflow.md @@ -0,0 +1,93 @@ +# Add Capability Workflow + +## Step 1: Load Context + +Before making changes, load: + +1. Instruction files in `/.github/instructions/`: + - `host-setup.instructions.md` + - `event-subscribers.instructions.md` + - `application-services.instructions.md` + - `database-project.instructions.md` + +2. Sample host wiring from: + - `samples/src/Contoso.Products.Api/Program.cs` + - `samples/src/Contoso.Products.Subscribe/Program.cs` + - `samples/src/Contoso.Products.Outbox.Relay/Program.cs` + +3. Domain templates under `/.github/templates/domain/**` + +## Step 2: Inspect Domain State + +Determine current shape before proposing changes. + +Inspect for: +- Domain boundary and project names +- Existing hosts: `*.Api`, `*.Outbox.Relay`, `*.Subscribe` +- Database support: `*.Database` project, outbox tables/procedures, SQL Server references +- Messaging support: `CoreEx.Events`, `CoreEx.Azure.Messaging.ServiceBus`, `AddEventFormatter`, `AddSqlServerOutboxPublisher`, `AddSubscribedManager`, `AzureServiceBusReceiving` +- Existing telemetry and health wiring +- Integration-event semantics: subjects, subscriber classes, related service methods + +Use conservative detection. Ask if ambiguous. + +## Step 3: Clarify User Intent + +Ask only what cannot be inferred: +- Which domain to retrofit? +- Which capability: relay, subscribe, subscriber classes, or combined? +- Use SQL Server and Azure Service Bus as defaults? +- If adding subscribers: what subjects and payload contracts? +- Infrastructure/host wiring only, or also application-facing handlers? + +## Step 4: Choose Retrofit Mode + +### Mode A — Add Outbox.Relay +Use when domain already writes data and should publish integration events reliably. + +Expected work: +- Create `*.Outbox.Relay` project if missing +- Add packages and project references +- Add relay `Program.cs` wiring per host-setup conventions +- Ensure database has outbox tables and procedures +- Ensure API host has event formatter + outbox publisher wiring + +### Mode B — Add Subscribe +Use when domain must consume integration events/commands from other services. + +Expected work: +- Create `*.Subscribe` project if missing +- Add Service Bus client and receiver wiring +- Add hosted service manager and mapping +- Add subscriber classes and registration +- Reuse reference data, cache, infrastructure, telemetry patterns + +### Mode C — Add Both Relay and Subscribe +Service publishes its own events AND consumes events from others. + +### Mode D — Add Subscribers to Existing Subscribe Host +Host exists but subscriber classes, registration, or error handling incomplete. + +## Step 5: Apply Incremental Changes + +Prefer targeted edits over regeneration. + +Rules: +1. Reuse existing project naming and layering +2. Do not duplicate wiring that exists +3. Keep subscriber logic thin; delegate to Application services +4. Preserve host middleware order and telemetry conventions +5. Reuse domain templates only for missing pieces +6. If domain shape inconsistent, stop and explain blockers + +## Step 6: Validate + +Run messaging-retrofit-checklist.md completion gate. + +Minimum criteria: +- `Program.cs` files follow host setup conventions +- Required package/project references present +- Relay outbox database assets exist when relay added +- Subscribers registered with `SubscribedBase` patterns +- Files fit existing naming/layering conventions +- Clean build/diagnostics diff --git a/.github/skills/aspire/SKILL.md b/.github/skills/aspire/SKILL.md new file mode 100644 index 00000000..5e8b6394 --- /dev/null +++ b/.github/skills/aspire/SKILL.md @@ -0,0 +1,108 @@ +--- +name: aspire +description: "Orchestrates Aspire distributed applications using the Aspire CLI for running, debugging, and managing distributed apps. USE FOR: aspire start, aspire stop, start aspire app, aspire describe, list aspire integrations, debug aspire issues, view aspire logs, add aspire resource, aspire dashboard, update aspire apphost. DO NOT USE FOR: non-Aspire .NET apps (use dotnet CLI), container-only deployments (use docker/podman), Azure deployment after local testing (use azure-deploy skill). INVOKES: Aspire CLI commands (aspire start, aspire describe, aspire otel logs, aspire docs search, aspire add), bash. FOR SINGLE OPERATIONS: Use Aspire CLI commands directly for quick resource status or doc lookups." +argument-hint: "Optional: resource name, command (start/stop/logs), or debug context" +tags: ["aspire", "orchestration", "distributed-apps", "cli", "debugging"] +--- + +# Aspire Skill + +This repository uses Aspire to orchestrate its distributed application. Resources are defined in the AppHost project (`apphost.cs` or `apphost.ts`). + +## CLI command reference + +| Task | Command | +|---|---| +| Start the app | `aspire start` | +| Start isolated (worktrees) | `aspire start --isolated` | +| Restart the app | `aspire start` (stops previous automatically) | +| Wait for resource healthy | `aspire wait ` | +| Stop the app | `aspire stop` | +| List resources | `aspire describe` or `aspire resources` | +| Run resource command | `aspire resource ` | +| Start/stop/restart resource | `aspire resource start|stop|restart` | +| Rebuild a .NET project resource | `aspire resource rebuild` | +| View console logs | `aspire logs [resource]` | +| View structured logs | `aspire otel logs [resource]` | +| View traces | `aspire otel traces [resource]` | +| Logs for a trace | `aspire otel logs --trace-id ` | +| Add an integration | `aspire add` | +| List running AppHosts | `aspire ps` | +| Update AppHost packages | `aspire update` | +| Search docs | `aspire docs search ` | +| Get doc page | `aspire docs get ` | +| List doc pages | `aspire docs list` | +| Environment diagnostics | `aspire doctor` | +| List resource MCP tools | `aspire mcp tools` | +| Call resource MCP tool | `aspire mcp call --input ` | + +Most commands support `--format Json` for machine-readable output. Use `--apphost ` to target a specific AppHost. + +## Key workflows + +### Running in agent environments + +Use `aspire start` to run the AppHost in the background. When working in a git worktree, use `--isolated` to avoid port conflicts and to prevent sharing user secrets or other local state with other running instances: + +```bash +aspire start --isolated +``` + +Use `aspire wait ` to block until a resource is healthy before interacting with it: + +```bash +aspire start --isolated +aspire wait myapi +``` + +### Applying code changes + +Choose the right action based on what changed: + +| What changed | Action | Why | +|---|---|---| +| AppHost project (`apphost.cs`/`apphost.ts`) | `aspire start` | Resource graph changed; full restart required | +| Compiled .NET project resource | `aspire resource rebuild` | Rebuilds and restarts only that resource | +| Interpreted resource (JavaScript, Python) | Typically nothing — most run with file watchers | Restart the resource if no watch mode is configured | + +**Never restart the entire AppHost just because a single resource changed.** Use `aspire resource rebuild` for .NET project resources — it coordinates stop, build, and restart for just that resource. Use `aspire describe --format Json` to check which commands a resource supports. + +### Debugging issues + +Before making code changes, inspect the app state: + +1. `aspire describe` — check resource status +2. `aspire otel logs ` — view structured logs +3. `aspire logs ` — view console output +4. `aspire otel traces ` — view distributed traces + +### Adding integrations + +Use `aspire docs search` to find integration documentation, then `aspire docs get` to read the full guide. Use `aspire add` to add the integration package to the AppHost. + +After adding an integration, restart the app with `aspire start` for the new resource to take effect. + +### Using resource MCP tools + +Some resources expose MCP tools (e.g. `WithPostgresMcp()` adds SQL query tools). Discover and call them via CLI: + +```bash +aspire mcp tools # list available tools +aspire mcp tools --format Json # includes input schemas +aspire mcp call --input '{"key":"value"}' # invoke a tool +``` + +## Important rules + +- **Always start the app first** (`aspire start`) before making changes to verify the starting state. +- **To restart, just run `aspire start` again** — it automatically stops the previous instance. NEVER use `aspire stop` then `aspire run`. NEVER use `aspire run` at all. +- **Only restart the AppHost when AppHost code changes.** For .NET project resources, use `aspire resource rebuild` instead. +- Use `--isolated` when working in a worktree. +- **Avoid persistent containers** early in development to prevent state management issues. +- **Never install the Aspire workload** — it is obsolete. +- **For Aspire API reference and documentation, prefer `aspire docs search ` and `aspire docs get `** over searching NuGet package caches or XML doc files. The CLI provides up-to-date content from aspire.dev. +- Prefer `aspire.dev` and `learn.microsoft.com/microsoft/aspire` for official documentation. + +## Playwright CLI + +If configured, use Playwright CLI for functional testing of resources. Get endpoints via `aspire describe`. Run `playwright-cli --help` for available commands. \ No newline at end of file diff --git a/.github/skills/generate-domain/SKILL.md b/.github/skills/generate-domain/SKILL.md new file mode 100644 index 00000000..20784a36 --- /dev/null +++ b/.github/skills/generate-domain/SKILL.md @@ -0,0 +1,38 @@ +--- +name: generate-domain +description: "Generate a new CoreEx domain or microservice. Use when: scaffolding a new domain, creating a new microservice, adding a new bounded context, generating sample domain code like shopping or product, creating contracts/application/infrastructure/API/database layers from scratch following CoreEx conventions." +argument-hint: "Optional: solution prefix, domain name, and root entity — e.g. 'Contoso Orders Order'" +tags: ["scaffolding", "microservice", "bounded-context", "code-generation", "layering"] +--- + +# Generate Domain + +Scaffolds all layers of a new CoreEx domain — Contracts, Application, Infrastructure, API, Database, and baseline Unit/Api tests — aligned to the Contoso sample architecture. + +## When to Use + +- Scaffolding a new microservice or bounded context from scratch. +- Generating domain code that follows CoreEx conventions (ETag, ChangeLog, Outbox, FusionCache, NSwag). +- Producing code that mirrors the Shopping or Product sample domains. + +## Inputs Required + +Before generating, confirm with user: + +| Input | Example | +|-------|---------| +| Solution prefix | `Contoso` | +| Domain name | `Orders` | +| Root entity | `Order` | +| Fields, ref-data codes, operations, event subjects | Confirm before generating | + +## Workflow Overview + +For complete step-by-step workflow covering all 8 phases (Contracts, Application, Infrastructure, API, Database, Tests, Quality Gates, Naming), see [`references/workflow.md`](references/workflow.md). + +## Key References + +- All instruction files: `/.github/instructions/*.instructions.md` +- Templates: `/.github/templates/domain/**` +- Checklist: `DomainScaffold.checklist.md` +- Sample domains: `samples/src/Contoso.Products/`, `samples/src/Contoso.Shopping/` \ No newline at end of file diff --git a/.github/skills/generate-domain/references/workflow.md b/.github/skills/generate-domain/references/workflow.md new file mode 100644 index 00000000..e0696315 --- /dev/null +++ b/.github/skills/generate-domain/references/workflow.md @@ -0,0 +1,119 @@ +# Generate Domain Detailed Workflow + +## Phase 1: Load Context + +Before generating any files: + +1. Read all `.github/instructions/*.instructions.md` — especially api-controllers, application-services, contracts, database-project, repositories, tests, validators, host-setup +2. Load all templates in `/.github/templates/domain/**` +3. Load `DomainScaffold.checklist.md` to track completion gates + +## Phase 2: Gather and Confirm Inputs + +Ask user for any values not supplied. Confirm all before creating files. + +| Input | Example | +|-------|---------| +| Solution prefix | `Contoso` | +| Domain name | `Orders` | +| Root entity name | `Order` | +| Root entity fields | Names, types, ref-data codes, read-only flags | +| Child entity (optional) | `OrderItem` with fields | +| Operations | Create / Read / Update / Patch / Delete | +| Event subjects | Confirm: `{solution}.{domain}.{entity}.{action}.v1` | + +## Phase 3: Generate Contracts Layer + +`{Solution}.{Domain}.Contracts` + +In order: +1. `GlobalUsing.cs` — usings: `CoreEx.Entities`, `CoreEx.Localization`, `CoreEx.RefData`, `System.ComponentModel`, `System.Text.Json.Serialization` +2. `{Entity}Base.cs` — `[Contract] partial class` with `IIdentifier`. Use `[ReadOnly(true)]` for server fields. Use `[ReferenceData]` for ref-data codes. Add `[Localization("...")]` for poor property names. +3. `{Entity}.cs` — extends `{Entity}Base`, implements `IETag, IChangeLog`. Mark `ETag` and `ChangeLog` as `[ReadOnly(true)]`. +4. `{Entity}Lite.cs` (optional) — trimmed projection for query responses +5. Reference data types — inherit `ReferenceData`, use `[ReferenceData]`, pair with `{Type}Collection` +6. `{Solution}.{Domain}.Contracts.csproj` + +## Phase 4: Generate Application Layer + +`{Solution}.{Domain}.Application` + +In order: +1. `GlobalUsing.cs` +2. `Interfaces/I{Entity}Service.cs` + `I{Entity}ReadService.cs` (if CQRS) +3. `Repositories/I{Entity}Repository.cs` — return types: `Task<{Entity}?>` (Get), `Task>` (Create/Update), `Task` (Delete), `Task>` (Query) +4. `Validators/{Entity}Validator.cs` — `Validator<{Entity}, {Entity}Validator>` with `.Mandatory()`, `.MaximumLength()`, `.IsValid()`, `.PrecisionScale()` +5. `{Entity}Service.cs` — `[ScopedService]`. Guard inputs. Validate. Wrap mutations in `_unitOfWork.ExecuteAsync(...)`. Emit events inside `WhereMutated(...)` +6. `{Entity}ReadService.cs` (if CQRS) — read-only, no UoW or events +7. `{Solution}.{Domain}.Application.csproj` + +## Phase 5: Generate Infrastructure Layer + +`{Solution}.{Domain}.Infrastructure` + +In order: +1. `{Domain}EfDb.cs` — EF database class with `EfDbSet<{Entity}>` +2. `{Domain}DbContext.cs` — DbContext with entity model config +3. `Repositories/{Entity}Repository.cs` — `[ScopedService]`. Static `QueryArgsConfig`. Apply via `.Where(parsed).OrderBy(parsed).ToMappedItemsResultAsync(...)` +4. `{Domain}OutboxPublisher.cs` +5. `{Solution}.{Domain}.Infrastructure.csproj` + +## Phase 6: Generate API Host + +`{Solution}.{Domain}.Api` + +In order: +1. `Controllers/{Entity}Controller.cs` — mutations (POST, PUT, PATCH, DELETE). `[ApiController, Route("/api/{entities}"), OpenApiTag(...)]`. `[IdempotencyKey]` on POST +2. `Controllers/{Entity}ReadController.cs` — reads (GET single, GET query). `[Query(supportsOrderBy: true), Paging(supportsCount: true)]` +3. `Program.cs` — AddExecutionContext, AddReferenceDataOrchestrator (if needed), AddMvcWebApi, AddHttpWebApi, AddDynamicServicesUsing, FusionCache, SQL Server + EF + Outbox, OpenAPI, telemetry, middleware order +4. `appsettings.json` — connection string placeholders: `SqlServer`, `redis` +5. `GlobalUsing.cs` +6. `{Solution}.{Domain}.Api.csproj` + +## Phase 7: Generate Database + +`{Solution}.{Domain}.Database` + +In order: +1. `Program.cs` — `SqlServerMigrationConsole` with `DataResetFilterPredicate` scoped to `{Domain}` schema +2. `dbex.yaml` — outbox enabled; full table list +3. `Migrations/*.sql` — schema, ref-data, aggregate, child, and outbox tables +4. `Schema/Stored Procedures/*.g.sql` — six outbox stored procedures +5. `Data/ref-data.yaml` — seed data +6. `{Solution}.{Domain}.Database.csproj` + +## Phase 8: Generate Test Projects + +`{Solution}.{Domain}.Test.*` + +In order: +1. Create `{Solution}.{Domain}.Test.Unit` — validator/service-focused tests with `WithGenericTester` +2. Create `{Solution}.{Domain}.Test.Api` — `WithApiTester<{Solution}.{Domain}.Api.Program>` +3. Ensure both use AwesomeAssertions (not FluentAssertions) +4. Add both to solution under `{Domain}` solution folder +5. Group all domain projects together under `{Domain}` folder + +## Quality Gates + +Check before finishing: +- Every injected dependency guarded with `.ThrowIfNull()` +- Every `await` uses `.ConfigureAwait(false)` +- All mutations wrapped in `_unitOfWork.ExecuteAsync(...)` +- Events added inside `WhereMutated(...)` only +- POST endpoints carry `[IdempotencyKey]` +- Both Unit and Api test projects scaffolded +- `dotnet test` passes for Unit and Api projects +- `DomainScaffold.checklist.md` fully checked + +## Naming Conventions + +| Artefact | Pattern | +|----------|---------| +| Namespace root | `{Solution}.{Domain}.{Layer}` | +| Event subjects | `{solution}.{domain}.{entity}.{action}.v1` (lowercase) | +| Write controller | `{Entity}Controller` | +| Read controller | `{Entity}ReadController` | +| Write service | `{Entity}Service` / `I{Entity}Service` | +| Read service | `{Entity}ReadService` / `I{Entity}ReadService` | +| Repository | `{Entity}Repository` / `I{Entity}Repository` | +| Ref-data collection | `{Type}Collection` | diff --git a/.github/templates/domain/Api/Controllers/EntityController.cs.template b/.github/templates/domain/Api/Controllers/EntityController.cs.template new file mode 100644 index 00000000..331cbdc4 --- /dev/null +++ b/.github/templates/domain/Api/Controllers/EntityController.cs.template @@ -0,0 +1,38 @@ +namespace {Solution}.{Domain}.Api.Controllers; + +[ApiController, Route("/api/{entityPluralKebab}"), OpenApiTag("{EntityPlural}")] +public class {Entity}Controller(WebApi webApi, I{Entity}Service service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly I{Entity}Service _service = service.ThrowIfNull(); + + [HttpPost] + [Accepts<{Entity}>] + [ProducesResponseType<{Entity}>(StatusCodes.Status201Created)] + [IdempotencyKey] + public Task PostAsync() => _webApi.PostAsync<{Entity}, {Entity}>(Request, (ro, _) => + { + ro.WithLocationUri(e => new Uri($"/api/{entityPluralKebab}/{e.Id}", UriKind.Relative)); + return _service.CreateAsync(ro.Value); + }); + + [HttpPut("{id}")] + [Accepts<{Entity}>] + [ProducesResponseType(typeof({Entity}), StatusCodes.Status200OK)] + [ProducesNotFoundProblem()] + public Task PutAsync(string id) => _webApi.PutAsync<{Entity}, {Entity}>(Request, (ro, _) + => _service.UpdateAsync(ro.Value.Adjust(e => e.Id = id.Required()))); + + [HttpPatch("{id}")] + [Accepts<{Entity}>(HttpNames.MergePatchJsonMediaTypeName)] + [ProducesResponseType(typeof({Entity}), StatusCodes.Status200OK)] + [ProducesNotFoundProblem()] + public Task PatchAsync(string id) => _webApi.PatchAsync<{Entity}>(Request, + get: (ro, _) => _service.GetAsync(id.Required()), + put: (ro, _) => _service.UpdateAsync(ro.Value.Adjust(e => e.Id = id))); + + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public Task DeleteAsync(string id) => _webApi.DeleteAsync(Request, (_, _) + => _service.DeleteAsync(id.Required())); +} diff --git a/.github/templates/domain/Api/Controllers/EntityReadController.cs.template b/.github/templates/domain/Api/Controllers/EntityReadController.cs.template new file mode 100644 index 00000000..73c37325 --- /dev/null +++ b/.github/templates/domain/Api/Controllers/EntityReadController.cs.template @@ -0,0 +1,20 @@ +namespace {Solution}.{Domain}.Api.Controllers; + +[ApiController, Route("/api/{entityPluralKebab}"), OpenApiTag("{EntityPlural}")] +public class {Entity}ReadController(WebApi webApi, I{Entity}ReadService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly I{Entity}ReadService _service = service.ThrowIfNull(); + + [HttpGet("{id}"), HttpHead("{id}")] + [ProducesResponseType(typeof({Entity}), StatusCodes.Status200OK)] + [ProducesNotFoundProblem()] + public Task GetAsync(string id) => _webApi.GetAsync(Request, (_, _) + => _service.GetAsync(id.Required())); + + [HttpGet] + [ProducesResponseType(typeof({Entity}Lite[]), StatusCodes.Status200OK)] + [Query(supportsOrderBy: true), Paging(supportsCount: true)] + public Task QueryAsync() => _webApi.GetAsync(Request, (ro, _) + => _service.QueryAsync(ro.QueryArgs, ro.PagingArgs)); +} diff --git a/.github/templates/domain/Api/Controllers/ReferenceDataController.cs.template b/.github/templates/domain/Api/Controllers/ReferenceDataController.cs.template new file mode 100644 index 00000000..ddebd64a --- /dev/null +++ b/.github/templates/domain/Api/Controllers/ReferenceDataController.cs.template @@ -0,0 +1,12 @@ +namespace {Solution}.{Domain}.Api.Controllers; + +[ApiController, Route("/api/refdata")] +public class ReferenceDataController(WebApi webApi) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + + [HttpGet("{entityKebab}-statuses"), HttpHead("{entityKebab}-statuses")] + [ProducesResponseType(typeof({Entity}Status[]), StatusCodes.Status200OK)] + public Task Get{Entity}StatusesAsync([FromQuery] IEnumerable? codes = default, string? text = default) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetWithFilterAsync<{Entity}Status>(codes, text, ro.IsIncludeInactive, ct)); +} diff --git a/.github/templates/domain/Api/Domain.Api.csproj.template b/.github/templates/domain/Api/Domain.Api.csproj.template new file mode 100644 index 00000000..cdeab65e --- /dev/null +++ b/.github/templates/domain/Api/Domain.Api.csproj.template @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/.github/templates/domain/Api/GlobalUsing.cs.template b/.github/templates/domain/Api/GlobalUsing.cs.template new file mode 100644 index 00000000..cbf89e82 --- /dev/null +++ b/.github/templates/domain/Api/GlobalUsing.cs.template @@ -0,0 +1,14 @@ +global using {Solution}.{Domain}.Application; +global using {Solution}.{Domain}.Application.Interfaces; +global using {Solution}.{Domain}.Contracts; +global using CoreEx; +global using CoreEx.AspNetCore.Mvc; +global using CoreEx.Entities; +global using CoreEx.Http; +global using CoreEx.Json; +global using CoreEx.RefData; +global using CoreEx.Validation; +global using Microsoft.AspNetCore.Mvc; +global using NSwag.Annotations; +global using System.Net; +global using System.Text.Json; diff --git a/.github/templates/domain/Api/Program.cs.template b/.github/templates/domain/Api/Program.cs.template new file mode 100644 index 00000000..f5150584 --- /dev/null +++ b/.github/templates/domain/Api/Program.cs.template @@ -0,0 +1,78 @@ +using {Solution}.{Domain}.Infrastructure.Repositories; +using Microsoft.Extensions.Options; +using OpenTelemetry; +using OpenTelemetry.Trace; +using StackExchange.Redis; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; + +namespace {Solution}.{Domain}.Api; + +public class Program +{ + private static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.AddHostSettings(); + + builder.Services + .AddExecutionContext() + .AddReferenceDataOrchestrator() + .AddMvcWebApi() + .AddHttpWebApi(); + + builder.Services.AddDynamicServicesUsing(); + + builder.Services.AddMemoryCache(); + builder.AddRedisDistributedCache("redis"); + + builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithRegisteredDistributedCache() + .WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions { Configuration = sp.GetRequiredService>().Value.ToString() })) + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); + + builder.Services + .AddFusionHybridCache() + .AddDefaultCacheKeyProvider() + .AddHybridCacheIdempotencyProvider(); + + builder.AddSqlServerClient("SqlServer"); + builder.Services + .AddSqlServerDatabase() + .AddSqlServerUnitOfWork() + .AddEventFormatter() + .AddSqlServerOutboxPublisher<{Domain}OutboxPublisher>() + .AddDbContext<{Domain}DbContext>() + .AddEfDb<{Domain}EfDb>(); + + builder.Services.PostConfigureAllHealthChecks(); + builder.Services.AddControllers(); + + builder.Services.AddOpenApiDocument(s => + { + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); + }); + + builder.WithCoreExTelemetry() + .WithCoreExSqlServerTelemetry() + .UseOtlpExporter(); + + var app = builder.Build(); + + app.UseCoreExExceptionHandler(); + app.UseHttpsRedirection(); + app.UseAuthorization(); + app.UseExecutionContext(); + app.UseIdempotencyKey(); + app.MapControllers(); + + app.UseOpenApi(); + app.UseSwaggerUi(); + app.MapHealthChecks(); + + app.Run(); + } +} diff --git a/.github/templates/domain/Api/appsettings.json.template b/.github/templates/domain/Api/appsettings.json.template new file mode 100644 index 00000000..e676968a --- /dev/null +++ b/.github/templates/domain/Api/appsettings.json.template @@ -0,0 +1,20 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "{Solution}", + "DomainName": "{Domain}" + }, + "Events": { + "Destination": "contoso" + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Update": "None" + } + }, + "AllowedHosts": "*" +} diff --git a/.github/templates/domain/Application/Domain.Application.csproj.template b/.github/templates/domain/Application/Domain.Application.csproj.template new file mode 100644 index 00000000..0241b47e --- /dev/null +++ b/.github/templates/domain/Application/Domain.Application.csproj.template @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.github/templates/domain/Application/EntityReadService.cs.template b/.github/templates/domain/Application/EntityReadService.cs.template new file mode 100644 index 00000000..29977161 --- /dev/null +++ b/.github/templates/domain/Application/EntityReadService.cs.template @@ -0,0 +1,12 @@ +namespace {Solution}.{Domain}.Application; + +[ScopedService] +public class {Entity}ReadService(I{Entity}Repository repository) : I{Entity}ReadService +{ + private readonly I{Entity}Repository _repository = repository.ThrowIfNull(); + + public Task<{Entity}?> GetAsync(string id) => _repository.GetAsync(id); + + public Task> QueryAsync(QueryArgs? query, PagingArgs? paging) + => _repository.QueryAsync(query, paging); +} diff --git a/.github/templates/domain/Application/EntityService.cs.template b/.github/templates/domain/Application/EntityService.cs.template new file mode 100644 index 00000000..7914da45 --- /dev/null +++ b/.github/templates/domain/Application/EntityService.cs.template @@ -0,0 +1,56 @@ +namespace {Solution}.{Domain}.Application; + +[ScopedService] +public class {Entity}Service(IUnitOfWork unitOfWork, I{Entity}Repository repository) : I{Entity}Service +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork.ThrowIfNull(); + private readonly I{Entity}Repository _repository = repository.ThrowIfNull(); + + public Task<{Entity}?> GetAsync(string id) => _repository.GetAsync(id); + + public async Task<{Entity}> CreateAsync({Entity} entity) + { + entity.ThrowIfNull(); + + await {Entity}Validator.Default.ValidateAndThrowAsync(entity).ConfigureAwait(false); + + entity.Id = Runtime.NewId(); + entity.StatusCode ??= "P"; + + return await _unitOfWork.ExecuteAsync(async () => + { + var dr = await _repository.CreateAsync(entity).ConfigureAwait(false); + return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created))); + }).ConfigureAwait(false); + } + + public async Task<{Entity}> UpdateAsync({Entity} entity) + { + entity.ThrowIfNull(); + entity.Id.ThrowIfNullOrEmpty(); + + await {Entity}Validator.Default.ValidateAndThrowAsync(entity).ConfigureAwait(false); + + var current = await _repository.GetAsync(entity.Id).ConfigureAwait(false); + NotFoundException.ThrowIfDefault(current); + + return await _unitOfWork.ExecuteAsync(async () => + { + var dr = await _repository.UpdateAsync(entity).ConfigureAwait(false); + return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Updated))); + }).ConfigureAwait(false); + } + + public async Task DeleteAsync(string id) + { + var entity = await _repository.GetAsync(id).ConfigureAwait(false); + if (entity is null) + return; + + await _unitOfWork.ExecuteAsync(async () => + { + var dr = await _repository.DeleteAsync(id).ConfigureAwait(false); + dr.WhereMutated(() => _unitOfWork.Events.Add(EventData.CreateEventWith<{Entity}>(default, EventAction.Deleted).WithKey(id))); + }).ConfigureAwait(false); + } +} diff --git a/.github/templates/domain/Application/GlobalUsing.cs.template b/.github/templates/domain/Application/GlobalUsing.cs.template new file mode 100644 index 00000000..e3f020b1 --- /dev/null +++ b/.github/templates/domain/Application/GlobalUsing.cs.template @@ -0,0 +1,14 @@ +global using {Solution}.{Domain}.Application.Interfaces; +global using {Solution}.{Domain}.Application.Repositories; +global using {Solution}.{Domain}.Application.Validators; +global using {Solution}.{Domain}.Contracts; +global using CoreEx; +global using CoreEx.Data; +global using CoreEx.DependencyInjection; +global using CoreEx.Events; +global using CoreEx.Localization; +global using CoreEx.RefData; +global using CoreEx.RefData.Abstractions; +global using CoreEx.Results; +global using CoreEx.Validation; +global using System.Text.Json; diff --git a/.github/templates/domain/Application/Interfaces/IEntityReadService.cs.template b/.github/templates/domain/Application/Interfaces/IEntityReadService.cs.template new file mode 100644 index 00000000..f828ce70 --- /dev/null +++ b/.github/templates/domain/Application/Interfaces/IEntityReadService.cs.template @@ -0,0 +1,8 @@ +namespace {Solution}.{Domain}.Application.Interfaces; + +public interface I{Entity}ReadService +{ + Task GetAsync(string id); + + Task> QueryAsync(QueryArgs? query, PagingArgs? paging); +} diff --git a/.github/templates/domain/Application/Interfaces/IEntityService.cs.template b/.github/templates/domain/Application/Interfaces/IEntityService.cs.template new file mode 100644 index 00000000..5df58402 --- /dev/null +++ b/.github/templates/domain/Application/Interfaces/IEntityService.cs.template @@ -0,0 +1,12 @@ +namespace {Solution}.{Domain}.Application.Interfaces; + +public interface I{Entity}Service +{ + Task GetAsync(string id); + + Task CreateAsync(Contracts.{Entity} entity); + + Task UpdateAsync(Contracts.{Entity} entity); + + Task DeleteAsync(string id); +} diff --git a/.github/templates/domain/Application/ReferenceDataService.cs.template b/.github/templates/domain/Application/ReferenceDataService.cs.template new file mode 100644 index 00000000..e55a00a6 --- /dev/null +++ b/.github/templates/domain/Application/ReferenceDataService.cs.template @@ -0,0 +1,18 @@ +namespace {Solution}.{Domain}.Application; + +[ScopedService] +public class ReferenceDataService(IReferenceDataRepository repository) : IReferenceDataProvider +{ + private readonly IReferenceDataRepository _repository = repository.ThrowIfNull(); + + public IEnumerable<(Type, Type)> Types => + [ + (typeof({Entity}Status), typeof({Entity}StatusCollection)), + ]; + + public async Task GetAsync(Type type, CancellationToken cancellationToken = default) => type switch + { + _ when type == typeof({Entity}Status) => await _repository.GetAll{Entity}StatusesAsync().ConfigureAwait(false), + _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.") + }; +} diff --git a/.github/templates/domain/Application/Repositories/IEntityRepository.cs.template b/.github/templates/domain/Application/Repositories/IEntityRepository.cs.template new file mode 100644 index 00000000..2041a994 --- /dev/null +++ b/.github/templates/domain/Application/Repositories/IEntityRepository.cs.template @@ -0,0 +1,14 @@ +namespace {Solution}.{Domain}.Application.Repositories; + +public interface I{Entity}Repository +{ + Task GetAsync(string id); + + Task> CreateAsync(Contracts.{Entity} entity); + + Task> UpdateAsync(Contracts.{Entity} entity); + + Task DeleteAsync(string id); + + Task> QueryAsync(QueryArgs? query, PagingArgs? paging); +} diff --git a/.github/templates/domain/Application/Repositories/IReferenceDataRepository.cs.template b/.github/templates/domain/Application/Repositories/IReferenceDataRepository.cs.template new file mode 100644 index 00000000..981f8e4a --- /dev/null +++ b/.github/templates/domain/Application/Repositories/IReferenceDataRepository.cs.template @@ -0,0 +1,6 @@ +namespace {Solution}.{Domain}.Application.Repositories; + +public interface IReferenceDataRepository +{ + Task<{Entity}StatusCollection> GetAll{Entity}StatusesAsync(); +} diff --git a/.github/templates/domain/Application/Validators/EntityValidator.cs.template b/.github/templates/domain/Application/Validators/EntityValidator.cs.template new file mode 100644 index 00000000..a038705c --- /dev/null +++ b/.github/templates/domain/Application/Validators/EntityValidator.cs.template @@ -0,0 +1,16 @@ +namespace {Solution}.{Domain}.Application.Validators; + +public class {Entity}Validator : Validator<{Entity}, {Entity}Validator> +{ + private static readonly Validator<{ChildEntity}> _itemValidator = Validator.Create<{ChildEntity}>() + .HasProperty(x => x.ProductId, c => c.Mandatory().MaximumLength(100)) + .HasProperty(x => x.Quantity, c => c.GreaterThan(0).PrecisionScale(null, 4)) + .HasProperty(x => x.UnitPrice, c => c.GreaterThanOrEqualTo(0, _ => "zero").PrecisionScale(null, 4)); + + public {Entity}Validator() + { + Property(o => o.CustomerId).Mandatory().MaximumLength(100); + Property(o => o.Status).Mandatory().IsValid(); + Property(o => o.Items).Collection(c => c.MinimumCount(1).Entity(_itemValidator)); + } +} diff --git a/.github/templates/domain/Contracts/ChildEntity.cs.template b/.github/templates/domain/Contracts/ChildEntity.cs.template new file mode 100644 index 00000000..a7612950 --- /dev/null +++ b/.github/templates/domain/Contracts/ChildEntity.cs.template @@ -0,0 +1,14 @@ +namespace {Solution}.{Domain}.Contracts; + +[Contract] +public partial class {ChildEntity} : IIdentifier +{ + [ReadOnly(true)] + public string? Id { get; set; } + + public string? ProductId { get; set; } + + public decimal Quantity { get; set; } + + public decimal UnitPrice { get; set; } +} diff --git a/.github/templates/domain/Contracts/Domain.Contracts.csproj.template b/.github/templates/domain/Contracts/Domain.Contracts.csproj.template new file mode 100644 index 00000000..31817534 --- /dev/null +++ b/.github/templates/domain/Contracts/Domain.Contracts.csproj.template @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.github/templates/domain/Contracts/Entity.cs.template b/.github/templates/domain/Contracts/Entity.cs.template new file mode 100644 index 00000000..ece359f1 --- /dev/null +++ b/.github/templates/domain/Contracts/Entity.cs.template @@ -0,0 +1,11 @@ +namespace {Solution}.{Domain}.Contracts; + +[Contract] +public partial class {Entity} : {Entity}Base, IETag, IChangeLog +{ + [ReadOnly(true)] + public ChangeLog? ChangeLog { get; set; } + + [ReadOnly(true)] + public string? ETag { get; set; } +} diff --git a/.github/templates/domain/Contracts/EntityBase.cs.template b/.github/templates/domain/Contracts/EntityBase.cs.template new file mode 100644 index 00000000..638eb79d --- /dev/null +++ b/.github/templates/domain/Contracts/EntityBase.cs.template @@ -0,0 +1,16 @@ +namespace {Solution}.{Domain}.Contracts; + +[Contract] +public abstract partial class {Entity}Base : IIdentifier +{ + [ReadOnly(true)] + public string? Id { get; set; } + + public string? CustomerId { get; set; } + + [ReferenceData<{Entity}Status>] + [Localization("{Entity} status")] + public partial string? StatusCode { get; set; } + + public List<{ChildEntity}>? Items { get; set; } +} diff --git a/.github/templates/domain/Contracts/EntityLite.cs.template b/.github/templates/domain/Contracts/EntityLite.cs.template new file mode 100644 index 00000000..1f5fc4fd --- /dev/null +++ b/.github/templates/domain/Contracts/EntityLite.cs.template @@ -0,0 +1,17 @@ +namespace {Solution}.{Domain}.Contracts; + +[Contract] +public partial class {Entity}Lite : IIdentifier +{ + [ReadOnly(true)] + public string? Id { get; set; } + + public string? CustomerId { get; set; } + + [ReferenceData<{Entity}Status>] + [Localization("{Entity} status")] + public partial string? StatusCode { get; set; } + + [ReadOnly(true)] + public ChangeLog? ChangeLog { get; set; } +} diff --git a/.github/templates/domain/Contracts/EntityStatus.cs.template b/.github/templates/domain/Contracts/EntityStatus.cs.template new file mode 100644 index 00000000..c93f189c --- /dev/null +++ b/.github/templates/domain/Contracts/EntityStatus.cs.template @@ -0,0 +1,6 @@ +namespace {Solution}.{Domain}.Contracts; + +[ReferenceData] +public partial class {Entity}Status : ReferenceData<{Entity}Status> { } + +public class {Entity}StatusCollection() : ReferenceDataCollection<{Entity}Status>(ReferenceDataSortOrder.Code) { } diff --git a/.github/templates/domain/Contracts/GlobalUsing.cs.template b/.github/templates/domain/Contracts/GlobalUsing.cs.template new file mode 100644 index 00000000..b3b07bf1 --- /dev/null +++ b/.github/templates/domain/Contracts/GlobalUsing.cs.template @@ -0,0 +1,5 @@ +global using CoreEx.Entities; +global using CoreEx.Localization; +global using CoreEx.RefData; +global using System.ComponentModel; +global using System.Text.Json.Serialization; diff --git a/.github/templates/domain/Database/Data/ref-data.yaml.template b/.github/templates/domain/Database/Data/ref-data.yaml.template new file mode 100644 index 00000000..1fb02f42 --- /dev/null +++ b/.github/templates/domain/Database/Data/ref-data.yaml.template @@ -0,0 +1,5 @@ +{Domain}: + - $^{Entity}Status: + - P: Pending + - C: Confirmed + - X: Cancelled diff --git a/.github/templates/domain/Database/Domain.Database.csproj.template b/.github/templates/domain/Database/Domain.Database.csproj.template new file mode 100644 index 00000000..82f1cdf2 --- /dev/null +++ b/.github/templates/domain/Database/Domain.Database.csproj.template @@ -0,0 +1,21 @@ + + + + Exe + + + + + + + + + + + + + + + + + diff --git a/.github/templates/domain/Database/Migrations/000001-create-schema.sql.template b/.github/templates/domain/Database/Migrations/000001-create-schema.sql.template new file mode 100644 index 00000000..318ca703 --- /dev/null +++ b/.github/templates/domain/Database/Migrations/000001-create-schema.sql.template @@ -0,0 +1 @@ +CREATE SCHEMA [{Domain}] diff --git a/.github/templates/domain/Database/Migrations/000101-create-entitystatus.sql.template b/.github/templates/domain/Database/Migrations/000101-create-entitystatus.sql.template new file mode 100644 index 00000000..c989a971 --- /dev/null +++ b/.github/templates/domain/Database/Migrations/000101-create-entitystatus.sql.template @@ -0,0 +1,18 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [{Domain}].[{Entity}Status] ( + [{Entity}StatusId] NVARCHAR(50) NOT NULL PRIMARY KEY, + [Code] NVARCHAR(50) NOT NULL UNIQUE, + [Text] NVARCHAR(250) NULL, + [IsActive] BIT NULL, + [SortOrder] INT NULL, + [RowVersion] TIMESTAMP NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); + +COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/Migrations/000201-create-entity.sql.template b/.github/templates/domain/Database/Migrations/000201-create-entity.sql.template new file mode 100644 index 00000000..8d5d4c81 --- /dev/null +++ b/.github/templates/domain/Database/Migrations/000201-create-entity.sql.template @@ -0,0 +1,16 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [{Domain}].[{Entity}] ( + [{Entity}Id] NVARCHAR(50) NOT NULL PRIMARY KEY, + [CustomerId] NVARCHAR(100) NOT NULL, + [StatusCode] NVARCHAR(50) NOT NULL, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL, + [RowVersion] TIMESTAMP NOT NULL +); + +COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/Migrations/000202-create-childentity.sql.template b/.github/templates/domain/Database/Migrations/000202-create-childentity.sql.template new file mode 100644 index 00000000..e887cf3b --- /dev/null +++ b/.github/templates/domain/Database/Migrations/000202-create-childentity.sql.template @@ -0,0 +1,17 @@ +-- Migration Script + +BEGIN TRANSACTION + +CREATE TABLE [{Domain}].[{ChildEntity}] ( + [{ChildEntity}Id] NVARCHAR(50) NOT NULL PRIMARY KEY, + [{Entity}Id] NVARCHAR(50) NOT NULL FOREIGN KEY REFERENCES [{Domain}].[{Entity}]([{Entity}Id]), + [ProductId] NVARCHAR(100) NOT NULL, + [Quantity] DECIMAL(18, 4) NOT NULL DEFAULT 0, + [UnitPrice] DECIMAL(18, 4) NOT NULL DEFAULT 0, + [CreatedBy] NVARCHAR(250) NULL, + [CreatedOn] DATETIMEOFFSET NULL, + [UpdatedBy] NVARCHAR(250) NULL, + [UpdatedOn] DATETIMEOFFSET NULL +); + +COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/Migrations/000301-create-outbox-tables.sql.template b/.github/templates/domain/Database/Migrations/000301-create-outbox-tables.sql.template new file mode 100644 index 00000000..f120a547 --- /dev/null +++ b/.github/templates/domain/Database/Migrations/000301-create-outbox-tables.sql.template @@ -0,0 +1,29 @@ +BEGIN TRANSACTION + +CREATE TABLE [{Domain}].[Outbox] ( + [OutboxId] BIGINT IDENTITY (1, 1) NOT NULL PRIMARY KEY, + [TenantId] NVARCHAR(255) NOT NULL, + [PartitionId] INT NOT NULL, + [Status] TINYINT NOT NULL DEFAULT 0, + [EnqueuedUtc] DATETIME2 NOT NULL, + [AvailableUtc] DATETIME2 NOT NULL, + [DequeuedUtc] DATETIME2 NULL, + [Attempts] INT NOT NULL DEFAULT 0, + [Destination] NVARCHAR(255) NULL, + [Event] NVARCHAR(MAX) NOT NULL, + [LeaseId] UNIQUEIDENTIFIER NULL, + [LeaseUntilUtc] DATETIME2 NULL +); + +CREATE INDEX [IX_{Domain}_Outbox_Claim] ON [{Domain}].[Outbox] ([TenantId], [PartitionId], [Status], [OutboxId], [AvailableUtc], [LeaseUntilUtc]); + +CREATE TABLE [{Domain}].[OutboxLease] ( + [TenantId] NVARCHAR(255) NOT NULL, + [PartitionId] INT NOT NULL, + [LeaseId] UNIQUEIDENTIFIER NULL, + [LeaseUntilUtc] DATETIME2 NULL, + + CONSTRAINT PK_{Domain}_OutboxLease PRIMARY KEY (TenantId, PartitionId) +); + +COMMIT TRANSACTION diff --git a/.github/templates/domain/Database/Program.cs.template b/.github/templates/domain/Database/Program.cs.template new file mode 100644 index 00000000..170168b9 --- /dev/null +++ b/.github/templates/domain/Database/Program.cs.template @@ -0,0 +1,26 @@ +using CoreEx.Database; +using DbEx.Migration; +using DbEx.SqlServer.Console; + +namespace {Solution}.{Domain}.Database; + +public class Program +{ + public static Task Main(string[] args) => SqlServerMigrationConsole + .Create("Data Source=127.0.0.1,1433;Initial Catalog=Contoso;User id=sa;Password=yourStrong(!)Password;TrustServerCertificate=true") + .Configure(c => ConfigureMigrationArgs(c.Args)) + .RunAsync(args); + + public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) + { + args.AddAssembly().AddAssembly() + .IncludeExtendedSchemaScripts() + .DataParserArgs + .RefDataColumnDefault("SortOrder", _ => 0) + .RefDataColumnDefault("Scale", _ => 0); + + args.DataResetFilterPredicate = ts => ts.Schema == "{Domain}"; + + return args; + } +} diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql.template new file mode 100644 index 00000000..6c41280b --- /dev/null +++ b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchCancel.g.sql.template @@ -0,0 +1,46 @@ +CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxBatchCancel] + @LeaseId UNIQUEIDENTIFIER, + @BackoffSeconds INT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; + SET TRANSACTION ISOLATION LEVEL READ COMMITTED; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + + BEGIN TRY + BEGIN TRAN; + + UPDATE o + SET o.[Status] = 0, + o.[Attempts] = o.[Attempts] + 1, + o.[AvailableUtc] = DATEADD(SECOND, @BackoffSeconds, @Now), + o.[LeaseId] = NULL, + o.[LeaseUntilUtc] = NULL + FROM [{Domain}].[Outbox] AS o WITH (UPDLOCK, ROWLOCK) + WHERE o.[LeaseId] = @LeaseId + AND o.[Status] = 1; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + RETURN -1; + END + + COMMIT; + + BEGIN TRY + EXEC [{Domain}].[spOutboxLeaseRelease] @LeaseId; + END TRY + BEGIN CATCH + END CATCH + + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; + END CATCH +END diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql.template new file mode 100644 index 00000000..b02f953b --- /dev/null +++ b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchClaim.g.sql.template @@ -0,0 +1,96 @@ +CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxBatchClaim] + @TenantId NVARCHAR(255) = NULL, + @PartitionId INT, + @BatchSize INT, + @LeaseId UNIQUEIDENTIFIER, + @LeaseSeconds INT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @LeaseUntilUtc DATETIME2; + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + SET TRANSACTION ISOLATION LEVEL READ COMMITTED; + SET LOCK_TIMEOUT 5000; + + DECLARE @RC INT; + EXEC @RC = [{Domain}].[spOutboxLeaseAcquire] @EffectiveTenantId, @PartitionId, @LeaseId, @LeaseSeconds, @LeaseUntilUtc OUTPUT; + IF (@RC < 0) RETURN -3; + + BEGIN TRY + BEGIN TRAN; + + DECLARE @HeadId BIGINT; + DECLARE @BlockerId BIGINT; + + SELECT @HeadId = MIN(o.OutboxId) + FROM [{Domain}].[Outbox] o WITH (UPDLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[Status] IN (0, 1) + OPTION (RECOMPILE); + + IF @HeadId IS NULL + BEGIN + COMMIT; + EXEC [{Domain}].[spOutboxLeaseRelease] @LeaseId; + RETURN -2; + END + + SELECT @BlockerId = MIN(o.OutboxId) + FROM [{Domain}].[Outbox] o WITH (READPAST, UPDLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[OutboxId] >= @HeadId + AND ((o.Status = 1 AND o.[LeaseUntilUtc] IS NOT NULL AND o.[LeaseUntilUtc] > @Now) + OR (o.Status = 0 AND o.[AvailableUtc] > @Now)) + OPTION (RECOMPILE); + + ;WITH claim AS + ( + SELECT TOP (@BatchSize) + o.[OutboxId], o.[TenantId], o.[Status], o.[PartitionId], o.[Destination], o.[Event], + o.[Attempts], o.[EnqueuedUtc], o.[AvailableUtc], o.[LeaseId], o.[LeaseUntilUtc] + FROM [{Domain}].[Outbox] o WITH (READPAST, UPDLOCK, ROWLOCK) + WHERE o.[TenantId] = @EffectiveTenantId + AND o.[PartitionId] = @PartitionId + AND o.[OutboxId] >= @HeadId + AND (@BlockerId IS NULL OR o.[OutboxId] < @BlockerId) + AND ((o.[Status] = 0 AND o.[AvailableUtc] <= @Now) + OR (o.[Status] = 1 AND (o.[LeaseUntilUtc] IS NULL OR o.[LeaseUntilUtc] <= @Now))) + ORDER BY o.OutboxId + ) + UPDATE claim + SET [Status] = 1, + [LeaseId] = @LeaseId, + [LeaseUntilUtc] = @LeaseUntilUtc + OUTPUT + inserted.[OutboxId], + inserted.[TenantId], + inserted.[Status], + inserted.[PartitionId], + inserted.[Destination], + inserted.[Event], + inserted.[Attempts], + inserted.[EnqueuedUtc], + inserted.[AvailableUtc], + inserted.[LeaseUntilUtc]; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + EXEC [{Domain}].[spOutboxLeaseRelease] @LeaseId; + RETURN -1; + END + + COMMIT; + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; + END CATCH +END diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql.template new file mode 100644 index 00000000..aca350ab --- /dev/null +++ b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxBatchComplete.g.sql.template @@ -0,0 +1,50 @@ +CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxBatchComplete] + @LeaseId UNIQUEIDENTIFIER, + @DequeuedUtc DATETIME2 NULL +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; + SET TRANSACTION ISOLATION LEVEL READ COMMITTED; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @Completed TABLE (TenantId NVARCHAR(255), PartitionId INT); + + BEGIN TRY + BEGIN TRAN; + + UPDATE o + SET o.[Status] = 2, + o.[LeaseId] = NULL, + o.[LeaseUntilUtc] = NULL, + o.[DequeuedUtc] = COALESCE(@DequeuedUtc, @Now) + OUTPUT + deleted.[TenantId], + deleted.[PartitionId] + INTO @Completed + FROM [{Domain}].[Outbox] AS o WITH (UPDLOCK, ROWLOCK) + WHERE o.[LeaseId] = @LeaseId + AND o.[Status] = 1; + + IF (@@ROWCOUNT = 0) + BEGIN + COMMIT; + RETURN -1; + END + + COMMIT; + + BEGIN TRY + EXEC [{Domain}].[spOutboxLeaseRelease] @LeaseId; + END TRY + BEGIN CATCH + END CATCH + + RETURN 0; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; + END CATCH +END diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql.template new file mode 100644 index 00000000..881dd973 --- /dev/null +++ b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxEnqueue.g.sql.template @@ -0,0 +1,18 @@ +CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxEnqueue] + @TenantId AS NVARCHAR(255) = NULL, + @PartitionId AS INT, + @Destination AS NVARCHAR(255), + @Event AS NVARCHAR(MAX), + @EnqueuedUtc AS DATETIME2 = NULL, + @AvailableUtc AS DATETIME2 = NULL +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + INSERT INTO [{Domain}].[Outbox] ([TenantId], [PartitionId], [Destination], [Event], [EnqueuedUtc], [AvailableUtc]) + VALUES (@EffectiveTenantId, @PartitionId, @Destination, @Event, COALESCE(@EnqueuedUtc, @Now), COALESCE(@AvailableUtc, COALESCE(@EnqueuedUtc, @Now))); +END diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql.template new file mode 100644 index 00000000..d29d14ba --- /dev/null +++ b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseAcquire.g.sql.template @@ -0,0 +1,49 @@ +CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxLeaseAcquire] + @TenantId NVARCHAR(255) = NULL, + @PartitionId INT, + @LeaseId UNIQUEIDENTIFIER, + @LeaseSeconds INT, + @LeaseUntilUtc DATETIME2 OUTPUT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; + SET TRANSACTION ISOLATION LEVEL READ COMMITTED; + + DECLARE @Now DATETIME2 = SYSUTCDATETIME(); + DECLARE @Until DATETIME2 = DATEADD(SECOND, @LeaseSeconds, @Now); + DECLARE @EffectiveTenantId NVARCHAR(255) = COALESCE(@TenantId, '(none)'); + + BEGIN TRY + BEGIN TRAN; + + IF NOT EXISTS (SELECT 1 FROM [{Domain}].[OutboxLease] WITH (UPDLOCK, HOLDLOCK) WHERE [TenantId] = @EffectiveTenantId AND [PartitionId] = @PartitionId) + INSERT INTO [{Domain}].[OutboxLease] ([TenantId], [PartitionId]) VALUES (@EffectiveTenantId, @PartitionId); + + UPDATE ol + SET ol.[LeaseId] = @LeaseId, + ol.[LeaseUntilUtc] = @Until + FROM [{Domain}].[OutboxLease] AS ol WITH (UPDLOCK, ROWLOCK) + WHERE ol.[PartitionId] = @PartitionId + AND ol.[TenantId] = @EffectiveTenantId + AND (ol.[LeaseUntilUtc] IS NULL OR ol.[LeaseUntilUtc] <= @Now) + OPTION (RECOMPILE); + + DECLARE @Rows INT = @@ROWCOUNT; + COMMIT; + + IF @Rows = 1 + BEGIN + SET @LeaseUntilUtc = @Until; + RETURN 0; + END + + SET @LeaseUntilUtc = NULL; + RETURN -1; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; + END CATCH +END diff --git a/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql.template b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql.template new file mode 100644 index 00000000..10fa2995 --- /dev/null +++ b/.github/templates/domain/Database/Schema/Stored Procedures/spOutboxLeaseRelease.g.sql.template @@ -0,0 +1,29 @@ +CREATE OR ALTER PROCEDURE [{Domain}].[spOutboxLeaseRelease] + @LeaseId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET LOCK_TIMEOUT 5000; + SET TRANSACTION ISOLATION LEVEL READ COMMITTED; + + BEGIN TRY + BEGIN TRAN; + + UPDATE ol + SET ol.[LeaseId] = NULL, + ol.[LeaseUntilUtc] = NULL + FROM [{Domain}].[OutboxLease] AS ol WITH (UPDLOCK, ROWLOCK) + WHERE ol.[LeaseId] = @LeaseId; + + DECLARE @Rows INT = @@ROWCOUNT; + COMMIT; + + IF @Rows = 1 RETURN 0; + RETURN -1; + END TRY + BEGIN CATCH + IF (XACT_STATE() <> 0) ROLLBACK; + THROW; + END CATCH +END diff --git a/.github/templates/domain/Database/dbex.yaml.template b/.github/templates/domain/Database/dbex.yaml.template new file mode 100644 index 00000000..7268ba82 --- /dev/null +++ b/.github/templates/domain/Database/dbex.yaml.template @@ -0,0 +1,8 @@ +outbox: true +tables: +# Reference-data +- name: {Entity}Status + +# Transactional-data +- name: {Entity} +- name: {ChildEntity} diff --git a/.github/templates/domain/DomainScaffold.checklist.md b/.github/templates/domain/DomainScaffold.checklist.md new file mode 100644 index 00000000..4f292c83 --- /dev/null +++ b/.github/templates/domain/DomainScaffold.checklist.md @@ -0,0 +1,74 @@ +# Domain Scaffold Checklist + +Use this checklist after scaffolding a new domain from templates/prompts. + +## Inputs Confirmed + +- [ ] Solution prefix confirmed. +- [ ] Domain name confirmed. +- [ ] Root entity name confirmed. +- [ ] Child entity name confirmed (or explicitly omitted). +- [ ] CRUD operations confirmed. +- [ ] Reference data/status values confirmed. +- [ ] Event subjects confirmed. + +## Projects Created + +- [ ] All domain projects are grouped under a Visual Studio solution folder named {Domain} (for example, Orders). +- [ ] All new domain projects are added to the solution file. +- [ ] {Solution}.{Domain}.Contracts. +- [ ] {Solution}.{Domain}.Application. +- [ ] {Solution}.{Domain}.Infrastructure. +- [ ] {Solution}.{Domain}.Api. +- [ ] {Solution}.{Domain}.Database. +- [ ] {Solution}.{Domain}.Test.Unit. +- [ ] {Solution}.{Domain}.Test.Api. + +## Contracts Layer + +- [ ] [Contract] classes are partial. +- [ ] Id, ETag, ChangeLog are [ReadOnly(true)]. +- [ ] ReferenceData code properties are partial. +- [ ] Reference data classes and collections exist. + +## Application Layer + +- [ ] Interfaces for service/read-service/repository created. +- [ ] Validator created and invoked in mutate methods. +- [ ] All mutate methods wrapped in _unitOfWork.ExecuteAsync. +- [ ] Outbox events added in WhereMutated callbacks. +- [ ] All awaited calls use ConfigureAwait(false). + +## Infrastructure Layer + +- [ ] Persistence models created. +- [ ] Mapper(s) created and wired. +- [ ] EfDb + DbContext created and configured. +- [ ] Repository implementation includes QueryArgsConfig. +- [ ] Outbox publisher points to [{Domain}].[spOutboxEnqueue]. + +## API Layer + +- [ ] Mutation and read controllers split. +- [ ] POST endpoints use [IdempotencyKey]. +- [ ] GET/HEAD dual route used for get-by-id. +- [ ] PATCH implemented with get + put delegates. +- [ ] Program.cs includes cache, SQL, outbox, OpenAPI, telemetry, health checks. + +## Database Layer + +- [ ] dbex.yaml includes all required tables. +- [ ] Schema + table migrations created. +- [ ] Outbox tables migration created. +- [ ] All six outbox stored procedures created. +- [ ] Reference data seed file created. +- [ ] Program.cs DataResetFilterPredicate scoped to schema. + +## Final Validation + +- [ ] Diagnostics check returns no errors. +- [ ] Project compiles. +- [ ] Unit tests run and pass for {Solution}.{Domain}.Test.Unit. +- [ ] Api tests run and pass for {Solution}.{Domain}.Test.Api. +- [ ] Added to solution file and organized under the {Domain} solution folder (including test projects). +- [ ] README/docs updated where required. diff --git a/.github/templates/domain/Infrastructure/Domain.Infrastructure.csproj.template b/.github/templates/domain/Infrastructure/Domain.Infrastructure.csproj.template new file mode 100644 index 00000000..05aa7fe4 --- /dev/null +++ b/.github/templates/domain/Infrastructure/Domain.Infrastructure.csproj.template @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/.github/templates/domain/Infrastructure/GlobalUsing.cs.template b/.github/templates/domain/Infrastructure/GlobalUsing.cs.template new file mode 100644 index 00000000..b9f42c29 --- /dev/null +++ b/.github/templates/domain/Infrastructure/GlobalUsing.cs.template @@ -0,0 +1,19 @@ +global using {Solution}.{Domain}.Application.Repositories; +global using {Solution}.{Domain}.Infrastructure.Mapping; +global using CoreEx; +global using CoreEx.Data; +global using CoreEx.Data.Models; +global using CoreEx.Data.Querying; +global using CoreEx.Database; +global using CoreEx.Database.SqlServer; +global using CoreEx.Database.SqlServer.Outbox; +global using CoreEx.DependencyInjection; +global using CoreEx.Entities; +global using CoreEx.EntityFrameworkCore; +global using CoreEx.EntityFrameworkCore.Converters; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Mapping; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Logging; +global using System.Text.Json; diff --git a/.github/templates/domain/Infrastructure/Mapping/EntityMapper.cs.template b/.github/templates/domain/Infrastructure/Mapping/EntityMapper.cs.template new file mode 100644 index 00000000..067e968b --- /dev/null +++ b/.github/templates/domain/Infrastructure/Mapping/EntityMapper.cs.template @@ -0,0 +1,33 @@ +namespace {Solution}.{Domain}.Infrastructure.Mapping; + +internal class {Entity}Mapper : BiDirectionMapper +{ + protected override Persistence.{Entity} OnMap(Contracts.{Entity} source) => new() + { + Id = source.Id, + CustomerId = source.CustomerId, + StatusCode = source.Status?.Code, + Items = source.Items?.Select(i => new Persistence.{ChildEntity} + { + Id = i.Id, + {Entity}Id = source.Id, + ProductId = i.ProductId, + Quantity = i.Quantity, + UnitPrice = i.UnitPrice + }).ToList() ?? [] + }; + + protected override Contracts.{Entity} OnMap(Persistence.{Entity} source) => new() + { + Id = source.Id, + CustomerId = source.CustomerId, + StatusCode = source.StatusCode, + Items = source.Items?.Select(i => new Contracts.{ChildEntity} + { + Id = i.Id, + ProductId = i.ProductId, + Quantity = i.Quantity, + UnitPrice = i.UnitPrice + }).ToList() ?? [] + }; +} diff --git a/.github/templates/domain/Infrastructure/Mapping/EntityStatusMapper.cs.template b/.github/templates/domain/Infrastructure/Mapping/EntityStatusMapper.cs.template new file mode 100644 index 00000000..98500d89 --- /dev/null +++ b/.github/templates/domain/Infrastructure/Mapping/EntityStatusMapper.cs.template @@ -0,0 +1,16 @@ +namespace {Solution}.{Domain}.Infrastructure.Mapping; + +internal class {Entity}StatusMapper : BiDirectionMapper +{ + protected override Persistence.{Entity}Status OnMap(Contracts.{Entity}Status source) => throw new NotImplementedException(); + + protected override Contracts.{Entity}Status OnMap(Persistence.{Entity}Status source) => new() + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + SortOrder = source.SortOrder, + IsInactive = !source.IsActive, + ETag = source.ETag + }; +} diff --git a/.github/templates/domain/Infrastructure/Persistence/ChildEntity.cs.template b/.github/templates/domain/Infrastructure/Persistence/ChildEntity.cs.template new file mode 100644 index 00000000..96f2b705 --- /dev/null +++ b/.github/templates/domain/Infrastructure/Persistence/ChildEntity.cs.template @@ -0,0 +1,12 @@ +namespace {Solution}.{Domain}.Infrastructure.Persistence; + +public partial class {ChildEntity} : ModelBase +{ + public string? {Entity}Id { get; set; } + + public string? ProductId { get; set; } + + public decimal Quantity { get; set; } + + public decimal UnitPrice { get; set; } +} diff --git a/.github/templates/domain/Infrastructure/Persistence/Entity.cs.template b/.github/templates/domain/Infrastructure/Persistence/Entity.cs.template new file mode 100644 index 00000000..2004a1f8 --- /dev/null +++ b/.github/templates/domain/Infrastructure/Persistence/Entity.cs.template @@ -0,0 +1,10 @@ +namespace {Solution}.{Domain}.Infrastructure.Persistence; + +public partial class {Entity} : ModelBase +{ + public string? CustomerId { get; set; } + + public string? StatusCode { get; set; } + + public virtual ICollection<{ChildEntity}> Items { get; set; } = []; +} diff --git a/.github/templates/domain/Infrastructure/Persistence/EntityStatus.cs.template b/.github/templates/domain/Infrastructure/Persistence/EntityStatus.cs.template new file mode 100644 index 00000000..7f27d618 --- /dev/null +++ b/.github/templates/domain/Infrastructure/Persistence/EntityStatus.cs.template @@ -0,0 +1,3 @@ +namespace {Solution}.{Domain}.Infrastructure.Persistence; + +public partial class {Entity}Status : ReferenceDataModelBase { } diff --git a/.github/templates/domain/Infrastructure/Repositories/DomainDbContext.cs.template b/.github/templates/domain/Infrastructure/Repositories/DomainDbContext.cs.template new file mode 100644 index 00000000..bc020773 --- /dev/null +++ b/.github/templates/domain/Infrastructure/Repositories/DomainDbContext.cs.template @@ -0,0 +1,64 @@ +namespace {Solution}.{Domain}.Infrastructure.Repositories; + +public class {Domain}DbContext(DbContextOptions<{Domain}DbContext> 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); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ThrowIfNull().Entity(e => + { + e.ToTable("{Entity}", "{Domain}"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("{Entity}Id").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.CustomerId).HasColumnName("CustomerId").HasColumnType("NVARCHAR(100)"); + e.Property(p => p.StatusCode).HasColumnName("StatusCode").HasColumnType("NVARCHAR(50)"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); + e.HasMany(p => p.Items).WithOne().HasForeignKey(i => i.{Entity}Id).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.ThrowIfNull().Entity(e => + { + e.ToTable("{ChildEntity}", "{Domain}"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("{ChildEntity}Id").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.{Entity}Id).HasColumnName("{Entity}Id").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.ProductId).HasColumnName("ProductId").HasColumnType("NVARCHAR(100)"); + e.Property(p => p.Quantity).HasColumnName("Quantity").HasColumnType("DECIMAL(18,4)"); + e.Property(p => p.UnitPrice).HasColumnName("UnitPrice").HasColumnType("DECIMAL(18,4)"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + }); + + modelBuilder.ThrowIfNull().Entity(e => + { + e.ToTable("{Entity}Status", "{Domain}"); + e.HasKey(p => p.Id); + e.Property(p => p.Id).HasColumnName("{Entity}StatusId").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Code).HasColumnName("Code").HasColumnType("NVARCHAR(50)"); + e.Property(p => p.Text).HasColumnName("Text").HasColumnType("NVARCHAR(250)"); + e.Property(p => p.SortOrder).HasColumnName("SortOrder").HasColumnType("INT"); + e.Property(p => p.IsActive).HasColumnName("IsActive").HasColumnType("BIT"); + 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)"); + e.Property(p => p.UpdatedOn).HasColumnName("UpdatedOn").HasColumnType("DATETIMEOFFSET"); + e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(StringBase64Converter.Default); + e.Ignore(p => p.Description).Ignore(p => p.StartsOn).Ignore(p => p.EndsOn); + }); + } +} diff --git a/.github/templates/domain/Infrastructure/Repositories/DomainEfDb.cs.template b/.github/templates/domain/Infrastructure/Repositories/DomainEfDb.cs.template new file mode 100644 index 00000000..832ceb30 --- /dev/null +++ b/.github/templates/domain/Infrastructure/Repositories/DomainEfDb.cs.template @@ -0,0 +1,9 @@ +namespace {Solution}.{Domain}.Infrastructure.Repositories; + +public sealed class {Domain}EfDb({Domain}DbContext dbContext) : EfDb<{Domain}DbContext>(dbContext) +{ + public EfDbModel {Entity}Statuses => Model(); + + public EfDbMappedModel {EntityPlural} + => Model().ToMappedModel({Entity}Mapper.Default); +} diff --git a/.github/templates/domain/Infrastructure/Repositories/DomainOutboxPublisher.cs.template b/.github/templates/domain/Infrastructure/Repositories/DomainOutboxPublisher.cs.template new file mode 100644 index 00000000..c7c4c885 --- /dev/null +++ b/.github/templates/domain/Infrastructure/Repositories/DomainOutboxPublisher.cs.template @@ -0,0 +1,7 @@ +namespace {Solution}.{Domain}.Infrastructure.Repositories; + +public class {Domain}OutboxPublisher(SqlServerDatabase database, IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger<{Domain}OutboxPublisher>? logger = null) + : SqlServerOutboxPublisher(database, destinationProvider, formatter, logger) +{ + public override SqlStatement Statement { get; set; } = SqlStatement.StoredProcedure("[{Domain}].[spOutboxEnqueue]"); +} diff --git a/.github/templates/domain/Infrastructure/Repositories/EntityRepository.cs.template b/.github/templates/domain/Infrastructure/Repositories/EntityRepository.cs.template new file mode 100644 index 00000000..bc54bac9 --- /dev/null +++ b/.github/templates/domain/Infrastructure/Repositories/EntityRepository.cs.template @@ -0,0 +1,38 @@ +namespace {Solution}.{Domain}.Infrastructure.Repositories; + +[ScopedService] +public class {Entity}Repository({Domain}EfDb ef) : I{Entity}Repository +{ + private readonly {Domain}EfDb _ef = ef.ThrowIfNull(); + + private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() + .WithFilter(filter => filter + .WithDefaultModelPrefix("{Entity}") + .AddField(nameof(Contracts.{Entity}Base.CustomerId), c => c.WithOperators(QueryFilterOperator.EqualityOperators | QueryFilterOperator.StartsWith)) + .AddReferenceDataField(nameof(Contracts.{Entity}Base.Status), "StatusCode")) + .WithOrderBy(orderby => orderby + .WithDefaultModelPrefix("{Entity}") + .AddField(nameof(Contracts.{Entity}Base.CustomerId), c => c.WithDefault().WithAlwaysInclude())); + + public Task GetAsync(string id) => _ef.{EntityPlural}.GetAsync(id); + + public Task> CreateAsync(Contracts.{Entity} entity) => _ef.{EntityPlural}.CreateAsync(entity); + + public Task> UpdateAsync(Contracts.{Entity} entity) => _ef.{EntityPlural}.UpdateAsync(entity); + + public Task DeleteAsync(string id) => _ef.{EntityPlural}.DeleteAsync(id); + + public async Task> QueryAsync(QueryArgs? query, PagingArgs? paging) + { + var parsed = _queryConfig.Parse(query).ThrowOnError(); + var entities = _ef.{EntityPlural}.Model.Query(); + + return await entities.Where(parsed).OrderBy(parsed).ToMappedItemsResultAsync(x => new Contracts.{Entity}Lite + { + Id = x.Id, + CustomerId = x.CustomerId, + StatusCode = x.StatusCode, + ChangeLog = new ChangeLog { CreatedBy = x.CreatedBy, CreatedOn = x.CreatedOn, UpdatedBy = x.UpdatedBy, UpdatedOn = x.UpdatedOn } + }, paging).ConfigureAwait(false); + } +} diff --git a/.github/templates/domain/Infrastructure/Repositories/ReferenceDataRepository.cs.template b/.github/templates/domain/Infrastructure/Repositories/ReferenceDataRepository.cs.template new file mode 100644 index 00000000..2d0a5aee --- /dev/null +++ b/.github/templates/domain/Infrastructure/Repositories/ReferenceDataRepository.cs.template @@ -0,0 +1,10 @@ +namespace {Solution}.{Domain}.Infrastructure.Repositories; + +[ScopedService] +public class ReferenceDataRepository({Domain}EfDb ef) : IReferenceDataRepository +{ + private readonly {Domain}EfDb _ef = ef.ThrowIfNull(); + + public Task GetAll{Entity}StatusesAsync() + => _ef.{Entity}Statuses.Query().ToMappedItemsAsync({Entity}StatusMapper.From); +} diff --git a/CoreEx.sln b/CoreEx.sln index b5488a0b..7c157588 100644 --- a/CoreEx.sln +++ b/CoreEx.sln @@ -160,6 +160,38 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Outbox.Rel EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Products.Test.Subscribe", "samples\tests\Contoso.Products.Test.Subscribe\Contoso.Products.Test.Subscribe.csproj", "{4B987914-01EE-48B4-B645-A6F469297853}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Orders", "Orders", "{4033DC3B-5F3E-4D69-AEC0-97D5BA4DB370}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{95780E7B-43ED-4404-8917-A46D4DC30083}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D2F88DCC-0DDB-4B25-BA0C-975D437F633D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{8658F459-F63E-443E-90CA-B9FFE463996B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "hosts", "hosts", "{848DC6FA-94E0-4805-9107-501102BC4A6D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Orders.Contracts", "samples\src\Contoso.Orders.Contracts\Contoso.Orders.Contracts.csproj", "{5FA63BE3-43AB-47E4-9479-FC6AE30760E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Orders.Database", "samples\src\Contoso.Orders.Database\Contoso.Orders.Database.csproj", "{BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Orders.Infrastructure", "samples\src\Contoso.Orders.Infrastructure\Contoso.Orders.Infrastructure.csproj", "{D271F0DB-79CC-4878-9922-482FA7C49333}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Orders.Application", "samples\src\Contoso.Orders.Application\Contoso.Orders.Application.csproj", "{37285E51-9DEE-4344-9B0E-B3185016600A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Orders.Api", "samples\src\Contoso.Orders.Api\Contoso.Orders.Api.csproj", "{E4E396DA-90FB-489C-A40C-1B22563CF203}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Orders.Test.Common", "samples\tests\Contoso.Orders.Test.Common\Contoso.Orders.Test.Common.csproj", "{8C369124-BA40-4B77-8DCB-588700C97430}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Orders.Test.Api", "samples\tests\Contoso.Orders.Test.Api\Contoso.Orders.Test.Api.csproj", "{D1CF8953-16EB-4B3A-92F9-ABA545F87D76}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Orders.Test.Unit", "samples\tests\Contoso.Orders.Test.Unit\Contoso.Orders.Test.Unit.csproj", "{ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Order.Workflow.Client", "samples\src\Contoso.Order.Workflow.Client\Contoso.Order.Workflow.Client.csproj", "{8CB05C57-7A07-4228-AE5F-2AC88792062A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Order.Workflow.Worker", "samples\src\Contoso.Order.Workflow.Worker\Contoso.Order.Workflow.Worker.csproj", "{70E70027-97A1-4862-953F-32874684A334}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Order.Workflow.Workflow", "samples\src\Contoso.Order.Workflow.Workflow\Contoso.Order.Workflow.Workflow.csproj", "{09E56536-DD59-49BD-A48F-41299C5B7ABF}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Generator", "gen\CoreEx.Generator\CoreEx.Generator.csproj", "{54CD8587-0F45-2C2C-7AE4-BB92254202F5}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "gen", "gen", "{570F3635-BEB1-4067-B10F-33DD890BDBD4}" @@ -786,6 +818,138 @@ Global {4B987914-01EE-48B4-B645-A6F469297853}.Release|x64.Build.0 = Release|Any CPU {4B987914-01EE-48B4-B645-A6F469297853}.Release|x86.ActiveCfg = Release|Any CPU {4B987914-01EE-48B4-B645-A6F469297853}.Release|x86.Build.0 = Release|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Debug|x64.Build.0 = Debug|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Debug|x86.Build.0 = Debug|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Release|Any CPU.Build.0 = Release|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Release|x64.ActiveCfg = Release|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Release|x64.Build.0 = Release|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Release|x86.ActiveCfg = Release|Any CPU + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8}.Release|x86.Build.0 = Release|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Debug|x64.ActiveCfg = Debug|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Debug|x64.Build.0 = Debug|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Debug|x86.ActiveCfg = Debug|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Debug|x86.Build.0 = Debug|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Release|Any CPU.Build.0 = Release|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Release|x64.ActiveCfg = Release|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Release|x64.Build.0 = Release|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Release|x86.ActiveCfg = Release|Any CPU + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574}.Release|x86.Build.0 = Release|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Debug|x64.ActiveCfg = Debug|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Debug|x64.Build.0 = Debug|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Debug|x86.ActiveCfg = Debug|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Debug|x86.Build.0 = Debug|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Release|Any CPU.Build.0 = Release|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Release|x64.ActiveCfg = Release|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Release|x64.Build.0 = Release|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Release|x86.ActiveCfg = Release|Any CPU + {D271F0DB-79CC-4878-9922-482FA7C49333}.Release|x86.Build.0 = Release|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Debug|x64.ActiveCfg = Debug|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Debug|x64.Build.0 = Debug|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Debug|x86.ActiveCfg = Debug|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Debug|x86.Build.0 = Debug|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Release|Any CPU.Build.0 = Release|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Release|x64.ActiveCfg = Release|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Release|x64.Build.0 = Release|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Release|x86.ActiveCfg = Release|Any CPU + {37285E51-9DEE-4344-9B0E-B3185016600A}.Release|x86.Build.0 = Release|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Debug|x64.ActiveCfg = Debug|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Debug|x64.Build.0 = Debug|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Debug|x86.ActiveCfg = Debug|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Debug|x86.Build.0 = Debug|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Release|Any CPU.Build.0 = Release|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Release|x64.ActiveCfg = Release|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Release|x64.Build.0 = Release|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Release|x86.ActiveCfg = Release|Any CPU + {E4E396DA-90FB-489C-A40C-1B22563CF203}.Release|x86.Build.0 = Release|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Debug|x64.ActiveCfg = Debug|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Debug|x64.Build.0 = Debug|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Debug|x86.ActiveCfg = Debug|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Debug|x86.Build.0 = Debug|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Release|Any CPU.Build.0 = Release|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Release|x64.ActiveCfg = Release|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Release|x64.Build.0 = Release|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Release|x86.ActiveCfg = Release|Any CPU + {8C369124-BA40-4B77-8DCB-588700C97430}.Release|x86.Build.0 = Release|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Debug|x64.Build.0 = Debug|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Debug|x86.Build.0 = Debug|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Release|Any CPU.Build.0 = Release|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Release|x64.ActiveCfg = Release|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Release|x64.Build.0 = Release|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Release|x86.ActiveCfg = Release|Any CPU + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76}.Release|x86.Build.0 = Release|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Debug|x64.ActiveCfg = Debug|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Debug|x64.Build.0 = Debug|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Debug|x86.ActiveCfg = Debug|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Debug|x86.Build.0 = Debug|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Release|Any CPU.Build.0 = Release|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Release|x64.ActiveCfg = Release|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Release|x64.Build.0 = Release|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Release|x86.ActiveCfg = Release|Any CPU + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32}.Release|x86.Build.0 = Release|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Debug|x64.ActiveCfg = Debug|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Debug|x64.Build.0 = Debug|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Debug|x86.ActiveCfg = Debug|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Debug|x86.Build.0 = Debug|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Release|Any CPU.Build.0 = Release|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Release|x64.ActiveCfg = Release|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Release|x64.Build.0 = Release|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Release|x86.ActiveCfg = Release|Any CPU + {8CB05C57-7A07-4228-AE5F-2AC88792062A}.Release|x86.Build.0 = Release|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Debug|x64.ActiveCfg = Debug|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Debug|x64.Build.0 = Debug|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Debug|x86.ActiveCfg = Debug|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Debug|x86.Build.0 = Debug|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Release|Any CPU.Build.0 = Release|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Release|x64.ActiveCfg = Release|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Release|x64.Build.0 = Release|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Release|x86.ActiveCfg = Release|Any CPU + {70E70027-97A1-4862-953F-32874684A334}.Release|x86.Build.0 = Release|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Debug|x64.ActiveCfg = Debug|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Debug|x64.Build.0 = Debug|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Debug|x86.ActiveCfg = Debug|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Debug|x86.Build.0 = Debug|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Release|Any CPU.Build.0 = Release|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Release|x64.ActiveCfg = Release|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Release|x64.Build.0 = Release|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Release|x86.ActiveCfg = Release|Any CPU + {09E56536-DD59-49BD-A48F-41299C5B7ABF}.Release|x86.Build.0 = Release|Any CPU {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Debug|Any CPU.Build.0 = Debug|Any CPU {54CD8587-0F45-2C2C-7AE4-BB92254202F5}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -865,6 +1029,22 @@ Global {48955503-ECE7-4E3E-A1A1-8A04AA133724} = {4943BF85-38ED-4E0D-9FD7-1CC3D24FE106} {E47B85E3-AB64-C985-5561-3BA06AEB256F} = {E01B4001-A2EB-4134-8AA8-8A32F06F53FE} {4B987914-01EE-48B4-B645-A6F469297853} = {86C93AEE-6E67-44EB-BE96-168DFD26311D} + {4033DC3B-5F3E-4D69-AEC0-97D5BA4DB370} = {5B5342D2-2392-4EB8-9933-A21DB9416534} + {95780E7B-43ED-4404-8917-A46D4DC30083} = {4033DC3B-5F3E-4D69-AEC0-97D5BA4DB370} + {D2F88DCC-0DDB-4B25-BA0C-975D437F633D} = {4033DC3B-5F3E-4D69-AEC0-97D5BA4DB370} + {8658F459-F63E-443E-90CA-B9FFE463996B} = {4033DC3B-5F3E-4D69-AEC0-97D5BA4DB370} + {848DC6FA-94E0-4805-9107-501102BC4A6D} = {4033DC3B-5F3E-4D69-AEC0-97D5BA4DB370} + {5FA63BE3-43AB-47E4-9479-FC6AE30760E8} = {95780E7B-43ED-4404-8917-A46D4DC30083} + {BCEA1093-FB6F-4B58-AA8C-FF01DD5CB574} = {8658F459-F63E-443E-90CA-B9FFE463996B} + {D271F0DB-79CC-4878-9922-482FA7C49333} = {95780E7B-43ED-4404-8917-A46D4DC30083} + {37285E51-9DEE-4344-9B0E-B3185016600A} = {95780E7B-43ED-4404-8917-A46D4DC30083} + {E4E396DA-90FB-489C-A40C-1B22563CF203} = {848DC6FA-94E0-4805-9107-501102BC4A6D} + {8C369124-BA40-4B77-8DCB-588700C97430} = {D2F88DCC-0DDB-4B25-BA0C-975D437F633D} + {D1CF8953-16EB-4B3A-92F9-ABA545F87D76} = {D2F88DCC-0DDB-4B25-BA0C-975D437F633D} + {ECC8BFF1-12F9-4F7B-90FE-4FA454683C32} = {D2F88DCC-0DDB-4B25-BA0C-975D437F633D} + {8CB05C57-7A07-4228-AE5F-2AC88792062A} = {95780E7B-43ED-4404-8917-A46D4DC30083} + {70E70027-97A1-4862-953F-32874684A334} = {848DC6FA-94E0-4805-9107-501102BC4A6D} + {09E56536-DD59-49BD-A48F-41299C5B7ABF} = {95780E7B-43ED-4404-8917-A46D4DC30083} {54CD8587-0F45-2C2C-7AE4-BB92254202F5} = {570F3635-BEB1-4067-B10F-33DD890BDBD4} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/Directory.Packages.props b/Directory.Packages.props index 81e385c5..f648c311 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,9 @@ + + + diff --git a/README.md b/README.md index 31e5a0f4..270403e1 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,13 @@ The included [change log](CHANGELOG.md) details all key changes per published ve ## Samples -The following samples are provided to guide usage: +The repository includes Contoso reference samples that demonstrate CoreEx across API, database, outbox, subscriber, orchestration, and testing scenarios. See [samples/README.md](./samples/README.md) for the runnable topology, prerequisites, and commands. Sample | Description -|- -[My.Hr](./samples/My.Hr) | A sample to demonstrate the usage of _CoreEx_ within the context of a fictitious Human Resources solution. The main intent is to show how _CoreEx_ can be leveraged to build Web APIs and Azure Functions. Additionally, the unit testing provided within demonstrates the thoroughness of testing that can be achieved with some of the other repos mentioned below. +[Contoso Products](./samples/README.md) | Reference microservice showing API, database migrations, transactional outbox, relay, subscriber, and test coverage for a product/inventory domain. +[Contoso Shopping](./samples/README.md) | Reference microservice showing aggregate-centric application design, cross-service HTTP integration, hybrid caching, messaging, and integration testing. +[Contoso Orders / Order.Workflow](./samples/README.md) | Additional sample areas for order processing and Durable Task orchestration that are currently in progress.
diff --git a/azure/AGENTS.md b/azure/AGENTS.md index 1ad7023a..07433fe5 100644 --- a/azure/AGENTS.md +++ b/azure/AGENTS.md @@ -1,3 +1,9 @@ +--- +description: "Operational guidance for AI agents deploying Contoso sample services to Azure via azd/Bicep or Terraform" +scope: "azure/" +tags: ["azure", "deployment", "iac", "bicep", "terraform"] +--- + # AGENTS.md — Azure Deployment Operational guide for AI agents working in the `azure/` folder of this repository. This deploys the Contoso sample services (under `samples/src/`) to Azure using either **Azure Developer CLI (azd) + Bicep** or **Terraform**. diff --git a/docker-compose.yml b/docker-compose.yml index a477b9a3..f88a7114 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,4 +41,12 @@ services: ACCEPT_EULA: "Y" ports: - "5672:5672" # AMQP - - "5300:5300" # Management / HTTP \ No newline at end of file + - "5300:5300" # Management / HTTP + + dts-emulator: + image: mcr.microsoft.com/dts/dts-emulator:latest + environment: + DTS_TASK_HUB_NAMES: "default,order" + ports: + - "8080:8080" + - "8082:8082" \ No newline at end of file diff --git a/docs/JsonDataReader-vs-JsonNodeDataReader.md b/docs/JsonDataReader-vs-JsonNodeDataReader.md new file mode 100644 index 00000000..02539b9f --- /dev/null +++ b/docs/JsonDataReader-vs-JsonNodeDataReader.md @@ -0,0 +1,189 @@ +# JsonDataReader vs JsonNodeDataReader + +## Overview + +`JsonNodeDataReader` is the **mutable** alternative to `JsonDataReader`, using `JsonNode` instead of `JsonElement`. + +## Key Differences + +| Feature | JsonDataReader | JsonNodeDataReader | +|---------|----------------|-------------------| +| **Underlying Type** | `JsonElement` (struct) | `JsonNode` (class hierarchy) | +| **Mutability** | Immutable/Read-only | Mutable - can modify returned nodes | +| **Memory Model** | Backed by `JsonDocument` | Standalone object graph | +| **Disposal** | Requires `IDisposable` | No disposal needed | +| **Root Property** | `RootElement` (JsonElement) | `RootNode` (JsonNode) | +| **Type Checking** | `ValueKind` enum | Type patterns (`is JsonObject`) | +| **Performance** | Lower memory overhead | Higher allocation cost | +| **Thread Safety** | Safe (immutable) | Unsafe (mutable) | + +## API Comparison + +### Creating Instances + +```csharp +// JsonDataReader +var reader = JsonDataReader.ParseJson(jsonString); +using (reader) // Must dispose +{ + var root = reader.RootElement; +} + +// JsonNodeDataReader +var nodeReader = JsonNodeDataReader.ParseJson(jsonString); +// No disposal needed +var root = nodeReader.RootNode; +``` + +### Getting Data + +```csharp +// JsonDataReader +if (reader.TryGetPath("path.to.data", out JsonElement element)) +{ + // element is read-only +} + +// JsonNodeDataReader +if (nodeReader.TryGetPath("path.to.data", out JsonNode? node)) +{ + // node can be modified + if (node is JsonObject obj) + { + obj["newProperty"] = "new value"; // Mutation! + } +} +``` + +### Creating Data with Parameters + +```csharp +// JsonDataReader +if (reader.TryCreateData("data", out JsonElement? result, properties, parameters)) +{ + // result is immutable +} + +// JsonNodeDataReader +if (nodeReader.TryCreateData("data", out JsonNode? result, properties, parameters)) +{ + // result can be modified after creation + if (result is JsonArray arr) + { + arr.Add(JsonValue.Create("new item")); // Can add items! + } +} +``` + +## When to Use Each + +### Use JsonDataReader When: +- ✅ You need maximum performance and minimal allocations +- ✅ Read-only access is sufficient +- ✅ Working with large JSON documents +- ✅ Thread-safety is important + +### Use JsonNodeDataReader When: +- ✅ You need to mutate the JSON after loading +- ✅ Building or modifying JSON structures dynamically +- ✅ Working with smaller datasets where allocation overhead is acceptable +- ✅ You prefer working with object-oriented APIs + +## Migration Path + +To migrate from `JsonDataReader` to `JsonNodeDataReader`: + +1. Change the type: + ```csharp + // Before + var reader = JsonDataReader.ParseJson(json); + + // After + var reader = JsonNodeDataReader.ParseJson(json); + ``` + +2. Update property access: + ```csharp + // Before + JsonElement root = reader.RootElement; + + // After + JsonNode root = reader.RootNode; + ``` + +3. Remove disposal (if using `using`): + ```csharp + // Before + using var reader = JsonDataReader.ParseJson(json); + + // After (no disposal needed) + var reader = JsonNodeDataReader.ParseJson(json); + ``` + +4. Update type checking: + ```csharp + // Before + if (element.ValueKind == JsonValueKind.Object) + + // After + if (node is JsonObject obj) + ``` + +5. Update parameter functions: + ```csharp + // Before + var params = new Dictionary>(); + + // After + var params = new Dictionary>(); + ``` + +## Internal Implementation Differences + +### JsonDataReaderArgs vs JsonNodeDataReaderArgs + +```csharp +// JsonDataReaderArgs +public class JsonDataReaderArgs +{ + public JsonElement Root { get; init; } + public JsonProperty Current { get; internal set; } // Struct with Name + Value +} + +// JsonNodeDataReaderArgs +public class JsonNodeDataReaderArgs +{ + public JsonNode? Root { get; init; } + public string? CurrentPropertyName { get; internal set; } // Separate name + public JsonNode? CurrentValue { get; internal set; } // and value +} +``` + +### Copying Logic + +- **JsonDataReader**: Uses `Utf8JsonWriter` to write, then re-parses to `JsonElement` +- **JsonNodeDataReader**: Creates new `JsonObject`/`JsonArray`/`JsonValue` instances directly + +## Performance Considerations + +```csharp +// JsonDataReader - minimal allocations +var reader = JsonDataReader.ParseJson(largeJson); +var element = reader.RootElement; // Zero-copy access to document + +// JsonNodeDataReader - object allocations +var nodeReader = JsonNodeDataReader.ParseJson(largeJson); +var node = nodeReader.RootNode; // Allocates object graph +``` + +## Deprecation Timeline + +- **Phase 1** (Current): Both APIs available side-by-side +- **Phase 2** (Future): Mark `JsonDataReader` as `[Obsolete]` with migration guidance +- **Phase 3** (Major version): Remove `JsonDataReader` if JsonNodeDataReader proves superior + +## Examples + +See unit tests in: +- `tests/CoreEx.Data.Test.Unit/Json/JsonDataReaderTests.cs` (existing) +- `tests/CoreEx.Data.Test.Unit/Json/JsonNodeDataReaderTests.cs` (to be created) diff --git a/docs/agent-interaction-guide.md b/docs/agent-interaction-guide.md new file mode 100644 index 00000000..ff749b5e --- /dev/null +++ b/docs/agent-interaction-guide.md @@ -0,0 +1,277 @@ +# CoreEx Agent Interaction Guide + +This guide is for **consulting delivery teams consuming CoreEx from the starter or NuGet packages**. Its purpose is to help you use the agent as an interactive partner for learning the framework, understanding the sample architecture, and implementing features safely. + +This is not a framework reference. Use it when you want to know **how to ask**, **what to ask**, and **which skill or prompt to use**. + +## Start with the Right Kind of Request + +Most unhelpful agent conversations start with a request that is too vague. Tell the agent which of these modes you want: + +| If you want to... | Ask for... | Example | +|---|---|---| +| Understand how the repo works. | **Explanation / discovery.** | `Map the Shopping domain and explain its layers.` | +| Understand a pattern. | **Sample-backed explanation.** | `Explain outbox publishing in this repo using the Product sample.` | +| Decide what to build. | **Solution shaping.** | `Given this use case, what is the smallest CoreEx shape I should scaffold?` | +| Create a new domain. | **Greenfield scaffolding.** | `Generate a new Orders domain with validation and SQL Server persistence.` | +| Add capabilities to an existing domain. | **Capability retrofit.** | `Inspect this domain and add the missing messaging pieces.` | +| Change working code. | **Implementation.** | `Inspect the existing implementation first, then add support for X using current CoreEx patterns.` | + +## Ask for Repo-Grounded Answers + +When you are new to the framework, avoid asking for generic platform advice when what you really want is **how this repo does it**. + +Prefer: + +- `Explain validation in this repo with examples from the samples.` +- `Compare API + relay versus API-only using the Product and Shopping samples.` +- `Show me where ETag handling belongs in CoreEx service/controller flow.` + +Avoid: + +- `How should .NET APIs do validation?` +- `What is the best eventing architecture?` + +Those broader questions tend to produce generic answers instead of CoreEx-specific guidance. + +## Ask Capability Questions Directly + +Yes — you should absolutely ask direct capability questions such as: + +- `What is the idempotency key feature in CoreEx?` +- `What problem does it solve?` +- `When should I use it?` +- `How would I implement it in my solution?` + +That is a good way to learn the framework. In practice, the best version of that question asks for four things together: + +1. **What the capability is.** +2. **What problem it solves.** +3. **When to use it versus not use it.** +4. **How it is implemented in this repo or in my current solution.** + +Good examples: + +- `Explain the idempotency key feature in CoreEx. What problem does it solve, when should I use it, and how is it implemented in this repo?` +- `Explain ETag handling in CoreEx, what risks it addresses, and how I would add it to my current API.` +- `Explain the outbox pattern in this repo, what failure mode it prevents, and what I would need to add to my solution to support it.` + +This same pattern works well for: + +- idempotency +- ETags +- validation +- reference data orchestration +- FusionCache / Redis +- outbox relay +- subscriber hosts +- orchestration + +## Tell the Agent What Outcome You Want + +The same topic can produce very different outcomes. State the intended result explicitly: + +- **Explanation only** — no code changes. +- **Plan only** — propose the approach before editing. +- **Implement it** — make the changes. +- **Implement it and align to the samples** — make the changes using the current repo conventions. + +Examples: + +- `Explain how subscribers work here. No code changes.` +- `Plan the smallest safe way to add reliable event publishing to this domain.` +- `Implement this feature using existing CoreEx patterns only.` + +For quick capability or pattern questions that you do not want to add to the main conversation flow, use the Copilot CLI **`/ask`** command as a lightweight side question. + +Examples: + +- `/ask What is the idempotency key feature in CoreEx and what problem does it solve?` +- `/ask When is outbox sufficient and when do I need orchestration?` +- `/ask Does this repo treat ETag support as optional or expected for mutable entities?` + +## Ask the Agent to Inspect Before Recommending + +For existing solutions, ask the agent to inspect the current state before it suggests changes. This is especially important for consulting work, where a domain may already be partially set up. + +Good examples: + +- `Inspect this domain and explain what capabilities are already present before recommending changes.` +- `Check whether this service already has outbox, subscribers, or relay support before proposing a retrofit.` +- `Map the current host shape first, then tell me what is missing for this use case.` + +This is the safest way to avoid duplicate hosts, redundant packages, or advice that ignores what the project already has. + +## Use the Right Skill or Prompt + +The repo already exposes several entry points. Use them intentionally. + +| Need | Best fit | Why | +|---|---|---| +| Understand an unfamiliar repo or area. | `acquire-codebase-knowledge` | Produces structured codebase documentation and evidence-backed discovery. | +| Create a new solution shape. | `coreex-project-bootstrap` | Best for solution-level bootstrapping from requirements. | +| Create a new custom domain. | `/generate-domain` | Best when the agent needs to reason about fields, validation, and event naming. | +| Create a template-shaped domain quickly. | `/scaffold-domain-from-templates` | Best for deterministic, template-aligned output. | +| Add capabilities to an existing domain. | `/add-capability` | Best for incremental retrofits such as relay, subscribe, and messaging alignment. | +| Start local dependencies or sample runtime. | `init`, `setup`, or Aspire tooling | Best for environment and sample execution workflows. | + +## A Good Question Usually Includes Four Things + +A strong request usually includes: + +1. **The use case.** +2. **The current context.** +3. **The desired outcome.** +4. **Any constraints.** + +Template: + +```text +I am working on . +Please inspect first. +I want . +Use existing CoreEx patterns and align to . +``` + +Example: + +```text +I am working on an Orders domain. +Inspect the current domain first. +I want a plan for adding reliable integration-event publishing. +Use existing CoreEx patterns and align to the Product sample. +``` + +## Questions That Work Well for New Developers + +### Learn the architecture + +- `Map the Products sample and explain the role of each project.` +- `Explain how Contracts, Application, Infrastructure, Api, Subscribe, and Outbox.Relay fit together here.` +- `Show me the request flow for a Product create in the samples.` + +### Learn a practice + +- `Explain where validation belongs in CoreEx and show the sample pattern.` +- `Explain why this repo uses outbox relay instead of publishing directly from the API.` +- `Show me how reference data is modeled and consumed.` + +### Learn a capability feature + +- `What is the idempotency key feature in CoreEx, what problem does it solve, and how would I implement it in my solution?` +- `What is ETag support in CoreEx, what issue does it prevent, and where does it belong in the API flow?` +- `What is FusionCache used for here, when should I add it, and what would the minimal implementation look like?` +- `What is the reference data orchestration feature, when is it worth adding, and how would it fit in this solution shape?` + +### Decide what to scaffold + +- `Given this use case, do I need API only, API + relay, API + subscribe, or orchestration?` +- `What is the smallest CoreEx shape that supports this requirement?` +- `Should this domain use /generate-domain or /add-capability?` + +### Prepare to implement + +- `Inspect the current domain and list what is already set up before recommending changes.` +- `Compare my use case to Product, Shopping, and Order.Workflow and tell me which pattern is closest.` +- `Plan the feature in terms of Contracts, Application, Infrastructure, Api, and hosts.` + +## When to Ask for Comparisons + +Comparative questions are especially useful when you are still learning the framework. + +Examples: + +- `Compare API-only versus API + relay for this use case.` +- `Compare subscriber host versus orchestration worker for this business flow.` +- `Compare /generate-domain versus /scaffold-domain-from-templates for this new domain.` +- `Compare adding capability retroactively versus scaffolding the full host shape from day one.` + +This helps you learn the framework decision points instead of only getting one recommendation. + +## Ask for Sample Alignment Explicitly + +If you want implementation help that matches repo conventions, say so directly. + +Useful phrases: + +- `Align to the Product sample.` +- `Use the same host wiring pattern as Shopping.Subscribe.` +- `Follow the existing CoreEx instructions and sample conventions.` +- `Prefer the current repo pattern over generic alternatives.` + +## Tell the Agent How Conservative to Be + +In consulting projects, you often want the **smallest safe change**, not a broad redesign. + +Useful phrases: + +- `Prefer the smallest safe change.` +- `Do not restructure the domain unless required.` +- `Preserve the current layering and naming unless there is a clear mismatch.` +- `Add only the missing capability pieces.` + +## Good Framing for Feature Requests + +When asking for implementation help, frame the feature in business terms first, then let the agent translate that into CoreEx capabilities. + +Good: + +- `This service needs to publish an event after a successful write. What CoreEx capabilities should I add?` +- `This domain must react to upstream product updates. How should that be implemented in this repo?` +- `This process waits for external approval. Is this a subscriber problem or an orchestration problem?` + +Less effective: + +- `Add Service Bus.` +- `Use Redis.` +- `Add CoreEx.Events.` + +Package-driven questions are less useful than use-case-driven questions. + +## Ask for Boundaries, Not Just Answers + +When you are uncertain, ask the agent to explain **what is in scope and what is out of scope** for a pattern. + +Examples: + +- `When is outbox sufficient and when do I need orchestration?` +- `When should I stop at API-only instead of adding subscribers?` +- `What does /add-capability handle today, and what would still need manual work?` + +That helps you understand the framework’s decision boundaries instead of only the happy path. + +For capability questions, useful follow-ups are: + +- `When should I avoid this feature?` +- `What is the smallest version of this capability I can add first?` +- `Does my current solution already have part of this set up?` +- `Which files or hosts would change if I add it?` + +## Common Anti-Patterns + +Avoid these when interacting with the agent: + +- Asking for a package before stating the use case. +- Asking for implementation without asking the agent to inspect the current state first. +- Asking for generic .NET advice when you need CoreEx-specific guidance. +- Asking for “the best architecture” instead of comparing concrete CoreEx shapes. +- Asking for a full redesign when you only need a capability retrofit. + +## Suggested Learning Sequence + +If you are new to CoreEx, this sequence works well: + +1. Read `README.md`. +2. Read `docs/application-scaffolding-guide.md`. +3. Read `docs/capabilities.md`. +4. Use the agent to map one sample domain. +5. Ask the agent to explain one end-to-end request flow. +6. Ask the agent to compare two implementation shapes for your real use case. +7. Move to planning or implementation only after that. + +## Where to Go Next + +- Use `docs/agent-prompt-recipes.md` for copy/paste prompt starters. +- Use `docs/application-scaffolding-guide.md` to choose the right host/capability shape. +- Use `docs/capabilities.md` for deeper pattern explanations. +- Use `docs/orchestration.md` when the use case goes beyond request/response plus outbox. diff --git a/docs/agent-prompt-recipes.md b/docs/agent-prompt-recipes.md new file mode 100644 index 00000000..0029625b --- /dev/null +++ b/docs/agent-prompt-recipes.md @@ -0,0 +1,337 @@ +# CoreEx Agent Prompt Recipes + +This guide gives **copy/paste prompt patterns** for consultant delivery teams using CoreEx from the starter or NuGet packages. Adapt the wording to your domain, but keep the structure. + +If you want to ask one of these as a quick side question in Copilot CLI without adding it to the main conversation flow, you can also prefix it with **`/ask`**. + +## 1. Understand the Codebase + +### Map a domain + +```text +Map the area of this repo for me. +Explain the role of Contracts, Application, Infrastructure, Api, Database, Subscribe, and Outbox.Relay if present. +Use concrete file references from this repo. +No code changes. +``` + +### Explain a request flow + +```text +Show me the end-to-end request flow for in the samples. +Use the actual sample code and explain which layer owns each step. +No code changes. +``` + +### Compare two samples + +```text +Compare the Product and Shopping samples for . +Focus on the differences in host shape, messaging, and domain behavior. +No code changes. +``` + +## 2. Learn a Pattern + +### Capability explainer template + +```text +Explain the feature in CoreEx. +Tell me: +- what it is +- what problem it solves +- when to use it and when not to use it +- how it is implemented in this repo +- how I would add it to my solution + +Use sample-backed guidance where possible. +No code changes. +``` + +### Idempotency key + +```text +What is the idempotency key feature in CoreEx? +What problem does it solve? +When should I use it? +How would I implement it in my solution? +Use the repo patterns and samples where relevant. +No code changes. +``` + +Quick CLI variant: + +```text +/ask What is the idempotency key feature in CoreEx, what problem does it solve, and when should I use it? +``` + +### Reference data orchestration + +```text +Explain the reference data orchestration feature in CoreEx. +What problem does it solve? +When is it worth adding? +How would it fit into my current solution shape? +``` + +### FusionCache / Redis + +```text +Explain the FusionCache and Redis pattern used in this repo. +What problem does it solve? +When should I add it? +What is the smallest implementation I could add first? +``` + +### Validation + +```text +Explain how validation is implemented in this repo. +Show me where the validator lives, where it is called, and how that differs from controller validation. +Use sample-backed examples only. +``` + +### Outbox + +```text +Explain how reliable event publishing works in this repo. +Use the Product sample and describe API host, outbox tables, relay host, and subscriber flow. +No code changes. +``` + +### ETag and concurrency + +```text +Explain ETag and optimistic concurrency handling in this repo. +Show me the contract, service, and API implications using an existing sample. +``` + +### Orchestration + +```text +Compare request/response plus outbox versus orchestration for this use case: +. +Use the Order.Workflow sample where relevant. +No code changes. +``` + +## 3. Shape a New Solution + +### Choose the smallest CoreEx shape + +```text +Given this use case: + + +Recommend the smallest CoreEx application shape that supports it. +Tell me whether I need API only, API + relay, API + subscribe, or orchestration. +Explain why. +``` + +### Choose the right scaffolding entry point + +```text +I need to implement this: + + +Tell me whether I should use coreex-project-bootstrap, /generate-domain, /scaffold-domain-from-templates, or /add-capability. +Explain the tradeoffs using the current repo guidance. +``` + +## 4. Plan a Feature + +### Plan before coding + +```text +Inspect the current implementation first. +Then create a plan for adding . +Use existing CoreEx patterns only and align to the closest sample in this repo. +Do not implement yet. +``` + +### Ask for layer-by-layer impact + +```text +For this feature: + + +Tell me what should change in Contracts, Application, Infrastructure, Api, and any hosts. +Use the current repo conventions. +No code changes yet. +``` + +### Ask for capability guidance + +```text +This feature needs: + + +Tell me which CoreEx capabilities are actually needed and which should be deferred. +Prefer the smallest safe change. +``` + +## 5. Implement a Feature Safely + +### Smallest safe change + +```text +Inspect the current domain first. +Implement using the smallest safe change. +Preserve the current layering and naming unless a restructure is required. +Use existing CoreEx patterns only. +``` + +### Sample-aligned implementation + +```text +Implement in this domain. +Align to the closest existing sample in this repo. +Explain briefly which sample you followed and why. +``` + +### Conservative enhancement + +```text +Enhance the existing implementation to support . +Do not regenerate the domain. +Do not add unrelated capabilities. +Inspect what is already present before editing. +``` + +## 6. Retrofit Existing Domains + +### Add reliable event publishing + +```text +Inspect this domain and determine whether it already has outbox, relay, or event publisher wiring. +Then add the missing pieces required for reliable integration-event publishing. +Use current CoreEx messaging patterns only. +``` + +### Add subscriber support + +```text +Inspect this domain for any existing Subscribe host or Service Bus wiring. +Then add the missing pieces required to consume . +Keep subscriber logic thin and aligned to repo conventions. +``` + +### Use the retrofit skill intentionally + +```text +Use /add-capability for this existing domain. +Inspect the current state first, then add . +Treat SQL Server and Azure Service Bus as defaults unless you find evidence otherwise. +``` + +## 7. Review an Existing Design + +### Convention check + +```text +Review this implementation against the current repo conventions. +Focus on layering, CoreEx usage, validation placement, host wiring, and messaging patterns. +Ignore style-only feedback. +``` + +### Compare against the samples + +```text +Compare this implementation to the closest sample in the repo. +Tell me what is aligned, what is drifting, and what matters functionally. +``` + +### Ask for missing capabilities + +```text +Inspect this domain and list which CoreEx capabilities appear to be missing for this use case: + + +Explain which are required now versus optional later. +``` + +## 8. Debug Architecture or Modeling Uncertainty + +### Is this CRUD, messaging, or orchestration? + +```text +I am not sure whether this requirement should be modeled as: +- a normal API write +- API + outbox event publishing +- subscriber-driven reaction +- orchestration + +Use the current repo patterns to compare those options for this use case: + +``` + +### Should I add a new host? + +```text +Inspect the current domain shape. +Tell me whether this requirement justifies adding a new host or can be handled in the existing ones. +Prefer the smallest safe architecture. +``` + +## 9. Learn by Asking Better Follow-Ups + +When the first answer is not enough, use follow-ups like these: + +- `Show me the actual files that demonstrate that pattern.` +- `Which sample is the closest fit for this advice?` +- `What is the smallest version of this that I can implement first?` +- `What would you defer until later?` +- `What files or hosts would change if I added this capability?` +- `Does my current solution already have part of this capability set up?` +- `What would change if this were an existing domain instead of a new one?` +- `What should I ask you next if I want you to implement this safely?` + +## 10. Prompt Framing Patterns That Usually Work + +### Explanation only + +```text +Explain in the context of this repo. +Use sample-backed evidence. +No code changes. +``` + +### Plan only + +```text +Inspect the current implementation first. +Then create a plan for . +Do not implement yet. +``` + +### Implement + +```text +Inspect the current implementation first. +Then implement using existing CoreEx conventions and the closest sample pattern. +Prefer the smallest safe change. +``` + +### Implement and verify scope + +```text +Implement . +Before changing code, tell me which hosts/layers you expect to modify and why. +Then proceed with the smallest safe change. +``` + +## 11. What to Include in Your Prompt + +For the best results, include: + +- the **use case** +- whether the code already exists +- whether you want **explanation**, **plan**, or **implementation** +- whether the result should align to a specific sample +- any constraints such as “smallest safe change”, “no new host unless necessary”, or “use current SQL Server/Service Bus defaults” + +## Where to Go Next + +- Use `docs/agent-interaction-guide.md` to understand how to interact with the agent effectively. +- Use `docs/application-scaffolding-guide.md` to choose the right CoreEx shape. +- Use `docs/capabilities.md` to dive deeper into the underlying patterns. diff --git a/docs/agent-skills-workflow-guide.md b/docs/agent-skills-workflow-guide.md new file mode 100644 index 00000000..75ae1b82 --- /dev/null +++ b/docs/agent-skills-workflow-guide.md @@ -0,0 +1,368 @@ +--- +description: "Guide to CoreEx agent skills and their workflow integration" +tags: ["skills", "workflow", "scaffolding", "domains"] +--- + +# CoreEx Agent Skills Workflow Guide + +## Overview + +CoreEx provides four complementary skills that support different phases of domain development and codebase exploration. This guide explains when to use each skill and how they integrate into your development workflow. + +| Skill | Author | Purpose | Typical Use | +|-------|--------|---------|------------| +| `/generate-domain` | CoreEx Team | Scaffold a new domain from scratch | Greenfield domain creation | +| `/add-capability` | CoreEx Team | Add features to an existing domain | Post-generation feature enhancement | +| `/acquire-codebase-knowledge` | Awesome Copilot | Document and understand existing codebases | Onboarding and architecture discovery | +| `/aspire` | Aspire Team | Orchestrate distributed apps locally | Running and debugging Aspire apps | + +--- + +## Skill Descriptions + +### 1. `/generate-domain` — Greenfield Domain Scaffolding + +**Purpose:** Create a new CoreEx domain from scratch following established patterns and templates. + +**When to use:** +- Starting a new bounded context or microservice +- Creating a new domain entity (e.g., a new product line, tenant model, order type) +- You want to scaffold all five layers (Contracts, Application, Infrastructure, Api, Database) at once +- You prefer guided scaffolding with questions about your domain + +**What it generates:** +- Contracts (DTOs with source generation markers) +- Application services with validation and unit-of-work patterns +- Infrastructure repositories with EF Core and data access +- API controllers with WebApi helpers +- Database projects with migrations and DbEx YAML +- Sample validators, mappers, and domain events + +**Output characteristics:** +- Minimal viable product (MVP) focused +- Follows CoreEx conventions and patterns +- Ready to run and extend +- Includes standard error handling and validation + +**Example workflow:** +``` +User: Create a new Orders domain with Order and OrderItem entities +→ /generate-domain scaffolds the complete domain structure +→ User modifies generated code to add business logic +``` + +--- + +### 2. `/add-capability` — Post-Generation Enhancement + +**Purpose:** Retrofit an existing CoreEx domain with additional capabilities and integrations. + +**When to use:** +- Domain already exists (created with `/generate-domain` or manually) +- Adding event outbox and relay support +- Integrating with Azure Service Bus messaging +- Scaffolding event subscribers +- Adding cross-domain integration features +- Aligning an existing domain with messaging patterns + +**What it adds:** +- Outbox configuration and migrations +- Service Bus integration +- Event publisher scaffolding +- Subscriber hosts and event handler templates +- Integration wiring in Program.cs files +- Deployment/infrastructure updates + +**Prerequisites:** +- Domain must follow CoreEx project structure conventions +- Project naming must align with CoreEx patterns (`*.Contracts`, `*.Application`, etc.) +- If retrofitting existing code: must be compatible with CoreEx layering + +**Example workflow:** +``` +User has: Orders domain created with /generate-domain +→ /add-capability adds event outbox and Service Bus support +→ User creates domain event contracts +→ User implements event handlers in Subscribe project +``` + +--- + +### 3. `/acquire-codebase-knowledge` — Codebase Discovery & Documentation + +**Purpose:** Map, document, and understand an existing codebase structure. + +**When to use:** +- Onboarding to a new CoreEx repository or codebase +- Creating architectural documentation +- Understanding how an existing domain is structured +- Analyzing cross-domain dependencies +- Documenting layering and separation of concerns +- Planning a refactor or migration + +**What it discovers:** +- Project structure and organization +- Dependency graphs and cross-project relationships +- Layer architecture (Contracts → Application → Infrastructure) +- Design patterns and conventions in use +- Integration points and messaging patterns +- Technology stack and framework choices + +**Output characteristics:** +- Comprehensive codebase maps +- Architecture documentation +- Pattern identification +- Dependency analysis +- Best practice summaries + +**Relationship to other skills:** +- Run this first if inheriting unfamiliar code +- Use output to inform `/add-capability` decisions +- Helps determine if existing code aligns with CoreEx patterns for `/add-capability` + +**Example workflow:** +``` +User: Onboard to existing Shopping domain +→ /acquire-codebase-knowledge documents the structure +→ User understands layering and patterns +→ User can then use /add-capability to extend the domain +``` + +--- + +### 4. `/aspire` — Distributed App Orchestration + +**Purpose:** Run, manage, and debug Aspire distributed applications locally. + +**When to use:** +- Starting the local development environment +- Debugging multiple services together +- Viewing Aspire dashboard and observability +- Managing service dependencies +- Adding new resources to orchestration +- Checking service health and logs + +**What it handles:** +- `aspire start` / `aspire stop` commands +- Aspire dashboard access +- Service dependency resolution +- Container and local resource management +- OpenTelemetry and observability +- Resource status and health checks + +**Integration:** +- Used after domain(s) are created and configured +- Works with domains scaffolded by `/generate-domain` +- Coordinates with domains enhanced by `/add-capability` +- Provides feedback on Service Bus, databases, and messaging + +**Example workflow:** +``` +User: Ready to test Orders and Shopping domains together +→ /aspire start orchestrates all services +→ /aspire opens dashboard to monitor +→ Services interact via Service Bus (added by /add-capability) +``` + +--- + +## Typical Development Workflows + +### Workflow A: Greenfield Domain from Scratch + +**Scenario:** Building a new domain (e.g., Payments, Reporting, Inventory). + +``` +1. /generate-domain + ↓ (asks: entity name, business rules, events, database strategy) + ↓ (scaffolds: all 5 layers with MVP structure) + +2. Customize generated code + ↓ (add business logic, validation rules, database schema refinements) + +3. /add-capability (optional) + ↓ (add: outbox, Service Bus, subscribers if cross-domain events needed) + +4. /aspire + ↓ (test domain in context of other services) +``` + +**Tools used sequentially:** `/generate-domain` → customize → `/add-capability` → `/aspire` + +--- + +### Workflow B: Extending an Existing Domain + +**Scenario:** Orders domain already exists; now add event publishing and subscribers. + +``` +1. /acquire-codebase-knowledge + ↓ (understand: current structure, dependencies, patterns) + +2. /add-capability + ↓ (add: outbox events, Service Bus, subscriber scaffold) + +3. Implement domain events and handlers + ↓ (code: event contracts, event handlers, business logic) + +4. /aspire + ↓ (test: event flow, cross-domain integration) +``` + +**Tools used sequentially:** `/acquire-codebase-knowledge` → `/add-capability` → `/aspire` + +--- + +## Decision Tree: Which Skill to Use + +``` +START: What do you need? + +├─ I have an IDEA for a new domain or service +│ └─ → Use /generate-domain +│ +├─ I have an EXISTING domain and want to ADD features +│ ├─ Does it follow CoreEx structure? +│ │ ├─ Yes → Use /add-capability +│ │ └─ No → Use /acquire-codebase-knowledge first, then decide +│ └─ Examples: add messaging, add events, add caching +│ +├─ I'm NEW to a codebase and need to UNDERSTAND it +│ └─ → Use /acquire-codebase-knowledge +│ +├─ I want to RUN the system locally or DEBUG multiple services +│ └─ → Use /aspire +│ +└─ I want to PLAN a refactor or understand dependencies + └─ → Use /acquire-codebase-knowledge +``` + +--- + +## Integration Points + +### Between `/generate-domain` and `/add-capability` + +- **Handoff:** `/generate-domain` creates the initial domain structure; `/add-capability` enhances it. +- **Compatibility:** `/add-capability` expects domains created by `/generate-domain` to have standard project naming and layering. +- **Timing:** Can be done immediately after generation or deferred until messaging is needed. + +### Between `/acquire-codebase-knowledge` and `/add-capability` + +- **Prerequisite:** Run `/acquire-codebase-knowledge` first if uncertain whether existing code aligns with CoreEx patterns. +- **Decision Making:** Output from `/acquire-codebase-knowledge` informs whether `/add-capability` is suitable. +- **Retrofit Readiness:** Helps determine if hand-written code can be extended with `/add-capability`. + +### Between Domain Skills and `/aspire` + +- **Execution Environment:** `/aspire` provides the runtime orchestration for domains created or extended by the other skills. +- **Observability:** `/aspire` dashboard visualizes event flows, messaging, and service health across domains scaffolded by `/generate-domain` and enhanced by `/add-capability`. +- **Testing Loop:** After `/generate-domain` or `/add-capability`, use `/aspire` to validate the implementation. + +--- + +## Key Principles + +1. **Layered Approach:** Skills are designed to be used in sequence—start with generation, then add capabilities, then orchestrate. + +2. **Conventions Over Configuration:** All skills assume and enforce CoreEx project structure and naming patterns. Code created by these skills is interoperable. + +3. **Non-Breaking:** Each skill can be skipped. Using `/generate-domain` does not require later use of `/add-capability`. Running a domain with `/aspire` does not require prior use of `/acquire-codebase-knowledge`. + +4. **Scaffolding as Foundation:** Skills provide scaffolding and templates, not complete implementations. Customization and business logic still require developer input. + +5. **CoreEx Alignment:** All four skills assume and respect CoreEx architecture (layering, validation, unit of work, event patterns, etc.). + +--- + +## Common Scenarios and Recommended Skill Sequencing + +### New Microservice: E-Commerce Domain + +1. **Ideation:** Decide on Orders, Products, Payments domains +2. **Create Products:** `/generate-domain` → Products domain scaffold +3. **Enhance Products:** `/add-capability` → add outbox/events/Service Bus +4. **Create Orders:** `/generate-domain` → Orders domain scaffold +5. **Enhance Orders:** `/add-capability` → add outbox/events/Service Bus +6. **Create Payments:** `/generate-domain` → Payments domain scaffold +7. **Enhance Payments:** `/add-capability` → add outbox/events/Service Bus +8. **Integrate:** `/aspire` → run all three together, validate event flow + +**Skills in sequence:** `/generate-domain` × 3, `/add-capability` × 3, `/aspire` + +--- + +### Migrating Existing Codebase to CoreEx + +1. **Assess:** `/acquire-codebase-knowledge` → understand current structure +2. **Compare:** Review generated map against CoreEx expectations +3. **Decide:** Retrofit possible? Use `/add-capability`? Or rebuild with `/generate-domain`? +4. **Extend:** `/add-capability` → add messaging/integration if retrofitting works +5. **Validate:** `/aspire` → test migrated domain in new environment + +**Skills in sequence:** `/acquire-codebase-knowledge`, (optional: `/add-capability` or `/generate-domain`), `/aspire` + +--- + +### Team Onboarding to Contoso Sample + +1. **Orient:** `/acquire-codebase-knowledge` → document Contoso structure +2. **Review:** Generated map shows Products, Shopping, Orders layers +3. **Explore:** `/aspire` → run sample locally, observe services +4. **Propose:** Team proposes new domain → `/generate-domain` → scaffold new domain +5. **Enhance:** New domain needs messaging → `/add-capability` → add event support +6. **Validate:** `/aspire` → new domain integrates with existing services + +**Skills in sequence:** `/acquire-codebase-knowledge`, `/aspire`, `/generate-domain`, `/add-capability`, `/aspire` + +--- + +## FAQ + +**Q: Can I use `/add-capability` without first using `/generate-domain`?** + +A: Yes, if your existing domain follows CoreEx structure conventions: +- Project naming: `*.Contracts`, `*.Application`, `*.Infrastructure`, `*.Api`, `*.Database` +- Layering: Contracts → Application → Infrastructure separation +- Patterns: Uses CoreEx validators, repositories, unit of work, etc. + +If uncertain, run `/acquire-codebase-knowledge` first to assess alignment. + +--- + +**Q: Should I use `/acquire-codebase-knowledge` before `/generate-domain`?** + +A: Only if you're uncertain about domain design or CoreEx patterns. For greenfield scenarios, `/generate-domain` is a faster start. Use `/acquire-codebase-knowledge` when learning from existing samples or migrating legacy code. + +--- + +**Q: What if I only want to scaffold contracts and test with `/aspire`, skipping the full domain?** + +A: `/generate-domain` is all-or-nothing for a complete domain. For partial scaffolding, you'll need to code by hand following CoreEx conventions or ask the CoreEx Expert agent for guidance on minimal structure. + +--- + +**Q: Can multiple domains share a database?** + +A: Yes. `/generate-domain` and `/add-capability` default to per-domain databases, but you can manually merge database projects post-generation. Review the Contoso sample for examples of shared infrastructure. + +--- + +**Q: How do I know if my existing code is compatible with `/add-capability`?** + +A: Run `/acquire-codebase-knowledge` first and review the output. Look for: +- Standard project structure (5 layers) +- CoreEx naming conventions +- Use of `IUnitOfWork`, validators, repositories +- Absence of conflicting frameworks (e.g., conflicting IoC patterns) + +If alignment is unclear, ask the CoreEx Expert agent. + +--- + +## See Also + +- [CoreEx Capabilities](capabilities.md) — Detailed feature guide +- [Application Scaffolding Guide](application-scaffolding-guide.md) — Deep dive on domain structure +- [Orchestration Guide](orchestration.md) — Aspire and distributed app patterns +- [Agent Interaction Guide](agent-interaction-guide.md) — How to interact with CoreEx agents effectively diff --git a/docs/application-scaffolding-guide.md b/docs/application-scaffolding-guide.md new file mode 100644 index 00000000..d79edd0f --- /dev/null +++ b/docs/application-scaffolding-guide.md @@ -0,0 +1,361 @@ +# CoreEx Application Scaffolding Guide + +This guide helps a new team decide **what to scaffold first**, **which hosts to include**, and **which CoreEx capabilities to add now versus later**. It is intentionally decision-oriented: `docs/capabilities.md` explains what the framework can do, while this guide explains how to turn that into an application shape that fits your use case. If you want help learning how to ask the agent the right questions while making these decisions, see the [Agent Interaction Guide](agent-interaction-guide.md) and [Agent Prompt Recipes](agent-prompt-recipes.md). + +## Understand the Defaults vs the Abstractions + +In this repository, **SQL Server** and **Azure Service Bus** are the default scaffolding targets because they are the most complete sample implementations and the primary host wiring demonstrated in the starter and Contoso samples. + +That should not be read as "CoreEx only works with SQL Server and Service Bus." A better mental model is: + +- CoreEx provides **application and integration patterns** first. +- This repo currently provides **default implementation paths** for those patterns using SQL Server and Azure Service Bus. +- Alternative databases or brokers should be introduced when the **use case requires them**, not because teams want to abstract everything up front. + +Examples: + +- If SQL Server fits the operational and data requirements, use the standard SQL Server projects and migrations because that path is the most proven in this repo. +- If a domain truly requires a different database backend, CoreEx patterns such as contracts, services, validation, unit-of-work boundaries, and event workflows still matter; only the implementation plumbing changes. +- If Azure Service Bus fits the messaging needs, use the repo's default publisher/subscriber/relay wiring. +- If a use case requires another broker, the `EventData` abstraction and event-oriented architecture remain relevant even when the transport changes. + +## Start with the Smallest Useful Shape + +The starter and sample architecture support a modular domain layout built from: + +- `Contracts` +- `Application` +- `Infrastructure` +- `Api` +- `Database` +- optionally `Subscribe` +- optionally `Outbox.Relay` +- optionally a separate worker or orchestration host + +The sample host shapes also include **OpenTelemetry-compatible telemetry wiring** via the standard CoreEx/OpenTelemetry setup shown in the sample `Program.cs` files, so observability can be added as part of the normal host composition rather than as a separate architecture track. + +For most teams, the right question is not "Which CoreEx packages exist?" but "Which responsibilities does this application need on day one?" + +Use this progression: + +1. Start with a **single API domain** when the service owns its own data and synchronous CRUD-style operations are the primary need. +2. Add an **outbox relay** when the API must publish integration events reliably after database commits. +3. Add a **subscriber host** when the service must react to events or commands from other services. +4. Add a **worker or orchestration host** when the business process is long-running, stateful, batch-oriented, externally coordinated, or compensation-heavy. + +## Which Scaffolding Path to Use + +| Need | Best starting point | Why | +|---|---|---| +| New implementation solution with one or more domains and standard hosts. | `coreex-project-bootstrap` | The starter is built to scaffold solution structure, package choices, standard `Program.cs` wiring, tests, and layered projects. | +| Existing domain needs new messaging/integration capability added incrementally. | `/add-capability` | Best when the domain already exists and you want to retrofit capabilities such as `Outbox.Relay`, `Subscribe`, Service Bus wiring, or subscriber scaffolding without re-scaffolding the whole domain. | +| New domain that mostly fits the standard template shape. | `/scaffold-domain-from-templates` | Fastest path when the entity shape is conventional and you want deterministic output. | +| New domain with custom rules, non-trivial fields, validation nuance, query behavior, or event naming decisions. | `/generate-domain` | Best when the agent needs to reason about the model and apply CoreEx conventions instead of copying templates verbatim. | + +## Recommended Application Shapes + +### 1. API-Only Domain + +Choose this when: + +- The service mainly exposes synchronous HTTP operations. +- It owns its own schema and data lifecycle. +- Cross-service integration is limited or can be added later. + +Scaffold: + +- `Contracts` +- `Application` +- `Infrastructure` +- `Api` +- `Database` +- matching API and common test projects + +Pull in early: + +- `CoreEx` +- `CoreEx.AspNetCore` +- `CoreEx.AspNetCore.NSwag` +- `CoreEx.Database.SqlServer` +- `CoreEx.EntityFrameworkCore` + +Default implementation note: + +- SQL Server is the default starting point because it has the strongest scaffolding and sample coverage in this repo. +- Treat that as the recommended initial implementation, not as a rule that every CoreEx application must use SQL Server forever. + +Usually add immediately: + +- `ETag` and change-log support for mutable entities. +- `ProblemDetails`/CoreEx exception handling. +- OpenAPI and health checks. + +Good fit: + +- Product master data. +- Reference-data-backed CRUD domains. +- Internal line-of-business APIs that do not yet need async integration. + +### 2. API + Outbox Relay + +Choose this when: + +- The API updates business data and must publish integration events reliably. +- You need to avoid dual writes to database plus broker. +- Other services depend on ordered or guaranteed event publication. + +Scaffold: + +- API-only domain shape, plus `Outbox.Relay` + +Pull in early: + +- `CoreEx.Events` +- `CoreEx.Database.SqlServer` +- `CoreEx.Azure.Messaging.ServiceBus` + +Default implementation note: + +- SQL Server plus Azure Service Bus is the standard initial combination for reliable integration-event publication in this repo. +- Choose a different database or broker only when the business, platform, compliance, latency, throughput, tenancy, or deployment constraints justify that divergence. + +Usually add immediately: + +- Unit-of-work with outbox. +- Event formatter. +- Outbox tables and stored procedures in the database project. +- Relay host telemetry and health checks. + +Good fit: + +- Product, catalog, pricing, customer, or order domains that publish state-change events. +- Any service where "database committed but event not published" is unacceptable. + +### 3. API + Subscribe + Outbox Relay + +Choose this when: + +- The service both publishes its own events and consumes events or commands from other services. +- You are building a distributed service, not just a standalone API. +- You need asynchronous integration boundaries with explicit host separation. + +Scaffold: + +- `Contracts` +- `Application` +- `Infrastructure` +- `Api` +- `Database` +- `Subscribe` +- `Outbox.Relay` + +Pull in early: + +- `CoreEx.Events` +- `CoreEx.Azure.Messaging.ServiceBus` +- `CoreEx.Caching.FusionCache` when the service caches reference or replica data + +Default implementation note: + +- The sample architecture uses Azure Service Bus because the repo already demonstrates subscriber and relay hosts around it. +- The broader architectural pattern is still publish/subscribe with `EventData`; the broker choice is an implementation decision driven by the integration use case. + +Usually add immediately: + +- Subscriber classes per message subject. +- Shared error-handling strategy for known recoverable subscriber failures. +- Reference data orchestration if incoming messages rely on code tables or shared reference sets. + +Good fit: + +- Inventory availability projections. +- Shopping or basket domains that react to product or reservation events. +- Services that maintain local replicas of upstream data. + +### 4. API + Worker / Orchestration + +Choose this when: + +- The core business process spans multiple steps, services, or time boundaries. +- You need retries, timers, external-event waits, fan-out/fan-in, batching, or compensation. +- A request/response API plus pub/sub is not enough to model the process safely. + +Scaffold: + +- Core domain projects +- API host if the workflow is externally started or queried over HTTP +- separate worker/orchestration host +- supporting infrastructure for the workflow backend + +Pull in when needed: + +- Durable Task SDK + DTS patterns described in `docs/orchestration.md` +- CoreEx telemetry and health checks in the worker host + +Usually add immediately: + +- Explicit orchestration contracts. +- Activity boundaries around external calls. +- Client endpoint or service to start/query workflow instances. + +Good fit: + +- Order submission and approval flows. +- Long-running fulfilment or settlement processes. +- Human approval, callback-driven, or scheduled business operations. + +## Capability-by-Capability Guidance + +The biggest mistake new teams make is enabling every framework feature up front. Prefer enabling capabilities because the **use case demands them**, not because the package exists. + +| Capability | Add it when | Skip or defer when | +|---|---|---| +| **Validation** | The API accepts business input that must be checked consistently before persistence or orchestration. | The host is read-only or input shape is trivial and temporary. | +| **ETag / optimistic concurrency** | Multiple users or systems can update the same resource and lost updates matter. | Data is append-only or single-writer. | +| **Idempotency key** | Clients may retry POST requests, especially across unstable networks or user-driven retries. | The endpoint is naturally idempotent already or not externally retried. | +| **FusionCache + Redis** | Reads are hot, repeated, cross-instance, or expensive; you need hybrid L1/L2 caching and graceful degraded reads. | Data changes too frequently to benefit, or the service is small and latency/load do not justify cache complexity yet. | +| **Reference data orchestration** | The domain uses shared code tables, statuses, categories, units of measure, or other read-heavy lookup sets. | The values are local-only, short-lived, or not managed as reference data. | +| **Unit-of-work + outbox** | Data writes and integration-event publication must succeed together from a business perspective. | The service has no async integration boundary yet. | +| **Azure Service Bus integration** | You publish or consume messages across service boundaries and want the repo's standard broker pattern. | The application is strictly synchronous or local-only. | +| **Subscriber host** | The service reacts to upstream events/commands independently of user HTTP traffic. | Integration is outbound only. | +| **Outbox relay** | The service publishes integration events from committed business transactions. | The service consumes only and does not publish. | +| **Result pipelines** | You are modeling expected business failures or domain flows compositionally, especially around aggregates/workflows. | Exception-style services are clearer and the flow is simple CRUD. | +| **DomainDriven aggregate patterns** | The domain has invariants across child entities or rich mutation rules. | The service is mostly thin CRUD over simple records. | +| **Dynamic query / paging / filtering** | List endpoints need flexible API-side filtering, ordering, and projection. | Consumers only need a few fixed queries. | +| **Workflow orchestration** | A business process is long-running, resumable, externally coordinated, or compensation-heavy. | Simple CRUD plus event publication already covers the need. | + +## Choosing Defaults vs Diverging from Them + +Start with the repo defaults unless there is a concrete reason not to: + +- **Use SQL Server by default** because the database projects, migration tooling, outbox procedures, and sample hosts are already shaped around it. +- **Use Azure Service Bus by default** because the relay and subscriber patterns in this repo are already demonstrated around it. + +Diverge when the use case clearly demands it, for example: + +- Existing enterprise platform standards require another database or broker. +- Required operational characteristics are a poor fit for the default choice. +- A managed service, deployment target, or regulatory boundary constrains the technology selection. +- A specific integration landscape already centers on another messaging platform. + +When you do diverge, preserve the CoreEx patterns first: + +- keep the layered project shape +- keep `EventData` and integration-event conventions +- keep unit-of-work and outbox thinking where reliable publication still matters +- keep validation, execution context, HTTP semantics, and contract patterns + +In other words, the **use case should drive the backend**, not the other way around. + +## A Practical "What Should I Scaffold?" Checklist + +### If your application is mostly CRUD over owned data + +Scaffold a standard API domain first. Add: + +- contracts with `IIdentifier`, `IETag`, and `IChangeLog` where appropriate +- application service plus validator +- infrastructure repository and SQL Server database project +- API controllers with CoreEx `WebApi` helper style + +Do **not** start with orchestration or subscriber hosts unless there is an immediate business requirement. + +### If your application must notify other services after changes + +Start with the standard API domain, but include outbox and relay from the beginning. That gives you: + +- reliable post-commit event publication +- a clean boundary between request handling and broker delivery +- room to add subscribers later without redesigning the write path + +### If your application depends heavily on upstream domain data + +Scaffold a subscriber host early. This is a strong signal that the service is part of an event-driven landscape and should not rely only on synchronous API calls to other domains. + +Typical example: + +- Shopping depends on product and inventory-related events. +- The service keeps local state aligned through subscribers while still serving its own API. + +### If your application has approvals, callbacks, batches, or days-long flows + +Scaffold orchestration intentionally rather than forcing that logic into controllers, subscribers, or background timers. A plain background service can run repeated work, but it is not a substitute for durable workflow state, replay, timers, external events, or compensation logic. + +## How to Think About Layering + +CoreEx is most effective when you keep the responsibilities sharp: + +| Layer | Put this here | Do not put this here | +|---|---|---| +| Contracts | DTOs, identifiers, ETags, change logs, reference-data code properties. | Domain rules, persistence logic, service calls. | +| Application | Validation, unit-of-work orchestration, business use cases, event creation, adapters as interfaces. | HTTP plumbing, EF details, transport-specific code. | +| Infrastructure | Repositories, EF mapping, query config, typed HTTP clients, adapter implementations, outbox publisher plumbing. | Controller concerns and user-facing endpoint logic. | +| API | Routing, request/response behavior, `WebApi` helper usage, OpenAPI metadata, idempotency and HTTP semantics. | Rich business rules or database composition. | +| Subscribe / Worker / Relay | Message consumption, hosted background processing, relay mechanics, orchestration workers. | User-driven request/response logic. | + +If a capability changes the transport or execution model, it usually belongs in a host project. If it changes business rules or persistence orchestration, it usually belongs in Application or Infrastructure. + +## Opinionated Defaults That Usually Pay Off + +For a new greenfield CoreEx service, these defaults are usually worth keeping: + +- Use the layered domain shape instead of collapsing everything into the API. +- Use SQL Server first unless you have a strong reason to diverge. +- Use validators rather than ad hoc controller checks. +- Use ETags on mutable resources. +- Use OpenAPI, ProblemDetails, execution context, and health endpoints from the start. +- Use outbox if you already know the service will publish integration events. +- Use reference data orchestration if statuses, categories, or codes are central to the model. + +## Things to Avoid Scaffolding Too Early + +- A subscriber host when the domain does not actually consume messages yet. +- Orchestration for simple CRUD plus single-event publication. +- Rich aggregate patterns for record-centric admin data with no real invariants. +- Redis/FusionCache before there is either shared-cache need or measurable read pressure. +- CQRS split for every service when read and write concerns are still simple. + +## Suggested First Questions for a New Domain + +Before scaffolding, answer these: + +1. Does this service own its own database schema? +2. Will it publish integration events after writes? +3. Will it consume events or commands from other services? +4. Does it need shared reference data? +5. Are updates concurrent enough to require ETags? +6. Are POST retries likely enough to require idempotency? +7. Is the core business flow synchronous, eventually consistent, or orchestrated over time? +8. Does the model behave like true aggregates with invariants, or mostly like CRUD records? + +Those answers usually determine the host set, package set, and scaffold depth more reliably than entity field lists alone. + +## Suggested Starter Combinations + +| Use case | Scaffold | CoreEx capabilities to prioritize | +|---|---|---| +| Internal admin CRUD API. | API domain + database. | Validation, ETag, change log, OpenAPI, paging/filtering. | +| Master-data service that other domains depend on. | API + database + outbox relay. | Validation, reference data, outbox, Service Bus publisher, idempotency. | +| Event-driven domain maintaining local replicas or reacting to commands. | API + subscribe + outbox relay. | Service Bus subscriber/publisher, outbox, reference data, cache, health/telemetry. | +| Long-running business process or approval workflow. | API + worker/orchestration host, optionally plus outbox/subscribers. | Durable orchestration, telemetry, external-event waits, retries, compensation. | +| Rich aggregate domain with nested rules. | Domain scaffold via `/generate-domain`. | DomainDriven patterns, validators, Result pipelines where appropriate, explicit mapping. | +| Straightforward conventional entity. | Domain scaffold via `/scaffold-domain-from-templates`. | Standard contracts/application/infrastructure/API/database shape with minimal custom reasoning. | + +## Where to Go Next + +- Read `coreex-starter/README.md` for starter/bootstrap expectations. +- Read `docs/capabilities.md` for the underlying framework features and patterns. +- Read `docs/orchestration.md` before adding a workflow worker. +- Use the Product and Shopping samples as the concrete reference architecture for API, subscribe, and outbox relay hosts. + +## Evidence + +- `coreex-starter/README.md` +- `coreex-starter/.github/skills/coreex-project-bootstrap/SKILL.md` +- `.github/skills/generate-domain/SKILL.md` +- `.github/prompts/scaffold-domain-from-templates.prompt.md` +- `docs/capabilities.md` +- `docs/orchestration.md` +- `samples/src/Contoso.Products.Api/Program.cs` +- `samples/src/Contoso.Products.Subscribe/Program.cs` +- `samples/src/Contoso.Products.Outbox.Relay/Program.cs` diff --git a/docs/capabilities.md b/docs/capabilities.md new file mode 100644 index 00000000..8b0136c7 --- /dev/null +++ b/docs/capabilities.md @@ -0,0 +1,1109 @@ +# CoreEx Capabilities & Patterns Guide + +This document provides detailed explanations of CoreEx capabilities and common patterns to help developers understand the value and appropriate use cases for each feature. If you are deciding what to scaffold for a new service or domain, start with the [Application Scaffolding Guide](application-scaffolding-guide.md) and then use this document for the deeper capability details. If you are still learning how to ask the agent about these patterns effectively, also see the [Agent Interaction Guide](agent-interaction-guide.md) and [Agent Prompt Recipes](agent-prompt-recipes.md). + +## Table of Contents + +- [General Capabilities](#general-capabilities) + - [Exception-Based Error Handling](#exception-based-error-handling) + - [Dynamic Dependency Injection](#dynamic-dependency-injection) + - [Entity Patterns](#entity-patterns) + - [Roslyn Source Generation](#roslyn-source-generation) + - [Instrumentation & Health Checks](#instrumentation--health-checks) + - [Hybrid Caching (L1 + L2)](#hybrid-caching-l1--l2) + - [Hosted Services](#hosted-services-timer--synchronized) + - [Reference Data](#reference-data) + - [JSON Filtering & Merge-Patch](#json-filtering--merge-patch) + - [Validation](#validation) + - [Mapping Helpers](#mapping-helpers) + - [Globalization & Localization](#globalization--localization) + - [Railway-Oriented Programming](#railway-oriented-programming-with-resultt) +- [API & HTTP Features](#api--http-features) + - [Web API Styles](#web-api-styles-minimal--mvc) + - [RFC 7386 Merge-Patch](#rfc-7386-merge-patch-applicationmerge-patchjson) + - [Response JSON Filtering](#response-json-filtering) + - [Error Handling with ProblemDetails](#error-handling-with-problemdetails) + - [Conditional Request Semantics](#conditional-request-semantics-if-match) + - [Idempotency-Key](#idempotency-key) + - [Health Check Endpoints](#health-check-endpoints) + - [OpenAPI Integration](#openapi-integration-nswag) + - [CQRS](#cqrs-command-query-responsibility-segregation) +- [Data Access & Persistence](#data-access--persistence) + - [Unit-of-Work with Integrated Outbox](#unit-of-work-with-integrated-outbox) + - [Paging & Enumeration](#paging--enumeration) + - [Dynamic Query](#dynamic-query-odata-style) + - [Multi-Tenancy](#multi-tenancy) + - [Type Discriminators](#type-discriminators) +- [Database Support](#database-support) + - [SQL Server](#sql-server) + - [PostgreSQL](#postgresql) + - [ADO.NET Command & Parameter Extensions](#adonet-command--parameter-extensions) + - [Entity Framework Integration](#entity-framework-integration) +- [Messaging & Events](#messaging--events) + - [EventData Abstraction](#eventdata-abstraction) + - [CloudEvent Interoperability](#cloudevent-interoperability) + - [Publish + Subscribe Patterns](#publish--subscribe-patterns) + - [Azure Service Bus Integration](#azure-service-bus-integration) + - [Outbox Relay](#outbox-relay-with-partitioning) + - [Workflow Orchestration (Durable Task SDK + DTS)](#workflow-orchestration-durable-task-sdk--dts) +- [Domain-Driven Design](#domain-driven-design) + - [Aggregate & Entity Modeling](#aggregate--entity-modeling) + - [Value Objects](#value-objects) + - [Integration Events Only](#integration-events-only) +- [Putting It All Together](#putting-it-all-together-a-typical-request-flow) +- [Summary](#summary) + +--- + +## General Capabilities + +### Exception-Based Error Handling + +**Pattern:** CoreEx defines specific exception types that map to HTTP status codes automatically. + +CoreEx exception types include: +- `NotFoundException` — Resource not found (404). +- `ValidationException` — Validation failure (400). +- `ConcurrencyException` — ETag/version conflict (409). +- `BusinessException` — Domain rule violation (400). +- `AuthenticationException` — Unauthorized (401). +- `AuthorizationException` — Forbidden (403). + +**Why it matters:** Instead of throwing generic `Exception` or returning error codes, you throw domain-specific exceptions that middlewares automatically convert to RFC 9457 ProblemDetails responses. This keeps error handling logic centralized and consistent across APIs. + +**Example:** +```csharp +public async Task GetProductAsync(Guid id) +{ + var product = await _repository.GetByIdAsync(id); + if (product == null) + throw new NotFoundException($"Product '{id}' not found."); + return product; +} + +// Middleware automatically converts to: +// HTTP 404 with ProblemDetails JSON +``` + +### Dynamic Dependency Injection + +**Pattern:** Register and resolve services without a traditional DI container through dynamic composition. + +CoreEx provides extension methods like `AddExecutionContext()`, `AddMvcWebApi()`, `AddHttpWebApi()` that setup services with sensible defaults. You can layer additional registrations on top without heavyweight container configuration. + +**Why it matters:** Reduces boilerplate, makes service composition explicit, and keeps middleware stacks clean and understandable. + +**Example:** +```csharp +builder.Services + .AddExecutionContext() // Execution tenant/user context + .AddMvcWebApi() // MVC + exception handling + .AddHttpWebApi() // Minimal API + exception handling + .AddSqlServerDatabase() + .AddOutbox() + .AddFusionCache(); +``` + +### Entity Patterns + +**Identifiers & Composite Keys** + +CoreEx supports two patterns for entity identity: + +1. **Single Identifier** — Most entities have a single ID (GUID, int, string). + ```csharp + public interface IIdentifier + { + object? Id { get; } + } + + public class Product : IIdentifier + { + public Guid Id { get; set; } + public string Sku { get; set; } + } + ``` + +2. **Composite Key** — Some entities have multi-part identity (e.g., tenant + entityId). + ```csharp + public interface ICompositeKey + { + object?[] CompositeKeys { get; } + } + + public class TenantProduct : ICompositeKey + { + public Guid TenantId { get; set; } + public Guid ProductId { get; set; } + + public object?[] CompositeKeys => new object[] { TenantId, ProductId }; + } + ``` + +**ETags (Optimistic Concurrency)** + +ETags prevent lost-update conflicts in optimistic concurrency scenarios: + +```csharp +public interface IETag +{ + string? ETag { get; set; } +} + +public class Product : IETag +{ + public Guid Id { get; set; } + public string? ETag { get; set; } + public decimal Price { get; set; } +} + +// API usage: +// GET /api/products/123 returns Product with ETag: "abc123" +// PUT /api/products/123 with IF-MATCH: abc123 header +// If another request updated the product first, PUT returns 409 Conflict +``` + +**Change Logs (Audit Metadata)** + +Track when entities were created and last modified: + +```csharp +public interface IChangeLog +{ + ChangeLog? ChangeLog { get; set; } +} + +public class ChangeLog +{ + public DateTime? CreatedDate { get; set; } + public string? CreatedBy { get; set; } + public DateTime? UpdatedDate { get; set; } + public string? UpdatedBy { get; set; } +} + +// Automatically populated by repository on insert/update +public class Product : IChangeLog +{ + public Guid Id { get; set; } + public ChangeLog? ChangeLog { get; set; } +} +``` + +**Deep Compare** + +Compare two entities for equality considering all properties recursively: + +```csharp +var original = await _repo.GetByIdAsync(id); +// ... user modifies entity +var modified = new Product { Id = id, Name = "New Name", /* ... */ }; + +bool hasChanged = original.DeepEquals(modified); +if (!hasChanged) return NoContent(); // 204 +``` + +### Roslyn Source Generation + +**Pattern:** Auto-generate boilerplate code (e.g., serialization, mapping, contracts) at compile time using Roslyn analyzers. + +CoreEx includes a contract generator that creates DTOs, mapping, and validation code from domain models using source generation. This eliminates manual mapping code and keeps serialization fast. + +**Why it matters:** Reduces hand-written boilerplate, ensures domain model and contracts stay in sync, and improves startup performance via compile-time code generation. + +### Instrumentation & Health Checks + +**Pattern:** Built-in OpenTelemetry integration and standard health check endpoints. + +CoreEx middleware automatically emits traces, metrics, and logs. Health checks are exposed on `/health/live` and `/health/ready` endpoints: + +```csharp +app.MapHealthChecks("/health/live"); // Liveness (app running?) +app.MapHealthChecks("/health/ready"); // Readiness (ready to receive traffic?) +``` + +These endpoints integrate with Kubernetes and container orchestrators for probes and graceful shutdown. + +### Hybrid Caching (L1 + L2) + +**Pattern:** Distributed cache with local in-process backup for fault tolerance. + +CoreEx uses **FusionCache** with optional **Redis** backplane: +- **L1:** In-process memory cache (fast, shared scope, ~1MB typical). +- **L2:** Redis (slower, shared across all service instances). +- **Fallback:** If Redis is down, L1 cache continues serving stale data. + +```csharp +builder.Services.AddFusionCache() + .WithRedisBackplane("localhost:6379"); + +// Usage in services: +var product = await _cache.GetOrSetAsync( + key: $"product:{id}", + factory: async ct => await _repository.GetByIdAsync(id, ct), + duration: TimeSpan.FromHours(1), + cancellationToken: ct +); +``` + +**Why it matters:** Dramatically improves performance (milliseconds vs. seconds), reduces database load, and handles Redis failures gracefully. + +### Hosted Services (Timer & Synchronized) + +**Pattern:** Background work scheduled at intervals or synchronized across multiple instances. + +Use `IHostedService` implementation for: +- **Timer-based work** — Run a task every N seconds (e.g., cleanup, health checks). +- **Synchronized work** — Coordinate jobs across multiple instances using distributed locks. + +```csharp +public class InventoryAdjustmentService : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var timer = new PeriodicTimer(TimeSpan.FromMinutes(5)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await AdjustInventoryAsync(stoppingToken); + } + } +} + +builder.Services.AddHostedService(); +``` + +### Reference Data + +**Pattern:** Load, cache, and orchestrate reference datasets (enums, lookup tables) with transactional integrity. + +Reference data (like product categories, statuses, coupons) is typically read-heavy and must sync across services. CoreEx provides: + +```csharp +// Define reference data +public class Category : ReferenceData +{ + public int Code { get; set; } + public string? Description { get; set; } +} + +// Load and cache +var categories = await _refData.GetCollectionAsync(); + +// Automatic caching with orchestration +// All instances see the same data +// Invalidation on source updates +``` + +**Why it matters:** Eliminates N+1 query problems, ensures consistency, and simplifies dependency management in distributed systems. + +### JSON Filtering & Merge-Patch + +**Pattern:** Dynamically exclude fields from responses and support RFC 7386 PATCH. + +**Response Filtering** — Control which fields appear in JSON based on query parameters or roles: + +```csharp +// GET /api/products/123?$fields=id,name +// Returns only id and name, omitting price, cost, margin +``` + +**Merge-Patch** — RFC 7386 PATCH for partial updates: + +```csharp +// PATCH /api/products/123 +// Content-Type: application/merge-patch+json +// {"name": "New Name"} // other fields unchanged +``` + +Both use `System.Text.Json` without external dependencies. + +### Validation + +**Pattern:** Built-in validation rules as alternative to FluentValidation frameworks. + +CoreEx provides validation decorators and APIs without forcing a particular framework: + +```csharp +public class ProductValidator +{ + public static void Validate(Product p) + { + var errors = new List(); + if (string.IsNullOrEmpty(p.Sku)) + errors.Add("Sku is required."); + if (p.Price < 0) + errors.Add("Price must be non-negative."); + + if (errors.Any()) + throw new ValidationException(errors); + } +} +``` + +### Mapping Helpers + +**Pattern:** Explicit one-way or bi-directional mapping without external frameworks. + +CoreEx provides mapping utilities that make transformations explicit and traceable: + +```csharp +var productDto = mapper.Map(product); +// or bi-directional +var product = mapper.MapFrom(dto); +``` + +No AutoMapper dependency means simpler dependencies and explicit code paths. + +### Globalization & Localization + +**Pattern:** Culture-aware text and formatting throughout requests. + +`ExecutionContext` carries culture information per request: + +```csharp +var currentCulture = ExecutionContext.Current?.CultureInfo; // e.g., "en-US" +var formattedPrice = product.Price.ToString("C", currentCulture); +``` + +Enables multi-language APIs without routing changes. + +### Railway-Oriented Programming with Result + +**Pattern:** Composable error flow using `Result` instead of exceptions for expected errors. + +`Result` represents success (Ok) or failure (Error) without throwing: + +```csharp +public Result ValidateProduct(Product p) +{ + if (string.IsNullOrEmpty(p.Name)) + return Result.Error("Name is required."); + + return Result.Ok(p); +} + +// Usage - chain results without try/catch +var result = ValidateProduct(product) + .Then(p => _repository.SaveAsync(p)) + .Then(p => MapToDto(p)); + +if (!result.IsSuccessful) + throw new ValidationException(result.Error); + +return result.Value; +``` + +--- + +## API & HTTP Features + +### Web API Styles (Minimal & MVC) + +**Pattern:** Support both minimal APIs and MVC controllers with unified middleware. + +CoreEx works with both styles seamlessly: + +**Minimal API:** +```csharp +app.MapGet("/api/products/{id}", GetProduct) + .WithName("GetProduct") + .WithOpenApi(); + +async Task GetProduct(Guid id, IProductService service) + => await service.GetProductAsync(id); +``` + +**MVC Controller:** +```csharp +[ApiController] +[Route("api/[controller]")] +public class ProductsController : ControllerBase +{ + [HttpGet("{id}")] + public async Task> GetProduct(Guid id) + => await _service.GetProductAsync(id); +} +``` + +Both use the same exception handling, logging, and middleware. + +### RFC 7386 Merge-Patch (application/merge-patch+json) + +**Pattern:** Partial updates with semantic merge semantics. + +Instead of PUT (must send whole resource) or ad-hoc PATCH, use standard merge-patch: + +```csharp +// Partial update - unspecified fields unchanged +PATCH /api/products/123 +Content-Type: application/merge-patch+json + +{ + "name": "New Name" + // price, category, etc. remain unchanged +} +``` + +Safer and more predictable than custom PATCH semantics. + +### Response JSON Filtering + +**Pattern:** Dynamically exclude fields from responses. + +Reduces payload size and hides sensitive fields: + +```csharp +// GET /api/products?$fields=id,name,price +// Response omits cost, margin, internalNotes + +// GET /api/products?$fields=id,name +// Response omits all other fields +``` + +Implemented via middleware with zero manual code per endpoint. + +### Error Handling with ProblemDetails + +**Pattern:** RFC 9457 standard error responses everywhere. + +CoreEx exception middleware automatically converts exceptions to ProblemDetails: + +```json +{ + "type": "https://example.com/problems/not-found", + "title": "Not Found", + "status": 404, + "detail": "Product 'xyz' not found.", + "traceId": "0HN4..." +} +``` + +Consistent error format across all endpoints and all APIs. + +### Conditional Request Semantics (IF-MATCH) + +**Pattern:** Prevent lost updates and concurrent modifications via ETags. + +GET returns an ETag; PUT/PATCH require IF-MATCH header: + +```http +GET /api/products/123 +200 OK +ETag: "v2-abc123" + +{product json} + +--- + +PUT /api/products/123 +IF-MATCH: v2-abc123 +{updated fields} +200 OK + +--- + +// If stale: +PUT /api/products/123 +IF-MATCH: v1-old +{updated fields} +409 Conflict // Another request updated it +``` + +Prevents lost-update anomalies in concurrent scenarios. + +### Idempotency-Key + +**Pattern:** Automatic deduplication of POST operations. + +Clients send a unique `Idempotency-Key` header; CoreEx ensures the operation runs exactly once: + +```http +POST /api/baskets/123/checkout +Idempotency-Key: client-request-id-abc123 + +201 Created / 200 OK +``` + +If the same key is resubmitted, CoreEx returns the cached response without re-executing. + +**Why it matters:** Safe retries in unreliable networks; critical for payment systems, order placement, etc. + +### Health Check Endpoints + +**Pattern:** Expose `/health/live` and `/health/ready` for orchestration. + +Kubernetes, Docker Swarm, and load balancers probe these endpoints: + +``` +GET /health/live +200 OK (app is running) + +GET /health/ready +503 Service Unavailable (database down, not ready for traffic) +``` + +Typical ready checks include database connectivity, cache availability, and broker connectivity. + +### OpenAPI Integration (NSwag) + +**Pattern:** Auto-generate OpenAPI schemas for use in Swagger UI and clients. + +CoreEx integrates with NSwag to produce accurate OpenAPI 3.0+ schemas: + +```csharp +builder.Services.AddOpenApiDocument(opts => +{ + opts.Title = "Product API"; + opts.Version = "v1"; +}); + +app.UseOpenApi(); // Serves /swagger/v1/openapi.json +app.UseSwaggerUI(); // Serves Swagger UI +``` + +**Why it matters:** Automatically generated API docs that stay in sync; enables client code generation. + +### CQRS (Command Query Responsibility Segregation) + +**Pattern:** Separate read and write services when architectures demand it. + +Typical microservice uses a single domain model. For complex systems: + +- **Commands (Write)** — ProductMutationService handles create/update/delete. +- **Queries (Read)** — ProductQueryService handles all reads with separate caching. + +```csharp +// Write model +public class ProductMutationService +{ + public async Task CreateAsync(CreateProductRequest req) { ... } + public async Task UpdateAsync(Guid id, UpdateProductRequest req) { ... } +} + +// Read model +public class ProductQueryService +{ + public async Task GetAsync(Guid id) { ... } + public async Task> QueryAsync(FilterOptions opts) { ... } +} +``` + +Useful for event-sourced or high-scale systems; adds complexity otherwise. + +--- + +## Data Access & Persistence + +### Unit-of-Work with Integrated Outbox + +**Pattern:** Transactional boundary ensuring database writes and event publishing are atomic. + +The unit-of-work wraps all database operations and maintains an outbox table for events: + +```csharp +public async Task CreateProductAsync(CreateProductRequest req) +{ + using var uow = _unitOfWorkFactory.Create(); + + var product = new Product { Name = req.Name, Price = req.Price }; + await uow.Products.SaveAsync(product); + + // Event added to UoW, written to [Products].[Outbox] + uow.Events.Add(new ProductCreated { ProductId = product.Id }); + + // All database writes flushed atomically + await uow.CommitAsync(); + + // Separate relay process reads [Products].[Outbox] + // and publishes to Service Bus +} +``` + +**Why it matters:** If you crash after committing to the database, events are guaranteed to be published (via relay). Eliminates the dual-write problem. + +### Paging & Enumeration + +**Pattern:** Skip/take pagination with total count for OData-like APIs. + +Pagination is stateless and works with dynamic filtering: + +```csharp +public class PagingArgs +{ + public int Skip { get; set; } // 0-based offset + public int Take { get; set; } // page size, usually 10–100 +} + +public async Task<(IEnumerable, long TotalCount)> QueryAsync( + PagingArgs paging, + FilterOptions? filter = null) +{ + var products = await _repository.QueryAsync(paging, filter); + var totalCount = await _repository.CountAsync(filter); + return (products, totalCount); +} + +// HTTP usage: +// GET /api/products?$skip=0&$take=20 +// Returns 20 products + X-Total-Count: 1543 header +``` + +### Dynamic Query (OData-Style) + +**Pattern:** User-provided filtering and ordering without hardcoding every combination. + +CoreEx translates query parameters to SQL dynamically: + +``` +GET /api/products?$filter=price gt 100 and category eq 'Bikes'&$orderby=name&$fields=id,name,price +``` + +Supports: +- Comparison operators: `eq`, `ne`, `gt`, `ge`, `lt`, `le` +- Logical operators: `and`, `or` +- Functions: `contains`, `startswith`, `endswith` +- Ordering: `$orderby=field1,field2 desc` +- Projection: `$fields=id,name` (response filtering) + +### Multi-Tenancy + +**Pattern:** Isolate data per tenant transparently via `ExecutionContext`. + +Each request carries tenant identity in `ExecutionContext`: + +```csharp +var tenantId = ExecutionContext.Current?.TenantId; + +// Repositories automatically filter by tenant +var products = await _repository.QueryAsync(); // Only this tenant's products +``` + +Database rows include a `TenantId` column; queries are filtered in the WHERE clause automatically. + +### Type Discriminators + +**Pattern:** Model polymorphic or partitioned data sets using discriminator columns. + +When entities might be subtypes (e.g., `Product` might be `PhysicalProduct` or `DigitalProduct`): + +```csharp +public abstract class Product +{ + public Guid Id { get; set; } + public string Type { get; set; } // Discriminator +} + +public class PhysicalProduct : Product +{ + public decimal Weight { get; set; } + public string Dimensions { get; set; } +} + +public class DigitalProduct : Product +{ + public Uri DownloadUrl { get; set; } + public int MaxDownloads { get; set; } +} +``` + +Stored in one table with a `Type` column; ORM automatically hydrates correct subclass. + +--- + +## Database Support + +### SQL Server + +**Pattern:** Primary database target with full feature support. + +CoreEx ships with `CoreEx.Database.SqlServer` providing: +- Migrations via **DbEx** (custom migration runner). +- Data seeding from YAML files. +- Outbox relay with partitioning. +- Full TSQL support. + +In this repository, SQL Server is the **default initial implementation** and the most complete scaffolding target. That reflects current sample coverage and tooling depth, not a claim that CoreEx patterns are inherently SQL Server-only. + +### PostgreSQL + +**Pattern:** Secondary/evolving support. + +PostgreSQL support depends on the package (marked with `*` in documentation). Many CoreEx features work, but SQL Server is the first-class target. + +### ADO.NET Command & Parameter Extensions + +**Pattern:** Fluent ADO.NET helpers reduce boilerplate SQL composition. + +Instead of manual `SqlCommand` construction: + +```csharp +// Manual +var cmd = new SqlCommand("SELECT * FROM [Products] WHERE Id = @Id", connection); +cmd.Parameters.AddWithValue("@Id", id); + +// CoreEx extension +var cmd = connection.CreateCommand("SELECT * FROM [Products] WHERE Id = @Id") + .ParamWithValue("@Id", id); +``` + +Safer, more readable, less repetitive. + +### Entity Framework Integration + +**Pattern:** CoreEx works with EF Core repositories and unit-of-work patterns. + +`CoreEx.EntityFrameworkCore` provides: +- Base repository classes wrapping `DbSet`. +- Unit-of-work with EF's `SaveChangesAsync()`. +- Outbox integration. + +```csharp +public class ProductRepository : Repository +{ + public ProductRepository(DbContext context) : base(context) { } + + public async Task GetBySkuAsync(string sku) + => await _context.Products.SingleOrDefaultAsync(p => p.Sku == sku); +} +``` + +--- + +## Messaging & Events + +### EventData Abstraction + +**Pattern:** Format-agnostic event envelope that decouples event definition from transport. + +Events are serialized into `EventData` and can be published to any broker: + +```csharp +public class ProductCreatedEvent +{ + public Guid ProductId { get; set; } + public string Name { get; set; } + public DateTime CreatedAt { get; set; } +} + +// Wrapped in EventData +var eventData = new EventData +{ + Subject = "contoso.products.product", + Action = "created", + Version = "v1", + Data = JsonSerializer.SerializeToElement(new ProductCreatedEvent { ... }) +}; + +// Can publish to Service Bus, RabbitMQ, Kafka, etc. +await _eventPublisher.PublishAsync(eventData); +``` + +### CloudEvent Interoperability + +**Pattern:** Automatic conversion to CNCF CloudEvents format. + +`EventData` can be serialized/deserialized as CloudEvents for standards compliance: + +```json +{ + "specversion": "1.0", + "type": "com.example.products.created", + "source": "https://example.com/products", + "id": "abc123", + "time": "2024-01-15T12:34:56Z", + "data": { "productId": "xyz", "name": "Bike" } +} +``` + +Enables interop with other CloudEvents consumers (AWS EventBridge, etc.). + +### Publish + Subscribe Patterns + +**Pattern:** Per-message subscription with configurable consumption strategy. + +Subscribers join a topic/queue and consume messages from a specific position: + +- **From Beginning** — Consume all historical events. +- **From End** — Consume only new events from now on. +- **Latest Checkpoint** — Resume from where the subscriber last left off. + +```csharp +public class ProductModifySubscriber : SubscriberHost +{ + protected override async Task OnEventAsync( + EventData eventData, + ProductCreatedEvent data, + CancellationToken cancellationToken) + { + // React to product creation + // E.g., sync to Read Model, update search index + await _searchIndex.IndexAsync(data.ProductId, cancellationToken); + } +} +``` + +### Azure Service Bus Integration + +**Pattern:** Native Service Bus topic/subscription support with partitioning. + +CoreEx provides `IEventPublisher` and `ISubscriber` implementations for Service Bus: + +```csharp +builder.Services.AddServiceBusEventPublisher("Endpoint=..."); +builder.Services.AddServiceBusSubscriber("products"); +``` + +Handles: +- Topic/subscription creation. +- Automatic serialization. +- Partition affinity (partition key = session ID for ordered processing). + +In this repository, Azure Service Bus is the **default initial broker implementation** because the sample relay/subscriber hosts are wired around it. The surrounding event model is broader than that specific broker choice: `EventData` is transport-oriented rather than Service Bus-specific. + +### Outbox Relay (with Partitioning) + +**Pattern:** Dedicated host that reads from database outbox and publishes to broker. + +Each domain has its own relay process: + +1. Business logic writes events to `[Schema].[Outbox]` table within transaction. +2. Separate **Outbox.Relay** host polls the table every N seconds. +3. Relay fetches unpublished outbox rows and publishes to Service Bus. +4. On success, marks rows as published. +5. On failure, retries with exponential backoff. + +``` +┌─────────────────┐ +│ API Process │ +│ Writes events │ +│ to Outbox │ +└────────┬────────┘ + │ + [DB Outbox] + │ +┌────────▼────────┐ +│ Outbox.Relay │ +│ Polls every │ +│ 5 seconds │ +└────────┬────────┘ + │ + [Service Bus] + │ +┌────────▼───────────┐ +│ Subscribe Services │ +│ React to events │ +└────────────────────┘ +``` + +**Partitioning:** If an event has a `PartitionKey`, relay publishes to the same partition in Service Bus for ordered processing. + +**Why it matters:** Guarantees events are published even if relay crashes; decouples API availability from messaging; enables ordered processing of related events. + +### Workflow Orchestration (Durable Task SDK + DTS) + +**Pattern:** Durable workflow coordination for long-running, stateful, and business-critical process flows. + +Use orchestration when a process needs one or more of these characteristics: + +- Long-running steps that must survive restarts. +- Fan-out or fan-in aggregation across parallel work items. +- Batch processing with retries and controlled concurrency. +- Compensation paths when downstream operations fail. +- External-event waits, timers, and human-approval checkpoints. +- Full execution audit trail and replay semantics. + +CoreEx samples include orchestration hosted in standard .NET worker processes using the Durable Task SDK with a DTS backend, including local emulator support and containerized hosting alignment. + +See [Orchestration with the Durable Task SDK](orchestration.md) for detailed guidance and examples. + +--- + +## Domain-Driven Design + +### Aggregate & Entity Modeling + +**Pattern:** Implement aggregates as root objects that enforce invariants and encapsulate child entities. + +An **aggregate root** orchestrates its entities and ensures consistency: + +```csharp +public class Basket : IIdentifier, IETag, IChangeLog +{ + public Guid Id { get; set; } + public string? ETag { get; set; } + public ChangeLog? ChangeLog { get; set; } + + public Guid CustomerId { get; set; } + public string StatusCode { get; set; } = "Active"; + + // Child entities + private List _items = new(); + public IReadOnlyList Items => _items.AsReadOnly(); + + // Business rules + public void AddItem(Guid productId, int quantity) + { + if (StatusCode != "Active") + throw new BusinessException("Cannot add item to checked-out basket."); + + var existing = _items.FirstOrDefault(i => i.ProductId == productId); + if (existing != null) + existing.Quantity += quantity; + else + _items.Add(new BasketItem { ProductId = productId, Quantity = quantity }); + } + + public void Checkout() + { + if (!_items.Any()) + throw new ValidationException("Cannot checkout empty basket."); + + StatusCode = "CheckedOut"; + } +} + +public class BasketItem // Child entity, not an aggregate root +{ + public Guid ProductId { get; set; } + public int Quantity { get; set; } +} +``` + +Aggregate roots: +- Own their entities (users modify through the root). +- Enforce invariants (business rules). +- Publish integration events. +- Are the transactional boundary. + +### Value Objects + +**Pattern:** Implement as immutable C# record classes with semantic equality. + +Value objects have no identity, only their values matter: + +```csharp +public record ItemPricing( + string UnitOfMeasure, + int Quantity, + decimal UnitPrice) +{ + public decimal Total => Quantity * UnitPrice; +} + +// Usage +var pricing = new ItemPricing("ea", 5, 19.99m); + +// Equality is by value +var pricing2 = new ItemPricing("ea", 5, 19.99m); +Assert.AreEqual(pricing, pricing2); // True + +// Immutable +// pricing.Quantity = 10; // CS8852: Init-only property +``` + +Record classes automatically provide: +- Value-based equality (`Equals`, `GetHashCode`). +- `ToString()` for debugging. +- Deconstruction. + +### Integration Events Only + +**Pattern:** Focus on integration events (published to outbox/broker) rather than domain events (in-process messaging). + +**Integration Events** — Published to an external event broker; subscribers in other services react. +```csharp +public class ProductCreatedIntegrationEvent +{ + public Guid ProductId { get; set; } + public string Name { get; set; } + public DateTime CreatedAt { get; set; } +} + +// Published to Service Bus for other services to consume +await _unitOfWork.Events.Add(new EventData { ... }); +``` + +**Avoid:** Domain Events + MediatR for in-process messaging. +```csharp +// NOT recommended in CoreEx style +public class ProductCreatedDomainEvent { ... } +_mediator.Publish(new ProductCreatedDomainEvent(...)); // In-process +``` + +**Why:** Keep services decoupled and independent. If you need cross-domain orchestration, use integration events and let services react asynchronously. + +--- + +## Putting It All Together: A Typical Request Flow + +To illustrate how these patterns work together, here's a typical request flow based on the **Contoso sample architecture in this repository**, not a mandatory flow for every CoreEx application. + +This example assumes the samples' **full outboxing and messaging setup**: + +- an API host handling the request +- a database-backed unit-of-work writing to an outbox table +- a separate `Outbox.Relay` host publishing to Azure Service Bus +- another service consuming the resulting integration event + +``` +1. Client: POST /api/products + with Idempotency-Key header + +2. CoreEx Middleware: + - Extract ExecutionContext (tenant, user, culture) + - Check Idempotency-Key (cached response if duplicate) + - Route to controller + +3. ProductController: + - Validate input (ValidationException if invalid) + - Call ProductService + +4. ProductService: + - Create Product entity + - Apply domain rules (throw BusinessException if violated) + - Create UnitOfWork + - Save to repository (within transaction) + - Add integration event to UoW.Events + - Call UoW.CommitAsync() — atomically saves product + event to Outbox + +5. Repository: + - Execute INSERT on [Products].[Product] + - Add row to [Products].[Outbox] + - Assign ETag, ChangeLog + - Transaction commits + +6. Separate Outbox.Relay Process: + - Poll [Products].[Outbox] every N seconds + - Find unpublished events + - Publish to Service Bus + - Mark as published + +7. Other Services Subscribe: + - Shopping.Subscribe consumes ProductCreated event + - Syncs product replica to [Shopping].[Product] + +8. CoreEx Response Handler: + - Convert Product to ProductDto (response filtering) + - Apply $fields projection + - Return 201 Created + - Include ETag header and Location header + +9. Client: + - Receives 201 with ETag + - For future updates, uses IF-MATCH: {ETag} header +``` + +--- + +## Summary + +CoreEx provides a cohesive set of patterns and utilities that work together to enable: + +- **Consistent API behavior** across minimal APIs and MVC. +- **Reliable messaging** via transactional outboxes. +- **Durable workflow orchestration** for long-running, compensating, and replayable process flows. +- **Multi-tenancy** and **concurrency** handling built-in. +- **Event-driven architecture** with integration events. +- **Clear separation of concerns** (aggregates, value objects, services). +- **Type-safe operations** (exceptions, Result types, source generation). + +The framework is particularly well-suited for distributed microservices architectures where consistency, reliability, and maintainability are critical. diff --git a/docs/codebase/ARCHITECTURE.md b/docs/codebase/ARCHITECTURE.md new file mode 100644 index 00000000..a077deb7 --- /dev/null +++ b/docs/codebase/ARCHITECTURE.md @@ -0,0 +1,65 @@ +# Architecture + +## Core Sections (Required) + +### 1) Architectural Style + +- Primary style: modular layered architecture for the reusable framework, plus event-driven microservice samples. +- Why this classification: sample domains are split into Api, Application, Infrastructure, Domain, Database, Outbox.Relay, and Subscribe projects, and the runtime flow combines synchronous HTTP with outbox-driven Service Bus messaging. +- Primary constraints: multi-target library packaging across net8/net9/net10; SQL Server-backed unit-of-work and outbox flows in sample hosts; CoreEx-centric patterns such as Result-based orchestration, ETag/idempotency, and dynamic service registration. + +### 2) System Flow + +```text +HTTP controller -> application service -> domain aggregate or repository -> SQL Server / HTTP adapter -> outbox or direct publisher -> subscriber/consumer -> response +``` + +Describe the flow in 4-6 steps using file-backed evidence. + +1. A controller receives an HTTP request and delegates through CoreEx.WebApi helpers, for example ProductController.PostAsync and PatchAsync. +2. The application service validates input, loads current state when needed, and coordinates a unit-of-work, for example ProductService and BasketService. +3. For Shopping mutations, domain behavior is applied on Basket and BasketItem before persistence. +4. Infrastructure repositories translate between domain/contracts and EF-backed persistence models, for example BasketRepository and ProductRepository. +5. Cross-service behavior happens through a typed HTTP client and adapter for real-time reservation, plus outbox messages or direct Service Bus publishing for async commands. +6. Relay and subscriber hosts move outbox records to Azure Service Bus and consume messages back into application services. + +### 3) Layer/Module Responsibilities + +| Layer or module | Owns | Must not own | Evidence | +|-----------------|------|--------------|----------| +| API controllers | HTTP routes, request/response semantics, idempotency attributes, WebApi delegation | Domain rules and persistence queries | samples/src/Contoso.Products.Api/Controllers/ProductController.cs | +| Application services | Validation, orchestration, Result pipelines, unit-of-work/event creation | ASP.NET startup and EF entity tracking details | samples/src/Contoso.Products.Application/ProductService.cs; samples/src/Contoso.Shopping.Application/BasketService.cs | +| Domain | Aggregate/entity invariants and mutation rules | Transport and infrastructure concerns | samples/src/Contoso.Shopping.Domain/Basket.cs; samples/src/Contoso.Shopping.Domain/BasketItem.cs | +| Infrastructure | EF DbContext access, query config, mapping, typed clients, adapters | Public HTTP endpoint definitions | samples/src/Contoso.Products.Infrastructure/Repositories/ProductRepository.cs; samples/src/Contoso.Shopping.Infrastructure/Repositories/BasketRepository.cs; samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs | +| Relay/subscriber hosts | Background message movement and consumption | Business orchestration for user-facing HTTP requests | samples/src/Contoso.Products.Outbox.Relay/Program.cs; samples/src/Contoso.Products.Subscribe/Program.cs | + +### 4) Reused Patterns + +| Pattern | Where found | Why it exists | +|---------|-------------|---------------| +| Unit of Work + Outbox | ProductService, BasketService, Products/Shopping relay hosts | Persist state and enqueue events/commands atomically before broker delivery | +| Repository | ProductRepository, BasketRepository | Separate data access/mapping from application services | +| Aggregate / Entity / Value Object | Basket, BasketItem, ItemPricing | Keep mutation rules and consistency checks in the domain model | +| Anti-corruption / adapter | ProductAdapter, ProductsHttpClient | Isolate Shopping from Products API and message semantics | +| Dynamic service registration | AddDynamicServicesUsing in API and subscriber hosts | Reduce explicit DI wiring across layered sample projects | +| Roslyn source generation | gen/CoreEx.Generator, generated .g.cs persistence files | Generate boilerplate and analyzer-time artifacts | + +### 5) Known Architectural Risks + +- Sample host bootstrapping is repeated across Products, Shopping, and Orders hosts; the repeated AddExecutionContext/AddMvcWebApi/cache/SQL/OpenTelemetry wiring increases configuration-drift risk. +- Shopping checkout intentionally mixes a transactional outbox path with a direct broker fallback on failure; that keeps reservations from being stranded, but it also creates two publication paths that must stay behaviorally aligned. + +### 6) Evidence + +- samples/src/Contoso.Products.Api/Controllers/ProductController.cs +- samples/src/Contoso.Products.Api/Program.cs +- samples/src/Contoso.Shopping.Api/Program.cs +- samples/src/Contoso.Products.Application/ProductService.cs +- samples/src/Contoso.Shopping.Application/BasketService.cs +- samples/src/Contoso.Shopping.Domain/Basket.cs +- samples/src/Contoso.Products.Infrastructure/Repositories/ProductRepository.cs +- samples/src/Contoso.Shopping.Infrastructure/Repositories/BasketRepository.cs +- samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs +- samples/src/Contoso.Products.Outbox.Relay/Program.cs +- samples/src/Contoso.Products.Subscribe/Program.cs +- gen/CoreEx.Generator.csproj diff --git a/docs/codebase/CONCERNS.md b/docs/codebase/CONCERNS.md new file mode 100644 index 00000000..37bfa572 --- /dev/null +++ b/docs/codebase/CONCERNS.md @@ -0,0 +1,63 @@ +# Codebase Concerns + +## Core Sections (Required) + +### 1) Top Risks (Prioritized) + +| Severity | Concern | Evidence | Impact | Suggested action | +|----------|---------|----------|--------|------------------| +| high | Sample credentials and connection strings are committed in local/dev artifacts. | docker-compose.yml; samples/src/Contoso.Products.Database/Program.cs; samples/tests/Contoso.E2E.Runner/appsettings.json | Increases the chance that local-only credentials are reused or copied into non-local environments. | Move sample secrets to user-secrets or env-template files and keep checked-in values obviously non-reusable. | +| medium | Multi-backend intent exists, but the current concrete implementation and scaffolding are SQL Server-centric; this can be misread as either SQL-only or equally mature multi-provider support. | README.md; docs/capabilities.md; src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj; docker-compose.yml | Onboarding and design decisions may assume the wrong provider maturity level. | Document provider strategy explicitly as SQL Server-primary with other backends added when needed. | +| medium | Sample host startup is duplicated across multiple API, relay, and subscriber Program.cs files. | samples/src/Contoso.Products.Api/Program.cs; samples/src/Contoso.Shopping.Api/Program.cs; samples/src/Contoso.Orders.Api/Program.cs; samples/src/Contoso.Products.Outbox.Relay/Program.cs; samples/src/Contoso.Products.Subscribe/Program.cs | Repeated bootstrap code can drift across domains and host types. | Extract common host-registration extensions or add tests/assertions around expected startup composition. | +| medium | Shopping checkout uses both transactional outbox publication and a direct broker fallback path. | samples/src/Contoso.Shopping.Application/BasketService.cs; samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs | Two message publication paths must remain semantically aligned during failure handling. | Add focused tests and documentation around compensation/fallback behavior. | + +### 2) Technical Debt + +| Debt item | Why it exists | Where | Risk if ignored | Suggested fix | +|-----------|---------------|-------|-----------------|---------------| +| Repeated host wiring | Each sample host configures overlapping CoreEx, cache, SQL, OpenTelemetry, and health-check setup inline | samples/src/Contoso.Products.Api/Program.cs; samples/src/Contoso.Shopping.Api/Program.cs; samples/src/Contoso.Orders.Api/Program.cs; samples/src/Contoso.Products.Subscribe/Program.cs | Behavioral drift between services and hosts becomes harder to spot | Introduce shared extension methods for common host composition | +| Sample status visibility | README.md describes two complete reference solutions, while the solution also contains Orders and order-workflow sample projects that are still in progress | README.md; CoreEx.sln | New contributors may misclassify in-progress samples as production-ready references | Label in-progress sample status in top-level docs and sample READMEs | +| Secret handling in examples | Sample-local secrets are embedded directly in repo files | docker-compose.yml; samples/src/Contoso.Products.Database/Program.cs; samples/tests/Contoso.E2E.Runner/appsettings.json | Normalizes insecure copy/paste patterns | Replace checked-in secrets with placeholders and env-driven overrides | + +### 3) Security Concerns + +| Risk | OWASP category (if applicable) | Evidence | Current mitigation | Gap | +|------|--------------------------------|----------|--------------------|-----| +| Checked-in passwords/connection strings in sample assets | A02 Cryptographic Failures / Secrets Management | docker-compose.yml; samples/src/Contoso.Products.Database/Program.cs; samples/tests/Contoso.E2E.Runner/appsettings.json | These appear scoped to local/dev usage only | The repo does not provide a committed env template or explicit secret-handling guardrail for these values | +| Local Aspire dashboard allows anonymous access | A01 Broken Access Control | docker-compose.yml | This is clearly configured for local development only | No environment-specific guard in the committed compose file other than the setting name itself | +| Internal Products API client shows no explicit auth configuration in inspected code | A01 Broken Access Control | samples/src/Contoso.Shopping.Infrastructure/Clients/ProductsHttpClient.cs; samples/src/Contoso.Shopping.Api/Program.cs | [TODO] auth may be applied elsewhere, but it was not visible in inspected files | The inspected client/host files do not show authentication or authorization for the inter-service call | + +### 4) Performance and Scaling Concerns + +| Concern | Evidence | Current symptom | Scaling risk | Suggested improvement | +|---------|----------|-----------------|-------------|-----------------------| +| Repeated cache/telemetry/bootstrap configuration per host | samples/src/Contoso.Products.Api/Program.cs; samples/src/Contoso.Shopping.Api/Program.cs; samples/src/Contoso.Products.Subscribe/Program.cs | Configuration parity depends on copy/paste discipline | One host can lag behind others in cache or telemetry behavior | Centralize shared startup composition | +| Checkout performs a synchronous cross-service reservation call before finalizing the transaction | samples/src/Contoso.Shopping.Application/BasketService.cs; samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs | Checkout latency includes remote API availability and response time | Higher latency or transient failures in Products directly affect Shopping checkout | Add explicit resilience policy configuration and document timeout/retry expectations | +| No explicit timeout/retry settings were found in inspected HTTP client wiring | samples/src/Contoso.Shopping.Api/Program.cs; samples/src/Contoso.Shopping.Infrastructure/Clients/ProductsHttpClient.cs | Runtime behavior depends on defaults or hidden configuration | Failure recovery characteristics are hard to reason about | Add explicit resilience/timeout configuration in host setup | + +### 5) Fragile/High-Churn Areas + +| Area | Why fragile | Churn signal | Safe change strategy | +|------|-------------|-------------|----------------------| +| samples/src/*/Program.cs host bootstraps | Several hosts repeat nearly the same registration pattern with small variations | Structural duplication is visible in inspected Program.cs files; 90-day and 365-day git queries over src and samples/src produced a flat result with no clear hotspot above 1 touched commit per listed file | Change one host pattern, then compare every sibling host and run the corresponding sample tests | +| src/CoreEx.Validation/* | Validation files are the most visible source family in the one-year churn sample, but the signal is still flat at 1 touched commit per listed file | Terminal git log --since='365 days ago' sample returned multiple CoreEx.Validation files, each with count 1 | Keep changes small and run the related unit test projects immediately | + +### 6) [ASK USER] Questions + +1. No open [ASK USER] items remain for this pass. + +### 7) Evidence + +- README.md +- docs/capabilities.md +- CoreEx.sln +- docker-compose.yml +- src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj +- samples/src/Contoso.Products.Api/Program.cs +- samples/src/Contoso.Shopping.Api/Program.cs +- samples/src/Contoso.Orders.Api/Program.cs +- samples/src/Contoso.Products.Subscribe/Program.cs +- samples/src/Contoso.Shopping.Application/BasketService.cs +- samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs +- samples/src/Contoso.Products.Database/Program.cs +- samples/tests/Contoso.E2E.Runner/appsettings.json diff --git a/docs/codebase/CONVENTIONS.md b/docs/codebase/CONVENTIONS.md new file mode 100644 index 00000000..afd63392 --- /dev/null +++ b/docs/codebase/CONVENTIONS.md @@ -0,0 +1,51 @@ +# Coding Conventions + +## Core Sections (Required) + +### 1) Naming Rules + +| Item | Rule | Example | Evidence | +|------|------|---------|----------| +| Files | PascalCase filenames for C# types and test files | ProductService.cs, BasketRepository.cs, ExceptionTests.cs | samples/src/Contoso.Products.Application/ProductService.cs; samples/src/Contoso.Shopping.Infrastructure/Repositories/BasketRepository.cs; tests/CoreEx.Test.Unit/ExceptionTests.cs | +| Functions/methods | PascalCase methods; async methods usually end with Async | CreateAsync, CheckoutAsync, DeleteAsync | samples/src/Contoso.Products.Application/ProductService.cs; samples/src/Contoso.Shopping.Application/BasketService.cs | +| Types/interfaces | Types use PascalCase; interfaces use I-prefix | Basket, ProductController, IProductService, IBasketRepository | samples/src/Contoso.Shopping.Domain/Basket.cs; samples/src/Contoso.Products.Api/Controllers/ProductController.cs; samples/src/Contoso.Products.Application/Interfaces/IProductService.cs; samples/src/Contoso.Shopping.Application/Repositories/IBasketRepository.cs | +| Constants/env vars | Environment variables are uppercase or configuration-key style | TASKHUB, dts-endpoint, E2E__Products__BaseAddress | samples/src/Contoso.Order.Workflow.Worker/Program.cs; samples/README.md | + +### 2) Formatting and Linting + +- Formatter: .editorconfig defines spaces, 4-space indentation for .cs, and 2-space indentation for json/xml/yaml/props/csproj/sln/sql. +- Linter: [TODO] no dedicated style linter config such as StyleCop or Roslyn ruleset file was found in the inspected repo files; analyzer packages exist for the generator project, and build settings enforce warnings as errors. +- Most relevant enforced rules: Nullable enabled, ImplicitUsings enabled, LangVersion preview, TreatWarningsAsErrors true. +- Run commands: dotnet build CoreEx.sln; dotnet test CoreEx.sln. + +### 3) Import and Module Conventions + +- Import grouping/order: using directives sit at the top of the file and projects commonly centralize repeated imports in GlobalUsing.cs files. +- Alias vs relative import policy: standard project references and namespace imports are used; no alternate aliasing scheme was found beyond a test-only alias for ExecutionContext. +- Public exports/barrel policy: GlobalUsing.cs is used per project; [TODO] no broader documented export policy was found. + +### 4) Error and Logging Conventions + +- Error strategy by layer: application and domain code use CoreEx exceptions and Result/BusinessError/NotFoundError flows; API hosts apply UseCoreExExceptionHandler so exceptions map to HTTP responses. +- Logging style and required context fields: typed ILogger is used where explicit logging appears, and sample host appsettings set Logging:LogLevel with Default and category overrides. +- Sensitive-data redaction rules: [TODO] no explicit redaction policy or sanitizer configuration was found in the inspected files. + +### 5) Testing Conventions + +- Test file naming/location rule: tests live under tests/ and samples/tests/; filenames commonly end in Tests.cs or split partial suites such as ProductMutateTests.Create.cs. +- Mocking strategy norm: UnitTestEx tester base classes, expected outbox publisher wrappers, and MockHttpClientFactory for downstream HTTP isolation are used in samples. +- Coverage expectation: coverlet.collector is referenced; [TODO] no committed coverage threshold or reporting gate was found. + +### 6) Evidence + +- .editorconfig +- src/Directory.Build.props +- tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj +- tests/CoreEx.Test.Unit/ExceptionTests.cs +- samples/src/Contoso.Products.Api/GlobalUsing.cs +- samples/src/Contoso.Products.Api/Controllers/ProductController.cs +- samples/src/Contoso.Products.Application/ProductService.cs +- samples/src/Contoso.Shopping.Application/BasketService.cs +- samples/src/Contoso.Order.Workflow.Worker/Program.cs +- samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs +- samples/README.md diff --git a/docs/codebase/INTEGRATIONS.md b/docs/codebase/INTEGRATIONS.md new file mode 100644 index 00000000..6a12c5c0 --- /dev/null +++ b/docs/codebase/INTEGRATIONS.md @@ -0,0 +1,54 @@ +# External Integrations + +## Core Sections (Required) + +### 1) Integration Inventory + +| System | Type (API/DB/Queue/etc) | Purpose | Auth model | Criticality | Evidence | +|--------|---------------------------|---------|------------|-------------|----------| +| SQL Server | DB | Primary persistence for sample domains, outbox tables, and migration utilities | Connection string-based | High | docker-compose.yml; samples/src/Contoso.Products.Database/Program.cs; samples/src/Contoso.Products.Api/Program.cs | +| Redis | Cache/backplane | L2 distributed cache and FusionCache backplane | Connection configured through Aspire/registered ConfigurationOptions | Medium | docker-compose.yml; samples/src/Contoso.Products.Api/Program.cs; samples/src/Contoso.Shopping.Api/Program.cs | +| Azure Service Bus | Queue/topic broker | Async event publishing, relay, and subscriber processing | Connection configured through Aspire/host config; emulator config committed for local use | High | servicebus/Config.json; samples/src/Contoso.Products.Outbox.Relay/Program.cs; samples/src/Contoso.Products.Subscribe/Program.cs | +| Products API from Shopping | Internal HTTP API | Real-time inventory reservation during checkout | [TODO] no explicit auth configuration was found in the inspected Shopping client code | High | samples/src/Contoso.Shopping.Infrastructure/Clients/ProductsHttpClient.cs; samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs | +| OTLP / Aspire dashboard | Observability endpoint | Export traces from sample hosts and inspect them locally | No auth found in local compose config; dashboard is configured for anonymous local access | Medium | docker-compose.yml; samples/src/Contoso.Products.Api/Program.cs; samples/src/Contoso.Products.Outbox.Relay/Program.cs | +| Durable Task Scheduler | Workflow runtime | Order workflow worker orchestration sample | Connection string assembled from endpoint/task hub and auth mode | Medium | samples/src/Contoso.Order.Workflow.Worker/Program.cs | + +### 2) Data Stores + +| Store | Role | Access layer | Key risk | Evidence | +|-------|------|--------------|----------|----------| +| SQL Server | Transactional domain storage, outbox, and migration target | EF-backed repositories and DbEx console utilities | Sample connection strings and passwords are committed in local/dev artifacts | samples/src/Contoso.Shopping.Infrastructure/Contoso.Shopping.Infrastructure.csproj; samples/src/Contoso.Products.Database/Program.cs; samples/tests/Contoso.E2E.Runner/appsettings.json | +| Redis | Hybrid cache and backplane | FusionCache + AddRedisDistributedCache + CoreEx hybrid cache abstractions | Cache invalidation/consistency depends on host parity across repeated startup code | samples/src/Contoso.Products.Api/Program.cs; samples/src/Contoso.Shopping.Api/Program.cs | + +### 3) Secrets and Credentials Handling + +- Credential sources: docker-compose environment variables, appsettings.json for samples/E2E runner, UserSecretsId in the Aspire host, and runtime configuration/environment variables in the workflow worker. +- Hardcoding checks: committed sample credentials and connection strings are present in docker-compose.yml, samples/src/Contoso.Products.Database/Program.cs, and samples/tests/Contoso.E2E.Runner/appsettings.json. +- Rotation or lifecycle notes: [TODO] no secret-rotation guidance or secret-manager policy file was found. + +### 4) Reliability and Failure Behavior + +- Retry/backoff behavior: transactional outbox relays are implemented for Products and Shopping; [TODO] no explicit HTTP retry/backoff policy configuration was found in the inspected host files. +- Timeout policy: [TODO] no explicit timeout configuration was found in the inspected host or client files. +- Circuit-breaker or fallback behavior: Shopping checkout falls back to direct broker publication for reservation cancellation if the transactional path fails; Service Bus subscriber sessions set MaxConcurrentSessions and emulator MaxDeliveryCount is configured. + +### 5) Observability for Integrations + +- Logging around external calls: yes, host-level logging is configured via appsettings and the checkout failure path logs an error before sending a direct cancellation command. +- Metrics/tracing coverage: yes, sample APIs, relays, subscribers, and the workflow worker all add OpenTelemetry tracing and OTLP export. +- Missing visibility gaps: [TODO] no committed alerting, dashboard provisioning, or SLO configuration was found beyond the local Aspire dashboard container. + +### 6) Evidence + +- docker-compose.yml +- servicebus/Config.json +- samples/aspire/Contoso.Aspire/Contoso.Aspire.csproj +- samples/src/Contoso.Products.Api/Program.cs +- samples/src/Contoso.Shopping.Api/Program.cs +- samples/src/Contoso.Products.Outbox.Relay/Program.cs +- samples/src/Contoso.Products.Subscribe/Program.cs +- samples/src/Contoso.Shopping.Infrastructure/Clients/ProductsHttpClient.cs +- samples/src/Contoso.Shopping.Infrastructure/Adapters/ProductAdapter.cs +- samples/src/Contoso.Order.Workflow.Worker/Program.cs +- samples/src/Contoso.Products.Database/Program.cs +- samples/tests/Contoso.E2E.Runner/appsettings.json diff --git a/docs/codebase/STACK.md b/docs/codebase/STACK.md new file mode 100644 index 00000000..062f8c5e --- /dev/null +++ b/docs/codebase/STACK.md @@ -0,0 +1,73 @@ +# Technology Stack + +## Core Sections (Required) + +### 1) Runtime Summary + +| Area | Value | Evidence | +|------|-------|----------| +| Primary language | C# | src/Directory.Build.props; CoreEx.sln | +| Runtime + version | Reusable libraries target net8.0, net9.0, and net10.0; sample hosts use net10.0; generator targets netstandard2.0 | src/Directory.Build.props; samples/Directory.Build.props; samples/aspire/Contoso.Aspire/Contoso.Aspire.csproj; gen/CoreEx.Generator/CoreEx.Generator.csproj | +| Package manager | NuGet with Central Package Management | Directory.Packages.props | +| Module/build system | MSBuild project/solution build with SDK-style .csproj files | CoreEx.sln; src/CoreEx/CoreEx.csproj | + +### 2) Production Frameworks and Dependencies + +| Dependency | Version | Role in system | Evidence | +|------------|---------|----------------|----------| +| ASP.NET Core | 8.0.24 / 9.0.13 / 10.0.3 | Web API hosting, controllers, OpenAPI support | Directory.Packages.props; src/CoreEx.AspNetCore/CoreEx.AspNetCore.csproj | +| Entity Framework Core + SQL Server | 8.0.24 / 9.0.13 / 10.0.3 (EFCore); 8.0.22 / 9.0.11 / 10.0.0 (SqlServer) | Data access for sample infrastructure and CoreEx EF integration | Directory.Packages.props; samples/src/Contoso.Shopping.Infrastructure/Contoso.Shopping.Infrastructure.csproj | +| Microsoft.Data.SqlClient + Aspire SQL client | 6.1.3 / 13.1.1 | SQL Server connectivity | Directory.Packages.props; src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj | +| NSwag.AspNetCore | 14.6.2 | OpenAPI generation for sample APIs | Directory.Packages.props; samples/src/Contoso.Products.Api/Program.cs | +| OpenTelemetry | 1.15.1-1.15.3 | Tracing and OTLP export across APIs, relays, subscribers, and workflow worker | Directory.Packages.props; samples/src/Contoso.Products.Api/Program.cs; samples/src/Contoso.Products.Outbox.Relay/Program.cs; samples/src/Contoso.Order.Workflow.Worker/Program.cs | +| Azure Service Bus Aspire integration | 13.1.1 | Messaging publisher/subscriber wiring | Directory.Packages.props; src/CoreEx.Azure.Messaging.ServiceBus/CoreEx.Azure.Messaging.ServiceBus.csproj; samples/src/Contoso.Products.Subscribe/Program.cs | +| FusionCache + Redis backplane | 2.5.0 | Hybrid cache and idempotency/caching support | Directory.Packages.props; src/CoreEx.Caching.FusionCache/CoreEx.Caching.FusionCache.csproj; samples/src/Contoso.Shopping.Api/Program.cs | +| Microsoft.Extensions.Http.Resilience | 8.10.0 / 9.10.0 / 10.3.0 | Resilience policies for HTTP clients | Directory.Packages.props | +| Scalar.AspNetCore | 2.12.47 | API reference documentation and interactive API explorer | Directory.Packages.props | +| Spectre.Console | 0.49.1 | Rich terminal output for console tooling | Directory.Packages.props | +| System.Linq.Dynamic.Core | 1.7.1 | Dynamic LINQ query support | Directory.Packages.props | +| YamlDotNet | 16.3.0 | YAML parsing and serialization | Directory.Packages.props | +| CloudNative.CloudEvents.SystemTextJson | 2.8.0 | CloudEvent interoperability | Directory.Packages.props | +| Microsoft.DurableTask.* | 1.17.1 | Order workflow sample orchestration and worker runtime | Directory.Packages.props; samples/src/Contoso.Order.Workflow.Worker/Program.cs; samples/src/Contoso.Order.Workflow.Workflow/Contoso.Order.Workflow.Workflow.csproj | +| DbEx.SqlServer | 3.0.0-preview-3 | Database migration/data console utilities in samples/tests | Directory.Packages.props; samples/src/Contoso.Products.Database/Program.cs | + +### 3) Development Toolchain + +| Tool | Purpose | Evidence | +|------|---------|----------| +| NUnit | Test framework | Directory.Packages.props; tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj | +| AwesomeAssertions | Assertions | Directory.Packages.props; tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj | +| coverlet.collector | Test coverage collection | Directory.Packages.props; tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj | +| UnitTestEx / UnitTestEx.NUnit | API and integration-style test helpers | Directory.Packages.props; samples/tests/Contoso.Products.Test.Api/Contoso.Products.Test.Api.csproj | +| CoreEx.Generator | Roslyn analyzer/source generator packaged as an analyzer | gen/CoreEx.Generator/CoreEx.Generator.csproj; tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj | +| .editorconfig | Formatting baseline | .editorconfig | + +### 4) Key Commands + +```bash +dotnet build CoreEx.sln +dotnet test CoreEx.sln +dotnet run --project samples/aspire/Contoso.Aspire +docker compose up -d db-sql-server redis-cache servicebus-emulator aspire-dashboard dts-emulator +``` + +### 5) Environment and Config + +- Config sources: appsettings.json and appsettings.Development.json in sample hosts, docker-compose.yml, servicebus/Config.json, central MSBuild props, and a UserSecretsId in the Aspire host. +- Required env vars: dts-endpoint, TASKHUB, [TODO] additional deployment/runtime variables are not summarized in a committed env template. +- Deployment/runtime constraints: local sample execution expects SQL Server, Redis, Azure Service Bus emulator, and Aspire dashboard infrastructure. Current concrete implementation/scaffolding in inspected code is SQL Server-primary, with broader backend support discussed in docs as a capability direction. + +### 6) Evidence + +- Directory.Packages.props +- src/Directory.Build.props +- CoreEx.sln +- src/CoreEx/CoreEx.csproj +- src/CoreEx.Database.SqlServer/CoreEx.Database.SqlServer.csproj +- src/CoreEx.Azure.Messaging.ServiceBus/CoreEx.Azure.Messaging.ServiceBus.csproj +- src/CoreEx.Caching.FusionCache/CoreEx.Caching.FusionCache.csproj +- samples/aspire/Contoso.Aspire/Contoso.Aspire.csproj +- samples/src/Contoso.Products.Api/Program.cs +- samples/src/Contoso.Shopping.Api/Program.cs +- samples/src/Contoso.Order.Workflow.Worker/Program.cs +- docker-compose.yml diff --git a/docs/codebase/STRUCTURE.md b/docs/codebase/STRUCTURE.md new file mode 100644 index 00000000..e75f79d5 --- /dev/null +++ b/docs/codebase/STRUCTURE.md @@ -0,0 +1,56 @@ +# Codebase Structure + +## Core Sections (Required) + +### 1) Top-Level Map + +List only meaningful top-level directories and files. + +| Path | Purpose | Evidence | +|------|---------|----------| +| src/ | CoreEx reusable library packages | CoreEx.sln; README.md | +| tests/ | Unit and API-style tests for CoreEx libraries | CoreEx.sln; tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj | +| samples/src/ | Contoso sample applications and hosts by domain/layer, including in-progress Orders and Order.Workflow sample areas | CoreEx.sln; samples/README.md | +| samples/tests/ | Sample API, relay, subscriber, unit, and E2E test projects | CoreEx.sln; samples/README.md | +| samples/aspire/ | Aspire AppHost that references runnable sample services | samples/aspire/Contoso.Aspire/Contoso.Aspire.csproj | +| gen/ | Roslyn source generator/analyzer project | CoreEx.sln; gen/CoreEx.Generator/CoreEx.Generator.csproj | +| docs/ | Technical notes and generated codebase knowledge docs | README.md; docs/capabilities.md | +| ref/ | Separate reference solution content (NDCOrderOrchestration.sln) | list_dir output; ref/NDCOrderOrchestration.sln | +| servicebus/ | Emulator configuration for local Azure Service Bus topics/subscriptions | CoreEx.sln; servicebus/Config.json | +| tools/ | Repo utility area | list_dir output | + +### 2) Entry Points + +- Main runtime entry: there is no single root application entry; runnable entry points are sample host Program.cs files under samples/src/ plus the Aspire AppHost in samples/aspire/Contoso.Aspire. +- Secondary entry points (worker/cli/jobs): database console utilities in samples/src/*Database/Program.cs, outbox relays in samples/src/*.Outbox.Relay/Program.cs, subscriber hosts in samples/src/*.Subscribe/Program.cs, and the order workflow worker in samples/src/Contoso.Order.Workflow.Worker/Program.cs. +- How entry is selected (script/config): projects are selected explicitly via dotnet run --project ..., as shown in README.md and samples/README.md. + +### 3) Module Boundaries + +| Boundary | What belongs here | What must not be here | +|----------|-------------------|------------------------| +| src/CoreEx.* | Reusable framework primitives, ASP.NET integration, data, validation, events, caching, and unit-testing helpers | Sample-specific domain logic | +| samples/src/Contoso.*.Api | HTTP host bootstrap and controllers | Direct persistence details beyond injected services/repositories | +| samples/src/Contoso.*.Application | Use-case orchestration, validation, repository interfaces, adapters, and service contracts | ASP.NET host wiring and low-level EF mappings | +| samples/src/Contoso.Shopping.Domain | Aggregate/entity/value-object behavior | HTTP transport or EF DbContext concerns | +| samples/src/Contoso.*.Infrastructure | EF repositories, mappers, typed clients, adapters, outbox publishers | Web host startup | +| samples/tests/ and tests/ | Automated tests, test data, and test-only resources | Production host/runtime code | + +### 4) Naming and Organization Rules + +- File naming pattern: PascalCase .cs filenames such as ProductService.cs, BasketRepository.cs, ProductController.cs, and ExceptionTests.cs. +- Directory organization pattern: mostly layer-first under samples (Api, Application, Infrastructure, Domain, Database, Subscribe, Outbox.Relay) and package-first under src (CoreEx.*, CoreEx.AspNetCore.*, CoreEx.Database.*). +- Import aliasing or path conventions: standard C# project references and global using files are used; no TypeScript-style path alias system exists in the inspected files. + +### 5) Evidence + +- CoreEx.sln +- README.md +- samples/README.md +- samples/aspire/Contoso.Aspire/Contoso.Aspire.csproj +- samples/src/Contoso.Products.Api/Program.cs +- samples/src/Contoso.Products.Database/Program.cs +- samples/src/Contoso.Products.Outbox.Relay/Program.cs +- samples/src/Contoso.Products.Subscribe/Program.cs +- samples/src/Contoso.Shopping.Domain/Basket.cs +- gen/CoreEx.Generator/CoreEx.Generator.csproj diff --git a/docs/codebase/TESTING.md b/docs/codebase/TESTING.md new file mode 100644 index 00000000..6d97caea --- /dev/null +++ b/docs/codebase/TESTING.md @@ -0,0 +1,53 @@ +# Testing Patterns + +## Core Sections (Required) + +### 1) Test Stack and Commands + +- Primary test framework: NUnit 4.3.2. +- Assertion/mocking tools: AwesomeAssertions, UnitTestEx.NUnit, coverlet.collector, and sample-specific mock helpers such as MockHttpClientFactory described in samples/README.md. +- Commands: + +```bash +dotnet test CoreEx.sln +dotnet test tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj +dotnet test samples/tests/Contoso.Products.Test.Api/Contoso.Products.Test.Api.csproj +[TODO] no dedicated committed coverage command beyond standard dotnet test with coverlet.collector references was found. +``` + +### 2) Test Layout + +- Test file placement pattern: reusable library tests live under tests/; sample tests live under samples/tests/ with separate projects for unit, API, relay, subscriber, common test data, and E2E runner. +- Naming convention: project names end in .Test.Unit, .Test.Api, .Test.Subscribe, .Test.Outbox.Relay, or .Test.Common; files end in Tests.cs or split a suite into partial files like ProductMutateTests.Create.cs. +- Setup files and where they run: sample integration projects copy appsettings.unittest.json, embed Resources/**/*, and keep shared YAML test data in *.Test.Common projects; sample README describes OneTimeSetUp database migration, cache clearing, event capture, and HTTP client replacement. + +### 3) Test Scope Matrix + +| Scope | Covered? | Typical target | Notes | +|-------|----------|----------------|-------| +| Unit | yes | CoreEx library primitives and sample validators/domain behavior | tests/CoreEx.Test.Unit and Contoso.Products.Test.Unit are present | +| Integration | yes | Sample APIs, subscriber hosts, and relay hosts | Contoso.Products.Test.Api, Contoso.Shopping.Test.Api, Contoso.Products.Test.Subscribe, and Contoso.Products.Test.Outbox.Relay are present | +| E2E | yes | Cross-service sample scenarios against running APIs | Contoso.E2E.Runner is an interactive console runner | + +### 4) Mocking and Isolation Strategy + +- Main mocking approach: UnitTestEx tester base classes drive HTTP and generic tests; sample tests wrap outbox/service bus publishers and replace downstream HTTP clients with mocks. +- Isolation guarantees: sample integration setup migrates and reseeds SQL data, clears FusionCache/Redis state, and captures emitted events before assertions. +- Common failure mode in tests: [TODO] no committed flaky-test catalog or failure analysis file was found. + +### 5) Coverage and Quality Signals + +- Coverage tool + threshold: coverlet.collector is referenced; [TODO] no threshold was found. +- Current reported coverage: [TODO] no committed coverage report or badge was found. +- Known gaps/flaky areas: [TODO] none were explicitly documented in the inspected files. + +### 6) Evidence + +- Directory.Packages.props +- tests/CoreEx.Test.Unit/CoreEx.Test.Unit.csproj +- tests/CoreEx.Test.Unit/ExceptionTests.cs +- samples/tests/Contoso.Products.Test.Api/Contoso.Products.Test.Api.csproj +- samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs +- samples/tests/Contoso.E2E.Runner/Contoso.E2E.Runner.csproj +- samples/tests/Contoso.E2E.Runner/appsettings.json +- samples/README.md diff --git a/docs/orchestration.md b/docs/orchestration.md new file mode 100644 index 00000000..95179bd8 --- /dev/null +++ b/docs/orchestration.md @@ -0,0 +1,557 @@ +# Orchestration with the Durable Task SDK + +This document explains when and how to incorporate workflow orchestration into a CoreEx-based application landscape. It draws on the `Contoso.Order.Workflow.*` sample, which demonstrates an order validation and submission workflow backed by Durable Task Scheduler (DTS). + +## Table of Contents + +- [When to Use Orchestration](#when-to-use-orchestration) +- [Durable Task SDK with DTS vs Durable Functions](#durable-task-sdk-with-dts-vs-durable-functions) +- [Long-Running Workflows](#long-running-workflows) +- [Business-Critical Orchestration](#business-critical-orchestration) +- [Compensation and Retries](#compensation-and-retries) +- [Deterministic Execution](#deterministic-execution) +- [Fan-Out / Fan-In](#fan-out--fan-in) +- [Batch Processing](#batch-processing) +- [External Events and Human Approval](#external-events-and-human-approval) +- [Auditability and Replay](#auditability-and-replay) +- [DTS Dashboard for Observability and Management](#dts-dashboard-for-observability-and-management) +- [Project Layout](#project-layout) +- [Worker Host Setup](#worker-host-setup) +- [Client Registration](#client-registration) +- [Running Locally](#running-locally) + +--- + +## When to Use Orchestration + +Standard request/response services, backed by application services, repositories, and outbox-relay messaging, cover the majority of business operations. Workflow orchestration solves a different class of problems where those patterns alone are insufficient. + +| Scenario | Request/response + outbox sufficient? | Orchestration adds value? | +|---|---|---| +| CRUD operations with side-effect events | Yes | No | +| Simple pub/sub fan-out (fire-and-forget, no aggregated result) | Yes | No | +| Fan-out with aggregated result or partial-failure handling | No | Yes | +| Batch processing of a variable-size work list | No | Yes | +| Throttled parallel work with a concurrency cap | No | Yes | +| External-event wait (human approval, webhook callback) | No | Yes | +| Multi-step process spanning seconds to days | No | Yes | +| Process requiring compensation on failure | No | Yes | +| Steps that must run in strict order with branching | No | Yes | +| Audit trail of every execution step required | No | Yes | +| Step must retry independently from the whole workflow | No | Yes | + +Choose orchestration when at least one of those characteristics is central to the business process. + +--- + +## Durable Task SDK with DTS vs Durable Functions + +The Durable Task SDK and Durable Functions share the same core orchestration concepts: orchestrators, activities, deterministic replay, durable timers, and external events. Both can use Durable Task Scheduler (DTS) as the durable backend. The primary difference is runtime hosting preference rather than orchestration semantics. + +### What is different + +Durable Functions is a Functions-hosted programming model. It is designed around Azure Functions triggers, bindings, and the Functions runtime lifecycle. That model is productive when the application is already centered on Functions and event-triggered serverless hosting. + +The Durable Task SDK with DTS is a general-purpose .NET library and backend combination. Instead of writing against the Azure Functions host, you write orchestrators and activities and then host them inside any .NET process that can register a worker and a client. In this repository, the workflow is hosted in a normal ASP.NET Core process: + +```csharp +builder.Services.AddDurableTaskWorker() + .AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddActivity(); + }) + .UseDurableTaskScheduler(connectionString); +``` + +And any host can schedule or query workflow instances through a normal client registration: + +```csharp +services.AddDurableTaskClient(durableTaskBuilder => +{ + durableTaskBuilder.UseDurableTaskScheduler(connectionString); +}); +``` + +### Why that matters in practice + +| Concern | Durable Functions | Durable Task SDK with DTS | +|---|---|---| +| Primary hosting model | Azure Functions runtime | Any .NET host | +| Programming surface | Triggers and bindings | Explicit worker and client APIs | +| DTS backend support | Yes | Yes | +| Best fit | Serverless Functions applications | Existing APIs, workers, services, and containerized apps | +| Runtime dependency | Functions host | No Functions host required | +| Local backend story | Can run locally with emulator tooling | DTS emulator can be started directly as infrastructure | +| Container hosting | Possible, but still centered on the Functions runtime model | Natural fit for regular ASP.NET Core or worker containers | + +The difference is slight at the orchestration-code level, but important at the application-hosting level. If the goal is to use durable workflows inside an existing service landscape rather than build a Functions application, the Durable Task SDK with DTS is often the more direct fit. If the solution is already Functions-centric, Durable Functions with DTS can provide the same durable backend while preserving Functions triggers and bindings. + +### Local emulator benefit + +One of the practical advantages of DTS is that the backend can be run locally as infrastructure, without switching the application into a Functions-hosting model. This repository already includes the emulator in [docker-compose.yml](../../docker-compose.yml): + +```yaml +dts-emulator: + image: mcr.microsoft.com/dts/dts-emulator:latest + environment: + DTS_TASK_HUB_NAMES: "default,order" + ports: + - "8080:8080" + - "8082:8082" +``` + +That means developers can run the orchestration backend locally, start the workflow worker, and test orchestration behavior end-to-end without needing Azure-hosted infrastructure. In the sample worker, the connection logic explicitly detects the local emulator and switches authentication to `None`: + +```csharp +var isLocalEmulator = hostAddress.StartsWith("http://localhost:8080", StringComparison.OrdinalIgnoreCase); + +var connectionString = isLocalEmulator + ? $"Endpoint={hostAddress};TaskHub={taskHubName};Authentication=None" + : $"Endpoint={hostAddress};TaskHub={taskHubName};Authentication=DefaultAzure"; +``` + +This gives a clean local-development loop: + +1. Start the DTS emulator with container infrastructure. +2. Run the worker host locally. +3. Run an API, console app, or test that schedules orchestration instances. +4. Observe orchestration status and traces without changing the application architecture. + +### Container-hosting benefit + +Because the Durable Task SDK is hosted inside ordinary .NET applications, it fits naturally into containerized environments. A workflow worker can be packaged exactly like any other ASP.NET Core or background-service container, and the DTS backend can run either as the local emulator or as a managed service. + +That is useful when the broader application landscape already uses: + +- Containerized APIs and background workers. +- Kubernetes, Container Apps, or Docker Compose for local and deployed environments. +- Shared OpenTelemetry, health checks, and common ASP.NET Core hosting patterns. + +In this sample, the worker is just another host process with logging, OpenTelemetry, and health checks, not a special Functions runtime host. That reduces the amount of platform-specific infrastructure needed when orchestration is only one capability inside a larger service estate. + +### Guidance + +Prefer Durable Functions when the solution is intentionally Functions-centric and benefits from trigger-and-binding composition. + +Prefer the Durable Task SDK with DTS when: + +- The application is already an API, worker, or service-host landscape. +- You want orchestration without adopting the Azure Functions runtime. +- You want to run the backend locally through the DTS emulator. +- You want workflow workers to be packaged and deployed like ordinary containers. + +--- + +## Long-Running Workflows + +Durable orchestrations persist their state between steps. A workflow can be suspended while waiting for an external event, a timer, or a slow downstream system, and then resume without holding a thread or blocking an HTTP request. + +**Apply this when:** +- A business process spans minutes, hours, or days, for example order fulfilment, approval chains, or scheduled reminders. +- Steps involve human interaction, third-party callbacks, or polling. +- The initiating HTTP request cannot or should not block until the process finishes. + +**Pattern in the sample:** + +The `OrderWorkflowOrchestration` is initiated by a client call that returns an instance ID immediately. The caller can poll for status later using `GetMetadataAsync`: + +```csharp +var instanceId = await _orderWorkflowClient.StartAsync(request, cancellationToken: ct); +var metadata = await _orderWorkflowClient.GetMetadataAsync(instanceId); +``` + +The orchestration itself runs as a durable sequence of activity calls, each persisted between steps: + +```csharp +public override async Task RunAsync( + TaskOrchestrationContext context, OrderWorkflowRequest input) +{ + var validation = await context.CallActivityAsync( + nameof(ValidateOrderActivity), + new ValidateOrderActivityInput(input.OrderId, input.Amount, input.Currency)); + + if (!validation) + { + return new OrderWorkflowResult( + input.OrderId, + false, + "Order request failed validation.", + context.CurrentUtcDateTime); + } + + return await context.CallActivityAsync( + nameof(SubmitOrderActivity), + new SubmitOrderActivityInput(input.OrderId, input.Amount, input.Currency, input.RequestedBy)); +} +``` + +Each `CallActivityAsync` checkpoint is recorded. If the worker process restarts between steps, the orchestration replays only what is needed to reach the last durable checkpoint. + +--- + +## Business-Critical Orchestration + +Orchestration guarantees that every step is recorded and that the overall process will eventually reach a terminal state, even across process restarts or transient infrastructure failures. + +**Apply this when:** +- Partial execution of a process would leave the system in an inconsistent or unacceptable state. +- A process coordinates writes across multiple services or systems that do not share a transaction boundary. +- Regulatory or commercial requirements demand that every step and outcome is traceable. + +**Guidance:** +- Model each external call or side-effecting operation as a discrete `TaskActivity`. +- Keep orchestrator code free of direct I/O. +- Keep contracts serializable and explicit. +- Name activities and orchestrations clearly because those names become part of operations and diagnostics. + +**Sample activity pattern:** + +```csharp +[DurableTask] +public sealed class SubmitOrderActivity : TaskActivity +{ + public override Task RunAsync( + TaskActivityContext context, + SubmitOrderActivityInput input) + { + var message = $"Order '{input.OrderId}' accepted for {input.Amount:0.00} {input.Currency}."; + var result = new OrderWorkflowResult(input.OrderId, true, message, DateTimeOffset.UtcNow); + return Task.FromResult(result); + } +} +``` + +Activities receive typed input records and return typed result records. Keep those contracts as plain records so they serialize cleanly across durable boundaries. + +--- + +## Compensation and Retries + +When a step in a multi-step workflow fails, the process may need to undo work already performed by earlier steps. That is the classic compensation or saga pattern. + +**Apply this when:** +- Earlier steps have already committed side effects, for example inventory reservation or payment authorization. +- There is no distributed rollback mechanism. +- The compensating action itself must be durable and observable. + +**Pattern:** + +```csharp +public override async Task RunAsync( + TaskOrchestrationContext context, OrderWorkflowRequest input) +{ + var reservationId = await context.CallActivityAsync( + nameof(ReserveInventoryActivity), input); + + try + { + return await context.CallActivityAsync( + nameof(ChargePaymentActivity), input); + } + catch (TaskFailedException) + { + await context.CallActivityAsync( + nameof(ReleaseInventoryActivity), reservationId); + + return new OrderWorkflowResult( + input.OrderId, + false, + "Payment failed; reservation released.", + context.CurrentUtcDateTime); + } +} +``` + +**Retry policies:** + +Configure retries on the activity call rather than burying retry logic inside the activity: + +```csharp +var retryOptions = new TaskOptions(new RetryPolicy( + maxNumberOfAttempts: 3, + firstRetryInterval: TimeSpan.FromSeconds(5), + backoffCoefficient: 2.0)); + +await context.CallActivityAsync( + nameof(SubmitOrderActivity), + input, + retryOptions); +``` + +Retries replay only the failing step. Previously completed steps are not re-executed. + +--- + +## Deterministic Execution + +Orchestrators are replayed whenever the worker resumes. Every line of orchestrator code may run multiple times during replay. Non-deterministic logic inside the orchestrator will corrupt the execution history. + +**Rules:** +- Do not read `DateTime.UtcNow` or `DateTimeOffset.UtcNow` directly inside an orchestrator. Use `context.CurrentUtcDateTime`. +- Do not generate random values or GUIDs inside an orchestrator. +- Do not perform I/O inside an orchestrator. +- Do not use `Task.Delay`; use `context.CreateTimer`. +- Do not read environment variables or configuration directly inside the orchestrator. + +**Correct:** + +```csharp +var processedAt = context.CurrentUtcDateTime; +``` + +The sample follows that rule by keeping business work inside activities and using `context.CurrentUtcDateTime` for durable timestamps. + +--- + +## Fan-Out / Fan-In + +Fan-out/fan-in describes a pattern where one orchestrator dispatches many parallel work items, waits for them all to complete, and then aggregates the results. + +**Apply this when:** +- One business action must trigger work against a dynamic list of targets. +- The caller needs an aggregated result before proceeding. +- Individual branches may fail and need independent retries. +- The work list is not fixed at design time. + +**Pattern:** + +```csharp +[DurableTask] +public sealed class NotifyRecipientsOrchestration + : TaskOrchestrator +{ + public override async Task RunAsync( + TaskOrchestrationContext context, NotifyRecipientsRequest input) + { + var tasks = input.RecipientIds.Select(id => + context.CallActivityAsync( + nameof(SendNotificationActivity), + new SendNotificationInput(id, input.MessageTemplate))); + + var outcomes = await Task.WhenAll(tasks); + + var failed = outcomes.Where(x => !x.Delivered).Select(x => x.RecipientId).ToList(); + return new NotifyRecipientsResult(outcomes.Length, outcomes.Count(x => x.Delivered), failed); + } +} +``` + +`Task.WhenAll` is safe here because the inner tasks are durable activity calls, not raw background work. + +--- + +## Batch Processing + +Batch processing involves iterating over a variable-size list of work items and executing durable work for each item. Orchestration adds per-item checkpointing, independent retries, and controllable parallelism. + +**Apply this when:** +- A scheduled job, import file, or upstream event delivers a list of items. +- The batch must survive worker restarts. +- Some items may fail without invalidating the whole batch. +- Throughput must be capped to protect downstream systems. + +**Pattern:** + +```csharp +[DurableTask] +public sealed class OrderBatchOrchestration + : TaskOrchestrator +{ + private const int MaxConcurrency = 10; + + public override async Task RunAsync( + TaskOrchestrationContext context, OrderBatchRequest input) + { + var results = new List(); + var queue = new Queue(input.OrderIds); + + while (queue.Count > 0) + { + var window = Enumerable.Range(0, Math.Min(MaxConcurrency, queue.Count)) + .Select(_ => queue.Dequeue()) + .ToList(); + + var tasks = window.Select(orderId => + context.CallActivityAsync( + nameof(ProcessSingleOrderActivity), + new ProcessSingleOrderInput(orderId))); + + results.AddRange(await Task.WhenAll(tasks)); + } + + return new OrderBatchResult( + results.Count, + results.Count(x => x.Accepted), + results.Count(x => !x.Accepted)); + } +} +``` + +For very large batches, use sub-orchestrations to shard the work and keep orchestration histories compact. + +--- + +## External Events and Human Approval + +An orchestration can pause and wait for an event raised by an external system or a human actor, then resume with the event payload. + +**Apply this when:** +- A step requires human approval. +- A third-party system responds asynchronously. +- A timeout should trigger a compensating or fallback path. + +**Pattern:** + +```csharp +[DurableTask] +public sealed class OrderApprovalOrchestration + : TaskOrchestrator +{ + public override async Task RunAsync( + TaskOrchestrationContext context, OrderWorkflowRequest input) + { + await context.CallActivityAsync(nameof(NotifyApproverActivity), input); + + using var timeoutCts = new CancellationTokenSource(); + var approvalTask = context.WaitForExternalEvent("ApprovalDecision", timeoutCts.Token); + var timeoutTask = context.CreateTimer(context.CurrentUtcDateTime.AddHours(48), timeoutCts.Token); + + var winner = await Task.WhenAny(approvalTask, timeoutTask); + timeoutCts.Cancel(); + + if (winner == timeoutTask || !approvalTask.Result) + { + await context.CallActivityAsync(nameof(CancelOrderActivity), input.OrderId); + return new OrderWorkflowResult( + input.OrderId, + false, + "Approval not received within deadline.", + context.CurrentUtcDateTime); + } + + return await context.CallActivityAsync( + nameof(SubmitOrderActivity), + new SubmitOrderActivityInput(input.OrderId, input.Amount, input.Currency, input.RequestedBy)); + } +} +``` + +An external caller raises the event with `DurableTaskClient.RaiseEventAsync`. + +--- + +## Auditability and Replay + +The Durable Task runtime stores execution history for every orchestration instance: inputs, outputs, activity timing, and status transitions. That gives a built-in audit trail and a basis for replay-aware diagnostics. + +**Querying instance status:** + +```csharp +var metadata = await _orderWorkflowClient.GetMetadataAsync(instanceId, getInputsAndOutputs: true); +``` + +`OrchestrationMetadata` provides runtime status, timestamps, inputs, outputs, and failure details. + +Use a caller-supplied or business-key-derived instance ID when idempotent scheduling matters. That ties the durable history directly to the business entity and prevents duplicate scheduling. + +--- + +## DTS Dashboard for Observability and Management + +The DTS dashboard provides a unified operational view of orchestrations, activities, and entities. It is useful for both day-to-day observability and active management actions. + +### What it provides + +- Instance-level visibility: runtime status, duration, input and output payloads, and failure details. +- Execution flow insight: orchestration timelines including fan-out and fan-in activity branches. +- Operational controls: pause, terminate, and restart operations for orchestration instances. +- Query and filtering: locate instances by status, age, name, or identifier patterns. +- Troubleshooting support: correlate orchestration history with application logs and traces. + +### Why it matters locally + +The same dashboard experience is available when using the local emulator. That means developers can test workflows and inspect execution behavior on their workstation without deploying to Azure. + +In this repository, the emulator dashboard is exposed on `http://localhost:8082`. + +Typical local workflow: + +1. Start the emulator and run the worker host. +2. Schedule or trigger orchestration instances. +3. Open `http://localhost:8082` and select the task hub. +4. Inspect timelines, activity outcomes, and instance metadata to verify behavior. +5. Use management actions as needed during testing and debugging. + +### Relationship to OpenTelemetry + +The dashboard and OpenTelemetry traces are complementary: + +- DTS dashboard is orchestration-centric and state-history centric. +- OpenTelemetry is distributed-call centric across services and infrastructure. + +Using both gives complete coverage: orchestration state transitions plus end-to-end dependency traces. + +--- + +## Project Layout + +The sample separates concerns across three projects: + +| Project | Responsibility | +|---|---| +| `Contoso.Order.Workflow.Workflow` | Orchestrations, activities, and workflow contracts. | +| `Contoso.Order.Workflow.Worker` | Worker host that registers orchestration code with DTS. | +| `Contoso.Order.Workflow.Client` | Client library used by APIs or other callers to start and query workflows. | + +This keeps workflow logic portable and independent of the specific host process. + +--- + +## Worker Host Setup + +The worker host wires the DTS connection, registers orchestrators and activities, and configures telemetry: + +```csharp +builder.Services.AddDurableTaskWorker() + .AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddActivity(); + }) + .UseDurableTaskScheduler(connectionString); +``` + +The sample worker resolves endpoint and task hub from configuration and uses `Authentication=None` for the local emulator or `Authentication=DefaultAzure` for managed DTS. + +--- + +## Client Registration + +Any host that needs to schedule or query orchestrations can register the client: + +```csharp +builder.Services.AddContosoOrderWorkflowClient(builder.Configuration); +``` + +The client registration falls back from `ConnectionStrings:DurableTaskScheduler` to `DurableTaskScheduler:Endpoint` and `DurableTaskScheduler:TaskHub`. + +--- + +## Running Locally + +This repository includes the DTS emulator in [docker-compose.yml](../../docker-compose.yml). Start it with container infrastructure: + +```bash +docker compose up -d dts-emulator +``` + +Then run the worker host: + +```bash +dotnet run --project samples/src/Contoso.Order.Workflow.Worker +``` + +By default the sample worker connects to `http://localhost:8080`, task hub `order`, with `Authentication=None`. diff --git a/docs/presentations/coreex-agentic-scaffolding-slides.md b/docs/presentations/coreex-agentic-scaffolding-slides.md new file mode 100644 index 00000000..745335d6 --- /dev/null +++ b/docs/presentations/coreex-agentic-scaffolding-slides.md @@ -0,0 +1,437 @@ +# CoreEx Agentic Scaffolding +## Skills + Prompts + Context-Aware Generation + +Audience: Engineering leadership, platform team, solution architects, implementation teams. +Duration: 20-30 minutes. + +--- + +## Slide ES1 - Executive Summary +### Why This Matters +- CoreEx scaffolding gives deterministic, repeatable delivery foundations. +- Agentic prompting adds speed and flexibility for domain-specific requirements. +- Teams move faster with less architecture and boilerplate friction. + +Speaker notes: +This section is designed for executive stakeholders. It focuses on outcomes, risk reduction, and delivery acceleration. + +--- + +## Slide ES2 - Business Value +### Impact on Delivery +- Faster time from idea to a buildable, reviewable solution baseline. +- Consistent architecture and coding conventions across teams. +- Less rework by reducing early structural and wiring defects. +- Higher engineering focus on business features instead of plumbing. +- Move fast early: discovery, prototyping, and proving value with stakeholders. + +Speaker notes: +CoreEx reduces setup variability and enables teams to start from proven implementation patterns. It is especially effective when teams need rapid evidence of value in the first phase of a delivery. + +--- + +## Slide ES3 - Deterministic Foundation + Agentic Speed +### Balanced Delivery Model +- Deterministic: templates, instructions, and skills enforce known-good structure. +- Agentic: prompts tailor scaffolding to domains, features, and constraints. +- Result: predictable governance with rapid customization. +- Converge from experimentation to determinism for repeatability, compliance, and supportability. + +Speaker notes: +This is the key message: not deterministic versus agentic, but deterministic core plus agentic acceleration. Teams can begin with rapid exploration, then lock into repeatable patterns as delivery matures. + +--- + +## Slide ES4 - What Comes Out of the Box +### CoreEx Opinionated Acceleration +- Modern architecture patterns: microservices, eventing, and DDD-aligned domains. +- Enterprise capabilities ready to pull in as needed: validation, caching, outbox, observability. +- Integrated hosting and API conventions reduce framework integration overhead. + +Speaker notes: +CoreEx is not a generic framework toolkit that teams must assemble from scratch. It is an opinionated accelerator with practical defaults. + +--- + +## Slide ES5 - Platform Flexibility +### Enterprise Choice Without Chaos +- Supports multiple backend patterns and provider strategies. +- Messaging approach is not locked to one broker. +- Teams can select infrastructure choices while preserving a consistent architecture model. + +Speaker notes: +Executives get both standardization and optionality. + +--- + +## Slide ES6 - Decision and Rollout +### Recommended Path +- Adopt CoreEx agentic scaffolding as the default start for new services. +- Run a phased rollout with KPI tracking on speed, consistency, and rework reduction. +- Govern templates and instructions as platform assets. +- Ensure the delivered solution remains maintainable and operable over time, including after team transition. + +Speaker notes: +Treat scaffolding as a strategic product capability, not a one-off project artifact. This improves continuity when ownership transitions to long-term product teams. + +--- + +## Slide ES7 - Transition to Technical Deep Dive +### Section 2: Full Technical Detail +- Detailed architecture, capability inventory, platform options, and roadmap. +- Prompt, skill, and instruction workflow with enforcement guardrails. + +Speaker notes: +The next section provides implementation depth for architects and engineering leads. + +--- + +## Slide 1 - Title +### The New Agentic Way to Scaffold CoreEx Solutions +- Generate production-aligned CoreEx domain scaffolding faster. +- Encode architecture and coding standards as reusable prompt and skill assets. +- Keep generated output aligned to CoreEx patterns and sample implementations. + +Speaker notes: +This deck explains how we move from manual scaffolding to an agentic, policy-driven workflow that is repeatable, governed, and practical for teams starting new CoreEx implementations. + +--- + +## Slide 2 - Why Change +### Pain in the Previous Approach +- Manual setup was slow and inconsistent across teams. +- Architecture intent was scattered across tribal knowledge and sample code. +- Early project output often drifted from CoreEx conventions. +- Review cycles focused on fixing structure and wiring instead of business value. + +Speaker notes: +We are not replacing engineering judgment. We are automating boilerplate and codifying known-good patterns so teams can spend time on domain behavior. + +--- + +## Slide 3 - What Is New +### Agentic Scaffolding Stack +- Prompt workflows define user intent capture and step sequencing. +- Skills define capability packs with selection logic and generation strategy. +- Instruction files enforce file-scope coding conventions. +- Templates and scripts provide repeatable project materialization. +- Sample implementations provide concrete behavioral and architectural examples. +- CoreEx scaffolding introduces deterministic outputs, reducing non-deterministic variance from pure free-form agentic generation. + +Speaker notes: +The key idea is layered context. Prompts ask for what we need, skills decide what to generate, instructions govern how to write code, and templates provide deterministic structure. + +--- + +## Slide 4 - Core Building Blocks +### Assets in the Repository +- Bootstrap skill: coreex-project-bootstrap. +- Domain generation prompts: generate-domain, scaffold-domain-from-templates. +- Environment and startup prompts: init, setup. +- File-scoped instructions for controllers, services, repositories, validators, tests, and host setup. +- Starter docs for architecture, conventions, and domains. + +Speaker notes: +Everything needed is shipped as repository assets. Teams do not start from a blank prompt. + +--- + +## Slide 5 - Operating Model +### Human + Agent Collaboration +- Human provides bounded context and domain intent. +- Agent asks only minimal clarification questions for critical decisions. +- Agent scaffolds layered projects and hosts. +- Agent validates quality gates and resolves diagnostics. +- Human reviews domain correctness and business rules. +- Deterministic CoreEx scaffolding provides predictable baselines; agentic prompting provides fast customization on top. + +Speaker notes: +This is controlled autonomy. The agent handles deterministic work while still adapting to requested features and domains. Humans remain accountable for correctness, semantics, and trade-off decisions. + +--- + +## Slide 6 - End-to-End Flow +### From Prompt to Running Solution +1. Capture request: domains, hosts, persistence, behaviors. +2. Select skill and package set. +3. Materialize solution and project templates. +4. Generate layer artifacts: Contracts, Application, Infrastructure, API, Database. +5. Apply conventions from scoped instruction files. +6. Run diagnostics and fix generation errors. +7. Build and verify starter functionality. + +Speaker notes: +The flow is intentionally explicit so we can audit and improve each stage. + +--- + +## Slide 7 - Context Hierarchy +### How the Agent Stays Aligned to CoreEx +- Copilot instructions define global CoreEx-first behavior. +- Scoped instruction files enforce local patterns by file type. +- Skill logic maps requested capabilities to concrete package choices. +- Prompts capture required inputs and enforce completion checklists. +- Sample projects and docs provide reference implementations. + +Speaker notes: +This hierarchy reduces ambiguity. If a rule exists in multiple places, the narrower scope wins for generation behavior. + +--- + +## Slide 8 - Generated Architecture +### Standard Layered Output +- Contracts project for DTOs and source-generation annotations. +- Application project for services, validation, exceptions, and orchestration. +- Infrastructure project for repository and adapter implementations. +- API host with CoreEx middleware and OpenAPI conventions. +- Database project with migrations, outbox tables, and procedures. +- Optional subscribe and outbox relay hosts when integration requires them. +- Domain-first modeling aligned to DDD (aggregates, value objects, bounded contexts). +- Modern architecture support out of the box: eventing, microservice host separation, and integration patterns. + +Speaker notes: +The generated shape matches CoreEx reference architecture, keeps dependencies layered and predictable, and gives teams a ready-to-use microservices/event-driven baseline. + +--- + +## Slide 8A - Products and Shopping Sample Topology +### Architecture Shown in the Diagram +- Two bounded domains, each with its own API and Application layer. +- Each domain application encapsulates contracts and infrastructure concerns behind its service boundary. +- Unit-of-Work sits between application logic and persistence. +- Each domain has isolated Data and Outbox stores. +- Outbox Relay and Message Subscriber are deployed per domain. +- Domains communicate asynchronously through a shared queue or stream backbone. +- Cache is shown as a shared cross-domain optimization layer. + +Speaker notes: +This diagram demonstrates the CoreEx pattern in practice: independent domain services with local transaction boundaries and outbox-driven integration. Product and Shopping remain decoupled operationally, while events over queue or stream coordinate cross-domain workflows. + +--- + +## Slide 9 - Determinism vs Pure Agentic Variance +### Why CoreEx Scaffolding Matters +- Pure agent-only generation can vary between runs, prompts, and model behavior. +- CoreEx templates, instructions, and skills constrain generation into known-good patterns. +- Teams get deterministic project structure, wiring, and conventions from day one. +- Agentic prompting still adds flexibility for domain-specific features and behavior. + +Speaker notes: +This is not a choice between deterministic and agentic. CoreEx combines both: deterministic scaffolding for foundation, agentic adaptation for domain acceleration. + +--- + +## Slide 10 - Guardrails and Quality Gates +### Built-In Enforcement +- Constructor dependency null guards. +- ConfigureAwait(false) in async service and repository flows. +- Mutation operations wrapped in Unit of Work. +- Event emission inside mutation-aware blocks. +- CoreEx WebApi helper usage and response conventions. +- Validation before persistence using CoreEx validators. +- Diagnostics check before completion. + +Speaker notes: +Guardrails shift many common review comments from late discovery to immediate generation-time enforcement. + +--- + +## Slide 11 - Prompt Sequence for Teams +### Suggested Delivery Workflow +1. Run init prompt to verify machine prerequisites. +2. Run setup prompt to start dependencies and local runtime. +3. Run solution bootstrap skill for initial project layout. +4. Run domain generation prompt for new bounded contexts. +5. Run template-scaffold prompt for fast repeatable domain cloning. +6. Execute tests and E2E checks. + +Speaker notes: +Teams can adopt this as a standard onboarding and delivery playbook. + +--- + +## Slide 12 - Example Ask +### Example Prompt to Kick Off Scaffolding +Create a new CoreEx solution. +I need a Web API and Worker service. +Domains: Product and Shopping. +Include validation and behaviors. +Use SQL Server persistence. +Use Kafka as message broker. +Scaffold full repository structure with tests. + +Speaker notes: +This level of intent is enough for the agent to scaffold an entire repo, including domains, hosts, and tests, while honoring architecture constraints. + +--- + +## Slide 13 - Benefits Realized +### Expected Outcomes +- Faster time from idea to compilable baseline. +- Higher consistency with CoreEx coding and architecture conventions. +- Reduced architecture drift during early implementation. +- Lower cognitive load for new teams onboarding to CoreEx. +- Better review quality by focusing reviews on business semantics. +- Opinionated patterns out of the box reduce friction versus composing a generic framework from scratch. +- Teams pull in only needed capabilities (validation, events, caching, messaging) instead of hand-integrating foundations. +- Smoother transition from initial delivery teams to long-lived ownership with maintainable, standards-aligned code. + +Speaker notes: +CoreEx accelerates teams by providing a pre-wired, opinionated path that removes setup churn and allows focus on business capabilities. It also reduces operational risk by leaving behind predictable, supportable implementation assets. + +--- + +## Slide 14 - Platform Flexibility +### Not Locked to One Backend or Broker +- Data persistence can target multiple backend types depending on provider and project needs. +- Messaging integration is pluggable; Azure Service Bus is supported, but not mandatory. +- Agentic prompts can specify preferred database and broker choices per solution. +- Deterministic scaffolding still applies even when platform selections vary. + +Speaker notes: +CoreEx gives an opinionated architecture, not a hard platform lock. Teams can keep consistency while selecting infrastructure that fits enterprise constraints. + +--- + +## Slide 15 - CoreEx Capability Inventory (General) +### Included Components and Features +- Error-based exceptions: NotFound, Validation, Concurrency, and related exception types. +- Dynamic dependency injection patterns. +- Entities: Identifier and CompositeKey support. +- ETag support for optimistic concurrency. +- Change log support for created and updated metadata. +- Deep-compare capabilities for entity state comparison. +- Roslyn source-generation support. +- Instrumentation and health checks. +- Hybrid cache (L1 and L2) with FusionCache, backplane support, and Redis integration. +- Hosted services for timer-driven and synchronized workloads. +- Reference data orchestration, including caching support. +- System.Text.Json support for filtering and merge-patch workflows. +- Validation pipeline as an alternative to FluentValidation. +- Mapping helpers with explicit mapping patterns (no AutoMapper). +- Globalization and localization primitives. +- Result-based railway-oriented programming composition. + +Speaker notes: +This slide is the CoreEx baseline inventory. It communicates the practical accelerator: teams pull in tested primitives instead of recreating them per project. + +--- + +## Slide 16 - ASP.NET Core and Data Capabilities +### Included Web, Data, and Database Features +- Web API styles: Http-style minimal APIs and MVC controller APIs. +- application/merge-patch+json support. +- Response JSON filtering support. +- Error handling middleware aligned with ProblemDetails. +- IF-MATCH ETag semantics for GET and PUT or PATCH workflows. +- Idempotency-Key support for POST. +- Health check endpoints. +- OpenAPI generation via NSwag. +- CQRS read and write separation support. +- Unit-of-Work with integrated Outbox. +- Paging support using skip/take with total count. +- Dynamic OData-like query support for filtering and ordering. +- Multi-tenancy behavior support. +- Type discriminator support where required. +- Database support: SQL Server and PostgreSQL (provider dependent). +- ADO.NET command, record, and parameter extensions. +- Entity Framework integration support. + +Speaker notes: +This is where friction drops versus generic frameworks: CoreEx packages wire these conventions directly so teams avoid repeated plumbing decisions. + +--- + +## Slide 17 - Messaging and Domain-Driven Capabilities +### Included Eventing and DDD Features +- EventData as an agnostic message representation. +- CloudEvent conversion and interoperability support. +- Publish and subscribe patterns with per-message subscription behavior from stream. +- Azure Service Bus integration patterns. +- Outbox relay support with partition-aware patterns. +- Domain-driven modeling support for aggregates and entities. +- ValueObject modeling using C# record class patterns. +- Integration-events only guidance. +- Explicitly no domain-events and no MediatR-based in-process orchestration by default. + +Speaker notes: +CoreEx aligns eventing and domain modeling so distributed services remain explicit, testable, and consistent across teams. + +--- + +## Slide 18 - Current Upgrade Status and Roadmap +### What Is Next +To be upgraded: +- Azure Functions. +- Cosmos (CRUD and Query). +- Dataverse. +- OData. +- Solace messaging integration. + +Roadmap: +- MongoDB. +- DocumentDB (new). +- Kafka. + +Aspire enabled (done): +- Leverages component runtime libraries. +- Sample uses console for logging, tracing, and metrics visualization. + +Speaker notes: +This makes current maturity and future direction explicit for stakeholders. It also reinforces that platform portability is planned and active, not theoretical. + +--- + +## Slide 19 - Risks and Mitigations +### What to Watch +- Risk: over-trusting generated output. + Mitigation: enforce mandatory human review and test gates. +- Risk: stale templates or instruction drift. + Mitigation: version and periodically validate scaffolding assets against samples. +- Risk: ambiguous prompts produce wrong shape. + Mitigation: require minimal clarifying questions for orchestration, CQRS, and integration topology. + +Speaker notes: +Agentic does not remove governance. It improves it when paired with clear controls. + +--- + +## Slide 20 - Adoption Plan +### 30-60-90 Day Rollout +- 30 days: pilot with one domain and baseline metrics. +- 60 days: codify team prompt playbooks and update templates from findings. +- 90 days: make agentic scaffolding the default start path for new CoreEx implementations. + +Suggested KPIs: +- Time to first successful build. +- Number of post-generation architecture corrections. +- Defect rate in generated boilerplate. +- Onboarding time for new engineers. + +Speaker notes: +Measure both speed and quality. The objective is not just faster generation, but better initial correctness. + +--- + +## Slide 21 - Evidence Pointers +### Source Anchors Used for This Deck +- .github prompts for init, setup, and domain generation. +- coreex-starter bootstrap skill and script. +- coreex-starter architecture, conventions, and domain guidance docs. +- scoped instruction files for controllers, application services, repositories, tests, validators, and host setup. + +Speaker notes: +These slides are grounded in repository assets and can be updated as those assets evolve. + +--- + +## Appendix - Presenter Q and A +### Likely Questions +- How much can we customize generated domains? +- How do we prevent template sprawl? +- Can this flow support non-SQL providers? +- How do we handle major CoreEx version upgrades? +- What is the approval model for changing instructions and skills? + +Speaker notes: +Use these as discussion prompts for architecture review and platform governance forums. diff --git a/docs/presentations/coreex-agentic-scaffolding-slides.pptx b/docs/presentations/coreex-agentic-scaffolding-slides.pptx new file mode 100644 index 0000000000000000000000000000000000000000..c0b462ba12fd8ebe399e3177d24005ecff25337a GIT binary patch literal 2292709 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]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�UCn&S<`s1b5QX7R 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

  • 3f14L!ITqGOSReEwcL4hUY`9~ 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 z�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%0A9phe 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

    N8}gKoqhpU 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 z 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

    @)}^IjEoP&0T7G`rxplZvkKYez9wJ0 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

    I1@pE(d(~ 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%0AD(e@cL> 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

    =H^(bhac6%jMZPCMYIS^hBe;GQ42Ww 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⪼+y5GLBjF;D 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