Skip to content

Commit ff37de2

Browse files
committed
feat(surrealdb): ensures ns/db are created in strict mode
1 parent 8410e56 commit ff37de2

File tree

5 files changed

+278
-1
lines changed

5 files changed

+278
-1
lines changed

examples/surrealdb/CommunityToolkit.Aspire.Hosting.SurrealDb.AppHost/Program.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,34 @@
22

33
var builder = DistributedApplication.CreateBuilder(args);
44

5-
var db = builder.AddSurrealServer("surreal")
5+
bool strictMode = true;
6+
7+
var db = builder.AddSurrealServer("surreal", strictMode: strictMode)
68
.WithSurrealist()
79
.AddNamespace("ns")
810
.AddDatabase("db");
911

12+
if (strictMode)
13+
{
14+
db.WithCreationScript(
15+
$"""
16+
DEFINE DATABASE IF NOT EXISTS {nameof(db)};
17+
USE DATABASE {nameof(db)};
18+
19+
DEFINE TABLE todo;
20+
DEFINE FIELD title ON todo TYPE string;
21+
DEFINE FIELD dueBy ON todo TYPE datetime;
22+
DEFINE FIELD isComplete ON todo TYPE bool;
23+
24+
DEFINE TABLE weatherForecast;
25+
DEFINE FIELD date ON weatherForecast TYPE datetime;
26+
DEFINE FIELD country ON weatherForecast TYPE string;
27+
DEFINE FIELD temperatureC ON weatherForecast TYPE number;
28+
DEFINE FIELD summary ON weatherForecast TYPE string;
29+
"""
30+
);
31+
}
32+
1033
builder.AddProject<CommunityToolkit_Aspire_Hosting_SurrealDb_ApiService>("apiservice")
1134
.WithReference(db)
1235
.WaitFor(db);

src/CommunityToolkit.Aspire.Hosting.SurrealDb/SurrealDbBuilderExtensions.cs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
using CommunityToolkit.Aspire.SurrealDb;
66
using Microsoft.Extensions.DependencyInjection;
77
using Microsoft.Extensions.Diagnostics.HealthChecks;
8+
using Microsoft.Extensions.Logging;
89
using SurrealDb.Net;
10+
using SurrealDb.Net.Models.Response;
911
using System.Text;
1012
using System.Text.Json;
1113

