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
33 changes: 33 additions & 0 deletions src/DocumentDbTests/Indexes/full_text_index.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlogPost>());

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<BlogPost>()
.Where(x => x.Search("Priced"))
.ToList();

noMatch.ShouldBeEmpty();

// PrefixSearch DOES match because it uses the :* prefix operator
var results = session.Query<BlogPost>()
.Where(x => x.PrefixSearch("Priced"))
.ToList();

results.Count.ShouldBe(1);
results[0].Id.ShouldBe(expectedId);
}
}
}

public static class FullTextIndexTestsExtension
Expand Down
18 changes: 18 additions & 0 deletions src/Marten/IQuerySession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,24 @@ Task<IReadOnlyList<TDoc>> PhraseSearchAsync<TDoc>(string searchTerm,
Task<IReadOnlyList<TDoc>> WebStyleSearchAsync<TDoc>(string searchTerm,
string regConfig = FullTextIndexDefinition.DefaultRegConfig, CancellationToken token = default);

/// <summary>
/// Performs an asynchronous full text search against <typeparamref name="TDoc" /> 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.
/// </summary>
/// <param name="searchTerm">The text to search for. Each word is treated as a prefix.</param>
/// <param name="regConfig">
/// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used
/// by <seealso cref="DocumentMapping.AddFullTextIndex(string)" />
/// </param>
/// <param name="token"></param>
/// <remarks>
/// Uses PostgreSQL's to_tsquery with the :* prefix matching operator.
/// See: https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES
/// </remarks>
Task<IReadOnlyList<TDoc>> PrefixSearchAsync<TDoc>(string searchTerm,
string regConfig = FullTextIndexDefinition.DefaultRegConfig, CancellationToken token = default);

/// <summary>
/// Fetch the entity version and last modified time from the database
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/Marten/Internal/Sessions/QuerySession.FullTextSearch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,10 @@ public Task<IReadOnlyList<TDoc>> WebStyleSearchAsync<TDoc>(string searchTerm,
{
return Query<TDoc>().Where(d => d.WebStyleSearch(searchTerm, regConfig)).ToListAsync(token);
}

public Task<IReadOnlyList<TDoc>> PrefixSearchAsync<TDoc>(string searchTerm,
string regConfig = FullTextIndexDefinition.DefaultRegConfig, CancellationToken token = default)
{
return Query<TDoc>().Where(d => d.PrefixSearch(searchTerm, regConfig)).ToListAsync(token);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)!
Expand All @@ -65,4 +66,6 @@ public ISqlFragment Parse(IQueryableMemberCollection memberCollection, IReadOnly
searchTerm,
regConfig);
}

protected virtual string TransformSearchTerm(string searchTerm) => searchTerm;
}
18 changes: 18 additions & 0 deletions src/Marten/Linq/Parsing/Methods/FullText/PrefixSearch.cs
Original file line number Diff line number Diff line change
@@ -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 + ":*"));
}
}
36 changes: 36 additions & 0 deletions src/Marten/LinqExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,42 @@ public static bool WebStyleSearch<T>(this T variable, string searchTerm, string
$"{nameof(WebStyleSearch)} extension method can only be used in Marten Linq queries.");
}

/// <summary>
/// Performs a full text search against <typeparamref name="T" /> 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.
/// </summary>
/// <param name="searchTerm">The text to search for. Each word is treated as a prefix.</param>
/// <remarks>
/// Uses PostgreSQL's to_tsquery with the :* prefix matching operator.
/// See: https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES
/// </remarks>
public static bool PrefixSearch<T>(this T variable, string searchTerm)
{
throw new NotSupportedException(
$"{nameof(PrefixSearch)} extension method can only be used in Marten Linq queries.");
}

/// <summary>
/// Performs a full text search against <typeparamref name="T" /> 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.
/// </summary>
/// <param name="searchTerm">The text to search for. Each word is treated as a prefix.</param>
/// <param name="regConfig">
/// The dictionary config passed to the 'to_tsquery' function, must match the config parameter used
/// by <seealso cref="DocumentMapping.AddFullTextIndex(string)" />
/// </param>
/// <remarks>
/// Uses PostgreSQL's to_tsquery with the :* prefix matching operator.
/// See: https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES
/// </remarks>
public static bool PrefixSearch<T>(this T variable, string searchTerm, string regConfig)
{
throw new NotSupportedException(
$"{nameof(PrefixSearch)} extension method can only be used in Marten Linq queries.");
}

/// <summary>
/// Performs a ngram search against <typeparamref name="T" /> using a custom ngram search function
/// </summary>
Expand Down
3 changes: 2 additions & 1 deletion src/Marten/LinqParsing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ public class LinqParsing: IReadOnlyLinqParsing
new PhraseSearch(),
new PlainTextSearch(),
new WebStyleSearch(),
new NgramSearch()
new NgramSearch(),
new PrefixSearch()
};

private readonly StoreOptions _options;
Expand Down
Loading