From d5c65da390cc27bfa0f1173b654a7a3c323659ea Mon Sep 17 00:00:00 2001 From: Maxwell Weru Date: Thu, 7 Mar 2024 16:06:38 +0300 Subject: [PATCH] Imported Tingle.Extensions.EntityFrameworkCore --- .github/workflows/cleanup.yml | 1 + .vscode/settings.json | 1 + README.md | 1 + Tingle.Extensions.sln | 14 ++ .../Conventions/LengthAttributeConvention.cs | 55 +++++ .../Converters/ByteSizeConverter.cs | 24 ++ .../Converters/DurationConverter.cs | 24 ++ .../Converters/EtagConverter.cs | 24 ++ .../Converters/IPNetworkConverter.cs | 26 +++ .../Converters/JsonElementConverter.cs | 26 +++ .../Converters/JsonNodeConverter.cs | 27 +++ .../Converters/JsonObjectConverter.cs | 27 +++ .../Converters/SequenceNumberConverter.cs | 24 ++ .../DatabaseSetup.cs | 70 ++++++ .../EfCoreJsonSerializerContext.cs | 7 + .../IServiceCollectionExtensions.cs | 25 ++ .../ModelConfigurationBuilderExtensions.cs | 139 +++++++++++ .../Extensions/PropertyBuilderExtensions.cs | 218 ++++++++++++++++++ .../MessageStrings.cs | 7 + .../README.md | 55 +++++ ...ngle.Extensions.EntityFrameworkCore.csproj | 26 +++ ...xtensions.EntityFrameworkCore.Tests.csproj | 7 + 22 files changed, 828 insertions(+) create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Conventions/LengthAttributeConvention.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Converters/ByteSizeConverter.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Converters/DurationConverter.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Converters/EtagConverter.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Converters/IPNetworkConverter.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonElementConverter.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonNodeConverter.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonObjectConverter.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Converters/SequenceNumberConverter.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/DatabaseSetup.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/EfCoreJsonSerializerContext.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Extensions/IServiceCollectionExtensions.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Extensions/PropertyBuilderExtensions.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/MessageStrings.cs create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/README.md create mode 100644 src/Tingle.Extensions.EntityFrameworkCore/Tingle.Extensions.EntityFrameworkCore.csproj create mode 100644 tests/Tingle.Extensions.EntityFrameworkCore.Tests/Tingle.Extensions.EntityFrameworkCore.Tests.csproj diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 15b5cfb..1706cc8 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -19,6 +19,7 @@ jobs: - { name: 'Tingle.AspNetCore.Tokens' } - { name: 'Tingle.Extensions.Caching.MongoDB' } - { name: 'Tingle.Extensions.DataAnnotations' } + - { name: 'Tingle.Extensions.EntityFrameworkCore' } - { name: 'Tingle.Extensions.Http' } - { name: 'Tingle.Extensions.Http.Authentication' } - { name: 'Tingle.Extensions.JsonPatch' } diff --git a/.vscode/settings.json b/.vscode/settings.json index 9d04d56..6c23d4f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ "Bson", + "EFCORE", "etag", "Ksuid", "libphonenumber", diff --git a/README.md b/README.md index e5f2d40..0c72957 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This repository contains projects/libraries for adding useful functionality to . |[`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.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.EntityFrameworkCore`](https://www.nuget.org/packages/Tingle.Extensions.EntityFrameworkCore/)|Convenience functionality and extensions for working with EntityFrameworkCore. See [docs](./src/Tingle.Extensions.EntityFrameworkCore/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).| |[`Tingle.Extensions.JsonPatch`](https://www.nuget.org/packages/Tingle.Extensions.JsonPatch/)|JSON Patch (RFC 6902) support for .NET to easily generate JSON Patch documents using `System.Text.Json` for client applications. See [docs](./src/Tingle.Extensions.JsonPatch/README.md).| diff --git a/Tingle.Extensions.sln b/Tingle.Extensions.sln index 9a83542..0797d0b 100644 --- a/Tingle.Extensions.sln +++ b/Tingle.Extensions.sln @@ -22,6 +22,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Caching.M EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.DataAnnotations", "src\Tingle.Extensions.DataAnnotations\Tingle.Extensions.DataAnnotations.csproj", "{51FA6572-8EB6-4291-8D02-BB736057A50E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tingle.Extensions.EntityFrameworkCore", "src\Tingle.Extensions.EntityFrameworkCore\Tingle.Extensions.EntityFrameworkCore.csproj", "{32D8B776-DF01-4726-B76B-9D0D1A4B8EE3}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http", "src\Tingle.Extensions.Http\Tingle.Extensions.Http.csproj", "{5BFAD4DB-D6A6-44F4-ACAB-B7B04E5A052E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http.Authentication", "src\Tingle.Extensions.Http.Authentication\Tingle.Extensions.Http.Authentication.csproj", "{47F95938-964A-47FE-A0D6-1EDD0893455B}" @@ -59,6 +61,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Caching.M EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.DataAnnotations.Tests", "tests\Tingle.Extensions.DataAnnotations.Tests\Tingle.Extensions.DataAnnotations.Tests.csproj", "{8E3530BB-ED60-4A2C-9BD2-C6879D67B4BD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tingle.Extensions.EntityFrameworkCore.Tests", "tests\Tingle.Extensions.EntityFrameworkCore.Tests\Tingle.Extensions.EntityFrameworkCore.Tests.csproj", "{AE6F9F63-4EB0-4BD8-B407-4AE91184FB07}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http.Tests", "tests\Tingle.Extensions.Http.Tests\Tingle.Extensions.Http.Tests.csproj", "{41980843-7F99-4AD2-B9E9-B13FC349A150}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http.Authentication.Tests", "tests\Tingle.Extensions.Http.Authentication.Tests\Tingle.Extensions.Http.Authentication.Tests.csproj", "{D0C66D3A-ED1F-486E-AA19-BDBB19025368}" @@ -272,6 +276,14 @@ Global {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 {009C5985-9DD4-45A8-A31E-4E6B7FE5EE78}.Release|Any CPU.Build.0 = Release|Any CPU + {32D8B776-DF01-4726-B76B-9D0D1A4B8EE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {32D8B776-DF01-4726-B76B-9D0D1A4B8EE3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {32D8B776-DF01-4726-B76B-9D0D1A4B8EE3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {32D8B776-DF01-4726-B76B-9D0D1A4B8EE3}.Release|Any CPU.Build.0 = Release|Any CPU + {AE6F9F63-4EB0-4BD8-B407-4AE91184FB07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE6F9F63-4EB0-4BD8-B407-4AE91184FB07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE6F9F63-4EB0-4BD8-B407-4AE91184FB07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE6F9F63-4EB0-4BD8-B407-4AE91184FB07}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -318,6 +330,8 @@ Global {CFEE0754-9DAF-4AA6-98F3-477F065D7DC4} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {AC04C113-8F75-43BA-8FEE-987475A87C58} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {009C5985-9DD4-45A8-A31E-4E6B7FE5EE78} = {9071B0C9-DE4D-411D-A9D3-CB7326CBBD80} + {32D8B776-DF01-4726-B76B-9D0D1A4B8EE3} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {AE6F9F63-4EB0-4BD8-B407-4AE91184FB07} = {815F0941-3B70-4705-A583-AF627559595C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B9323FCA-8E8B-4176-A463-87D202EC4552} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Conventions/LengthAttributeConvention.cs b/src/Tingle.Extensions.EntityFrameworkCore/Conventions/LengthAttributeConvention.cs new file mode 100644 index 0000000..c76c5ac --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Conventions/LengthAttributeConvention.cs @@ -0,0 +1,55 @@ +#if NET8_0_OR_GREATER +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; + +namespace Tingle.Extensions.EntityFrameworkCore.Conventions; + +/// +/// A convention that configures the maximum length based on the applied on the property. +/// +/// +/// See Model building conventions for more information and examples. +/// +/// Parameter object containing dependencies for this convention. +public class LengthAttributeConvention(ProviderConventionSetBuilderDependencies dependencies) : PropertyAttributeConventionBase(dependencies), IComplexPropertyAddedConvention +{ + /// + protected override void ProcessPropertyAdded( + IConventionPropertyBuilder propertyBuilder, + LengthAttribute attribute, + MemberInfo clrMember, + IConventionContext context) + { + if (attribute.MaximumLength > 0) + { + propertyBuilder.HasMaxLength(attribute.MaximumLength, fromDataAnnotation: true); + } + } + + /// + protected override void ProcessPropertyAdded( + IConventionComplexPropertyBuilder propertyBuilder, + LengthAttribute attribute, + MemberInfo clrMember, + IConventionContext context) + { + var property = propertyBuilder.Metadata; +#pragma warning disable EF1001 + var member = property.GetIdentifyingMemberInfo(); +#pragma warning restore EF1001 + if (member != null + && Attribute.IsDefined(member, typeof(ForeignKeyAttribute), inherit: true)) + { + throw new InvalidOperationException( + CoreStrings.AttributeNotOnEntityTypeProperty( + "MaxLength", property.DeclaringType.DisplayName(), property.Name)); + } + } +} +#endif diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Converters/ByteSizeConverter.cs b/src/Tingle.Extensions.EntityFrameworkCore/Converters/ByteSizeConverter.cs new file mode 100644 index 0000000..90afd49 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Converters/ByteSizeConverter.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.EntityFrameworkCore.Converters; + +/// +public class ByteSizeConverter : ValueConverter +{ + /// + public ByteSizeConverter() : base(convertToProviderExpression: v => v.Bytes, + convertFromProviderExpression: v => v == default ? default : new ByteSize(v)) + { } +} + +/// +public class ByteSizeComparer : ValueComparer +{ + /// + public ByteSizeComparer() : base(equalsExpression: (l, r) => l == r, + hashCodeExpression: v => v.GetHashCode(), + snapshotExpression: v => new ByteSize(v.Bytes)) + { } +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Converters/DurationConverter.cs b/src/Tingle.Extensions.EntityFrameworkCore/Converters/DurationConverter.cs new file mode 100644 index 0000000..5552abe --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Converters/DurationConverter.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.EntityFrameworkCore.Converters; + +/// +public class DurationConverter : ValueConverter +{ + /// + public DurationConverter() : base(convertToProviderExpression: v => v.ToString(), + convertFromProviderExpression: v => v == null ? default : Duration.Parse(v)) + { } +} + +/// +public class DurationComparer : ValueComparer +{ + /// + public DurationComparer() : base(equalsExpression: (l, r) => l == r, + hashCodeExpression: v => v.GetHashCode(), + snapshotExpression: v => Duration.Parse(v.ToString())) + { } +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Converters/EtagConverter.cs b/src/Tingle.Extensions.EntityFrameworkCore/Converters/EtagConverter.cs new file mode 100644 index 0000000..a61e9ca --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Converters/EtagConverter.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.EntityFrameworkCore.Converters; + +/// +public class EtagConverter : ValueConverter +{ + /// + public EtagConverter() : base(convertToProviderExpression: v => v.ToByteArray(), + convertFromProviderExpression: v => v == null ? default : new Etag(v)) + { } +} + +/// +public class EtagComparer : ValueComparer +{ + /// + public EtagComparer() : base(equalsExpression: (l, r) => l == r, + hashCodeExpression: v => v.GetHashCode(), + snapshotExpression: v => new Etag(v.ToByteArray())) + { } +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Converters/IPNetworkConverter.cs b/src/Tingle.Extensions.EntityFrameworkCore/Converters/IPNetworkConverter.cs new file mode 100644 index 0000000..4276c8b --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Converters/IPNetworkConverter.cs @@ -0,0 +1,26 @@ +#if NET8_0_OR_GREATER +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Net; + +namespace Tingle.Extensions.EntityFrameworkCore.Converters; + +/// +public class IPNetworkConverter : ValueConverter +{ + /// + public IPNetworkConverter() : base(convertToProviderExpression: v => v.ToString(), + convertFromProviderExpression: v => v == null ? default : IPNetwork.Parse(v)) + { } +} + +/// +public class IPNetworkComparer : ValueComparer +{ + /// + public IPNetworkComparer() : base(equalsExpression: (l, r) => l == r, + hashCodeExpression: v => v.GetHashCode(), + snapshotExpression: v => IPNetwork.Parse(v.ToString())) + { } +} +#endif \ No newline at end of file diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonElementConverter.cs b/src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonElementConverter.cs new file mode 100644 index 0000000..d9115f4 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonElementConverter.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json; +using SC = Tingle.Extensions.EntityFrameworkCore.EfCoreJsonSerializerContext; + +namespace Tingle.Extensions.EntityFrameworkCore.Converters; + +/// +public class JsonElementConverter : ValueConverter +{ + /// + public JsonElementConverter() : base(convertToProviderExpression: v => v.ToString(), + convertFromProviderExpression: v => v == null ? default : JsonDocument.Parse(v, default).RootElement) + { } +} + +/// +public class JsonElementComparer : ValueComparer +{ + /// + public JsonElementComparer() : base( + equalsExpression: (l, r) => JsonSerializer.Serialize(l, SC.Default.JsonElement) == JsonSerializer.Serialize(r, SC.Default.JsonElement), + hashCodeExpression: v => v.GetHashCode(), + snapshotExpression: v => JsonDocument.Parse(JsonSerializer.Serialize(v, SC.Default.JsonElement), default).RootElement) + { } +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonNodeConverter.cs b/src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonNodeConverter.cs new file mode 100644 index 0000000..131a5e9 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonNodeConverter.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json.Nodes; + +#pragma warning disable CS8603 // Possible null reference return. +#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code + +namespace Tingle.Extensions.EntityFrameworkCore.Converters; + +/// +public class JsonNodeConverter : ValueConverter +{ + /// + public JsonNodeConverter() : base(convertToProviderExpression: v => v.ToJsonString(default), + convertFromProviderExpression: v => v == null ? default : JsonNode.Parse(v, default, default)) + { } +} + +/// +public class JsonNodeComparer : ValueComparer +{ + /// + public JsonNodeComparer() : base(equalsExpression: (l, r) => (l == null ? null : l.ToJsonString(default)) == (r == null ? null : r.ToJsonString(default)), + hashCodeExpression: v => v == null ? 0 : v.ToJsonString(default).GetHashCode(), + snapshotExpression: v => JsonNode.Parse(v.ToJsonString(default), default, default)) + { } +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonObjectConverter.cs b/src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonObjectConverter.cs new file mode 100644 index 0000000..26c7f43 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Converters/JsonObjectConverter.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json.Nodes; + +#pragma warning disable CS8603 // Possible null reference return. +#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code + +namespace Tingle.Extensions.EntityFrameworkCore.Converters; + +/// +public class JsonObjectConverter : ValueConverter +{ + /// + public JsonObjectConverter() : base(convertToProviderExpression: v => v.ToJsonString(default), + convertFromProviderExpression: v => v == null ? default : JsonNode.Parse(v, default, default)!.AsObject()) + { } +} + +/// +public class JsonObjectComparer : ValueComparer +{ + /// + public JsonObjectComparer() : base(equalsExpression: (l, r) => (l == null ? null : l.ToJsonString(default)) == (r == null ? null : r.ToJsonString(default)), + hashCodeExpression: v => v == null ? 0 : v.ToJsonString(default).GetHashCode(), + snapshotExpression: v => v == null ? null : JsonNode.Parse(v.ToJsonString(default), default, default)!.AsObject()) + { } +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Converters/SequenceNumberConverter.cs b/src/Tingle.Extensions.EntityFrameworkCore/Converters/SequenceNumberConverter.cs new file mode 100644 index 0000000..bdda1e4 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Converters/SequenceNumberConverter.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tingle.Extensions.Primitives; + +namespace Tingle.Extensions.EntityFrameworkCore.Converters; + +/// +public class SequenceNumberConverter : ValueConverter +{ + /// + public SequenceNumberConverter() : base(convertToProviderExpression: v => v.Value, + convertFromProviderExpression: v => v == SequenceNumber.Empty ? default : new SequenceNumber(v)) + { } +} + +/// +public class SequenceNumberComparer : ValueComparer +{ + /// + public SequenceNumberComparer() : base(equalsExpression: (l, r) => l == r, + hashCodeExpression: v => v.GetHashCode(), + snapshotExpression: v => new SequenceNumber(v.Value)) + { } +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/DatabaseSetup.cs b/src/Tingle.Extensions.EntityFrameworkCore/DatabaseSetup.cs new file mode 100644 index 0000000..90c62fe --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/DatabaseSetup.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Tingle.Extensions.EntityFrameworkCore; + +/// +/// Helper for performing migrations or creation. +/// +/// The type of context to be used. +public class DatabaseSetup : IHostedService where TContext : DbContext +{ + private readonly IServiceScopeFactory scopeFactory; + private readonly ILogger logger; + + /// + /// Creates an instance of . + /// + /// The with which to create instances. + /// The instance to use. + public DatabaseSetup(IServiceScopeFactory scopeFactory, ILogger> logger) : this(scopeFactory, (ILogger)logger) { } + + /// + /// Creates an instance of . + /// + /// The with which to create instances. + /// The instance to use. + protected DatabaseSetup(IServiceScopeFactory scopeFactory, ILogger logger) + { + this.scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + this.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 migrations or creation + var environment = provider.GetRequiredService(); + var configuration = provider.GetRequiredService(); + if (bool.TryParse(configuration["EFCORE_PERFORM_MIGRATIONS"], out var b) && b) + { + // Perform migrations + logger.LogInformation("Performing EfCore migrations ..."); + var context = provider.GetRequiredService(); + await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("Completed EfCore migrations."); + } + else if (bool.TryParse(configuration["EFCORE_CREATE_DATABASE"], out b) && b) + { + // Create database + logger.LogInformation("Creating EfCore database ..."); + var context = provider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("Completed EfCore database creation."); + } + else + { + logger.LogDebug("Database migrations/creation skipped."); + return; + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/EfCoreJsonSerializerContext.cs b/src/Tingle.Extensions.EntityFrameworkCore/EfCoreJsonSerializerContext.cs new file mode 100644 index 0000000..ce4670b --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/EfCoreJsonSerializerContext.cs @@ -0,0 +1,7 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.EntityFrameworkCore; + +[JsonSerializable(typeof(JsonElement))] +internal partial class EfCoreJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Extensions/IServiceCollectionExtensions.cs b/src/Tingle.Extensions.EntityFrameworkCore/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 0000000..40cd216 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Tingle.Extensions.EntityFrameworkCore; + +namespace Microsoft.Extensions.DependencyInjection; + +/// Extensions on . +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. + /// + /// + /// Migrations are done when the configuration value EFCORE_PERFORM_MIGRATIONS is set to . + /// Database creation is done when configuration value EFCORE_CREATE_DATABASE is set to . + /// + public static IServiceCollection AddDatabaseSetup(this IServiceCollection services) + where TContext : DbContext + { + return services.AddHostedService>(); + } +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs b/src/Tingle.Extensions.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..959cf00 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Tingle.Extensions.EntityFrameworkCore.Converters; +using Tingle.Extensions.Primitives; + +#if NET8_0_OR_GREATER +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using System.ComponentModel.DataAnnotations; +using System.Net; +using Tingle.Extensions.EntityFrameworkCore.Conventions; +#endif + +namespace Microsoft.EntityFrameworkCore; + +/// Extensions for . +public static class ModelConfigurationBuilderExtensions +{ + /// + /// Add fields of type to be converted using . + /// + /// The to use. + public static void AddEtagConventions(this ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + configurationBuilder.Properties().HaveConversion(); + } + + /// + /// Add fields of type to be converted using . + /// + /// The to use. + public static void AddSequenceNumberConventions(this ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + configurationBuilder.Properties() + .HaveConversion(); + } + + /// + /// Add fields of type to be converted using . + /// + /// The to use. + public static void AddByteSizeConventions(this ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + configurationBuilder.Properties().HaveConversion(); + } + + /// + /// Add fields of type to be converted using . + /// + /// The to use. + public static void AddDurationConventions(this ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + configurationBuilder.Properties().HaveConversion(); + } + +#if NET8_0_OR_GREATER + /// + /// Add fields of type to be converted using . + /// + /// The to use. + public static void AddIPNetworkConventions(this ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + configurationBuilder.Properties().HaveConversion(); + } +#endif + + /// + /// Add fields of type to be converted using . + /// + /// The to use. + public static void AddJsonElementConventions(this ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + configurationBuilder.Properties().HaveConversion(); + } + + /// + /// Add fields of type to be converted using . + /// + /// The to use. + public static void AddJsonObjectConventions(this ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + configurationBuilder.Properties().HaveConversion(); + } + + /// + /// Add fields of type to be converted using . + /// + /// The to use. + public static void AddJsonNodeConventions(this ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + configurationBuilder.Properties().HaveConversion(); + } + + /// + /// Add fields of type, , or + /// to be converted using , , or + /// . + /// + /// The to use. + public static void AddJsonConventions(this ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + configurationBuilder.AddJsonElementConventions(); + configurationBuilder.AddJsonObjectConventions(); + configurationBuilder.AddJsonNodeConventions(); + } + +#if NET8_0_OR_GREATER + /// + /// Add convention for handling . + /// + /// The to use. + public static void AddLengthAttributeConvention(this ModelConfigurationBuilder configurationBuilder) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + configurationBuilder.Conventions.Add(provider + => new LengthAttributeConvention( + provider.GetRequiredService())); + } +#endif +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Extensions/PropertyBuilderExtensions.cs b/src/Tingle.Extensions.EntityFrameworkCore/Extensions/PropertyBuilderExtensions.cs new file mode 100644 index 0000000..3763572 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Extensions/PropertyBuilderExtensions.cs @@ -0,0 +1,218 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using Tingle.Extensions.EntityFrameworkCore; +using Tingle.Extensions.EntityFrameworkCore.Converters; +using Tingle.Extensions.Primitives; + +namespace Microsoft.EntityFrameworkCore; + +/// +/// Extensions for . +/// +public static class PropertyBuilderExtensions +{ + /// + /// Attach conversion of property to/from stored in the database as a . + /// + /// The to extend. + /// + public static PropertyBuilder HasEtagConversion(this PropertyBuilder propertyBuilder) + { + ArgumentNullException.ThrowIfNull(propertyBuilder); + + propertyBuilder.HasConversion(new EtagConverter()); + propertyBuilder.Metadata.SetValueComparer(new EtagComparer()); + + return propertyBuilder; + } + + /// + /// Attach conversion of property to/from stored in the database as a . + /// + /// The to extend. + /// + public static PropertyBuilder HasSequenceNumberConversion(this PropertyBuilder propertyBuilder) + { + ArgumentNullException.ThrowIfNull(propertyBuilder); + + propertyBuilder.HasConversion(new SequenceNumberConverter()); + propertyBuilder.Metadata.SetValueComparer(new SequenceNumberComparer()); + + return propertyBuilder; + } + + /// + /// Attach conversion of property to/from stored in the database as a . + /// + /// The to extend. + /// + public static PropertyBuilder HasByteSizeConversion(this PropertyBuilder propertyBuilder) + { + ArgumentNullException.ThrowIfNull(propertyBuilder); + + propertyBuilder.HasConversion(new ByteSizeConverter()); + propertyBuilder.Metadata.SetValueComparer(new ByteSizeComparer()); + + return propertyBuilder; + } + + /// + /// Attach conversion of property to/from stored in the database as a . + /// + /// The to extend. + /// + public static PropertyBuilder HasDurationConversion(this PropertyBuilder propertyBuilder) + { + ArgumentNullException.ThrowIfNull(propertyBuilder); + + propertyBuilder.HasConversion(new DurationConverter()); + propertyBuilder.Metadata.SetValueComparer(new DurationComparer()); + + return propertyBuilder; + } + +#if NET8_0_OR_GREATER + /// + /// Attach conversion of property to/from stored in the database as a . + /// + /// The to extend. + /// + public static PropertyBuilder HasIPNetworkConversion(this PropertyBuilder propertyBuilder) + { + ArgumentNullException.ThrowIfNull(propertyBuilder); + + propertyBuilder.HasConversion(new IPNetworkConverter()); + propertyBuilder.Metadata.SetValueComparer(new IPNetworkComparer()); + + return propertyBuilder; + } +#endif + + /// + /// Attach conversion of property to/from stored in the database as a . + /// + /// The to extend. + /// + public static PropertyBuilder HasJsonElementConversion(this PropertyBuilder propertyBuilder) + { + ArgumentNullException.ThrowIfNull(propertyBuilder); + + propertyBuilder.HasConversion(new JsonElementConverter()); + propertyBuilder.Metadata.SetValueComparer(new JsonElementComparer()); + + return propertyBuilder; + } + + /// + /// Attach conversion of property to/from stored in the database as a . + /// + /// The to extend. + /// + public static PropertyBuilder HasJsonObjectConversion(this PropertyBuilder propertyBuilder) + { + ArgumentNullException.ThrowIfNull(propertyBuilder); + + propertyBuilder.HasConversion(new JsonObjectConverter()); + propertyBuilder.Metadata.SetValueComparer(new JsonObjectComparer()); + + return propertyBuilder; + } + + /// + /// Attach conversion of property to/from stored in the database as a . + /// + /// The to extend. + /// + public static PropertyBuilder HasJsonNodeConversion(this PropertyBuilder propertyBuilder) + { + ArgumentNullException.ThrowIfNull(propertyBuilder); + + propertyBuilder.HasConversion(new JsonNodeConverter()); + propertyBuilder.Metadata.SetValueComparer(new JsonNodeComparer()); + + return propertyBuilder; + } + + /// + /// Attach conversion of property to/from JSON stored in the database as a string. + /// + /// + /// The to extend. + /// The to use. + /// + [RequiresUnreferencedCode(MessageStrings.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(MessageStrings.SerializationRequiresDynamicCodeMessage)] + public static PropertyBuilder HasJsonConversion(this PropertyBuilder propertyBuilder, JsonSerializerOptions? serializerOptions = null) + { + ArgumentNullException.ThrowIfNull(propertyBuilder); + +#pragma warning disable CS8603 // Possible null reference return. + var converter = new ValueConverter( + convertToProviderExpression: v => ConvertToJson(v, serializerOptions), + convertFromProviderExpression: v => ConvertFromJson(v, serializerOptions)); + + var comparer = new ValueComparer( + equalsExpression: (l, r) => ConvertToJson(l, serializerOptions) == ConvertToJson(r, serializerOptions), + hashCodeExpression: v => v == null ? 0 : ConvertToJson(v, serializerOptions).GetHashCode(), + snapshotExpression: v => ConvertFromJson(ConvertToJson(v, serializerOptions), serializerOptions)); +#pragma warning restore CS8603 // Possible null reference return. + + propertyBuilder.HasConversion(converter); + propertyBuilder.Metadata.SetValueConverter(converter); + propertyBuilder.Metadata.SetValueComparer(comparer); + + return propertyBuilder; + } + + /// + /// Attach conversion of property to/from JSON stored in the database as a string. + /// + /// + /// The to extend. + /// Metadata about the type to convert. + /// + public static PropertyBuilder HasJsonConversion<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods + | DynamicallyAccessedMemberTypes.NonPublicMethods + | DynamicallyAccessedMemberTypes.PublicProperties)] T>( + this PropertyBuilder propertyBuilder, JsonTypeInfo jsonTypeInfo) + { + ArgumentNullException.ThrowIfNull(propertyBuilder); + +#pragma warning disable CS8603 // Possible null reference return. + var converter = new ValueConverter( + convertToProviderExpression: v => ConvertToJson(v, jsonTypeInfo), + convertFromProviderExpression: v => ConvertFromJson(v, jsonTypeInfo)); + + var comparer = new ValueComparer( + equalsExpression: (l, r) => ConvertToJsonNullable(l, jsonTypeInfo) == ConvertToJsonNullable(r, jsonTypeInfo), + hashCodeExpression: v => v == null ? 0 : ConvertToJson(v, jsonTypeInfo).GetHashCode(), + snapshotExpression: v => ConvertFromJson(ConvertToJson(v, jsonTypeInfo), jsonTypeInfo)); +#pragma warning restore CS8603 // Possible null reference return. + + propertyBuilder.HasConversion(converter); + propertyBuilder.Metadata.SetValueConverter(converter); + propertyBuilder.Metadata.SetValueComparer(comparer); + + return propertyBuilder; + } + + [RequiresUnreferencedCode(MessageStrings.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(MessageStrings.SerializationRequiresDynamicCodeMessage)] + private static string ConvertToJson(T value, JsonSerializerOptions? serializerOptions) => JsonSerializer.Serialize(value, serializerOptions); + + private static string ConvertToJson(T value, JsonTypeInfo jsonTypeInfo) => JsonSerializer.Serialize(value, jsonTypeInfo); + + private static string? ConvertToJsonNullable(T? value, JsonTypeInfo jsonTypeInfo) => value is null ? null : ConvertToJson(value, jsonTypeInfo); + + [RequiresUnreferencedCode(MessageStrings.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(MessageStrings.SerializationRequiresDynamicCodeMessage)] + private static T? ConvertFromJson(string? value, JsonSerializerOptions? serializerOptions) => string.IsNullOrEmpty(value) ? default : JsonSerializer.Deserialize(value, serializerOptions); + + private static T? ConvertFromJson(string? value, JsonTypeInfo jsonTypeInfo) => string.IsNullOrEmpty(value) ? default : JsonSerializer.Deserialize(value, jsonTypeInfo); +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/MessageStrings.cs b/src/Tingle.Extensions.EntityFrameworkCore/MessageStrings.cs new file mode 100644 index 0000000..1886bc2 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/MessageStrings.cs @@ -0,0 +1,7 @@ +namespace Tingle.Extensions.EntityFrameworkCore; + +internal class MessageStrings +{ + public const string SerializationUnreferencedCodeMessage = "JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved."; + public const string SerializationRequiresDynamicCodeMessage = "JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications."; +} diff --git a/src/Tingle.Extensions.EntityFrameworkCore/README.md b/src/Tingle.Extensions.EntityFrameworkCore/README.md new file mode 100644 index 0000000..4e617a4 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/README.md @@ -0,0 +1,55 @@ +# Tingle.Extensions.EntityFrameworkCore + +If you are looking for documentation for this package. It does not exist yet. Please help write it. + +TODO: write this documentation file + +## Converters + +|Source Types|BSON Destination Types| +|--|--| +|`System.Text.Json.Nodes.JsonObject`|`BsonDocument`| +|`System.Text.Json.Nodes.JsonNode`|`string`| +|`System.Text.Json.JsonElement`|`string`| +|`System.Net.IPNetwork` (.NET 8 or later)|`String`| +|`Tingle.Extensions.Primitives.Etag`|`byte[]`| +|`Tingle.Extensions.Primitives.Duration`|`String`| +|`Tingle.Extensions.Primitives.ByteSize`|`long`| +|`Tingle.Extensions.Primitives.SequenceNumber`|`long`| + +## Conventions + +|Name|Description| +|--|--| +|`LengthAttributeConvention`|A convention that configures the maximum length based on the `LengthAttribute` applied on a property.| + +## Database setup + +For development and preview environments, databases may need to be migrated or created automatically on startup. +This can be done using `EFCORE_PERFORM_MIGRATIONS` or `EFCORE_CREATE_DATABASE` environment variable. + +|Name|Description| +|--|--| +|EFCORE_PERFORM_MIGRATIONS|Whether to perform database migrations on startup.| +|EFCORE_CREATE_DATABASE|Whether to create the database on startup. Ignored if `EFCORE_PERFORM_MIGRATIONS` is set to `true` or `1`.| + +```json +{ + "profiles": { + "SerilogSample": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development", + "EFCORE_PERFORM_MIGRATIONS": "true" + } + } + } +} +``` + +In the application setup: + +```cs +builder.Services.AddDatabaseSetup(); +``` diff --git a/src/Tingle.Extensions.EntityFrameworkCore/Tingle.Extensions.EntityFrameworkCore.csproj b/src/Tingle.Extensions.EntityFrameworkCore/Tingle.Extensions.EntityFrameworkCore.csproj new file mode 100644 index 0000000..fe1c524 --- /dev/null +++ b/src/Tingle.Extensions.EntityFrameworkCore/Tingle.Extensions.EntityFrameworkCore.csproj @@ -0,0 +1,26 @@ + + + + Convenience functionality and extensions for working with EntityFrameworkCore + net7.0;net8.0 + + + + $(PackageTags);EntityFrameworkCore;efcore + + + + + + + + + + + + + + + + + diff --git a/tests/Tingle.Extensions.EntityFrameworkCore.Tests/Tingle.Extensions.EntityFrameworkCore.Tests.csproj b/tests/Tingle.Extensions.EntityFrameworkCore.Tests/Tingle.Extensions.EntityFrameworkCore.Tests.csproj new file mode 100644 index 0000000..7233963 --- /dev/null +++ b/tests/Tingle.Extensions.EntityFrameworkCore.Tests/Tingle.Extensions.EntityFrameworkCore.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + +