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 @@
-
+