@@ -80,6 +82,59 @@ public static IResourceBuilder<SurrealDbServerResource> AddSurrealServer(
8082
: SurrealDbContainerImageTags.Tag;
8183

8284
var surrealServer = new SurrealDbServerResource(name, userName?.Resource, passwordParameter);
85+
86+
string? connectionString = null;
87+
88+
builder.Eventing.Subscribe<ConnectionStringAvailableEvent>(surrealServer, async (_, ct) =>
89+
{
90+
connectionString = await surrealServer.GetConnectionStringAsync(ct).ConfigureAwait(false);
91+
92+
if (connectionString == null)
93+
{
94+
throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{surrealServer.Name}' resource but the connection string was null.");
95+
}
96+
});
97+
98+
builder.Eventing.Subscribe<ResourceReadyEvent>(surrealServer, async (@event, ct) =>
99+
{
100+
if (connectionString is null)
101+
{
102+
throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{surrealServer.Name}' resource but the connection string was null.");
103+
}
104+
105+
if (!strictMode)
106+
{
107+
return;
108+
}
109+
110+
var options = new SurrealDbOptionsBuilder().FromConnectionString(connectionString).Build();
111+
await using var surrealClient = new SurrealDbClient(options);
112+
113+
foreach (var nsResourceName in surrealServer.Namespaces.Keys)
114+
{
115+
if (builder.Resources.FirstOrDefault(n =>
116+
string.Equals(n.Name, nsResourceName, StringComparison.OrdinalIgnoreCase)) is
117+
SurrealDbNamespaceResource surrealDbNamespace)
118+
{
119+
await CreateNamespaceAsync(surrealClient, surrealDbNamespace, @event.Services, ct)
120+
.ConfigureAwait(false);
121+
122+
await surrealClient.Use(surrealDbNamespace.NamespaceName, null!, ct).ConfigureAwait(false);
123+
124+
foreach (var dbResourceName in surrealDbNamespace.Databases.Keys)
125+
{
126+
if (builder.Resources.FirstOrDefault(n =>
127+
string.Equals(n.Name, dbResourceName, StringComparison.OrdinalIgnoreCase)) is
128+
SurrealDbDatabaseResource surrealDbDatabase)
129+
{
130+
await CreateDatabaseAsync(surrealClient, surrealDbDatabase, @event.Services, ct)
131+
.ConfigureAwait(false);
132+
}
133+
}
134+
}
135+
}
136+
});
137+
83138
return builder.AddResource(surrealServer)
84139
.WithEndpoint(port: port, targetPort: SurrealDbPort, name: SurrealDbServerResource.PrimaryEndpointName)
85140
.WithImage(SurrealDbContainerImageTags.Image, imageTag)
@@ -132,6 +187,25 @@ public static IResourceBuilder<SurrealDbNamespaceResource> AddNamespace(
132187
var surrealServerNamespace = new SurrealDbNamespaceResource(name, namespaceName, builder.Resource);
133188
return builder.ApplicationBuilder.AddResource(surrealServerNamespace);
134189
}
190+
191+
/// <summary>
192+
/// Defines the SQL script used to create the namespace.
193+
/// </summary>
194+
/// <param name="builder">The builder for the <see cref="SurrealDbNamespaceResource"/>.</param>
195+
/// <param name="script">The SQL script used to create the namespace.</param>
196+
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
197+
/// <remarks>
198+
/// <value>Default script is <code>DEFINE NAMESPACE IF NOT EXISTS `QUOTED_NAMESPACE_NAME`;</code></value>
199+
/// </remarks>
200+
public static IResourceBuilder<SurrealDbNamespaceResource> WithCreationScript(this IResourceBuilder<SurrealDbNamespaceResource> builder, string script)
201+
{
202+
ArgumentNullException.ThrowIfNull(builder);
203+
ArgumentNullException.ThrowIfNull(script);
204+
205+
builder.WithAnnotation(new SurrealDbCreateNamespaceScriptAnnotation(script));
206+
207+
return builder;
208+
}
135209

136210
/// <summary>
137211
/// Adds a SurrealDB database to the application model. This is a child resource of a <see cref="SurrealDbNamespaceResource"/>.
@@ -202,6 +276,25 @@ public static IResourceBuilder<SurrealDbDatabaseResource> AddDatabase(
202276
return builder.ApplicationBuilder.AddResource(surrealServerDatabase)
203277
.WithHealthCheck(healthCheckKey);
204278
}
279+
280+
/// <summary>
281+
/// Defines the SQL script used to create the database.
282+
/// </summary>
283+
/// <param name="builder">The builder for the <see cref="SurrealDbDatabaseResource"/>.</param>
284+
/// <param name="script">The SQL script used to create the database.</param>
285+
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
286+
/// <remarks>
287+
/// <value>Default script is <code>DEFINE DATABASE IF NOT EXISTS `QUOTED_DATABASE_NAME`;</code></value>
288+
/// </remarks>
289+
public static IResourceBuilder<SurrealDbDatabaseResource> WithCreationScript(this IResourceBuilder<SurrealDbDatabaseResource> builder, string script)
290+
{
291+
ArgumentNullException.ThrowIfNull(builder);
292+
ArgumentNullException.ThrowIfNull(script);
293+
294+
builder.WithAnnotation(new SurrealDbCreateDatabaseScriptAnnotation(script));
295+
296+
return builder;
297+
}
205298

206299
/// <summary>
207300
/// Adds a named volume for the data folder to a SurrealDB resource.
@@ -447,4 +540,62 @@ CancellationToken cancellationToken
447540

448541
return Encoding.UTF8.GetString(stream.ToArray());
449542
}
543+
544+
private static async Task CreateNamespaceAsync(
545+
SurrealDbClient surrealClient,
546+
SurrealDbNamespaceResource namespaceResource,
547+
IServiceProvider serviceProvider,
548+
CancellationToken cancellationToken
549+
)
550+
{
551+
var scriptAnnotation = namespaceResource.Annotations.OfType<SurrealDbCreateNamespaceScriptAnnotation>().LastOrDefault();
552+
553+
var logger = serviceProvider.GetRequiredService<ResourceLoggerService>().GetLogger(namespaceResource.Parent);
554+
logger.LogDebug("Creating namespace '{NamespaceName}'", namespaceResource.NamespaceName);
555+
556+
try
557+
{
558+
var response = await surrealClient.RawQuery(
559+
scriptAnnotation?.Script ?? $"DEFINE NAMESPACE IF NOT EXISTS `{namespaceResource.NamespaceName}`;",
560+
cancellationToken: cancellationToken
561+
).ConfigureAwait(false);
562+
563+
response.EnsureAllOks();
564+
565+
logger.LogDebug("Namespace '{NamespaceName}' created successfully", namespaceResource.NamespaceName);
566+
}
567+
catch (Exception e)
568+
{
569+
logger.LogError(e, "Failed to create namespace '{NamespaceName}'", namespaceResource.NamespaceName);
570+
}
571+
}
572+
573+
private static async Task CreateDatabaseAsync(
574+
SurrealDbClient surrealClient,
575+
SurrealDbDatabaseResource databaseResource,
576+
IServiceProvider serviceProvider,
577+
CancellationToken cancellationToken
578+
)
579+
{
580+
var scriptAnnotation = databaseResource.Annotations.OfType<SurrealDbCreateDatabaseScriptAnnotation>().LastOrDefault();
581+
582+
var logger = serviceProvider.GetRequiredService<ResourceLoggerService>().GetLogger(databaseResource.Parent.Parent);
583+
logger.LogDebug("Creating database '{DatabaseName}'", databaseResource.DatabaseName);
584+
585+
try
586+
{
587+
var response = await surrealClient.RawQuery(
588+
scriptAnnotation?.Script ?? $"DEFINE DATABASE IF NOT EXISTS `{databaseResource.DatabaseName}`;",
589+
cancellationToken: cancellationToken
590+
).ConfigureAwait(false);
591+
592+
response.EnsureAllOks();
593+
594+
logger.LogDebug("Database '{DatabaseName}' created successfully", databaseResource.DatabaseName);
595+
}
596+
catch (Exception e)
597+
{
598+
logger.LogError(e, "Failed to create database '{DatabaseName}'", databaseResource.DatabaseName);
599+
}
600+
}
450601
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.ApplicationModel;
5+
6+
namespace Aspire.Hosting;
7+
8+
/// <summary>
9+
/// Represents an annotation for defining a script to create a database in SurrealDB.
10+
/// </summary>
11+
internal sealed class SurrealDbCreateDatabaseScriptAnnotation : IResourceAnnotation
12+
{
13+
/// <summary>
14+
/// Initializes a new instance of the <see cref="SurrealDbCreateDatabaseScriptAnnotation"/> class.
15+
/// </summary>
16+
/// <param name="script">The script used to create the database.</param>
17+
public SurrealDbCreateDatabaseScriptAnnotation(string script)
18+
{
19+
ArgumentNullException.ThrowIfNull(script);
20+
Script = script;
21+
}
22+
23+
/// <summary>
24+
/// Gets the script used to create the database.
25+
/// </summary>
26+
public string Script { get; }
27+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.ApplicationModel;
5+
6+
namespace Aspire.Hosting;
7+
8+
/// <summary>
9+
/// Represents an annotation for defining a script to create a namespace in SurrealDB.
10+
/// </summary>
11+
internal sealed class SurrealDbCreateNamespaceScriptAnnotation : IResourceAnnotation
12+
{
13+
/// <summary>
14+
/// Initializes a new instance of the <see cref="SurrealDbCreateNamespaceScriptAnnotation"/> class.
15+
/// </summary>
16+
/// <param name="script">The script used to create the namespace.</param>
17+
public SurrealDbCreateNamespaceScriptAnnotation(string script)
18+
{
19+
ArgumentNullException.ThrowIfNull(script);
20+
Script = script;
21+
}
22+
23+
/// <summary>
24+
/// Gets the script used to create the namespace.
25+
/// </summary>
26+
public string Script { get; }
27+
}

tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbFunctionalTests.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,55 @@ await pipeline.ExecuteAsync(async token =>
334334
}
335335
}
336336
}
337+
338+
[Fact]
339+
public async Task AddDatabaseCreatesDatabaseWithCustomScript()
340+
{
341+
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
342+
343+
using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
344+
345+
var surrealNsName = "ns1";
346+
var surrealDbName = "db1";
347+
348+
var surreal = builder.AddSurrealServer("surreal", strictMode: true);
349+
350+
var db = surreal
351+
.AddNamespace(surrealNsName)
352+
.AddDatabase(surrealDbName)
353+
.WithCreationScript(
354+
$"""
355+
DEFINE DATABASE IF NOT EXISTS {surrealDbName};
356+
USE DATABASE {surrealDbName};
357+
358+
DEFINE TABLE todo;
359+
DEFINE FIELD Title ON todo TYPE string;
360+
DEFINE FIELD DueBy ON todo TYPE datetime;
361+
DEFINE FIELD IsComplete ON todo TYPE bool;
362+
"""
363+
);
364+
365+
using var app = builder.Build();
366+
367+
await app.StartAsync(cts.Token);
368+
369+
var hb = Host.CreateApplicationBuilder();
370+
371+
hb.Configuration[$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(default);
372+
373+
hb.AddSurrealClient(db.Resource.Name);
374+
375+
using var host = hb.Build();
376+
377+
await host.StartAsync();
378+
379+
await app.ResourceNotifications.WaitForResourceHealthyAsync(db.Resource.Name, cts.Token);
380+
381+
await using var client = host.Services.GetRequiredService<SurrealDbClient>();
382+
383+
await CreateTestData(client, cts.Token);
384+
await AssertTestData(client, cts.Token);
385+
}
337386

338387
private static async Task CreateTestData(SurrealDbClient surrealDbClient, CancellationToken ct)
339388
{

0 commit comments

Comments
 (0)