Added full-text search to voice works search provider. Added initial tests for voice works full-text search.

This commit is contained in:
2025-08-31 23:29:01 -04:00
parent 3d0b2ed31d
commit a73a2ea1c9
8 changed files with 214 additions and 43 deletions

View File

@@ -20,8 +20,9 @@ public sealed class VoiceWorkSearchConfiguration : IEntityTypeConfiguration<Voic
// MariaDB/MySQL (Pomelo) fulltext (BOOLEAN/NATURAL) — create an FT index // MariaDB/MySQL (Pomelo) fulltext (BOOLEAN/NATURAL) — create an FT index
// Pomelo supports .HasMethod("FULLTEXT"). If your version doesn't, add it in a migration SQL. // Pomelo supports .HasMethod("FULLTEXT"). If your version doesn't, add it in a migration SQL.
//builder.HasIndex(x => x.SearchText) builder.HasIndex(x => x.SearchText)
// .HasDatabaseName("FT_SearchText") .IsFullText()
// .HasMethod("FULLTEXT"); .HasDatabaseName("FT_SearchText");
//.HasMethod("FULLTEXT");
} }
} }

View File

@@ -0,0 +1,6 @@
namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks;
public interface IVoiceWorkFullTextSearch
{
IQueryable<int> MatchingIds(AppDbContext context, string searchText);
}

View File

@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks;
public class MySqlVoiceWorkFullTextSearch : IVoiceWorkFullTextSearch
{
public IQueryable<int> MatchingIds(AppDbContext context, string searchText) =>
context.VoiceWorkSearches
.Where(v => EF.Functions.Match(v.SearchText, searchText, MySqlMatchSearchMode.Boolean) > 0)
.Select(v => v.VoiceWorkId);
}

View File

