diff --git a/playground/mysql/MySql.ApiService/MySql.ApiService.http b/playground/mysql/MySql.ApiService/MySql.ApiService.http index 77a660c4a6c..4ae498166a1 100644 --- a/playground/mysql/MySql.ApiService/MySql.ApiService.http +++ b/playground/mysql/MySql.ApiService/MySql.ApiService.http @@ -24,3 +24,6 @@ DELETE {{HostAddress}}/catalog/4 Accept: application/json ### + +GET {{HostAddress}}/myTestDb2/ +Accept: application/json diff --git a/playground/mysql/MySql.ApiService/Program.cs b/playground/mysql/MySql.ApiService/Program.cs index e5ffd065396..3c0bb998f5d 100644 --- a/playground/mysql/MySql.ApiService/Program.cs +++ b/playground/mysql/MySql.ApiService/Program.cs @@ -12,6 +12,7 @@ builder.Services.AddProblemDetails(); builder.AddMySqlDataSource("Catalog"); +builder.AddKeyedMySqlDataSource("myTestDb2"); var app = builder.Build(); @@ -63,6 +64,17 @@ DELETE FROM catalog return rows > 0 ? Results.NoContent() : Results.NotFound(); }); +app.MapGet("/myTestDb2", async ([FromKeyedServices("myTestDb2")] MySqlConnection db) => +{ + const string sql = """ + SELECT id, name + FROM example_table + """; + + return await db.QueryAsync(sql); +}); + app.Run(); public record CatalogItem(int Id, string Name, string Description, decimal Price); +public record ExampleTableItem(int Id, string Name); diff --git a/playground/mysql/MySqlDb.AppHost/Program.cs b/playground/mysql/MySqlDb.AppHost/Program.cs index dd89bdfbb48..c67535dedb0 100644 --- a/playground/mysql/MySqlDb.AppHost/Program.cs +++ b/playground/mysql/MySqlDb.AppHost/Program.cs @@ -4,14 +4,33 @@ var builder = DistributedApplication.CreateBuilder(args); var catalogDbName = "catalog"; // MySql database & table names are case-sensitive on non-Windows. -var catalogDb = builder.AddMySql("mysql") +var mySql = builder.AddMySql("mysql") .WithEnvironment("MYSQL_DATABASE", catalogDbName) .WithBindMount("../MySql.ApiService/data", "/docker-entrypoint-initdb.d") - .WithPhpMyAdmin() - .AddDatabase(catalogDbName); + .WithPhpMyAdmin(); + +var catalogDb = mySql.AddDatabase(catalogDbName); + +var myTestDb = mySql.AddDatabase("myTestDb"); + +var myTestDb2 = mySql.AddDatabase("myTestDb2").WithCreationScript($""" + + CREATE DATABASE IF NOT EXISTS `myTestDb2`; + + USE myTestDb2; + + CREATE TABLE IF NOT EXISTS example_table ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL + ); + + INSERT INTO example_table (name) VALUES ('Example Name 1'); +"""); builder.AddProject("apiservice") .WithExternalHttpEndpoints() - .WithReference(catalogDb).WaitFor(catalogDb); + .WithReference(catalogDb).WaitFor(catalogDb) + .WithReference(myTestDb).WaitFor(myTestDb) + .WithReference(myTestDb2).WaitFor(myTestDb2); builder.Build().Run(); diff --git a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs index 8d371f12a6a..6855715b026 100644 --- a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs +++ b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs @@ -5,6 +5,8 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.MySql; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MySqlConnector; namespace Aspire.Hosting; @@ -48,6 +50,27 @@ public static IResourceBuilder AddMySql(this IDistributedAp } }); + builder.Eventing.Subscribe(resource, async (@event, ct) => + { + if (connectionString is null) + { + throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{resource.Name}' resource but the connection string was null."); + } + + using var sqlConnection = new MySqlConnection(connectionString); + await sqlConnection.OpenAsync(ct).ConfigureAwait(false); + + if (sqlConnection.State != System.Data.ConnectionState.Open) + { + throw new InvalidOperationException($"Could not open connection to '{resource.Name}'"); + } + + foreach (var sqlDatabase in resource.DatabaseResources) + { + await CreateDatabaseAsync(sqlConnection, sqlDatabase, @event.Services, ct).ConfigureAwait(false); + } + }); + var healthCheckKey = $"{name}_check"; builder.Services.AddHealthChecks().AddMySql(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); @@ -77,9 +100,79 @@ public static IResourceBuilder AddDatabase(this IResource // Use the resource name as the database name if it's not provided databaseName ??= name; - builder.Resource.AddDatabase(name, databaseName); var mySqlDatabase = new MySqlDatabaseResource(name, databaseName, builder.Resource); - return builder.ApplicationBuilder.AddResource(mySqlDatabase); + + builder.Resource.AddDatabase(mySqlDatabase); + + string? connectionString = null; + + builder.ApplicationBuilder.Eventing.Subscribe(mySqlDatabase, async (@event, ct) => + { + connectionString = await mySqlDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + + if (connectionString is null) + { + throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{name}' resource but the connection string was null."); + } + }); + + var healthCheckKey = $"{name}_check"; + builder.ApplicationBuilder.Services.AddHealthChecks().AddMySql(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); + + return builder.ApplicationBuilder + .AddResource(mySqlDatabase) + .WithHealthCheck(healthCheckKey); + } + + private static async Task CreateDatabaseAsync(MySqlConnection sqlConnection, MySqlDatabaseResource sqlDatabase, IServiceProvider serviceProvider, CancellationToken ct) + { + var logger = serviceProvider.GetRequiredService().GetLogger(sqlDatabase.Parent); + + logger.LogDebug("Creating database '{DatabaseName}'", sqlDatabase.DatabaseName); + + try + { + var scriptAnnotation = sqlDatabase.Annotations.OfType().LastOrDefault(); + + if (scriptAnnotation?.Script is null) + { + var quotedDatabaseIdentifier = new MySqlCommandBuilder().QuoteIdentifier(sqlDatabase.DatabaseName); + using var command = sqlConnection.CreateCommand(); + command.CommandText = $"CREATE DATABASE IF NOT EXISTS {quotedDatabaseIdentifier};"; + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + else + { + using var command = sqlConnection.CreateCommand(); + command.CommandText = scriptAnnotation.Script; + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + logger.LogDebug("Database '{DatabaseName}' created successfully", sqlDatabase.DatabaseName); + } + catch (Exception e) + { + logger.LogError(e, "Failed to create database '{DatabaseName}'", sqlDatabase.DatabaseName); + } + } + + /// + /// Defines the SQL script used to create the database. + /// + /// The builder for the . + /// The SQL script used to create the database. + /// A reference to the . + /// + /// Default script is CREATE DATABASE IF NOT EXISTS `QUOTED_DATABASE_NAME`; + /// + public static IResourceBuilder WithCreationScript(this IResourceBuilder builder, string script) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(script); + + builder.WithAnnotation(new MySqlCreateDatabaseScriptAnnotation(script)); + + return builder; } /// diff --git a/src/Aspire.Hosting.MySql/MySqlCreateDatabaseScriptAnnotation.cs b/src/Aspire.Hosting.MySql/MySqlCreateDatabaseScriptAnnotation.cs new file mode 100644 index 00000000000..36e5c6c52e3 --- /dev/null +++ b/src/Aspire.Hosting.MySql/MySqlCreateDatabaseScriptAnnotation.cs @@ -0,0 +1,27 @@ +// 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.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Represents an annotation for defining a script to create a database in MySql. +/// +internal sealed class MySqlCreateDatabaseScriptAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// The script used to create the database. + public MySqlCreateDatabaseScriptAnnotation(string script) + { + ArgumentNullException.ThrowIfNull(script); + Script = script; + } + + /// + /// Gets the script used to create the database. + /// + public string Script { get; } +} diff --git a/src/Aspire.Hosting.MySql/MySqlDatabaseResource.cs b/src/Aspire.Hosting.MySql/MySqlDatabaseResource.cs index 6acef5cb340..71de49ba54d 100644 --- a/src/Aspire.Hosting.MySql/MySqlDatabaseResource.cs +++ b/src/Aspire.Hosting.MySql/MySqlDatabaseResource.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using MySqlConnector; namespace Aspire.Hosting.ApplicationModel; @@ -23,9 +24,18 @@ public class MySqlDatabaseResource(string name, string databaseName, MySqlServer /// /// Gets the connection string expression for the MySQL database. /// - public ReferenceExpression ConnectionStringExpression => - ReferenceExpression.Create($"{Parent};Database={DatabaseName}"); + public ReferenceExpression ConnectionStringExpression + { + get + { + var connectionStringBuilder = new MySqlConnectionStringBuilder + { + ["Database"] = DatabaseName + }; + return ReferenceExpression.Create($"{Parent};{connectionStringBuilder.ToString()}"); + } + } /// /// Gets the database name. /// diff --git a/src/Aspire.Hosting.MySql/MySqlServerResource.cs b/src/Aspire.Hosting.MySql/MySqlServerResource.cs index 3815334ec4d..3ca75e56eaf 100644 --- a/src/Aspire.Hosting.MySql/MySqlServerResource.cs +++ b/src/Aspire.Hosting.MySql/MySqlServerResource.cs @@ -10,6 +10,9 @@ public class MySqlServerResource : ContainerResource, IResourceWithConnectionStr { internal static string PrimaryEndpointName => "tcp"; + private readonly Dictionary _databases = new(StringComparers.ResourceName); + private readonly List _databaseResources = []; + /// /// Initializes a new instance of the class. /// @@ -40,15 +43,16 @@ public MySqlServerResource(string name, ParameterResource password) : base(name) ReferenceExpression.Create( $"Server={PrimaryEndpoint.Property(EndpointProperty.Host)};Port={PrimaryEndpoint.Property(EndpointProperty.Port)};User ID=root;Password={PasswordParameter}"); - private readonly Dictionary _databases = new Dictionary(StringComparers.ResourceName); - /// /// A dictionary where the key is the resource name and the value is the database name. /// public IReadOnlyDictionary Databases => _databases; - internal void AddDatabase(string name, string databaseName) + internal IReadOnlyList DatabaseResources => _databaseResources; + + internal void AddDatabase(MySqlDatabaseResource database) { - _databases.TryAdd(name, databaseName); + _databases.TryAdd(database.Name, database.DatabaseName); + _databaseResources.Add(database); } } diff --git a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs index cd173fab18a..0cef5868b28 100644 --- a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs +++ b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs @@ -521,4 +521,241 @@ public async Task MySql_WithPersistentLifetime_ReusesContainers(bool useMultiple return resourceEvent.Snapshot.Properties.FirstOrDefault(x => x.Name == "container.id")?.Value?.ToString(); } } + + [Theory] + [InlineData(false)] + [InlineData(true)] + [RequiresDocker] + public async Task AddDatabaseCreatesNewDatabaseWithCustomScript(bool addEnvVar) + { + var mySqlDbName = "my-test-db"; + + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new() { MaxRetryAttempts = 10, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2) }) + .Build(); + + using var builder = TestDistributedApplicationBuilder.Create(o => { }, testOutputHelper); + + var mysql = builder.AddMySql("mysql"); + + if (addEnvVar) + { + mysql = mysql.WithEnvironment("MYSQL_DATABASE", mySqlDbName); + } + + // Create a database with Accent Insensitive collation + var newDb = mysql.AddDatabase(mySqlDbName) + .WithCreationScript($""" + CREATE DATABASE IF NOT EXISTS `{mySqlDbName}`; + + USE `{mySqlDbName}`; + + CREATE TABLE IF NOT EXISTS example_table ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL + ); + + INSERT INTO example_table (name) VALUES ('Example Name 1'); + """); + + using var app = builder.Build(); + + await app.StartAsync(cts.Token); + + var hb = Host.CreateApplicationBuilder(); + + hb.Configuration[$"ConnectionStrings:{newDb.Resource.Name}"] = await newDb.Resource.ConnectionStringExpression.GetValueAsync(default); + + hb.AddMySqlDataSource(newDb.Resource.Name); + + using var host = hb.Build(); + + await host.StartAsync(); + + await app.ResourceNotifications.WaitForResourceHealthyAsync(newDb.Resource.Name, cts.Token); + + // Test SqlConnection + await pipeline.ExecuteAsync(async token => + { + var conn = host.Services.GetRequiredService(); + + if (conn.State != System.Data.ConnectionState.Open) + { + await conn.OpenAsync(token); + } + + var selectCommand = conn.CreateCommand(); + selectCommand.CommandText = $"SELECT * FROM `example_table`"; + + var results = await selectCommand.ExecuteReaderAsync(token); + Assert.True(results.HasRows); + }, cts.Token); + } + + [Fact] + [RequiresDocker] + public async Task AddDatabaseCreatesDatabaseResiliently() + { + // Creating the database multiple times should not fail + + const string databaseName = "db1"; + const string resourceName = "db"; + + string? volumeName = null; + + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new() { MaxRetryAttempts = 3, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2) }) + .Build(); + + var password = "p@ssw0rd1"; + + try + { + for (var i = 0; i < 2; i++) + { + using var builder = TestDistributedApplicationBuilder.Create(o => { }, testOutputHelper); + + var passwordParameter = builder.AddParameter("pwd", password, secret: true); + + var mysql = builder.AddMySql("db1", passwordParameter); + + // Use a deterministic volume name to prevent them from exhausting the machines if deletion fails + volumeName = VolumeNameGenerator.Generate(mysql, nameof(AddDatabaseCreatesDatabaseResiliently)); + + if (i == 0) + { + // If the volume already exists (because of a crashing previous run), delete it + DockerUtils.AttemptDeleteDockerVolume(volumeName); + } + + mysql.WithDataVolume(volumeName); + + var newDb = mysql.AddDatabase(resourceName, databaseName); + + using var app = builder.Build(); + + await app.StartAsync(cts.Token); + + var hb = Host.CreateApplicationBuilder(); + + hb.Configuration[$"ConnectionStrings:{newDb.Resource.Name}"] = await newDb.Resource.ConnectionStringExpression.GetValueAsync(default); + + hb.AddMySqlDataSource(newDb.Resource.Name); + + using var host = hb.Build(); + + await host.StartAsync(); + + await app.ResourceNotifications.WaitForResourceHealthyAsync(mysql.Resource.Name, cts.Token); + + // Test connection + await pipeline.ExecuteAsync(async token => + { + var conn = host.Services.GetRequiredService(); + + if (conn.State != ConnectionState.Open) + { + await conn.OpenAsync(token); + } + + Assert.Equal(ConnectionState.Open, conn.State); + }, cts.Token); + + await app.StopAsync(cts.Token); + } + } + finally + { + if (volumeName is not null) + { + DockerUtils.AttemptDeleteDockerVolume(volumeName); + } + } + } + + [Fact] + [RequiresDocker] + public async Task AddDatabaseCreatesMultipleDatabases() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + + using var builder = TestDistributedApplicationBuilder.Create(o => { }, testOutputHelper); + + var mysql = builder.AddMySql("mysql"); + + var db1 = mysql.AddDatabase("db1"); + var db2 = mysql.AddDatabase("db2"); + var db3 = mysql.AddDatabase("db3"); + + var dbs = new[] { db1, db2, db3 }; + + using var app = builder.Build(); + + await app.StartAsync(cts.Token); + + var hb = Host.CreateApplicationBuilder(); + + foreach (var db in dbs) + { + hb.Configuration[$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(default); + hb.AddKeyedMySqlDataSource(db.Resource.Name); + } + + using var host = hb.Build(); + + await host.StartAsync(); + + foreach (var db in dbs) + { + await app.ResourceNotifications.WaitForResourceHealthyAsync(db.Resource.Name, cts.Token); + + var conn = host.Services.GetRequiredKeyedService(db.Resource.Name); + + if (conn.State != ConnectionState.Open) + { + await conn.OpenAsync(cts.Token); + } + + Assert.Equal(ConnectionState.Open, conn.State); + } + } + + [Fact] + [RequiresDocker] + public async Task AddDatabaseCreatesDatabaseWithSpecialNames() + { + const string databaseName = "!']`'[\""; + const string resourceName = "db"; + + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + + var mysql = builder.AddMySql("mysql"); + var newDb = mysql.AddDatabase(resourceName, databaseName); + + using var app = builder.Build(); + + await app.StartAsync(cts.Token); + + var hb = Host.CreateApplicationBuilder(); + hb.Configuration[$"ConnectionStrings:{newDb.Resource.Name}"] = await newDb.Resource.ConnectionStringExpression.GetValueAsync(default); + hb.AddMySqlDataSource(newDb.Resource.Name); + + using var host = hb.Build(); + await host.StartAsync(); + + await app.ResourceNotifications.WaitForResourceHealthyAsync(newDb.Resource.Name, cts.Token); + + var conn = host.Services.GetRequiredService(); + + if (conn.State != ConnectionState.Open) + { + await conn.OpenAsync(cts.Token); + } + + Assert.Equal(ConnectionState.Open, conn.State); + } }