Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="MSBuild.Sdk.SqlProj/3.0.0">
<Project Sdk="MSBuild.Sdk.SqlProj/3.2.0">
<PropertyGroup>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<SqlServerVersion>Sql150</SqlServerVersion>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string?> 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<string> 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<bool> 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
},
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// 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.
/// </summary>
public sealed class DacpacSkipWhenDeployedAnnotation : IResourceAnnotation
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Microsoft.Extensions.Logging;

namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects;

/// <summary>
/// Abstracts the check of the .dacpac file already having been deployed to the target SQL Server database.
/// </summary>
internal interface IDacpacChecksumService
{
/// <summary>
/// Checks if the <paramref name="dacpacPath" /> file has already been deployed to the specified <paramref name="targetConnectionString" />
/// </summary>
/// <param name="dacpacPath">Path to the .dacpac file to deploy.</param>
/// <param name="targetConnectionString">Connection string to the SQL Server.</param>
/// <param name="deploymentSkipLogger">An <see cref="ILogger" /> to write the log to.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the deployment operation.</param>
/// <returns>the checksum calculated for the .dacpac if it has not been deployed, otherwise null</returns>
Task<string?> CheckIfDeployedAsync(string dacpacPath, string targetConnectionString, ILogger deploymentSkipLogger, CancellationToken cancellationToken);

/// <summary>
/// Sets the checksum extended property on the target database to indicate that the <paramref name="dacpacPath" /> file has been deployed.
/// </summary>
/// <param name="dacpacPath">Path to the .dacpac file to deploy.</param>
/// <param name="targetConnectionString">Connection string to the SQL Server.</param>
/// <param name="dacpacChecksum">Checksum for the .dacpac </param>
/// <param name="deploymentSkipLogger">An <see cref="ILogger" /> to write the log to.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the deployment operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task SetChecksumAsync(string dacpacPath, string targetConnectionString, string dacpacChecksum, ILogger deploymentSkipLogger, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -64,6 +68,7 @@ builder.Build().Run();
```

## Deployment options support

Define options that affect the behavior of package deployment.

```csharp
Expand All @@ -77,4 +82,24 @@ builder.AddSqlProject("mysqlproj")
.WithReference(sql);

builder.Build().Run();
```
```

## 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<Projects.SdkProject>("mysqlproj")
.WithSkipWhenDeployed()
.WithReference(database);

builder.Build().Run();
```
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,29 @@ internal static IResourceBuilder<TResource> InternalWithDacpac<TResource>(this I
return builder.WithAnnotation(new DacpacMetadataAnnotation(dacpacPath));
}

/// <summary>
/// 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.
/// </summary>
/// <param name="builder">An <see cref="IResourceBuilder{T}"/> representing the SQL Server Database project.</param>
/// <returns>An <see cref="IResourceBuilder{T}"/> that can be used to further customize the resource.</returns>
public static IResourceBuilder<SqlProjectResource> WithSkipWhenDeployed(this IResourceBuilder<SqlProjectResource> builder)
=> InternalWithSkipWhenDeployed(builder);

/// <summary>
/// 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.
/// </summary>
/// <param name="builder">An <see cref="IResourceBuilder{T}"/> representing the SQL Server Database project.</param>
/// <returns>An <see cref="IResourceBuilder{T}"/> that can be used to further customize the resource.</returns>
public static IResourceBuilder<SqlPackageResource<TPackage>> WithSkipWhenDeployed<TPackage>(this IResourceBuilder<SqlPackageResource<TPackage>> builder)
where TPackage : IPackageMetadata => InternalWithSkipWhenDeployed(builder);


internal static IResourceBuilder<TResource> InternalWithSkipWhenDeployed<TResource>(this IResourceBuilder<TResource> builder)
where TResource : IResourceWithDacpac
{
return builder.WithAnnotation(new DacpacSkipWhenDeployedAnnotation());
}

/// <summary>
/// Adds a delegate annotation for configuring dacpac deployment options to the <see cref="SqlProjectResource"/>.
/// </summary>
Expand Down Expand Up @@ -218,6 +241,7 @@ internal static IResourceBuilder<TResource> InternalWithReference<TResource>(thi
where TResource : IResourceWithDacpac
{
builder.ApplicationBuilder.Services.TryAddSingleton<IDacpacDeployer, DacpacDeployer>();
builder.ApplicationBuilder.Services.TryAddSingleton<IDacpacChecksumService, DacpacChecksumService>();
builder.ApplicationBuilder.Services.TryAddSingleton<SqlProjectPublishService>();

builder.WithParentRelationship(target.Resource);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -42,11 +42,33 @@ await resourceNotificationService.PublishUpdateAsync(resource,
return;
}

string? checksum = null;

if (resource.HasAnnotationOfType<DacpacSkipWhenDeployedAnnotation>())
{
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<DacpacSkipWhenDeployedAnnotation>())
{
await dacpacChecksumService.SetChecksumAsync(dacpacPath, connectionString, checksum, logger, cancellationToken);
}

await resourceNotificationService.PublishUpdateAsync(resource,
state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success) });

Expand Down
Loading
Loading