Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Scripts/Create-SqlTestContainer.ps1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why we need a ps file to execute the docker cli, no ps on my machine!

Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions src/Scripts/Docker/servicecontrol-testing-sqlserver/Dockerfile
Original file line number Diff line number Diff line change
@@ -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" ]
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@ namespace ServiceControl.Persistence.EFCore.Implementation;

public class ExternalIntegrationRequestsDataStore : IExternalIntegrationRequestsDataStore, IHostedService
{
public void Subscribe(Func<object[], Task> callback) =>
throw new NotImplementedException();
public void Subscribe(Func<object[], Task> callback) { }

public Task StoreDispatchRequest(IEnumerable<ExternalIntegrationDispatchRequest> dispatchRequests) =>
throw new NotImplementedException();
public Task StoreDispatchRequest(IEnumerable<ExternalIntegrationDispatchRequest> 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ public class FailedMessageViewIndexNotifications : IFailedMessageViewIndexNotifi
public IDisposable Subscribe(Func<FailedMessageTotals, Task> 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;
}
Original file line number Diff line number Diff line change
@@ -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<int> 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)
{
}
}
}
178 changes: 178 additions & 0 deletions src/ServiceControl.Persistence.Tests.SqlServer/FullTextSearchTests.cs
Original file line number Diff line number Diff line change
@@ -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<bool> 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<int> 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<string> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -36,8 +37,7 @@ public static async Task<string> GetConnectionStringAsync(CancellationToken ct =

static async Task<MsSqlContainer> 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;
}
Expand Down