Skip to content

Commit 041a47a

Browse files
committed
Add Database/Schema support to SQL Server and PostgreSQL persisters
Each test now takes its own schema in one shared database rather than its own database, which exercises the same Database/Schema setting offered to customers. Migrations are stamped with the configured schema at generation time via a custom IMigrationsSqlGenerator, raw SQL in the dialects is qualified using the schema-aware table name helper, and the migrators require the schema to exist rather than silently creating it. SchemaName validates the value before it reaches any DDL interpolation.
1 parent 1c2cef6 commit 041a47a

40 files changed

Lines changed: 994 additions & 139 deletions

File tree

‎docs/testing-persistence.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ ServiceControl supports multiple persistence types
88

99
All persistence test projects can be run with `dotnet test` against the corresponding test project in `src/`.
1010

11+
## Test isolation on the SQL persisters
12+
13+
The SQL Server and PostgreSQL suites give each test its own **schema** in one shared database, named `sc_test_<guid>` for persistence tests and `sc_at_<guid>` for acceptance tests, and drop it on teardown. This uses the same `Database/Schema` setting that is offered to customers, so every run exercises that feature.
14+
15+
Two consequences are worth knowing:
16+
17+
* The connection string is used exactly as it is given, so **the database it names must already exist**. The test containers create one; a server you point the environment variable at will not.
18+
* A test failure that leaves a process behind can leave a schema behind with it. `SELECT nspname FROM pg_namespace WHERE nspname LIKE 'sc\_%'` and `SELECT name FROM sys.schemas WHERE name LIKE 'sc[_]%'` will find any strays.
19+
1120
## RavenDB
1221

1322
RavenDB persistence tests start an embedded RavenDB instance for the duration of the test run.
@@ -23,7 +32,7 @@ Build that image locally before running SQL Server persistence tests:
2332
docker buildx build --platform=linux/amd64 --tag particular/servicecontrol-testing-sqlserver:latest ./src/Scripts/Docker/servicecontrol-testing-sqlserver
2433
```
2534

26-
If you want to use an existing SQL Server instance instead of a test container, set the `ServiceControl_Persistence_SqlServer_ConnectionString` environment variable to a valid SQL Server connection string.
35+
If you want to use an existing SQL Server instance instead of a test container, set the `ServiceControl_Persistence_SqlServer_ConnectionString` environment variable to a valid SQL Server connection string. It must name a database that exists, not `master`, because the tests create their schemas in whatever database it points at. The test container creates a `ServiceControlTests` database for this.
2736

2837
## PostgreSQL
2938

@@ -35,4 +44,4 @@ If you want to use an existing PostgreSQL instance instead of a test container,
3544
ServiceControl_Persistence_PostgreSql_ConnectionString
3645
```
3746

38-
to a valid PostgreSQL connection string.
47+
to a valid PostgreSQL connection string. It must name a database that exists, because the tests create their schemas in whatever database it points at. The test container creates a `servicecontroltests` database for this, so that test schemas do not end up in the `postgres` maintenance database.

‎src/ServiceControl.AcceptanceTests.PostgreSql/AcceptanceTestStorageConfiguration.cs‎

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ namespace ServiceControl.AcceptanceTests.PostgreSql;
44
using System.IO;
55
using System.Threading;
66
using System.Threading.Tasks;
7-
using Npgsql;
87
using ServiceBus.Management.Infrastructure.Settings;
98
using ServiceControl.AcceptanceTests.TestSupport;
109
using ServiceControl.Persistence.EFCore.Abstractions;
@@ -17,19 +16,16 @@ public class AcceptanceTestStorageConfiguration : IAcceptanceTestStorageConfigur
1716

