Category: Aspire

Execute SqlServer scripts at startup – Aspire 9.1 vs Aspire 9.2

One of the usual things in Software development is creating the database with all the tables,views, stored procedures and anything related . For Aspire 9.1 with Sql Ser, this involved a coordination between some shell scripts – see the code :

var sqlserver = builder.AddSqlServer("sqlserver", paramPass, 1433)
    .WithDbGate()
// Mount the init scripts directory into the container.
.WithBindMount("./sqlserverconfig", "/usr/config")
// Mount the SQL scripts directory into the container so that the init scripts run.
.WithBindMount("../../Scripts/data/sqlserver", "/docker-entrypoint-initdb.d")
// Run the custom entrypoint script on startup.
.WithEntrypoint("/usr/config/entrypoint.sh")

The scripts where in the mount binding. Also, some .gitattributes files was required with

* text=auto
*.sh text eol=lf
*.mod text eol=lf
*.sum text eol=lf

to can execute the .sh entry point. An painfull orchestration just for create tables

For Aspire 9.2 this has been simplified a bit , because of Creation Script for database

var db = sqlserver.AddDatabase("MyDB")
    .WithCreationScript("....")

However , this is has some flaws :

1 .Executing in the timeout of an sql

2, Executing just the last one

You can find the code at SqlServerBuilderExtensions.cs :

 private static async Task CreateDatabaseAsync(SqlConnection sqlConnection, SqlServerDatabaseResource sqlDatabase, IServiceProvider serviceProvider, CancellationToken ct)
 {
     try
     {
         var scriptAnnotation = sqlDatabase.Annotations.OfType<SqlServerCreateDatabaseScriptAnnotation>().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);
   

So I made my own extension,

  [GeneratedRegex(@"^\s*GO(?<repeat>\s+\d{1,6})?(\s*\-{2,}.*)?\s*$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
  internal static partial Regex GoStatements();
  public static IResourceBuilder<SqlServerDatabaseResource> ExecuteSqlServerScripts(this IResourceBuilder<SqlServerDatabaseResource> db, params IEnumerable<string> sqlScripts)
  {
      var builder = db.ApplicationBuilder;
      builder.Eventing.Subscribe<ResourceReadyEvent>(async (ev, ct) =>
      {
          if (ev.Resource is not SqlServerDatabaseResource dbRes) return;
          if (db.Resource != dbRes) return;
          var cn = await dbRes.ConnectionStringExpression.GetValueAsync(ct);
          if (cn == null) return;
          using var sqlConnection = new SqlConnection();
          sqlConnection.ConnectionString = cn;
          await sqlConnection.OpenAsync(ct);
          foreach (var item in sqlScripts)
          {
              using var reader = new StringReader(item);
              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;
                          //TODO: modify the timeout
                          command.CommandTimeout = 120;
                          try
                          {
                              await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
                          }
                          catch (Exception ex)
                          {
                              //TODO: log
                              Console.WriteLine($"!!!!Error in executing {batch} : {ex.Message}");
                          }
                      }

                      batchBuilder.Clear();
                  }
                  else
                  {
                      // Prevent batches with only whitespace
                      if (!string.IsNullOrWhiteSpace(line))
                      {
                          batchBuilder.AppendLine(line);
                      }
                  }
              }

              // Process the remaining batch lines
              if (batchBuilder.Length > 0)
              {
                  using var command = sqlConnection.CreateCommand();
                  var batch = batchBuilder.ToString();
                  command.CommandText = batch;
                  //TODO: modify the timeout
                  try
                  {
                      await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
                  }
                  catch (Exception ex)
                  {
                      //TODO: log

                      Console.WriteLine($"!!!!Error in executing {batch} : {ex.Message}");
                  }
              }
          }
      });
      return db;
  }

And can be executed like this:

var db = sqlserver.AddDatabase("MyDB")
    .WithSqlPadViewerForDB(sqlserver)
    .ExecuteSqlServerScripts(DBFiles.FilesToCreate)

Aspire 9.x add sql server database viewer

.NET Aspire is a formidable tool to visualize your components and relation between them . Today I will show you how to add a custom visualizer for SqlServer database .

The code for adding a database is pretty simple

var paramPass = builder.AddParameter("password", "P@ssw0rd");
var sqlserver = builder.AddSqlServer("sqlserver", paramPass, 1433)
    .WithDbGate()
