Skip to content

Commit 076cf0a

Browse files
authored
feat(surrealdb): add WithInitFiles method (#778)
1 parent 78465ee commit 076cf0a

File tree

3 files changed

+221
-38
lines changed

3 files changed

+221
-38
lines changed

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public static class SurrealDbBuilderExtensions
1919
private const int SurrealDbPort = 8000;
2020
private const string UserEnvVarName = "SURREAL_USER";
2121
private const string PasswordEnvVarName = "SURREAL_PASS";
22+
private const string ImportFileEnvVarName = "SURREAL_IMPORT_FILE";
2223

2324
/// <summary>
2425
/// Adds a SurrealDB resource to the application model. A container is used for local development.
@@ -266,6 +267,37 @@ public static IResourceBuilder<SurrealDbServerResource> WithDataBindMount(this I
266267

267268
return builder.WithBindMount(source, "/data");
268269
}
270+
271+
/// <summary>
272+
/// Copies init files into a SurrealDB container resource.
273+
/// </summary>
274+
/// <param name="builder">The resource builder.</param>
275+
/// <param name="source">The source file on the host to copy into the container.</param>
276+
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
277+
/// <exception cref="DistributedApplicationException">SurrealDB only support importing a single script file.</exception>
278+
public static IResourceBuilder<SurrealDbServerResource> WithInitFiles(this IResourceBuilder<SurrealDbServerResource> builder, string source)
279+
{
280+
ArgumentNullException.ThrowIfNull(builder);
281+
ArgumentException.ThrowIfNullOrEmpty(source);
282+
283+
const string initPath = "/docker-entrypoint-initdb.d";
284+
285+
var importFullPath = Path.GetFullPath(source, builder.ApplicationBuilder.AppHostDirectory);
286+
if (Directory.Exists(importFullPath) || !File.Exists(importFullPath))
287+
{
288+
throw new DistributedApplicationException($"Unable to determine the file name for '{source}'.");
289+
}
290+
291+
string fileName = Path.GetFileName(importFullPath);
292+
string initFilePath = $"{initPath}/{fileName}";
293+
294+
return builder
295+
.WithContainerFiles(initPath, importFullPath)
296+
.WithEnvironment(context =>
297+
{
298+
context.EnvironmentVariables[ImportFileEnvVarName] = initFilePath;
299+
});
300+
}
269301

270302
/// <summary>
271303
/// Adds a Surrealist UI instance for SurrealDB to the application model.

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

Lines changed: 121 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
using Aspire.Hosting;
66
using Aspire.Hosting.Utils;
77
using Bogus;
8+
using CommunityToolkit.Aspire.Testing;
9+
using Microsoft.Extensions.Configuration;
810
using Microsoft.Extensions.Diagnostics.HealthChecks;
911
using Microsoft.Extensions.Hosting;
12+
using Polly;
1013
using SurrealDb.Net;
14+
using SurrealDb.Net.Exceptions;
15+
using System.Data;
1116
using Xunit.Abstractions;
1217
using SurrealRecord = SurrealDb.Net.Models.Record;
1318

@@ -33,7 +38,7 @@ static SurrealDbFunctionalTests()
3338
[Fact]
3439
public async Task VerifySurrealDbResource()
3540
{
36-
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
41+
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
3742
var ct = cts.Token;
3843
using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
3944

@@ -70,7 +75,8 @@ public async Task VerifySurrealDbResource()
7075
[InlineData(false)]
7176
public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume)
7277
{
73-
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
78+
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
79+
var ct = cts.Token;
7480

7581
string? volumeName = null;
7682
string? bindMountPath = null;
@@ -110,30 +116,30 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume)
110116

111117
using (var app = builder1.Build())
112118
{
113-
await app.StartAsync(cts.Token);
119+
await app.StartAsync(ct);
114120

115-
await app.ResourceNotifications.WaitForResourceHealthyAsync(surrealServer1.Resource.Name, cts.Token);
116-
await app.ResourceNotifications.WaitForResourceHealthyAsync(db1.Resource.Name, cts.Token);
121+
await app.ResourceNotifications.WaitForResourceHealthyAsync(surrealServer1.Resource.Name, ct);
122+
await app.ResourceNotifications.WaitForResourceHealthyAsync(db1.Resource.Name, ct);
117123

118124
try
119125
{
120126
var hb = Host.CreateApplicationBuilder();
121127

122-
hb.Configuration[$"ConnectionStrings:{db1.Resource.Name}"] = await db1.Resource.ConnectionStringExpression.GetValueAsync(cts.Token);
128+
hb.Configuration[$"ConnectionStrings:{db1.Resource.Name}"] = await db1.Resource.ConnectionStringExpression.GetValueAsync(ct);
123129

124130
hb.AddSurrealClient(db1.Resource.Name);
125131

126132
using var host = hb.Build();
127-
await host.StartAsync(cts.Token);
133+
await host.StartAsync(ct);
128134

129135
await using var surrealDbClient = host.Services.GetRequiredService<SurrealDbClient>();
130-
await CreateTestData(surrealDbClient, cts.Token);
131-
await AssertTestData(surrealDbClient, cts.Token);
136+
await CreateTestData(surrealDbClient, ct);
137+
await AssertTestData(surrealDbClient, ct);
132138
}
133139
finally
134140
{
135141
// Stops the container, or the Volume would still be in use
136-
await app.StopAsync(cts.Token);
142+
await app.StopAsync(ct);
137143
}
138144
}
139145

@@ -159,28 +165,28 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume)
159165

