diff --git a/src/Scripts/Create-SqlTestContainer.ps1 b/src/Scripts/Create-SqlTestContainer.ps1 new file mode 100644 index 0000000000..305fe28099 --- /dev/null +++ b/src/Scripts/Create-SqlTestContainer.ps1 @@ -0,0 +1,2 @@ +# Temporarily here to build the container for testing until we settle on whether this will get published +docker buildx build --tag particular/servicecontrol-testing-sqlserver:latest $PSScriptRoot/Docker/servicecontrol-testing-sqlserver --platform=linux/amd64 \ No newline at end of file diff --git a/src/Scripts/Docker/servicecontrol-testing-sqlserver/Dockerfile b/src/Scripts/Docker/servicecontrol-testing-sqlserver/Dockerfile new file mode 100644 index 0000000000..be4c15270c --- /dev/null +++ b/src/Scripts/Docker/servicecontrol-testing-sqlserver/Dockerfile @@ -0,0 +1,21 @@ +FROM ubuntu:22.04 +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -y curl gnupg2 apt-transport-https && \ + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | \ + gpg --dearmor -o /etc/apt/trusted.gpg.d/microsoft.gpg && \ + curl -fsSL https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2025.list | \ + tee /etc/apt/sources.list.d/mssql-server-2025.list && \ + curl -fsSL https://packages.microsoft.com/config/ubuntu/22.04/prod.list | \ + tee /etc/apt/sources.list.d/mssql-release.list && \ + apt-get update && \ + ACCEPT_EULA=Y apt-get install -y mssql-server mssql-server-fts && \ + ACCEPT_EULA=Y apt-get install -y mssql-tools18 unixodbc-dev && \ + # Clean up apt cache + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +EXPOSE 1433 + +ENTRYPOINT [ "/opt/mssql/bin/sqlservr" ] \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/ExternalIntegrationRequestsDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/ExternalIntegrationRequestsDataStore.cs index dfa5094631..dcbb12b415 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/ExternalIntegrationRequestsDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/ExternalIntegrationRequestsDataStore.cs @@ -5,15 +5,11 @@ namespace ServiceControl.Persistence.EFCore.Implementation; public class ExternalIntegrationRequestsDataStore : IExternalIntegrationRequestsDataStore, IHostedService { - public void Subscribe(Func callback) => - throw new NotImplementedException(); + public void Subscribe(Func callback) { } - public Task StoreDispatchRequest(IEnumerable dispatchRequests) => - throw new NotImplementedException(); + public Task StoreDispatchRequest(IEnumerable dispatchRequests) => Task.CompletedTask; - public Task StartAsync(CancellationToken cancellationToken) => - throw new NotImplementedException(); + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StopAsync(CancellationToken cancellationToken) => - throw new NotImplementedException(); + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageViewIndexNotifications.cs b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageViewIndexNotifications.cs index 583e6de887..cea390cdbb 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageViewIndexNotifications.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageViewIndexNotifications.cs @@ -7,9 +7,7 @@ public class FailedMessageViewIndexNotifications : IFailedMessageViewIndexNotifi public IDisposable Subscribe(Func callback) => throw new NotImplementedException(); - public Task StartAsync(CancellationToken cancellationToken) => - throw new NotImplementedException(); + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StopAsync(CancellationToken cancellationToken) => - throw new NotImplementedException(); + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/FullTextSearchTests.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/FullTextSearchTests.cs new file mode 100644 index 0000000000..2a89e55d20 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/FullTextSearchTests.cs @@ -0,0 +1,78 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using EFCore.PostgreSql; +using Npgsql; +using NUnit.Framework; + +class FullTextSearchTests : PersistenceTestBase +{ + [Test] + public async Task Can_create_and_query_full_text_index() + { + var postgreSqlSettings = PersistenceSettings as PostgreSqlPersisterSettings; + Assert.That(postgreSqlSettings, Is.Not.Null); + + var tableName = $"fts_{Guid.NewGuid():N}"; + var commandTimeoutSeconds = 30; + + await using var connection = new NpgsqlConnection(postgreSqlSettings.ConnectionString); + await connection.OpenAsync(); + + try + { + await ExecuteNonQuery(connection, $""" + CREATE TABLE public."{tableName}" ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + body TEXT NOT NULL, + search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED + ) + """, commandTimeoutSeconds); + + await ExecuteNonQuery(connection, $"""CREATE INDEX "{tableName}_search_vector_idx" ON public."{tableName}" USING GIN (search_vector)""", commandTimeoutSeconds); + + await ExecuteNonQuery(connection, $""" + INSERT INTO public."{tableName}"(body) VALUES + ('quick brown fox jumps'), + ('azure service bus transport') + """, commandTimeoutSeconds); + + var matchCount = await ExecuteScalarInt(connection, $""" + SELECT COUNT(1) + FROM public."{tableName}" + WHERE search_vector @@ plainto_tsquery('english', 'quick') + """, commandTimeoutSeconds); + + Assert.That(matchCount, Is.EqualTo(1)); + } + finally + { + await ExecuteNonQuery(connection, $"""DROP TABLE IF EXISTS public."{tableName}" CASCADE""", commandTimeoutSeconds, ignoreErrors: true); + } + } + + static async Task ExecuteScalarInt(NpgsqlConnection connection, string sql, int commandTimeoutSeconds) + { + await using var command = connection.CreateCommand(); + command.CommandTimeout = commandTimeoutSeconds; + command.CommandText = sql; + var scalar = await command.ExecuteScalarAsync(); + Assert.That(scalar, Is.Not.Null); + return Convert.ToInt32(scalar); + } + + static async Task ExecuteNonQuery(NpgsqlConnection connection, string sql, int commandTimeoutSeconds, bool ignoreErrors = false) + { + try + { + await using var command = connection.CreateCommand(); + command.CommandTimeout = commandTimeoutSeconds; + command.CommandText = sql; + await command.ExecuteNonQueryAsync(); + } + catch when (ignoreErrors) + { + } + } +} diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/FullTextSearchTests.cs b/src/ServiceControl.Persistence.Tests.SqlServer/FullTextSearchTests.cs new file mode 100644 index 0000000000..eda658de49 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.SqlServer/FullTextSearchTests.cs @@ -0,0 +1,178 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using EFCore.SqlServer; +using Microsoft.Data.SqlClient; +using NUnit.Framework; + +class FullTextSearchTests : PersistenceTestBase +{ + [Test] + public async Task Can_create_and_query_full_text_index() + { + var sqlServerSettings = PersistenceSettings as SqlServerPersisterSettings; + Assert.That(sqlServerSettings, Is.Not.Null); + + var tableName = $"fts_{Guid.NewGuid():N}"; + var catalogName = $"ftc_{Guid.NewGuid():N}"; + var primaryKeyName = $"pk_{tableName}"; + var commandTimeoutSeconds = 30; + + var connectionResult = await OpenConnectionForFullTextSearch(sqlServerSettings.ConnectionString, commandTimeoutSeconds); + var temporaryDatabaseName = connectionResult.TemporaryDatabaseName; + await using var connection = connectionResult.Connection; + + try + { + var isFullTextInstalled = await ExecuteScalarInt(connection, "SELECT FULLTEXTSERVICEPROPERTY('IsFullTextInstalled')", commandTimeoutSeconds); + Assert.That(isFullTextInstalled, Is.EqualTo(1), "SQL Server instance must have Full-Text Search installed."); + + await ExecuteNonQuery(connection, $""" + CREATE TABLE [dbo].[{tableName}] ( + [Id] INT IDENTITY(1,1) NOT NULL, + [Body] NVARCHAR(MAX) NOT NULL, + CONSTRAINT [{primaryKeyName}] PRIMARY KEY CLUSTERED ([Id]) + ) + """, commandTimeoutSeconds); + + await ExecuteNonQuery(connection, $"CREATE FULLTEXT CATALOG [{catalogName}]", commandTimeoutSeconds); + + await ExecuteNonQuery(connection, $""" + CREATE FULLTEXT INDEX ON [dbo].[{tableName}]([Body] LANGUAGE 1033) + KEY INDEX [{primaryKeyName}] + ON [{catalogName}] + WITH CHANGE_TRACKING AUTO + """, commandTimeoutSeconds); + + await ExecuteNonQuery(connection, $""" + INSERT INTO [dbo].[{tableName}]([Body]) VALUES + (N'quick brown fox jumps'), + (N'azure service bus transport') + """, commandTimeoutSeconds); + + var found = await WaitForFullTextMatch(connection, tableName, commandTimeoutSeconds); + Assert.That(found, Is.True); + } + finally + { + await ExecuteNonQuery(connection, $""" + IF EXISTS (SELECT 1 FROM sys.fulltext_indexes fi + JOIN sys.tables t ON fi.object_id = t.object_id + WHERE t.name = '{tableName}') + DROP FULLTEXT INDEX ON [dbo].[{tableName}] + """, commandTimeoutSeconds, ignoreErrors: true); + + await ExecuteNonQuery(connection, $"IF OBJECT_ID(N'[dbo].[{tableName}]', N'U') IS NOT NULL DROP TABLE [dbo].[{tableName}]", commandTimeoutSeconds, ignoreErrors: true); + await ExecuteNonQuery(connection, $"IF EXISTS (SELECT 1 FROM sys.fulltext_catalogs WHERE name = '{catalogName}') DROP FULLTEXT CATALOG [{catalogName}]", commandTimeoutSeconds, ignoreErrors: true); + + if (!string.IsNullOrEmpty(temporaryDatabaseName)) + { + await connection.CloseAsync(); + await DropDatabase(sqlServerSettings.ConnectionString, temporaryDatabaseName, commandTimeoutSeconds); + } + } + } + + static async Task WaitForFullTextMatch(SqlConnection connection, string tableName, int commandTimeoutSeconds) + { + var deadline = DateTime.UtcNow.AddSeconds(15); + while (DateTime.UtcNow < deadline) + { + await using var command = connection.CreateCommand(); + command.CommandTimeout = commandTimeoutSeconds; + command.CommandText = $"SELECT COUNT(1) FROM [dbo].[{tableName}] WHERE CONTAINS([Body], '\"quick\"')"; + var count = (int)await command.ExecuteScalarAsync(); + if (count == 1) + { + return true; + } + + await Task.Delay(250); + } + + return false; + } + + static async Task ExecuteScalarInt(SqlConnection connection, string sql, int commandTimeoutSeconds) + { + await using var command = connection.CreateCommand(); + command.CommandTimeout = commandTimeoutSeconds; + command.CommandText = sql; + var scalar = await command.ExecuteScalarAsync(); + Assert.That(scalar, Is.Not.Null); + return Convert.ToInt32(scalar); + } + + static async Task ExecuteScalarString(SqlConnection connection, string sql, int commandTimeoutSeconds) + { + await using var command = connection.CreateCommand(); + command.CommandTimeout = commandTimeoutSeconds; + command.CommandText = sql; + var scalar = await command.ExecuteScalarAsync(); + Assert.That(scalar, Is.Not.Null); + return (string)scalar; + } + + static async Task ExecuteNonQuery(SqlConnection connection, string sql, int commandTimeoutSeconds, bool ignoreErrors = false) + { + try + { + await using var command = connection.CreateCommand(); + command.CommandTimeout = commandTimeoutSeconds; + command.CommandText = sql; + await command.ExecuteNonQueryAsync(); + } + catch when (ignoreErrors) + { + } + } + + static async Task<(SqlConnection Connection, string TemporaryDatabaseName)> OpenConnectionForFullTextSearch(string connectionString, int commandTimeoutSeconds) + { + var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + + var databaseName = await ExecuteScalarString(connection, "SELECT DB_NAME()", commandTimeoutSeconds); + if (!IsSystemDatabase(databaseName)) + { + return (connection, string.Empty); + } + + var temporaryDatabaseName = $"ftsdb_{Guid.NewGuid():N}"; + await ExecuteNonQuery(connection, $"CREATE DATABASE [{temporaryDatabaseName}]", commandTimeoutSeconds); + await connection.DisposeAsync(); + + var builder = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = temporaryDatabaseName + }; + + var dedicatedConnection = new SqlConnection(builder.ConnectionString); + await dedicatedConnection.OpenAsync(); + return (dedicatedConnection, temporaryDatabaseName); + } + + static async Task DropDatabase(string connectionString, string databaseName, int commandTimeoutSeconds) + { + var builder = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = "master" + }; + + await using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync(); + await ExecuteNonQuery(connection, $""" + IF DB_ID(N'{databaseName}') IS NOT NULL + BEGIN + ALTER DATABASE [{databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [{databaseName}]; + END + """, commandTimeoutSeconds, ignoreErrors: true); + } + + static bool IsSystemDatabase(string databaseName) => + string.Equals(databaseName, "master", StringComparison.OrdinalIgnoreCase) || + string.Equals(databaseName, "tempdb", StringComparison.OrdinalIgnoreCase) || + string.Equals(databaseName, "model", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerSharedContainer.cs b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerSharedContainer.cs index 066e823329..e89392258a 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerSharedContainer.cs +++ b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerSharedContainer.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Persistence.Tests; using System; using System.Threading; using System.Threading.Tasks; +using DotNet.Testcontainers.Builders; using Testcontainers.MsSql; static class SqlServerSharedContainer @@ -36,8 +37,7 @@ public static async Task GetConnectionStringAsync(CancellationToken ct = static async Task StartContainerAsync(CancellationToken ct) { - var c = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest") - .Build(); + var c = new MsSqlBuilder("particular/servicecontrol-testing-sqlserver:latest").Build(); await c.StartAsync(ct); return c; }