diff --git a/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs index 4299716..8d6c32f 100644 --- a/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs @@ -20,8 +20,9 @@ public sealed class VoiceWorkSearchConfiguration : IEntityTypeConfiguration x.SearchText) - // .HasDatabaseName("FT_SearchText") - // .HasMethod("FULLTEXT"); + builder.HasIndex(x => x.SearchText) + .IsFullText() + .HasDatabaseName("FT_SearchText"); + //.HasMethod("FULLTEXT"); } } \ No newline at end of file diff --git a/JSMR.Infrastructure/Data/Repositories/VoiceWorks/IVoiceWorkFullTextSearch.cs b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/IVoiceWorkFullTextSearch.cs new file mode 100644 index 0000000..a5b9da3 --- /dev/null +++ b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/IVoiceWorkFullTextSearch.cs @@ -0,0 +1,6 @@ +namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks; + +public interface IVoiceWorkFullTextSearch +{ + IQueryable MatchingIds(AppDbContext context, string searchText); +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs new file mode 100644 index 0000000..3ca20fe --- /dev/null +++ b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; + +namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks; + +public class MySqlVoiceWorkFullTextSearch : IVoiceWorkFullTextSearch +{ + public IQueryable MatchingIds(AppDbContext context, string searchText) => + context.VoiceWorkSearches + .Where(v => EF.Functions.Match(v.SearchText, searchText, MySqlMatchSearchMode.Boolean) > 0) + .Select(v => v.VoiceWorkId); +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Data/Repositories/VoiceWorks/VoiceWorkSearchProvider.cs b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/VoiceWorkSearchProvider.cs index 1ed4933..a1fb0cf 100644 --- a/JSMR.Infrastructure/Data/Repositories/VoiceWorks/VoiceWorkSearchProvider.cs +++ b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/VoiceWorkSearchProvider.cs @@ -18,15 +18,15 @@ public class VoiceWorkQuery //public VoiceWorkSearch? VoiceWorkSearch { get; init; } } -public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider, IVoiceWorkSearchProvider +public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSearch fullTextSearch) : SearchProvider, IVoiceWorkSearchProvider { protected override IQueryable 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 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 x.VoiceWork.Downloads <= criteria.MaxDownloads.Value); + return filteredQuery; + } + + private IQueryable ApplyKeywordsFilter(IQueryable 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 ApplyCircleStatusFilter(IQueryable 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 ApplyTagStatusFilter(IQueryable query, VoiceWorkSearchCriteria criteria) @@ -155,7 +175,7 @@ public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider FilterTagIds(IQueryable filteredQuery, VoiceWorkSearchCriteria criteria) + private IQueryable ApplyTagIdsFilter(IQueryable filteredQuery, VoiceWorkSearchCriteria criteria) { if (criteria.TagIds.Length == 0) return filteredQuery; @@ -195,7 +215,7 @@ public class VoiceWorkSearchProvider(AppDbContext context) : SearchProvider FilterCreatorIds(IQueryable filteredQuery, VoiceWorkSearchCriteria criteria) + private IQueryable ApplyCreatorIdsFilter(IQueryable filteredQuery, VoiceWorkSearchCriteria criteria) { if (criteria.CreatorIds.Length == 0) return filteredQuery; diff --git a/JSMR.Infrastructure/JSMR.Infrastructure.csproj b/JSMR.Infrastructure/JSMR.Infrastructure.csproj index 634d068..649c1bb 100644 --- a/JSMR.Infrastructure/JSMR.Infrastructure.csproj +++ b/JSMR.Infrastructure/JSMR.Infrastructure.csproj @@ -10,6 +10,8 @@ + + diff --git a/JSMR.Tests/Fixtures/MariaDbFixture.cs b/JSMR.Tests/Fixtures/MariaDbFixture.cs index d68e5c1..194231e 100644 --- a/JSMR.Tests/Fixtures/MariaDbFixture.cs +++ b/JSMR.Tests/Fixtures/MariaDbFixture.cs @@ -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); } diff --git a/JSMR.Tests/Fixtures/VoiceWorkSearchProviderFixture.cs b/JSMR.Tests/Fixtures/VoiceWorkSearchProviderFixture.cs index 29016a6..6435500 100644 --- a/JSMR.Tests/Fixtures/VoiceWorkSearchProviderFixture.cs +++ b/JSMR.Tests/Fixtures/VoiceWorkSearchProviderFixture.cs @@ -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 } ); + // + 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(); } } \ No newline at end of file diff --git a/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs b/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs index fb40fa6..392717f 100644 --- a/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs +++ b/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs @@ -9,11 +9,19 @@ namespace JSMR.Tests.Integration; public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture) : IClassFixture { + 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() { @@ -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() { @@ -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() { @@ -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() + { + 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() + { + 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() + { + 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() + { + 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)); + } } \ No newline at end of file