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
// Pomelo supports .HasMethod("FULLTEXT"). If your version doesn't, add it in a migration SQL.
//builder.HasIndex(x => x.SearchText)
// .HasDatabaseName("FT_SearchText")
builder.HasIndex(x => x.SearchText)
.IsFullText()
.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 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()
{
return
from voiceWork in context.VoiceWorks
join englishVoiceWork in context.EnglishVoiceWorks on voiceWork.VoiceWorkId equals englishVoiceWork.VoiceWorkId into ps
from voiceWork in context.VoiceWorks.AsNoTracking()
join englishVoiceWork in context.EnglishVoiceWorks.AsNoTracking() on voiceWork.VoiceWorkId equals englishVoiceWork.VoiceWorkId into ps
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()
//join voiceWorkLocalization in context.VoiceWorkLocalizations on voiceWork.VoiceWorkId equals voiceWorkLocalization.VoiceWorkId into vwl
//from voiceWorkLocalization in vwl.DefaultIfEmpty()
@@ -44,10 +44,12 @@ public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider<Voic
{
IQueryable<VoiceWorkQuery> filteredQuery = query;
// TODO: Full Text Search implementation
//filteredQuery = FuzzyKeywordSearch(filteredQuery, searchProperties.Keywords);
//filteredQuery = FuzzyTitleSearch(filteredQuery, searchProperties.Title);
//filteredQuery = FuzzyCircleSearch(filteredQuery, searchProperties.Circle);
filteredQuery = ApplyKeywordsFilter(filteredQuery, criteria);
filteredQuery = ApplyCircleStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
//filteredQuery = FilterCreatorStatus(filteredQuery, searchProperties.CreatorStatus, _voiceWorkContext);
filteredQuery = ApplyTagIdsFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorIdsFilter(filteredQuery, criteria);
switch (criteria.SaleStatus)
{
@@ -86,25 +88,43 @@ public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider<Voic
if (criteria.MaxDownloads is not null)
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)
{
case CircleStatus.NotBlacklisted:
filteredQuery = filteredQuery.Where(x => x.Circle.Blacklisted == false);
break;
case CircleStatus.Favorited:
filteredQuery = filteredQuery.Where(x => x.Circle.Favorite);
query = query.Where(q =>
!context.Circles.Any(c => c.CircleId == q.VoiceWork.CircleId && c.Blacklisted));
break;
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;
}
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
//filteredQuery = FilterCreatorStatus(filteredQuery, searchProperties.CreatorStatus, _voiceWorkContext);
filteredQuery = FilterTagIds(filteredQuery, criteria);
filteredQuery = FilterCreatorIds(filteredQuery, criteria);
return filteredQuery;
return query;
}
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)
return filteredQuery;
@@ -195,7 +215,7 @@ public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider<Voic
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)
return filteredQuery;

View File

@@ -10,6 +10,8 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" 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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -52,6 +52,43 @@ public class VoiceWorkSearchProviderFixture : MariaDbFixture
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(
new() { CreatorId = 1, Name = "陽向葵ゅか", Favorite = true },
new() { CreatorId = 2, Name = "秋野かえで" },
@@ -60,6 +97,15 @@ public class VoiceWorkSearchProviderFixture : MariaDbFixture
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();
}
}

View File

@@ -9,11 +9,19 @@ namespace JSMR.Tests.Integration;
public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture) : IClassFixture<VoiceWorkSearchProviderFixture>
{
private VoiceWorkSearchProvider InitializeVoiceWorkSearchProvider(AppDbContext context)
{
MySqlVoiceWorkFullTextSearch fullTextSearch = new();
VoiceWorkSearchProvider provider = new(context, fullTextSearch);
return provider;
}
[Fact]
public async Task Filter_Default()
{
await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = new(context);
VoiceWorkSearchProvider provider = InitializeVoiceWorkSearchProvider(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{
@@ -21,11 +29,7 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{
SaleStatus = SaleStatus.Available,
CircleStatus = CircleStatus.NotBlacklisted
},
SortOptions =
[
new(VoiceWorkSortField.ReleaseDate, Application.Common.Search.SortDirection.Descending)
]
}
};
var result = await provider.SearchAsync(options);
@@ -40,7 +44,7 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
public async Task Filter_Upcoming_Favorite()
{
await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = new(context);
VoiceWorkSearchProvider provider = InitializeVoiceWorkSearchProvider(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{
@@ -48,11 +52,7 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{
SaleStatus = SaleStatus.Upcoming,
CircleStatus = CircleStatus.Favorited
},
SortOptions =
[
new(VoiceWorkSortField.ReleaseDate, Application.Common.Search.SortDirection.Descending)
]
}
};
var result = await provider.SearchAsync(options);
@@ -67,7 +67,7 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
public async Task Filter_Availble_Blacklisted()
{
await using AppDbContext context = fixture.CreateDbContext();
VoiceWorkSearchProvider provider = new(context);
VoiceWorkSearchProvider provider = InitializeVoiceWorkSearchProvider(context);
var options = new SearchOptions<VoiceWorkSearchCriteria, VoiceWorkSortField>()
{
@@ -75,11 +75,7 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{
SaleStatus = SaleStatus.Available,
CircleStatus = CircleStatus.Blacklisted
},
SortOptions =
[
new(VoiceWorkSortField.ReleaseDate, Application.Common.Search.SortDirection.Descending)
]
}
};
var result = await provider.SearchAsync(options);
@@ -89,4 +85,93 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
result.Items.ShouldAllBe(item => item.SalesDate != 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));
}
}