1817
public async Task CustomizeSettings(Settings settings, CancellationToken cancellationToken = default)
1918
{
20-
databaseName = $"sc_at_{Guid.NewGuid():n}";
21-
serverConnectionString = await PostgreSqlSharedContainer.GetConnectionStringAsync(cancellationToken).ConfigureAwait(false);
22-
23-
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(serverConnectionString)
24-
{
25-
Database = databaseName
26-
};
19+
schema = $"sc_at_{Guid.NewGuid():n}";
20+
connectionString = await PostgreSqlSharedContainer.GetConnectionStringAsync(cancellationToken).ConfigureAwait(false);
21+
await TestSchema.Create(connectionString, schema, cancellationToken).ConfigureAwait(false);
2722

2823
bodyStoragePath = Directory.CreateTempSubdirectory("sc_at_bodies_").FullName;
2924

3025
settings.PersisterSpecificSettings = new PostgreSqlPersisterSettings
3126
{
32-
ConnectionString = connectionStringBuilder.ConnectionString,
27+
ConnectionString = connectionString,
28+
Schema = schema,
3329
ErrorRetentionPeriod = TimeSpan.FromDays(10),
3430
BodyStorage = new FileSystemBodyStorageSettings { StoragePath = bodyStoragePath }
3531
};
@@ -44,22 +40,12 @@ public async Task Cleanup(CancellationToken cancellationToken = default)
4440

4541
try
4642
{
47-
if (serverConnectionString == null || databaseName == null)
43+
if (connectionString == null || schema == null)
4844
{
4945
return;
5046
}
5147

52-
var connection = new NpgsqlConnection(serverConnectionString);
53-
await using (connection.ConfigureAwait(false))
54-
{
55-
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
56-
var command = connection.CreateCommand();
57-
await using (command.ConfigureAwait(false))
58-
{
59-
command.CommandText = $"DROP DATABASE IF EXISTS \"{databaseName}\" WITH (FORCE)";
60-
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
61-
}
62-
}
48+
await TestSchema.Drop(connectionString, schema, cancellationToken).ConfigureAwait(false);
6349
}
6450
finally
6551
{
@@ -91,8 +77,8 @@ public void Dispose()
9177
}
9278
}
9379

94-
string serverConnectionString;
95-
string databaseName;
80+
string connectionString;
81+
string schema;
9682
string bodyStoragePath;
9783
int cleanupStarted;
9884
}

‎src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
<ItemGroup>
3333
<Compile Include="..\ServiceControl.AcceptanceTests\**\*.cs" LinkBase="Shared" />
3434
<Compile Include="..\ServiceControl.Persistence.Tests.PostgreSql\PostgreSqlSharedContainer.cs" />
35+
<Compile Include="..\ServiceControl.Persistence.Tests.PostgreSql\TestSchema.cs" />
3536
<Compile Include="..\ServiceControl.Persistence.Tests.PostgreSql\StopSharedPostgreSql.cs" />
3637
<Compile Include="..\ServiceControl.UnitTests\NUnitParallelRunnerSettings.cs" />
3738

‎src/ServiceControl.AcceptanceTests.SqlServer/AcceptanceTestStorageConfiguration.cs‎

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ namespace ServiceControl.AcceptanceTests.SqlServer;
44
using System.IO;
55
using System.Threading;
66
using System.Threading.Tasks;
7-
using Microsoft.Data.SqlClient;
87
using ServiceBus.Management.Infrastructure.Settings;
98
using ServiceControl.AcceptanceTests.TestSupport;
109
using ServiceControl.Persistence.EFCore.Abstractions;
@@ -17,19 +16,16 @@ public class AcceptanceTestStorageConfiguration : IAcceptanceTestStorageConfigur
1716

