Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,24 @@ public IQueryable<AuditMessageEntity> Search(IQueryable<AuditMessageEntity> sour
message.HeadersJson + " " +
(message.BodyText ?? "").Substring(0, FullTextSearchSql.IndexedBodyLength) + " " +
(message.MessageType ?? "").Replace(".", " ").Replace("+", " "))
.Matches(EF.Functions.WebSearchToTsQuery(FullTextSearchSql.Configuration, ToOrQuery(searchTerms))));
.Matches(EF.Functions.ToTsQuery(FullTextSearchSql.Configuration, ToOrQuery(searchTerms))));

static string ToOrQuery(string searchTerms) =>
string.Join(" OR ", searchTerms.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
string.Join(" | ", searchTerms
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ToPhrase)
.OfType<string>());

static string? ToPhrase(string term)
{
var word = term.TrimEnd('*');
if (word.Length == 0)
{
return null;
}

var phrase = $"'{word.Replace(@"\", @"\\").Replace("'", "''")}'";

return word.Length < term.Length ? phrase + ":*" : phrase;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ namespace ServiceControl.Audit.Persistence.EFCore.SqlServer;

class SqlServerFullTextSearchDialect : IFullTextSearchDialect
{
public IQueryable<AuditMessageEntity> Search(IQueryable<AuditMessageEntity> source, string searchTerms) =>
source.Where(message =>
EF.Functions.FreeText(message.HeadersJson, searchTerms) ||
EF.Functions.FreeText(message.BodyText!, searchTerms));
public IQueryable<AuditMessageEntity> Search(IQueryable<AuditMessageEntity> source, string searchTerms)
{
var searchCondition = ToPhrases(searchTerms);

return source.Where(message =>
EF.Functions.Contains(message.HeadersJson, searchCondition) ||
EF.Functions.Contains(message.BodyText!, searchCondition));
}

static string ToPhrases(string searchTerms) =>
string.Join(" OR ", searchTerms
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(term => $"\"{term.Replace("\"", "\"\"")}\""));
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ public void Search_uses_the_indexed_expression()
[Test]
public void Terms_are_ored()
{
Assert.That(SearchQuery("forty two"), Does.Contain("='forty OR two'"));
Assert.That(SearchQuery("forty two"), Does.Contain("'forty' | 'two'"));
}

[Test]
public void A_trailing_star_makes_a_term_a_prefix()
{
Assert.That(SearchQuery("forty*"), Does.Contain("'forty':*"));
}

static string SearchQuery(string searchTerms)
Expand Down
40 changes: 40 additions & 0 deletions src/ServiceControl.Audit.Persistence.Tests/EFCore/EFQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,46 @@ public async Task Finds_a_word_that_is_quoted_in_single_quotes()
Assert.That(found.Results, Has.Count.EqualTo(1));
}

[Test]
public async Task Does_not_match_messages_that_share_part_of_the_message_id()
{
await Ingest(MakeMessage(messageId: "billing-1234-5678"), MakeMessage(messageId: "billing-9999-5678"));

var found = await MessagesViewStore.QueryMessages("billing-1234-5678", new PagingInfo(), new SortInfo("time_sent", "desc"));

Assert.That(found.Results.Select(view => view.MessageId), Is.EqualTo(new[] { "billing-1234-5678" }));
}

[Test]
public async Task Matches_a_term_ending_in_a_star_as_a_prefix()
{
await Ingest(MakeMessage(extraHeaders: new Dictionary<string, string> { [SearchableHeader] = SearchableWord }), MakeMessage());

var found = await MessagesViewStore.QueryMessages("merid*", new PagingInfo(), new SortInfo("time_sent", "desc"));

Assert.That(found.Results, Has.Count.EqualTo(1));
}

[TestCase("*")]
[TestCase(@"meridian\")]
[TestCase(@"merid\*")]
[TestCase("it's*")]
[TestCase("'")]
[TestCase("\"")]
[TestCase("foo \"bar")]
[TestCase("-")]
[TestCase("a & b")]
[TestCase("!(meridian")]
[TestCase("meridian:*")]
[TestCase("NEAR")]
[TestCase("AND")]
public async Task Searches_for_query_syntax_without_failing(string searchTerms)
{
await Ingest(MakeMessage(extraHeaders: new Dictionary<string, string> { [SearchableHeader] = SearchableWord }));

Assert.DoesNotThrowAsync(() => MessagesViewStore.QueryMessages(searchTerms, new PagingInfo(), new SortInfo("time_sent", "desc")));
}

[Test]
public async Task Sorts_by_status_in_the_order_the_primary_merges_instance_pages()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,27 @@ public IQueryable<FailedMessageEntity> Search(IQueryable<FailedMessageEntity> so
message.HeadersJson + " " +
(message.BodyText ?? "").Substring(0, FullTextSearchSql.IndexedBodyLength) + " " +
(message.MessageType ?? "").Replace(".", " ").Replace("+", " "))
.Matches(EF.Functions.WebSearchToTsQuery(FullTextSearchSql.Configuration, ToOrQuery(searchTerms))));
.Matches(EF.Functions.ToTsQuery(FullTextSearchSql.Configuration, ToOrQuery(searchTerms))));

