using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System.Diagnostics.CodeAnalysis; namespace OpenArchival.DataAccess; public class ArtifactGroupingProvider : IArtifactGroupingProvider { private readonly IDbContextFactory _context; private readonly ILogger _logger; [SetsRequiredMembers] public ArtifactGroupingProvider(IDbContextFactory context, ILogger logger) { _context = context; _logger = logger; } public async Task GetGroupingAsync(int id) { await using var context = await _context.CreateDbContextAsync(); return await context.ArtifactGroupings .Include(g => g.Category) .Include(g => g.IdentifierFields) .Include(g => g.Type) .Include(g => g.ChildArtifactEntries) .ThenInclude(g => g.Files) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.StorageLocation) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.Type) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.Tags) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.ListedNames) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.Defects) .Include(g => g.ViewCount) .Include(g => g.IdentifierFields) .Where(g => g.Id == id) .FirstOrDefaultAsync(); } public async Task GetGroupingAsync(string artifactGroupingIdentifier) { await using var context = await _context.CreateDbContextAsync(); return await context.ArtifactGroupings .Include(g => g.Category) .Include(g => g.IdentifierFields) .Include(g => g.Type) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.StorageLocation) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.Type) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.Tags) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.ListedNames) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.Defects) .Include(g => g.ViewCount) .Where(g => g.ArtifactGroupingIdentifier == artifactGroupingIdentifier) .FirstOrDefaultAsync(); } public async Task CreateGroupingAsync(ArtifactGrouping grouping) { await using var context = await _context.CreateDbContextAsync(); context.Attach(grouping.Category); var processedTypes = new Dictionary(); var processedLocations = new Dictionary(); var processedTags = new Dictionary(); var processedNames = new Dictionary(); var processedDefects = new Dictionary(); async Task GetUniqueTypeAsync(ArtifactType? typeToProcess) { if (typeToProcess == null || string.IsNullOrEmpty(typeToProcess.Name)) return typeToProcess; if (processedTypes.TryGetValue(typeToProcess.Name, out var uniqueType)) return uniqueType; var dbType = await context.ArtifactTypes.FirstOrDefaultAsync(t => t.Name == typeToProcess.Name); if (dbType != null) { processedTypes[dbType.Name] = dbType; return dbType; } processedTypes[typeToProcess.Name] = typeToProcess; return typeToProcess; } async Task GetUniqueLocationAsync(ArtifactStorageLocation? locationToProcess) { if (locationToProcess == null || string.IsNullOrEmpty(locationToProcess.Location)) return null; if (processedLocations.TryGetValue(locationToProcess.Location, out var uniqueLocation)) return uniqueLocation; var dbLocation = await context.ArtifactStorageLocations.FirstOrDefaultAsync(l => l.Location == locationToProcess.Location); if (dbLocation != null) { processedLocations[dbLocation.Location] = dbLocation; return dbLocation; } processedLocations[locationToProcess.Location] = locationToProcess; return locationToProcess; } async Task GetUniqueTagAsync(ArtifactEntryTag tagToProcess) { if (string.IsNullOrEmpty(tagToProcess?.Name)) return tagToProcess; if (processedTags.TryGetValue(tagToProcess.Name, out var uniqueTag)) return uniqueTag; var dbTag = await context.ArtifactEntryTags.FirstOrDefaultAsync(t => t.Name == tagToProcess.Name); if (dbTag != null) { processedTags[dbTag.Name] = dbTag; return dbTag; } processedTags[tagToProcess.Name] = tagToProcess; return tagToProcess; } async Task GetUniqueNameAsync(ListedName nameToProcess) { if (string.IsNullOrEmpty(nameToProcess?.Value)) return nameToProcess; if (processedNames.TryGetValue(nameToProcess.Value, out var uniqueName)) return uniqueName; var dbName = await context.ArtifactAssociatedNames.FirstOrDefaultAsync(n => n.Value == nameToProcess.Value); if (dbName != null) { processedNames[dbName.Value] = dbName; return dbName; } processedNames[nameToProcess.Value] = nameToProcess; return nameToProcess; } async Task GetUniqueDefectAsync(ArtifactDefect defectToProcess) { if (string.IsNullOrEmpty(defectToProcess?.Description)) return defectToProcess; if (processedDefects.TryGetValue(defectToProcess.Description, out var uniqueDefect)) return uniqueDefect; var dbDefect = await context.ArtifactDefects.FirstOrDefaultAsync(d => d.Description == defectToProcess.Description); if (dbDefect != null) { processedDefects[dbDefect.Description] = dbDefect; return dbDefect; } processedDefects[defectToProcess.Description] = defectToProcess; return defectToProcess; } grouping.Type = await GetUniqueTypeAsync(grouping.Type); foreach (var entry in grouping.ChildArtifactEntries) { entry.Type = await GetUniqueTypeAsync(entry.Type); entry.StorageLocation = await GetUniqueLocationAsync(entry.StorageLocation); var managedTags = new List(); foreach (var tag in entry.Tags) managedTags.Add(await GetUniqueTagAsync(tag)); entry.Tags = managedTags; var managedNames = new List(); foreach (var name in entry.ListedNames) managedNames.Add(await GetUniqueNameAsync(name)); entry.ListedNames = managedNames; var managedDefects = new List(); foreach (var defect in entry.Defects) managedDefects.Add(await GetUniqueDefectAsync(defect)); entry.Defects = managedDefects; } grouping.GenerateSearchIndex(); context.ChangeTracker.TrackGraph(grouping, node => { if (node.Entry.IsKeySet) node.Entry.State = EntityState.Unchanged; else node.Entry.State = EntityState.Added; }); await context.SaveChangesAsync(); } public async Task UpdateGroupingAsync(ArtifactGrouping updatedGrouping) { await using var context = await _context.CreateDbContextAsync(); var existingGrouping = await context.ArtifactGroupings .Include(g => g.Category) .Include(g => g.IdentifierFields) .Include(g => g.Type) .Include(g => g.ViewCount) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.StorageLocation) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.Type) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.Tags) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.ListedNames) .Include(g => g.ChildArtifactEntries) .ThenInclude(e => e.Defects) .Where(g => g.Id == updatedGrouping.Id) .FirstOrDefaultAsync(); if (existingGrouping == null) return; existingGrouping.Title = updatedGrouping.Title; existingGrouping.Description = updatedGrouping.Description; existingGrouping.IsPublicallyVisible = updatedGrouping.IsPublicallyVisible; existingGrouping.IdentifierFields = updatedGrouping.IdentifierFields; var existingGroupingType = await context.ArtifactTypes.FirstOrDefaultAsync(t => t.Name == updatedGrouping.Type.Name); existingGrouping.Type = existingGroupingType ?? updatedGrouping.Type; if (existingGrouping.Category.Name != updatedGrouping.Category.Name) { existingGrouping.Category = updatedGrouping.Category; context.Add(existingGrouping.Category); } if (updatedGrouping.ViewCount != null) { if (existingGrouping.ViewCount == null) { existingGrouping.ViewCount = new ArtifactGroupingViewCount { Grouping = existingGrouping, Views = updatedGrouping.ViewCount.Views }; } else { existingGrouping.ViewCount.Views = updatedGrouping.ViewCount.Views; } } var updatedEntryIds = updatedGrouping.ChildArtifactEntries.Select(e => e.Id).ToList(); var entriesToRemove = existingGrouping.ChildArtifactEntries .Where(e => !updatedEntryIds.Contains(e.Id)) .ToList(); foreach (var entryToRemove in entriesToRemove) existingGrouping.ChildArtifactEntries.Remove(entryToRemove); foreach (var updatedEntry in updatedGrouping.ChildArtifactEntries) { await DeDuplicateEntryRelationsAsync(context, updatedEntry); var existingEntry = existingGrouping.ChildArtifactEntries .FirstOrDefault(e => e.Id == updatedEntry.Id); if (existingEntry != null) { existingEntry.Title = updatedEntry.Title; existingEntry.Description = updatedEntry.Description; existingEntry.ArtifactNumber = updatedEntry.ArtifactNumber; existingEntry.IsPubliclyVisible = updatedEntry.IsPubliclyVisible; existingEntry.AssociatedDates = updatedEntry.AssociatedDates; existingEntry.FileTextContent = updatedEntry.FileTextContent; existingEntry.Quantity = updatedEntry.Quantity; existingEntry.Links = updatedEntry.Links; existingEntry.StorageLocation = updatedEntry.StorageLocation; existingEntry.Type = updatedEntry.Type; existingEntry.Tags.Clear(); updatedEntry.Tags.ForEach(tag => existingEntry.Tags.Add(tag)); existingEntry.ListedNames.Clear(); updatedEntry.ListedNames.ForEach(name => existingEntry.ListedNames.Add(name)); existingEntry.Defects.Clear(); updatedEntry.Defects.ForEach(defect => existingEntry.Defects.Add(defect)); } else { existingGrouping.ChildArtifactEntries.Add(updatedEntry); } } existingGrouping.GenerateSearchIndex(); await context.SaveChangesAsync(); } private async Task DeDuplicateEntryRelationsAsync(ApplicationDbContext context, ArtifactEntry entry) { // Handle StorageLocation - null if empty if (entry.StorageLocation != null && !string.IsNullOrEmpty(entry.StorageLocation.Location)) { var existingLocation = await context.ArtifactStorageLocations .FirstOrDefaultAsync(l => l.Location == entry.StorageLocation.Location); entry.StorageLocation = existingLocation ?? entry.StorageLocation; } else { entry.StorageLocation = null; } // Handle Type - null guard if (entry.Type != null && !string.IsNullOrEmpty(entry.Type.Name)) { var existingType = await context.ArtifactTypes .FirstOrDefaultAsync(t => t.Name == entry.Type.Name); entry.Type = existingType ?? entry.Type; } // De-duplicate Tags var processedTags = new List(); foreach (var tag in entry.Tags) { var existingTag = await context.ArtifactEntryTags.FirstOrDefaultAsync(t => t.Name == tag.Name) ?? tag; processedTags.Add(existingTag); } entry.Tags = processedTags; // De-duplicate ListedNames var processedNames = new List(); if (entry.ListedNames != null) { foreach (var name in entry.ListedNames) { var existingName = await context.ArtifactAssociatedNames.FirstOrDefaultAsync(n => n.Value == name.Value) ?? name; processedNames.Add(existingName); } entry.ListedNames = processedNames; } // De-duplicate Defects var processedDefects = new List(); if (entry.Defects != null) { foreach (var defect in entry.Defects) { var existingDefect = await context.ArtifactDefects.FirstOrDefaultAsync(d => d.Description == defect.Description) ?? defect; processedDefects.Add(existingDefect); } entry.Defects = processedDefects; } } private async Task SyncCollectionAsync( DbContext context, ICollection existingItems, ICollection updatedItems, Func keySelector) where TEntity : class { var existingKeys = existingItems.Select(keySelector).ToHashSet(); var updatedKeys = updatedItems.Select(keySelector).ToHashSet(); var keysToRemove = existingKeys.Except(updatedKeys); var itemsToRemove = existingItems.Where(item => keysToRemove.Contains(keySelector(item))).ToList(); foreach (var item in itemsToRemove) existingItems.Remove(item); var keysToAdd = updatedKeys.Except(existingKeys).ToList(); if (!keysToAdd.Any()) return; Dictionary existingDbItemsMap = []; if (typeof(TEntity) == typeof(ArtifactEntryTag)) { var tagKeys = keysToAdd.Cast().ToList(); var tags = await context.Set() .Where(t => tagKeys.Contains(t.Name)) .ToListAsync(); existingDbItemsMap = tags.ToDictionary(t => (TKey)(object)t.Name) as Dictionary; } else if (typeof(TEntity) == typeof(ListedName)) { var nameKeys = keysToAdd.Cast().ToList(); var names = await context.Set() .Where(n => nameKeys.Contains(n.Value)) .ToListAsync(); existingDbItemsMap = names.ToDictionary(n => (TKey)(object)n.Value) as Dictionary; } else if (typeof(TEntity) == typeof(ArtifactDefect)) { var defectKeys = keysToAdd.Cast().ToList(); var defects = await context.Set() .Where(d => defectKeys.Contains(d.Description)) .ToListAsync(); existingDbItemsMap = defects.ToDictionary(d => (TKey)(object)d.Description) as Dictionary; } foreach (var updatedItem in updatedItems.Where(i => keysToAdd.Contains(keySelector(i)))) { var key = keySelector(updatedItem); if (existingDbItemsMap.TryGetValue(key, out var dbItem)) existingItems.Add(dbItem); else existingItems.Add(updatedItem); } } public async Task DeleteGroupingAsync(int id) { await using var context = await _context.CreateDbContextAsync(); await context.ArtifactGroupings .Where(p => p.Id == id) .ExecuteDeleteAsync(); await context.SaveChangesAsync(); } public async Task DeleteGroupingAsync(ArtifactGrouping grouping) { await using var context = await _context.CreateDbContextAsync(); context.ArtifactGroupings.Remove(grouping); await context.SaveChangesAsync(); } public async Task> GetGroupingsPaged(int pageNumber, int resultsCount) { await using var context = await _context.CreateDbContextAsync(); if (pageNumber < 1 || resultsCount < 1) { throw new ArgumentOutOfRangeException($"Either page number or number of results was less than or equal to 0. {nameof(pageNumber)}={pageNumber} {nameof(resultsCount)}={resultsCount}"); } var items = await context.ArtifactGroupings .Include(g => g.ChildArtifactEntries) .Include(g => g.Category) .OrderBy(g => g.Id) .Skip((pageNumber - 1) * resultsCount) .Take(resultsCount) .ToListAsync(); return items; } public async Task GetTotalCount() { await using var context = await _context.CreateDbContextAsync(); return context.ArtifactGroupings.Count(); } }