1817
public async Task CustomizeSettings(Settings settings, CancellationToken cancellationToken = default)
1918
{
20-
databaseName = $"sc_at_{Guid.NewGuid():n}";
21-
serverConnectionString = await SqlServerSharedContainer.GetConnectionStringAsync(cancellationToken).ConfigureAwait(false);
22-
23-
var connectionStringBuilder = new SqlConnectionStringBuilder(serverConnectionString)
24-
{
25-
InitialCatalog = databaseName
26-
};
19+
schema = $"sc_at_{Guid.NewGuid():n}";
20+
connectionString = await SqlServerSharedContainer.GetConnectionStringAsync(cancellationToken).ConfigureAwait(false);
21+
await TestSchema.Create(connectionString, schema, cancellationToken).ConfigureAwait(false);
2722

2823
bodyStoragePath = Directory.CreateTempSubdirectory("sc_at_bodies_").FullName;
2924

3025
settings.PersisterSpecificSettings = new SqlServerPersisterSettings
3126
{
32-
ConnectionString = connectionStringBuilder.ConnectionString,
27+
ConnectionString = connectionString,
28+
Schema = schema,
3329
ErrorRetentionPeriod = TimeSpan.FromDays(10),
3430
BodyStorage = new FileSystemBodyStorageSettings { StoragePath = bodyStoragePath }
3531
};
@@ -44,28 +40,12 @@ public async Task Cleanup(CancellationToken cancellationToken = default)
4440

4541
try
4642
{
47-
if (serverConnectionString == null || databaseName == null)
43+
if (connectionString == null || schema == null)
4844
{
4945
return;
5046
}
5147

52-
var connection = new SqlConnection(serverConnectionString);
53-
await using (connection.ConfigureAwait(false))
54-
{
55-
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
56-
var command = connection.CreateCommand();
57-
await using (command.ConfigureAwait(false))
58-
{
59-
command.CommandText = $"""
60-
IF DB_ID('{databaseName}') IS NOT NULL
61-
BEGIN
62-
ALTER DATABASE [{databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
63-
DROP DATABASE [{databaseName}];
64-
END
65-
""";
66-
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
67-
}
68-
}
48+
await TestSchema.Drop(connectionString, schema, cancellationToken).ConfigureAwait(false);
6949
}
7050
finally
7151
{
@@ -97,8 +77,8 @@ public void Dispose()
9777
}
9878
}
9979

100-
string serverConnectionString;
101-
string databaseName;
80+
string connectionString;
81+
string schema;
10282
string bodyStoragePath;
10383
int cleanupStarted;
10484
}

‎src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
<ItemGroup>
3333
<Compile Include="..\ServiceControl.AcceptanceTests\**\*.cs" LinkBase="Shared" />
3434
<Compile Include="..\ServiceControl.Persistence.Tests.SqlServer\SqlServerSharedContainer.cs" />
35+
<Compile Include="..\ServiceControl.Persistence.Tests.SqlServer\TestSchema.cs" />
3536
<Compile Include="..\ServiceControl.Persistence.Tests.SqlServer\StopSharedSqlServer.cs" />
3637
<Compile Include="..\ServiceControl.UnitTests\NUnitParallelRunnerSettings.cs" />
3738

‎src/ServiceControl.Persistence.EFCore.PostgreSql/FullTextSearchSql.cs‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
namespace ServiceControl.Persistence.EFCore.PostgreSql;
22