;
var db = sqlserver.AddDatabase("NewDB")
;

 

 

The community extension, https://github.com/CommunityToolkit/Aspire, has already an extension, WithDBGate, that adds a viewer for the whole SqlServer . But I want something faster, that adds just for the database . So SqlPad , https://getsqlpad.com/ , that has also a docker container , will be enough. So this is the code

public  static class SqlServerExtensions
{
    /// <summary>
    /// 
    /// </summary>
    /// <param name="db"></param>
    /// <param name="sqlserver"></param>
    /// <returns></returns>
    public static IResourceBuilder<SqlServerDatabaseResource> WithSqlPadViewerForDB(this IResourceBuilder<SqlServerDatabaseResource> db,IResourceBuilder<SqlServerServerResource> sqlserver) 
    {
    var builder = db.ApplicationBuilder;
    
    var sqlpad = builder
.AddContainer("sqlpad", "sqlpad/sqlpad:latest")
.WithEndpoint(5600, 3000, "http")
.WithEnvironment("SQLPAD_AUTH_DISABLED", "true")
.WithEnvironment("SQLPAD_AUTH_DISABLED_DEFAULT_ROLE","Admin")
.WithEnvironment("SQLPAD_ADMIN", "admin@sqlpad.com")

.WithEnvironment("SQLPAD_ADMIN_PASSWORD", "admin")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__name", sqlserver.Resource.Name)
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__driver", "sqlserver")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__host", sqlserver.Resource.Name)
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__database", db.Resource.Name)
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__username", "sa")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__password", sqlserver.Resource.PasswordParameter.Value)


.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__name", "SqlMaster")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__driver", "sqlserver")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__host", sqlserver.Resource.Name)
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__database", "master")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__username", "sa")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__password", sqlserver.Resource.PasswordParameter.Value)
.WithParentRelationship(db)
.WaitFor(db)
.WaitFor(sqlserver)
;
    return db;
}
}

And the code for adding is

  var db = sqlserver.AddDatabase("NewDB")
  .WithSqlPadViewerForDB(sqlserver);

What I have learned by building .NET Stars -part 3- Aspire

I have that same idea to build a project : is what is called today a Modular Monolith – and it is very old in .NET world as can be implemented as a .sln solution.
For those unfamiliar, a Modular Monolith allows you to break down your application into independent modules (think database access, data flow logic, a sleek UI), yet keep them tightly integrated within a single, cohesive solution.

It’s how all these components can work together seamlessly. This idea resonated with me when I started thinking about my project. So, let me break it down:

Interfaces for Data/Flow These lay the foundation, ensuring that data and operations flow smoothly between different parts of the project.
  
– A database stores all the necessary information, serving as the backbone of my application.

– Then there’s the WebAPI, which acts like a messenger, transferring data between the database and the users’ interfaces.

– And finally, for the User Interface, I’ve chosen Blazor. It brings together code, design, and interactivity i

Aspire is a game-changer in the .NET world, offering a simple yet powerful way to coordinate multiple projects. By starting an app host with all the projects intertwined, it simplifies the process of building complex applications.  More, Aspire let me coordinate another project to save the data on Blazor – but this will be discussed in another blog post later .

I have had just 2 modifications to make it work flawlessly :

1. Blazor

  To know the address of the WebAPI to obtain the data ( once published, Blazor will be near the WebAPI in the wwwroot, but until then it needs the adress )

Blazor can have the configuration stored in a appsettings.json in the wwwroot – but how to write ? I developed an extension for ASPIRE in order to write the data

2. Database

In order to have the database with data , I need to write scripts for create table / insert the data.

This code shows how

var paramPass = builder.AddParameter("password", "P@ssw0rd");

var sqlserver = builder.AddSqlServer("sqlserver",paramPass,1433)
    //.WithArgs("pwd","&amp;","ls")
    // Mount the init scripts directory into the container.
    .WithBindMount("./sqlserverconfig", "/usr/config")
    // Mount the SQL scripts directory into the container so that the init scripts run.
    .WithBindMount("../../Scripts/data/sqlserver", "/docker-entrypoint-initdb.d")
    // Run the custom entrypoint script on startup.
    .WithEntrypoint("/usr/config/entrypoint.sh")
    // Configure the container to store data in a volume so that it persists across instances.
    .WithDataVolume() 
    // Keep the container running between app host sessions.
    .WithLifetime(ContainerLifetime.Persistent)

    ;