// websearch_to_tsquery ANDs bare terms; the RavenDB persister ORs them, so the terms are
// rejoined with the operator that syntax understands. It also never throws on odd input, which
// a hand built tsquery would.
static string ToOrQuery(string searchTerms) =>
string.Join(" OR ", searchTerms.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
string.Join(" | ", searchTerms
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ToPhrase)
.OfType<string>());

// A quoted lexeme is parsed into a phrase of the words it contains, and :* makes each of them a
// prefix. to_tsquery throws on an unescaped backslash or quote, and on an empty pair of quotes,
// which is what a bare * would leave.
static string? ToPhrase(string term)
{
var word = term.TrimEnd('*');
if (word.Length == 0)
{
return null;
}

var phrase = $"'{word.Replace(@"\", @"\\").Replace("'", "''")}'";

return word.Length < term.Length ? phrase + ":*" : phrase;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,22 @@ namespace ServiceControl.Persistence.EFCore.SqlServer;

class SqlServerFullTextSearchDialect : IFullTextSearchDialect
{
// FREETEXT ORs the terms itself, so the search string needs no parsing here. Both columns are
// covered by the index the AddFullTextSearch migration creates, and a NULL body simply does not
// match.
public IQueryable<FailedMessageEntity> Search(IQueryable<FailedMessageEntity> source, string searchTerms) =>
source.Where(message =>
EF.Functions.FreeText(message.HeadersJson, searchTerms) ||
EF.Functions.FreeText(message.BodyText!, searchTerms));
// Both columns are covered by the index the AddFullTextSearch migration creates, and a NULL body
// simply does not match.
public IQueryable<FailedMessageEntity> Search(IQueryable<FailedMessageEntity> source, string searchTerms)
{
var searchCondition = ToPhrases(searchTerms);

return source.Where(message =>
EF.Functions.Contains(message.HeadersJson, searchCondition) ||
EF.Functions.Contains(message.BodyText!, searchCondition));
}

// CONTAINS parses its argument as query syntax, where two bare words in a row are an error. A
// quoted term is a phrase: the pieces the word breaker splits it into must appear together and
// in order, and a trailing * makes it a prefix.
static string ToPhrases(string searchTerms) =>
string.Join(" OR ", searchTerms
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(term => $"\"{term.Replace("\"", "\"\"")}\""));
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ namespace ServiceControl.Persistence.EFCore.Infrastructure;
public interface IFullTextSearchDialect
{
/// <summary>
/// Terms are ORed, matching the RavenDB persister, whose Search defaults to SearchOperator.Or.
/// Callers guarantee the terms are not blank.
/// Terms are ORed, matching the RavenDB persister, whose Search defaults to SearchOperator.Or, and
/// a trailing * makes a term a prefix, as it does there. Each term matches as a phrase, so a
/// message id only matches the messages that contain all of it. Callers guarantee the terms are
/// not blank.
/// </summary>
IQueryable<FailedMessageEntity> Search(IQueryable<FailedMessageEntity> source, string searchTerms);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ public void Terms_are_ored()
{
var sql = SearchQuery("forty two");

Assert.That(sql, Does.Contain("='forty OR two'"));
Assert.That(sql, Does.Contain("'forty' | 'two'"));
}

[Test]
public void A_trailing_star_makes_a_term_a_prefix()
{
var sql = SearchQuery("forty*");

Assert.That(sql, Does.Contain("'forty':*"));
}

static string SearchQuery(string searchTerms)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,48 @@ public async Task Ors_the_search_terms()
await AssertSearchFinds("zarquon capacitor", first, second);
}

[Test]
public async Task Does_not_match_messages_that_share_part_of_the_message_id()
{
var matching = new IngestedFailure { MessageId = "billing-1234-5678", ExceptionMessage = "the zarquon overheated" };
var sharingPieces = new IngestedFailure { MessageId = "billing-9999-5678", ExceptionMessage = "the zarquon overheated" };

await Ingest(matching, sharingPieces);
await AssertSearchFinds("zarquon", matching, sharingPieces);

await AssertSearchFinds(matching.MessageId, matching);
}

[Test]
public async Task Matches_a_term_ending_in_a_star_as_a_prefix()
{
var matching = new IngestedFailure { ExceptionMessage = "the zarquon overheated" };

await Ingest(matching, new IngestedFailure());

await AssertSearchFinds("zarq*", matching);
}

[TestCase("*")]
[TestCase(@"zarquon\")]
[TestCase(@"zarq\*")]
[TestCase("it's*")]
[TestCase("'")]
[TestCase("\"")]
[TestCase("foo \"bar")]
[TestCase("-")]
[TestCase("a & b")]
[TestCase("!(zarquon")]
[TestCase("zarquon:*")]
[TestCase("NEAR")]
[TestCase("AND")]
public async Task Searches_for_query_syntax_without_failing(string searchTerms)
{
await Ingest(new IngestedFailure { ExceptionMessage = "the zarquon overheated" });

Assert.DoesNotThrowAsync(() => MessagesViewStore.GetAllMessagesForSearch(searchTerms, new PagingInfo(), new SortInfo()));
}

[Test]
public async Task Searches_within_an_endpoint()
{
Expand Down
Loading