@@ -18,15 +18,15 @@ public class VoiceWorkQuery
//public VoiceWorkSearch? VoiceWorkSearch { get; init; } //public VoiceWorkSearch? VoiceWorkSearch { get; init; }
} }
public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider<VoiceWorkSearchResult, VoiceWorkSearchCriteria, VoiceWorkSortField, VoiceWorkQuery>, IVoiceWorkSearchProvider public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSearch fullTextSearch) : SearchProvider<VoiceWorkSearchResult, VoiceWorkSearchCriteria, VoiceWorkSortField, VoiceWorkQuery>, IVoiceWorkSearchProvider
{ {
protected override IQueryable<VoiceWorkQuery> GetBaseQuery() protected override IQueryable<VoiceWorkQuery> GetBaseQuery()
{ {
return return
from voiceWork in context.VoiceWorks from voiceWork in context.VoiceWorks.AsNoTracking()
join englishVoiceWork in context.EnglishVoiceWorks on voiceWork.VoiceWorkId equals englishVoiceWork.VoiceWorkId into ps join englishVoiceWork in context.EnglishVoiceWorks.AsNoTracking() on voiceWork.VoiceWorkId equals englishVoiceWork.VoiceWorkId into ps
from englishVoiceWork in ps.DefaultIfEmpty() from englishVoiceWork in ps.DefaultIfEmpty()
join circle in context.Circles on voiceWork.CircleId equals circle.CircleId into cs join circle in context.Circles.AsNoTracking() on voiceWork.CircleId equals circle.CircleId into cs
from circle in cs.DefaultIfEmpty() from circle in cs.DefaultIfEmpty()
//join voiceWorkLocalization in context.VoiceWorkLocalizations on voiceWork.VoiceWorkId equals voiceWorkLocalization.VoiceWorkId into vwl //join voiceWorkLocalization in context.VoiceWorkLocalizations on voiceWork.VoiceWorkId equals voiceWorkLocalization.VoiceWorkId into vwl
//from voiceWorkLocalization in vwl.DefaultIfEmpty() //from voiceWorkLocalization in vwl.DefaultIfEmpty()
@@ -44,10 +44,12 @@ public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider<Voic
{ {
IQueryable<VoiceWorkQuery> filteredQuery = query; IQueryable<VoiceWorkQuery> filteredQuery = query;
// TODO: Full Text Search implementation filteredQuery = ApplyKeywordsFilter(filteredQuery, criteria);
//filteredQuery = FuzzyKeywordSearch(filteredQuery, searchProperties.Keywords); filteredQuery = ApplyCircleStatusFilter(filteredQuery, criteria);
//filteredQuery = FuzzyTitleSearch(filteredQuery, searchProperties.Title); filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
//filteredQuery = FuzzyCircleSearch(filteredQuery, searchProperties.Circle); //filteredQuery = FilterCreatorStatus(filteredQuery, searchProperties.CreatorStatus, _voiceWorkContext);
filteredQuery = ApplyTagIdsFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorIdsFilter(filteredQuery, criteria);
switch (criteria.SaleStatus) switch (criteria.SaleStatus)
{ {
@@ -86,25 +88,43 @@ public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider<Voic
if (criteria.MaxDownloads is not null) if (criteria.MaxDownloads is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.Downloads <= criteria.MaxDownloads.Value); filteredQuery = filteredQuery.Where(x => x.VoiceWork.Downloads <= criteria.MaxDownloads.Value);
return filteredQuery;
}
private IQueryable<VoiceWorkQuery> ApplyKeywordsFilter(IQueryable<VoiceWorkQuery> query, VoiceWorkSearchCriteria criteria)
{
if (string.IsNullOrWhiteSpace(criteria.Keywords))
return query;
var voiceWorkIds = fullTextSearch.MatchingIds(context, criteria.Keywords);
return query.Where(x => voiceWorkIds.Contains(x.VoiceWork.VoiceWorkId));
}
private IQueryable<VoiceWorkQuery> ApplyCircleStatusFilter(IQueryable<VoiceWorkQuery> query, VoiceWorkSearchCriteria criteria)
{
if (criteria.CircleStatus is null)
return query;
switch (criteria.CircleStatus) switch (criteria.CircleStatus)
{ {
case CircleStatus.NotBlacklisted: case CircleStatus.NotBlacklisted:
filteredQuery = filteredQuery.Where(x => x.Circle.Blacklisted == false); query = query.Where(q =>
break; !context.Circles.Any(c => c.CircleId == q.VoiceWork.CircleId && c.Blacklisted));
case CircleStatus.Favorited:
filteredQuery = filteredQuery.Where(x => x.Circle.Favorite);
break; break;
case CircleStatus.Blacklisted: case CircleStatus.Blacklisted:
filteredQuery = filteredQuery.Where(x => x.Circle.Blacklisted); query = query.Where(q =>
context.Circles.Any(c => c.CircleId == q.VoiceWork.CircleId && c.Blacklisted));
break;
case CircleStatus.Favorited:
query = query.Where(q =>
context.Circles.Any(c => c.CircleId == q.VoiceWork.CircleId && c.Favorite));
break; break;
} }
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria); return query;
//filteredQuery = FilterCreatorStatus(filteredQuery, searchProperties.CreatorStatus, _voiceWorkContext);
filteredQuery = FilterTagIds(filteredQuery, criteria);
filteredQuery = FilterCreatorIds(filteredQuery, criteria);
return filteredQuery;
} }
private IQueryable<VoiceWorkQuery> ApplyTagStatusFilter(IQueryable<VoiceWorkQuery> query, VoiceWorkSearchCriteria criteria) private IQueryable<VoiceWorkQuery> ApplyTagStatusFilter(IQueryable<VoiceWorkQuery> query, VoiceWorkSearchCriteria criteria)
@@ -155,7 +175,7 @@ public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider<Voic
}; };
} }
private IQueryable<VoiceWorkQuery> FilterTagIds(IQueryable<VoiceWorkQuery> filteredQuery, VoiceWorkSearchCriteria criteria) private IQueryable<VoiceWorkQuery> ApplyTagIdsFilter(IQueryable<VoiceWorkQuery> filteredQuery, VoiceWorkSearchCriteria criteria)
{ {
if (criteria.TagIds.Length == 0) if (criteria.TagIds.Length == 0)
return filteredQuery; return filteredQuery;
@@ -195,7 +215,7 @@ public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider<Voic
return filteredQuery; return filteredQuery;
} }
private IQueryable<VoiceWorkQuery> FilterCreatorIds(IQueryable<VoiceWorkQuery> filteredQuery, VoiceWorkSearchCriteria criteria) private IQueryable<VoiceWorkQuery> ApplyCreatorIdsFilter(IQueryable<VoiceWorkQuery> filteredQuery, VoiceWorkSearchCriteria criteria)
{ {
if (criteria.CreatorIds.Length == 0) if (criteria.CreatorIds.Length == 0)
return filteredQuery; return filteredQuery;

View File

@@ -10,6 +10,8 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.8" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.8" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -26,7 +26,7 @@ public class MariaDbFixture : IAsyncLifetime
await using AppDbContext context = CreateDbContext(); await using AppDbContext context = CreateDbContext();
await context.Database.EnsureCreatedAsync(); await context.Database.EnsureCreatedAsync();
//await context.Database.MigrateAsync(); // Testing
await OnInitializedAsync(context); await OnInitializedAsync(context);
} }

View File

@@ -52,6 +52,43 @@ public class VoiceWorkSearchProviderFixture : MariaDbFixture
new() { EnglishTagId = 8, TagId = 8, Name = "Maid" } new() { EnglishTagId = 8, TagId = 8, Name = "Maid" }
); );
context.VoiceWorkTags.AddRange(
new() { VoiceWorkId = 1, TagId = 1 }, // ASMR
new() { VoiceWorkId = 1, TagId = 2 }, // Office Lady
new() { VoiceWorkId = 2, TagId = 1 }, // ASMR
new() { VoiceWorkId = 2, TagId = 3 }, // Heartwarming
new() { VoiceWorkId = 2, TagId = 4 }, // Elf / Fairy
new() { VoiceWorkId = 2, TagId = 5 }, // Tsundere
new() { VoiceWorkId = 2, TagId = 6 }, // All Happy
new() { VoiceWorkId = 2, TagId = 7 }, // Gal
new() { VoiceWorkId = 2, TagId = 8 } // Maid
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 3, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 4, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 },
//new() { VoiceWorkId = 5, TagId = 1 }
);
context.Creators.AddRange( context.Creators.AddRange(
new() { CreatorId = 1, Name = "陽向葵ゅか", Favorite = true }, new() { CreatorId = 1, Name = "陽向葵ゅか", Favorite = true },
new() { CreatorId = 2, Name = "秋野かえで" }, new() { CreatorId = 2, Name = "秋野かえで" },
@@ -60,6 +97,15 @@ public class VoiceWorkSearchProviderFixture : MariaDbFixture
new() { CreatorId = 5, Name = "山田じぇみ子", Blacklisted = true } new() { CreatorId = 5, Name = "山田じぇみ子", Blacklisted = true }
); );
// <Product Id> <Maker Id> <Circle Name> <Product Name> <Product Description> <Tags> <Creators>
context.VoiceWorkSearches.AddRange(
new() { VoiceWorkId = 1, SearchText = "RJ0000001 RG00001 Good Dreams Today Sounds An average product. ASMR Office Lady" },
new() { VoiceWorkId = 2, SearchText = "RJ0000002 RG00002 Sweet Dreams Super Comfy ASMR An amazing product! ASMR Heartwarming Elf / Fairy Tsundere All Happy Gal Maid" },
new() { VoiceWorkId = 3, SearchText = "RJ0000003 RG00003 Nightmare Fuel Low Effort A bad product." },
new() { VoiceWorkId = 4, SearchText = "RJ0000004 RG00001 Good Dreams Tomorrow Sounds A average upcoming product." },
new() { VoiceWorkId = 5, SearchText = "RJ0000005 RG00002 Sweet Dreams Super Comfy ASMR+ All your favorite sounds, plus more!" }
);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
} }

