From f50d0f608899773cda78e397e5fc26c0218866af Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 11 Mar 2025 10:38:07 -0700 Subject: [PATCH 01/15] Create SQL Server database automatically --- .../SqlServerEndToEnd.AppHost/Program.cs | 9 +-- .../SqlServerBuilderExtensions.cs | 65 +++++++++++++++++-- .../SqlServerDatabaseResource.cs | 6 ++ .../SqlServerServerResource.cs | 8 ++- .../SqlServerFunctionalTests.cs | 20 +++--- 5 files changed, 89 insertions(+), 19 deletions(-) diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/Program.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/Program.cs index e120df70897..fff8d2ad528 100644 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/Program.cs +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/Program.cs @@ -13,15 +13,16 @@ var db2 = sql2.AddDatabase("db2"); -var dbsetup = builder.AddProject("dbsetup") - .WithReference(db1).WaitFor(sql1) - .WithReference(db2).WaitFor(sql2); +//var dbsetup = builder.AddProject("dbsetup") +// .WithReference(db1).WaitFor(sql1) +// .WithReference(db2).WaitFor(sql2); builder.AddProject("api") .WithExternalHttpEndpoints() .WithReference(db1).WaitFor(db1) .WithReference(db2).WaitFor(db2) - .WaitForCompletion(dbsetup); + //.WaitForCompletion(dbsetup) + ; #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 5d7d2bb1ced..8032bef9535 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -3,7 +3,9 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Microsoft.Data.SqlClient; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -45,6 +47,39 @@ public static IResourceBuilder AddSqlServer(this IDistr } }); + builder.Eventing.Subscribe(sqlServer, async (@event, ct) => + { + if (connectionString is null) + { + throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{sqlServer.Name}' resource but the connection string was null."); + } + + using var sqlConnection = new SqlConnection(connectionString); + await sqlConnection.OpenAsync(ct).ConfigureAwait(false); + + foreach (var sqlDatabase in sqlServer.DatabaseResources) + { + var quotedDatabaseIdentifier = new SqlCommandBuilder().QuoteIdentifier(sqlDatabase.DatabaseName); + + try + { + if (sqlConnection.State != System.Data.ConnectionState.Open) + { + throw new InvalidOperationException($"Could not open connection to '{sqlServer.Name}'"); + } + + using var command = sqlConnection.CreateCommand(); + command.CommandText = sqlDatabase.DatabaseCreationScript ?? $"IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = @DatabaseName ) ) CREATE DATABASE {quotedDatabaseIdentifier};"; + command.Parameters.Add(new SqlParameter("@DatabaseName", sqlDatabase.DatabaseName)); + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + catch (Exception e) + { + @event.Services.GetRequiredService>().LogError(e, "Failed to create database '{DatabaseName}'", sqlDatabase.DatabaseName); + } + } + }); + var healthCheckKey = $"{name}_check"; builder.Services.AddHealthChecks().AddSqlServer(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); @@ -66,18 +101,40 @@ public static IResourceBuilder AddSqlServer(this IDistr /// The SQL Server resource builders. /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. /// The name of the database. If not provided, this defaults to the same value as . + /// A custom database creation script. /// A reference to the . - public static IResourceBuilder AddDatabase(this IResourceBuilder builder, [ResourceName] string name, string? databaseName = null) + public static IResourceBuilder AddDatabase(this IResourceBuilder builder, [ResourceName] string name, string? databaseName = null, string? databaseCreationScript = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); + Dictionary databaseConnectionStrings = []; + // Use the resource name as the database name if it's not provided databaseName ??= name; - builder.Resource.AddDatabase(name, databaseName); - var sqlServerDatabase = new SqlServerDatabaseResource(name, databaseName, builder.Resource); - return builder.ApplicationBuilder.AddResource(sqlServerDatabase); + var sqlServerDatabase = new SqlServerDatabaseResource(name, databaseName, builder.Resource) { DatabaseCreationScript = databaseCreationScript }; + + builder.Resource.AddDatabase(sqlServerDatabase); + + builder.ApplicationBuilder.Eventing.Subscribe(sqlServerDatabase, async (@event, ct) => + { + var databaseConnectionString = await sqlServerDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + + if (databaseConnectionString == null) + { + throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{name}' resource but the connection string was null."); + } + + databaseConnectionStrings[name] = databaseConnectionString; + }); + + var healthCheckKey = $"{name}_check"; + builder.ApplicationBuilder.Services.AddHealthChecks().AddSqlServer(sp => databaseConnectionStrings[name] ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); + + return builder.ApplicationBuilder + .AddResource(sqlServerDatabase) + .WithHealthCheck(healthCheckKey); } /// diff --git a/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs b/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs index 0ab725cc679..fa85d7460be 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs @@ -31,6 +31,12 @@ public class SqlServerDatabaseResource(string name, string databaseName, SqlServ /// public string DatabaseName { get; } = ThrowIfNullOrEmpty(databaseName); + /// + /// Gets or sets the database creation script. + /// + /// Default is IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = '<ESCAPED_DATABASE_NAME%gt;' ) ) CREATE DATABASE [<QUOTED_DATABASE_NAME%gt;]; + public string? DatabaseCreationScript { get; set; } + private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) { ArgumentException.ThrowIfNullOrEmpty(argument, paramName); diff --git a/src/Aspire.Hosting.SqlServer/SqlServerServerResource.cs b/src/Aspire.Hosting.SqlServer/SqlServerServerResource.cs index ab114a9cc03..addfa0b027a 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerServerResource.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerServerResource.cs @@ -69,14 +69,18 @@ public ReferenceExpression ConnectionStringExpression } private readonly Dictionary _databases = new(StringComparers.ResourceName); + private readonly List _databaseResources = []; /// /// 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 void AddDatabase(SqlServerDatabaseResource database) { - _databases.TryAdd(name, databaseName); + _databases.TryAdd(database.Name, database.DatabaseName); + _databaseResources.Add(database); } + + internal IReadOnlyList DatabaseResources => _databaseResources; } diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index 8427d31d72a..eb6d262fefb 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -119,9 +119,11 @@ await pipeline.ExecuteAsync(async token => [RequiresDocker] public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) { + const string databaseName = "db"; + string? volumeName = null; string? bindMountPath = null; - + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); var pipeline = new ResiliencePipelineBuilder() .AddRetry(new() { MaxRetryAttempts = int.MaxValue, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2) }) @@ -132,7 +134,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) using var builder1 = TestDistributedApplicationBuilder.Create(o => { }, testOutputHelper); var sqlserver1 = builder1.AddSqlServer("sqlserver"); - var masterdb1 = sqlserver1.AddDatabase("master"); + var db1 = sqlserver1.AddDatabase(databaseName); var password = sqlserver1.Resource.PasswordParameter.Value; @@ -171,7 +173,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) await app1.StartAsync(); - await app1.ResourceNotifications.WaitForResourceHealthyAsync(masterdb1.Resource.Name, cts.Token); + await app1.ResourceNotifications.WaitForResourceHealthyAsync(db1.Resource.Name, cts.Token); try { @@ -179,10 +181,10 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) hb1.Configuration.AddInMemoryCollection(new Dictionary { - [$"ConnectionStrings:{masterdb1.Resource.Name}"] = await masterdb1.Resource.ConnectionStringExpression.GetValueAsync(default), + [$"ConnectionStrings:{db1.Resource.Name}"] = await db1.Resource.ConnectionStringExpression.GetValueAsync(default), }); - hb1.AddSqlServerClient(masterdb1.Resource.Name); + hb1.AddSqlServerClient(db1.Resource.Name); using var host1 = hb1.Build(); @@ -239,7 +241,7 @@ await pipeline.ExecuteAsync(async token => var passwordParameter2 = builder2.AddParameter("pwd", password); var sqlserver2 = builder2.AddSqlServer("sqlserver2", passwordParameter2); - var masterdb2 = sqlserver2.AddDatabase("master"); + var db2 = sqlserver2.AddDatabase(databaseName); if (useVolume) { @@ -254,7 +256,7 @@ await pipeline.ExecuteAsync(async token => { await app2.StartAsync(); - await app2.ResourceNotifications.WaitForResourceHealthyAsync(masterdb2.Resource.Name, cts.Token); + await app2.ResourceNotifications.WaitForResourceHealthyAsync(db2.Resource.Name, cts.Token); try { @@ -262,10 +264,10 @@ await pipeline.ExecuteAsync(async token => hb2.Configuration.AddInMemoryCollection(new Dictionary { - [$"ConnectionStrings:{masterdb2.Resource.Name}"] = await masterdb2.Resource.ConnectionStringExpression.GetValueAsync(default), + [$"ConnectionStrings:{db2.Resource.Name}"] = await db2.Resource.ConnectionStringExpression.GetValueAsync(default), }); - hb2.AddSqlServerClient(masterdb2.Resource.Name); + hb2.AddSqlServerClient(db2.Resource.Name); using (var host2 = hb2.Build()) { From 93efeb2fa92d414787af5c921990888f773d35c4 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 11 Mar 2025 14:58:19 -0700 Subject: [PATCH 02/15] Update tests to ensure new dbs are created --- .../SqlServerFunctionalTests.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index eb6d262fefb..b4bdc42d7c4 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -60,6 +60,8 @@ public async Task VerifyWaitForOnSqlServerBlocksDependentResources() [RequiresDocker] public async Task VerifySqlServerResource() { + const string databaseName = "newdb"; + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); var pipeline = new ResiliencePipelineBuilder() .AddRetry(new() { MaxRetryAttempts = int.MaxValue, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2) }) @@ -68,7 +70,7 @@ public async Task VerifySqlServerResource() using var builder = TestDistributedApplicationBuilder.Create(o => { }, testOutputHelper); var sqlserver = builder.AddSqlServer("sqlserver"); - var tempDb = sqlserver.AddDatabase("tempdb"); + var newDb = sqlserver.AddDatabase(databaseName); using var app = builder.Build(); @@ -76,10 +78,10 @@ public async Task VerifySqlServerResource() var hb = Host.CreateApplicationBuilder(); - hb.Configuration[$"ConnectionStrings:{tempDb.Resource.Name}"] = await tempDb.Resource.ConnectionStringExpression.GetValueAsync(default); + hb.Configuration[$"ConnectionStrings:{newDb.Resource.Name}"] = await newDb.Resource.ConnectionStringExpression.GetValueAsync(default); - hb.AddSqlServerDbContext(tempDb.Resource.Name); - hb.AddSqlServerClient(tempDb.Resource.Name); + hb.AddSqlServerDbContext(newDb.Resource.Name); + hb.AddSqlServerClient(newDb.Resource.Name); using var host = hb.Build(); @@ -151,7 +153,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(bindMountPath); + var directoryInfo = Directory.CreateDirectory(bindMountPath); if (!OperatingSystem.IsWindows()) { From 8b88f42a9013d99b11511334b0ab8064fd1ef7a3 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 11 Mar 2025 14:58:34 -0700 Subject: [PATCH 03/15] Create SQL Server container folders on Windows --- .../SqlServerBuilderExtensions.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 8032bef9535..b4440bc5a12 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -167,6 +167,24 @@ public static IResourceBuilder WithDataBindMount(this I ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(source); - return builder.WithBindMount(source, "/var/opt/mssql", isReadOnly); + if (!OperatingSystem.IsWindows()) + { + return builder.WithBindMount(source, "/var/opt/mssql", isReadOnly); + } + else + { + // c.f. https://learn.microsoft.com/sql/linux/sql-server-linux-docker-container-configure?view=sql-server-ver15&pivots=cs1-bash#mount-a-host-directory-as-data-volume + + foreach (var dir in new string[] { "data", "log", "secrets" }) + { + var path = Path.Combine(source, dir); + + Directory.CreateDirectory(path); + + builder.WithBindMount(path, $"/var/opt/mssql/{dir}", isReadOnly); + } + + return builder; + } } } From 4dda7077019b2505739288bad85f566314ff8214 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 11 Mar 2025 17:39:10 -0700 Subject: [PATCH 04/15] Test custom creation script annotations --- .../SqlServerBuilderExtensions.cs | 43 +++++++------- .../SqlServerDatabaseResource.cs | 6 -- .../ApplicationModel/ScriptAnnotation.cs | 25 ++++++++ .../SqlServerFunctionalTests.cs | 57 +++++++++++++++++++ 4 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/ScriptAnnotation.cs diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index b4440bc5a12..bf745b0259a 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -68,8 +68,11 @@ public static IResourceBuilder AddSqlServer(this IDistr throw new InvalidOperationException($"Could not open connection to '{sqlServer.Name}'"); } + var scriptAnnotation = sqlDatabase.Annotations.OfType().LastOrDefault(); + using var command = sqlConnection.CreateCommand(); - command.CommandText = sqlDatabase.DatabaseCreationScript ?? $"IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = @DatabaseName ) ) CREATE DATABASE {quotedDatabaseIdentifier};"; + command.CommandText = scriptAnnotation?.Script ?? + $"IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = @DatabaseName ) ) CREATE DATABASE {quotedDatabaseIdentifier};"; command.Parameters.Add(new SqlParameter("@DatabaseName", sqlDatabase.DatabaseName)); await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } @@ -101,9 +104,8 @@ public static IResourceBuilder AddSqlServer(this IDistr /// The SQL Server resource builders. /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. /// The name of the database. If not provided, this defaults to the same value as . - /// A custom database creation script. /// A reference to the . - public static IResourceBuilder AddDatabase(this IResourceBuilder builder, [ResourceName] string name, string? databaseName = null, string? databaseCreationScript = null) + public static IResourceBuilder AddDatabase(this IResourceBuilder builder, [ResourceName] string name, string? databaseName = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); @@ -113,7 +115,7 @@ public static IResourceBuilder AddDatabase(this IReso // Use the resource name as the database name if it's not provided databaseName ??= name; - var sqlServerDatabase = new SqlServerDatabaseResource(name, databaseName, builder.Resource) { DatabaseCreationScript = databaseCreationScript }; + var sqlServerDatabase = new SqlServerDatabaseResource(name, databaseName, builder.Resource); builder.Resource.AddDatabase(sqlServerDatabase); @@ -167,24 +169,25 @@ public static IResourceBuilder WithDataBindMount(this I ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(source); - if (!OperatingSystem.IsWindows()) - { - return builder.WithBindMount(source, "/var/opt/mssql", isReadOnly); - } - else - { - // c.f. https://learn.microsoft.com/sql/linux/sql-server-linux-docker-container-configure?view=sql-server-ver15&pivots=cs1-bash#mount-a-host-directory-as-data-volume - - foreach (var dir in new string[] { "data", "log", "secrets" }) - { - var path = Path.Combine(source, dir); + return builder.WithBindMount(source, "/var/opt/mssql", isReadOnly); + } - Directory.CreateDirectory(path); + /// + /// Alters the JSON configuration document used by the emulator. + /// + /// The builder for the . + /// The SQL script used to create the database. + /// A reference to the . + /// + /// Default script is IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = @DatabaseName ) ) CREATE DATABASE [<QUOTED_DATABASE_NAME%gt;]; + /// + public static IResourceBuilder WithCreationScript(this IResourceBuilder builder, string script) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(script); - builder.WithBindMount(path, $"/var/opt/mssql/{dir}", isReadOnly); - } + builder.WithAnnotation(new ScriptAnnotation(script)); - return builder; - } + return builder; } } diff --git a/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs b/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs index fa85d7460be..0ab725cc679 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs @@ -31,12 +31,6 @@ public class SqlServerDatabaseResource(string name, string databaseName, SqlServ /// public string DatabaseName { get; } = ThrowIfNullOrEmpty(databaseName); - /// - /// Gets or sets the database creation script. - /// - /// Default is IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = '<ESCAPED_DATABASE_NAME%gt;' ) ) CREATE DATABASE [<QUOTED_DATABASE_NAME%gt;]; - public string? DatabaseCreationScript { get; set; } - private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) { ArgumentException.ThrowIfNullOrEmpty(argument, paramName); diff --git a/src/Aspire.Hosting/ApplicationModel/ScriptAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ScriptAnnotation.cs new file mode 100644 index 00000000000..8a7f633ef74 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ScriptAnnotation.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. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents an annotation for defining a script to create a resource. +/// +public sealed class ScriptAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// The script used to create the resource. + public ScriptAnnotation(string script) + { + ArgumentNullException.ThrowIfNull(script); + Script = script; + } + + /// + /// Gets the script used to create the resource. + /// + public string Script { get; } +} diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index b4bdc42d7c4..bdb0bad4478 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -323,4 +323,61 @@ await pipeline.ExecuteAsync(async token => } } } + + [Fact] + [RequiresDocker] + public async Task AddDatabaseCreatesDatabaseWithCustomScript() + { + const string databaseName = "newdb"; + + 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 sqlserver = builder.AddSqlServer("sqlserver"); + + // Create a datbase with Accent Insensitive collation + var newDb = sqlserver.AddDatabase(databaseName) + .WithCreationScript($"CREATE DATABASE [{databaseName}] COLLATE French_CI_AI;"); + + 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.AddSqlServerClient(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 = """ + CREATE TABLE [Modèles] ([Name] nvarchar(max) NOT NULL); + INSERT INTO [Modèles] ([Name]) VALUES ('BatMobile'); + SELECT * FROM [Modeles]; -- Incorrect accent to verify the database collation + """; + + var results = await selectCommand.ExecuteReaderAsync(token); + Assert.True(results.HasRows); + }, cts.Token); + } } From 37a97208faa65c060bb695397d60a03c2736bb4f Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 11 Mar 2025 17:47:22 -0700 Subject: [PATCH 05/15] Revert playground changes --- .../SqlServerEndToEnd.AppHost/Program.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/Program.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/Program.cs index fff8d2ad528..e120df70897 100644 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/Program.cs +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/Program.cs @@ -13,16 +13,15 @@ var db2 = sql2.AddDatabase("db2"); -//var dbsetup = builder.AddProject("dbsetup") -// .WithReference(db1).WaitFor(sql1) -// .WithReference(db2).WaitFor(sql2); +var dbsetup = builder.AddProject("dbsetup") + .WithReference(db1).WaitFor(sql1) + .WithReference(db2).WaitFor(sql2); builder.AddProject("api") .WithExternalHttpEndpoints() .WithReference(db1).WaitFor(db1) .WithReference(db2).WaitFor(db2) - //.WaitForCompletion(dbsetup) - ; + .WaitForCompletion(dbsetup); #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging From ab9548d52a767aa005a93d3cabbd717a95b0d748 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 11 Mar 2025 18:19:15 -0700 Subject: [PATCH 06/15] Ensure special names are encoded in the creation script --- .../SqlServerDatabaseResource.cs | 13 ++++- .../AddSqlServerTests.cs | 14 +++--- .../SqlServerFunctionalTests.cs | 50 ++++++++++++++++++- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs b/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs index 0ab725cc679..30ef47c4212 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using Microsoft.Data.SqlClient; namespace Aspire.Hosting.ApplicationModel; @@ -23,8 +24,16 @@ public class SqlServerDatabaseResource(string name, string databaseName, SqlServ /// /// Gets the connection string expression for the SQL Server database. /// - public ReferenceExpression ConnectionStringExpression => - ReferenceExpression.Create($"{Parent};Database={DatabaseName}"); + public ReferenceExpression ConnectionStringExpression { + get + { + var connectionStringBuilder = new SqlConnectionStringBuilder(); + connectionStringBuilder["Database"] = DatabaseName; + var connectionString = connectionStringBuilder.ToString(); + + return ReferenceExpression.Create($"{Parent};{connectionString}"); + } + } /// /// Gets the database name. diff --git a/tests/Aspire.Hosting.SqlServer.Tests/AddSqlServerTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/AddSqlServerTests.cs index 66d1bd00b39..2a130baa493 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/AddSqlServerTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/AddSqlServerTests.cs @@ -116,8 +116,8 @@ public async Task SqlServerDatabaseCreatesConnectionString() var connectionStringResource = (IResourceWithConnectionString)sqlResource; var connectionString = await connectionStringResource.GetConnectionStringAsync(); - Assert.Equal("Server=127.0.0.1,1433;User ID=sa;Password=p@ssw0rd1;TrustServerCertificate=true;Database=mydb", connectionString); - Assert.Equal("{sqlserver.connectionString};Database=mydb", connectionStringResource.ConnectionStringExpression.ValueExpression); + Assert.Equal("Server=127.0.0.1,1433;User ID=sa;Password=p@ssw0rd1;TrustServerCertificate=true;Initial Catalog=mydb", connectionString); + Assert.Equal("{sqlserver.connectionString};Initial Catalog=mydb", connectionStringResource.ConnectionStringExpression.ValueExpression); } [Fact] @@ -154,7 +154,7 @@ public async Task VerifyManifest() expectedManifest = """ { "type": "value.v0", - "connectionString": "{sqlserver.connectionString};Database=db" + "connectionString": "{sqlserver.connectionString};Initial Catalog=db" } """; Assert.Equal(expectedManifest, dbManifest.ToString()); @@ -228,8 +228,8 @@ public void CanAddDatabasesWithDifferentNamesOnSingleServer() Assert.Equal("customers1", db1.Resource.DatabaseName); Assert.Equal("customers2", db2.Resource.DatabaseName); - Assert.Equal("{sqlserver1.connectionString};Database=customers1", db1.Resource.ConnectionStringExpression.ValueExpression); - Assert.Equal("{sqlserver1.connectionString};Database=customers2", db2.Resource.ConnectionStringExpression.ValueExpression); + Assert.Equal("{sqlserver1.connectionString};Initial Catalog=customers1", db1.Resource.ConnectionStringExpression.ValueExpression); + Assert.Equal("{sqlserver1.connectionString};Initial Catalog=customers2", db2.Resource.ConnectionStringExpression.ValueExpression); } [Fact] @@ -246,7 +246,7 @@ public void CanAddDatabasesWithTheSameNameOnMultipleServers() Assert.Equal("imports", db1.Resource.DatabaseName); Assert.Equal("imports", db2.Resource.DatabaseName); - Assert.Equal("{sqlserver1.connectionString};Database=imports", db1.Resource.ConnectionStringExpression.ValueExpression); - Assert.Equal("{sqlserver2.connectionString};Database=imports", db2.Resource.ConnectionStringExpression.ValueExpression); + Assert.Equal("{sqlserver1.connectionString};Initial Catalog=imports", db1.Resource.ConnectionStringExpression.ValueExpression); + Assert.Equal("{sqlserver2.connectionString};Initial Catalog=imports", db2.Resource.ConnectionStringExpression.ValueExpression); } } diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index bdb0bad4478..d254cc592eb 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -339,7 +339,7 @@ public async Task AddDatabaseCreatesDatabaseWithCustomScript() var sqlserver = builder.AddSqlServer("sqlserver"); - // Create a datbase with Accent Insensitive collation + // Create a database with Accent Insensitive collation var newDb = sqlserver.AddDatabase(databaseName) .WithCreationScript($"CREATE DATABASE [{databaseName}] COLLATE French_CI_AI;"); @@ -380,4 +380,52 @@ await pipeline.ExecuteAsync(async token => Assert.True(results.HasRows); }, cts.Token); } + + [Fact] + [RequiresDocker] + public async Task AddDatabaseCreatesDatabaseWithSpecialNames() + { + const string databaseName = "!'][\""; + const string resourceName = "db"; + + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new() { MaxRetryAttempts = 3, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2) }) + .Build(); + + using var builder = TestDistributedApplicationBuilder.Create(o => { }, testOutputHelper); + + var sqlserver = builder.AddSqlServer("sqlserver"); + + var newDb = sqlserver.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.AddSqlServerClient(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); + } + + Assert.Equal(System.Data.ConnectionState.Open, conn.State); + }, cts.Token); + } } From fa479038b58355596cdfceb6a0a99aef5ea59bf7 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 12 Mar 2025 09:31:25 -0700 Subject: [PATCH 07/15] PR feedback --- .../SqlServerBuilderExtensions.cs | 10 ++++------ .../SqlServerFunctionalTests.cs | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index bf745b0259a..17d90e7fc16 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -110,7 +110,7 @@ public static IResourceBuilder AddDatabase(this IReso ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); - Dictionary databaseConnectionStrings = []; + string? connectionString = null; // Use the resource name as the database name if it's not provided databaseName ??= name; @@ -121,18 +121,16 @@ public static IResourceBuilder AddDatabase(this IReso builder.ApplicationBuilder.Eventing.Subscribe(sqlServerDatabase, async (@event, ct) => { - var databaseConnectionString = await sqlServerDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + connectionString = await sqlServerDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); - if (databaseConnectionString == null) + if (connectionString == null) { throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{name}' resource but the connection string was null."); } - - databaseConnectionStrings[name] = databaseConnectionString; }); var healthCheckKey = $"{name}_check"; - builder.ApplicationBuilder.Services.AddHealthChecks().AddSqlServer(sp => databaseConnectionStrings[name] ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); + builder.ApplicationBuilder.Services.AddHealthChecks().AddSqlServer(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); return builder.ApplicationBuilder .AddResource(sqlServerDatabase) diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index d254cc592eb..d7f21d8b509 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -153,7 +153,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) { bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - var directoryInfo = Directory.CreateDirectory(bindMountPath); + Directory.CreateDirectory(bindMountPath); if (!OperatingSystem.IsWindows()) { From c3761c2f47bc6df191b1af784bd6c9da48146db1 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 12 Mar 2025 09:50:34 -0700 Subject: [PATCH 08/15] Formatting --- .../SqlServerDatabaseResource.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs b/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs index 30ef47c4212..e87343e2627 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerDatabaseResource.cs @@ -24,14 +24,16 @@ public class SqlServerDatabaseResource(string name, string databaseName, SqlServ /// /// Gets the connection string expression for the SQL Server database. /// - public ReferenceExpression ConnectionStringExpression { + public ReferenceExpression ConnectionStringExpression + { get { - var connectionStringBuilder = new SqlConnectionStringBuilder(); - connectionStringBuilder["Database"] = DatabaseName; - var connectionString = connectionStringBuilder.ToString(); + var connectionStringBuilder = new SqlConnectionStringBuilder + { + ["Database"] = DatabaseName + }; - return ReferenceExpression.Create($"{Parent};{connectionString}"); + return ReferenceExpression.Create($"{Parent};{connectionStringBuilder.ToString()}"); } } From 1d8456c5c33c55c55e4b79786b78372cfb4b13c4 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 12 Mar 2025 10:41:15 -0700 Subject: [PATCH 09/15] Feedback --- .../SqlServerEndToEnd.ApiService/Program.cs | 6 ------ .../SqlServerEndToEnd.DbSetup/Program.cs | 2 +- .../SqlServerBuilderExtensions.cs | 13 +++++++------ ...iptAnnotation.cs => CreationScriptAnnotation.cs} | 6 +++--- 4 files changed, 11 insertions(+), 16 deletions(-) rename src/Aspire.Hosting/ApplicationModel/{ScriptAnnotation.cs => CreationScriptAnnotation.cs} (74%) diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/Program.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/Program.cs index 15d818db146..a35c9418190 100644 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/Program.cs +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/Program.cs @@ -16,12 +16,6 @@ app.MapDefaultEndpoints(); app.MapGet("/", async (MyDb1Context db1Context, MyDb2Context db2Context) => { - // You wouldn't normally do this on every call, - // but doing it here just to make this simple. - - await db1Context.Database.EnsureCreatedAsync(); - await db2Context.Database.EnsureCreatedAsync(); - var entry1 = new Entry(); await db1Context.Entries.AddAsync(entry1); await db1Context.SaveChangesAsync(); diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs index 844e616151d..5ff3dc07e66 100644 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs @@ -17,6 +17,6 @@ var created = await db.Database.EnsureCreatedAsync(); if (created) { - Console.WriteLine("Database created!"); + Console.WriteLine("Database schema created!"); } } diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 17d90e7fc16..1a0b3f748f5 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -68,7 +68,7 @@ public static IResourceBuilder AddSqlServer(this IDistr throw new InvalidOperationException($"Could not open connection to '{sqlServer.Name}'"); } - var scriptAnnotation = sqlDatabase.Annotations.OfType().LastOrDefault(); + var scriptAnnotation = sqlDatabase.Annotations.OfType().LastOrDefault(); using var command = sqlConnection.CreateCommand(); command.CommandText = scriptAnnotation?.Script ?? @@ -78,7 +78,8 @@ public static IResourceBuilder AddSqlServer(this IDistr } catch (Exception e) { - @event.Services.GetRequiredService>().LogError(e, "Failed to create database '{DatabaseName}'", sqlDatabase.DatabaseName); + var logger = @event.Services.GetRequiredService>(); + logger.LogError(e, "Failed to create database '{DatabaseName}'", sqlDatabase.DatabaseName); } } }); @@ -110,8 +111,6 @@ public static IResourceBuilder AddDatabase(this IReso ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); - string? connectionString = null; - // Use the resource name as the database name if it's not provided databaseName ??= name; @@ -119,6 +118,8 @@ public static IResourceBuilder AddDatabase(this IReso builder.Resource.AddDatabase(sqlServerDatabase); + string? connectionString = null; + builder.ApplicationBuilder.Eventing.Subscribe(sqlServerDatabase, async (@event, ct) => { connectionString = await sqlServerDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); @@ -171,7 +172,7 @@ public static IResourceBuilder WithDataBindMount(this I } /// - /// Alters the JSON configuration document used by the emulator. + /// Defines the SQL script used to create the database. /// /// The builder for the . /// The SQL script used to create the database. @@ -184,7 +185,7 @@ public static IResourceBuilder WithCreationScript(thi ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(script); - builder.WithAnnotation(new ScriptAnnotation(script)); + builder.WithAnnotation(new CreationScriptAnnotation(script)); return builder; } diff --git a/src/Aspire.Hosting/ApplicationModel/ScriptAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/CreationScriptAnnotation.cs similarity index 74% rename from src/Aspire.Hosting/ApplicationModel/ScriptAnnotation.cs rename to src/Aspire.Hosting/ApplicationModel/CreationScriptAnnotation.cs index 8a7f633ef74..f66abcecf83 100644 --- a/src/Aspire.Hosting/ApplicationModel/ScriptAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/CreationScriptAnnotation.cs @@ -6,13 +6,13 @@ namespace Aspire.Hosting.ApplicationModel; /// /// Represents an annotation for defining a script to create a resource. /// -public sealed class ScriptAnnotation : IResourceAnnotation +public sealed class CreationScriptAnnotation : IResourceAnnotation { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The script used to create the resource. - public ScriptAnnotation(string script) + public CreationScriptAnnotation(string script) { ArgumentNullException.ThrowIfNull(script); Script = script; From 0a976707070d2e89c2b517cb5678bbc877db04dc Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 12 Mar 2025 18:49:55 -0700 Subject: [PATCH 10/15] Add support for multi-statements scripts --- .../SqlServerBuilderExtensions.cs | 34 +++++++++++++++---- .../SqlServerFunctionalTests.cs | 18 ++++++---- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 1a0b3f748f5..1d4483eeae2 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.RegularExpressions; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Microsoft.Data.SqlClient; @@ -12,8 +13,11 @@ namespace Aspire.Hosting; /// /// Provides extension methods for adding SQL Server resources to the application model. /// -public static class SqlServerBuilderExtensions +public static partial class SqlServerBuilderExtensions { + [GeneratedRegex(@"(?:[\r\n\s])*GO(?:[\r\n\s]|\z)*", RegexOptions.CultureInvariant)] + private static partial Regex GoStatements(); + /// /// Adds a SQL Server resource to the application model. A container is used for local development. /// @@ -70,11 +74,29 @@ public static IResourceBuilder AddSqlServer(this IDistr var scriptAnnotation = sqlDatabase.Annotations.OfType().LastOrDefault(); - using var command = sqlConnection.CreateCommand(); - command.CommandText = scriptAnnotation?.Script ?? - $"IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = @DatabaseName ) ) CREATE DATABASE {quotedDatabaseIdentifier};"; - command.Parameters.Add(new SqlParameter("@DatabaseName", sqlDatabase.DatabaseName)); - await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + if (scriptAnnotation?.Script == null) + { + using var command = sqlConnection.CreateCommand(); + command.CommandText = $"IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = @DatabaseName ) ) CREATE DATABASE {quotedDatabaseIdentifier};"; + command.Parameters.Add(new SqlParameter("@DatabaseName", sqlDatabase.DatabaseName)); + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + else + { + var batches = GoStatements().Split(scriptAnnotation.Script); + + foreach (var batch in batches) + { + if (string.IsNullOrWhiteSpace(batch)) + { + continue; + } + + using var command = sqlConnection.CreateCommand(); + command.CommandText = batch; + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + } } catch (Exception e) { diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index d7f21d8b509..a62d9447350 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -341,7 +341,17 @@ public async Task AddDatabaseCreatesDatabaseWithCustomScript() // Create a database with Accent Insensitive collation var newDb = sqlserver.AddDatabase(databaseName) - .WithCreationScript($"CREATE DATABASE [{databaseName}] COLLATE French_CI_AI;"); + .WithCreationScript($$""" + CREATE DATABASE [{{databaseName}}] COLLATE French_CI_AI; + GO + + USE [{{databaseName}}]; + GO + + CREATE TABLE [Modèles] ([Name] nvarchar(max) NOT NULL); + INSERT INTO [Modèles] ([Name]) VALUES ('BatMobile'); + GO + """); using var app = builder.Build(); @@ -370,11 +380,7 @@ await pipeline.ExecuteAsync(async token => } var selectCommand = conn.CreateCommand(); - selectCommand.CommandText = """ - CREATE TABLE [Modèles] ([Name] nvarchar(max) NOT NULL); - INSERT INTO [Modèles] ([Name]) VALUES ('BatMobile'); - SELECT * FROM [Modeles]; -- Incorrect accent to verify the database collation - """; + selectCommand.CommandText = "SELECT * FROM [Modeles]; -- Incorrect accent to verify the database collation"; var results = await selectCommand.ExecuteReaderAsync(token); Assert.True(results.HasRows); From 8ef42a630db3a754bd3dd597704b7185930594c6 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 13 Mar 2025 09:14:43 -0700 Subject: [PATCH 11/15] Improve GO separator support --- .../SqlServerBuilderExtensions.cs | 118 +++++++++++------- .../SqlServerFunctionalTests.cs | 3 +- 2 files changed, 78 insertions(+), 43 deletions(-) diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 1d4483eeae2..726633c1bf7 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using System.Text.RegularExpressions; +using System.Text; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Microsoft.Data.SqlClient; @@ -15,8 +17,10 @@ namespace Aspire.Hosting; /// public static partial class SqlServerBuilderExtensions { - [GeneratedRegex(@"(?:[\r\n\s])*GO(?:[\r\n\s]|\z)*", RegexOptions.CultureInvariant)] - private static partial Regex GoStatements(); + // GO delimiter format: {spaces?}GO{spaces?}{repeat?}{comment?} + // https://learn.microsoft.com/sql/t-sql/language-elements/sql-server-utilities-statements-go + [GeneratedRegex(@"^(?:\s*)(GO|go)(?\s+\d{1,6})?(?:\s*\-{2,}.*)?(?:\s+)?$", RegexOptions.CultureInvariant)] + internal static partial Regex GoStatements(); /// /// Adds a SQL Server resource to the application model. A container is used for local development. @@ -61,48 +65,14 @@ public static IResourceBuilder AddSqlServer(this IDistr using var sqlConnection = new SqlConnection(connectionString); await sqlConnection.OpenAsync(ct).ConfigureAwait(false); - foreach (var sqlDatabase in sqlServer.DatabaseResources) + if (sqlConnection.State != System.Data.ConnectionState.Open) { - var quotedDatabaseIdentifier = new SqlCommandBuilder().QuoteIdentifier(sqlDatabase.DatabaseName); - - try - { - if (sqlConnection.State != System.Data.ConnectionState.Open) - { - throw new InvalidOperationException($"Could not open connection to '{sqlServer.Name}'"); - } - - var scriptAnnotation = sqlDatabase.Annotations.OfType().LastOrDefault(); - - if (scriptAnnotation?.Script == null) - { - using var command = sqlConnection.CreateCommand(); - command.CommandText = $"IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = @DatabaseName ) ) CREATE DATABASE {quotedDatabaseIdentifier};"; - command.Parameters.Add(new SqlParameter("@DatabaseName", sqlDatabase.DatabaseName)); - await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } - else - { - var batches = GoStatements().Split(scriptAnnotation.Script); - - foreach (var batch in batches) - { - if (string.IsNullOrWhiteSpace(batch)) - { - continue; - } + throw new InvalidOperationException($"Could not open connection to '{sqlServer.Name}'"); + } - using var command = sqlConnection.CreateCommand(); - command.CommandText = batch; - await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } - } - } - catch (Exception e) - { - var logger = @event.Services.GetRequiredService>(); - logger.LogError(e, "Failed to create database '{DatabaseName}'", sqlDatabase.DatabaseName); - } + foreach (var sqlDatabase in sqlServer.DatabaseResources) + { + await CreateDatabaseAsync(sqlConnection, sqlDatabase, @event.Services, ct).ConfigureAwait(false); } }); @@ -211,4 +181,68 @@ public static IResourceBuilder WithCreationScript(thi return builder; } + + private static async Task CreateDatabaseAsync(SqlConnection sqlConnection, SqlServerDatabaseResource sqlDatabase, IServiceProvider serviceProvider, CancellationToken ct) + { + try + { + var scriptAnnotation = sqlDatabase.Annotations.OfType().LastOrDefault(); + + if (scriptAnnotation?.Script == null) + { + var quotedDatabaseIdentifier = new SqlCommandBuilder().QuoteIdentifier(sqlDatabase.DatabaseName); + using var command = sqlConnection.CreateCommand(); + command.CommandText = $"IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = @DatabaseName ) ) CREATE DATABASE {quotedDatabaseIdentifier};"; + command.Parameters.Add(new SqlParameter("@DatabaseName", sqlDatabase.DatabaseName)); + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + else + { + using var reader = new StringReader(scriptAnnotation.Script); + var batchBuilder = new StringBuilder(); + + while (reader.ReadLine() is { } line) + { + var matchGo = GoStatements().Match(line); + + if (matchGo.Success) + { + // Execute the current batch + var count = matchGo.Groups["repeat"].Success ? int.Parse(matchGo.Groups["repeat"].Value, CultureInfo.InvariantCulture) : 1; + var batch = batchBuilder.ToString(); + + for (var i = 0; i < count; i++) + { + using var command = sqlConnection.CreateCommand(); + command.CommandText = batch; + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + batchBuilder.Clear(); + } + else + { + // Prevent batches with only whitespace + if (!string.IsNullOrWhiteSpace(line)) + { + batchBuilder.AppendLine(line); + } + } + } + + // Process the remaining batch lines + if (batchBuilder.Length > 0) batchBuilder.IsNullOrWhiteSpace()) + { + using var command = sqlConnection.CreateCommand(); + command.CommandText = batchBuilder.ToString(); + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + } + } + catch (Exception e) + { + var logger = serviceProvider.GetRequiredService>(); + logger.LogError(e, "Failed to create database '{DatabaseName}'", sqlDatabase.DatabaseName); + } + } } diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs index a62d9447350..dac2f6485c9 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerFunctionalTests.cs @@ -125,7 +125,7 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) string? volumeName = null; string? bindMountPath = null; - + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); var pipeline = new ResiliencePipelineBuilder() .AddRetry(new() { MaxRetryAttempts = int.MaxValue, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2) }) @@ -351,6 +351,7 @@ public async Task AddDatabaseCreatesDatabaseWithCustomScript() CREATE TABLE [Modèles] ([Name] nvarchar(max) NOT NULL); INSERT INTO [Modèles] ([Name]) VALUES ('BatMobile'); GO + """); using var app = builder.Build(); From b9d281c575a4bfa741ca56be7bcedb11bc7e19ac Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 13 Mar 2025 12:07:05 -0700 Subject: [PATCH 12/15] Fix build --- src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 726633c1bf7..892103fed93 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -231,7 +231,7 @@ private static async Task CreateDatabaseAsync(SqlConnection sqlConnection, SqlSe } // Process the remaining batch lines - if (batchBuilder.Length > 0) batchBuilder.IsNullOrWhiteSpace()) + if (batchBuilder.Length > 0) { using var command = sqlConnection.CreateCommand(); command.CommandText = batchBuilder.ToString(); From 61875ef78c7d0fe31afa5f0377834c45237171f7 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 13 Mar 2025 13:46:33 -0700 Subject: [PATCH 13/15] Regex feedback and tests --- .../SqlServerBuilderExtensions.cs | 4 +- .../SqlServerGoStatementTests.cs | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/Aspire.Hosting.SqlServer.Tests/SqlServerGoStatementTests.cs diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 892103fed93..90b81112ee0 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -19,8 +19,8 @@ public static partial class SqlServerBuilderExtensions { // GO delimiter format: {spaces?}GO{spaces?}{repeat?}{comment?} // https://learn.microsoft.com/sql/t-sql/language-elements/sql-server-utilities-statements-go - [GeneratedRegex(@"^(?:\s*)(GO|go)(?\s+\d{1,6})?(?:\s*\-{2,}.*)?(?:\s+)?$", RegexOptions.CultureInvariant)] - internal static partial Regex GoStatements(); + [GeneratedRegex(@"^\s*GO(?\s+\d{1,6})?(\s*\-{2,}.*)?\s*$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex GoStatements(); /// /// Adds a SQL Server resource to the application model. A container is used for local development. diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerGoStatementTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerGoStatementTests.cs new file mode 100644 index 00000000000..36a1db3f752 --- /dev/null +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerGoStatementTests.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Text.RegularExpressions; +using Xunit; + +namespace Aspire.Hosting.SqlServer.Tests; +public class SqlServerGoStatementTests +{ + private static readonly Regex s_goStatements = (Regex)typeof(SqlServerBuilderExtensions).GetMethod("GoStatements", BindingFlags.Static | BindingFlags.NonPublic)?.Invoke(null, null)!; + + [Theory] + [InlineData("GO")] + [InlineData(" GO")] + [InlineData(" GO ")] + [InlineData(" GO ")] + [InlineData(" GO ")] + [InlineData("GO\n")] + [InlineData("GO--")] + [InlineData(" GO--")] + [InlineData(" GO --")] + [InlineData("GO -- comments")] + [InlineData("GO 123")] + [InlineData("GO 123 --")] + [InlineData("GO 123 --comments")] + public void DelimiterShouldMatch(string delimiter) + { + Assert.Matches(s_goStatements, delimiter); + } + + [Theory] + [InlineData("GO;")] + [InlineData("GO-")] + [InlineData("GO -comment")] + [InlineData(";GO")] + [InlineData("GO 1234567")] + [InlineData("GO 123456 123")] + [InlineData("a GO;")] + [InlineData("-- GO")] + [InlineData("GO a")] + [InlineData("GO 123_123")] + public void DelimiterShouldNotMatch(string delimiter) + { + Assert.DoesNotMatch(s_goStatements, delimiter); + } +} From c5e684140cb079b23981f81922141e22d4e159bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Fri, 14 Mar 2025 14:33:56 -0700 Subject: [PATCH 14/15] Update src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs Co-authored-by: Dan Moseley --- src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 90b81112ee0..2612270bd7a 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -19,7 +19,7 @@ public static partial class SqlServerBuilderExtensions { // GO delimiter format: {spaces?}GO{spaces?}{repeat?}{comment?} // https://learn.microsoft.com/sql/t-sql/language-elements/sql-server-utilities-statements-go - [GeneratedRegex(@"^\s*GO(?\s+\d{1,6})?(\s*\-{2,}.*)?\s*$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + [GeneratedRegex(@"^\s*GO\s+(?\d{1,6})?(\s*\-{2,}.*)?\s*$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] private static partial Regex GoStatements(); /// From 98780b4569ccd79e68182a1f194946df55ba65f6 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 14 Mar 2025 14:50:09 -0700 Subject: [PATCH 15/15] Fix test and remove private reflection --- .../Aspire.Hosting.SqlServer.csproj | 4 ++++ .../SqlServerBuilderExtensions.cs | 4 ++-- .../Aspire.Hosting.SqlServer.Tests.csproj | 4 ---- .../SqlServerGoStatementTests.cs | 9 +++------ 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Hosting.SqlServer/Aspire.Hosting.SqlServer.csproj b/src/Aspire.Hosting.SqlServer/Aspire.Hosting.SqlServer.csproj index 5539d1074de..f488e5e241b 100644 --- a/src/Aspire.Hosting.SqlServer/Aspire.Hosting.SqlServer.csproj +++ b/src/Aspire.Hosting.SqlServer/Aspire.Hosting.SqlServer.csproj @@ -20,4 +20,8 @@ + + + + diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 2612270bd7a..2b67d49d808 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -19,8 +19,8 @@ public static partial class SqlServerBuilderExtensions { // GO delimiter format: {spaces?}GO{spaces?}{repeat?}{comment?} // https://learn.microsoft.com/sql/t-sql/language-elements/sql-server-utilities-statements-go - [GeneratedRegex(@"^\s*GO\s+(?\d{1,6})?(\s*\-{2,}.*)?\s*$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] - private static partial Regex GoStatements(); + [GeneratedRegex(@"^\s*GO(?\s+\d{1,6})?(\s*\-{2,}.*)?\s*$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + internal static partial Regex GoStatements(); /// /// Adds a SQL Server resource to the application model. A container is used for local development. diff --git a/tests/Aspire.Hosting.SqlServer.Tests/Aspire.Hosting.SqlServer.Tests.csproj b/tests/Aspire.Hosting.SqlServer.Tests/Aspire.Hosting.SqlServer.Tests.csproj index 0bfa65c52a0..5e85eb5417e 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/Aspire.Hosting.SqlServer.Tests.csproj +++ b/tests/Aspire.Hosting.SqlServer.Tests/Aspire.Hosting.SqlServer.Tests.csproj @@ -11,8 +11,4 @@ - - - - diff --git a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerGoStatementTests.cs b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerGoStatementTests.cs index 36a1db3f752..5fa38a7d3c3 100644 --- a/tests/Aspire.Hosting.SqlServer.Tests/SqlServerGoStatementTests.cs +++ b/tests/Aspire.Hosting.SqlServer.Tests/SqlServerGoStatementTests.cs @@ -1,15 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Reflection; -using System.Text.RegularExpressions; using Xunit; namespace Aspire.Hosting.SqlServer.Tests; + public class SqlServerGoStatementTests { - private static readonly Regex s_goStatements = (Regex)typeof(SqlServerBuilderExtensions).GetMethod("GoStatements", BindingFlags.Static | BindingFlags.NonPublic)?.Invoke(null, null)!; - [Theory] [InlineData("GO")] [InlineData(" GO")] @@ -26,7 +23,7 @@ public class SqlServerGoStatementTests [InlineData("GO 123 --comments")] public void DelimiterShouldMatch(string delimiter) { - Assert.Matches(s_goStatements, delimiter); + Assert.Matches(SqlServerBuilderExtensions.GoStatements(), delimiter); } [Theory] @@ -42,6 +39,6 @@ public void DelimiterShouldMatch(string delimiter) [InlineData("GO 123_123")] public void DelimiterShouldNotMatch(string delimiter) { - Assert.DoesNotMatch(s_goStatements, delimiter); + Assert.DoesNotMatch(SqlServerBuilderExtensions.GoStatements(), delimiter); } }