Skip to content

Commit 5260f81

Browse files
committed
feat(surrealdb): add WithInitFiles method
1 parent fb773a1 commit 5260f81

File tree

3 files changed

+227
-38
lines changed

3 files changed

+227
-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: 127 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,99 @@ public async Task VerifyWaitForOnSurrealDbBlocksDependentResources()
244247

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

248344
private static async Task CreateTestData(SurrealDbClient surrealDbClient, CancellationToken ct)
249345
{
@@ -252,10 +348,10 @@ private static async Task CreateTestData(SurrealDbClient surrealDbClient, Cancel
252348

253349
private static async Task AssertTestData(SurrealDbClient surrealDbClient, CancellationToken ct)
254350
{
255-
var records = await surrealDbClient.Select<Todo>(Todo.Table);
351+
var records = await surrealDbClient.Select<Todo>(Todo.Table, ct);
256352
Assert.Equal(_generatedTodoCount, records.Count());
257353

258-
var firstRecord = await surrealDbClient.Select<Todo>((Todo.Table, "1"));
354+
var firstRecord = await surrealDbClient.Select<Todo>((Todo.Table, "1"), ct);
259355
Assert.NotNull(firstRecord);
260356
Assert.Equivalent(firstRecord, _todoList[0]);
261357
}
@@ -278,4 +374,11 @@ public TodoFaker()
278374
RuleFor(o => o.IsComplete, f => f.Random.Bool());
279375
}
280376
}
377+
378+
private sealed class Car : SurrealRecord
379+
{
380+
internal const string Table = "car";
381+
382+
public string Brand { get; set; } = string.Empty;
383+
}
281384
}

0 commit comments

Comments
 (0)