160166
using (var app = builder2.Build())
161167
{
162-
await app.StartAsync(cts.Token);
168+
await app.StartAsync(ct);
163169

164-
await app.ResourceNotifications.WaitForResourceHealthyAsync(surrealServer2.Resource.Name, cts.Token);
165-
await app.ResourceNotifications.WaitForResourceHealthyAsync(db2.Resource.Name, cts.Token);
170+
await app.ResourceNotifications.WaitForResourceHealthyAsync(surrealServer2.Resource.Name, ct);
171+
await app.ResourceNotifications.WaitForResourceHealthyAsync(db2.Resource.Name, ct);
166172

167173
try
168174
{
169175
var hb = Host.CreateApplicationBuilder();
170176

171-
hb.Configuration[$"ConnectionStrings:{db2.Resource.Name}"] = await db2.Resource.ConnectionStringExpression.GetValueAsync(cts.Token);
177+
hb.Configuration[$"ConnectionStrings:{db2.Resource.Name}"] = await db2.Resource.ConnectionStringExpression.GetValueAsync(ct);
172178

173179
hb.AddSurrealClient(db2.Resource.Name);
174180

175181
using var host = hb.Build();
176-
await host.StartAsync(cts.Token);
182+
await host.StartAsync(ct);
177183
await using var surrealDbClient = host.Services.GetRequiredService<SurrealDbClient>();
178-
await AssertTestData(surrealDbClient, cts.Token);
184+
await AssertTestData(surrealDbClient, ct);
179185
}
180186
finally
181187
{
182188
// Stops the container, or the Volume would still be in use
183-
await app.StopAsync(cts.Token);
189+
await app.StopAsync(ct);
184190
}
185191
}
186192

@@ -209,14 +215,11 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume)
209215
[Fact]
210216
public async Task VerifyWaitForOnSurrealDbBlocksDependentResources()
211217
{
212-
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
218+
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
213219
using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
214220

215221
var healthCheckTcs = new TaskCompletionSource<HealthCheckResult>();
216-
builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () =>
217-
{
218-
return healthCheckTcs.Task;
219-
});
222+
builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => healthCheckTcs.Task);
220223

221224
var resource = builder.AddSurrealServer("resource")
222225
.WithHealthCheck("blocking_check");
@@ -244,6 +247,93 @@ public async Task VerifyWaitForOnSurrealDbBlocksDependentResources()
244247