View File

@@ -9,11 +9,19 @@ namespace JSMR.Tests.Integration;
public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture) : IClassFixture<VoiceWorkSearchProviderFixture> public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture) : IClassFixture<VoiceWorkSearchProviderFixture>
{ {
private VoiceWorkSearchProvider InitializeVoiceWorkSearchProvider(AppDbContext context)
{
MySqlVoiceWorkFullTextSearch fullTextSearch = new();
VoiceWorkSearchProvider provider = new(context, fullTextSearch);
return provider;
}
[Fact] [Fact]
public async Task Filter_Default() public async Task Filter_Default()
{ {
await using AppDbContext context = fixture.CreateDbContext(); await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = new(context); VoiceWorkSearchProvider provider = InitializeVoiceWorkSearchProvider(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>() var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{ {
@@ -21,11 +29,7 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{ {
SaleStatus = SaleStatus.Available, SaleStatus = SaleStatus.Available,
CircleStatus = CircleStatus.NotBlacklisted CircleStatus = CircleStatus.NotBlacklisted
}, }
SortOptions =
[
new(VoiceWorkSortField.ReleaseDate, Application.Common.Search.SortDirection.Descending)
]
}; };
var result = await provider.SearchAsync(options); var result = await provider.SearchAsync(options);
@@ -40,7 +44,7 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
public async Task Filter_Upcoming_Favorite() public async Task Filter_Upcoming_Favorite()
{ {
await using AppDbContext context = fixture.CreateDbContext(); await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = new(context); VoiceWorkSearchProvider provider = InitializeVoiceWorkSearchProvider(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>() var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{ {
@@ -48,11 +52,7 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{ {
SaleStatus = SaleStatus.Upcoming, SaleStatus = SaleStatus.Upcoming,
CircleStatus = CircleStatus.Favorited CircleStatus = CircleStatus.Favorited
}, }
SortOptions =
[
new(VoiceWorkSortField.ReleaseDate, Application.Common.Search.SortDirection.Descending)
]
}; };
var result = await provider.SearchAsync(options); var result = await provider.SearchAsync(options);
@@ -67,7 +67,7 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
public async Task Filter_Availble_Blacklisted() public async Task Filter_Availble_Blacklisted()
{ {
await using AppDbContext context = fixture.CreateDbContext(); await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = new(context); VoiceWorkSearchProvider provider = InitializeVoiceWorkSearchProvider(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>() var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{ {
@@ -75,11 +75,7 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{ {
SaleStatus = SaleStatus.Available, SaleStatus = SaleStatus.Available,
CircleStatus = CircleStatus.Blacklisted CircleStatus = CircleStatus.Blacklisted
}, }
SortOptions =
[
new(VoiceWorkSortField.ReleaseDate, Application.Common.Search.SortDirection.Descending)
]
}; };
var result = await provider.SearchAsync(options); var result = await provider.SearchAsync(options);
@@ -89,4 +85,93 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
result.Items.ShouldAllBe(item => item.SalesDate != null); result.Items.ShouldAllBe(item => item.SalesDate != null);
result.Items.ShouldNotContain(item => item.ExpectedDate != null); result.Items.ShouldNotContain(item => item.ExpectedDate != null);
} }
[Fact]
public async Task Filter_Keywords_Basic()
{
await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = InitializeVoiceWorkSearchProvider(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{
Criteria = new()
{
Keywords = "ASMR"
}
};
var result = await provider.SearchAsync(options);
result.Items.Length.ShouldBe(3);
result.TotalItems.ShouldBe(3);
result.Items.ShouldAllBe(item => item.Tags.Any(tag => tag.Name == "ASMR") || item.ProductName.Contains("ASMR") || (item.Description ?? string.Empty).Contains("ASMR"));
}
[Fact]
public async Task Filter_Keywords_Not_Good()
{
await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = InitializeVoiceWorkSearchProvider(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{
Criteria = new()
{
Keywords = "ASMR -Good"
}
};
var result = await provider.SearchAsync(options);
result.Items.Length.ShouldBe(2);
result.TotalItems.ShouldBe(2);
result.Items.ShouldAllBe(item => item.Tags.Any(tag => tag.Name == "ASMR") || item.ProductName.Contains("ASMR") || (item.Description ?? string.Empty).Contains("ASMR"));
result.Items.ShouldAllBe(item => !item.Tags.Any(tag => tag.Name == "Good") || !item.ProductName.Contains("Good") || !(item.Description ?? string.Empty).Contains("Good"));
}
[Fact]
public async Task Filter_Keywords_Dreams_And_Amazing_Or_Favorite()
{
await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = InitializeVoiceWorkSearchProvider(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{
Criteria = new()
{
Keywords = "Dreams + (Amazing|Favorite)"
}
};
var result = await provider.SearchAsync(options);
result.Items.Length.ShouldBe(2);
result.TotalItems.ShouldBe(2);
result.Items
.OrderBy(item => item.ProductId)
.Select(item => item.ProductId)
.ShouldBe(["RJ0000002", "RJ0000005"]);
}
[Fact]
public async Task Filter_Keywords_Phrase_Search()
{
await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = InitializeVoiceWorkSearchProvider(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{
Criteria = new()
{
Keywords = "\"All Your Favorite\""
}
};
var result = await provider.SearchAsync(options);
result.Items.Length.ShouldBe(1);
result.TotalItems.ShouldBe(1);
result.Items.ShouldAllBe(item => (item.Description ?? string.Empty).Contains("All Your Favorite", StringComparison.OrdinalIgnoreCase));
}
} }