diff --git a/examples/sql-database-projects/SdkProject/SdkProject.csproj b/examples/sql-database-projects/SdkProject/SdkProject.csproj index d0ba19757..9bd8dbf27 100644 --- a/examples/sql-database-projects/SdkProject/SdkProject.csproj +++ b/examples/sql-database-projects/SdkProject/SdkProject.csproj @@ -1,4 +1,4 @@ - + $(DefaultTargetFramework) Sql150 diff --git a/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/DacpacChecksumService.cs b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/DacpacChecksumService.cs new file mode 100644 index 000000000..3771fabcc --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/DacpacChecksumService.cs @@ -0,0 +1,164 @@ +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using System.Data; +using System.Security.Cryptography; + +namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects; + +internal class DacpacChecksumService : IDacpacChecksumService +{ + public async Task CheckIfDeployedAsync(string dacpacPath, string targetConnectionString, ILogger logger, CancellationToken cancellationToken) + { + var targetDatabaseName = GetDatabaseName(targetConnectionString); + + var dacpacPathChecksum = GetStringChecksum(dacpacPath); + + var dacpacChecksum = await GetChecksumAsync(dacpacPath); + + using var connection = new SqlConnection(targetConnectionString); + + try + { + // Try to connect to the target database to see if it exists and fail fast if it does not. + await connection.OpenAsync(SqlConnectionOverrides.OpenWithoutRetry, cancellationToken); + } + catch (Exception ex) when (ex is InvalidOperationException || ex is SqlException) + { + logger.LogWarning(ex, "Target database {TargetDatabase} is not available.", targetDatabaseName); + return dacpacChecksum; + } + + var deployed = await CheckExtendedPropertyAsync(connection, dacpacPathChecksum, dacpacChecksum, cancellationToken); + + if (deployed) + { + logger.LogInformation("The .dacpac with checksum {DacpacChecksum} has already been deployed to database {TargetDatabaseName}.", dacpacChecksum, targetDatabaseName); + return null; + } + + logger.LogInformation("The .dacpac with checksum {DacpacChecksum} has not been deployed to database {TargetDatabaseName}.", dacpacChecksum, targetDatabaseName); + + return dacpacChecksum; + } + + public async Task SetChecksumAsync(string dacpacPath, string targetConnectionString, string dacpacChecksum, ILogger logger, CancellationToken cancellationToken) + { + var targetDatabaseName = GetDatabaseName(targetConnectionString); + + var dacpacPathChecksum = GetStringChecksum(dacpacPath); + + using var connection = new SqlConnection(targetConnectionString); + + await connection.OpenAsync(SqlConnectionOverrides.OpenWithoutRetry, cancellationToken); + + await UpdateExtendedPropertyAsync(connection, dacpacPathChecksum, dacpacChecksum, cancellationToken); + + logger.LogInformation("The .dacpac with checksum {DacpacChecksum} has been registered in database {TargetDatabaseName}.", dacpacChecksum, targetDatabaseName); + } + + private static string GetDatabaseName(string connectionString) + { + var builder = new SqlConnectionStringBuilder(connectionString); + return builder.InitialCatalog; + } + + private static async Task GetChecksumAsync(string file) + { + var output = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + + System.IO.Compression.ZipFile.ExtractToDirectory(file, output); + + var bytes = await File.ReadAllBytesAsync(Path.Join(output, "model.xml")); + + var predeployPath = Path.Join(output, "predeploy.sql"); + + if (File.Exists(predeployPath)) + { + var predeployBytes = await File.ReadAllBytesAsync(predeployPath); + bytes = bytes.Concat(predeployBytes).ToArray(); + } + + var postdeployPath = Path.Join(output, "postdeploy.sql"); + + if (File.Exists(postdeployPath)) + { + var postdeployBytes = await File.ReadAllBytesAsync(postdeployPath); + bytes = bytes.Concat(postdeployBytes).ToArray(); + } + + using var sha = SHA256.Create(); + var checksum = sha.ComputeHash(bytes); + + // Clean up the extracted files + try + { + Directory.Delete(output, true); + } + catch + { + // Ignore any errors during cleanup + } + + return BitConverter.ToString(checksum).Replace("-", string.Empty); + } + + private static string GetStringChecksum(string text) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(text); + using var sha = SHA256.Create(); + var checksum = sha.ComputeHash(bytes); + return BitConverter.ToString(checksum).Replace("-", string.Empty); + } + + private static async Task CheckExtendedPropertyAsync(SqlConnection connection, string dacpacPathChecksum, string dacpacChecksum, CancellationToken cancellationToken) + { + var command = new SqlCommand( + @$"SELECT CAST(1 AS BIT) FROM fn_listextendedproperty(NULL, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT) + WHERE [value] = @Expected + AND [name] = @dacpacId;", + connection); + + command.Parameters.AddRange(GetParameters(dacpacChecksum, dacpacPathChecksum)); + + var result = await command.ExecuteScalarAsync(cancellationToken); + + return result == null ? false : (bool)result; + } + + private static async Task UpdateExtendedPropertyAsync(SqlConnection connection, string dacpacPathChecksum, string dacpacChecksum, CancellationToken cancellationToken) + { + var command = new SqlCommand($@" + IF EXISTS + ( + SELECT 1 FROM fn_listextendedproperty(null, default, default, default, default, default, default) + WHERE [name] = @dacpacId + ) + BEGIN + EXEC sp_updateextendedproperty @name = @dacpacId, @value = @Expected; + END + ELSE + BEGIN + EXEC sp_addextendedproperty @name = @dacpacId, @value = @Expected; + END;", + connection); + + command.Parameters.AddRange(GetParameters(dacpacChecksum, dacpacPathChecksum)); + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private static SqlParameter[] GetParameters(string dacpacChecksum, string dacpacPathChecksum) + { + return + [ + new SqlParameter("@Expected", SqlDbType.VarChar) + { + Value = dacpacChecksum + }, + new SqlParameter("@dacpacId", SqlDbType.NVarChar, 128) + { + Value = dacpacPathChecksum + }, + ]; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/DacpacSkipWhenDeployedAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/DacpacSkipWhenDeployedAnnotation.cs new file mode 100644 index 000000000..0a551acd6 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/DacpacSkipWhenDeployedAnnotation.cs @@ -0,0 +1,8 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a metadata annotation that specifies that .dacpac deployment should be skipped if metadata in the target database indicates that the .dacpac has already been deployed in it's current state. +/// +public sealed class DacpacSkipWhenDeployedAnnotation : IResourceAnnotation +{ +} diff --git a/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/IDacpacChecksumService.cs b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/IDacpacChecksumService.cs new file mode 100644 index 000000000..f8da75353 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/IDacpacChecksumService.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects; + +/// +/// Abstracts the check of the .dacpac file already having been deployed to the target SQL Server database. +/// +internal interface IDacpacChecksumService +{ + /// + /// Checks if the file has already been deployed to the specified + /// + /// Path to the .dacpac file to deploy. + /// Connection string to the SQL Server. + /// An to write the log to. + /// A that can be used to cancel the deployment operation. + /// the checksum calculated for the .dacpac if it has not been deployed, otherwise null + Task CheckIfDeployedAsync(string dacpacPath, string targetConnectionString, ILogger deploymentSkipLogger, CancellationToken cancellationToken); + + /// + /// Sets the checksum extended property on the target database to indicate that the file has been deployed. + /// + /// Path to the .dacpac file to deploy. + /// Connection string to the SQL Server. + /// Checksum for the .dacpac + /// An to write the log to. + /// A that can be used to cancel the deployment operation. + /// A task that represents the asynchronous operation. + Task SetChecksumAsync(string dacpacPath, string targetConnectionString, string dacpacChecksum, ILogger deploymentSkipLogger, CancellationToken cancellationToken); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/README.md b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/README.md index 09e9ffd07..eadb79523 100644 --- a/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/README.md @@ -1,7 +1,9 @@ # CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects library + This package provides [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) integration for SQL Server Database Projects. It allows you to publish SQL Database Projects as part of your .NET Aspire AppHost projects. It currently works with both [MSBuild.Sdk.SqlProj](https://github.com/rr-wfm/MSBuild.Sdk.SqlProj) and [Microsoft.Build.Sql](https://github.com/microsoft/DacFx) (aka .sqlprojx) based projects. ## Usage + To use this package, install it into your .NET Aspire AppHost project: ```bash @@ -33,6 +35,7 @@ builder.Build().Run(); Now when you run your .NET Aspire AppHost project you will see the SQL Database Project being published to the specified SQL Server. ## Local .dacpac file support + If you are sourcing your .dacpac file from somewhere other than a project reference, you can also specify the path to the .dacpac file directly: ```csharp @@ -49,6 +52,7 @@ builder.Build().Run(); ``` ## Support for existing SQL Server + Instead of using the `AddSqlServer` method to use a SQL Server container, you can specify a connection string to an existing server: ```csharp @@ -64,6 +68,7 @@ builder.Build().Run(); ``` ## Deployment options support + Define options that affect the behavior of package deployment. ```csharp @@ -77,4 +82,24 @@ builder.AddSqlProject("mysqlproj") .WithReference(sql); builder.Build().Run(); -``` \ No newline at end of file +``` + +## Ability to skip deployment + +You can use the `WithSkipWhenDeployed` method to avoid re-deploying your SQL Database Project if no changes have been made. This is useful in scenarios where the SQL container database is persisted to permanent disk and will significantly improve the .NET Aspire AppHost project startup time. + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var server = builder.AddSqlServer("sql") + .WithDataVolume("testdata") + .WithLifetime(ContainerLifetime.Persistent); + +var database = server.AddDatabase("test"); + +var sdkProject = builder.AddSqlProject("mysqlproj") + .WithSkipWhenDeployed() + .WithReference(database); + +builder.Build().Run(); +``` diff --git a/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/SqlProjectBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/SqlProjectBuilderExtensions.cs index 7d36bff2c..3aa0c29dd 100644 --- a/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/SqlProjectBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/SqlProjectBuilderExtensions.cs @@ -114,6 +114,29 @@ internal static IResourceBuilder InternalWithDacpac(this I return builder.WithAnnotation(new DacpacMetadataAnnotation(dacpacPath)); } + /// + /// Specifies that .dacpac deployment should be skipped if metadata in the target database indicates that the .dacpac has already been deployed in its current state. + /// + /// An representing the SQL Server Database project. + /// An that can be used to further customize the resource. + public static IResourceBuilder WithSkipWhenDeployed(this IResourceBuilder builder) + => InternalWithSkipWhenDeployed(builder); + + /// + /// Specifies that .dacpac deployment should be skipped if metadata in the target database indicates that the .dacpac has already been deployed in its current state. + /// + /// An representing the SQL Server Database project. + /// An that can be used to further customize the resource. + public static IResourceBuilder> WithSkipWhenDeployed(this IResourceBuilder> builder) + where TPackage : IPackageMetadata => InternalWithSkipWhenDeployed(builder); + + + internal static IResourceBuilder InternalWithSkipWhenDeployed(this IResourceBuilder builder) + where TResource : IResourceWithDacpac + { + return builder.WithAnnotation(new DacpacSkipWhenDeployedAnnotation()); + } + /// /// Adds a delegate annotation for configuring dacpac deployment options to the . /// @@ -218,6 +241,7 @@ internal static IResourceBuilder InternalWithReference(thi where TResource : IResourceWithDacpac { builder.ApplicationBuilder.Services.TryAddSingleton(); + builder.ApplicationBuilder.Services.TryAddSingleton(); builder.ApplicationBuilder.Services.TryAddSingleton(); builder.WithParentRelationship(target.Resource); diff --git a/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/SqlProjectPublishService.cs b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/SqlProjectPublishService.cs index 0cefd8b19..cd87c8b97 100644 --- a/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/SqlProjectPublishService.cs +++ b/src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/SqlProjectPublishService.cs @@ -5,7 +5,7 @@ namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects; -internal class SqlProjectPublishService(IDacpacDeployer deployer, IHostEnvironment hostEnvironment, ResourceLoggerService resourceLoggerService, ResourceNotificationService resourceNotificationService, IDistributedApplicationEventing eventing, IServiceProvider serviceProvider) +internal class SqlProjectPublishService(IDacpacDeployer deployer, IDacpacChecksumService dacpacChecksumService, IHostEnvironment hostEnvironment, ResourceLoggerService resourceLoggerService, ResourceNotificationService resourceNotificationService, IDistributedApplicationEventing eventing, IServiceProvider serviceProvider) { public async Task PublishSqlProject(IResourceWithDacpac resource, IResourceWithConnectionString target, string? targetDatabaseName, CancellationToken cancellationToken) { @@ -42,11 +42,33 @@ await resourceNotificationService.PublishUpdateAsync(resource, return; } + string? checksum = null; + + if (resource.HasAnnotationOfType()) + { + options.DropExtendedPropertiesNotInSource = false; + + var result = await dacpacChecksumService.CheckIfDeployedAsync(dacpacPath, connectionString, logger, cancellationToken); + if (result is null) + { + await resourceNotificationService.PublishUpdateAsync(resource, + state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success) }); + return; + } + + checksum = result; + } + await resourceNotificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot("Publishing", KnownResourceStateStyles.Info) }); deployer.Deploy(dacpacPath, options, connectionString, targetDatabaseName, logger, cancellationToken); + if (!string.IsNullOrEmpty(checksum) && resource.HasAnnotationOfType()) + { + await dacpacChecksumService.SetChecksumAsync(dacpacPath, connectionString, checksum, logger, cancellationToken); + } + await resourceNotificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success) }); diff --git a/tests/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests/AddSqlPackageTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests/AddSqlPackageTests.cs index 6d12ab027..c594ed63f 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests/AddSqlPackageTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests/AddSqlPackageTests.cs @@ -116,4 +116,46 @@ public void AddSqlPackage_WithDeploymentOptions_FromFile() var options = ((IResourceWithDacpac)sqlProjectResource).GetDacpacDeployOptions(); Assert.False(options.BlockOnPossibleDataLoss); } + + [Fact] + public void AddSqlPackage_WithExplicitStart() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var targetDatabase = appBuilder.AddSqlServer("sql").AddDatabase("test"); + appBuilder.AddSqlPackage("chinook") + .WithReference(targetDatabase) + .WithExplicitStart(); + + // Act + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Assert + var sqlProjectResource = Assert.Single(appModel.Resources.OfType>()); + Assert.Equal("chinook", sqlProjectResource.Name); + + Assert.True(sqlProjectResource.HasAnnotationOfType()); + } + + [Fact] + public void AddSqlPackage_WithSkipWhenDeployed() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var targetDatabase = appBuilder.AddSqlServer("sql").AddDatabase("test"); + appBuilder.AddSqlPackage("chinook") + .WithReference(targetDatabase) + .WithSkipWhenDeployed(); + + // Act + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Assert + var sqlProjectResource = Assert.Single(appModel.Resources.OfType>()); + Assert.Equal("chinook", sqlProjectResource.Name); + + Assert.True(sqlProjectResource.HasAnnotationOfType()); + } } \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests/AddSqlProjectTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests/AddSqlProjectTests.cs index 4cb105448..4a0d14b30 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests/AddSqlProjectTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests/AddSqlProjectTests.cs @@ -158,6 +158,7 @@ public void WithReference_AddsRequiredServices() // Assert Assert.Single(app.Services.GetServices()); Assert.Single(app.Services.GetServices()); + Assert.Single(app.Services.GetServices()); } [Fact] @@ -180,4 +181,25 @@ public void AddSqlProject_WithExplicitStart() Assert.True(sqlProjectResource.HasAnnotationOfType()); } + + [Fact] + public void AddSqlProject_WithSkipWhenDeployed() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var targetDatabase = appBuilder.AddSqlServer("sql").AddDatabase("test"); + appBuilder.AddSqlProject("MySqlProject") + .WithReference(targetDatabase) + .WithSkipWhenDeployed(); + + // Act + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Assert + var sqlProjectResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("MySqlProject", sqlProjectResource.Name); + + Assert.True(sqlProjectResource.HasAnnotationOfType()); + } }