diff --git a/Backend.Tests/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs index e2bb8978b7..c62bf251ea 100644 --- a/Backend.Tests/Controllers/AudioControllerTests.cs +++ b/Backend.Tests/Controllers/AudioControllerTests.cs @@ -35,9 +35,11 @@ public void Dispose() public void Setup() { _projRepo = new ProjectRepositoryMock(); + var semDomCountRepo = new SemanticDomainCountRepositoryMock(); _wordRepo = new WordRepositoryMock(); _permissionService = new PermissionServiceMock(); - _wordService = new WordService(_wordRepo); + var semDomCountService = new SemanticDomainCountService(semDomCountRepo); + _wordService = new WordService(_wordRepo, semDomCountService); _audioController = new AudioController(_wordRepo, _wordService, _permissionService); _projId = _projRepo.Create(new Project { Name = "AudioControllerTests" }).Result!.Id; diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index a4b49aa7d7..27d5d8629e 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -46,16 +46,18 @@ public void Setup() { _projRepo = new ProjectRepositoryMock(); var semDomRepo = new SemanticDomainRepositoryMock(); + var semDomCountRepo = new SemanticDomainCountRepositoryMock(); _speakerRepo = new SpeakerRepositoryMock(); _wordRepo = new WordRepositoryMock(); var ackService = new AcknowledgmentServiceMock(); _liftService = new LiftService(); var notifyService = new HubContextMock(); var permissionService = new PermissionServiceMock(); - _wordService = new WordService(_wordRepo); + var semDomCountService = new SemanticDomainCountService(semDomCountRepo); + _wordService = new WordService(_wordRepo, semDomCountService); var logger = new LoggerMock(); _liftController = new LiftController(_projRepo, semDomRepo, _speakerRepo, _wordRepo, ackService, - _liftService, notifyService, permissionService, logger); + _liftService, notifyService, permissionService, _wordService, logger); _projId = _projRepo.Create(new Project { Name = ProjName }).Result!.Id; _file = new FormFile(_stream, 0, _stream.Length, "Name", FileName); diff --git a/Backend.Tests/Controllers/MergeControllerTests.cs b/Backend.Tests/Controllers/MergeControllerTests.cs index a64c5555e3..36992b6ee4 100644 --- a/Backend.Tests/Controllers/MergeControllerTests.cs +++ b/Backend.Tests/Controllers/MergeControllerTests.cs @@ -38,9 +38,11 @@ public void Setup() new ServiceCollection().AddMemoryCache().BuildServiceProvider().GetRequiredService(); _mergeBlacklistRepo = new MergeBlacklistRepositoryMock(); _mergeGraylistRepo = new MergeGraylistRepositoryMock(); + var semDomCountRepo = new SemanticDomainCountRepositoryMock(); _wordRepo = new WordRepositoryMock(); var ackService = new AcknowledgmentServiceMock(); - _wordService = new WordService(_wordRepo); + var semDomCountService = new SemanticDomainCountService(semDomCountRepo); + _wordService = new WordService(_wordRepo, semDomCountService); _mergeService = new MergeService(_cache, _mergeBlacklistRepo, _mergeGraylistRepo, _wordRepo, _wordService); var notifyService = new HubContextMock(); var permissionService = new PermissionServiceMock(); diff --git a/Backend.Tests/Controllers/WordControllerTests.cs b/Backend.Tests/Controllers/WordControllerTests.cs index 4125719b44..1001ed25b9 100644 --- a/Backend.Tests/Controllers/WordControllerTests.cs +++ b/Backend.Tests/Controllers/WordControllerTests.cs @@ -31,10 +31,12 @@ public void Dispose() [SetUp] public void Setup() { + var semDomCountRepo = new SemanticDomainCountRepositoryMock(); _wordRepo = new WordRepositoryMock(); - _wordService = new WordService(_wordRepo); + var semDomCountService = new SemanticDomainCountService(semDomCountRepo); + _wordService = new WordService(_wordRepo, semDomCountService); _permissionService = new PermissionServiceMock(); - _wordController = new WordController(_wordRepo, _wordService, _permissionService); + _wordController = new WordController(semDomCountRepo, _wordRepo, _wordService, _permissionService); } [Test] diff --git a/Backend.Tests/Mocks/SemanticDomainCountRepositoryMock.cs b/Backend.Tests/Mocks/SemanticDomainCountRepositoryMock.cs new file mode 100644 index 0000000000..5e3881f005 --- /dev/null +++ b/Backend.Tests/Mocks/SemanticDomainCountRepositoryMock.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BackendFramework.Interfaces; +using BackendFramework.Models; + +namespace Backend.Tests.Mocks +{ + public sealed class SemanticDomainCountRepositoryMock : ISemanticDomainCountRepository + { + private readonly List _counts = []; + + public Task GetCount(string projectId, string domainId) + { + var count = _counts.FirstOrDefault(c => c.ProjectId == projectId && c.DomainId == domainId); + return Task.FromResult(count?.Count ?? 0); + } + + public Task> GetAllCounts(string projectId) + { + var counts = _counts.Where(c => c.ProjectId == projectId).Select(c => c.Clone()).ToList(); + return Task.FromResult(counts); + } + + public Task Increment(string projectId, string domainId, int amount = 1) + { + var count = _counts.FirstOrDefault(c => c.ProjectId == projectId && c.DomainId == domainId); + if (count is null) + { + count = new(projectId, domainId, amount) { Id = Util.RandString() }; + _counts.Add(count); + } + else + { + count.Count += amount; + } + return Task.FromResult(count.Count); + } + + public Task DeleteAllCounts(string projectId) + { + return Task.FromResult(_counts.RemoveAll(c => c.ProjectId == projectId)); + } + } +} diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs index 11936493ac..275866807c 100644 --- a/Backend.Tests/Mocks/WordRepositoryMock.cs +++ b/Backend.Tests/Mocks/WordRepositoryMock.cs @@ -162,12 +162,5 @@ public Task Add(Word word) _words.Add(word.Clone()); return Task.FromResult(word); } - - public Task CountFrontierWordsWithDomain(string projectId, string domainId) - { - var count = _frontier.Count( - w => w.ProjectId == projectId && w.Senses.Any(s => s.SemanticDomains.Any(sd => sd.Id == domainId))); - return Task.FromResult(count); - } } } diff --git a/Backend.Tests/Services/MergeServiceTests.cs b/Backend.Tests/Services/MergeServiceTests.cs index f3c6366eeb..08f965447a 100644 --- a/Backend.Tests/Services/MergeServiceTests.cs +++ b/Backend.Tests/Services/MergeServiceTests.cs @@ -30,8 +30,10 @@ public void Setup() new ServiceCollection().AddMemoryCache().BuildServiceProvider().GetRequiredService(); _mergeBlacklistRepo = new MergeBlacklistRepositoryMock(); _mergeGraylistRepo = new MergeGraylistRepositoryMock(); + var semDomCountRepo = new SemanticDomainCountRepositoryMock(); _wordRepo = new WordRepositoryMock(); - _wordService = new WordService(_wordRepo); + var semDomCountService = new SemanticDomainCountService(semDomCountRepo); + _wordService = new WordService(_wordRepo, semDomCountService); _mergeService = new MergeService(_cache, _mergeBlacklistRepo, _mergeGraylistRepo, _wordRepo, _wordService); } diff --git a/Backend.Tests/Services/SemanticDomainCountServiceTests.cs b/Backend.Tests/Services/SemanticDomainCountServiceTests.cs new file mode 100644 index 0000000000..9f53fb38c0 --- /dev/null +++ b/Backend.Tests/Services/SemanticDomainCountServiceTests.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Backend.Tests.Mocks; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using BackendFramework.Services; +using NUnit.Framework; + +namespace Backend.Tests.Services +{ + internal sealed class SemanticDomainCountServiceTests + { + private ISemanticDomainCountRepository _countRepo = null!; + private ISemanticDomainCountService _countService = null!; + + private const string ProjId = "CountServiceTestProjId"; + private const string DomainId1 = "1.1"; + private const string DomainId2 = "2.1"; + + [SetUp] + public void Setup() + { + _countRepo = new SemanticDomainCountRepositoryMock(); + _countService = new SemanticDomainCountService(_countRepo); + } + + [Test] + public async Task TestUpdateCountsForWord() + { + var word = new Word + { + ProjectId = ProjId, + Senses = [new() { SemanticDomains = [new() { Id = DomainId1 }, new() { Id = DomainId2 }] }], + }; + + await _countService.UpdateCountsForWord(word); + + var count1 = await _countRepo.GetCount(ProjId, DomainId1); + var count2 = await _countRepo.GetCount(ProjId, DomainId2); + + Assert.That(count1, Is.EqualTo(1)); + Assert.That(count2, Is.EqualTo(1)); + } + + [Test] + public async Task TestUpdateCountsForWords() + { + var words = new List + { + new() + { + ProjectId = ProjId, + Senses = [new() { SemanticDomains = [new() { Id = DomainId1 }] }] + }, + new() + { + ProjectId = ProjId, + Senses = [new() { SemanticDomains = [new() { Id = DomainId1 }, new() { Id = DomainId2 }] }], + } + }; + + await _countService.UpdateCountsForWords(words); + + var count1 = await _countRepo.GetCount(ProjId, DomainId1); + var count2 = await _countRepo.GetCount(ProjId, DomainId2); + + Assert.That(count1, Is.EqualTo(2)); + Assert.That(count2, Is.EqualTo(1)); + } + + [Test] + public async Task TestUpdateCountsAfterWordUpdate() + { + var oldWord = new Word + { + ProjectId = ProjId, + Senses = [new() { SemanticDomains = [new() { Id = DomainId1 }, new() { Id = DomainId2 }] }], + }; + + var newWord = new Word + { + ProjectId = ProjId, + Senses = [new() { SemanticDomains = [new() { Id = DomainId1 }] }] + }; + + // Start with counts from old word + await _countService.UpdateCountsForWord(oldWord); + + // Update counts + await _countService.UpdateCountsAfterWordUpdate(oldWord, newWord); + + var count1 = await _countRepo.GetCount(ProjId, DomainId1); + var count2 = await _countRepo.GetCount(ProjId, DomainId2); + + Assert.That(count1, Is.EqualTo(1)); // Unchanged + Assert.That(count2, Is.EqualTo(0)); // Decremented by 1 + } + + [Test] + public async Task TestUpdateCountsForWordDeletion() + { + var word = new Word + { + ProjectId = ProjId, + Senses = [new() { SemanticDomains = [new() { Id = DomainId1 }, new() { Id = DomainId2 }] }], + }; + + // First add the word to get initial counts + await _countService.UpdateCountsForWord(word); + + var count1Before = await _countRepo.GetCount(ProjId, DomainId1); + var count2Before = await _countRepo.GetCount(ProjId, DomainId2); + Assert.That(count1Before, Is.EqualTo(1)); + Assert.That(count2Before, Is.EqualTo(1)); + + // Now delete it + await _countService.UpdateCountsForWordDeletion(word); + + var count1After = await _countRepo.GetCount(ProjId, DomainId1); + var count2After = await _countRepo.GetCount(ProjId, DomainId2); + Assert.That(count1After, Is.EqualTo(0)); + Assert.That(count2After, Is.EqualTo(0)); + } + } +} diff --git a/Backend.Tests/Services/StatisticsServiceTests.cs b/Backend.Tests/Services/StatisticsServiceTests.cs index 789f884c2b..330b95d715 100644 --- a/Backend.Tests/Services/StatisticsServiceTests.cs +++ b/Backend.Tests/Services/StatisticsServiceTests.cs @@ -11,19 +11,20 @@ namespace Backend.Tests.Services { internal sealed class StatisticsServiceTests { - private ISemanticDomainRepository _domainRepo = null!; + private ISemanticDomainRepository _semDomRepo = null!; + private ISemanticDomainCountRepository _semDomCountRepo = null!; private IUserRepository _userRepo = null!; private IWordRepository _wordRepo = null!; private IStatisticsService _statsService = null!; private const string ProjId = "StatsServiceTestProjId"; private const string SemDomId = "StatsServiceTestSemDomId"; - private readonly List NonEmptySchedule = new() { DateTime.Now }; - private readonly List TreeNodes = new() { new(new SemanticDomain { Id = SemDomId }) }; + private readonly List NonEmptySchedule = [DateTime.Now]; + private readonly List TreeNodes = [new(new SemanticDomain { Id = SemDomId })]; private static Sense GetSenseWithDomain(string semDomId = SemDomId) { - return new() { SemanticDomains = new() { new() { Id = semDomId } } }; + return new() { SemanticDomains = [new() { Id = semDomId }] }; } private static User GetUserWithProjId(string projId = ProjId) { @@ -37,7 +38,7 @@ private static Word GetWordWithDomain(string semDomId = SemDomId) { Id = Util.RandString(10), ProjectId = ProjId, - Senses = new() { GetSenseWithDomain(semDomId) }, + Senses = [GetSenseWithDomain(semDomId)], Vernacular = Util.RandString(10) }; } @@ -45,17 +46,18 @@ private static Word GetWordWithDomain(string semDomId = SemDomId) [SetUp] public void Setup() { - _domainRepo = new SemanticDomainRepositoryMock(); + _semDomRepo = new SemanticDomainRepositoryMock(); + _semDomCountRepo = new SemanticDomainCountRepositoryMock(); _userRepo = new UserRepositoryMock(); _wordRepo = new WordRepositoryMock(); - _statsService = new StatisticsService(_wordRepo, _domainRepo, _userRepo); + _statsService = new StatisticsService(_semDomRepo, _semDomCountRepo, _userRepo, _wordRepo); } [Test] public void GetSemanticDomainCountsTestNullDomainList() { - // Add a word to the database and leave the semantic domain list null - _wordRepo.AddFrontier(GetWordWithDomain()); + // Add a domain count to the database and leave the semantic domain list null + _semDomCountRepo.Increment(ProjId, SemDomId).Wait(); var result = _statsService.GetSemanticDomainCounts(ProjId, "").Result; Assert.That(result, Is.Empty); @@ -64,30 +66,31 @@ public void GetSemanticDomainCountsTestNullDomainList() [Test] public void GetSemanticDomainCountsTestEmptyDomainList() { - // Add to the database a word and an empty list of semantic domains - ((SemanticDomainRepositoryMock)_domainRepo).SetNextResponse(new List()); - _wordRepo.AddFrontier(GetWordWithDomain()); + // Add to the database an empty list of semantic domains and a domain count + ((SemanticDomainRepositoryMock)_semDomRepo).SetNextResponse(new List()); + _semDomCountRepo.Increment(ProjId, SemDomId).Wait(); var result = _statsService.GetSemanticDomainCounts(ProjId, "").Result; Assert.That(result, Is.Empty); } [Test] - public void GetSemanticDomainCountsTestEmptyFrontier() + public void GetSemanticDomainCountsTestEmptyCounts() { // Add to the database a semantic domain but no word - ((SemanticDomainRepositoryMock)_domainRepo).SetNextResponse(TreeNodes); + ((SemanticDomainRepositoryMock)_semDomRepo).SetNextResponse(TreeNodes); var result = _statsService.GetSemanticDomainCounts(ProjId, "").Result; - Assert.That(result, Is.Empty); + Assert.That(result, Is.Not.Empty); + Assert.That(result.First().Count, Is.EqualTo(0)); } [Test] public void GetSemanticDomainCountsTestIdMismatch() { - // Add to the database a semantic domain and a word with a different semantic domain - ((SemanticDomainRepositoryMock)_domainRepo).SetNextResponse(TreeNodes); - _wordRepo.AddFrontier(GetWordWithDomain("different-id")); + // Add to the database a semantic domain and count with a different domain id + ((SemanticDomainRepositoryMock)_semDomRepo).SetNextResponse(TreeNodes); + _semDomCountRepo.Increment(ProjId, "DifferentId").Wait(); var result = _statsService.GetSemanticDomainCounts(ProjId, "").Result; Assert.That(result, Has.Count.EqualTo(1)); @@ -97,9 +100,9 @@ public void GetSemanticDomainCountsTestIdMismatch() [Test] public void GetSemanticDomainCountsTestIdMatch() { - // Add to the database a semantic domain and a word with the same semantic domain - ((SemanticDomainRepositoryMock)_domainRepo).SetNextResponse(TreeNodes); - _wordRepo.AddFrontier(GetWordWithDomain()); + // Add to the database a semantic domain and a corresponding count + ((SemanticDomainRepositoryMock)_semDomRepo).SetNextResponse(TreeNodes); + _semDomCountRepo.Increment(ProjId, SemDomId).Wait(); var result = _statsService.GetSemanticDomainCounts(ProjId, "").Result; Assert.That(result, Has.Count.EqualTo(1)); diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index bc92f060ad..bb00374901 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -19,8 +19,10 @@ internal sealed class WordServiceTests [SetUp] public void Setup() { + var semDomCountRepo = new SemanticDomainCountRepositoryMock(); _wordRepo = new WordRepositoryMock(); - _wordService = new WordService(_wordRepo); + var semDomCountService = new SemanticDomainCountService(semDomCountRepo); + _wordService = new WordService(_wordRepo, semDomCountService); } [Test] diff --git a/Backend/Controllers/LiftController.cs b/Backend/Controllers/LiftController.cs index 34ea2734e4..43c393212a 100644 --- a/Backend/Controllers/LiftController.cs +++ b/Backend/Controllers/LiftController.cs @@ -25,7 +25,7 @@ namespace BackendFramework.Controllers public class LiftController(IProjectRepository projRepo, ISemanticDomainRepository semDomRepo, ISpeakerRepository speakerRepo, IWordRepository wordRepo, IAcknowledgmentService ackService, ILiftService liftService, IHubContext notifyService, IPermissionService permissionService, - ILogger logger) : Controller + IWordService wordService, ILogger logger) : Controller { private readonly IProjectRepository _projRepo = projRepo; private readonly ISemanticDomainRepository _semDomRepo = semDomRepo; @@ -35,6 +35,7 @@ public class LiftController(IProjectRepository projRepo, ISemanticDomainReposito private readonly ILiftService _liftService = liftService; private readonly IHubContext _notifyService = notifyService; private readonly IPermissionService _permissionService = permissionService; + private readonly IWordService _wordService = wordService; private readonly ILogger _logger = logger; private const string otelTagName = "otel.LiftController"; @@ -110,7 +111,7 @@ public async Task DeleteFrontierAndFinishUploadLiftFile(string pr } // Delete all frontier words and load the LIFT data - await _wordRepo.DeleteAllFrontierWords(projectId); + await _wordService.ClearFrontier(projectId); return await FinishUploadLiftFile(projectId, userId, true); } @@ -262,8 +263,8 @@ private async Task AddImportToProject(string liftStoragePath, str int countWordsImported; // Sets the projectId of our parser to add words to that project - var liftMerger = _liftService.GetLiftImporterExporter( - projectId, proj.VernacularWritingSystem.Bcp47, _wordRepo); + var liftMerger = + _liftService.GetLiftImporterExporter(projectId, proj.VernacularWritingSystem.Bcp47, _wordService); var importedAnalysisWritingSystems = new List(); var doesImportHaveDefinitions = false; var doesImportHaveGrammaticalInfo = false; diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index f0b37b7796..e1b3c0d7a9 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -13,9 +13,10 @@ namespace BackendFramework.Controllers [Authorize] [Produces("application/json")] [Route("v1/projects/{projectId}/words")] - public class WordController( - IWordRepository wordRepo, IWordService wordService, IPermissionService permissionService) : Controller + public class WordController(ISemanticDomainCountRepository semDomCountRepo, IWordRepository wordRepo, + IWordService wordService, IPermissionService permissionService) : Controller { + private readonly ISemanticDomainCountRepository _semDomCountRepo = semDomCountRepo; private readonly IWordRepository _wordRepo = wordRepo; private readonly IPermissionService _permissionService = permissionService; private readonly IWordService _wordService = wordService; @@ -303,7 +304,7 @@ public async Task RevertWords( return Ok(updates); } - /// Get the count of frontier words with senses in a specific semantic domain + /// Get the count of frontier word senses in a specific semantic domain /// An integer count [HttpGet("domainwordcount/{domainId}", Name = "GetDomainWordCount")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(int))] @@ -317,7 +318,7 @@ public async Task GetDomainWordCount(string projectId, string dom return Forbid(); } - return Ok(await _wordRepo.CountFrontierWordsWithDomain(projectId, domainId)); + return Ok(await _semDomCountRepo.GetCount(projectId, domainId)); } } } diff --git a/Backend/Interfaces/ILiftService.cs b/Backend/Interfaces/ILiftService.cs index 3328b0c80d..413f99d2e1 100644 --- a/Backend/Interfaces/ILiftService.cs +++ b/Backend/Interfaces/ILiftService.cs @@ -8,7 +8,7 @@ namespace BackendFramework.Interfaces { public interface ILiftService : IDisposable { - ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IWordRepository wordRepo); + ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IWordService wordService); Task LdmlImport(string dirPath, IProjectRepository projRepo, Project project); Task LiftExport(string projectId, IProjectRepository projRepo, ISemanticDomainRepository semDomRepo, ISpeakerRepository speakerRepo, IWordRepository wordRepo); diff --git a/Backend/Interfaces/ISemanticDomainCountRepository.cs b/Backend/Interfaces/ISemanticDomainCountRepository.cs new file mode 100644 index 0000000000..7167331e59 --- /dev/null +++ b/Backend/Interfaces/ISemanticDomainCountRepository.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BackendFramework.Models; + +namespace BackendFramework.Interfaces +{ + public interface ISemanticDomainCountRepository + { + Task GetCount(string projectId, string domainId); + Task> GetAllCounts(string projectId); + Task Increment(string projectId, string domainId, int amount = 1); + Task DeleteAllCounts(string projectId); + } +} diff --git a/Backend/Interfaces/ISemanticDomainCountService.cs b/Backend/Interfaces/ISemanticDomainCountService.cs new file mode 100644 index 0000000000..aa3f1b48d8 --- /dev/null +++ b/Backend/Interfaces/ISemanticDomainCountService.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BackendFramework.Models; + +namespace BackendFramework.Interfaces +{ + public interface ISemanticDomainCountService + { + Task ClearCountsForProject(string projectId); + Task UpdateCountsForWord(Word word); + Task UpdateCountsForWords(List words); + Task UpdateCountsAfterWordUpdate(Word oldWord, Word newWord); + Task UpdateCountsForWordDeletion(Word word); + } +} diff --git a/Backend/Interfaces/IWordRepository.cs b/Backend/Interfaces/IWordRepository.cs index 1aabed7bbd..e322b184d7 100644 --- a/Backend/Interfaces/IWordRepository.cs +++ b/Backend/Interfaces/IWordRepository.cs @@ -25,6 +25,5 @@ public interface IWordRepository Task AddFrontier(Word word); Task> AddFrontier(List words); Task DeleteFrontier(string projectId, string wordId, string? audioFileName = null); - Task CountFrontierWordsWithDomain(string projectId, string domainId); } } diff --git a/Backend/Interfaces/IWordService.cs b/Backend/Interfaces/IWordService.cs index ad878d63a7..5d535231bf 100644 --- a/Backend/Interfaces/IWordService.cs +++ b/Backend/Interfaces/IWordService.cs @@ -12,6 +12,7 @@ public interface IWordService Task DeleteAudio(string projectId, string userId, string wordId, string fileName); Task DeleteFrontierWord(string projectId, string userId, string wordId); Task RestoreFrontierWords(string projectId, List wordIds); + Task ClearFrontier(string projectId); Task FindContainingWord(Word word); } } diff --git a/Backend/Models/ProjectSemanticDomainCount.cs b/Backend/Models/ProjectSemanticDomainCount.cs new file mode 100644 index 0000000000..6eced2554e --- /dev/null +++ b/Backend/Models/ProjectSemanticDomainCount.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BackendFramework.Models +{ + /// + /// Tracks the number of senses in each semantic domain for a project. + /// Used for performant statistics queries without scanning the entire Frontier collection. + /// + public class ProjectSemanticDomainCount + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } + + [Required] + [BsonElement("projectId")] + public string ProjectId { get; set; } + + [Required] + [BsonElement("domainId")] + public string DomainId { get; set; } + + [Required] + [BsonElement("count")] + public int Count { get; set; } + + public ProjectSemanticDomainCount() + { + Id = ""; + ProjectId = ""; + DomainId = ""; + Count = 0; + } + + public ProjectSemanticDomainCount(string projectId, string domainId, int count = 0) : this() + { + ProjectId = projectId; + DomainId = domainId; + Count = count; + } + + public ProjectSemanticDomainCount Clone() + { + return new() + { + Id = Id, + ProjectId = ProjectId, + DomainId = DomainId, + Count = Count + }; + } + } +} diff --git a/Backend/Repositories/SemanticDomainCountRepository.cs b/Backend/Repositories/SemanticDomainCountRepository.cs new file mode 100644 index 0000000000..a425677572 --- /dev/null +++ b/Backend/Repositories/SemanticDomainCountRepository.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using BackendFramework.Otel; +using MongoDB.Driver; + +namespace BackendFramework.Repositories +{ + /// Atomic database functions for s. + [ExcludeFromCodeCoverage] + public class SemanticDomainCountRepository : ISemanticDomainCountRepository + { + private readonly IMongoCollection _counts; + + private const string otelTagName = "otel.SemanticDomainCountRepository"; + + public SemanticDomainCountRepository(IMongoDbContext dbContext) + { + _counts = dbContext.Db.GetCollection("SemanticDomainCountCollection"); + + // Create unique compound index on (ProjectId, DomainId) to optimize queries and enforce uniqueness + var indexKeys = + Builders.IndexKeys.Ascending(p => p.ProjectId).Ascending(p => p.DomainId); + var indexModel = new CreateIndexModel(indexKeys, new() { Unique = true }); + + // Create the index if it doesn't already exist + _counts.Indexes.CreateOne(indexModel); + } + + private static FilterDefinition ProjectFilter(string projectId) + { + var filterDef = new FilterDefinitionBuilder(); + return filterDef.Eq(c => c.ProjectId, projectId); + } + + private static FilterDefinition ProjectDomainFilter( + string projectId, string domainId) + { + var filterDef = new FilterDefinitionBuilder(); + return filterDef.And(filterDef.Eq(c => c.ProjectId, projectId), filterDef.Eq(c => c.DomainId, domainId)); + } + + /// Gets the count for a specific semantic domain in a project + public async Task GetCount(string projectId, string domainId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting semantic domain count"); + + var result = await _counts.FindAsync(ProjectDomainFilter(projectId, domainId)); + return (await result.FirstOrDefaultAsync())?.Count ?? 0; + } + + /// Gets all counts for a project + public async Task> GetAllCounts(string projectId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting all semantic domain counts"); + + return await _counts.Find(ProjectFilter(projectId)).ToListAsync(); + } + + /// Increments (or decrements if negative) the count for a semantic domain + /// the new count after incrementing + public async Task Increment(string projectId, string domainId, int amount = 1) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "incrementing semantic domain count"); + + var filter = ProjectDomainFilter(projectId, domainId); + + var updateDef = new UpdateDefinitionBuilder(); + var update = updateDef.Inc(c => c.Count, amount); + + var options = new FindOneAndUpdateOptions + { + IsUpsert = true, + ReturnDocument = ReturnDocument.After + }; + + var result = await _counts.FindOneAndUpdateAsync(filter, update, options); + return result?.Count ?? 0; + } + + /// Deletes all counts for a project + public async Task DeleteAllCounts(string projectId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting all semantic domain counts"); + + return (int)(await _counts.DeleteManyAsync(ProjectFilter(projectId))).DeletedCount; + } + } +} diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 1492677079..3948e0205f 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Threading.Tasks; using BackendFramework.Helper; using BackendFramework.Interfaces; @@ -297,23 +296,5 @@ public async Task> AddFrontier(List words) : await _frontier.FindOneAndDeleteAsync( GetProjectWordWithAudioFilter(projectId, wordId, audioFileName)); } - - /// - /// Counts the number of Frontier words that have the specified semantic domain. - /// - /// The project id - /// The semantic domain id - /// The count of words containing at least one sense with the specified domain. - public async Task CountFrontierWordsWithDomain(string projectId, string domainId) - { - using var activity = OtelService.StartActivityWithTag(otelTagName, "counting frontier words with domain"); - - var filterDef = new FilterDefinitionBuilder(); - var filter = filterDef.And( - filterDef.Eq(w => w.ProjectId, projectId), - filterDef.ElemMatch(w => w.Senses, s => s.SemanticDomains.Any(sd => sd.Id == domainId))); - - return (int)await _frontier.CountDocumentsAsync(filter); - } } } diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index 20c9493ff3..ac4ad85dad 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -687,9 +687,9 @@ private static void LdmlExport(string filePath, WritingSystem vernacularWS, List wsr.Save(); } - public ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IWordRepository wordRepo) + public ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IWordService wordService) { - return new LiftMerger(projectId, vernLang, wordRepo); + return new LiftMerger(projectId, vernLang, wordService); } private static void WriteRangeElement(XmlWriter liftRangesWriter, @@ -723,21 +723,14 @@ private static void WriteFormElement(XmlWriter liftRangesWriter, string element, liftRangesWriter.WriteEndElement(); // end element } - private sealed class LiftMerger : ILiftMerger + private sealed class LiftMerger(string projectId, string vernLang, IWordService wordService) : ILiftMerger { - private readonly string _projectId; + private readonly string _projectId = projectId; private readonly List _customSemDoms = []; - private readonly string _vernLang; - private readonly IWordRepository _wordRepo; + private readonly string _vernLang = vernLang; + private readonly IWordService _wordService = wordService; private readonly List _importEntries = []; - public LiftMerger(string projectId, string vernLang, IWordRepository wordRepo) - { - _projectId = projectId; - _vernLang = vernLang; - _wordRepo = wordRepo; - } - /// /// Check for any Definitions in the private field /// @@ -784,7 +777,7 @@ public List GetImportAnalysisWritingSystems() /// The words saved. public async Task> SaveImportEntries() { - var savedWords = new List(await _wordRepo.Create(_importEntries)); + var savedWords = new List(await _wordService.Create("", _importEntries)); _importEntries.Clear(); return savedWords; } diff --git a/Backend/Services/SemanticDomainCountService.cs b/Backend/Services/SemanticDomainCountService.cs new file mode 100644 index 0000000000..fa50321836 --- /dev/null +++ b/Backend/Services/SemanticDomainCountService.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using BackendFramework.Otel; + +namespace BackendFramework.Services +{ + /// Service for managing semantic domain sense counts + public class SemanticDomainCountService(ISemanticDomainCountRepository semDomCountRepo) : ISemanticDomainCountService + { + private readonly ISemanticDomainCountRepository _semDomCountRepo = semDomCountRepo; + + private const string otelTagName = "otel.SemanticDomainCountService"; + + /// Extracts domain counts from a word + private static Dictionary GetDomainCounts(Word word, Dictionary? domainCounts = null) + { + domainCounts ??= []; + foreach (var sense in word.Senses) + { + foreach (var domain in sense.SemanticDomains) + { + domainCounts[domain.Id] = domainCounts.GetValueOrDefault(domain.Id, 0) + 1; + } + } + return domainCounts; + } + + /// Clears all counts for a project + public async Task ClearCountsForProject(string projectId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "clearing counts for project"); + + await _semDomCountRepo.DeleteAllCounts(projectId); + } + + /// Updates counts when a new word is added + public async Task UpdateCountsForWord(Word word) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "updating counts for word"); + + var domainCounts = GetDomainCounts(word); + foreach (var entry in domainCounts) + { + await _semDomCountRepo.Increment(word.ProjectId, entry.Key, entry.Value); + } + } + + /// Updates counts when multiple new words are added + public async Task UpdateCountsForWords(List words) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "updating counts for words"); + + if (words.Count == 0) + { + return; + } + + var domainCounts = new Dictionary>(); + foreach (var word in words) + { + if (!domainCounts.TryGetValue(word.ProjectId, out var projectDomainCounts)) + { + projectDomainCounts = []; + domainCounts[word.ProjectId] = projectDomainCounts; + } + GetDomainCounts(word, projectDomainCounts); + } + + foreach (var projectEntry in domainCounts) + { + foreach (var domainEntry in projectEntry.Value) + { + await _semDomCountRepo.Increment(projectEntry.Key, domainEntry.Key, domainEntry.Value); + } + } + } + + /// Updates counts when a word is modified + public async Task UpdateCountsAfterWordUpdate(Word oldWord, Word newWord) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "updating counts after word update"); + + if (oldWord.ProjectId != newWord.ProjectId) + { + throw new System.ArgumentException("Old and new words must belong to the same project"); + } + + var oldDomains = GetDomainCounts(oldWord); + var newDomains = GetDomainCounts(newWord); + + // Calculate differences + var allDomainIds = oldDomains.Keys.Union(newDomains.Keys).ToHashSet(); + foreach (var domainId in allDomainIds) + { + var oldCount = oldDomains.GetValueOrDefault(domainId, 0); + var newCount = newDomains.GetValueOrDefault(domainId, 0); + var diff = newCount - oldCount; + + if (diff != 0) + { + await _semDomCountRepo.Increment(newWord.ProjectId, domainId, diff); + } + } + } + + /// Updates counts when a word is deleted + public async Task UpdateCountsForWordDeletion(Word word) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "updating counts for word deletion"); + + var domainCounts = GetDomainCounts(word); + foreach (var entry in domainCounts) + { + await _semDomCountRepo.Increment(word.ProjectId, entry.Key, -entry.Value); + } + } + } +} diff --git a/Backend/Services/StatisticsService.cs b/Backend/Services/StatisticsService.cs index f51a897ecc..c8a5920e14 100644 --- a/Backend/Services/StatisticsService.cs +++ b/Backend/Services/StatisticsService.cs @@ -9,22 +9,17 @@ namespace BackendFramework.Services { - public class StatisticsService : IStatisticsService + public class StatisticsService(ISemanticDomainRepository semDomRepo, + ISemanticDomainCountRepository semDomCountRepo, IUserRepository userRepo, IWordRepository wordRepo) + : IStatisticsService { - private readonly IWordRepository _wordRepo; - private readonly ISemanticDomainRepository _domainRepo; - private readonly IUserRepository _userRepo; + private readonly ISemanticDomainRepository _semDomRepo = semDomRepo; + private readonly ISemanticDomainCountRepository _semDomCountRepo = semDomCountRepo; + private readonly IUserRepository _userRepo = userRepo; + private readonly IWordRepository _wordRepo = wordRepo; private const string otelTagName = "otel.StatisticsService"; - public StatisticsService( - IWordRepository wordRepo, ISemanticDomainRepository domainRepo, IUserRepository userRepo) - { - _wordRepo = wordRepo; - _domainRepo = domainRepo; - _userRepo = userRepo; - } - // Statistic names (TODO: localize) const string StatAverage = "Average"; const string StatBurstProjection = "Burst Projection"; @@ -39,32 +34,17 @@ public async Task> GetSemanticDomainCounts(string proj { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting semantic domain counts"); - var hashMap = new Dictionary(); - var domainTreeNodeList = await _domainRepo.GetAllSemanticDomainTreeNodes(lang); - var wordList = await _wordRepo.GetAllFrontier(projectId); - - if (domainTreeNodeList is null || domainTreeNodeList.Count == 0 || wordList.Count == 0) + var domainTreeNodeList = await _semDomRepo.GetAllSemanticDomainTreeNodes(lang); + if (domainTreeNodeList is null || domainTreeNodeList.Count == 0) { return []; } - foreach (var word in wordList) - { - foreach (var sense in word.Senses) - { - foreach (var sd in sense.SemanticDomains) - { - hashMap[sd.Id] = hashMap.GetValueOrDefault(sd.Id, 0) + 1; - } - } - } + var domainCounts = + (await _semDomCountRepo.GetAllCounts(projectId)).ToDictionary(dc => dc.DomainId, dc => dc.Count); - var resList = new List(); - foreach (var domainTreeNode in domainTreeNodeList) - { - resList.Add(new(domainTreeNode, hashMap.GetValueOrDefault(domainTreeNode.Id, 0))); - } - return resList; + return domainTreeNodeList + .Select(node => new SemanticDomainCount(node, domainCounts.GetValueOrDefault(node.Id, 0))).ToList(); } /// diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 5d55909e29..e934279e89 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -8,15 +8,16 @@ namespace BackendFramework.Services { /// More complex functions and application logic for s - public class WordService(IWordRepository wordRepo) : IWordService + public class WordService(IWordRepository wordRepo, ISemanticDomainCountService semDomCountService) : IWordService { private readonly IWordRepository _wordRepo = wordRepo; + private readonly ISemanticDomainCountService _semDomCountService = semDomCountService; private const string otelTagName = "otel.WordService"; /// /// Clear the given word's Id and Metadata to be generated by the word repo, - /// and add the given userId to EditedBy if it's not already last on the list. + /// and add the given userId to EditedBy if it's nonempty and not already last on the list. /// private static Word PrepEditedData(string userId, Word word) { @@ -35,7 +36,9 @@ public async Task Create(string userId, Word word) { using var activity = OtelService.StartActivityWithTag(otelTagName, "creating a word"); - return await _wordRepo.Create(PrepEditedData(userId, word)); + var createdWord = await _wordRepo.Create(PrepEditedData(userId, word)); + await _semDomCountService.UpdateCountsForWord(createdWord); + return createdWord; } /// Creates new words with updated edited data. @@ -44,7 +47,9 @@ public async Task> Create(string userId, List words) { using var activity = OtelService.StartActivityWithTag(otelTagName, "creating words"); - return await _wordRepo.Create(words.Select(w => PrepEditedData(userId, w)).ToList()); + var createdWords = await _wordRepo.Create(words.Select(w => PrepEditedData(userId, w)).ToList()); + await _semDomCountService.UpdateCountsForWords(createdWords); + return createdWords; } /// Adds a new word with updated edited data. @@ -65,6 +70,7 @@ private async Task Add(string userId, Word word) { return null; } + await _semDomCountService.UpdateCountsForWordDeletion(wordWithAudioToDelete); wordWithAudioToDelete.Audio.RemoveAll(a => a.FileName == fileName); return await Update(userId, wordWithAudioToDelete); @@ -86,7 +92,7 @@ private async Task Add(string userId, Word word) word.History.Add(wordId); var deletedWord = await Add(userId, word); - + await _semDomCountService.UpdateCountsForWordDeletion(word); // Don't remove the Frontier word until the copy is successfully stored as deleted. await _wordRepo.DeleteFrontier(projectId, wordId); @@ -131,6 +137,7 @@ public async Task RestoreFrontierWords(string projectId, List word } await _wordRepo.AddFrontier(wordsToRestore); + await _semDomCountService.UpdateCountsForWords(wordsToRestore); return true; } @@ -157,13 +164,25 @@ public async Task RestoreFrontierWords(string projectId, List word word.UsingCitationForm &= word.Vernacular == oldWord.Vernacular; var newWord = await Create(userId, word); - + await _semDomCountService.UpdateCountsAfterWordUpdate(oldWord, newWord); // Don't remove the old Frontier word until the new word is successfully created. await _wordRepo.DeleteFrontier(word.ProjectId, oldWordId); return newWord; } + public async Task ClearFrontier(string projectId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "clearing the Frontier"); + + var success = await _wordRepo.DeleteAllFrontierWords(projectId); + if (success) + { + await _semDomCountService.ClearCountsForProject(projectId); + } + return success; + } + /// Checks if a word being added is a duplicate of a preexisting word. /// The id string of the existing word, or null if none. public async Task FindContainingWord(Word word) diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 068409c652..c58d55571f 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -272,6 +272,8 @@ public void ConfigureServices(IServiceCollection services) // Semantic Domain types services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); // Speaker types services.AddTransient();