Skip to content

Commit 95d965a

Browse files
committed
feat: add DatabaseMaintenanceService (clear all rows, keep schema)
1 parent ef4e274 commit 95d965a

4 files changed

Lines changed: 112 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System.Globalization;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace RustPlusBot.Persistence.Maintenance;
5+
6+
/// <summary>Clears every table's rows while keeping the schema (a live-safe "factory reset").</summary>
7+
/// <param name="context">The bot database context.</param>
8+
public sealed class DatabaseMaintenanceService(BotDbContext context) : IDatabaseMaintenanceService
9+
{
10+
/// <inheritdoc />
11+
public async Task ClearAllAsync(CancellationToken cancellationToken = default)
12+
{
13+
var tables = context.Model.GetEntityTypes()
14+
.Select(t => t.GetTableName())
15+
.Where(name => !string.IsNullOrEmpty(name))
16+
.Distinct(StringComparer.Ordinal)
17+
.ToList();
18+
19+
// Keep one connection open across every statement so the FK pragma persists
20+
// (with per-statement connections the pragma would reset before the DELETEs).
21+
await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
22+
try
23+
{
24+
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF", cancellationToken)
25+
.ConfigureAwait(false);
26+
27+
foreach (var table in tables)
28+
{
29+
// Table names come from the EF model (never user input); the identifier guard keeps
30+
// the raw statement demonstrably injection-safe for the Sonar gate.
31+
if (!IsSafeIdentifier(table!))
32+
{
33+
continue;
34+
}
35+
36+
var sql = string.Create(CultureInfo.InvariantCulture, $"DELETE FROM \"{table}\"");
37+
await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
38+
}
39+
40+
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON", cancellationToken)
41+
.ConfigureAwait(false);
42+
}
43+
finally
44+
{
45+
await context.Database.CloseConnectionAsync().ConfigureAwait(false);
46+
}
47+
}
48+
49+
private static bool IsSafeIdentifier(string identifier) =>
50+
identifier.All(c => char.IsLetterOrDigit(c) || c == '_');
51+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace RustPlusBot.Persistence.Maintenance;
2+
3+
/// <summary>Database-wide maintenance operations.</summary>
4+
public interface IDatabaseMaintenanceService
5+
{
6+
/// <summary>Deletes every row in every table across all guilds, preserving the schema.</summary>
7+
/// <param name="cancellationToken">A cancellation token.</param>
8+
/// <returns>A task that completes when all rows have been deleted.</returns>
9+
Task ClearAllAsync(CancellationToken cancellationToken = default);
10+
}

src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using RustPlusBot.Persistence.Commands;
66
using RustPlusBot.Persistence.Connections;
77
using RustPlusBot.Persistence.Credentials;
8+
using RustPlusBot.Persistence.Maintenance;
89
using RustPlusBot.Persistence.Map;
910
using RustPlusBot.Persistence.Servers;
1011
using RustPlusBot.Persistence.StorageMonitors;
@@ -34,6 +35,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi
3435
sp.GetRequiredService<IDbContextFactory<BotDbContext>>().CreateDbContext());
3536

3637
services.AddScoped<IServerService, ServerService>();
38+
services.AddScoped<IDatabaseMaintenanceService, DatabaseMaintenanceService>();
3739
services.AddScoped<ICredentialStore, CredentialStore>();
3840
services.AddScoped<IFcmRegistrationStore, FcmRegistrationStore>();
3941
services.AddScoped<IWorkspaceStore, WorkspaceStore>();
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using RustPlusBot.Domain.Guilds;
3+
using RustPlusBot.Domain.Servers;
4+
using RustPlusBot.Persistence.Maintenance;
5+
6+
namespace RustPlusBot.Persistence.Tests.Maintenance;
7+
8+
public sealed class DatabaseMaintenanceServiceTests
9+
{
10+
[Fact]
11+
public async Task ClearAllAsync_EmptiesEveryTable_AndKeepsSchema()
12+
{
13+
var (context, connection) = SqliteContextFixture.Create();
14+
await using var _ = context;
15+
await using var __ = connection;
16+
17+
context.RustServers.Add(new RustServer
18+
{
19+
GuildId = 1, Name = "A", Ip = "a", Port = 1
20+
});
21+
context.RustServers.Add(new RustServer
22+
{
23+
GuildId = 2, Name = "B", Ip = "b", Port = 2
24+
});
25+
context.GuildSettings.Add(new GuildSettings
26+
{
27+
GuildId = 1, Culture = "en"
28+
});
29+
context.GuildSettings.Add(new GuildSettings
30+
{
31+
GuildId = 2, Culture = "fr"
32+
});
33+
await context.SaveChangesAsync();
34+
35+
var service = new DatabaseMaintenanceService(context);
36+
await service.ClearAllAsync();
37+
38+
Assert.Empty(await context.RustServers.ToListAsync());
39+
Assert.Empty(await context.GuildSettings.ToListAsync());
40+
41+
// Schema still exists: a fresh insert succeeds.
42+
context.GuildSettings.Add(new GuildSettings
43+
{
44+
GuildId = 3, Culture = "en"
45+
});
46+
await context.SaveChangesAsync();
47+
Assert.Single(await context.GuildSettings.ToListAsync());
48+
}
49+
}

0 commit comments

Comments
 (0)