var db= sqlserver.AddDatabase("DotNetStats");

Also , I have an .gitattributes that contains

* text=auto
*.sh text eol=lf
*.mod text eol=lf
*.sum text eol=lf

in order for the .sh files to maintain linux ending.

You can see at https://ignatandrei.github.io/dotnetstats/ and the source code at http://github.com/ignatandrei/dotnetstats/

Aspire Blazor WebAssembly and WebAPI

 

Aspire is the new visualizer – see https://github.com/dotnet/aspire

I am very fond of WebAPI  –  it allows for all people to see the functionality of a site , in a programmatic way ( side note: , my nuget package, https://www.nuget.org/packages/NetCore2Blockly , allows to make workflows from your WebAPI)

And Blazor WebAssembly is a nice addition that the WebAPI . I am talking about Interactive WebAssembly (https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?preserve-view=true&view=aspnetcore-8.0  )  . I do want ( for the moment ) to use Interactive Server because

  1. it is easy to forget to add functionality to the WebAPI
  2. it is not separating UI from BL

So I decided to add an Blazor WebAssembly and WebAPI into Aspire to see how they work together.

The first problem that  I have is how to transmit the WebAPI URL to the Blazor WebAssembly . Think that is not Interactive Server or Auto – in order to have the environment or configuration . Blazor Interactive WebAssembly  are just static files that are downloaded to the client. And they are executed in the browser.

But I have tried with adding to the Environment in usual way

builder.AddProject<projects.exampleblazorapp>(nameof(Projects.ExampleBlazorApp))
.WithEnvironment(ctx =&gt;
{
if (api.Resource.TryGetAllocatedEndPoints(out var end))
{
if (end.Any())
	ctx.EnvironmentVariables["HOSTAPI"] = end.First().UriString;
}

 

And no use!

After reading ASP.NET Core Blazor configuration | Microsoft Learn  and aspire/src/Microsoft.Extensions.ServiceDiscovery at main · dotnet/aspire (github.com) and API review for Service Discovery · Issue #789 · dotnet/aspire (github.com) I realized that the ONLY way is to put in wwwroot/appsettings.json

So I came with the following code that tries to write DIRECTLY to wwwroot/appsettings.json file


namespace Aspire.Hosting;
public static class BlazorWebAssemblyProjectExtensions
{
    public static IResourceBuilder<ProjectResource> AddWebAssemblyProject<TProject>(
        this IDistributedApplicationBuilder builder, string name,
        IResourceBuilder<ProjectResource> api) 
        where TProject : IServiceMetadata, new()
    {
        var projectbuilder = builder.AddProject<TProject>(name);
        var p=new TProject();
        string hostApi= p.ProjectPath;
        var dir = Path.GetDirectoryName(hostApi);
        ArgumentNullException.ThrowIfNull(dir);
        var wwwroot = Path.Combine(dir, "wwwroot");
        if (!Directory.Exists(wwwroot)) {
            Directory.CreateDirectory(wwwroot);
        }
        var file = Path.Combine(wwwroot, "appsettings.json");
        if (!File.Exists(file))
            File.WriteAllText(file, "{}");
        projectbuilder =projectbuilder.WithEnvironment(ctx =>
        {
            if (api.Resource.TryGetAllocatedEndPoints(out var end))
            {
                if (end.Any())
                {
                    
                    var fileContent = File.ReadAllText(file);

                    Dictionary<string, object>? dict;
                    if (!string.IsNullOrWhiteSpace(fileContent))
                        dict = new Dictionary<string, object>();
                    else
                        dict = JsonSerializer.Deserialize<Dictionary<string,object>>(fileContent!);

                    ArgumentNullException.ThrowIfNull(dict);
                    dict["HOSTAPI"] = end.First().UriString;                    
                    JsonSerializerOptions opt = new JsonSerializerOptions(JsonSerializerOptions.Default)
                            { WriteIndented=true};
                    File.WriteAllText(file,JsonSerializer.Serialize(dict,opt));
                    ctx.EnvironmentVariables["HOSTAPI"]=end.First().UriString;
                    
                }
                    
            }

        });
        return projectbuilder;

    }
}

And in Aspire

var api = builder.AddProject<Projects.ExampleWebAPI>(nameof(Projects.ExampleWebAPI));
builder.AddWebAssemblyProject<Projects.ExampleBlazorApp>(nameof(Projects.ExampleBlazorApp), api);

And in Blazor Interactive WebAssembly


var hostApi = builder.Configuration["HOSTAPI"];
if (string.IsNullOrEmpty(hostApi))
{
    hostApi = builder.HostEnvironment.BaseAddress;
    var dict = new Dictionary<string, string?> { { "HOSTAPI", hostApi } };
    builder.Configuration.AddInMemoryCollection(dict.ToArray());
}

builder.Services.AddKeyedScoped("db",(sp,_) => new HttpClient { BaseAddress = new Uri(hostApi) });

What about deploying the code to production ? Well, I think that is better to wrote yourself to wwwroot/appsettings.json and remove the data . But I will try to deploy and let you know….

Aspire , containers and dotnet watch

Aspire is the new visualizer – see https://github.com/dotnet/aspire

If you use dotnet run ( or Visual Studio)  with an Aspire host that instantiate some containers  , then , when you stop the project, the container is released.

But, if you use

dotnet watch run –nohotreload

then the containers are not  deleted. The nice solution is to investigate dotnet watch and Aspire . And maybe fill a bug.

The easy ? Delete the containers first !


void DeleteDockerContainers()
{
    var process = new Process
    {
        StartInfo = new ProcessStartInfo
        {
            FileName = "powershell.exe",
            Arguments = $$"""
-Command "docker rm -f $(docker ps -a -q)"
""",
            RedirectStandardOutput = true,
            UseShellExecute = false,
            CreateNoWindow = true,
        }
    };
    process.Start();
    while (!process.StandardOutput.EndOfStream)
    {
        var line = process.StandardOutput.ReadLine();
        Console.WriteLine(line);
    }
}

Aspire, Sql Server Docker Container and multiple Connections strings

Aspire is the new visualizer – see https://github.com/dotnet/aspire

When creating a Docker container with Sql Server , the connection that is returned is without database – means that , usually, is connecting to the master.

That’s not that we usually want, so the following code is means that WebAPI will be connected to master

var builder = DistributedApplication.CreateBuilder(args);
var rb= builder.AddSqlServerContainer("Db2Gui", "<YourStrong@Passw0rd>");
builder.AddProject<Projects.ExampleWebAPI>(nameof(Projects.ExampleWebAPI))
   .WithReference(rb)
    ;

Instead , do what they do – but add the database

var builder = DistributedApplication.CreateBuilder(args);

var rb= builder.AddSqlServerContainer("Db2Gui", "<YourStrong@Passw0rd>");

builder.AddProject<Projects.ExampleWebAPI>(nameof(Projects.ExampleWebAPI))
    .WithEnvironment(ctx=>
    {
        var connectionStringName = $"ConnectionStrings__";
        var res=rb.Resource;
        var cn = res.GetConnectionString();
        ctx.EnvironmentVariables[connectionStringName+ "ApplicationDBContext"] = cn+ $";database=tests;";
        ctx.EnvironmentVariables[connectionStringName+ "NorthwindDBContext"] = cn + $";database=northwind;";
        ctx.EnvironmentVariables[connectionStringName+ "PubsDBContext"] = cn + $";database=pubs;";
    })
    //.WithReference(rb, "")
    ;

It is true that you can make the following :

builder.AddProject<Projects.ExampleWebAPI>(nameof(Projects.ExampleWebAPI))
    .WithReference(rb.AddDatabase("tests"), "ApplicationDBContext")
    .WithReference(rb.AddDatabase("northwind"), "NorthwindDBContext")
    .WithReference(rb.AddDatabase("pubs"), "PubsDBContext")
    .WithReference(rb.AddDatabase("NotCreated"), "NotCreated")

    ;

But it does not CREATE the database ( and it is a good thing …. EF EnsureCreated verifies JUST the existence of the database not of the tables within)
So thats why I prefer WithEnvironment rather than .WithReference(rb.AddDatabase

Andrei Ignat weekly software news(mostly .NET)

* indicates required

Please select all the ways you would like to hear from me:

You can unsubscribe at any time by clicking the link in the footer of our emails. For information about our privacy practices, please visit our website.

We use Mailchimp as our marketing platform. By clicking below to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp's privacy practices here.