diff --git a/src/DocumentDbTests/Indexes/full_text_index.cs b/src/DocumentDbTests/Indexes/full_text_index.cs index cc3e652996..2f6d493b54 100644 --- a/src/DocumentDbTests/Indexes/full_text_index.cs +++ b/src/DocumentDbTests/Indexes/full_text_index.cs @@ -923,6 +923,39 @@ await conn.CreateCommand( }); await Should.NotThrowAsync(async () => await store2.Storage.Database.AssertDatabaseMatchesConfigurationAsync()); } + + [Fact] + public async Task prefix_search_matches_partial_enum_value_GH_1327() + { + StoreOptions(_ => _.RegisterDocumentType()); + + var expectedId = Guid.NewGuid(); + + using (var session = theStore.LightweightSession()) + { + session.Store(new BlogPost { Id = expectedId, EnglishText = "PricedIdeaScreening" }); + session.Store(new BlogPost { Id = Guid.NewGuid(), EnglishText = "UnrelatedContent" }); + await session.SaveChangesAsync(); + } + + using (var session = theStore.QuerySession()) + { + // Standard Search does NOT match a partial prefix of a concatenated word + var noMatch = session.Query() + .Where(x => x.Search("Priced")) + .ToList(); + + noMatch.ShouldBeEmpty(); + + // PrefixSearch DOES match because it uses the :* prefix operator + var results = session.Query() + .Where(x => x.PrefixSearch("Priced")) + .ToList(); + + results.Count.ShouldBe(1); + results[0].Id.ShouldBe(expectedId); + } + } } public static class FullTextIndexTestsExtension diff --git a/src/Marten/IQuerySession.cs b/src/Marten/IQuerySession.cs index e3c4415f3d..7602c1b43f 100644 --- a/src/Marten/IQuerySession.cs +++ b/src/Marten/IQuerySession.cs @@ -478,6 +478,24 @@ Task> PhraseSearchAsync(string searchTerm, Task> WebStyleSearchAsync(string searchTerm, string regConfig = FullTextIndexDefinition.DefaultRegConfig, CancellationToken token = default); + /// + /// Performs an asynchronous full text search against using prefix matching. + /// Each word in the search term is treated as a prefix, so "Priced" will match "PricedIdeaScreening". + /// This is useful for searching enum values stored as strings or other concatenated identifiers. + /// + /// The text to search for. Each word is treated as a prefix. + /// + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used + /// by + /// + /// + /// + /// Uses PostgreSQL's to_tsquery with the :* prefix matching operator. + /// See: https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + Task> PrefixSearchAsync(string searchTerm, + string regConfig = FullTextIndexDefinition.DefaultRegConfig, CancellationToken token = default); + /// /// Fetch the entity version and last modified time from the database /// diff --git a/src/Marten/Internal/Sessions/QuerySession.FullTextSearch.cs b/src/Marten/Internal/Sessions/QuerySession.FullTextSearch.cs index f9a5e31a1e..2facbdbdb6 100644 --- a/src/Marten/Internal/Sessions/QuerySession.FullTextSearch.cs +++ b/src/Marten/Internal/Sessions/QuerySession.FullTextSearch.cs @@ -33,4 +33,10 @@ public Task> WebStyleSearchAsync(string searchTerm, { return Query().Where(d => d.WebStyleSearch(searchTerm, regConfig)).ToListAsync(token); } + + public Task> PrefixSearchAsync(string searchTerm, + string regConfig = FullTextIndexDefinition.DefaultRegConfig, CancellationToken token = default) + { + return Query().Where(d => d.PrefixSearch(searchTerm, regConfig)).ToListAsync(token); + } } diff --git a/src/Marten/Linq/Parsing/Methods/FullText/FullTextSearchMethodCallParser.cs b/src/Marten/Linq/Parsing/Methods/FullText/FullTextSearchMethodCallParser.cs index 2439db35fa..b44d944d78 100644 --- a/src/Marten/Linq/Parsing/Methods/FullText/FullTextSearchMethodCallParser.cs +++ b/src/Marten/Linq/Parsing/Methods/FullText/FullTextSearchMethodCallParser.cs @@ -54,6 +54,7 @@ public ISqlFragment Parse(IQueryableMemberCollection memberCollection, IReadOnly } var searchTerm = (string)expression.Arguments[1].Value(); + searchTerm = TransformSearchTerm(searchTerm); var regConfig = expression.Arguments.Count > 2 ? (expression.Arguments[2].Value() as string)! @@ -65,4 +66,6 @@ public ISqlFragment Parse(IQueryableMemberCollection memberCollection, IReadOnly searchTerm, regConfig); } + + protected virtual string TransformSearchTerm(string searchTerm) => searchTerm; } diff --git a/src/Marten/Linq/Parsing/Methods/FullText/PrefixSearch.cs b/src/Marten/Linq/Parsing/Methods/FullText/PrefixSearch.cs new file mode 100644 index 0000000000..1c243fdbf0 --- /dev/null +++ b/src/Marten/Linq/Parsing/Methods/FullText/PrefixSearch.cs @@ -0,0 +1,18 @@ +#nullable enable +using System; +using System.Linq; + +namespace Marten.Linq.Parsing.Methods.FullText; + +internal class PrefixSearch: FullTextSearchMethodCallParser +{ + public PrefixSearch(): base(nameof(LinqExtensions.PrefixSearch), FullTextSearchFunction.to_tsquery) + { + } + + protected override string TransformSearchTerm(string searchTerm) + { + var words = searchTerm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + return string.Join(" & ", words.Select(w => w + ":*")); + } +} diff --git a/src/Marten/LinqExtensions.cs b/src/Marten/LinqExtensions.cs index e566146fb7..00115bade9 100644 --- a/src/Marten/LinqExtensions.cs +++ b/src/Marten/LinqExtensions.cs @@ -300,6 +300,42 @@ public static bool WebStyleSearch(this T variable, string searchTerm, string $"{nameof(WebStyleSearch)} extension method can only be used in Marten Linq queries."); } + /// + /// Performs a full text search against using prefix matching. + /// Each word in the search term is treated as a prefix, so "Priced" will match "PricedIdeaScreening". + /// This is useful for searching enum values stored as strings or other concatenated identifiers. + /// + /// The text to search for. Each word is treated as a prefix. + /// + /// Uses PostgreSQL's to_tsquery with the :* prefix matching operator. + /// See: https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static bool PrefixSearch(this T variable, string searchTerm) + { + throw new NotSupportedException( + $"{nameof(PrefixSearch)} extension method can only be used in Marten Linq queries."); + } + + /// + /// Performs a full text search against using prefix matching. + /// Each word in the search term is treated as a prefix, so "Priced" will match "PricedIdeaScreening". + /// This is useful for searching enum values stored as strings or other concatenated identifiers. + /// + /// The text to search for. Each word is treated as a prefix. + /// + /// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used + /// by + /// + /// + /// Uses PostgreSQL's to_tsquery with the :* prefix matching operator. + /// See: https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES + /// + public static bool PrefixSearch(this T variable, string searchTerm, string regConfig) + { + throw new NotSupportedException( + $"{nameof(PrefixSearch)} extension method can only be used in Marten Linq queries."); + } + /// /// Performs a ngram search against using a custom ngram search function /// diff --git a/src/Marten/LinqParsing.cs b/src/Marten/LinqParsing.cs index fe7e264b34..23d5946a58 100644 --- a/src/Marten/LinqParsing.cs +++ b/src/Marten/LinqParsing.cs @@ -95,7 +95,8 @@ public class LinqParsing: IReadOnlyLinqParsing new PhraseSearch(), new PlainTextSearch(), new WebStyleSearch(), - new NgramSearch() + new NgramSearch(), + new PrefixSearch() }; private readonly StoreOptions _options;