From 9917db3e0fe1394590579375b3673e3360f3d1bc Mon Sep 17 00:00:00 2001 From: Maxwell Weru Date: Thu, 7 Mar 2024 15:33:11 +0300 Subject: [PATCH] Imported Tingle.Extensions.MongoDB --- .github/workflows/cleanup.yml | 1 + .vscode/settings.json | 1 + README.md | 2 +- Tingle.Extensions.sln | 47 ++- samples/MongoDBSample/MongoDBSample.csproj | 15 + samples/MongoDBSample/Program.cs | 42 +++ .../appsettings.Development.json | 8 + samples/MongoDBSample/appsettings.json | 8 + .../DatabaseSetup.cs | 42 +++ .../Diagnostics/MongoDbContextHealthCheck.cs | 31 ++ .../Diagnostics/MongoDbDiagnosticEvents.cs | 145 ++++++++ .../Extensions/BuildersExtensions.cs | 91 +++++ .../Extensions/CommandExtensions.cs | 47 +++ .../IHealthChecksBuilderExtensions.cs | 30 ++ .../Extensions/ILoggerExtensions.cs | 7 + .../Extensions/IMongoCollectionExtensions.cs | 106 ++++++ .../Extensions/IMongoQueryableExtensions.cs | 68 ++++ .../IServiceCollectionExtensions.cs | 184 ++++++++++ .../MongoDbContext.cs | 247 +++++++++++++ .../MongoDbContextOptions.cs | 289 +++++++++++++++ .../MongoJsonSerializerContext.cs | 7 + .../MongoUpdateConcurrencyException.cs | 97 ++++++ src/Tingle.Extensions.MongoDB/README.md | 64 ++++ .../DateTimeOffsetRepresentationConvention.cs | 81 +++++ .../EtagRepresentationConvention.cs | 81 +++++ .../SequenceNumberRepresentationConvention.cs | 80 +++++ ...ystemComponentModelAttributesConvention.cs | 54 +++ .../JsonSerializationProvider.cs | 47 +++ .../Serializers/DurationBsonSerializer.cs | 96 +++++ .../Serializers/EtagBsonSerializer.cs | 110 ++++++ .../Serializers/IPNetworkBsonSerializer.cs | 97 ++++++ .../Serializers/JsonElementBsonSerializer.cs | 72 ++++ .../Serializers/JsonNodeBsonSerializer.cs | 62 ++++ .../Serializers/JsonObjectBsonSerializer.cs | 43 +++ .../SequenceNumberBsonSerializer.cs | 103 ++++++ .../TingleSerializationProvider.cs | 52 +++ .../Tingle.Extensions.MongoDB.csproj | 21 ++ .../MongoDbContextTests.cs | 51 +++ .../MongoDbDiagnosticEventsTests.cs | 329 ++++++++++++++++++ ...TimeOffsetRepresentationConventionTests.cs | 89 +++++ .../EtagRepresentationConventionTests.cs | 90 +++++ ...enceNumberRepresentationConventionTests.cs | 87 +++++ ...ComponentModelAttributesConventionTests.cs | 60 ++++ .../DurationBsonSerializerTests.cs | 28 ++ .../Serializers/EtagBsonSerializerTests.cs | 92 +++++ .../IPNetworkBsonSerializerTests.cs | 66 ++++ .../JsonElementBsonSerializerTests.cs | 69 ++++ .../JsonNodeBsonSerializerTests.cs | 69 ++++ .../JsonObjectBsonSerializerTests.cs | 50 +++ .../SequenceNumberBsonSerializerTests.cs | 79 +++++ .../Tingle.Extensions.MongoDB.Tests.csproj | 11 + 51 files changed, 3734 insertions(+), 14 deletions(-) create mode 100644 samples/MongoDBSample/MongoDBSample.csproj create mode 100644 samples/MongoDBSample/Program.cs create mode 100644 samples/MongoDBSample/appsettings.Development.json create mode 100644 samples/MongoDBSample/appsettings.json create mode 100644 src/Tingle.Extensions.MongoDB/DatabaseSetup.cs create mode 100644 src/Tingle.Extensions.MongoDB/Diagnostics/MongoDbContextHealthCheck.cs create mode 100644 src/Tingle.Extensions.MongoDB/Diagnostics/MongoDbDiagnosticEvents.cs create mode 100644 src/Tingle.Extensions.MongoDB/Extensions/BuildersExtensions.cs create mode 100644 src/Tingle.Extensions.MongoDB/Extensions/CommandExtensions.cs create mode 100644 src/Tingle.Extensions.MongoDB/Extensions/IHealthChecksBuilderExtensions.cs create mode 100644 src/Tingle.Extensions.MongoDB/Extensions/ILoggerExtensions.cs create mode 100644 src/Tingle.Extensions.MongoDB/Extensions/IMongoCollectionExtensions.cs create mode 100644 src/Tingle.Extensions.MongoDB/Extensions/IMongoQueryableExtensions.cs create mode 100644 src/Tingle.Extensions.MongoDB/Extensions/IServiceCollectionExtensions.cs create mode 100644 src/Tingle.Extensions.MongoDB/MongoDbContext.cs create mode 100644 src/Tingle.Extensions.MongoDB/MongoDbContextOptions.cs create mode 100644 src/Tingle.Extensions.MongoDB/MongoJsonSerializerContext.cs create mode 100644 src/Tingle.Extensions.MongoDB/MongoUpdateConcurrencyException.cs create mode 100644 src/Tingle.Extensions.MongoDB/README.md create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Conventions/DateTimeOffsetRepresentationConvention.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Conventions/EtagRepresentationConvention.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Conventions/SequenceNumberRepresentationConvention.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Conventions/SystemComponentModelAttributesConvention.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/JsonSerializationProvider.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Serializers/DurationBsonSerializer.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Serializers/EtagBsonSerializer.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Serializers/IPNetworkBsonSerializer.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonElementBsonSerializer.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonNodeBsonSerializer.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonObjectBsonSerializer.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/Serializers/SequenceNumberBsonSerializer.cs create mode 100644 src/Tingle.Extensions.MongoDB/Serialization/TingleSerializationProvider.cs create mode 100644 src/Tingle.Extensions.MongoDB/Tingle.Extensions.MongoDB.csproj create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/MongoDbContextTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/MongoDbDiagnosticEventsTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/DateTimeOffsetRepresentationConventionTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/EtagRepresentationConventionTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/SequenceNumberRepresentationConventionTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/SystemComponentModelAttributesConventionTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/DurationBsonSerializerTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/EtagBsonSerializerTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/IPNetworkBsonSerializerTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonElementBsonSerializerTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonNodeBsonSerializerTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonObjectBsonSerializerTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/SequenceNumberBsonSerializerTests.cs create mode 100644 tests/Tingle.Extensions.MongoDB.Tests/Tingle.Extensions.MongoDB.Tests.csproj diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 451d07f7..15b5cfbf 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -22,6 +22,7 @@ jobs: - { name: 'Tingle.Extensions.Http' } - { name: 'Tingle.Extensions.Http.Authentication' } - { name: 'Tingle.Extensions.JsonPatch' } + - { name: 'Tingle.Extensions.MongoDB' } - { name: 'Tingle.Extensions.PhoneValidators' } - { name: 'Tingle.Extensions.Primitives' } - { name: 'Tingle.Extensions.Processing' } diff --git a/.vscode/settings.json b/.vscode/settings.json index a79d9920..9d04d564 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "Bson", "etag", "Ksuid", "libphonenumber", diff --git a/README.md b/README.md index 4119eb47..081afe51 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This repository contains projects/libraries for adding useful functionality to d |[`Tingle.AspNetCore.DataProtection.MongoDB`](https://www.nuget.org/packages/Tingle.AspNetCore.DataProtection.MongoDB/)|Data Protection store in [MongoDB](https://mongodb.com) for ASP.NET Core. See [docs](./src/Tingle.AspNetCore.DataProtection.MongoDB/README.md) and [sample](./samples/DataProtectionMongoDBSample).| |[`Tingle.AspNetCore.JsonPatch.NewtonsoftJson`](https://www.nuget.org/packages/Tingle.AspNetCore.JsonPatch.NewtonsoftJson/)|Helpers for validation when working with JsonPatch in ASP.NET Core. See [docs](./src/Tingle.AspNetCore.JsonPatch.NewtonsoftJson/README.md) and [blog](https://maxwellweru.com/blog/2020-11-17-immutable-properties-with-json-patch-in-aspnet-core).| |[`Tingle.AspNetCore.Tokens`](https://www.nuget.org/packages/Tingle.AspNetCore.Tokens/)|Support for generation of continuation tokens in ASP.NET Core with optional expiry. Useful for pagination, user invite tokens, expiring operation tokens, etc. This is availed through the `ContinuationToken` and `TimedContinuationToken` types. See [docs](./src/Tingle.AspNetCore.Tokens/README.md) and [sample](./samples/TokensSample).| -|[`Tingle.Extensions.Caching.MongoDB`](https://www.nuget.org/packages/Tingle.Extensions.Caching.MongoDB/)|Distributed caching implemented with [MongoDB](https://mongodb.com) on top of `IDistributedCache`, inspired by [CosmosCache](https://github.com/Azure/Microsoft.Extensions.Caching.Cosmos). See [docs](./src/Tingle.Extensions.Caching.MongoDB/README.md)and [sample](./samples/AspNetCoreSessionState)| +|[`Tingle.Extensions.Caching.MongoDB`](https://www.nuget.org/packages/Tingle.Extensions.Caching.MongoDB/)|Distributed caching implemented with [MongoDB](https://mongodb.com) on top of `IDistributedCache`, inspired by [CosmosCache](https://github.com/Azure/Microsoft.Extensions.Caching.Cosmos). See [docs](./src/Tingle.Extensions.Caching.MongoDB/README.md) and [sample](./samples/AspNetCoreSessionState)| |[`Tingle.Extensions.DataAnnotations`](https://www.nuget.org/packages/Tingle.Extensions.DataAnnotations/)|Additional data validation attributes in the `System.ComponentModel.DataAnnotations` namespace. Some of this should have been present in the framework but are very specific to some use cases. For example `FiveStarRatingAttribute`. See [docs](./src/Tingle.Extensions.DataAnnotations/README.md).| |[`Tingle.Extensions.Http`](https://www.nuget.org/packages/Tingle.Extensions.Http/)|Lightweight abstraction around `HttpClient` which can be used to build custom client with response wrapping semantics. See [docs](./src/Tingle.Extensions.Http/README.md).| |[`Tingle.Extensions.Http.Authentication`](https://www.nuget.org/packages/Tingle.Extensions.Http.Authentication/)|Authentication providers for use with HttpClient and includes support for DI via `Microsoft.Extensions.Http`. See [docs](./src/Tingle.Extensions.Http.Authentication/README.md) and [sample](./samples/HttpAuthenticationSample).| diff --git a/Tingle.Extensions.sln b/Tingle.Extensions.sln index 55dacf01..9a83542f 100644 --- a/Tingle.Extensions.sln +++ b/Tingle.Extensions.sln @@ -28,9 +28,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http.Auth EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.JsonPatch", "src\Tingle.Extensions.JsonPatch\Tingle.Extensions.JsonPatch.csproj", "{913C0212-58AC-42B7-B555-F96B8E287E7F}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.MongoDB", "src\Tingle.Extensions.MongoDB\Tingle.Extensions.MongoDB.csproj", "{17F58DAD-0789-4AD7-921E-24CEA09DC68B}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PhoneValidators", "src\Tingle.Extensions.PhoneValidators\Tingle.Extensions.PhoneValidators.csproj", "{F46ADD04-B716-4E9B-9799-7C47DDDB08FC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tingle.Extensions.Primitives", "src\Tingle.Extensions.Primitives\Tingle.Extensions.Primitives.csproj", "{3012AF4E-270B-4293-9C7B-51003F00A8D6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Primitives", "src\Tingle.Extensions.Primitives\Tingle.Extensions.Primitives.csproj", "{3012AF4E-270B-4293-9C7B-51003F00A8D6}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Processing", "src\Tingle.Extensions.Processing\Tingle.Extensions.Processing.csproj", "{A803DE4B-B050-48F2-82A1-8E947D8FB96C}" EndProject @@ -63,9 +65,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http.Auth EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.JsonPatch.Tests", "tests\Tingle.Extensions.JsonPatch.Tests\Tingle.Extensions.JsonPatch.Tests.csproj", "{B82E2980-E145-4341-BAE0-8FAE1F110D0C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.MongoDB.Tests", "tests\Tingle.Extensions.MongoDB.Tests\Tingle.Extensions.MongoDB.Tests.csproj", "{D8BFE67A-C3A7-4523-8119-D9FCD5EF3E7F}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PhoneValidators.Tests", "tests\Tingle.Extensions.PhoneValidators.Tests\Tingle.Extensions.PhoneValidators.Tests.csproj", "{526B37AD-256A-445A-9A42-E5C53989B11E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tingle.Extensions.Primitives.Tests", "tests\Tingle.Extensions.Primitives.Tests\Tingle.Extensions.Primitives.Tests.csproj", "{33E2AC82-DC46-42CA-A41B-0D8D97019402}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Primitives.Tests", "tests\Tingle.Extensions.Primitives.Tests\Tingle.Extensions.Primitives.Tests.csproj", "{33E2AC82-DC46-42CA-A41B-0D8D97019402}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Processing.Tests", "tests\Tingle.Extensions.Processing.Tests\Tingle.Extensions.Processing.Tests.csproj", "{978023EA-2ED5-4A28-96AD-4BB914EF2BE5}" EndProject @@ -88,13 +92,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataProtectionMongoDBSample EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpAuthenticationSample", "samples\HttpAuthenticationSample\HttpAuthenticationSample.csproj", "{37ED98F0-3129-49B8-BF60-3BDEBBB34822}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MongoDBSample", "samples\MongoDBSample\MongoDBSample.csproj", "{D68821D8-95A3-4DAB-9E90-676D9BE30AFB}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SerilogSample", "samples\SerilogSample\SerilogSample.csproj", "{CFEE0754-9DAF-4AA6-98F3-477F065D7DC4}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TokensSample", "samples\TokensSample\TokensSample.csproj", "{AC04C113-8F75-43BA-8FEE-987475A87C58}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "generator", "generator", "{9071B0C9-DE4D-411D-A9D3-CB7326CBBD80}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StaticDataGenerator", "generator\StaticDataGenerator.csproj", "{009C5985-9DD4-45A8-A31E-4E6B7FE5EE78}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StaticDataGenerator", "generator\StaticDataGenerator.csproj", "{009C5985-9DD4-45A8-A31E-4E6B7FE5EE78}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -142,10 +148,18 @@ Global {913C0212-58AC-42B7-B555-F96B8E287E7F}.Debug|Any CPU.Build.0 = Debug|Any CPU {913C0212-58AC-42B7-B555-F96B8E287E7F}.Release|Any CPU.ActiveCfg = Release|Any CPU {913C0212-58AC-42B7-B555-F96B8E287E7F}.Release|Any CPU.Build.0 = Release|Any CPU + {17F58DAD-0789-4AD7-921E-24CEA09DC68B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17F58DAD-0789-4AD7-921E-24CEA09DC68B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17F58DAD-0789-4AD7-921E-24CEA09DC68B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17F58DAD-0789-4AD7-921E-24CEA09DC68B}.Release|Any CPU.Build.0 = Release|Any CPU {F46ADD04-B716-4E9B-9799-7C47DDDB08FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F46ADD04-B716-4E9B-9799-7C47DDDB08FC}.Debug|Any CPU.Build.0 = Debug|Any CPU {F46ADD04-B716-4E9B-9799-7C47DDDB08FC}.Release|Any CPU.ActiveCfg = Release|Any CPU {F46ADD04-B716-4E9B-9799-7C47DDDB08FC}.Release|Any CPU.Build.0 = Release|Any CPU + {3012AF4E-270B-4293-9C7B-51003F00A8D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3012AF4E-270B-4293-9C7B-51003F00A8D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3012AF4E-270B-4293-9C7B-51003F00A8D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3012AF4E-270B-4293-9C7B-51003F00A8D6}.Release|Any CPU.Build.0 = Release|Any CPU {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Debug|Any CPU.Build.0 = Debug|Any CPU {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -198,10 +212,18 @@ Global {B82E2980-E145-4341-BAE0-8FAE1F110D0C}.Debug|Any CPU.Build.0 = Debug|Any CPU {B82E2980-E145-4341-BAE0-8FAE1F110D0C}.Release|Any CPU.ActiveCfg = Release|Any CPU {B82E2980-E145-4341-BAE0-8FAE1F110D0C}.Release|Any CPU.Build.0 = Release|Any CPU + {D8BFE67A-C3A7-4523-8119-D9FCD5EF3E7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8BFE67A-C3A7-4523-8119-D9FCD5EF3E7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8BFE67A-C3A7-4523-8119-D9FCD5EF3E7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8BFE67A-C3A7-4523-8119-D9FCD5EF3E7F}.Release|Any CPU.Build.0 = Release|Any CPU {526B37AD-256A-445A-9A42-E5C53989B11E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {526B37AD-256A-445A-9A42-E5C53989B11E}.Debug|Any CPU.Build.0 = Debug|Any CPU {526B37AD-256A-445A-9A42-E5C53989B11E}.Release|Any CPU.ActiveCfg = Release|Any CPU {526B37AD-256A-445A-9A42-E5C53989B11E}.Release|Any CPU.Build.0 = Release|Any CPU + {33E2AC82-DC46-42CA-A41B-0D8D97019402}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33E2AC82-DC46-42CA-A41B-0D8D97019402}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33E2AC82-DC46-42CA-A41B-0D8D97019402}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33E2AC82-DC46-42CA-A41B-0D8D97019402}.Release|Any CPU.Build.0 = Release|Any CPU {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Debug|Any CPU.Build.0 = Debug|Any CPU {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -234,6 +256,10 @@ Global {37ED98F0-3129-49B8-BF60-3BDEBBB34822}.Debug|Any CPU.Build.0 = Debug|Any CPU {37ED98F0-3129-49B8-BF60-3BDEBBB34822}.Release|Any CPU.ActiveCfg = Release|Any CPU {37ED98F0-3129-49B8-BF60-3BDEBBB34822}.Release|Any CPU.Build.0 = Release|Any CPU + {D68821D8-95A3-4DAB-9E90-676D9BE30AFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D68821D8-95A3-4DAB-9E90-676D9BE30AFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D68821D8-95A3-4DAB-9E90-676D9BE30AFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D68821D8-95A3-4DAB-9E90-676D9BE30AFB}.Release|Any CPU.Build.0 = Release|Any CPU {CFEE0754-9DAF-4AA6-98F3-477F065D7DC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CFEE0754-9DAF-4AA6-98F3-477F065D7DC4}.Debug|Any CPU.Build.0 = Debug|Any CPU {CFEE0754-9DAF-4AA6-98F3-477F065D7DC4}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -242,14 +268,6 @@ Global {AC04C113-8F75-43BA-8FEE-987475A87C58}.Debug|Any CPU.Build.0 = Debug|Any CPU {AC04C113-8F75-43BA-8FEE-987475A87C58}.Release|Any CPU.ActiveCfg = Release|Any CPU {AC04C113-8F75-43BA-8FEE-987475A87C58}.Release|Any CPU.Build.0 = Release|Any CPU - {3012AF4E-270B-4293-9C7B-51003F00A8D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3012AF4E-270B-4293-9C7B-51003F00A8D6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3012AF4E-270B-4293-9C7B-51003F00A8D6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3012AF4E-270B-4293-9C7B-51003F00A8D6}.Release|Any CPU.Build.0 = Release|Any CPU - {33E2AC82-DC46-42CA-A41B-0D8D97019402}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {33E2AC82-DC46-42CA-A41B-0D8D97019402}.Debug|Any CPU.Build.0 = Debug|Any CPU - {33E2AC82-DC46-42CA-A41B-0D8D97019402}.Release|Any CPU.ActiveCfg = Release|Any CPU - {33E2AC82-DC46-42CA-A41B-0D8D97019402}.Release|Any CPU.Build.0 = Release|Any CPU {009C5985-9DD4-45A8-A31E-4E6B7FE5EE78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {009C5985-9DD4-45A8-A31E-4E6B7FE5EE78}.Debug|Any CPU.Build.0 = Debug|Any CPU {009C5985-9DD4-45A8-A31E-4E6B7FE5EE78}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -269,7 +287,9 @@ Global {5BFAD4DB-D6A6-44F4-ACAB-B7B04E5A052E} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {47F95938-964A-47FE-A0D6-1EDD0893455B} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {913C0212-58AC-42B7-B555-F96B8E287E7F} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {17F58DAD-0789-4AD7-921E-24CEA09DC68B} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {F46ADD04-B716-4E9B-9799-7C47DDDB08FC} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {3012AF4E-270B-4293-9C7B-51003F00A8D6} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {A803DE4B-B050-48F2-82A1-8E947D8FB96C} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {6A1901A5-D01F-47E2-9ED5-2BE4CCE95100} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {29035EF2-2391-4441-AAC5-85AA43586EEB} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} @@ -283,7 +303,9 @@ Global {41980843-7F99-4AD2-B9E9-B13FC349A150} = {815F0941-3B70-4705-A583-AF627559595C} {D0C66D3A-ED1F-486E-AA19-BDBB19025368} = {815F0941-3B70-4705-A583-AF627559595C} {B82E2980-E145-4341-BAE0-8FAE1F110D0C} = {815F0941-3B70-4705-A583-AF627559595C} + {D8BFE67A-C3A7-4523-8119-D9FCD5EF3E7F} = {815F0941-3B70-4705-A583-AF627559595C} {526B37AD-256A-445A-9A42-E5C53989B11E} = {815F0941-3B70-4705-A583-AF627559595C} + {33E2AC82-DC46-42CA-A41B-0D8D97019402} = {815F0941-3B70-4705-A583-AF627559595C} {978023EA-2ED5-4A28-96AD-4BB914EF2BE5} = {815F0941-3B70-4705-A583-AF627559595C} {A8AB6597-DEBB-4828-AE1B-A11FD818E428} = {815F0941-3B70-4705-A583-AF627559595C} {8E611861-09A3-4AE4-8392-E7CB9BE02B3B} = {815F0941-3B70-4705-A583-AF627559595C} @@ -292,10 +314,9 @@ Global {B97964EC-658A-4205-AA5A-BB814B191C35} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {ACED6271-2F75-4E3B-BB79-9D40B74D6A51} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {37ED98F0-3129-49B8-BF60-3BDEBBB34822} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} + {D68821D8-95A3-4DAB-9E90-676D9BE30AFB} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {CFEE0754-9DAF-4AA6-98F3-477F065D7DC4} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {AC04C113-8F75-43BA-8FEE-987475A87C58} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} - {3012AF4E-270B-4293-9C7B-51003F00A8D6} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} - {33E2AC82-DC46-42CA-A41B-0D8D97019402} = {815F0941-3B70-4705-A583-AF627559595C} {009C5985-9DD4-45A8-A31E-4E6B7FE5EE78} = {9071B0C9-DE4D-411D-A9D3-CB7326CBBD80} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/samples/MongoDBSample/MongoDBSample.csproj b/samples/MongoDBSample/MongoDBSample.csproj new file mode 100644 index 00000000..de39324a --- /dev/null +++ b/samples/MongoDBSample/MongoDBSample.csproj @@ -0,0 +1,15 @@ + + + + 8bd0ef6d-24c8-4ae8-8397-32053d171b84 + + + + + + + + + + + diff --git a/samples/MongoDBSample/Program.cs b/samples/MongoDBSample/Program.cs new file mode 100644 index 00000000..5ce45f49 --- /dev/null +++ b/samples/MongoDBSample/Program.cs @@ -0,0 +1,42 @@ +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +Console.WriteLine("Hello World!"); + +var dbName = Guid.NewGuid().ToString().Replace("_", "")[..10]; +var connectionString = $"mongodb://localhost:27017/{dbName}"; + +var services = new ServiceCollection(); +services.AddLogging(); +services.AddMongoDbContext(options => +{ + options.UseMongoConnectionString(connectionString); +}); + +var sp = services.BuildServiceProvider(validateScopes: true); + +// resolving directly +using var scope = sp.CreateScope(); +var provider = scope.ServiceProvider; + +var context = provider.GetRequiredService(); +var persons = await context.Persons.AsQueryable().ToListAsync(); +Console.WriteLine($"Found {persons.Count} persons"); + +class Person +{ + [BsonId] + public string? Id { get; set; } + + public string? Name { get; set; } +} + +class SampleDbContext(MongoDbContextOptions options) : MongoDbContext(options) +{ + public IMongoCollection Persons => Collection("Persons"); + + protected override void OnConfiguring(MongoDbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + } +} diff --git a/samples/MongoDBSample/appsettings.Development.json b/samples/MongoDBSample/appsettings.Development.json new file mode 100644 index 00000000..b2dcdb67 --- /dev/null +++ b/samples/MongoDBSample/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/samples/MongoDBSample/appsettings.json b/samples/MongoDBSample/appsettings.json new file mode 100644 index 00000000..b2dcdb67 --- /dev/null +++ b/samples/MongoDBSample/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/Tingle.Extensions.MongoDB/DatabaseSetup.cs b/src/Tingle.Extensions.MongoDB/DatabaseSetup.cs new file mode 100644 index 00000000..c0c443a2 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/DatabaseSetup.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Tingle.Extensions.MongoDB; + +/// +/// Helper for performing creation. +/// +/// The type of context to be used. +internal class DatabaseSetup(IServiceScopeFactory scopeFactory, ILogger> logger) : IHostedService where TContext : MongoDbContext +{ + private readonly IServiceScopeFactory scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + private readonly ILogger logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = scopeFactory.CreateScope(); + var provider = scope.ServiceProvider; + + // Check if explicitly told to do creation + var environment = provider.GetRequiredService(); + var configuration = provider.GetRequiredService(); + if (bool.TryParse(configuration["MONGO_CREATE_DATABASE"], out var b) && b) + { + // Create database + logger.LogInformation("Creating MongoDB database ..."); + var context = provider.GetRequiredService(); + await context.EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("Completed MongoDB database creation."); + } + else + { + logger.LogDebug("Database creation skipped."); + return; + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Tingle.Extensions.MongoDB/Diagnostics/MongoDbContextHealthCheck.cs b/src/Tingle.Extensions.MongoDB/Diagnostics/MongoDbContextHealthCheck.cs new file mode 100644 index 00000000..8d425068 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Diagnostics/MongoDbContextHealthCheck.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using MongoDB.Driver; + +namespace Tingle.Extensions.MongoDB.Diagnostics; + +internal class MongoDbContextHealthCheck : IHealthCheck where TContext : MongoDbContext +{ + private readonly IMongoDatabase database; + public MongoDbContextHealthCheck(TContext context) + { + database = context.Database ?? throw new ArgumentNullException(nameof(database)); + } + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + using (await database.ListCollectionsAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) + { + + } + + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); + } + } +} diff --git a/src/Tingle.Extensions.MongoDB/Diagnostics/MongoDbDiagnosticEvents.cs b/src/Tingle.Extensions.MongoDB/Diagnostics/MongoDbDiagnosticEvents.cs new file mode 100644 index 00000000..7da61497 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Diagnostics/MongoDbDiagnosticEvents.cs @@ -0,0 +1,145 @@ +using MongoDB.Driver.Core.Events; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Reflection; + +namespace Tingle.Extensions.MongoDB.Diagnostics; + +/// +/// A subscriber to events that writes to a +/// +/// +/// This class is highly borrowed from https://github.com/jbogard/MongoDB.Driver.Core.Extensions.DiagnosticSources +/// +public class MongoDbDiagnosticEvents : IEventSubscriber +{ + internal static readonly AssemblyName AssemblyName = typeof(MongoDbDiagnosticEvents).Assembly.GetName(); + internal static readonly string ActivitySourceName = AssemblyName.Name!; + internal static readonly Version Version = AssemblyName.Version!; + internal static readonly ActivitySource ActivitySource = new(ActivitySourceName, Version.ToString()); + + private const string ActivityName = "MongoDB"; + + private readonly bool captureCommandText = false; // Ideally should be provided via constructor + private readonly Func? shouldStartActivity; + private readonly ReflectionEventSubscriber subscriber; + private readonly ConcurrentDictionary activityMap = new(); + + /// + /// Creates an instance of . + /// + /// indicates if the command text should be captured + /// optional delegate to check if an activity should be started + public MongoDbDiagnosticEvents(bool captureCommandText = false, Func? shouldStartActivity = null) + { + this.captureCommandText = captureCommandText; + this.shouldStartActivity = shouldStartActivity; + + // the reflection-based subscriber accepts any objects, for this case, we take non public ones + subscriber = new ReflectionEventSubscriber(this, bindingFlags: BindingFlags.Instance | BindingFlags.NonPublic); + } + + /// + /// Tries to get an event handler for an event of type TEvent. + /// + /// The type of the event. + /// The handler. + /// true if this subscriber has provided an event handler; otherwise false. + public bool TryGetEventHandler(out Action handler) => subscriber.TryGetEventHandler(out handler); + +#pragma warning disable IDE0051 // Remove unused private members + + private void Handle(CommandStartedEvent @event) + { + if (shouldStartActivity != null && !shouldStartActivity(@event)) + { + return; + } + + var activity = ActivitySource.StartActivity(ActivityName, ActivityKind.Client); + + // if the activity is null, there is no one listening so just return + if (activity == null) return; + + var collectionName = @event.GetCollectionName(); + + // https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/semantic_conventions/database.md + activity.DisplayName = collectionName == null ? $"mongodb.{@event.CommandName}" : $"{collectionName}.{@event.CommandName}"; + + // add tags known by open telemetry + activity.AddTag("db.system", "mongodb"); + activity.AddTag("db.name", @event.DatabaseNamespace?.DatabaseName); + activity.AddTag("db.mongodb.collection", collectionName); + activity.AddTag("db.operation", @event.CommandName); + var endPoint = @event.ConnectionId?.ServerId?.EndPoint; + switch (endPoint) + { + case IPEndPoint ipe: + activity.AddTag("db.user", $"mongodb://{ipe.Address}:{ipe.Port}"); + activity.AddTag("net.peer.ip", ipe.Address.ToString()); + activity.AddTag("net.peer.port", ipe.Port.ToString()); + break; + case DnsEndPoint dnse: + activity.AddTag("db.user", $"mongodb://{dnse.Host}:{dnse.Port}"); + activity.AddTag("net.peer.name", dnse.Host); + activity.AddTag("net.peer.port", dnse.Port.ToString()); + break; + } + + if (activity.IsAllDataRequested && captureCommandText) + { + activity.AddTag("db.statement", @event.Command.ToString()); + } + + activityMap.TryAdd(@event.RequestId, activity); + } + + private void Handle(CommandSucceededEvent @event) + { + if (activityMap.TryRemove(@event.RequestId, out var activity)) + { + WithReplacedActivityCurrent(activity, () => + { + activity.AddTag("otel.status_code", "Ok"); + activity.Stop(); + }); + } + } + + private void Handle(CommandFailedEvent @event) + { + if (activityMap.TryRemove(@event.RequestId, out var activity)) + { + WithReplacedActivityCurrent(activity, () => + { + if (activity.IsAllDataRequested) + { + activity.AddTag("otel.status_code", "Error"); + activity.AddTag("otel.status_description", @event.Failure.Message); + activity.AddTag("error.type", @event.Failure.GetType().FullName); + activity.AddTag("error.msg", @event.Failure.Message); + activity.AddTag("error.stack", @event.Failure.StackTrace); + } + + activity.Stop(); + }); + } + } + + private static void WithReplacedActivityCurrent(Activity activity, Action action) + { + var current = Activity.Current; + try + { + Activity.Current = activity; + action(); + } + finally + { + Activity.Current = current; + } + } + +#pragma warning restore IDE0051 // Remove unused private members +} diff --git a/src/Tingle.Extensions.MongoDB/Extensions/BuildersExtensions.cs b/src/Tingle.Extensions.MongoDB/Extensions/BuildersExtensions.cs new file mode 100644 index 00000000..f87e9bee --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Extensions/BuildersExtensions.cs @@ -0,0 +1,91 @@ +using MongoDB.Bson.Serialization; +using System.Linq.Expressions; + +namespace MongoDB.Driver; + +/// +/// Extensions on for indexing properties in an array. +/// Copied from https://jira.mongodb.org/browse/CSHARP-1309 before modification. +/// +public static class BuildersExtensions +{ + /// + /// Creates an ascending index key definition. + /// + /// + /// + /// + /// The collection. + /// The member. + /// An ascending index key definition. + public static IndexKeysDefinition Ascending(this IndexKeysDefinitionBuilder builder, + Expression>> collectionExpression, + Expression> memberExpression) + { + var definition = CreateFieldDefinition(collectionExpression, memberExpression); + return builder.Ascending(definition); + } + + /// + /// Creates an ascending index key definition. + /// + /// + /// + /// + /// The collection. + /// The member. + /// An ascending index key definition. + public static IndexKeysDefinition Ascending(this IndexKeysDefinition builder, + Expression>> collectionExpression, + Expression> memberExpression) + { + var definition = CreateFieldDefinition(collectionExpression, memberExpression); + return builder.Ascending(definition); + } + + /// + /// Creates a descending index key definition. + /// + /// + /// + /// + /// The collection. + /// The member. + /// A descending index key definition. + public static IndexKeysDefinition Descending(this IndexKeysDefinitionBuilder builder, + Expression>> collectionExpression, + Expression> memberExpression) + { + var definition = CreateFieldDefinition(collectionExpression, memberExpression); + return builder.Descending(definition); + } + + /// + /// Creates a descending index key definition. + /// + /// + /// + /// + /// The collection. + /// The member. + /// A descending index key definition. + public static IndexKeysDefinition Descending(this IndexKeysDefinition builder, + Expression>> collectionExpression, + Expression> memberExpression) + { + var definition = CreateFieldDefinition(collectionExpression, memberExpression); + return builder.Descending(definition); + } + + private static StringFieldDefinition CreateFieldDefinition(Expression>> collectionExpression, + Expression> memberExpression) + { + var collection = new ExpressionFieldDefinition(collectionExpression); + var child = new ExpressionFieldDefinition(memberExpression); + var collectionDefinition = collection.Render(BsonSerializer.LookupSerializer(), BsonSerializer.SerializerRegistry); + var childDefinition = child.Render(BsonSerializer.LookupSerializer(), BsonSerializer.SerializerRegistry); + + var fieldDefinition = new StringFieldDefinition(collectionDefinition.FieldName + "." + childDefinition.FieldName); + return fieldDefinition; + } +} diff --git a/src/Tingle.Extensions.MongoDB/Extensions/CommandExtensions.cs b/src/Tingle.Extensions.MongoDB/Extensions/CommandExtensions.cs new file mode 100644 index 00000000..b95da11b --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Extensions/CommandExtensions.cs @@ -0,0 +1,47 @@ +namespace MongoDB.Driver.Core.Events; + +internal static class CommandExtensions +{ + private static readonly HashSet CommandsWithCollectionNameAsValue = [ + "aggregate", + "count", + "distinct", + "mapReduce", + "geoSearch", + "delete", + "find", + "killCursors", + "findAndModify", + "insert", + "update", + "create", + "drop", + "createIndexes", + "listIndexes", + ]; + + public static string? GetCollectionName(this CommandStartedEvent @event) + { + if (@event.CommandName == "getMore") + { + if (@event.Command.Contains("collection")) + { + var collectionValue = @event.Command.GetValue("collection"); + if (collectionValue.IsString) + { + return collectionValue.AsString; + } + } + } + else if (CommandsWithCollectionNameAsValue.Contains(@event.CommandName)) + { + var commandValue = @event.Command.GetValue(@event.CommandName); + if (commandValue != null && commandValue.IsString) + { + return commandValue.AsString; + } + } + + return null; + } +} diff --git a/src/Tingle.Extensions.MongoDB/Extensions/IHealthChecksBuilderExtensions.cs b/src/Tingle.Extensions.MongoDB/Extensions/IHealthChecksBuilderExtensions.cs new file mode 100644 index 00000000..ba0c88b8 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Extensions/IHealthChecksBuilderExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using MongoDB.Driver; +using Tingle.Extensions.MongoDB.Diagnostics; + +namespace Microsoft.Extensions.DependencyInjection; + +/// Extension methods for . +public static class IHealthChecksBuilderExtensions +{ + /// Add a health check for an instance of . + /// The to add to. + /// The health check name. + /// + /// The that should be reported when the health check reports a failure. + /// If the provided value is , then will be reported. + /// + /// A list of tags that can be used to filter health checks. + /// An optional representing the timeout of the check. + /// The . + public static IHealthChecksBuilder AddMongoDbContextCheck(this IHealthChecksBuilder builder, + string? name = null, + HealthStatus? failureStatus = null, + IEnumerable? tags = null, + TimeSpan? timeout = null) + where TContext : MongoDbContext + { + name ??= typeof(TContext).Name; + return builder.AddCheck>(name, failureStatus, tags, timeout); + } +} diff --git a/src/Tingle.Extensions.MongoDB/Extensions/ILoggerExtensions.cs b/src/Tingle.Extensions.MongoDB/Extensions/ILoggerExtensions.cs new file mode 100644 index 00000000..987e34ce --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Extensions/ILoggerExtensions.cs @@ -0,0 +1,7 @@ +namespace Microsoft.Extensions.Logging; + +internal static partial class ILoggerExtensions +{ + [LoggerMessage(1, LogLevel.Debug, "Standalone servers (e.g. localhost) do not support transactions. Transactions will be omitted.")] + public static partial void StandaloneServerNotSupported(this ILogger logger); +} diff --git a/src/Tingle.Extensions.MongoDB/Extensions/IMongoCollectionExtensions.cs b/src/Tingle.Extensions.MongoDB/Extensions/IMongoCollectionExtensions.cs new file mode 100644 index 00000000..e0975596 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Extensions/IMongoCollectionExtensions.cs @@ -0,0 +1,106 @@ +namespace MongoDB.Driver; + +/// +/// Extension methods on +/// +public static class IMongoCollectionExtensions +{ + /// + /// Updates many documents and checks the result. + /// + /// + /// + /// The filter. + /// The update. + /// . + /// The optional to use. + /// + /// + /// + /// The update was not acknowledged. + /// + public static async Task UpdateManyWithResultCheckAsync(this IMongoCollection collection, + FilterDefinition filter, + UpdateDefinition update, + UpdateOptions? options = null, + IClientSessionHandle? session = null, + CancellationToken cancellationToken = default) + { + // Do the actual update + var ur = session is null + ? await collection.UpdateManyAsync(filter, update, options, cancellationToken).ConfigureAwait(false) + : await collection.UpdateManyAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false); + + // Throw exception if the update did not match or did not effect + // For multiple document updates, we just need to check acknowledgment + if (!ur.IsAcknowledged) + { + throw new MongoUpdateConcurrencyException(acknowledged: ur.IsAcknowledged); + } + + return ur.ModifiedCount; + } + + /// + /// Deletes multiple documents and checks the result. + /// + /// + /// + /// The filter without the Etag condition. + /// . + /// The optional to use. + /// + /// + /// + /// The deletion was not acknowledged. + /// + public static async Task DeleteManyCheckedAsync(this IMongoCollection collection, + FilterDefinition filter, + DeleteOptions? options = null, + IClientSessionHandle? session = null, + CancellationToken cancellationToken = default) + { + // Do the actual delete + var dr = session is null + ? await collection.DeleteManyAsync(filter, options, cancellationToken).ConfigureAwait(false) + : await collection.DeleteManyAsync(session, filter, options, cancellationToken).ConfigureAwait(false); + + // Throw exception if the delete did not match or did not effect + // For multiple document updates, we just need to check acknowledgment + if (!dr.IsAcknowledged) + { + throw new MongoUpdateConcurrencyException(acknowledged: dr.IsAcknowledged); + } + + return dr.DeletedCount; + } + + /// + /// Performs multiple write operations and checks the result. + /// + /// + /// + /// The requests. + /// . + /// The optional to use. + /// + /// + public static async Task> BulkWriteCheckedAsync(this IMongoCollection collection, + IEnumerable> requests, + BulkWriteOptions? options = null, + IClientSessionHandle? session = null, + CancellationToken cancellationToken = default) + { + // Do the actual bulk write + var bwr = session is null + ? await collection.BulkWriteAsync(requests, options, cancellationToken).ConfigureAwait(false) + : await collection.BulkWriteAsync(session, requests, options, cancellationToken).ConfigureAwait(false); + + if (!bwr.IsAcknowledged) + { + throw new MongoUpdateConcurrencyException(acknowledged: bwr.IsAcknowledged); + } + + return bwr; + } +} diff --git a/src/Tingle.Extensions.MongoDB/Extensions/IMongoQueryableExtensions.cs b/src/Tingle.Extensions.MongoDB/Extensions/IMongoQueryableExtensions.cs new file mode 100644 index 00000000..ef3facdf --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Extensions/IMongoQueryableExtensions.cs @@ -0,0 +1,68 @@ +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +namespace System.Linq; + +/// +/// Extension methods on +/// +public static class IMongoQueryableExtensions +{ + /// + /// Returns a list containing all the documents returned from Mongo. + /// This method removes the need to convert instances of to . + /// + /// + /// + /// + /// + public static Task> ToListInMongoAsync(this IQueryable query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + // cast to IMongoQueryable, if not possible, a type cast exception is thrown + var mq = (IMongoQueryable)query; + + // execute the async operation + return mq.ToListAsync(cancellationToken); + } + + /// + /// Returns the only element of a sequence from Mongo, and throws an exception if there is not exactly one element in the sequence. + /// This method removes the need to convert instances of to . + /// + /// + /// + /// + /// + public static Task SingleInMongoAsync(this IQueryable query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + // cast to IMongoQueryable, if not possible, a type cast exception is thrown + var mq = (IMongoQueryable)query; + + // execute the async operation + return mq.SingleAsync(cancellationToken); + } + + /// + /// Returns the only element of a sequence from Mongo, or a default value if the sequence is empty. + /// This method throws an exception if there is more than one element in the sequence. + /// This method removes the need to convert instances of to . + /// + /// + /// + /// + /// + public static Task SingleOrDefaultInMongoAsync(this IQueryable query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + // cast to IMongoQueryable, if not possible, a type cast exception is thrown + var mq = (IMongoQueryable)query; + + // execute the async operation + return mq.SingleOrDefaultAsync(cancellationToken); + } +} diff --git a/src/Tingle.Extensions.MongoDB/Extensions/IServiceCollectionExtensions.cs b/src/Tingle.Extensions.MongoDB/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 00000000..973c3bd5 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,184 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using MongoDB.Driver; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Tingle.Extensions.MongoDB; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Microsoft.Extensions.DependencyInjection; + +/// Extension methods for . +public static class IServiceCollectionExtensions +{ + /// + /// Add an to perform database setup depending on the configuration. + /// + /// The type of context to be used in setup. + /// The to add to. + /// + /// + /// Database creation is done when configuration value MONGO_CREATE_DATABASE is set to . + /// + public static IServiceCollection AddMongoDatabaseSetup(this IServiceCollection services) + where TContext : MongoDbContext + { + return services.AddHostedService>(); + } + + public static IServiceCollection AddMongoDbContext<[DynamicallyAccessedMembers(MongoDbContext.DynamicallyAccessedMemberTypes)] TContext>( + this IServiceCollection services, + IConfiguration configuration, + Action? optionsAction = null, + ServiceLifetime contextLifetime = ServiceLifetime.Singleton, + ServiceLifetime optionsLifetime = ServiceLifetime.Singleton) + where TContext : MongoDbContext + { + var connectionString = configuration.GetConnectionString("Mongo"); + return connectionString is not null + ? AddMongoDbContext(services, connectionString: connectionString, optionsAction, contextLifetime, optionsLifetime) + : AddMongoDbContext(services, optionsAction, contextLifetime, optionsLifetime); + } + + public static IServiceCollection AddMongoDbContext<[DynamicallyAccessedMembers(MongoDbContext.DynamicallyAccessedMemberTypes)] TContext>( + this IServiceCollection services, + string connectionString, + Action? optionsAction = null, + ServiceLifetime contextLifetime = ServiceLifetime.Singleton, + ServiceLifetime optionsLifetime = ServiceLifetime.Singleton) + where TContext : MongoDbContext + { + return AddMongoDbContext(services, options => + { + options.UseMongoConnectionString(connectionString); + optionsAction?.Invoke(options); + }, contextLifetime, optionsLifetime); + } + + public static IServiceCollection AddMongoDbContext<[DynamicallyAccessedMembers(MongoDbContext.DynamicallyAccessedMemberTypes)] TContext>( + this IServiceCollection services, + Action? optionsAction = null, + ServiceLifetime contextLifetime = ServiceLifetime.Singleton, + ServiceLifetime optionsLifetime = ServiceLifetime.Singleton) + where TContext : MongoDbContext + => AddMongoDbContext(services, optionsAction, contextLifetime, optionsLifetime); + + public static IServiceCollection AddMongoDbContext( + this IServiceCollection services, + Action? optionsAction = null, + ServiceLifetime contextLifetime = ServiceLifetime.Singleton, + ServiceLifetime optionsLifetime = ServiceLifetime.Singleton) + where TContextImplementation : MongoDbContext, TContextService + => AddMongoDbContext( + services, + optionsAction == null + ? null + : (p, b) => optionsAction(b), contextLifetime, optionsLifetime); + + public static IServiceCollection AddMongoDbContext<[DynamicallyAccessedMembers(MongoDbContext.DynamicallyAccessedMemberTypes)] TContext>( + this IServiceCollection services, + ServiceLifetime contextLifetime, + ServiceLifetime optionsLifetime = ServiceLifetime.Singleton) + where TContext : MongoDbContext + => AddMongoDbContext(services, contextLifetime, optionsLifetime); + + public static IServiceCollection AddMongoDbContext( + this IServiceCollection services, + ServiceLifetime contextLifetime, + ServiceLifetime optionsLifetime = ServiceLifetime.Singleton) + where TContextImplementation : MongoDbContext, TContextService + where TContextService : class + => AddMongoDbContext( + services, + (Action?)null, + contextLifetime, + optionsLifetime); + + public static IServiceCollection AddMongoDbContext<[DynamicallyAccessedMembers(MongoDbContext.DynamicallyAccessedMemberTypes)] TContext>( + this IServiceCollection services, + Action? optionsAction, + ServiceLifetime contextLifetime = ServiceLifetime.Singleton, + ServiceLifetime optionsLifetime = ServiceLifetime.Singleton) + where TContext : MongoDbContext + => AddMongoDbContext(services, optionsAction, contextLifetime, optionsLifetime); + + public static IServiceCollection AddMongoDbContext( + this IServiceCollection services, + Action? optionsAction, + ServiceLifetime contextLifetime = ServiceLifetime.Singleton, + ServiceLifetime optionsLifetime = ServiceLifetime.Singleton) + where TContextImplementation : MongoDbContext, TContextService + { + if (contextLifetime == ServiceLifetime.Singleton) + { + optionsLifetime = ServiceLifetime.Singleton; + } + + if (optionsAction != null) + { + CheckContextConstructors(); + } + + AddCoreServices(services, optionsAction, optionsLifetime); + + services.TryAdd(new ServiceDescriptor(typeof(TContextService), typeof(TContextImplementation), contextLifetime)); + + if (typeof(TContextService) != typeof(TContextImplementation)) + { + services.TryAdd( + new ServiceDescriptor( + typeof(TContextImplementation), + p => (TContextImplementation)p.GetService()!, + contextLifetime)); + } + + return services; + } + + private static void AddCoreServices( + IServiceCollection services, + Action? optionsAction, + ServiceLifetime optionsLifetime) + where TContextImplementation : MongoDbContext + { + services.TryAdd( + new ServiceDescriptor( + typeof(MongoDbContextOptions), + p => CreateDbContextOptions(p, optionsAction), + optionsLifetime)); + + services.Add( + new ServiceDescriptor( + typeof(MongoDbContextOptions), + p => p.GetRequiredService>(), + optionsLifetime)); + } + + private static MongoDbContextOptions CreateDbContextOptions( + IServiceProvider applicationServiceProvider, + Action? optionsAction) + where TContext : MongoDbContext + { + var builder = new MongoDbContextOptionsBuilder( + new MongoDbContextOptions()); + + builder.UseApplicationServiceProvider(applicationServiceProvider); + + optionsAction?.Invoke(applicationServiceProvider, builder); + + return builder.Options; + } + + private static void CheckContextConstructors<[DynamicallyAccessedMembers(MongoDbContext.DynamicallyAccessedMemberTypes)] TContext>() + where TContext : MongoDbContext + { + var declaredConstructors = typeof(TContext).GetTypeInfo().DeclaredConstructors.ToList(); + if (declaredConstructors.Count == 1 + && declaredConstructors[0].GetParameters().Length == 0) + { + throw new ArgumentException($"{typeof(TContext).Name} does not have the necessary constructor"); + } + } +} diff --git a/src/Tingle.Extensions.MongoDB/MongoDbContext.cs b/src/Tingle.Extensions.MongoDB/MongoDbContext.cs new file mode 100644 index 00000000..d0c3c8c4 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/MongoDbContext.cs @@ -0,0 +1,247 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using System.Collections.Concurrent; + +namespace MongoDB.Driver; + +/// +/// An abstraction for a database context backed by MongoDB +/// +public abstract class MongoDbContext +{ + internal const System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes DynamicallyAccessedMemberTypes + = System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors + | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicConstructors + | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties; + + private readonly ConcurrentDictionary collections = new(); + private readonly MongoDbContextOptions options; + private readonly object _lockObjectClient = new(); + private readonly object _lockObjectDatabase = new(); + + private IMongoClient? client; + private IMongoDatabase? database; + + /// + /// Initializes a new instance of the class. + /// The + /// method will be called to configure the database (and other options) to be used + /// for this context. + /// + protected MongoDbContext() : this(new MongoDbContextOptions()) { } + + /// + /// Initializes a new instance of the class + /// using the specified options. he + /// method will still be called to allow further configuration of the options. + /// + /// The options for this context. + public MongoDbContext(MongoDbContextOptions options) + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + if (!options.ContextType.IsAssignableFrom(GetType())) + { + throw new InvalidOperationException($"Non generic options used with '{GetType().Name}' are not supported"); + } + + var provider = options.GetServiceProvider(); + Logger = provider.GetRequiredService().CreateLogger(GetType()); + + var optionsBuilder = new MongoDbContextOptionsBuilder(options); + OnConfiguring(optionsBuilder); + } + + /// + public string? DatabaseName { get; protected set; } + + /// + public IMongoClient Client + { + get + { + if (client is not null) return client; + + lock (_lockObjectClient) + { + if (client is null) + { + // ensure we have a MongoUrl + if (!options.TryGetMongoUrl(out var url)) + { + throw new InvalidOperationException("The connection string or URL must be configured."); + } + + // get the MongoClientSettings or create from the MongoUrl + if (!options.TryGetMongoClientSettings(out var settings)) + { + settings = MongoClientSettings.FromUrl(url); + } + + client = new MongoClient(settings); + DatabaseName = url.DatabaseName; + } + } + + return client; + } + } + + /// + public IMongoDatabase Database + { + get + { + if (database is not null) return database; + + lock (_lockObjectDatabase) + { + database ??= Client.GetDatabase(DatabaseName); + } + + return database; + } + } + + /// + protected ILogger Logger { get; } + + /// Gets a collection. + /// The document type. + /// The name of the collection. + /// The settings for the collection. + /// An implementation of a collection. + /// + /// The dictionary already contains the maximum number of elements (). + /// + protected IMongoCollection Collection(string name, MongoCollectionSettings? settings = null) + => Collection(name: name, databaseName: null, settings: settings); + + /// Gets a collection. + /// The document type. + /// The name of the collection. + /// The name of the database, if none is provided, the one in the connection string or is used. + /// The settings for the collection. + /// An implementation of a collection. + /// + /// The dictionary already contains the maximum number of elements (). + /// + protected IMongoCollection Collection(string name, string? databaseName, MongoCollectionSettings? settings = null) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException($"'{nameof(name)}' cannot be null or empty.", nameof(name)); + } + + var db = string.IsNullOrWhiteSpace(databaseName) ? Database : Client.GetDatabase(databaseName); + var key = new CollectionKeyEntry(db.DatabaseNamespace.DatabaseName, name, typeof(T).FullName); + return (IMongoCollection)collections.GetOrAdd(key, k => db.GetCollection(name, settings)); + } + + #region Creation + + /// Ensure the collections, indexes and schema validations are created. + public void EnsureCreated() => EnsureCreated(Database); + + /// Ensure the collections, indexes and schema validations are created. + /// + public async Task EnsureCreatedAsync(CancellationToken cancellationToken = default) + => await EnsureCreatedAsync(Database, cancellationToken).ConfigureAwait(false); + + /// Ensure the collections, indexes and schema validations are created. + /// The database to use. + protected void EnsureCreated(IMongoDatabase database) => EnsureCreatedAsync(database).GetAwaiter().GetResult(); + + /// Ensure the collections, indexes and schema validations are created. + /// The to use. + /// + protected virtual Task EnsureCreatedAsync(IMongoDatabase database, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + #endregion + + /// + /// Override this method to configure the database (and other options) to be used + /// for this context. This method is called for each instance of the context that + /// is created. The base implementation does nothing. + /// + /// + /// A builder used to create or modify options for this context. + /// Extensions typically define extension methods on this object that allow you to configure the context. + /// + protected internal virtual void OnConfiguring(MongoDbContextOptionsBuilder optionsBuilder) { } + + #region Transactions + + /// Execute a given set of operations in a session with the transaction model. + /// The func to be executed within in the transaction. + /// The for creating the session. + /// The for the transaction. + /// + /// + public async Task WithTransactionAsync(Func> callbackAsync, + ClientSessionOptions? options = null, + TransactionOptions? transactionOptions = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callbackAsync); + + var client = Client; + + // Create a session object that is used when leveraging transactions + using var session = await client.StartSessionAsync(options, cancellationToken).ConfigureAwait(false); + + // For local or standalone servers, transactions are not supported + var isLocal = client.Settings.Servers.Count() == 1 && client.Settings.Server.ToString().Contains("localhost"); + if (isLocal) + { + Logger.StandaloneServerNotSupported(); + return await callbackAsync(session, cancellationToken).ConfigureAwait(false); + } + + return await session.WithTransactionAsync(callbackAsync: callbackAsync, + transactionOptions: transactionOptions, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// Execute a given set of operations in a session with the transaction model. + /// The func to be executed within in the transaction. + /// The for creating the session. + /// The for the transaction. + /// + /// + public async Task WithTransactionAsync(Func func, + ClientSessionOptions? options = null, + TransactionOptions? transactionOptions = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(func); + + async Task callbackAsync(IClientSessionHandle session, CancellationToken cancellationToken) + { + await func(session, cancellationToken).ConfigureAwait(false); + return "Done"; + } + + await WithTransactionAsync(callbackAsync: callbackAsync, + options: options, + transactionOptions: transactionOptions, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + #endregion + + private sealed record CollectionKeyEntry + { + public CollectionKeyEntry(string databaseName, string collectionName, string? type) + { + DatabaseName = databaseName ?? throw new ArgumentNullException(nameof(databaseName)); + CollectionName = collectionName ?? throw new ArgumentNullException(nameof(collectionName)); + Type = type; + } + + public string DatabaseName { get; } + public string CollectionName { get; } + public string? Type { get; } + } +} diff --git a/src/Tingle.Extensions.MongoDB/MongoDbContextOptions.cs b/src/Tingle.Extensions.MongoDB/MongoDbContextOptions.cs new file mode 100644 index 00000000..771a2280 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/MongoDbContextOptions.cs @@ -0,0 +1,289 @@ +using MongoDB.Driver; +using MongoDB.Driver.Core.Events; +using System.Diagnostics.CodeAnalysis; +using Tingle.Extensions.MongoDB.Diagnostics; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Options for configuring instances. +/// +/// The options to be used by a . You normally +/// override +/// or use a to create instances +/// of this class and it is not designed to be directly constructed in your application +/// code. +/// +public abstract class MongoDbContextOptions +{ + private readonly Dictionary metadata = []; + + /// + /// The type of context that these options are for. Will return + /// if the options are not built for a specific derived context. + /// + public abstract Type ContextType { get; } + + /// Add metadata to . + /// The type of metadata. + /// The value to be added. + public void AddMetadata(T value) where T : class => metadata.Add(typeof(T), value); + + /// Gets the metadata from . + /// The type of metadata. + /// + public T GetMetadata() where T : class => (T)metadata[typeof(T)]; + + /// Gets the metadata from . + /// The type of metadata. + /// + /// + public bool TryGetMetadata([NotNullWhen(true)] out T? value) where T : class + { + if (metadata.TryGetValue(typeof(T), out var raw)) + { + if (raw is T t) + { + value = t; + return true; + } + } + + value = null; + return false; + } + + internal IServiceProvider GetServiceProvider() => GetMetadata(); + + internal MongoUrl GetMongoUrl() => GetMetadata(); + internal bool TryGetMongoClientSettings([NotNullWhen(true)] out MongoClientSettings? settings) => TryGetMetadata(out settings); + internal bool TryGetMongoUrl([NotNullWhen(true)] out MongoUrl? url) => TryGetMetadata(out url); +} + +/// +/// The options to be used by a . You normally +/// override +/// or use a to create instances +/// of this class and it is not designed to be directly constructed in your application +/// code. +/// +/// The type of the context these options apply to. +public class MongoDbContextOptions : MongoDbContextOptions where TContext : MongoDbContext +{ + /// + /// The type of context that these options are for (TContext). + /// + public override Type ContextType => typeof(TContext); +} + +/// +/// Provides a simple API surface for configuring . +/// You can use to configure +/// a context by overriding +/// or creating a externally and passing +/// it to the context constructor. +/// +/// +/// Initializes a new instance of the class to further configure +/// a given . +/// +/// The options to be configured. +public class MongoDbContextOptionsBuilder(MongoDbContextOptions options) +{ + private readonly MongoDbContextOptions options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Initializes a new instance of the class with no options set. + /// + public MongoDbContextOptionsBuilder() + : this(new MongoDbContextOptions()) + { + } + + /// + /// Gets the options being configured. + /// + public virtual MongoDbContextOptions Options => options; + + /// + /// Sets the from which application services will be obtained. + /// This is done automatically when using 'AddDbContext' , so + /// it is rare that this method needs to be called. + /// + /// The service provider to be used. + /// The same builder instance so that multiple calls can be chained. + public virtual MongoDbContextOptionsBuilder UseApplicationServiceProvider(IServiceProvider serviceProvider) + { + options.AddMetadata(serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider))); + return this; + } + + /// + /// Sets the to use when configuring the context. + /// + /// The to be used + /// + /// Whether the command text should be captured in instrumentation. + /// + /// + /// Delegate for determining if a should be instrumented. + /// + /// + public virtual MongoDbContextOptionsBuilder UseMongoUrl(MongoUrl url, + bool instrumentCommandText = true, + Func? shouldInstrument = null) + { + ArgumentNullException.ThrowIfNull(url); + if (string.IsNullOrWhiteSpace(url.DatabaseName)) + { + throw new ArgumentException("The database must be specified", nameof(url)); + } + + options.AddMetadata(url); + + // add the default event subscriber + ConfigureMongoClientSettings(settings => + { + settings.ClusterConfigurator = builder => + { + builder.Subscribe( + new MongoDbDiagnosticEvents( + captureCommandText: instrumentCommandText, + shouldStartActivity: shouldInstrument)); + }; + }); + + return this; + } + + /// + /// Sets the to use when configuring the context by + /// parsing the . + /// + /// + /// A valid Mongo db collection in the format + /// mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]] + /// e.g. mongodb://localhost:27017/myDatabase + /// + /// + /// Whether the command text should be captured in instrumentation. + /// + /// + /// Delegate for determining if a should be instrumented. + /// + /// + public virtual MongoDbContextOptionsBuilder UseMongoConnectionString(string connectionString, + bool instrumentCommandText = true, + Func? shouldInstrument = null) + { + ArgumentNullException.ThrowIfNull(connectionString); + return UseMongoUrl(new MongoUrl(connectionString), instrumentCommandText, shouldInstrument); + } + + /// + /// Further configure the existing instance of . + /// + /// An to further modify the instance. + /// + public virtual MongoDbContextOptionsBuilder ConfigureMongoClientSettings(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + // Ensure we have settings + if (!options.TryGetMongoClientSettings(out var settings)) + { + if (!options.TryGetMongoUrl(out var url)) + { + throw new InvalidOperationException("The connection string or URL must be configured before the settings are added"); + } + + settings = MongoClientSettings.FromUrl(url); + options.AddMetadata(settings); + } + + // Invoke the action + configure(settings); + return this; + } +} + +/// +/// Provides a simple API surface for configuring . +/// You can use to configure +/// a context by overriding +/// or creating a externally and passing +/// it to the context constructor. +/// +/// The type of context to be configured. +/// +/// Initializes a new instance of the class to further configure +/// a given . +/// +/// The options to be configured. +public class MongoDbContextOptionsBuilder(MongoDbContextOptions options) : MongoDbContextOptionsBuilder(options) where TContext : MongoDbContext +{ + /// + /// Initializes a new instance of the class with no options set. + /// + public MongoDbContextOptionsBuilder() : this(new MongoDbContextOptions()) { } + + /// + /// Gets the options being configured. + /// + public new virtual MongoDbContextOptions Options + => (MongoDbContextOptions)base.Options; + + /// + /// Sets the from which application services will be obtained. This + /// is done automatically when using 'AddDbContext', so it is rare that this method needs to be called. + /// + /// The service provider to be used. + /// The same builder instance so that multiple calls can be chained. + public new virtual MongoDbContextOptionsBuilder UseApplicationServiceProvider(IServiceProvider serviceProvider) + => (MongoDbContextOptionsBuilder)base.UseApplicationServiceProvider(serviceProvider); + + /// + /// Sets the to use when configuring the context. + /// + /// The to be used + /// + /// Whether the command text should be captured in instrumentation. + /// + /// + /// Delegate for determining if a should be instrumented. + /// + /// + public new virtual MongoDbContextOptionsBuilder UseMongoUrl(MongoUrl url, + bool instrumentCommandText = true, + Func? shouldInstrument = null) + => (MongoDbContextOptionsBuilder)base.UseMongoUrl(url, instrumentCommandText, shouldInstrument); + + /// + /// Sets the to use when configuring the context by + /// parsing the . + /// + /// + /// A valid Mongo db collection in the format + /// mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]] + /// e.g. mongodb://localhost:27017/myDatabase + /// + /// + /// Whether the command text should be captured in instrumentation. + /// + /// + /// Delegate for determining if a should be instrumented. + /// + /// + public new virtual MongoDbContextOptionsBuilder UseMongoConnectionString(string connectionString, + bool instrumentCommandText = true, + Func? shouldInstrument = null) + => (MongoDbContextOptionsBuilder)base.UseMongoConnectionString(connectionString, instrumentCommandText, shouldInstrument); + + /// + /// Further configure the existing instance of . + /// + /// An to further modify the instance. + /// + public new virtual MongoDbContextOptionsBuilder ConfigureMongoClientSettings(Action configure) + => (MongoDbContextOptionsBuilder)base.ConfigureMongoClientSettings(configure); +} diff --git a/src/Tingle.Extensions.MongoDB/MongoJsonSerializerContext.cs b/src/Tingle.Extensions.MongoDB/MongoJsonSerializerContext.cs new file mode 100644 index 00000000..256513cf --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/MongoJsonSerializerContext.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; +using System.Text.Json; + +namespace Tingle.Extensions.MongoDB; + +[JsonSerializable(typeof(JsonElement))] +internal partial class MongoJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Tingle.Extensions.MongoDB/MongoUpdateConcurrencyException.cs b/src/Tingle.Extensions.MongoDB/MongoUpdateConcurrencyException.cs new file mode 100644 index 00000000..36e00194 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/MongoUpdateConcurrencyException.cs @@ -0,0 +1,97 @@ +namespace MongoDB.Driver; + +/// +/// Exception thrown by Etag versions of Replace and Update +/// when it was expected that Replace/Update for a document would result in a database update +/// but in fact no documents in the database were affected. +/// This usually indicates that the database has been concurrently updated such that a concurrency token that was expected to match +/// did not actually match. +/// +[Serializable] +public class MongoUpdateConcurrencyException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public MongoUpdateConcurrencyException() { } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + public MongoUpdateConcurrencyException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + /// The inner exception. + public MongoUpdateConcurrencyException(string message, Exception inner) : base(message, inner) { } + + /// + /// Initializes a new instance of the class. + /// + /// Indicates if the database operation was acknowledged by the server. + public MongoUpdateConcurrencyException(bool acknowledged) : this(acknowledged, null, null, null) { } + + /// + /// Initializes a new instance of the class. + /// + /// Indicates if the database operation was acknowledged by the server. + /// Indicates the number of documents that were deleted. + public MongoUpdateConcurrencyException(bool acknowledged, long? deleted = null) + : this(acknowledged: acknowledged, matched: null, modified: null, deleted: deleted) { } + + /// + /// + /// + /// Indicates if the database operation was acknowledged by the server. + /// Indicates the number of documents that matched. + /// Indicated the number of documents that were modified. + /// Indicates the number of documents that were deleted. + public MongoUpdateConcurrencyException(bool acknowledged, long? matched = null, long? modified = null, long? deleted = null) + : this(MakeMessage(acknowledged, matched, modified, deleted)) + { + Acknowledged = acknowledged; + Matched = matched; + Modified = modified; + Deleted = deleted; + } + + /// + /// Indicates if the database operation was acknowledged by the server. + /// + /// + /// + /// + public bool? Acknowledged { get; private set; } + + /// + /// Indicates the number of documents that matched. + /// + /// + /// + public long? Matched { get; private set; } + + /// + /// Indicated the number of documents that were modified. + /// + /// + /// + public long? Modified { get; private set; } + + /// + /// Indicates the number of documents that were deleted. + /// + /// + public long? Deleted { get; private set; } + + private static string MakeMessage(bool acknowledged, long? matched, long? modified, long? deleted) + { + if (!acknowledged) return " Database update/delete operation was not acknowledged."; + + return deleted != null + ? "Database delete operation was acknowledged but no document was deleted." + : $"Database replace/update operation as acknowledged but no document was replaced/updated. Matched: {matched}, Modified: {modified}"; + } +} diff --git a/src/Tingle.Extensions.MongoDB/README.md b/src/Tingle.Extensions.MongoDB/README.md new file mode 100644 index 00000000..1350a275 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/README.md @@ -0,0 +1,64 @@ +# Tingle.Extensions.MongoDB + +> [!NOTE] The use of `MongoDbContext` will likely be replaced by EfCore support for Mongo [here](https://github.com/mongodb/mongo-efcore-provider). + +`MongoDB` is a cross-platform NoSQL database program which uses JSON like documents with schema. + +To connect to the database, we use the `MongoClient` class to access a `MongoDB` instance and through it, we can select the database we want to use. With the `MongoClient` instance we can drop a database, get a database, or retrieve names of the database from the server. There is no option for creating the database because once you pick/select a database and insert data into it, it automatically creates the database if it did not exist before. + +`MongoClient` has a constructor which can accept a connection string or URL as parameters. If the database name is specified in the connection string, a singleton is added to the services. If the database name is specified in the `MongoClient` constructor parameters, it overrides any database name specified in the connection string. + +This library provides extensions for working with `MongoDB`. It includes extension methods for `IServiceCollection` and for performing health checks on `MongoDB`. + +## Adding to services collection + +If a MongoDB client is created that takes a connection string as a parameter, the `GetConnectionString`() function is called which returns the connection string to the MongoDB. + +Also, a MongoDB database contains a collection to store data, To get a reference of the collection, it is required to call the `GetCollection`() function of the MongoDB database. The `GetCollection`() function takes a collection name as a parameter and returns a reference to the collection. + +Health checks that monitor the performance and functioning of MongoDB are also added to the services as well. + +In `Program.cs` add MongoDB services as shown in the code snippet below: + +```csharp +builder.Services.AddMongoDbContext(options => options.UseConnectionString(Configuration.GetConnectionString("Mongo"))); +``` + +## Configuration + +If MongoDB client is configured with a connection string, add the `ConnectionStrings` configuration to the configuration file such as appsettings.json as in the example below: + +```json +{ + "ConnectionStrings:Mongo":"#{ConnectionStringsMongo}#" +} +``` + +## Serializers + +|Source Types|BSON Destination Types| +|--|--| +|`System.Text.Json.Nodes.JsonObject`|`BsonDocument`| +|`System.Text.Json.Nodes.JsonArray`|`BsonArray`| +|`System.Text.Json.JsonElement`|`BsonDocument`, `BsonArray`, or value| +|`System.Net.IPNetwork` (.NET 8 or later)|`String`| +|`Tingle.Extensions.Primitives.Duration`|`String`| +|`Tingle.Extensions.Primitives.Etag`|`Int64`, `Binary` or `String`| +|`Tingle.Extensions.Primitives.SequenceNumber`|`Int64` or `String`| + +## Diagnostics + +Events for Mongo are produced on an `ActivitySource` named `MongoDB`. This is done by registering and instance of `IEventSubscriber` named `MongoDbDiagnosticEvents` to the `ClusterConfigurator`. However, this is done automatically when using `MongoDbContext`. + +## HealthChecks + +```cs +services.AddHealthChecks() + .AddMongoDbContextCheck(); +``` + +## Extensions + +A number of extensions for building indexes or performing operations on collections exist. +See extensions for building ascending/descending indexes [here](https://github.com/tinglesoftware/dotnet-extensions/blob/main/src/Tingle.Extensions.MongoDB/Extensions/BuildersExtensions.cs) +See extensions for bulk operations [here](https://github.com/tinglesoftware/dotnet-extensions/blob/main/src/Tingle.Extensions.MongoDB/Extensions/IMongoCollectionExtensions.cs) diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Conventions/DateTimeOffsetRepresentationConvention.cs b/src/Tingle.Extensions.MongoDB/Serialization/Conventions/DateTimeOffsetRepresentationConvention.cs new file mode 100644 index 00000000..2c7e50c3 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Conventions/DateTimeOffsetRepresentationConvention.cs @@ -0,0 +1,81 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using System.Reflection; + +namespace Tingle.Extensions.MongoDB.Serialization.Conventions; + +/// +/// A convention that allows you to set the DateTimeOffset serialization representation +/// +public class DateTimeOffsetRepresentationConvention : ConventionBase, IMemberMapConvention +{ + private readonly BsonType _representation; + + /// + /// Initializes a new instance of the class. + /// + /// The serialization representation. + public DateTimeOffsetRepresentationConvention(BsonType representation) + { + EnsureRepresentationIsValidForDateTimeOffsets(representation); + _representation = representation; + } + + /// Gets the representation. + public BsonType Representation => _representation; + + /// + public void Apply(BsonMemberMap memberMap) + { + var memberType = memberMap.MemberType; + var memberTypeInfo = memberType.GetTypeInfo(); + + if (memberTypeInfo == typeof(DateTimeOffset)) + { + var serializer = memberMap.GetSerializer(); + if (serializer is IRepresentationConfigurable representationConfigurableSerializer) + { + var reconfiguredSerializer = representationConfigurableSerializer.WithRepresentation(_representation); + memberMap.SetSerializer(reconfiguredSerializer); + } + return; + } + + if (IsNullableDateTimeOffset(memberType)) + { + var serializer = memberMap.GetSerializer(); + if (serializer is IChildSerializerConfigurable childSerializerConfigurableSerializer) + { + var childSerializer = childSerializerConfigurableSerializer.ChildSerializer; + if (childSerializer is IRepresentationConfigurable representationConfigurableChildSerializer) + { + var reconfiguredChildSerializer = representationConfigurableChildSerializer.WithRepresentation(_representation); + var reconfiguredSerializer = childSerializerConfigurableSerializer.WithChildSerializer(reconfiguredChildSerializer); + memberMap.SetSerializer(reconfiguredSerializer); + } + } + return; + } + } + + private static bool IsNullableDateTimeOffset(Type type) + { + return + type.GetTypeInfo().IsGenericType && + type.GetGenericTypeDefinition() == typeof(Nullable<>) && + Nullable.GetUnderlyingType(type)!.GetTypeInfo() == typeof(DateTimeOffset); + } + + private static void EnsureRepresentationIsValidForDateTimeOffsets(BsonType representation) + { + if (representation == BsonType.Array || + representation == BsonType.Document || + representation == BsonType.String || + representation == BsonType.DateTime) + { + return; + } + throw new ArgumentException("DateTimeOffsets can only be represented as Array, Document, String, or DateTime", nameof(representation)); + } +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Conventions/EtagRepresentationConvention.cs b/src/Tingle.Extensions.MongoDB/Serialization/Conventions/EtagRepresentationConvention.cs new file mode 100644 index 00000000..dd5fa3c4 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Conventions/EtagRepresentationConvention.cs @@ -0,0 +1,81 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using System.Reflection; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Serialization.Conventions; + +/// +/// A convention that allows you to set the Etag serialization representation +/// +public class EtagRepresentationConvention : ConventionBase, IMemberMapConvention +{ + private readonly BsonType _representation; + + /// + /// Initializes a new instance of the class. + /// + /// The serialization representation. + public EtagRepresentationConvention(BsonType representation) + { + EnsureRepresentationIsValidForEtags(representation); + _representation = representation; + } + + /// Gets the representation. + public BsonType Representation => _representation; + + /// + public void Apply(BsonMemberMap memberMap) + { + var memberType = memberMap.MemberType; + var memberTypeInfo = memberType.GetTypeInfo(); + + if (memberTypeInfo == typeof(Etag)) + { + var serializer = memberMap.GetSerializer(); + if (serializer is IRepresentationConfigurable representationConfigurableSerializer) + { + var reconfiguredSerializer = representationConfigurableSerializer.WithRepresentation(_representation); + memberMap.SetSerializer(reconfiguredSerializer); + } + return; + } + + if (IsNullableEtag(memberType)) + { + var serializer = memberMap.GetSerializer(); + if (serializer is IChildSerializerConfigurable childSerializerConfigurableSerializer) + { + var childSerializer = childSerializerConfigurableSerializer.ChildSerializer; + if (childSerializer is IRepresentationConfigurable representationConfigurableChildSerializer) + { + var reconfiguredChildSerializer = representationConfigurableChildSerializer.WithRepresentation(_representation); + var reconfiguredSerializer = childSerializerConfigurableSerializer.WithChildSerializer(reconfiguredChildSerializer); + memberMap.SetSerializer(reconfiguredSerializer); + } + } + return; + } + } + + private static bool IsNullableEtag(Type type) + { + return + type.GetTypeInfo().IsGenericType && + type.GetGenericTypeDefinition() == typeof(Nullable<>) && + Nullable.GetUnderlyingType(type)!.GetTypeInfo() == typeof(Etag); + } + + private static void EnsureRepresentationIsValidForEtags(BsonType representation) + { + if (representation == BsonType.Int64 || + representation == BsonType.String || + representation == BsonType.Binary) + { + return; + } + throw new ArgumentException("Etags can only be represented as Int64, String, or Binary", nameof(representation)); + } +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Conventions/SequenceNumberRepresentationConvention.cs b/src/Tingle.Extensions.MongoDB/Serialization/Conventions/SequenceNumberRepresentationConvention.cs new file mode 100644 index 00000000..eaee2b3e --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Conventions/SequenceNumberRepresentationConvention.cs @@ -0,0 +1,80 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using System.Reflection; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Serialization.Conventions; + +/// +/// A convention that allows you to set the SequenceNumber serialization representation +/// +public class SequenceNumberRepresentationConvention : ConventionBase, IMemberMapConvention +{ + private readonly BsonType _representation; + + /// + /// Initializes a new instance of the class. + /// + /// The serialization representation. + public SequenceNumberRepresentationConvention(BsonType representation) + { + EnsureRepresentationIsValidForSequenceNumbers(representation); + _representation = representation; + } + + /// Gets the representation. + public BsonType Representation => _representation; + + /// + public void Apply(BsonMemberMap memberMap) + { + var memberType = memberMap.MemberType; + var memberTypeInfo = memberType.GetTypeInfo(); + + if (memberTypeInfo == typeof(SequenceNumber)) + { + var serializer = memberMap.GetSerializer(); + if (serializer is IRepresentationConfigurable representationConfigurableSerializer) + { + var reconfiguredSerializer = representationConfigurableSerializer.WithRepresentation(_representation); + memberMap.SetSerializer(reconfiguredSerializer); + } + return; + } + + if (IsNullableSequenceNumber(memberType)) + { + var serializer = memberMap.GetSerializer(); + if (serializer is IChildSerializerConfigurable childSerializerConfigurableSerializer) + { + var childSerializer = childSerializerConfigurableSerializer.ChildSerializer; + if (childSerializer is IRepresentationConfigurable representationConfigurableChildSerializer) + { + var reconfiguredChildSerializer = representationConfigurableChildSerializer.WithRepresentation(_representation); + var reconfiguredSerializer = childSerializerConfigurableSerializer.WithChildSerializer(reconfiguredChildSerializer); + memberMap.SetSerializer(reconfiguredSerializer); + } + } + return; + } + } + + private static bool IsNullableSequenceNumber(Type type) + { + return + type.GetTypeInfo().IsGenericType && + type.GetGenericTypeDefinition() == typeof(Nullable<>) && + Nullable.GetUnderlyingType(type)!.GetTypeInfo() == typeof(SequenceNumber); + } + + private static void EnsureRepresentationIsValidForSequenceNumbers(BsonType representation) + { + if (representation == BsonType.Int64 || + representation == BsonType.String) + { + return; + } + throw new ArgumentException("SequenceNumbers can only be represented as Int64 or String", nameof(representation)); + } +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Conventions/SystemComponentModelAttributesConvention.cs b/src/Tingle.Extensions.MongoDB/Serialization/Conventions/SystemComponentModelAttributesConvention.cs new file mode 100644 index 00000000..f5f78b65 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Conventions/SystemComponentModelAttributesConvention.cs @@ -0,0 +1,54 @@ +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson.Serialization.Conventions; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; + +namespace Tingle.Extensions.MongoDB.Serialization.Conventions; + +/// +/// A convention that maps attributes in the System.ComponentModel.DataAnnotations +/// to equivalents in the MongoDB.Bson.Serialization.Attributes. +/// For example: +/// +/// instead of +/// instead of +/// +/// +public class SystemComponentModelAttributesConvention : ConventionBase, IClassMapConvention +{ + /// + public void Apply(BsonClassMap classMap) + { + // Handle KeyAttribute + if (classMap.IdMemberMap is null) + { + var flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.GetProperty | BindingFlags.SetProperty; +#pragma warning disable IL2075 // 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations. + var properties = classMap.ClassType.GetProperties(flags); +#pragma warning restore IL2075 // 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations. + var props = properties.Where(p => p.GetCustomAttribute() != null).ToList(); + if (props.Count > 1) + { + throw new InvalidOperationException("Cannot pull key from more than one property"); + } + + var target = props.SingleOrDefault(); + if (target is not null) + { + classMap.MapIdProperty(target.Name); + } + } + + // Handle NotMappedAttribute + foreach (var memberMap in classMap.DeclaredMemberMaps.ToList()) + { + var attr = memberMap.MemberInfo.GetCustomAttributes().FirstOrDefault(); + if (attr is not null) + { + classMap.UnmapMember(memberMap.MemberInfo); + } + } + } +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/JsonSerializationProvider.cs b/src/Tingle.Extensions.MongoDB/Serialization/JsonSerializationProvider.cs new file mode 100644 index 00000000..89be3c06 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/JsonSerializationProvider.cs @@ -0,0 +1,47 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using System.Reflection; +using Tingle.Extensions.MongoDB.Serialization.Serializers; + +namespace Tingle.Extensions.MongoDB.Serialization; + +/// +/// Provides serializers for System.Text.Json types. +/// +public class JsonSerializationProvider : BsonSerializationProviderBase +{ + private static readonly Dictionary __serializersTypes = new() + { + { typeof(System.Text.Json.JsonElement), typeof(JsonElementBsonSerializer) }, + { typeof(System.Text.Json.Nodes.JsonObject), typeof(JsonObjectBsonSerializer) }, + { typeof(System.Text.Json.Nodes.JsonNode), typeof(JsonNodeBsonSerializer) }, + }; + + /// + public override IBsonSerializer? GetSerializer(Type type, IBsonSerializerRegistry serializerRegistry) + { + ArgumentNullException.ThrowIfNull(type); + + var typeInfo = type.GetTypeInfo(); + if (typeInfo.IsGenericType && typeInfo.ContainsGenericParameters) + { + var message = string.Format("Generic type {0} has unassigned type parameters.", BsonUtils.GetFriendlyTypeName(type)); + throw new ArgumentException(message, nameof(type)); + } + + if (__serializersTypes.TryGetValue(type, out var serializerType)) + { + return CreateSerializer(serializerType, serializerRegistry); + } + + if (typeInfo.IsGenericType && !typeInfo.ContainsGenericParameters) + { + if (__serializersTypes.TryGetValue(type.GetGenericTypeDefinition(), out var serializerTypeDefinition)) + { + return CreateGenericSerializer(serializerTypeDefinition, typeInfo.GetGenericArguments(), serializerRegistry); + } + } + + return null; + } +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Serializers/DurationBsonSerializer.cs b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/DurationBsonSerializer.cs new file mode 100644 index 00000000..69964ea4 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/DurationBsonSerializer.cs @@ -0,0 +1,96 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Serialization.Serializers; + +/// Implementation of for +public class DurationBsonSerializer : StructSerializerBase, IRepresentationConfigurable +{ + // private fields + private readonly StringSerializer _stringSerializer = new(); + private readonly BsonType _representation; + + // constructors + /// + /// Initializes a new instance of the class. + /// + public DurationBsonSerializer() : this(BsonType.String) { } + + /// + /// Initializes a new instance of the class. + /// + /// The representation. + public DurationBsonSerializer(BsonType representation) + { + switch (representation) + { + case BsonType.String: + break; + + default: + var message = string.Format("{0} is not a valid representation for a DurationBsonSerializer.", representation); + throw new ArgumentException(message); + } + + _representation = representation; + } + + // public properties + /// + /// Gets the representation. + /// + /// + /// The representation. + /// + public BsonType Representation => _representation; + + /// + public override Duration Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var bsonType = context.Reader.CurrentBsonType; + return bsonType switch + { + BsonType.String => Duration.Parse(_stringSerializer.Deserialize(context)), + _ => throw CreateCannotDeserializeFromBsonTypeException(bsonType), + }; + } + + /// + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Duration value) + { + var bsonWriter = context.Writer; + + switch (_representation) + { + case BsonType.String: + bsonWriter.WriteString(value.ToString()); + break; + + default: + var message = string.Format("'{0}' is not a valid Duration representation.", _representation); + throw new BsonSerializationException(message); + } + } + + /// + public DurationBsonSerializer WithRepresentation(BsonType representation) + { + if (representation == _representation) + { + return this; + } + else + { + return new DurationBsonSerializer(representation); + } + } + + // explicit interface implementations + IBsonSerializer IRepresentationConfigurable.WithRepresentation(BsonType representation) + { + return WithRepresentation(representation); + } + +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Serializers/EtagBsonSerializer.cs b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/EtagBsonSerializer.cs new file mode 100644 index 00000000..2faad3c3 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/EtagBsonSerializer.cs @@ -0,0 +1,110 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Serialization.Serializers; + +/// Implementation of for +public class EtagBsonSerializer : StructSerializerBase, IRepresentationConfigurable +{ + // private fields + private readonly Int64Serializer _int64Serializer = new(); + private readonly StringSerializer _stringSerializer = new(); + private readonly ByteArraySerializer _byteArraySerializer = new(); + private readonly BsonType _representation; + + // constructors + /// + /// Initializes a new instance of the class. + /// + public EtagBsonSerializer() : this(BsonType.Int64) { } + + /// + /// Initializes a new instance of the class. + /// + /// The representation. + public EtagBsonSerializer(BsonType representation) + { + switch (representation) + { + case BsonType.Int64: + case BsonType.String: + case BsonType.Binary: + break; + + default: + var message = string.Format("{0} is not a valid representation for a EtagBsonSerializer.", representation); + throw new ArgumentException(message); + } + + _representation = representation; + } + + // public properties + /// + /// Gets the representation. + /// + /// + /// The representation. + /// + public BsonType Representation => _representation; + + /// + public override Etag Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var bsonType = context.Reader.CurrentBsonType; + return bsonType switch + { + BsonType.String => new Etag(_stringSerializer.Deserialize(context)), + BsonType.Int64 => new Etag((ulong)_int64Serializer.Deserialize(context)), + BsonType.Binary => new Etag(_byteArraySerializer.Deserialize(context)), + _ => throw CreateCannotDeserializeFromBsonTypeException(bsonType), + }; + } + + /// + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Etag value) + { + var bsonWriter = context.Writer; + + switch (_representation) + { + case BsonType.String: + bsonWriter.WriteString(value.ToString()); + break; + + case BsonType.Int64: + bsonWriter.WriteInt64((long)(ulong)value); + break; + + case BsonType.Binary: + bsonWriter.WriteBinaryData(value.ToByteArray()); + break; + + default: + var message = string.Format("'{0}' is not a valid Etag representation.", _representation); + throw new BsonSerializationException(message); + } + } + + /// + public EtagBsonSerializer WithRepresentation(BsonType representation) + { + if (representation == _representation) + { + return this; + } + else + { + return new EtagBsonSerializer(representation); + } + } + + // explicit interface implementations + IBsonSerializer IRepresentationConfigurable.WithRepresentation(BsonType representation) + { + return WithRepresentation(representation); + } + +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Serializers/IPNetworkBsonSerializer.cs b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/IPNetworkBsonSerializer.cs new file mode 100644 index 00000000..cd2866b0 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/IPNetworkBsonSerializer.cs @@ -0,0 +1,97 @@ +#if NET8_0_OR_GREATER +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using System.Net; + +namespace Tingle.Extensions.MongoDB.Serialization.Serializers; + +/// Implementation of for +public class IPNetworkBsonSerializer : StructSerializerBase, IRepresentationConfigurable +{ + // private fields + private readonly StringSerializer _stringSerializer = new(); + private readonly BsonType _representation; + + // constructors + /// + /// Initializes a new instance of the class. + /// + public IPNetworkBsonSerializer() : this(BsonType.String) { } + + /// + /// Initializes a new instance of the class. + /// + /// The representation. + public IPNetworkBsonSerializer(BsonType representation) + { + switch (representation) + { + case BsonType.String: + break; + + default: + var message = string.Format("{0} is not a valid representation for a IPNetworkBsonSerializer.", representation); + throw new ArgumentException(message); + } + + _representation = representation; + } + + // public properties + /// + /// Gets the representation. + /// + /// + /// The representation. + /// + public BsonType Representation => _representation; + + /// + public override IPNetwork Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var bsonType = context.Reader.CurrentBsonType; + return bsonType switch + { + BsonType.String => IPNetwork.Parse(_stringSerializer.Deserialize(context)), + _ => throw CreateCannotDeserializeFromBsonTypeException(bsonType), + }; + } + + /// + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, IPNetwork value) + { + var bsonWriter = context.Writer; + + switch (_representation) + { + case BsonType.String: + bsonWriter.WriteString(value.ToString()); + break; + + default: + var message = string.Format("'{0}' is not a valid IPNetwork representation.", _representation); + throw new BsonSerializationException(message); + } + } + + /// + public IPNetworkBsonSerializer WithRepresentation(BsonType representation) + { + if (representation == _representation) + { + return this; + } + else + { + return new IPNetworkBsonSerializer(representation); + } + } + + // explicit interface implementations + IBsonSerializer IRepresentationConfigurable.WithRepresentation(BsonType representation) + { + return WithRepresentation(representation); + } +} +#endif diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonElementBsonSerializer.cs b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonElementBsonSerializer.cs new file mode 100644 index 00000000..b5f6ee26 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonElementBsonSerializer.cs @@ -0,0 +1,72 @@ +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using System.Text.Json; +using SC = Tingle.Extensions.MongoDB.MongoJsonSerializerContext; + +namespace Tingle.Extensions.MongoDB.Serialization.Serializers; + +/// +/// Implementation of for +/// +internal class JsonElementBsonSerializer : StructSerializerBase +{ + /// + /// Create a JsonWriterSettings object to use when serializing BSON docs to JSON. + /// This will force the serializer to create valid ("strict") JSON. + /// Without this, ObjectIDs, Dates, and Integers are output as {"_id": ObjectId(ds8f7s9d87f89sd9f8d9f7sd9f9s8d)}, + /// {"date": ISODate("2020-04-14 14:30:00:000")}, {"int": NumberInt(130)} and respectively, which is not valid JSON + /// +#pragma warning disable CS0618 // Type or member is obsolete + internal static readonly JsonWriterSettings settings = new() { OutputMode = JsonOutputMode.Strict, }; +#pragma warning restore CS0618 // Type or member is obsolete + + /// + public override JsonElement Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + if (context.Reader.CurrentBsonType == BsonType.Array) + { + var barr = BsonSerializer.Deserialize(context.Reader); + return JsonSerializer.Deserialize(barr.ToJson(settings), SC.Default.JsonElement); + } + else if (context.Reader.CurrentBsonType is BsonType.Document or BsonType.EndOfDocument) + { + var bdoc = BsonDocumentSerializer.Instance.Deserialize(context); + return JsonSerializer.Deserialize(bdoc.ToJson(settings), SC.Default.JsonElement); + } + else + { + context.Reader.ReadNull(); + return default; + } + } + + /// + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, JsonElement value) + { + if (value.ValueKind != JsonValueKind.Undefined) + { + var json = value.GetRawText(); + if (value.ValueKind == JsonValueKind.Array) + { + var barr = BsonSerializer.Deserialize(json); + BsonSerializer.Serialize(context.Writer, barr/*, args: args*/); // passing args causes errors + } + else if (value.ValueKind == JsonValueKind.Object) + { + var bdoc = BsonDocument.Parse(json); + BsonSerializer.Serialize(context.Writer, bdoc/*, args: args*/); // passing args causes errors + } + else + { + var bdoc = BsonDocument.Parse(json); + BsonDocumentSerializer.Instance.Serialize(context, bdoc); + } + } + else + { + context.Writer.WriteNull(); + } + } +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonNodeBsonSerializer.cs b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonNodeBsonSerializer.cs new file mode 100644 index 00000000..5f9b12eb --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonNodeBsonSerializer.cs @@ -0,0 +1,62 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using System.Text.Json.Nodes; + +#nullable disable + +namespace Tingle.Extensions.MongoDB.Serialization.Serializers; + +/// +/// Implementation of for +/// +internal class JsonNodeBsonSerializer : SerializerBase +{ + /// + public override JsonNode Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + if (context.Reader.CurrentBsonType == BsonType.Array) + { + var barr = BsonSerializer.Deserialize(context.Reader); + return JsonNode.Parse(barr.ToJson(JsonElementBsonSerializer.settings))!; + } + else if (context.Reader.CurrentBsonType is BsonType.Document or BsonType.EndOfDocument) + { + var bdoc = BsonDocumentSerializer.Instance.Deserialize(context); + return JsonNode.Parse(bdoc.ToJson(JsonElementBsonSerializer.settings))!; + } + else + { + context.Reader.ReadNull(); + return null; + } + } + + /// + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, JsonNode value) + { + if (value != null) + { + var json = value.ToString(); + if (value is JsonArray) + { + var barr = BsonSerializer.Deserialize(json); + BsonSerializer.Serialize(context.Writer, barr/*, args: args*/); // passing args causes errors + } + else if (value is JsonObject) + { + var bdoc = BsonDocument.Parse(json); + BsonSerializer.Serialize(context.Writer, bdoc/*, args: args*/); // passing args causes errors + } + else + { + var bdoc = BsonDocument.Parse(json); + BsonDocumentSerializer.Instance.Serialize(context, bdoc); + } + } + else + { + context.Writer.WriteNull(); + } + } +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonObjectBsonSerializer.cs b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonObjectBsonSerializer.cs new file mode 100644 index 00000000..04a614ef --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/JsonObjectBsonSerializer.cs @@ -0,0 +1,43 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using System.Text.Json.Nodes; + +#nullable disable + +namespace Tingle.Extensions.MongoDB.Serialization.Serializers; + +/// +/// Implementation of for +/// +internal class JsonObjectBsonSerializer : SerializerBase +{ + /// + public override JsonObject Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + if (context.Reader.CurrentBsonType != BsonType.Null) + { + var bdoc = BsonDocumentSerializer.Instance.Deserialize(context); + return (JsonObject)JsonNode.Parse(bdoc.ToJson(JsonElementBsonSerializer.settings))!; + } + else + { + context.Reader.ReadNull(); + return null; + } + } + + /// + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, JsonObject value) + { + if (value != null) + { + var bdoc = BsonDocument.Parse(value.ToString()); + BsonDocumentSerializer.Instance.Serialize(context, bdoc); + } + else + { + context.Writer.WriteNull(); + } + } +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/Serializers/SequenceNumberBsonSerializer.cs b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/SequenceNumberBsonSerializer.cs new file mode 100644 index 00000000..182ef306 --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/Serializers/SequenceNumberBsonSerializer.cs @@ -0,0 +1,103 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Serialization.Serializers; + +/// Implementation of for +public class SequenceNumberBsonSerializer : StructSerializerBase, IRepresentationConfigurable +{ + // private fields + private readonly Int64Serializer _int64Serializer = new(); + private readonly StringSerializer _stringSerializer = new(); + private readonly BsonType _representation; + + // constructors + /// + /// Initializes a new instance of the class. + /// + public SequenceNumberBsonSerializer() : this(BsonType.Int64) { } + + /// + /// Initializes a new instance of the class. + /// + /// The representation. + public SequenceNumberBsonSerializer(BsonType representation) + { + switch (representation) + { + case BsonType.Int64: + case BsonType.String: + break; + + default: + var message = string.Format("{0} is not a valid representation for a SequenceNumberBsonSerializer.", representation); + throw new ArgumentException(message); + } + + _representation = representation; + } + + // public properties + /// + /// Gets the representation. + /// + /// + /// The representation. + /// + public BsonType Representation => _representation; + + /// + public override SequenceNumber Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var bsonType = context.Reader.CurrentBsonType; + return bsonType switch + { + BsonType.String => new SequenceNumber(long.Parse(_stringSerializer.Deserialize(context))), + BsonType.Int64 => new SequenceNumber(_int64Serializer.Deserialize(context)), + _ => throw CreateCannotDeserializeFromBsonTypeException(bsonType), + }; + } + + /// + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, SequenceNumber value) + { + var bsonWriter = context.Writer; + + switch (_representation) + { + case BsonType.String: + bsonWriter.WriteString(value.ToString()); + break; + + case BsonType.Int64: + bsonWriter.WriteInt64(value); + break; + + default: + var message = string.Format("'{0}' is not a valid SequenceNumber representation.", _representation); + throw new BsonSerializationException(message); + } + } + + /// + public SequenceNumberBsonSerializer WithRepresentation(BsonType representation) + { + if (representation == _representation) + { + return this; + } + else + { + return new SequenceNumberBsonSerializer(representation); + } + } + + // explicit interface implementations + IBsonSerializer IRepresentationConfigurable.WithRepresentation(BsonType representation) + { + return WithRepresentation(representation); + } + +} diff --git a/src/Tingle.Extensions.MongoDB/Serialization/TingleSerializationProvider.cs b/src/Tingle.Extensions.MongoDB/Serialization/TingleSerializationProvider.cs new file mode 100644 index 00000000..8b7158be --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Serialization/TingleSerializationProvider.cs @@ -0,0 +1,52 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using System.Net; +using System.Reflection; +using Tingle.Extensions.MongoDB.Serialization.Serializers; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Serialization; + +/// +/// Provides serializers for Tingle types. +/// +public class TingleSerializationProvider : BsonSerializationProviderBase +{ + private static readonly Dictionary __serializersTypes = new() + { +#if NET8_0_OR_GREATER + { typeof(IPNetwork), typeof(IPNetworkBsonSerializer) }, +#endif + { typeof(Duration), typeof(DurationBsonSerializer) }, + { typeof(Etag), typeof(EtagBsonSerializer) }, + { typeof(SequenceNumber), typeof(SequenceNumberBsonSerializer) } + }; + + /// + public override IBsonSerializer? GetSerializer(Type type, IBsonSerializerRegistry serializerRegistry) + { + ArgumentNullException.ThrowIfNull(type); + + var typeInfo = type.GetTypeInfo(); + if (typeInfo.IsGenericType && typeInfo.ContainsGenericParameters) + { + var message = string.Format("Generic type {0} has unassigned type parameters.", BsonUtils.GetFriendlyTypeName(type)); + throw new ArgumentException(message, nameof(type)); + } + + if (__serializersTypes.TryGetValue(type, out var serializerType)) + { + return CreateSerializer(serializerType, serializerRegistry); + } + + if (typeInfo.IsGenericType && !typeInfo.ContainsGenericParameters) + { + if (__serializersTypes.TryGetValue(type.GetGenericTypeDefinition(), out var serializerTypeDefinition)) + { + return CreateGenericSerializer(serializerTypeDefinition, typeInfo.GetGenericArguments(), serializerRegistry); + } + } + + return null; + } +} diff --git a/src/Tingle.Extensions.MongoDB/Tingle.Extensions.MongoDB.csproj b/src/Tingle.Extensions.MongoDB/Tingle.Extensions.MongoDB.csproj new file mode 100644 index 00000000..9ee2a6fa --- /dev/null +++ b/src/Tingle.Extensions.MongoDB/Tingle.Extensions.MongoDB.csproj @@ -0,0 +1,21 @@ + + + + Extensions for working with MongoDB + net7.0;net8.0 + + + + + + + + + + + + + + + + diff --git a/tests/Tingle.Extensions.MongoDB.Tests/MongoDbContextTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/MongoDbContextTests.cs new file mode 100644 index 00000000..ef1ba6dc --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/MongoDbContextTests.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; +using Xunit.Abstractions; + +namespace Tingle.Extensions.MongoDB.Tests; + +public class MongoDbContextTests(ITestOutputHelper outputHelper) +{ + [Fact] + public void CanBeResolvedViaDI() + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + + services.AddMongoDbContext(options => + { + var dbName = Guid.NewGuid().ToString().Replace("_", "")[..10]; + var connectionString = $"mongodb://localhost:27017/{dbName}"; + options.UseMongoConnectionString(connectionString); + }); + + var sp = services.BuildServiceProvider(validateScopes: true); + + // resolving directly + using var scope = sp.CreateScope(); + var provider = scope.ServiceProvider; + + var context = provider.GetRequiredService(); + Assert.NotNull(context.Persons); + } + + public class Person + { + [BsonId] + public string? Id { get; set; } + + public string? Name { get; set; } + } + + public class SampleDbContext(MongoDbContextOptions options) : MongoDbContext(options) + { + public IMongoCollection Persons => Collection("Persons"); + + protected internal override void OnConfiguring(MongoDbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + } + } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/MongoDbDiagnosticEventsTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/MongoDbDiagnosticEventsTests.cs new file mode 100644 index 00000000..30b89d90 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/MongoDbDiagnosticEventsTests.cs @@ -0,0 +1,329 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDB.Driver.Core.Clusters; +using MongoDB.Driver.Core.Connections; +using MongoDB.Driver.Core.Events; +using MongoDB.Driver.Core.Servers; +using System.Diagnostics; +using System.Net; +using Tingle.Extensions.MongoDB.Diagnostics; + +namespace Tingle.Extensions.MongoDB.Tests; + +public class MongoDbDiagnosticEventsTests +{ + static MongoDbDiagnosticEventsTests() + { + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + Activity.ForceDefaultIdFormat = true; + } + + [Fact] + public void NoActivityCreatedWhenNoListenerIsAttached() + { + var startFired = false; + var stopFired = false; + + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Nonsense", + ActivityStarted = _ => startFired = true, + ActivityStopped = _ => stopFired = true + }; + + ActivitySource.AddActivityListener(listener); + + var behavior = new MongoDbDiagnosticEvents(); + + Assert.True(behavior.TryGetEventHandler(out var startEvent)); + Assert.True(behavior.TryGetEventHandler(out var stopEvent)); + + startEvent(new CommandStartedEvent()); + stopEvent(new CommandSucceededEvent()); + + Assert.False(startFired); + Assert.False(stopFired); + } + + [Fact] + public void ActivityStartedAndStoppedWhenSampling() + { + var startFired = false; + var stopFired = false; + + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Tingle.Extensions.MongoDB", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.PropagationData, + ActivityStarted = _ => startFired = true, + ActivityStopped = _ => stopFired = true + }; + ActivitySource.AddActivityListener(listener); + + var behavior = new MongoDbDiagnosticEvents(); + + Assert.True(behavior.TryGetEventHandler(out var startEvent)); + Assert.True(behavior.TryGetEventHandler(out var stopEvent)); + + startEvent(new CommandStartedEvent()); + stopEvent(new CommandSucceededEvent()); + + Assert.True(startFired); + Assert.True(stopFired); + Assert.Null(Activity.Current); + } + + [Fact] + public void StartsAndLogsSuccessfulActivity() + { + var stopFired = false; + var startFired = false; + + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Tingle.Extensions.MongoDB", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.PropagationData, + ActivityStarted = activity => + { + startFired = true; + Assert.NotNull(activity); + Assert.Equal("MongoDB", Activity.Current?.OperationName); + }, + ActivityStopped = activity => + { + stopFired = true; + Assert.NotNull(activity); + Assert.Equal("MongoDB", Activity.Current?.OperationName); + } + }; + ActivitySource.AddActivityListener(listener); + + var behavior = new MongoDbDiagnosticEvents(); + + Assert.True(behavior.TryGetEventHandler(out var startEvent)); + Assert.True(behavior.TryGetEventHandler(out var stopEvent)); + + startEvent(new CommandStartedEvent()); + stopEvent(new CommandSucceededEvent()); + + Assert.True(startFired); + Assert.True(stopFired); + Assert.Null(Activity.Current); + } + + [Fact] + public void StartsAndLogsFailedActivity() + { + var exceptionFired = false; + var startFired = false; + + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Tingle.Extensions.MongoDB", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = activity => + { + startFired = true; + Assert.NotNull(activity); + Assert.Equal("MongoDB", Activity.Current?.OperationName); + }, + ActivityStopped = activity => + { + exceptionFired = true; + Assert.NotNull(activity); + Assert.Equal("MongoDB", Activity.Current?.OperationName); + var statusTag = activity.Tags.SingleOrDefault(t => t.Key == "otel.status_code"); + Assert.NotEqual(default, statusTag); + Assert.Equal("Error", statusTag.Value); + } + }; + ActivitySource.AddActivityListener(listener); + + var behavior = new MongoDbDiagnosticEvents(); + + Assert.True(behavior.TryGetEventHandler(out var startEvent)); + Assert.True(behavior.TryGetEventHandler(out var stopEvent)); + + var connectionId = new ConnectionId(new ServerId(new ClusterId(), new DnsEndPoint("localhost", 8000))); + var databaseNamespace = new DatabaseNamespace("test"); + var command = new BsonDocument(new Dictionary + { + {"update", "my_collection"} + }); + startEvent(new CommandStartedEvent("update", command, databaseNamespace, null, 1, connectionId)); + stopEvent(new CommandFailedEvent("update", databaseNamespace, new Exception("Failed"), null, 1, connectionId, TimeSpan.Zero)); + + Assert.True(startFired); + Assert.True(exceptionFired); + Assert.Null(Activity.Current); + } + + [Fact] + public void RecordsAllData() + { + var stopFired = false; + var startFired = false; + + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Tingle.Extensions.MongoDB", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = activity => + { + startFired = true; + Assert.NotNull(activity); + }, + ActivityStopped = activity => + { + stopFired = true; + Assert.NotNull(activity); + Assert.Equal("MongoDB", Activity.Current?.OperationName); + var instanceTag = activity.Tags.SingleOrDefault(t => t.Key == "db.name"); + Assert.NotEqual(default, instanceTag); + Assert.Equal("test", instanceTag.Value); + + Assert.Equal("mongodb", activity.Tags.SingleOrDefault(t => t.Key == "db.system").Value); + Assert.Equal("update", activity.Tags.SingleOrDefault(t => t.Key == "db.operation").Value); + Assert.Equal(default, activity.Tags.SingleOrDefault(t => t.Key == "db.statement").Value); + } + }; + ActivitySource.AddActivityListener(listener); + + var behavior = new MongoDbDiagnosticEvents(captureCommandText: false); + + Assert.True(behavior.TryGetEventHandler(out var startEvent)); + Assert.True(behavior.TryGetEventHandler(out var stopEvent)); + + var connectionId = new ConnectionId(new ServerId(new ClusterId(), new DnsEndPoint("localhost", 8000))); + var databaseNamespace = new DatabaseNamespace("test"); + var command = new BsonDocument(new Dictionary + { + {"update", "my_collection"} + }); + startEvent(new CommandStartedEvent("update", command, databaseNamespace, null, 1, connectionId)); + stopEvent(new CommandSucceededEvent("update", command, databaseNamespace, null, 1, connectionId, TimeSpan.Zero)); + + Assert.True(startFired); + Assert.True(stopFired); + } + + [Fact] + public void RecordsCommandTextWhenOptionIsSet() + { + var stopFired = false; + var startFired = false; + + var command = new BsonDocument(new Dictionary + { + {"update", "my_collection"} + }); + + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Tingle.Extensions.MongoDB", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = activity => + { + startFired = true; + Assert.NotNull(activity); + }, + ActivityStopped = activity => + { + Assert.NotNull(activity); + Assert.Equal("MongoDB", Activity.Current?.OperationName); + var statementTag = activity.Tags.SingleOrDefault(t => t.Key == "db.statement"); + Assert.NotEqual(default, statementTag); + Assert.Equal(command.ToString(), statementTag.Value); + + stopFired = true; + } + }; + ActivitySource.AddActivityListener(listener); + + var behavior = new MongoDbDiagnosticEvents(captureCommandText: true); + + Assert.True(behavior.TryGetEventHandler(out var startEvent)); + Assert.True(behavior.TryGetEventHandler(out var stopEvent)); + + var connectionId = new ConnectionId(new ServerId(new ClusterId(), new DnsEndPoint("localhost", 8000))); + var databaseNamespace = new DatabaseNamespace("test"); + startEvent(new CommandStartedEvent("update", command, databaseNamespace, null, 1, connectionId)); + stopEvent(new CommandSucceededEvent("update", command, databaseNamespace, null, 1, connectionId, TimeSpan.Zero)); + + Assert.True(startFired); + Assert.True(stopFired); + } + + [Fact] + public void WorksWithParallelActivities() + { + var activities = new List(); + + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Tingle.Extensions.MongoDB", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = _ => activities.Add(Activity.Current), + ActivityStopped = _ => activities.Add(Activity.Current) + }; + ActivitySource.AddActivityListener(listener); + + var behavior = new MongoDbDiagnosticEvents(); + + Assert.True(behavior.TryGetEventHandler(out var startEvent)); + Assert.True(behavior.TryGetEventHandler(out var stopEvent)); + + var outerActivity = new Activity("Outer"); + outerActivity.Start(); + + var connectionId = new ConnectionId(new ServerId(new ClusterId(), new DnsEndPoint("localhost", 8000))); + var databaseNamespace = new DatabaseNamespace("test"); + var updateCommand = new BsonDocument(new Dictionary + { + {"update", "my_collection"} + }); + var insertCommand = new BsonDocument(new Dictionary + { + {"insert", "my_collection"} + }); + startEvent(new CommandStartedEvent("update", updateCommand, databaseNamespace, null, 1, connectionId)); + startEvent(new CommandStartedEvent("insert", insertCommand, databaseNamespace, null, 2, connectionId)); + stopEvent(new CommandSucceededEvent("update", updateCommand, databaseNamespace, null, 1, connectionId, TimeSpan.Zero)); + stopEvent(new CommandSucceededEvent("insert", insertCommand, databaseNamespace, null, 2, connectionId, TimeSpan.Zero)); + + outerActivity.Stop(); + + Assert.Equal(4, activities.Count); + Assert.Equal(4, activities.Count(a => a != null && a.OperationName == "MongoDB")); + Assert.Null(Activity.Current); + } + + [Theory] + [InlineData(null, true)] + [InlineData(true, true)] + [InlineData(false, false)] + public void ShouldStartActivityIsRespected(bool? filterResult, bool shouldFireActivity) + { + var activities = new List(); + + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Tingle.Extensions.MongoDB", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.PropagationData, + ActivityStarted = _ => activities.Add(Activity.Current) + }; + ActivitySource.AddActivityListener(listener); + + Func? filter = filterResult == null ? null : x => filterResult.Value; + + var behavior = new MongoDbDiagnosticEvents(shouldStartActivity: filter); + + Assert.True(behavior.TryGetEventHandler(out var startEvent)); + Assert.True(behavior.TryGetEventHandler(out var stopEvent)); + + startEvent(new CommandStartedEvent()); + stopEvent(new CommandSucceededEvent()); + + Assert.Equal(shouldFireActivity ? 1 : 0, activities.Count); + } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/DateTimeOffsetRepresentationConventionTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/DateTimeOffsetRepresentationConventionTests.cs new file mode 100644 index 00000000..971695a4 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/DateTimeOffsetRepresentationConventionTests.cs @@ -0,0 +1,89 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using Tingle.Extensions.MongoDB.Serialization.Conventions; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Conventions; + +public class DateTimeOffsetRepresentationConventionTests +{ + [Theory] + [InlineData(BsonType.Array)] + [InlineData(BsonType.Document)] + [InlineData(BsonType.DateTime)] + [InlineData(BsonType.String)] + public void Convention_Is_Used_When_Memeber_Is_A_DateTimeOffset(BsonType representation) + { + var subject = new DateTimeOffsetRepresentationConvention(representation); + var classMap = new BsonClassMap(); + var memberMap = classMap.MapMember(b => b.Created); + + subject.Apply(memberMap); + + var serializer = (DateTimeOffsetSerializer)memberMap.GetSerializer(); + Assert.Equal(representation, serializer.Representation); + } + + [Theory] + [InlineData(BsonType.Array)] + [InlineData(BsonType.Document)] + [InlineData(BsonType.DateTime)] + [InlineData(BsonType.String)] + public void Convention_Is_Used_When_Memeber_Is_A_Nullable_DateTimeOffset(BsonType representation) + { + var subject = new DateTimeOffsetRepresentationConvention(representation); + var classMap = new BsonClassMap(); + var memberMap = classMap.MapMember(b => b.Closed); + + subject.Apply(memberMap); + + var serializer = (IChildSerializerConfigurable)memberMap.GetSerializer(); + var childSerializer = (DateTimeOffsetSerializer)serializer.ChildSerializer; + Assert.Equal(representation, childSerializer.Representation); + } + + [Fact] + public void Convention_Is_Not_Used_When_Memeber_Is_Not_A_DateTimeOffset() + { + var subject = new DateTimeOffsetRepresentationConvention(BsonType.Array); + var classMap = new BsonClassMap(); + var memberMap = classMap.MapMember(b => b.Created); + + var serializer = memberMap.GetSerializer(); + + subject.Apply(memberMap); + + Assert.Equal(serializer, memberMap.GetSerializer()); + } + + [Theory] + [InlineData(BsonType.Array)] + [InlineData(BsonType.Document)] + [InlineData(BsonType.DateTime)] + [InlineData(BsonType.String)] + public void Constructor_Should_Initialize_Instance_When_Representation_Is_Valid(BsonType representation) + { + var subject = new DateTimeOffsetRepresentationConvention(representation); + + Assert.Equal(representation, subject.Representation); + } + + [Theory] + [InlineData(BsonType.Decimal128)] + [InlineData(BsonType.Double)] + public void Constructor_Should_Throw_When_Representation_Is_Not_Valid(BsonType representation) + { + var exception = Assert.Throws(() => new DateTimeOffsetRepresentationConvention(representation)); + + Assert.Equal("representation", exception.ParamName); + } + + + class Bookshop + { + public string? Id { get; set; } + public DateTimeOffset Created { get; set; } + public DateTimeOffset? Closed { get; set; } + public int Count { get; set; } + } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/EtagRepresentationConventionTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/EtagRepresentationConventionTests.cs new file mode 100644 index 00000000..c97baa2b --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/EtagRepresentationConventionTests.cs @@ -0,0 +1,90 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using Tingle.Extensions.MongoDB.Serialization; +using Tingle.Extensions.MongoDB.Serialization.Conventions; +using Tingle.Extensions.MongoDB.Serialization.Serializers; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Conventions; + +public class EtagRepresentationConventionTests +{ + static EtagRepresentationConventionTests() => BsonSerializer.RegisterSerializationProvider(new TingleSerializationProvider()); + + [Theory] + [InlineData(BsonType.Int64)] + [InlineData(BsonType.String)] + [InlineData(BsonType.Binary)] + public void Convention_Is_Used_When_Memeber_Is_An_Etag(BsonType representation) + { + var subject = new EtagRepresentationConvention(representation); + var classMap = new BsonClassMap(); + var memberMap = classMap.MapMember(b => b.First); + + subject.Apply(memberMap); + + var serializer = (EtagBsonSerializer)memberMap.GetSerializer(); + Assert.Equal(representation, serializer.Representation); + } + + [Theory] + [InlineData(BsonType.Int64)] + [InlineData(BsonType.String)] + [InlineData(BsonType.Binary)] + public void Convention_Is_Used_When_Memeber_Is_A_Nullable_Etag(BsonType representation) + { + var subject = new EtagRepresentationConvention(representation); + var classMap = new BsonClassMap(); + var memberMap = classMap.MapMember(b => b.Second); + + subject.Apply(memberMap); + + var serializer = (IChildSerializerConfigurable)memberMap.GetSerializer(); + var childSerializer = (EtagBsonSerializer)serializer.ChildSerializer; + Assert.Equal(representation, childSerializer.Representation); + } + + [Fact] + public void Convention_Is_Not_Used_When_Memeber_Is_Not_An_Etag() + { + var subject = new EtagRepresentationConvention(BsonType.Int64); + var classMap = new BsonClassMap(); + var memberMap = classMap.MapMember(b => b.First); + + var serializer = memberMap.GetSerializer(); + + subject.Apply(memberMap); + + Assert.Equal(serializer, memberMap.GetSerializer()); + } + + [Theory] + [InlineData(BsonType.Int64)] + [InlineData(BsonType.String)] + [InlineData(BsonType.Binary)] + public void Constructor_Should_Initialize_Instance_When_Representation_Is_Valid(BsonType representation) + { + var subject = new EtagRepresentationConvention(representation); + + Assert.Equal(representation, subject.Representation); + } + + [Theory] + [InlineData(BsonType.Decimal128)] + [InlineData(BsonType.Double)] + public void Constructor_Should_Throw_When_Representation_Is_Not_Valid(BsonType representation) + { + var exception = Assert.Throws(() => new EtagRepresentationConvention(representation)); + + Assert.Equal("representation", exception.ParamName); + } + + + class Bookshop + { + public string? Id { get; set; } + public Etag First { get; set; } + public Etag? Second { get; set; } + public int Count { get; set; } + } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/SequenceNumberRepresentationConventionTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/SequenceNumberRepresentationConventionTests.cs new file mode 100644 index 00000000..216b8ad3 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/SequenceNumberRepresentationConventionTests.cs @@ -0,0 +1,87 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using Tingle.Extensions.MongoDB.Serialization; +using Tingle.Extensions.MongoDB.Serialization.Conventions; +using Tingle.Extensions.MongoDB.Serialization.Serializers; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Conventions; + +public class SequenceNumberRepresentationConventionTests +{ + static SequenceNumberRepresentationConventionTests() => BsonSerializer.RegisterSerializationProvider(new TingleSerializationProvider()); + + [Theory] + [InlineData(BsonType.Int64)] + [InlineData(BsonType.String)] + public void Convention_Is_Used_When_Memeber_Is_A_SequenceNumber(BsonType representation) + { + var subject = new SequenceNumberRepresentationConvention(representation); + var classMap = new BsonClassMap(); + var memberMap = classMap.MapMember(b => b.First); + + subject.Apply(memberMap); + + var serializer = (SequenceNumberBsonSerializer)memberMap.GetSerializer(); + Assert.Equal(representation, serializer.Representation); + } + + [Theory] + [InlineData(BsonType.Int64)] + [InlineData(BsonType.String)] + public void Convention_Is_Used_When_Memeber_Is_A_Nullable_SequenceNumber(BsonType representation) + { + var subject = new SequenceNumberRepresentationConvention(representation); + var classMap = new BsonClassMap(); + var memberMap = classMap.MapMember(b => b.Second); + + subject.Apply(memberMap); + + var serializer = (IChildSerializerConfigurable)memberMap.GetSerializer(); + var childSerializer = (SequenceNumberBsonSerializer)serializer.ChildSerializer; + Assert.Equal(representation, childSerializer.Representation); + } + + [Fact] + public void Convention_Is_Not_Used_When_Memeber_Is_Not_A_SequenceNumber() + { + var subject = new SequenceNumberRepresentationConvention(BsonType.Int64); + var classMap = new BsonClassMap(); + var memberMap = classMap.MapMember(b => b.First); + + var serializer = memberMap.GetSerializer(); + + subject.Apply(memberMap); + + Assert.Equal(serializer, memberMap.GetSerializer()); + } + + [Theory] + [InlineData(BsonType.Int64)] + [InlineData(BsonType.String)] + public void Constructor_Should_Initialize_Instance_When_Representation_Is_Valid(BsonType representation) + { + var subject = new SequenceNumberRepresentationConvention(representation); + + Assert.Equal(representation, subject.Representation); + } + + [Theory] + [InlineData(BsonType.Decimal128)] + [InlineData(BsonType.Double)] + public void Constructor_Should_Throw_When_Representation_Is_Not_Valid(BsonType representation) + { + var exception = Assert.Throws(() => new SequenceNumberRepresentationConvention(representation)); + + Assert.Equal("representation", exception.ParamName); + } + + + class Bookshop + { + public string? Id { get; set; } + public SequenceNumber First { get; set; } + public SequenceNumber? Second { get; set; } + public int Count { get; set; } + } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/SystemComponentModelAttributesConventionTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/SystemComponentModelAttributesConventionTests.cs new file mode 100644 index 00000000..cbf39979 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Conventions/SystemComponentModelAttributesConventionTests.cs @@ -0,0 +1,60 @@ +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Tingle.Extensions.MongoDB.Serialization.Conventions; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Conventions; + +public class SystemComponentModelAttributesConventionTests +{ + [Fact] + public void TestApply() + { + var subject = new SystemComponentModelAttributesConvention(); + var classMap = new BsonClassMap(); + + classMap.AutoMap(); + + Assert.Null(classMap.IdMemberMap); + subject.Apply(classMap); + Assert.NotNull(classMap.IdMemberMap); + Assert.Equal(typeof(string), classMap.IdMemberMap.MemberType); + Assert.Equal("SomeKey", classMap.IdMemberMap.ElementName); + + Assert.Equal(["SomeKey", "NormalValue"], + classMap.DeclaredMemberMaps.Select(m => m.MemberName)); + } + + //[Fact] + //public void Serialization_Works() + //{ + // ConventionRegistry.Register("Test", TestConventionPack.Instance, _ => true); + + // var json = $"{{ '_id' : 'cake', 'NormalValue' : NumberLong(2) }}".Replace("'", "\""); + // var result = BsonSerializer.Deserialize(json); + // Assert.Equal("cake", result.SomeKey); + // var bson = result.ToBson(); + // Assert.Equal(json, result.ToJson()); + //} + + private class TestClass + { + [Key] + public string? SomeKey { get; set; } + + [NotMapped] + public int SomeValue { get; set; } + + public long NormalValue { get; set; } + } + + private class TestConventionPack : IConventionPack + { + private static readonly IConventionPack __defaultConventionPack = new TestConventionPack(); + private readonly IEnumerable _conventions = new List { new SystemComponentModelAttributesConvention(), }; + + public static IConventionPack Instance => __defaultConventionPack; + public IEnumerable Conventions => _conventions; + } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/DurationBsonSerializerTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/DurationBsonSerializerTests.cs new file mode 100644 index 00000000..838f1931 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/DurationBsonSerializerTests.cs @@ -0,0 +1,28 @@ +using MongoDB.Bson.Serialization; +using Tingle.Extensions.MongoDB.Serialization; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Serializers; + +public class DurationBsonSerializerTests +{ + static DurationBsonSerializerTests() => BsonSerializer.RegisterSerializationProvider(new TingleSerializationProvider()); + + [Theory] + [InlineData("P0D")] + [InlineData("P3M")] + [InlineData("P1Y2M3W4DT5H6M7S")] + public void Deserialize_Works_For_String(string val) + { + var json = $"{{ '_id' : 'cake', 'Duration' : '{val}' }}".Replace("'", "\""); + var result = BsonSerializer.Deserialize(json); + Assert.Equal("cake", result.Id); + Assert.Equal(Duration.Parse(val), result.Duration); + } + + class Bookshop + { + public string? Id { get; set; } + public Duration Duration { get; set; } + } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/EtagBsonSerializerTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/EtagBsonSerializerTests.cs new file mode 100644 index 00000000..4b65732b --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/EtagBsonSerializerTests.cs @@ -0,0 +1,92 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Attributes; +using Tingle.Extensions.MongoDB.Serialization; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Serializers; + +public class EtagBsonSerializerTests +{ + static EtagBsonSerializerTests() => BsonSerializer.RegisterSerializationProvider(new TingleSerializationProvider()); + + [Theory] + [InlineData(0UL)] + [InlineData(123456789UL)] + public void Deserialize_Works_For_String(ulong val) + { + var json = $"{{ '_id' : 'cake', 'Etag' : '{Convert.ToBase64String(BitConverter.GetBytes(val))}' }}".Replace("'", "\""); + var result = BsonSerializer.Deserialize(json); + Assert.Equal("cake", result.Id); + Assert.Equal(val, result.Etag); + } + + [Theory] + [InlineData(0UL)] + [InlineData(123456789UL)] + public void Deserialize_Works_For_Int64(ulong val) + { + var json = $"{{ '_id' : 'cake', 'Etag' : NumberLong({val}) }}".Replace("'", "\""); + var result = BsonSerializer.Deserialize(json); + Assert.Equal("cake", result.Id); + Assert.Equal(val, result.Etag); + } + + [Theory] + [InlineData(0UL)] + [InlineData(123456789UL)] + public void Deserialize_Works_For_Binary(ulong val) + { + var json = $"{{ '_id' : 'cake', 'Etag' : BinData(0, '{Convert.ToBase64String(BitConverter.GetBytes(val))}') }}".Replace("'", "\""); + var result = BsonSerializer.Deserialize(json); + Assert.Equal("cake", result.Id); + Assert.Equal(val, result.Etag); + } + + [Fact] + public void Default_BsonRepresentation_IsUsed() + { + var obj = new Bookshop + { + Id = "cake", + Etag = new Etag(123456789UL) + }; + var json = obj.ToJson(); + var expected = $"{{ '_id' : 'cake', 'Etag' : NumberLong(123456789) }}".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + Assert.Equal(123456789UL, rehydrated.Etag); + } + + [Theory] + [InlineData(123456789UL, typeof(BookshopString), "'Fc1bBwAAAAA='")] + [InlineData(123456789UL, typeof(BookshopInt64), "NumberLong(123456789)")] + [InlineData(123456789UL, typeof(BookshopBinary), "new BinData(0, 'Fc1bBwAAAAA=')")] + public void BsonRepresentation_Is_Respected(ulong val, Type t, string bsonRaw) + { + var obj = (IBookshop)Activator.CreateInstance(t)!; + obj.Etag = new Etag(val); + var json = obj.ToJson(t); + var expected = $"{{ 'Etag' : {bsonRaw} }}".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(t); + var rehydrated = (IBookshop)BsonSerializer.Deserialize(bson, t); + Assert.True(bson.SequenceEqual(rehydrated.ToBson(t))); + Assert.Equal(val, rehydrated.Etag); + } + + class Bookshop + { + public string? Id { get; set; } + public Etag Etag { get; set; } + } + + interface IBookshop { Etag Etag { get; set; } } + class BookshopString : IBookshop { [BsonRepresentation(BsonType.String)] public Etag Etag { get; set; } } + class BookshopInt64 : IBookshop { [BsonRepresentation(BsonType.Int64)] public Etag Etag { get; set; } } + class BookshopBinary : IBookshop { [BsonRepresentation(BsonType.Binary)] public Etag Etag { get; set; } } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/IPNetworkBsonSerializerTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/IPNetworkBsonSerializerTests.cs new file mode 100644 index 00000000..928707c7 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/IPNetworkBsonSerializerTests.cs @@ -0,0 +1,66 @@ +#if NET8_0_OR_GREATER +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using System.Net; +using Tingle.Extensions.MongoDB.Serialization; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Serializers; + +public class IPNetworkBsonSerializerTests +{ + static IPNetworkBsonSerializerTests() => BsonSerializer.RegisterSerializationProvider(new TingleSerializationProvider()); + + [Fact] + public void Works_For_Null() + { + var obj = new TestClass + { + Network = null + }; + var json = obj.ToJson(); + var expected = "{ 'Network' : null }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + [Fact] + public void Works_For_Parse() + { + var obj = new TestClass + { + Network = IPNetwork.Parse("192.168.0.4/32") + }; + var json = obj.ToJson(); + var expected = "{ 'Network' : '192.168.0.4/32' }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + [Fact] + public void Works_For_Create() + { + var obj = new TestClass + { + Network = new IPNetwork(new IPAddress(new byte[] { 30, 0, 0, 0 }), 27) + }; + var json = obj.ToJson(); + var expected = "{ 'Network' : '30.0.0.0/27' }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + public class TestClass + { + public IPNetwork? Network { get; set; } + } +} +#endif diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonElementBsonSerializerTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonElementBsonSerializerTests.cs new file mode 100644 index 00000000..5a534390 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonElementBsonSerializerTests.cs @@ -0,0 +1,69 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using System.Text.Json; +using Tingle.Extensions.MongoDB.Serialization; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Serializers; + +public class JsonElementBsonSerializerTests +{ + static JsonElementBsonSerializerTests() => BsonSerializer.RegisterSerializationProvider(new JsonSerializationProvider()); + + [Fact] + public void TestEmpty() + { + var obj = JsonSerializer.Deserialize("{}"); + var json = obj.ToJson(); + var expected = "{ }"; + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + [Fact] + public void TestSimple() + { + var obj = JsonSerializer.Deserialize("{'a':2,'b':null}".Replace("'", "\"")); + var json = obj.ToJson(); + var expected = "{ 'a' : 2, 'b' : null }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + [Fact] + public void TestNested() + { + var obj = JsonSerializer.Deserialize("{'a':2,'b':null,'c':{'c1':'cake'}}".Replace("'", "\"")); + var json = obj.ToJson(); + var expected = "{ 'a' : 2, 'b' : null, 'c' : { 'c1' : 'cake' } }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + [Fact] + public void TestArray() + { + var obj = new Animal { Details = JsonSerializer.Deserialize("['a','b','c']".Replace("'", "\"")), }; + var json = obj.ToJson(); + var expected = "{ 'Details' : ['a', 'b', 'c'] }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + + } + + class Animal + { + public JsonElement Details { get; set; } + } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonNodeBsonSerializerTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonNodeBsonSerializerTests.cs new file mode 100644 index 00000000..fe0c27d6 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonNodeBsonSerializerTests.cs @@ -0,0 +1,69 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using System.Text.Json.Nodes; +using Tingle.Extensions.MongoDB.Serialization; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Serializers; + +public class JsonNodeBsonSerializerTests +{ + static JsonNodeBsonSerializerTests() => BsonSerializer.RegisterSerializationProvider(new JsonSerializationProvider()); + + [Fact] + public void TestEmpty() + { + var obj = JsonNode.Parse("{}"); + var json = obj.ToJson(); + var expected = "{ }"; + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + [Fact] + public void TestSimple() + { + var obj = JsonNode.Parse("{'a':2,'b':null}".Replace("'", "\"")); + var json = obj.ToJson(); + var expected = "{ 'a' : 2, 'b' : null }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + [Fact] + public void TestNested() + { + var obj = JsonNode.Parse("{'a':2,'b':null,'c':{'c1':'cake'}}".Replace("'", "\"")); + var json = obj.ToJson(); + var expected = "{ 'a' : 2, 'b' : null, 'c' : { 'c1' : 'cake' } }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + [Fact] + public void TestArray() + { + var obj = new Animal { Details = JsonNode.Parse("['a','b','c']".Replace("'", "\"")), }; + var json = obj.ToJson(); + var expected = "{ 'Details' : ['a', 'b', 'c'] }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + + } + + class Animal + { + public JsonNode? Details { get; set; } + } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonObjectBsonSerializerTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonObjectBsonSerializerTests.cs new file mode 100644 index 00000000..cb2902a8 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/JsonObjectBsonSerializerTests.cs @@ -0,0 +1,50 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using System.Text.Json.Nodes; +using Tingle.Extensions.MongoDB.Serialization; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Serializers; + +public class JsonObjectBsonSerializerTests +{ + static JsonObjectBsonSerializerTests() => BsonSerializer.RegisterSerializationProvider(new JsonSerializationProvider()); + + [Fact] + public void TestEmpty() + { + var obj = new JsonObject(); + var json = obj.ToJson(); + var expected = "{ }"; + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + [Fact] + public void TestSimple() + { + var obj = (JsonObject)JsonNode.Parse("{'a':2,'b':null}".Replace("'", "\""))!; + var json = obj.ToJson(); + var expected = "{ 'a' : 2, 'b' : null }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } + + [Fact] + public void TestNested() + { + var obj = (JsonObject)JsonNode.Parse("{'a':2,'b':null,'c':{'c1':'cake'}}".Replace("'", "\""))!; + var json = obj.ToJson(); + var expected = "{ 'a' : 2, 'b' : null, 'c' : { 'c1' : 'cake' } }".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/SequenceNumberBsonSerializerTests.cs b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/SequenceNumberBsonSerializerTests.cs new file mode 100644 index 00000000..994ac589 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Serialization/Serializers/SequenceNumberBsonSerializerTests.cs @@ -0,0 +1,79 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Attributes; +using Tingle.Extensions.MongoDB.Serialization; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.MongoDB.Tests.Serialization.Serializers; + +public class SequenceNumberBsonSerializerTests +{ + static SequenceNumberBsonSerializerTests() => BsonSerializer.RegisterSerializationProvider(new TingleSerializationProvider()); + + [Theory] + [InlineData(0UL)] + [InlineData(123456789UL)] + public void Deserialize_Works_For_String(long val) + { + var json = $"{{ '_id' : 'cake', 'Position' : '{val}' }}".Replace("'", "\""); + var result = BsonSerializer.Deserialize(json); + Assert.Equal("cake", result.Id); + Assert.Equal(val, result.Position); + } + + [Theory] + [InlineData(0UL)] + [InlineData(123456789UL)] + public void Deserialize_Works_For_Int64(long val) + { + var json = $"{{ '_id' : 'cake', 'Position' : NumberLong({val}) }}".Replace("'", "\""); + var result = BsonSerializer.Deserialize(json); + Assert.Equal("cake", result.Id); + Assert.Equal(val, result.Position); + } + + [Fact] + public void Default_BsonRepresentation_IsUsed() + { + var obj = new Bookshop + { + Id = "cake", + Position = new SequenceNumber(123456789) + }; + var json = obj.ToJson(); + var expected = $"{{ '_id' : 'cake', 'Position' : NumberLong(123456789) }}".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(); + var rehydrated = BsonSerializer.Deserialize(bson); + Assert.True(bson.SequenceEqual(rehydrated.ToBson())); + Assert.Equal(123456789, rehydrated.Position); + } + + [Theory] + [InlineData(123456789UL, typeof(BookshopString), "'123456789'")] + [InlineData(123456789UL, typeof(BookshopInt64), "NumberLong(123456789)")] + public void BsonRepresentation_Is_Respected(long val, Type t, string bsonRaw) + { + var obj = (IBookshop)Activator.CreateInstance(t)!; + obj.Position = new SequenceNumber(val); + var json = obj.ToJson(t); + var expected = $"{{ 'Position' : {bsonRaw} }}".Replace("'", "\""); + Assert.Equal(expected, json); + + var bson = obj.ToBson(t); + var rehydrated = (IBookshop)BsonSerializer.Deserialize(bson, t); + Assert.True(bson.SequenceEqual(rehydrated.ToBson(t))); + Assert.Equal(val, rehydrated.Position); + } + + class Bookshop + { + public string? Id { get; set; } + public SequenceNumber Position { get; set; } + } + + interface IBookshop { SequenceNumber Position { get; set; } } + class BookshopString : IBookshop { [BsonRepresentation(BsonType.String)] public SequenceNumber Position { get; set; } } + class BookshopInt64 : IBookshop { [BsonRepresentation(BsonType.Int64)] public SequenceNumber Position { get; set; } } +} diff --git a/tests/Tingle.Extensions.MongoDB.Tests/Tingle.Extensions.MongoDB.Tests.csproj b/tests/Tingle.Extensions.MongoDB.Tests/Tingle.Extensions.MongoDB.Tests.csproj new file mode 100644 index 00000000..a0b12307 --- /dev/null +++ b/tests/Tingle.Extensions.MongoDB.Tests/Tingle.Extensions.MongoDB.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + +