diff --git a/Aspire.sln b/Aspire.sln index ba87562f8d5..b96f0767876 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -201,6 +201,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CosmosEndToEnd.ApiService", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Playground.ServiceDefaults", "playground\Playground.ServiceDefaults\Playground.ServiceDefaults.csproj", "{25208C6F-0A9D-4D60-9EDD-256C9891B1CD}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Pomelo.EntityFrameworkCore.MySql", "src\Components\Aspire.Pomelo.EntityFrameworkCore.MySql\Aspire.Pomelo.EntityFrameworkCore.MySql.csproj", "{C565532A-0754-44FE-A0C7-78D5338DDBCA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Pomelo.EntityFrameworkCore.MySql.Tests", "tests\Aspire.Pomelo.EntityFrameworkCore.MySql.Tests\Aspire.Pomelo.EntityFrameworkCore.MySql.Tests.csproj", "{BFAF55A8-A737-4EC1-BBA2-76001A8F16E0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -539,6 +543,14 @@ Global {25208C6F-0A9D-4D60-9EDD-256C9891B1CD}.Debug|Any CPU.Build.0 = Debug|Any CPU {25208C6F-0A9D-4D60-9EDD-256C9891B1CD}.Release|Any CPU.ActiveCfg = Release|Any CPU {25208C6F-0A9D-4D60-9EDD-256C9891B1CD}.Release|Any CPU.Build.0 = Release|Any CPU + {C565532A-0754-44FE-A0C7-78D5338DDBCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C565532A-0754-44FE-A0C7-78D5338DDBCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C565532A-0754-44FE-A0C7-78D5338DDBCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C565532A-0754-44FE-A0C7-78D5338DDBCA}.Release|Any CPU.Build.0 = Release|Any CPU + {BFAF55A8-A737-4EC1-BBA2-76001A8F16E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BFAF55A8-A737-4EC1-BBA2-76001A8F16E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFAF55A8-A737-4EC1-BBA2-76001A8F16E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BFAF55A8-A737-4EC1-BBA2-76001A8F16E0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -633,6 +645,8 @@ Global {51DDD6BC-1D6C-466A-B509-FC49E3BD72E4} = {DBEDDF76-1C33-4943-8CCB-337A7D48AFF5} {EABB20A8-CDA2-4AFE-A5B1-FB631200CD64} = {DBEDDF76-1C33-4943-8CCB-337A7D48AFF5} {25208C6F-0A9D-4D60-9EDD-256C9891B1CD} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} + {C565532A-0754-44FE-A0C7-78D5338DDBCA} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} + {BFAF55A8-A737-4EC1-BBA2-76001A8F16E0} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6DCEDFEC-988E-4CB3-B45B-191EB5086E0C} diff --git a/Directory.Packages.props b/Directory.Packages.props index a206cc9914a..9e13f5923d9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -86,10 +86,12 @@ + + diff --git a/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/Aspire.Pomelo.EntityFrameworkCore.MySql.csproj b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/Aspire.Pomelo.EntityFrameworkCore.MySql.csproj new file mode 100644 index 00000000000..aa1fd552c03 --- /dev/null +++ b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/Aspire.Pomelo.EntityFrameworkCore.MySql.csproj @@ -0,0 +1,25 @@ + + + + $(NetCurrent) + true + $(ComponentEfCorePackageTags) pomelo mysql sql + A MySQL provider for Entity Framework Core that integrates with Aspire, including connection pooling, health checks, logging, and telemetry. + $(SharedDir)SQL_256x.png + + + + + + + + + + + + + + + + + diff --git a/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/AspireEFMySqlExtensions.cs b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/AspireEFMySqlExtensions.cs new file mode 100644 index 00000000000..120fd61de37 --- /dev/null +++ b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/AspireEFMySqlExtensions.cs @@ -0,0 +1,164 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire; +using Aspire.Pomelo.EntityFrameworkCore.MySql; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MySqlConnector.Logging; +using OpenTelemetry.Metrics; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for registering a MySQL database context in an Aspire application. +/// +public static partial class AspireEFMySqlExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Pomelo:EntityFrameworkCore:MySql"; + private const DynamicallyAccessedMemberTypes RequiredByEF = DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties; + + /// + /// Registers the given as a service in the services provided by the . + /// Enables db context pooling, corresponding health check, logging and telemetry. + /// + /// The that needs to be registered. + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional delegate that can be used for customizing options. It's invoked after the settings are read from the configuration. + /// An optional delegate to configure the for the context. + /// + /// + /// Reads the configuration from "Aspire:Pomelo:EntityFrameworkCore:MySql:{typeof(TContext).Name}" config section, or "Aspire:Pomelo:EntityFrameworkCore:MySql" if former does not exist. + /// + /// + /// The method can then be overridden to configure options. + /// + /// + /// Thrown if mandatory is null. + /// Thrown when mandatory is not provided. + public static void AddMySqlDbContext<[DynamicallyAccessedMembers(RequiredByEF)] TContext>( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action? configureDbContextOptions = null) where TContext : DbContext + { + ArgumentNullException.ThrowIfNull(builder); + + PomeloEntityFrameworkCoreMySqlSettings settings = new(); + var typeSpecificSectionName = $"{DefaultConfigSectionName}:{typeof(TContext).Name}"; + var typeSpecificConfigurationSection = builder.Configuration.GetSection(typeSpecificSectionName); + if (typeSpecificConfigurationSection.Exists()) // https://github.com/dotnet/runtime/issues/91380 + { + typeSpecificConfigurationSection.Bind(settings); + } + else + { + builder.Configuration.GetSection(DefaultConfigSectionName).Bind(settings); + } + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ConnectionString = connectionString; + } + + configureSettings?.Invoke(settings); + + if (settings.DbContextPooling) + { + builder.Services.AddDbContextPool(ConfigureDbContext); + } + else + { + builder.Services.AddDbContext(ConfigureDbContext); + } + + if (settings.HealthChecks) + { + // calling MapHealthChecks is the responsibility of the app, not Component + builder.TryAddHealthCheck( + name: typeof(TContext).Name, + static hcBuilder => hcBuilder.AddDbContextCheck()); + } + + if (settings.Tracing) + { + builder.Services.AddOpenTelemetry() + .WithTracing(tracerProviderBuilder => + { + // add tracing from the underlying MySqlConnector ADO.NET library + tracerProviderBuilder.AddSource("MySqlConnector"); + }); + } + + if (settings.Metrics) + { + builder.Services.AddOpenTelemetry() + .WithMetrics(meterProviderBuilder => + { + // Currently EF provides only Event Counters: + // https://learn.microsoft.com/ef/core/logging-events-diagnostics/event-counters?tabs=windows#counters-and-their-meaning + meterProviderBuilder.AddEventCountersInstrumentation(eventCountersInstrumentationOptions => + { + // The magic strings come from: + // https://github.com/dotnet/efcore/blob/a1cd4f45aa18314bc91d2b9ea1f71a3b7d5bf636/src/EFCore/Infrastructure/EntityFrameworkEventSource.cs#L45 + eventCountersInstrumentationOptions.AddEventSources("Microsoft.EntityFrameworkCore"); + }); + + // add metrics from the underlying MySqlConnector ADO.NET library + meterProviderBuilder.AddMeter("MySqlConnector"); + }); + } + + void ConfigureDbContext(IServiceProvider serviceProvider, DbContextOptionsBuilder dbContextOptionsBuilder) + { + // use the legacy method of setting the ILoggerFactory because Pomelo EF Core doesn't use MySqlDataSource + if (serviceProvider.GetService() is { } loggerFactory) + { + MySqlConnectorLogManager.Provider = new MicrosoftExtensionsLoggingLoggerProvider(loggerFactory); + } + + var connectionString = settings.ConnectionString ?? string.Empty; + + ServerVersion serverVersion; + if (settings.ServerVersion is null) + { + if (string.IsNullOrEmpty(connectionString)) + { + ThrowForMissingConnectionString(); + } + serverVersion = ServerVersion.AutoDetect(connectionString); + } + else + { + serverVersion = ServerVersion.Parse(settings.ServerVersion); + } + + var builder = dbContextOptionsBuilder.UseMySql(connectionString, serverVersion, builder => + { + // delay validating the ConnectionString until the DbContext is configured. This ensures an exception doesn't happen until a Logger is established. + if (string.IsNullOrEmpty(connectionString)) + { + ThrowForMissingConnectionString(); + } + + // Resiliency: + // 1. Connection resiliency automatically retries failed database commands: https://github.com/PomeloFoundation/Pomelo.EntityFrameworkCore.MySql/wiki/Configuration-Options#enableretryonfailure + if (settings.MaxRetryCount > 0) + { + builder.EnableRetryOnFailure(settings.MaxRetryCount); + } + }); + + configureDbContextOptions?.Invoke(dbContextOptionsBuilder); + + void ThrowForMissingConnectionString() + { + throw new InvalidOperationException($"ConnectionString is missing. It should be provided in 'ConnectionStrings:{connectionName}' or under the 'ConnectionString' key in '{DefaultConfigSectionName}' or '{typeSpecificSectionName}' configuration section."); + } + } + } +} diff --git a/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/AssemblyInfo.cs b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/AssemblyInfo.cs new file mode 100644 index 00000000000..95b90cc3016 --- /dev/null +++ b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/AssemblyInfo.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Aspire.Pomelo.EntityFrameworkCore.MySql; + +[assembly: ConfigurationSchema("Aspire:Pomelo:EntityFrameworkCore:MySql", typeof(PomeloEntityFrameworkCoreMySqlSettings))] + +[assembly: LoggingCategories( + "Microsoft.EntityFrameworkCore", + "Microsoft.EntityFrameworkCore.ChangeTracking", + "Microsoft.EntityFrameworkCore.Database", + "Microsoft.EntityFrameworkCore.Database.Command", + "Microsoft.EntityFrameworkCore.Database.Connection", + "Microsoft.EntityFrameworkCore.Database.Transaction", + "Microsoft.EntityFrameworkCore.Infrastructure", + "Microsoft.EntityFrameworkCore.Migrations", + "Microsoft.EntityFrameworkCore.Model", + "Microsoft.EntityFrameworkCore.Model.Validation", + "Microsoft.EntityFrameworkCore.Query", + "Microsoft.EntityFrameworkCore.Update")] diff --git a/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/ConfigurationSchema.json b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/ConfigurationSchema.json new file mode 100644 index 00000000000..6a7898b1a0a --- /dev/null +++ b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/ConfigurationSchema.json @@ -0,0 +1,100 @@ +{ + "definitions": { + "logLevel": { + "properties": { + "Microsoft.EntityFrameworkCore": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.ChangeTracking": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Database": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Database.Command": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Database.Connection": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Database.Transaction": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Infrastructure": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Migrations": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Model": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Model.Validation": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Query": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Update": { + "$ref": "#/definitions/logLevelThreshold" + } + } + } + }, + "properties": { + "Aspire": { + "type": "object", + "properties": { + "Pomelo": { + "type": "object", + "properties": { + "EntityFrameworkCore": { + "type": "object", + "properties": { + "MySql": { + "type": "object", + "properties": { + "ConnectionString": { + "type": "string", + "description": "Gets or sets the connection string of the MySQL database to connect to." + }, + "DbContextPooling": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the DbContext will be pooled or explicitly created every time it's requested.", + "default": true + }, + "HealthChecks": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the database health check is enabled or not.", + "default": true + }, + "MaxRetryCount": { + "type": "integer", + "description": "Gets or sets the maximum number of retry attempts." + }, + "Metrics": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are enabled or not.", + "default": true + }, + "ServerVersion": { + "type": "string", + "description": "Gets or sets the server version of the MySQL database to connect to." + }, + "Tracing": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is enabled or not.", + "default": true + } + }, + "description": "Provides the client configuration settings for connecting to a MySQL database using EntityFrameworkCore." + } + } + } + } + } + } + } + }, + "type": "object" +} diff --git a/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/PomeloEntityFrameworkCoreMySqlSettings.cs b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/PomeloEntityFrameworkCoreMySqlSettings.cs new file mode 100644 index 00000000000..ca0628d8684 --- /dev/null +++ b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/PomeloEntityFrameworkCoreMySqlSettings.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Pomelo.EntityFrameworkCore.MySql; + +/// +/// Provides the client configuration settings for connecting to a MySQL database using EntityFrameworkCore. +/// +public sealed class PomeloEntityFrameworkCoreMySqlSettings +{ + /// + /// Gets or sets the connection string of the MySQL database to connect to. + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets the server version of the MySQL database to connect to. + /// + public string? ServerVersion { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the DbContext will be pooled or explicitly created every time it's requested. + /// + /// + /// The default value is . + /// + /// Should be set to false in multi-tenant scenarios. + public bool DbContextPooling { get; set; } = true; + + /// + /// Gets or sets the maximum number of retry attempts. + /// + /// + /// The default is 6. + /// Set it to 0 to disable the retry mechanism. + /// + public int MaxRetryCount { get; set; } = 6; + + /// + /// Gets or sets a boolean value that indicates whether the database health check is enabled or not. + /// + /// + /// The default value is . + /// + public bool HealthChecks { get; set; } = true; + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is enabled or not. + /// + /// + /// The default value is . + /// + public bool Tracing { get; set; } = true; + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are enabled or not. + /// + /// + /// The default value is . + /// + public bool Metrics { get; set; } = true; +} diff --git a/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/README.md b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/README.md new file mode 100644 index 00000000000..09bfb54493b --- /dev/null +++ b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/README.md @@ -0,0 +1,117 @@ +# Aspire.Pomelo.EntityFrameworkCore.MySql library + +Registers [EntityFrameworkCore](https://learn.microsoft.com/ef/core/) [DbContext](https://learn.microsoft.com/dotnet/api/microsoft.entityframeworkcore.dbcontext) in the DI container for connecting MySql database. Enables connection pooling, health check, logging and telemetry. + +## Getting started + +### Prerequisites + +- MySQL database and connection string for accessing the database. + +### Install the package + +Install the .NET Aspire Pomelo EntityFrameworkCore MySQL library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Pomelo.EntityFrameworkCore.MySql +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddMySqlDbContext` extension method to register a `DbContext` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddMySqlDbContext("mysqldb"); +``` + +You can then retrieve the `MyDbContext` instance using dependency injection. For example, to retrieve the context from a Web API controller: + +```csharp +private readonly MyDbContext _context; + +public ProductsController(MyDbContext context) +{ + _context = context; +} +``` + +## Configuration + +The .NET Aspire Pomelo EntityFrameworkCore MySQL component provides multiple options to configure the database connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddMySqlDbContext()`: + +```csharp +builder.AddMySqlDbContext("myConnection"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "myConnection": "Server=myserver;Database=test" + } +} +``` + +See the [ConnectionString documentation](https://mysqlconnector.net/connection-options/) for more information on how to format this connection string. + +### Use configuration providers + +The .NET Aspire Pomelo EntityFrameworkCore MySQL component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). +It loads the `PomeloEntityFrameworkCoreMySqlSettings` from configuration by using the `Aspire:Pomelo:EntityFrameworkCore:MySql` key. +Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Pomelo": { + "EntityFrameworkCore": { + "MySql": { + "DbContextPooling": true, + "HealthChecks": false, + "Tracing": false + } + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable health checks from code: + +```csharp + builder.AddMySqlDbContext("mysqldb", settings => settings.HealthChecks = false); +``` + +## AppHost extensions + +In your AppHost project, register a MySQL container and consume the connection using the following methods: + +```csharp +var mysqldb = builder.AddMySql("mysql").AddDatabase("mysqldb"); + +var myService = builder.AddProject() + .WithReference(mysqldb); +``` + +The `WithReference` method configures a connection in the `MyService` project named `mysqldb`. +In the _Program.cs_ file of `MyService`, the database connection can be consumed using: + +```csharp +builder.AddMySqlDbContext("mysqldb"); +``` + +## Additional documentation + +* https://learn.microsoft.com/ef/core/ +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index 1e6224bba3d..7088644d977 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -22,9 +22,10 @@ As part of the .NET Aspire November preview, we want to include a set of .NET As | StackExchange.Redis.DistributedCaching | ✅ | ✅ | N/A | ✅ | ✅ | ✅ | ❌ | ✅ | | StackExchange.Redis.OutputCaching | ✅ | ✅ | N/A | ✅ | ✅ | ✅ | ❌ | ✅ | | RabbitMQ | ✅ | ✅ | ✅ | ✅ | | | ❌ | ✅ | -| MySqlConnector | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| MySqlConnector | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Oracle.EntityFrameworkCore | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Confluent.Kafka | ✅ | | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| Pomelo.EntityFrameworkCore.MySql | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Nomenclature used in the table above: diff --git a/src/Components/Telemetry.md b/src/Components/Telemetry.md index f84e43e4925..ccefeeda0a8 100644 --- a/src/Components/Telemetry.md +++ b/src/Components/Telemetry.md @@ -277,6 +277,43 @@ Aspire.Oracle.EntityFrameworkCore: - "ec_Microsoft_EntityFramew_total_optimistic_concurrency_failures" - "ec_Microsoft_EntityF_optimistic_concurrency_failures_per_second" +Aspire.Pomelo.EntityFrameworkCore.MySql: +- Log categories: + - "Microsoft.EntityFrameworkCore.ChangeTracking" + - "Microsoft.EntityFrameworkCore.Database.Command" + - "Microsoft.EntityFrameworkCore.Database.Connection" + - "Microsoft.EntityFrameworkCore.Database.Transaction" + - "Microsoft.EntityFrameworkCore.Infrastructure" + - "Microsoft.EntityFrameworkCore.Migrations" + - "Microsoft.EntityFrameworkCore.Model" + - "Microsoft.EntityFrameworkCore.Model.Validation" + - "Microsoft.EntityFrameworkCore.Query" + - "Microsoft.EntityFrameworkCore.Update" +- Activity source names: + - "MySqlConnector" +- Metric names: + - "Microsoft.EntityFrameworkCore": + - "ec_Microsoft_EntityFrameworkCore_active_db_contexts" + - "ec_Microsoft_EntityFrameworkCore_total_queries" + - "ec_Microsoft_EntityFrameworkCore_queries_per_second" + - "ec_Microsoft_EntityFrameworkCore_total_save_changes" + - "ec_Microsoft_EntityFrameworkCore_save_changes_per_second" + - "ec_Microsoft_EntityFrameworkCore_compiled_query_cache_hit_rate" + - "ec_Microsoft_Entity_total_execution_strategy_operation_failures" + - "ec_Microsoft_E_execution_strategy_operation_failures_per_second" + - "ec_Microsoft_EntityFramew_total_optimistic_concurrency_failures" + - "ec_Microsoft_EntityF_optimistic_concurrency_failures_per_second" + - "MySqlConnector": + - "db.client.connections.create_time" + - "db.client.connections.use_time" + - "db.client.connections.wait_time" + - "db.client.connections.idle.max" + - "db.client.connections.idle.min" + - "db.client.connections.max" + - "db.client.connections.pending_requests" + - "db.client.connections.timeouts" + - "db.client.connections.usage" + Aspire.RabbitMQ.Client: - Log categories: - "RabbitMQ.Client" diff --git a/tests/Aspire.Hosting.Tests/MySql/AddMySqlTests.cs b/tests/Aspire.Hosting.Tests/MySql/AddMySqlTests.cs new file mode 100644 index 00000000000..4aeca65a533 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/MySql/AddMySqlTests.cs @@ -0,0 +1,157 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Hosting.Tests.MySql; + +public class AddMySqlTests +{ + [Fact] + public void AddMySqlContainerWithDefaultsAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddMySqlContainer("mysql"); + + var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("mysql", containerResource.Name); + + var manifestAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.NotNull(manifestAnnotation.Callback); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal("latest", containerAnnotation.Tag); + Assert.Equal("mysql", containerAnnotation.Image); + Assert.Null(containerAnnotation.Registry); + + var endpoint = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(3306, endpoint.ContainerPort); + Assert.False(endpoint.IsExternal); + Assert.Equal("tcp", endpoint.Name); + Assert.Null(endpoint.Port); + Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); + Assert.Equal("tcp", endpoint.Transport); + Assert.Equal("tcp", endpoint.UriScheme); + + var envAnnotations = containerResource.Annotations.OfType(); + + var config = new Dictionary(); + var context = new EnvironmentCallbackContext("dcp", config); + + foreach (var annotation in envAnnotations) + { + annotation.Callback(context); + } + + Assert.Collection(config, + env => + { + Assert.Equal("MYSQL_ROOT_PASSWORD", env.Key); + Assert.False(string.IsNullOrEmpty(env.Value)); + }); + } + + [Fact] + public void AddMySqlAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddMySqlContainer("mysql", 1234, "pass"); + + var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.GetContainerResources()); + Assert.Equal("mysql", containerResource.Name); + + var manifestPublishing = Assert.Single(containerResource.Annotations.OfType()); + Assert.NotNull(manifestPublishing.Callback); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal("latest", containerAnnotation.Tag); + Assert.Equal("mysql", containerAnnotation.Image); + Assert.Null(containerAnnotation.Registry); + + var endpoint = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(3306, endpoint.ContainerPort); + Assert.False(endpoint.IsExternal); + Assert.Equal("tcp", endpoint.Name); + Assert.Equal(1234, endpoint.Port); + Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); + Assert.Equal("tcp", endpoint.Transport); + Assert.Equal("tcp", endpoint.UriScheme); + + var envAnnotations = containerResource.Annotations.OfType(); + + var config = new Dictionary(); + var context = new EnvironmentCallbackContext("dcp", config); + + foreach (var annotation in envAnnotations) + { + annotation.Callback(context); + } + + Assert.Collection(config, + env => + { + Assert.Equal("MYSQL_ROOT_PASSWORD", env.Key); + Assert.Equal("pass", env.Value); + }); + } + + [Fact] + public void MySqlCreatesConnectionString() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddMySqlContainer("mysql") + .WithAnnotation( + new AllocatedEndpointAnnotation("mybinding", + ProtocolType.Tcp, + "localhost", + 2000, + "https" + )); + + var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var connectionStringResource = Assert.Single(appModel.Resources.OfType()); + var connectionString = connectionStringResource.GetConnectionString(); + Assert.StartsWith("Server=localhost;Port=2000;User ID=root;Password=", connectionString); + Assert.EndsWith(";", connectionString); + } + + [Fact] + public void MySqlCreatesConnectionStringWithDatabase() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddMySqlContainer("mysql") + .WithAnnotation( + new AllocatedEndpointAnnotation("mybinding", + ProtocolType.Tcp, + "localhost", + 2000, + "https" + )) + .AddDatabase("db"); + + var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var mySqlResource = Assert.Single(appModel.Resources.OfType()); + var mySqlConnectionString = mySqlResource.GetConnectionString(); + var mySqlDatabaseResource = Assert.Single(appModel.Resources.OfType()); + var dbConnectionString = mySqlDatabaseResource.GetConnectionString(); + + Assert.EndsWith(";", mySqlConnectionString); + Assert.Equal(mySqlConnectionString + "Database=db", dbConnectionString); + } +} diff --git a/tests/Aspire.Hosting.Tests/MySql/PomeloEFCoreMySqlFunctionalTests.cs b/tests/Aspire.Hosting.Tests/MySql/PomeloEFCoreMySqlFunctionalTests.cs new file mode 100644 index 00000000000..62669e3a3c0 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/MySql/PomeloEFCoreMySqlFunctionalTests.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Tests.Helpers; +using Xunit; + +namespace Aspire.Hosting.Tests.MySql; + +[Collection("IntegrationServices")] +public class PomeloEFCoreMySqlFunctionalTests +{ + private readonly IntegrationServicesFixture _integrationServicesFixture; + + public PomeloEFCoreMySqlFunctionalTests(IntegrationServicesFixture integrationServicesFixture) + { + _integrationServicesFixture = integrationServicesFixture; + } + + [LocalOnlyFact()] + public async Task VerifyPomeloEFCoreMySqlWorks() + { + var testProgram = _integrationServicesFixture.TestProgram; + var client = _integrationServicesFixture.HttpClient; + + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + + var response = await testProgram.IntegrationServiceABuilder!.HttpGetAsync(client, "http", "/pomelo/verify", cts.Token); + var responseContent = await response.Content.ReadAsStringAsync(); + + Assert.True(response.IsSuccessStatusCode, responseContent); + } +} diff --git a/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests.csproj b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests.csproj new file mode 100644 index 00000000000..a5803bb42e1 --- /dev/null +++ b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests.csproj @@ -0,0 +1,19 @@ + + + + $(NetCurrent) + + + + + + + + + + + + + + + diff --git a/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/AspireEFMySqlExtensionsTests.cs b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/AspireEFMySqlExtensionsTests.cs new file mode 100644 index 00000000000..43d24b9d44f --- /dev/null +++ b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/AspireEFMySqlExtensionsTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure.Internal; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Pomelo.EntityFrameworkCore.MySql.Infrastructure.Internal; +using Xunit; + +namespace Aspire.Pomelo.EntityFrameworkCore.MySql.Tests; + +public class AspireEFMySqlExtensionsTests +{ + private const string ConnectionString = "Server=localhost;User ID=root;Database=test"; + private const string ConnectionStringSuffixAddedByPomelo = ";Allow User Variables=True;Use Affected Rows=False"; + + [Fact] + public void ReadsFromConnectionStringsCorrectly() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("Aspire:Pomelo:EntityFrameworkCore:MySql:ServerVersion", "8.2.0-mysql"), + new KeyValuePair("ConnectionStrings:mysql", ConnectionString) + ]); + + builder.AddMySqlDbContext("mysql"); + + var host = builder.Build(); + var context = host.Services.GetRequiredService(); + + Assert.Equal(ConnectionString + ConnectionStringSuffixAddedByPomelo, context.Database.GetDbConnection().ConnectionString); + } + + [Fact] + public void ConnectionStringCanBeSetInCode() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("Aspire:Pomelo:EntityFrameworkCore:MySql:ServerVersion", "8.2.0-mysql"), + new KeyValuePair("ConnectionStrings:mysql", "unused") + ]); + + builder.AddMySqlDbContext("mysql", settings => settings.ConnectionString = ConnectionString); + + var host = builder.Build(); + var context = host.Services.GetRequiredService(); + + var actualConnectionString = context.Database.GetDbConnection().ConnectionString; + Assert.Equal(ConnectionString + ConnectionStringSuffixAddedByPomelo, actualConnectionString); + // the connection string from config should not be used since code set it explicitly + Assert.DoesNotContain("unused", actualConnectionString); + } + + [Fact] + public void ConnectionNameWinsOverConfigSection() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("Aspire:Pomelo:EntityFrameworkCore:MySql:ServerVersion", "8.2.0-mysql"), + new KeyValuePair("Aspire:Pomelo:EntityFrameworkCore:MySql:ConnectionString", "unused"), + new KeyValuePair("ConnectionStrings:mysql", ConnectionString) + ]); + + builder.AddMySqlDbContext("mysql"); + + var host = builder.Build(); + var context = host.Services.GetRequiredService(); + + var actualConnectionString = context.Database.GetDbConnection().ConnectionString; + Assert.Equal(ConnectionString + ConnectionStringSuffixAddedByPomelo, actualConnectionString); + // the connection string from config should not be used since it was found in ConnectionStrings + Assert.DoesNotContain("unused", actualConnectionString); + } + + [Fact] + public void CanConfigureDbContextOptions() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("Aspire:Pomelo:EntityFrameworkCore:MySql:ServerVersion", "8.2.0-mysql"), + new KeyValuePair("ConnectionStrings:mysql", ConnectionString), + new KeyValuePair("Aspire:Pomelo:EntityFrameworkCore:MySql:MaxRetryCount", "304") + ]); + + builder.AddMySqlDbContext("mysql", configureDbContextOptions: optionsBuilder => + { + optionsBuilder.UseMySql(new MySqlServerVersion(new Version(8, 2, 0)), mySqlBuilder => + { + mySqlBuilder.CommandTimeout(123); + }); + }); + + var host = builder.Build(); + var context = host.Services.GetRequiredService(); + +#pragma warning disable EF1001 // Internal EF Core API usage. + + var extension = context.Options.FindExtension(); + Assert.NotNull(extension); + + // ensure the command timeout was respected + Assert.Equal(123, extension.CommandTimeout); + + // ensure the connection string from config was respected + var actualConnectionString = context.Database.GetDbConnection().ConnectionString; + Assert.Equal(ConnectionString + ";Allow User Variables=True;Default Command Timeout=123;Use Affected Rows=False", actualConnectionString); + + // ensure the max retry count from config was respected + Assert.NotNull(extension.ExecutionStrategyFactory); + var executionStrategy = extension.ExecutionStrategyFactory(new ExecutionStrategyDependencies(new CurrentDbContext(context), context.Options, null!)); + var retryStrategy = Assert.IsType(executionStrategy); + Assert.Equal(304, retryStrategy.MaxRetryCount); + +#pragma warning restore EF1001 // Internal EF Core API usage. + } +} diff --git a/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConfigurationTests.cs b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConfigurationTests.cs new file mode 100644 index 00000000000..96a7db1e029 --- /dev/null +++ b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConfigurationTests.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Aspire.Pomelo.EntityFrameworkCore.MySql.Tests; + +public class ConfigurationTests +{ + [Fact] + public void ConnectionStringIsNullByDefault() + => Assert.Null(new PomeloEntityFrameworkCoreMySqlSettings().ConnectionString); + + [Fact] + public void DbContextPoolingIsEnabledByDefault() + => Assert.True(new PomeloEntityFrameworkCoreMySqlSettings().DbContextPooling); + + [Fact] + public void HealthCheckIsEnabledByDefault() + => Assert.True(new PomeloEntityFrameworkCoreMySqlSettings().HealthChecks); + + [Fact] + public void TracingIsEnabledByDefault() + => Assert.True(new PomeloEntityFrameworkCoreMySqlSettings().Tracing); + + [Fact] + public void MetricsAreEnabledByDefault() + => Assert.True(new PomeloEntityFrameworkCoreMySqlSettings().Metrics); + + [Fact] + public void MaxRetryCountIsSameAsInTheDefaultPomeloPolicy() + { + DbContextOptionsBuilder dbContextOptionsBuilder = new(); + dbContextOptionsBuilder.UseMySql("Server=fake", new MySqlServerVersion(new Version(8, 2, 0))); + TestDbContext dbContext = new(dbContextOptionsBuilder.Options); + + Assert.Equal(new WorkaroundToReadProtectedField(dbContext).RetryCount, new PomeloEntityFrameworkCoreMySqlSettings().MaxRetryCount); + } + + public class WorkaroundToReadProtectedField : MySqlRetryingExecutionStrategy + { + public WorkaroundToReadProtectedField(DbContext context) : base(context) + { + } + + public int RetryCount => base.MaxRetryCount; + } +} diff --git a/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_NoPooling.cs b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_NoPooling.cs new file mode 100644 index 00000000000..089d07677f6 --- /dev/null +++ b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_NoPooling.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Aspire.Pomelo.EntityFrameworkCore.MySql.Tests; + +public class ConformanceTests_NoPooling : ConformanceTests_Pooling +{ + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Scoped; + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + builder.AddMySqlDbContext("mysql", settings => + { + settings.DbContextPooling = false; + + configure?.Invoke(settings); + }); + } +} diff --git a/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_NoPooling_TypeSpecificConfig.cs b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_NoPooling_TypeSpecificConfig.cs new file mode 100644 index 00000000000..fecac6c5b27 --- /dev/null +++ b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_NoPooling_TypeSpecificConfig.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.Extensions.Configuration; + +namespace Aspire.Pomelo.EntityFrameworkCore.MySql.Tests; + +public class ConformanceTests_NoPooling_TypeSpecificConfig : ConformanceTests_NoPooling +{ + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[2] + { + new($"Aspire:Pomelo:EntityFrameworkCore:MySql:{typeof(TestDbContext).Name}:ConnectionString", ConnectionString), + new($"Aspire:Pomelo:EntityFrameworkCore:MySql:{typeof(TestDbContext).Name}:ServerVersion", "8.2.0-mysql") + }); +} diff --git a/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_Pooling.cs b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_Pooling.cs new file mode 100644 index 00000000000..a27886dfde3 --- /dev/null +++ b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_Pooling.cs @@ -0,0 +1,151 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Components.ConformanceTests; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Pomelo.EntityFrameworkCore.MySql.Tests; + +public class ConformanceTests_Pooling : ConformanceTests +{ + // in the future it can become a static property that reads the value from Env Var + protected const string ConnectionString = "Server=localhost;User ID=root;Password=pass;Database=test"; + + private static readonly Lazy s_canConnectToServer = new(GetCanConnect); + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + // https://github.com/mysql-net/MySqlConnector/blob/6a63fa49795b54318085938e4a09cda0bc0ab2cd/src/MySqlConnector/Utilities/ActivitySourceHelper.cs#L61 + protected override string ActivitySourceName => "MySqlConnector"; + + protected override string[] RequiredLogCategories => new string[] + { + "Microsoft.EntityFrameworkCore.Infrastructure", + "Microsoft.EntityFrameworkCore.ChangeTracking", + "Microsoft.EntityFrameworkCore.Infrastructure", + "Microsoft.EntityFrameworkCore.Database.Command", + "Microsoft.EntityFrameworkCore.Query", + "Microsoft.EntityFrameworkCore.Database.Transaction", + "Microsoft.EntityFrameworkCore.Database.Connection", + "Microsoft.EntityFrameworkCore.Model", + "Microsoft.EntityFrameworkCore.Model.Validation", + "Microsoft.EntityFrameworkCore.Update", + "Microsoft.EntityFrameworkCore.Migrations", + "MySqlConnector.ConnectionPool", + "MySqlConnector.MySqlBulkCopy", + "MySqlConnector.MySqlCommand", + "MySqlConnector.MySqlConnection", + "MySqlConnector.MySqlDataSource", + }; + + protected override bool CanConnectToServer => s_canConnectToServer.Value; + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Pomelo": { + "EntityFrameworkCore": { + "MySql": { + "ConnectionString": "YOUR_CONNECTION_STRING", + "HealthChecks": false, + "DbContextPooling": true, + "Tracing": true, + "Metrics": true + } + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "Pomelo": { "EntityFrameworkCore":{ "MySql": { "MaxRetryCount": "5"}}}}}""", "Value is \"string\" but should be \"integer\""), + ("""{"Aspire": { "Pomelo": { "EntityFrameworkCore":{ "MySql": { "HealthChecks": "false"}}}}}""", "Value is \"string\" but should be \"boolean\""), + ("""{"Aspire": { "Pomelo": { "EntityFrameworkCore":{ "MySql": { "ConnectionString": "", "DbContextPooling": "Yes"}}}}}""", "Value is \"string\" but should be \"boolean\"") + }; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[2] + { + new KeyValuePair("Aspire:Pomelo:EntityFrameworkCore:MySql:ConnectionString", ConnectionString), + new KeyValuePair("Aspire:Pomelo:EntityFrameworkCore:MySql:ServerVersion", "8.2.0-mysql") + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + => builder.AddMySqlDbContext("mysql", configure); + + protected override void SetHealthCheck(PomeloEntityFrameworkCoreMySqlSettings options, bool enabled) + => options.HealthChecks = enabled; + + protected override void SetTracing(PomeloEntityFrameworkCoreMySqlSettings options, bool enabled) + => options.Tracing = enabled; + + protected override void SetMetrics(PomeloEntityFrameworkCoreMySqlSettings options, bool enabled) + => options.Metrics = enabled; + + protected override void TriggerActivity(TestDbContext service) + { + if (service.Database.CanConnect()) + { + service.Database.EnsureCreated(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required to verify pooling without touching DB")] + public void DbContextPoolingRegistersIDbContextPool(bool enabled) + { + using IHost host = CreateHostWithComponent(options => options.DbContextPooling = enabled); + + IDbContextPool? pool = host.Services.GetService>(); + + Assert.Equal(enabled, pool is not null); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DbContextCanBeAlwaysResolved(bool enabled) + { + using IHost host = CreateHostWithComponent(options => options.DbContextPooling = enabled); + + TestDbContext? dbContext = host.Services.GetService(); + + Assert.NotNull(dbContext); + } + + [ConditionalFact] + public void TracingEnablesTheRightActivitySource() + { + SkipIfCanNotConnectToServer(); + + RemoteExecutor.Invoke(() => ActivitySourceTest(key: null)).Dispose(); + } + + private static bool GetCanConnect() + { + var builder = new DbContextOptionsBuilder().UseMySql(connectionString: ConnectionString, new MySqlServerVersion(new Version(8, 2, 0))); + using TestDbContext dbContext = new(builder.Options); + + try + { + dbContext.Database.EnsureCreated(); + + return true; + } + catch (InvalidOperationException) + { + return false; + } + } +} diff --git a/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_Pooling_TypeSpecificConfig.cs b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_Pooling_TypeSpecificConfig.cs new file mode 100644 index 00000000000..a849da81b19 --- /dev/null +++ b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/ConformanceTests_Pooling_TypeSpecificConfig.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.Extensions.Configuration; + +namespace Aspire.Pomelo.EntityFrameworkCore.MySql.Tests; + +public class ConformanceTests_Pooling_TypeSpecificConfig : ConformanceTests_Pooling +{ + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[2] + { + new($"Aspire:Pomelo:EntityFrameworkCore:MySql:{typeof(TestDbContext).Name}:ConnectionString", ConnectionString), + new($"Aspire:Pomelo:EntityFrameworkCore:MySql:{typeof(TestDbContext).Name}:ServerVersion", "8.2.0-mysql") + }); +} diff --git a/tests/testproject/TestProject.IntegrationServiceA/MySql/PomeloDbContext.cs b/tests/testproject/TestProject.IntegrationServiceA/MySql/PomeloDbContext.cs new file mode 100644 index 00000000000..e326b98e1cf --- /dev/null +++ b/tests/testproject/TestProject.IntegrationServiceA/MySql/PomeloDbContext.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore; + +public class PomeloDbContext(DbContextOptions options) : DbContext(options) +{ +} diff --git a/tests/testproject/TestProject.IntegrationServiceA/MySql/PomeloEFCoreMySqlExtensions.cs b/tests/testproject/TestProject.IntegrationServiceA/MySql/PomeloEFCoreMySqlExtensions.cs new file mode 100644 index 00000000000..e16edbe3161 --- /dev/null +++ b/tests/testproject/TestProject.IntegrationServiceA/MySql/PomeloEFCoreMySqlExtensions.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore; + +public static class PomeloEFCoreMySqlExtensions +{ + public static void MapPomeloEFCoreMySqlApi(this WebApplication app) + { + app.MapGet("/pomelo/verify", VerifyPomeloEFCoreMySqlAsync); + } + + private static IResult VerifyPomeloEFCoreMySqlAsync(PomeloDbContext dbContext) + { + try + { + var results = dbContext.Database.SqlQueryRaw("SELECT 1"); + return results.Any() ? Results.Ok("Success!") : Results.Problem("Failed"); + } + catch (Exception e) + { + return Results.Problem(e.ToString()); + } + } +} diff --git a/tests/testproject/TestProject.IntegrationServiceA/Program.cs b/tests/testproject/TestProject.IntegrationServiceA/Program.cs index 7f71be0db0f..43e7dea06c1 100644 --- a/tests/testproject/TestProject.IntegrationServiceA/Program.cs +++ b/tests/testproject/TestProject.IntegrationServiceA/Program.cs @@ -4,6 +4,7 @@ var builder = WebApplication.CreateBuilder(args); builder.AddSqlServerClient("tempdb"); builder.AddMySqlDataSource("mysqldb"); +builder.AddMySqlDbContext("mysqldb", settings => settings.ServerVersion = "8.2.0-mysql"); builder.AddRedis("rediscontainer"); builder.AddNpgsqlDataSource("postgresdb"); builder.AddRabbitMQ("rabbitmqcontainer"); @@ -48,6 +49,8 @@ app.MapMySqlApi(); +app.MapPomeloEFCoreMySqlApi(); + app.MapPostgresApi(); app.MapSqlServerApi(); diff --git a/tests/testproject/TestProject.IntegrationServiceA/TestProject.IntegrationServiceA.csproj b/tests/testproject/TestProject.IntegrationServiceA/TestProject.IntegrationServiceA.csproj index b3351f97b68..699e21f840c 100644 --- a/tests/testproject/TestProject.IntegrationServiceA/TestProject.IntegrationServiceA.csproj +++ b/tests/testproject/TestProject.IntegrationServiceA/TestProject.IntegrationServiceA.csproj @@ -16,11 +16,11 @@ - +