245248
await app.StopAsync();
246249
}
250+
251+
[Fact]
252+
public async Task VerifyWithInitFiles()
253+
{
254+
// Creates a script that should be executed when the container is initialized.
255+
256+
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
257+
var pipeline = new ResiliencePipelineBuilder()
258+
.AddRetry(new() { MaxRetryAttempts = 10, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2), ShouldHandle = new PredicateBuilder().Handle<SurrealDbException>() })
259+
.Build();
260+
261+
var initDirPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
262+
263+
Directory.CreateDirectory(initDirPath);
264+
265+
var initFilePath = Path.Combine(initDirPath, "init.surql");
266+
267+
var surrealNsName = "ns1";
268+
var surrealDbName = "db1";
269+
270+
try
271+
{
272+
await File.WriteAllTextAsync(
273+
initFilePath,
274+
$"""
275+
USE NS {surrealNsName};
276+
USE DB {surrealDbName};
277+
278+
CREATE car SET Brand = "BatMobile";
279+
""",
280+
cts.Token
281+
);
282+
283+
using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
284+
285+
var surrealServer = builder
286+
.AddSurrealServer("surreal")
287+
.WithInitFiles(initFilePath);
288+
289+
var ns = surrealServer.AddNamespace(surrealNsName);
290+
var db = ns.AddDatabase(surrealDbName);
291+
292+
using var app = builder.Build();
293+
294+
await app.StartAsync(cts.Token);
295+
296+
var rns = app.Services.GetRequiredService<ResourceNotificationService>();
297+
298+
await rns.WaitForResourceAsync(db.Resource.Name, KnownResourceStates.Running, cts.Token);
299+
300+
var hb = Host.CreateApplicationBuilder();
301+
302+
hb.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
303+
{
304+
[$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(cts.Token)
305+
});
306+
307+
hb.AddSurrealClient(db.Resource.Name);
308+
309+
using var host = hb.Build();
310+
311+
await host.StartAsync(cts.Token);
312+
313+
// Wait until the database is available
314+
await rns.WaitForResourceHealthyAsync(db.Resource.Name, cts.Token);
315+
316+
await pipeline.ExecuteAsync(async token =>
317+
{
318+
var client = host.Services.GetRequiredService<SurrealDbClient>();
319+
320+
var cars = (await client.Select<Car>(Car.Table, token)).ToList();
321+
var car = Assert.Single(cars);
322+
Assert.Equal("BatMobile", car.Brand);
323+
}, cts.Token);
324+
}
325+
finally
326+
{
327+
try
328+
{
329+
Directory.Delete(initDirPath);
330+
}
331+
catch
332+
{
333+
// Don't fail test if we can't clean the temporary folder
334+
}
335+
}
336+
}
247337

248338
private static async Task CreateTestData(SurrealDbClient surrealDbClient, CancellationToken ct)
249339
{
@@ -252,10 +342,10 @@ private static async Task CreateTestData(SurrealDbClient surrealDbClient, Cancel
252342

253343
private static async Task AssertTestData(SurrealDbClient surrealDbClient, CancellationToken ct)
254344
{
255-
var records = await surrealDbClient.Select<Todo>(Todo.Table);
345+
var records = await surrealDbClient.Select<Todo>(Todo.Table, ct);
256346
Assert.Equal(_generatedTodoCount, records.Count());
257347

258-
var firstRecord = await surrealDbClient.Select<Todo>((Todo.Table, "1"));
348+
var firstRecord = await surrealDbClient.Select<Todo>((Todo.Table, "1"), ct);
259349
Assert.NotNull(firstRecord);
260350
Assert.Equivalent(firstRecord, _todoList[0]);
261351
}
@@ -278,4 +368,11 @@ public TodoFaker()
278368
RuleFor(o => o.IsComplete, f => f.Random.Bool());
279369
}
280370
}
371+
372+
private sealed class Car : SurrealRecord
373+
{
374+
internal const string Table = "car";
375+
376+
public string Brand { get; set; } = string.Empty;
377+
}
281378
}

0 commit comments

Comments
 (0)