Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for deploying dacpacs from referenced NuGet packages #334

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
<PackageVersion Include="EventStore.Client.Extensions.OpenTelemetry" Version="23.3.7" />
<PackageVersion Include="EventStore.Client.Grpc.Streams" Version="23.3.7" />
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.3.2" />
<PackageVersion Include="ErikEJ.Dacpac.Chinook" Version="1.0.0" />
<!-- Build dependencies -->
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4" />
<!-- Testcontainers packages -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" />
<PackageReference Include="ErikEJ.Dacpac.Chinook" IsAspirePackageResource="true" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects\CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.csproj" IsAspireProjectResource="False" />
<ProjectReference Include="..\SdkProject\SdkProject.csproj" />
</ItemGroup>

<Import Project="..\..\..\src\CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects\build\CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.targets" />

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@
builder.AddSqlProject<Projects.SdkProject>("sdk-project")
.WithReference(server);

builder.AddSqlPackage<Packages.ErikEJ_Dacpac_Chinook>("chinook")
.WithReference(server);

builder.Build().Run();
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
<PackageReference Include="Microsoft.SqlServer.DacFx" />
</ItemGroup>

<ItemGroup>
<None Include="**/*.props;**/*.targets" Pack="true" PackagePath="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting;

/// <summary>
/// Represents metadata for a referenced NuGet package.
/// </summary>
public interface IPackageMetadata : IResourceAnnotation
{
/// <summary>
/// Gets the unique identifier of the package.
/// </summary>
string PackageId { get; }

/// <summary>
/// Gets the version of the package.
/// </summary>
Version PackageVersion { get; }

/// <summary>
/// Gets the physical location on disk of the package.
/// </summary>
string PackagePath { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ Aspire.Hosting.ApplicationModel.ConfigureDacDeployOptionsAnnotation.ConfigureDac
Aspire.Hosting.ApplicationModel.ConfigureDacDeployOptionsAnnotation.ConfigureDeploymentOptions.get -> System.Action<Microsoft.SqlServer.Dac.DacDeployOptions!>!
Aspire.Hosting.ApplicationModel.ConfigureDacDeployOptionsAnnotation.ConfigureDeploymentOptions.init -> void
static Aspire.Hosting.SqlProjectBuilderExtensions.WithConfigureDacDeployOptions(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.SqlProjectResource!>! builder, System.Action<Microsoft.SqlServer.Dac.DacDeployOptions!>! configureDeploymentOptions) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.SqlProjectResource!>!
Aspire.Hosting.IPackageMetadata
Aspire.Hosting.IPackageMetadata.PackageId.get -> string!
Aspire.Hosting.IPackageMetadata.PackageVersion.get -> System.Version!
Aspire.Hosting.IPackageMetadata.PackagePath.get -> string!
static Aspire.Hosting.SqlProjectBuilderExtensions.AddSqlPackage<TPackage>(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.SqlProjectResource!>!
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ static SqlProjectBuilderExtensions()
/// <param name="builder">An <see cref="IDistributedApplicationBuilder"/> instance to add the SQL Server Database project to.</param>
/// <param name="name">Name of the resource.</param>
/// <returns>An <see cref="IResourceBuilder{T}"/> that can be used to further customize the resource.</returns>
public static IResourceBuilder<SqlProjectResource> AddSqlProject<TProject>(this IDistributedApplicationBuilder builder, [ResourceName]string name)
public static IResourceBuilder<SqlProjectResource> AddSqlProject<TProject>(this IDistributedApplicationBuilder builder, [ResourceName] string name)
where TProject : IProjectMetadata, new()
{
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
Expand All @@ -46,7 +46,7 @@ public static IResourceBuilder<SqlProjectResource> AddSqlProject<TProject>(this
/// <param name="builder">An <see cref="IDistributedApplicationBuilder"/> instance to add the SQL Server Database project to.</param>
/// <param name="name">Name of the resource.</param>
/// <returns>An <see cref="IResourceBuilder{T}"/> that can be used to further customize the resource.</returns>
public static IResourceBuilder<SqlProjectResource> AddSqlProject(this IDistributedApplicationBuilder builder, [ResourceName]string name)
public static IResourceBuilder<SqlProjectResource> AddSqlProject(this IDistributedApplicationBuilder builder, [ResourceName] string name)
{
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
ArgumentNullException.ThrowIfNull(name, nameof(name));
Expand All @@ -57,6 +57,24 @@ public static IResourceBuilder<SqlProjectResource> AddSqlProject(this IDistribut
.ExcludeFromManifest();
}

/// <summary>
/// Adds a SQL Server Database Project resource to the application based on a referenced NuGet package.
/// </summary>
/// <typeparam name="TPackage">Type that represents the NuGet package that contains the .dacpac file.</typeparam>
/// <param name="builder">An <see cref="IDistributedApplicationBuilder"/> instance to add the SQL Server Database project to.</param>
/// <param name="name">Name of the resource.</param>
/// <returns>Am <see cref="IResourceBuilder{T}"/> that can be used to further customize the resource.</returns>
public static IResourceBuilder<SqlProjectResource> AddSqlPackage<TPackage>(this IDistributedApplicationBuilder builder, [ResourceName] string name)
where TPackage : IPackageMetadata, new()
{
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
ArgumentNullException.ThrowIfNull(name, nameof(name));

return builder.AddSqlProject(name)
.WithAnnotation(new TPackage())
.ExcludeFromManifest();
}

/// <summary>
/// Specifies the path to the .dacpac file.
/// </summary>
Expand All @@ -65,11 +83,6 @@ public static IResourceBuilder<SqlProjectResource> AddSqlProject(this IDistribut
/// <returns>An <see cref="IResourceBuilder{T}"/> that can be used to further customize the resource.</returns>
public static IResourceBuilder<SqlProjectResource> WithDacpac(this IResourceBuilder<SqlProjectResource> builder, string dacpacPath)
{
if (!Path.IsPathRooted(dacpacPath))
{
dacpacPath = Path.Combine(builder.ApplicationBuilder.AppHostDirectory, dacpacPath);
}

return builder.WithAnnotation(new DacpacMetadataAnnotation(dacpacPath));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Eventing;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects;

internal class SqlProjectPublishService(IDacpacDeployer deployer, ResourceLoggerService resourceLoggerService, ResourceNotificationService resourceNotificationService, IDistributedApplicationEventing eventing, IServiceProvider serviceProvider)
internal class SqlProjectPublishService(IDacpacDeployer deployer, IHostEnvironment hostEnvironment, ResourceLoggerService resourceLoggerService, ResourceNotificationService resourceNotificationService, IDistributedApplicationEventing eventing, IServiceProvider serviceProvider)
{
public async Task PublishSqlProject(SqlProjectResource sqlProject, SqlServerDatabaseResource target, CancellationToken cancellationToken)
{
Expand All @@ -13,6 +16,11 @@ public async Task PublishSqlProject(SqlProjectResource sqlProject, SqlServerData
try
{
var dacpacPath = sqlProject.GetDacpacPath();
if (!Path.IsPathRooted(dacpacPath))
{
dacpacPath = Path.Combine(hostEnvironment.ContentRootPath, dacpacPath);
}

if (!File.Exists(dacpacPath))
{
logger.LogError("SQL Server Database project package not found at path {DacpacPath}.", dacpacPath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ internal string GetDacpacPath()
return targetPath;
}

if (this.TryGetLastAnnotation<IPackageMetadata>(out var packageMetadata))
{
var packagePath = packageMetadata.PackagePath;
if (this.TryGetLastAnnotation<DacpacMetadataAnnotation>(out var relativeDacpacMetadata))
{
return Path.Combine(packagePath, relativeDacpacMetadata.DacpacPath);;
}
else
{
return Path.Combine(packagePath, "tools", packageMetadata.PackageId + ".dacpac");
}
}

if (this.TryGetLastAnnotation<DacpacMetadataAnnotation>(out var dacpacMetadata))
{
return dacpacMetadata.DacpacPath;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<Project>

<ItemGroup>
<_AspirePackageResource Condition="'$(ManagePackageVersionsCentrally)'!='true'" Include="@(PackageReference->WithMetadataValue('IsAspirePackageResource', 'true'))" />
<_AspirePackageResource Condition="'$(ManagePackageVersionsCentrally)'=='true'" Include="@(PackageVersion)" />
<_AspirePackageResource Condition="'$(ManagePackageVersionsCentrally)'=='true'" Update="@(PackageReference)">
<IsAspirePackageResource Condition="'%(PackageReference.IsAspirePackageResource)' != ''">%(PackageReference.IsAspirePackageResource)</IsAspirePackageResource>
</_AspirePackageResource>
</ItemGroup>

<!-- Generate the data structures for doing the codegen for package resources -->
<Target Name="CreateAspirePackageMetadataSources"
DependsOnTargets="_CreateAspireProjectResources">
<ItemGroup>
<AspirePackageMetadataSource Include="@(_AspirePackageResource->WithMetadataValue('IsAspirePackageResource', 'true'))" Condition="'@(_AspirePackageResource)' != ''">
<ClassName Condition="%(_AspirePackageResource.AspirePackageMetadataTypeName) == ''">$([System.Text.RegularExpressions.Regex]::Replace(%(_AspirePackageResource.Identity), $(_GeneratedClassNameFixupRegex), '_'))</ClassName>
<ClassName Condition="%(_AspirePackageResource.AspirePackageMetadataTypeName) != ''">$([System.Text.RegularExpressions.Regex]::Replace(%(_AspirePackageResource.AspirePackageMetadataTypeName), $(_GeneratedClassNameFixupRegex), '_'))</ClassName>
<PackageName>@(_AspirePackageResource->'%(Identity)'->Replace('.', '_'))</PackageName>
<PackagePath>$([System.String]::new('$(NuGetPackageRoot)%(_AspirePackageResource.Identity)/%(_AspirePackageResource.Version)').ToLower())</PackagePath>
</AspirePackageMetadataSource>
</ItemGroup>
</Target>

<Target Name="_CSharpWritePackageMetadataSources" DependsOnTargets="CreateAspirePackageMetadataSources" Condition="'$(Language)' == 'C#'">
<ItemGroup>
<AspirePackageMetadataSource Update="@(AspirePackageMetadataSource)">
<Source>
<![CDATA[// <auto-generated/>

namespace Packages%3B

[global::System.CodeDom.Compiler.GeneratedCode("Aspire.Hosting", null)]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "Generated code.")]
[global::System.Diagnostics.DebuggerDisplay("Type = {GetType().Name,nq}, PackageId = {PackageId}, PackageVersion = {PackageVersion}, PackagePath = {PackagePath}")]
]]>$(AspireGeneratedClassesVisibility)<![CDATA[ class ]]>%(ClassName)<![CDATA[ : global::Aspire.Hosting.IPackageMetadata
{
public string PackageId => """]]>%(Identity)<![CDATA["""%3B
public Version PackageVersion => new Version("""]]>%(Version)<![CDATA[""")%3B
public string PackagePath => """]]>%(PackagePath)<![CDATA["""%3B
}]]>
</Source>
</AspirePackageMetadataSource>
</ItemGroup>

<WriteLinesToFile File="$(_AspireIntermediatePath)references\%(AspirePackageMetadataSource.ClassName).PackageMetadata.g.cs"
Overwrite="true"
Lines="%(AspirePackageMetadataSource.Source)"
Condition="%(AspirePackageMetadataSource.ClassName) != ''"
WriteOnlyWhenDifferent="true" />
<ItemGroup>
<FileWrites Include="$(_AspireIntermediatePath)references\%(AspirePackageMetadataSource.ClassName).PackageMetadata.g.cs" />
<Compile Include="$(_AspireIntermediatePath)references\%(AspirePackageMetadataSource.ClassName).PackageMetadata.g.cs"
Condition="%(AspirePackageMetadataSource.ClassName) != ''" />
</ItemGroup>
</Target>

<PropertyGroup>
<!-- Easy extension point for adding new languages' write support. -->
<WriteAspirePackageMetadataSourcesDependsOn>_CSharpWritePackageMetadataSources;</WriteAspirePackageMetadataSourcesDependsOn>
</PropertyGroup>

<!-- The purpose of this target is to take all of the generated package metadata and write them to the intermediate build directory
and reference them for compilation. There will be a ClassName.PackageMetadata.g.cs file for each referenced project. -->
<Target Name="WriteAspirePackageMetadataSources" DependsOnTargets="$(WriteAspirePackageMetadataSourcesDependsOn)" BeforeTargets="CoreCompile" />
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<Project>
<Import Project="..\build\CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.targets" />
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Aspire.Hosting;

namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests;

public class AddSqlPackageTests
{
[Fact]
public void AddSqlPackage_WithPackageMetadata()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
appBuilder.AddSqlPackage<TestPackage>("chinook");

// Act
using var app = appBuilder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();

// Assert
var sqlProjectResource = Assert.Single(appModel.Resources.OfType<SqlProjectResource>());
Assert.Equal("chinook", sqlProjectResource.Name);

var dacpacPath = sqlProjectResource.GetDacpacPath();
Assert.NotNull(dacpacPath);
Assert.Equal(Path.Combine(TestPackage.NuGetPackageCache, "erikej.dacpac.chinook", "1.0.0", "tools", "ErikEJ.Dacpac.Chinook.dacpac"), dacpacPath);
Assert.True(File.Exists(dacpacPath));
}

[Fact]
public void AddSqlPackage_WithExplicitRelativePath()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
appBuilder.AddSqlPackage<TestPackage>("chinook").WithDacpac(Path.Combine("tools", "ErikEJ.Dacpac.Chinook2.dacpac"));

// Act
using var app = appBuilder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();

// Assert
var sqlProjectResource = Assert.Single(appModel.Resources.OfType<SqlProjectResource>());
Assert.Equal("chinook", sqlProjectResource.Name);

var dacpacPath = sqlProjectResource.GetDacpacPath();
Assert.NotNull(dacpacPath);
Assert.Equal(Path.Combine(TestPackage.NuGetPackageCache, "erikej.dacpac.chinook", "1.0.0", "tools", "ErikEJ.Dacpac.Chinook2.dacpac"), dacpacPath);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public void AddSqlProject_WithExplicitPath()
Assert.Equal("MySqlProject", sqlProjectResource.Name);

Assert.True(sqlProjectResource.TryGetLastAnnotation(out DacpacMetadataAnnotation? dacpacMetadataAnnotation));
Assert.Equal(Path.Combine(appBuilder.AppHostDirectory, TestProject.RelativePath), dacpacMetadataAnnotation.DacpacPath);
Assert.Equal(TestProject.RelativePath, dacpacMetadataAnnotation.DacpacPath);

var dacpacPath = sqlProjectResource.GetDacpacPath();
Assert.NotNull(dacpacPath);
Expand Down Expand Up @@ -96,7 +96,7 @@ public void AddSqlProject_WithDeploymentOptions()
}

[Fact]
public void PublishTo_AddsRequiredServices()
public void WithReference_AddsRequiredServices()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests;
public class AppHostTests(AspireIntegrationTestFixture<Projects.CommunityToolkit_Aspire_Hosting_SqlDatabaseProjects_AppHost> fixture) : IClassFixture<AspireIntegrationTestFixture<Projects.CommunityToolkit_Aspire_Hosting_SqlDatabaseProjects_AppHost>>
{
[Fact]
public async Task ResourceStartsAndRespondsOk()
public async Task ProjectBasedResourceStartsAndRespondsOk()
{
string resourceName = "sdk-project";
await fixture.ResourceNotificationService.WaitForResourceAsync(resourceName, KnownResourceStates.Finished).WaitAsync(TimeSpan.FromMinutes(5));
Expand All @@ -29,4 +29,27 @@ public async Task ResourceStartsAndRespondsOk()
var result = await command.ExecuteScalarAsync();
Assert.Equal(1, result);
}

[Fact]
public async Task PackageBasedResourceStartsAndRespondsOk()
{
string resourceName = "chinook";
await fixture.ResourceNotificationService.WaitForResourceAsync(resourceName, KnownResourceStates.Finished).WaitAsync(TimeSpan.FromMinutes(5));

string? connectionString = await fixture.GetConnectionString("TargetDatabase");
Assert.NotNull(connectionString);

using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();

using var command = connection.CreateCommand();
command.CommandText =
"SELECT COUNT(1) " +
"FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_SCHEMA = 'dbo' " +
"AND TABLE_NAME = 'InvoiceLine'";

var result = await command.ExecuteScalarAsync();
Assert.Equal(1, result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Aspire.Hosting;

namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects.Tests;

internal class TestPackage : IPackageMetadata
{
public static readonly string NuGetPackageCache = Path.Combine(Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE") ?? string.Empty, ".nuget", "packages");

public string PackageId { get; } = "ErikEJ.Dacpac.Chinook";

public Version PackageVersion { get; } = new Version(1, 0, 0);

public string PackagePath { get; } = Path.Combine(NuGetPackageCache, "erikej.dacpac.chinook", "1.0.0");
}
Loading