-
Notifications
You must be signed in to change notification settings - Fork 51
Add full text search to sql container #5616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
rbev
wants to merge
2
commits into
master
Choose a base branch
from
rhys-sql-testing
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
21
src/Scripts/Docker/servicecontrol-testing-sqlserver/Dockerfile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
78 changes: 78 additions & 0 deletions
78
src/ServiceControl.Persistence.Tests.PostgreSql/FullTextSearchTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
178
src/ServiceControl.Persistence.Tests.SqlServer/FullTextSearchTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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!