diff --git a/examples/surrealdb/CommunityToolkit.Aspire.Hosting.SurrealDb.AppHost/Program.cs b/examples/surrealdb/CommunityToolkit.Aspire.Hosting.SurrealDb.AppHost/Program.cs index d83ecaca9..89db3e925 100644 --- a/examples/surrealdb/CommunityToolkit.Aspire.Hosting.SurrealDb.AppHost/Program.cs +++ b/examples/surrealdb/CommunityToolkit.Aspire.Hosting.SurrealDb.AppHost/Program.cs @@ -2,11 +2,34 @@ var builder = DistributedApplication.CreateBuilder(args); -var db = builder.AddSurrealServer("surreal") +bool strictMode = true; + +var db = builder.AddSurrealServer("surreal", strictMode: strictMode) .WithSurrealist() .AddNamespace("ns") .AddDatabase("db"); +if (strictMode) +{ + db.WithCreationScript( + $""" + DEFINE DATABASE IF NOT EXISTS {nameof(db)}; + USE DATABASE {nameof(db)}; + + DEFINE TABLE todo; + DEFINE FIELD title ON todo TYPE string; + DEFINE FIELD dueBy ON todo TYPE datetime; + DEFINE FIELD isComplete ON todo TYPE bool; + + DEFINE TABLE weatherForecast; + DEFINE FIELD date ON weatherForecast TYPE datetime; + DEFINE FIELD country ON weatherForecast TYPE string; + DEFINE FIELD temperatureC ON weatherForecast TYPE number; + DEFINE FIELD summary ON weatherForecast TYPE string; + """ + ); +} + builder.AddProject("apiservice") .WithReference(db) .WaitFor(db); diff --git a/src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbBuilderExtensions.cs index 5f98ebe9c..711bc1c1e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbBuilderExtensions.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Aspire.SurrealDb; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; using SurrealDb.Net; using System.Text; using System.Text.Json; @@ -80,6 +81,7 @@ public static IResourceBuilder AddSurrealServer( : SurrealDbContainerImageTags.Tag; var surrealServer = new SurrealDbServerResource(name, userName?.Resource, passwordParameter); + return builder.AddResource(surrealServer) .WithEndpoint(port: port, targetPort: SurrealDbPort, name: SurrealDbServerResource.PrimaryEndpointName) .WithImage(SurrealDbContainerImageTags.Image, imageTag) @@ -90,7 +92,47 @@ public static IResourceBuilder AddSurrealServer( context.EnvironmentVariables[PasswordEnvVarName] = surrealServer.PasswordParameter; }) .WithEntrypoint("/surreal") - .WithArgs([.. args]); + .WithArgs([.. args]) + .OnResourceReady(async (_, @event, ct) => + { + if (!strictMode) + { + return; + } + + var connectionString = await surrealServer.GetConnectionStringAsync(ct).ConfigureAwait(false); + if (connectionString is null) + { + throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{surrealServer.Name}' resource but the connection string was null."); + } + + var options = new SurrealDbOptionsBuilder().FromConnectionString(connectionString).Build(); + await using var surrealClient = new SurrealDbClient(options); + + foreach (var nsResourceName in surrealServer.Namespaces.Keys) + { + if (builder.Resources.FirstOrDefault(n => + string.Equals(n.Name, nsResourceName, StringComparison.OrdinalIgnoreCase)) is + SurrealDbNamespaceResource surrealDbNamespace) + { + await CreateNamespaceAsync(surrealClient, surrealDbNamespace, @event.Services, ct) + .ConfigureAwait(false); + + await surrealClient.Use(surrealDbNamespace.NamespaceName, null!, ct).ConfigureAwait(false); + + foreach (var dbResourceName in surrealDbNamespace.Databases.Keys) + { + if (builder.Resources.FirstOrDefault(n => + string.Equals(n.Name, dbResourceName, StringComparison.OrdinalIgnoreCase)) is + SurrealDbDatabaseResource surrealDbDatabase) + { + await CreateDatabaseAsync(surrealClient, surrealDbDatabase, @event.Services, ct) + .ConfigureAwait(false); + } + } + } + } + }); } /// @@ -132,6 +174,25 @@ public static IResourceBuilder AddNamespace( var surrealServerNamespace = new SurrealDbNamespaceResource(name, namespaceName, builder.Resource); return builder.ApplicationBuilder.AddResource(surrealServerNamespace); } + + /// + /// Defines the SQL script used to create the namespace. + /// + /// The builder for the . + /// The SQL script used to create the namespace. + /// A reference to the . + /// + /// Default script is DEFINE NAMESPACE IF NOT EXISTS `QUOTED_NAMESPACE_NAME`; + /// + public static IResourceBuilder WithCreationScript(this IResourceBuilder builder, string script) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(script); + + builder.WithAnnotation(new SurrealDbCreateNamespaceScriptAnnotation(script)); + + return builder; + } /// /// Adds a SurrealDB database to the application model. This is a child resource of a . @@ -202,6 +263,25 @@ public static IResourceBuilder AddDatabase( return builder.ApplicationBuilder.AddResource(surrealServerDatabase) .WithHealthCheck(healthCheckKey); } + + /// + /// Defines the SQL script used to create the database. + /// + /// The builder for the . + /// The SQL script used to create the database. + /// A reference to the . + /// + /// Default script is DEFINE DATABASE IF NOT EXISTS `QUOTED_DATABASE_NAME`; + /// + public static IResourceBuilder WithCreationScript(this IResourceBuilder builder, string script) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(script); + + builder.WithAnnotation(new SurrealDbCreateDatabaseScriptAnnotation(script)); + + return builder; + } /// /// Adds a named volume for the data folder to a SurrealDB resource. @@ -447,4 +527,62 @@ CancellationToken cancellationToken return Encoding.UTF8.GetString(stream.ToArray()); } + + private static async Task CreateNamespaceAsync( + SurrealDbClient surrealClient, + SurrealDbNamespaceResource namespaceResource, + IServiceProvider serviceProvider, + CancellationToken cancellationToken + ) + { + var scriptAnnotation = namespaceResource.Annotations.OfType().LastOrDefault(); + + var logger = serviceProvider.GetRequiredService().GetLogger(namespaceResource.Parent); + logger.LogDebug("Creating namespace '{NamespaceName}'", namespaceResource.NamespaceName); + + try + { + var response = await surrealClient.RawQuery( + scriptAnnotation?.Script ?? $"DEFINE NAMESPACE IF NOT EXISTS `{namespaceResource.NamespaceName}`;", + cancellationToken: cancellationToken + ).ConfigureAwait(false); + + response.EnsureAllOks(); + + logger.LogDebug("Namespace '{NamespaceName}' created successfully", namespaceResource.NamespaceName); + } + catch (Exception e) + { + logger.LogError(e, "Failed to create namespace '{NamespaceName}'", namespaceResource.NamespaceName); + } + } + + private static async Task CreateDatabaseAsync( + SurrealDbClient surrealClient, + SurrealDbDatabaseResource databaseResource, + IServiceProvider serviceProvider, + CancellationToken cancellationToken + ) + { + var scriptAnnotation = databaseResource.Annotations.OfType().LastOrDefault(); + + var logger = serviceProvider.GetRequiredService().GetLogger(databaseResource.Parent.Parent); + logger.LogDebug("Creating database '{DatabaseName}'", databaseResource.DatabaseName); + + try + { + var response = await surrealClient.RawQuery( + scriptAnnotation?.Script ?? $"DEFINE DATABASE IF NOT EXISTS `{databaseResource.DatabaseName}`;", + cancellationToken: cancellationToken + ).ConfigureAwait(false); + + response.EnsureAllOks(); + + logger.LogDebug("Database '{DatabaseName}' created successfully", databaseResource.DatabaseName); + } + catch (Exception e) + { + logger.LogError(e, "Failed to create database '{DatabaseName}'", databaseResource.DatabaseName); + } + } } \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbCreateDatabaseScriptAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbCreateDatabaseScriptAnnotation.cs new file mode 100644 index 000000000..eda181f14 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbCreateDatabaseScriptAnnotation.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Represents an annotation for defining a script to create a database in SurrealDB. +/// +internal sealed class SurrealDbCreateDatabaseScriptAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// The script used to create the database. + public SurrealDbCreateDatabaseScriptAnnotation(string script) + { + ArgumentNullException.ThrowIfNull(script); + Script = script; + } + + /// + /// Gets the script used to create the database. + /// + public string Script { get; } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbCreateNamespaceScriptAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbCreateNamespaceScriptAnnotation.cs new file mode 100644 index 000000000..c01c58c15 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbCreateNamespaceScriptAnnotation.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Represents an annotation for defining a script to create a namespace in SurrealDB. +/// +internal sealed class SurrealDbCreateNamespaceScriptAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// The script used to create the namespace. + public SurrealDbCreateNamespaceScriptAnnotation(string script) + { + ArgumentNullException.ThrowIfNull(script); + Script = script; + } + + /// + /// Gets the script used to create the namespace. + /// + public string Script { get; } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbFunctionalTests.cs index 43574db13..db7961eeb 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbFunctionalTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbFunctionalTests.cs @@ -334,6 +334,55 @@ await pipeline.ExecuteAsync(async token => } } } + + [Fact] + public async Task AddDatabaseCreatesDatabaseWithCustomScript() + { + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var surrealNsName = "ns1"; + var surrealDbName = "db1"; + + var surreal = builder.AddSurrealServer("surreal", strictMode: true); + + var db = surreal + .AddNamespace(surrealNsName) + .AddDatabase(surrealDbName) + .WithCreationScript( + $""" + DEFINE DATABASE IF NOT EXISTS {surrealDbName}; + USE DATABASE {surrealDbName}; + + DEFINE TABLE todo; + DEFINE FIELD Title ON todo TYPE string; + DEFINE FIELD DueBy ON todo TYPE datetime; + DEFINE FIELD IsComplete ON todo TYPE bool; + """ + ); + + using var app = builder.Build(); + + await app.StartAsync(cts.Token); + + var hb = Host.CreateApplicationBuilder(); + + hb.Configuration[$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(default); + + hb.AddSurrealClient(db.Resource.Name); + + using var host = hb.Build(); + + await host.StartAsync(); + + await app.ResourceNotifications.WaitForResourceHealthyAsync(db.Resource.Name, cts.Token); + + await using var client = host.Services.GetRequiredService(); + + await CreateTestData(client, cts.Token); + await AssertTestData(client, cts.Token); + } private static async Task CreateTestData(SurrealDbClient surrealDbClient, CancellationToken ct) {