3+
using Microsoft.EntityFrameworkCore.Migrations.Operations;
4+
35
/// <summary>
46
/// Full text search DDL for the failed messages table. EF Core cannot model a GIN index over an
57
/// expression, so it is applied by the AddFullTextSearch migration. The statements live here, and
@@ -9,6 +11,7 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql;
911
static class FullTextSearchSql
1012
{
1113
const string IndexName = "ix_failed_messages_full_text";
14+
const string TableName = "failed_messages";
1215

1316
// 'simple' rather than 'english': message and header content is technical, stemming and
1417
// stopword removal do more harm than good.
@@ -26,7 +29,34 @@ static class FullTextSearchSql
2629
public const string IndexedExpression =
2730
$"""to_tsvector('{Configuration}', headers_json || ' ' || COALESCE(body_text, '') || ' ' || replace(replace(COALESCE(message_type, ''), '.', ' '), '+', ' '))""";
2831

29-
public const string Up = $"CREATE INDEX {IndexName} ON failed_messages USING GIN ({IndexedExpression})";
32+
public static readonly string Up = CreateIndexSql(null);
33+
34+
public static readonly string Down = DropIndexSql(null);
35+
36+
/// <summary>
37+
/// Re-renders the statement the migration carries, this time with the configured schema in it.
38+
/// Anything else is left alone: EF Core builds the migrations history table's own SQL through
39+
/// the same generator, already pointed at the right schema. MigrationSqlIsSchemaAwareTests is
40+
/// what catches a statement of ours that should have been listed here.
41+
/// </summary>
42+
public static MigrationOperation Rewrite(SqlOperation operation, string schema) =>
43+
operation.Sql switch
44+
{
45+
var sql when sql == Up => WithSql(operation, CreateIndexSql(schema)),
46+
var sql when sql == Down => WithSql(operation, DropIndexSql(schema)),
47+
_ => operation
48+
};
49+
50+
public static bool IsHandled(string sql) => sql == Up || sql == Down;
51+
52+
static string CreateIndexSql(string? schema) =>
53+
$"CREATE INDEX {IndexName} ON {Qualify(schema, TableName)} USING GIN ({IndexedExpression})";
54+
55+
// An index belongs to its table's schema, so it is the index that gets qualified here.
56+
static string DropIndexSql(string? schema) => $"DROP INDEX IF EXISTS {Qualify(schema, IndexName)}";
57+
58+
static string Qualify(string? schema, string name) => schema is null ? name : $"\"{schema}\".{name}";
3059

31-
public const string Down = $"DROP INDEX IF EXISTS {IndexName}";
60+
static SqlOperation WithSql(SqlOperation operation, string sql) =>
61+
new() { Sql = sql, SuppressTransaction = operation.SuppressTransaction };
3262
}

‎src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseMigrator.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,31 @@ public async Task ApplyMigrations(CancellationToken cancellationToken = default)
1414
var previousTimeout = dbContext.Database.GetCommandTimeout();
1515
dbContext.Database.SetCommandTimeout(EFPersisterSettings.MigrationCommandTimeout);
1616

17+
await RequireSchema(cancellationToken);
1718
await dbContext.Database.MigrateAsync(cancellationToken);
1819

1920
dbContext.Database.SetCommandTimeout(previousTimeout);
2021

2122
logger.LogInformation("PostgreSQL database migration completed");
2223
}
24+
25+
// EF Core would create the schema on its way to creating the migrations history table, which
26+
// would turn a misspelled Database/Schema into a silently empty instance rather than an error.
27+
async Task RequireSchema(CancellationToken cancellationToken)
28+
{
29+
if (dbContext.Schema is null)
30+
{
31+
return;
32+
}
33+
34+
var exists = await dbContext.Database
35+
.SqlQueryRaw<int>("""SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = {0}) THEN 1 ELSE 0 END AS "Value" """, dbContext.Schema)
36+
.SingleAsync(cancellationToken);
37+
38+
if (exists == 0)
39+
{
40+
throw new InvalidOperationException(
41+
$"The configured schema '{dbContext.Schema}' does not exist in the database. ServiceControl does not create schemas, the same way it does not create the database. Create the schema, grant the configured user rights on it, and run setup again.");
42+
}
43+
}
2344
}

‎src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDialect.cs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql;
44
using Microsoft.EntityFrameworkCore;
55
using Microsoft.EntityFrameworkCore.Storage;
66
using ServiceControl.Persistence.EFCore.DbContexts;
7+
using ServiceControl.Persistence.EFCore.Infrastructure;
78

89
abstract class PostgreSqlDialect
910
{
11+
/// <summary>
12+
/// The delimited, schema qualified name of the table an entity is mapped to. Every statement
13+
/// below names its target this way so that a configured schema reaches the raw SQL too.
14+
/// </summary>
15+
protected static string Table<TEntity>(ServiceControlDbContext dbContext) => SchemaQualifiedTableName.For<TEntity>(dbContext);
16+
1017
protected static async Task Execute(ServiceControlDbContext dbContext, string sql, IEnumerable<object?[]> rows, CancellationToken cancellationToken = default)
1118
{
1219
await using var command = dbContext.Database.GetDbConnection().CreateCommand();

0 commit comments

Comments
 (0)