diff --git a/Core/Models/TagDatabase/TagDatabase.ExternalTransaction.cs b/Core/Models/TagDatabase/TagDatabase.ExternalTransaction.cs new file mode 100644 index 0000000..321f96c --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.ExternalTransaction.cs @@ -0,0 +1,54 @@ +using Microsoft.Data.Sqlite; + +namespace TagHierarchyManager.Models; + +public partial class TagDatabase +{ + /// + /// An object representing an SQLite transaction started outside of the confines of the TagDatabase object (e.g. in the + /// Tag Hierarchy Manager UI). + /// + public class ExternalTransaction(TagDatabase db, SqliteTransaction transaction) : IAsyncDisposable + { + internal readonly SqliteTransaction Transaction = transaction; + + private readonly List _newlyAddedTags = []; + private readonly List _updatedTags = []; + + public async Task CommitAsync() + { + await this.Transaction.CommitAsync(); + + foreach (var tag in this._newlyAddedTags) db.Tags.Add(tag); + + foreach (var tag in this._updatedTags) + { + var index = db.Tags.FindIndex(t => t.Id == tag.Id); + if (index != -1) + db.Tags[index] = tag; + } + + db.TagsWritten?.Invoke(db, new DatabaseEditResult(this._newlyAddedTags, this._updatedTags, [])); + } + + public async ValueTask DisposeAsync() + { + await this.Transaction.DisposeAsync(); + } + + public async Task RollbackAsync() + { + await this.Transaction.RollbackAsync(); + } + + internal void AccumulateAdditions(Tag tag) + { + this._newlyAddedTags.Add(tag); + } + + internal void AccumulateUpdates(Tag tag) + { + this._updatedTags.Add(tag); + } + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs b/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs index 2591134..326b4a9 100644 --- a/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs +++ b/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs @@ -6,106 +6,127 @@ namespace TagHierarchyManager.Models; public partial class TagDatabase { /// - /// Saves all tag objects to the database. + /// Saves a Tag object to the database. /// - /// The List of Tag objects. + /// The List of Tag objects. /// The SqliteTransaction to execute queries on, will make its own if null. /// Thrown if the tag already existed in the database. - public async Task WriteTagsToDatabase(List tags, SqliteTransaction? transaction = null) + public async Task WriteTagToDatabase(Tag tag, SqliteTransaction? transaction = null) { this.CheckInitialisation(); - bool isTransactionOwner = transaction == null; + var isTransactionOwner = transaction == null; transaction ??= (SqliteTransaction)await this.currentConnection.BeginTransactionAsync().ConfigureAwait(false); - List oldTags = []; - List<(Tag tag, int id)> originalIds = []; - List updatedTags = []; - List newlyAddedTags = []; + var oldTag = new Tag + { + Id = tag.Id, + Name = tag.Name, + ParentIds = tag.ParentIds, + TagBindings = tag.TagBindings, + Aliases = tag.Aliases, + Notes = tag.Notes, + IsTopLevel = tag.IsTopLevel + }; + var originalId = 0; - List<(int index, Tag tag)> pendingUpdates = []; - List pendingAdditions = []; - try { - foreach (var tag in tags) - { - if (tag.Id != 0) oldTags.Add(new Tag - { - Id = tag.Id, - Name = tag.Name, - ParentIds = tag.ParentIds, - TagBindings = tag.TagBindings, - Aliases = tag.Aliases, - Notes = tag.Notes, - IsTopLevel = tag.IsTopLevel, - }); - originalIds.Add((tag, tag.Id)); - - SqliteCommand addCommand = this.currentConnection.CreateCommand(); - addCommand.Transaction = transaction; - QueryProcessorHandler.ProcessTagSaveCommand(addCommand, tag); - - tag.Id = Convert.ToInt32(await addCommand.ExecuteScalarAsync().ConfigureAwait(false), - CultureInfo.InvariantCulture); - - await SaveTagParentIds(transaction, tag); - - int index = this.Tags.FindIndex(t => t.Id == tag.Id); - if (index != -1) - { - pendingUpdates.Add((index, tag)); - updatedTags.Add(tag); - } - else - { - pendingAdditions.Add(tag); - newlyAddedTags.Add(tag); - } - - tag.CreatedAt ??= DateTime.Now; - tag.UpdatedAt = DateTime.Now; - - - } - + originalId = tag.Id; + + var addCommand = this.currentConnection.CreateCommand(); + addCommand.Transaction = transaction; + QueryProcessorHandler.ProcessTagSaveCommand(addCommand, tag); + + tag.Id = Convert.ToInt32(await addCommand.ExecuteScalarAsync().ConfigureAwait(false), + CultureInfo.InvariantCulture); + await this.SaveTagParentIds(transaction, tag); + + var index = this.Tags.FindIndex(t => t.Id == tag.Id); + + tag.CreatedAt ??= DateTime.Now; + tag.UpdatedAt = DateTime.Now; + if (isTransactionOwner) await transaction.CommitAsync(); - - foreach (var (index, tag) in pendingUpdates) + if (originalId != 0) + { this.Tags[index] = tag; - foreach (var tag in pendingAdditions) + this.TagsWritten?.Invoke(this, new DatabaseEditResult([], [tag], [])); + } + else + { this.Tags.Add(tag); - - TagsWritten?.Invoke(this, new DatabaseEditResult(newlyAddedTags, updatedTags, [])); + this.TagsWritten?.Invoke(this, new DatabaseEditResult([tag], [], [])); + } } catch (Exception) { await transaction.RollbackAsync().ConfigureAwait(false); - foreach (var tag in oldTags) + if (oldTag.Id != 0) { - int index = this.Tags.FindIndex(t => t.Id == tag.Id); - - if (index != -1) this.Tags[index] = tag; - + var index = this.Tags.FindIndex(t => t.Id == tag.Id); + this.Tags[index] = oldTag; } - - foreach (var (tag, id) in originalIds) - tag.Id = id; - + throw; } finally { - if (isTransactionOwner) await transaction.DisposeAsync().ConfigureAwait(false); + await transaction.DisposeAsync().ConfigureAwait(false); } } + public async Task WriteTagToDatabase(Tag tag, ExternalTransaction transaction) + { + this.CheckInitialisation(); + + var oldTag = new Tag + { + Id = tag.Id, + Name = tag.Name, + ParentIds = tag.ParentIds, + TagBindings = tag.TagBindings, + Aliases = tag.Aliases, + Notes = tag.Notes, + IsTopLevel = tag.IsTopLevel + }; + + try + { + var addCommand = this.currentConnection.CreateCommand(); + addCommand.Transaction = transaction.Transaction; + QueryProcessorHandler.ProcessTagSaveCommand(addCommand, tag); + + tag.Id = Convert.ToInt32(await addCommand.ExecuteScalarAsync().ConfigureAwait(false), + CultureInfo.InvariantCulture); + await this.SaveTagParentIds(transaction.Transaction, tag); + + tag.CreatedAt ??= DateTime.Now; + tag.UpdatedAt = DateTime.Now; + + if (oldTag.Id != 0) + transaction.AccumulateUpdates(tag); + else transaction.AccumulateAdditions(tag); + } + catch (Exception) + { + if (oldTag.Id != 0) + { + var index = this.Tags.FindIndex(t => t.Id == tag.Id); + this.Tags[index] = oldTag; + } + + throw; + } + } + + private async Task SaveTagParentIds(SqliteTransaction transaction, Tag tag) { if (tag.ParentIds.Count == 0) return; this.CheckInitialisation(); - + // clear existing tag parents so we have a clean slate. - SqliteCommand deleteCommand = this.currentConnection.CreateCommand(); + var deleteCommand = this.currentConnection.CreateCommand(); deleteCommand.Transaction = transaction; deleteCommand.CommandText = """ DELETE FROM tag_parent_link @@ -115,7 +136,7 @@ public partial class TagDatabase await deleteCommand.ExecuteNonQueryAsync().ConfigureAwait(false); // process + add parent links - SqliteCommand parentCommand = this.currentConnection.CreateCommand(); + var parentCommand = this.currentConnection.CreateCommand(); parentCommand.Transaction = transaction; parentCommand.CommandText = """ INSERT INTO tag_parent_link (target_tag_id, parent_tag_id) @@ -126,13 +147,13 @@ public partial class TagDatabase parentCommand.Parameters.Add("@parent_tag_id", SqliteType.Integer); await parentCommand.PrepareAsync(); - foreach (int parentId in tag.ParentIds) + foreach (var parentId in tag.ParentIds) { parentCommand.Parameters["@parent_tag_id"].Value = (long)parentId; await parentCommand.ExecuteNonQueryAsync().ConfigureAwait(false); } } - + private async Task SaveTagParents(SqliteTransaction transaction, int id, IReadOnlyCollection parents, Tag? tag = null) { @@ -142,17 +163,17 @@ public partial class TagDatabase List parentIds = []; // process parents, grabbing the names first in case the user wants to change the parents. - foreach (string parentName in parents) + foreach (var parentName in parents) { - Tag? retrievedTag = this.Tags.SingleOrDefault(t => t.Name == parentName) - ?? await this.SelectTagFromDatabase(parentName).ConfigureAwait(false); + var retrievedTag = this.Tags.SingleOrDefault(t => t.Name == parentName) + ?? await this.SelectTagFromDatabase(parentName).ConfigureAwait(false); if (retrievedTag is null) throw new ArgumentException(ErrorMessages.TagNotFound); parentIds.Add(retrievedTag.Id); } // clear existing tag parents so we have a clean slate. - SqliteCommand deleteCommand = this.currentConnection.CreateCommand(); + var deleteCommand = this.currentConnection.CreateCommand(); deleteCommand.Transaction = transaction; deleteCommand.CommandText = """ DELETE FROM tag_parent_link @@ -162,7 +183,7 @@ public partial class TagDatabase await deleteCommand.ExecuteNonQueryAsync().ConfigureAwait(false); // process + add parent links - SqliteCommand parentCommand = this.currentConnection.CreateCommand(); + var parentCommand = this.currentConnection.CreateCommand(); parentCommand.Transaction = transaction; parentCommand.CommandText = """ INSERT INTO tag_parent_link (target_tag_id, parent_tag_id) @@ -173,7 +194,7 @@ public partial class TagDatabase parentCommand.Parameters.Add("@parent_tag_id", SqliteType.Integer); await parentCommand.PrepareAsync(); - foreach (int parentId in parentIds) + foreach (var parentId in parentIds) { parentCommand.Parameters["@parent_tag_id"].Value = (long)parentId; await parentCommand.ExecuteNonQueryAsync().ConfigureAwait(false); diff --git a/Core/Models/TagDatabase/TagDatabase.cs b/Core/Models/TagDatabase/TagDatabase.cs index 72c440e..fb17d3e 100644 --- a/Core/Models/TagDatabase/TagDatabase.cs +++ b/Core/Models/TagDatabase/TagDatabase.cs @@ -10,15 +10,15 @@ namespace TagHierarchyManager.Models; public partial class TagDatabase { private const string AliasesColumnName = "also_known_as"; + private const string DateCreatedColumnName = "date_created"; + private const string DateModifiedColumnName = "date_modified"; private const string IdColumnName = "id"; + private const int LatestVersion = 3; private const string NameColumnName = "name"; private const string NotesColumnName = "notes"; private const string ParentIdsColumnName = "parent_ids"; private const string TagBindingsColumnName = "tags_to_bind"; private const string TopLevelColumnName = "top_level"; - private const string DateCreatedColumnName = "date_created"; - private const string DateModifiedColumnName = "date_modified"; - private const int LatestVersion = 3; private SqliteConnection? currentConnection; private List defaultBindings = ["genre"]; @@ -78,7 +78,7 @@ public partial class TagDatabase /// Gets version of the database. Cannot be set outside of initialisation. /// public int Version { get; private set; } - + /// /// Gets the SQLite connection associated with the . /// @@ -94,9 +94,10 @@ public partial class TagDatabase /// private bool Initialised { get; set; } - public async Task BeginTransactionAsync() + public async Task BeginExternalTransactionAsync() { this.CheckInitialisation(); - return (SqliteTransaction)await this.currentConnection.BeginTransactionAsync(); + var transaction = (SqliteTransaction)await this.currentConnection.BeginTransactionAsync(); + return new ExternalTransaction(this, transaction); } } \ No newline at end of file diff --git a/Tests/TagDatabaseSaveTests.cs b/Tests/TagDatabaseSaveTests.cs index f5f2a20..00022be 100644 --- a/Tests/TagDatabaseSaveTests.cs +++ b/Tests/TagDatabaseSaveTests.cs @@ -26,7 +26,7 @@ public class TagDatabaseWriteTests : TestBase public async Task ClearDatabaseAndAddSampleData() { this.Database.ClearTags(); - await this.Database.WriteTagsToDatabase([TestSampleTags.Ambient, TestSampleTags.Electronic]); + await this.Database.WriteTagToDatabase([TestSampleTags.Ambient, TestSampleTags.Electronic]); } /// @@ -42,7 +42,7 @@ public class TagDatabaseWriteTests : TestBase Name = "DELETE ME", IsTopLevel = true, }; - await this.Database.WriteTagsToDatabase([deletedTag]); + await this.Database.WriteTagToDatabase([deletedTag]); // Act await this.Database.DeleteTag(deletedTag.Id); @@ -93,7 +93,7 @@ public class TagDatabaseWriteTests : TestBase TagBindings = ["genre"], Parents = ["Test parent tag 1"], }; - await this.Database.WriteTagsToDatabase([firstParentTag, secondParentTag, childTag]); + await this.Database.WriteTagToDatabase([firstParentTag, secondParentTag, childTag]); int childTagId = childTag.Id; List expectedParents = [firstParentTag.Id, secondParentTag.Id]; @@ -112,7 +112,7 @@ public class TagDatabaseWriteTests : TestBase childTag.Parents.Add(addedParentName); childTag.Notes = newNotes; childTag.Aliases = newAliases; - await this.Database.WriteTagsToDatabase([childTag]); + await this.Database.WriteTagToDatabase([childTag]); // Assert Tag? editedChildTag = await this.Database.SelectTagFromDatabase(childTagId); @@ -140,7 +140,7 @@ public class TagDatabaseWriteTests : TestBase // Act/Assert ArgumentException? ex = - Assert.ThrowsAsync(async () => await this.Database.WriteTagsToDatabase([ambient])); + Assert.ThrowsAsync(async () => await this.Database.WriteTagToDatabase([ambient])); Assert.That(ex!.Message, Does.EndWith("already exists in the database.")); } @@ -157,7 +157,7 @@ public class TagDatabaseWriteTests : TestBase bool tagValidated = inputTag.Validate(); // Act - await this.Database.WriteTagsToDatabase([inputTag]); + await this.Database.WriteTagToDatabase([inputTag]); Tag? savedTag = await this.Database.SelectTagFromDatabase(inputTag.Name); // Assert @@ -192,7 +192,7 @@ public class TagDatabaseWriteTests : TestBase this.Database.ClearTags(); // Act - await this.Database.WriteTagsToDatabase(testTags); + await this.Database.WriteTagToDatabase(testTags); // Assert var retrievedTags = await this.Database.GetAllTagsFromDatabase(); @@ -215,7 +215,7 @@ public class TagDatabaseWriteTests : TestBase db.ClearTags(); // Act/Assert - await db.WriteTagsToDatabase(testTags); + await db.WriteTagToDatabase(testTags); Assert.That(test.Added.Count, Is.EqualTo(testTags.Count)); Assert.That(test.Updated.Count, Is.EqualTo(0)); Assert.That(test.Deleted.Count, Is.EqualTo(0)); @@ -223,7 +223,7 @@ public class TagDatabaseWriteTests : TestBase Tag? retrievedTag = await db.SelectTagFromDatabase(testTags[0].Name); Assert.That(retrievedTag, Is.Not.Null); retrievedTag.Notes = "test edit"; - await db.WriteTagsToDatabase([retrievedTag]); + await db.WriteTagToDatabase([retrievedTag]); Assert.That(test.Updated.Count, Is.EqualTo(1)); Tag? deletedTag = await db.SelectTagFromDatabase(testTags[2].Name); diff --git a/Tests/TagDatabaseSearchTests.cs b/Tests/TagDatabaseSearchTests.cs index 8f7d72f..cc7f1ec 100644 --- a/Tests/TagDatabaseSearchTests.cs +++ b/Tests/TagDatabaseSearchTests.cs @@ -124,7 +124,7 @@ public class TagDatabaseSearchTests : TestBase Parents = ["Ambient", "Electronic"], Aliases = ["áéíóúçýỷủ"], }; - await this.Database.WriteTagsToDatabase([normalisedTest]); + await this.Database.WriteTagToDatabase([normalisedTest]); // Act List tags = this.Database.SearchWithAliases(query, mode); diff --git a/Tests/TestBase.cs b/Tests/TestBase.cs index 01f3109..a8630c3 100644 --- a/Tests/TestBase.cs +++ b/Tests/TestBase.cs @@ -63,7 +63,7 @@ public abstract class TestBase List sampleTags = TestSampleTags.AllTags(); this.Database.ClearTags(); - await this.Database.WriteTagsToDatabase(sampleTags); + await this.Database.WriteTagToDatabase(sampleTags); } // do not use in Test1_Init. That needs to be tested at a lower level. diff --git a/UI/TagDatabaseService.cs b/UI/TagDatabaseService.cs index dc40219..fcfc67e 100644 --- a/UI/TagDatabaseService.cs +++ b/UI/TagDatabaseService.cs @@ -94,36 +94,36 @@ public class TagDatabaseService : ObservableObject : []; } + public List GetAllTags(bool topLevelOnly = false) + { + if (this.Database is null) return []; + + return topLevelOnly + ? this.Database.Tags.Where(t => t.IsTopLevel).OrderBy(t => t.Name).ToList() + : this.Database.Tags.OrderBy(t => t.Name).ToList(); + } + public Dictionary> GetChildLookup() { - if (this.Database is null) return new(); + if (this.Database is null) return new Dictionary>(); var lookup = new Dictionary>(); foreach (var tag in this.Database.Tags) + foreach (var parentId in tag.ParentIds) { - foreach (var parentId in tag.ParentIds) + if (!lookup.TryGetValue(parentId, out var children)) { - if (!lookup.TryGetValue(parentId, out var children)) - { - children = []; - lookup[parentId] = children; - } - children.Add(tag); + children = []; + lookup[parentId] = children; } + + children.Add(tag); } + foreach (var children in lookup.Values) children.Sort((a, b) => a.Name.CompareTo(b.Name, StringComparison.CurrentCultureIgnoreCase)); return lookup; } - public List GetAllTags(bool topLevelOnly = false) - { - if (this.Database is null) return []; - - return topLevelOnly - ? this.Database.Tags.Where(t => t.IsTopLevel).OrderBy(t => t.Name).ToList() - : this.Database.Tags.OrderBy(t => t.Name).ToList(); - } - public List GetParentNamesByIds(List ids) { return ids.Select(id => this.Database?.Tags.FirstOrDefault(t => t.Id == id)) @@ -179,38 +179,6 @@ public class TagDatabaseService : ObservableObject this.NotifyDatabasePropertiesChanged(); } - private List SortTagsTopologically(List tags) - { - var sorted = new List(); - var checkedNames = new HashSet(); - var checking = new HashSet(); - - void CheckParents(Tag tag) - { - if (checking.Contains(tag)) - throw new InvalidOperationException($"Circular parent depedency detected involving {tag.Name}"); - - if (!checkedNames.Add(tag)) return; - - checking.Add(tag); - - foreach (var parentName in tag.Parents) - { - var parents = tags.Where(t => t.Name == parentName).ToList(); - if (parents.Count == 1) - CheckParents(parents[0]); - } - - checking.Remove(tag); - sorted.Add(tag); - } - - foreach (var tag in tags) - CheckParents(tag); - - return sorted; - } - public async Task WriteTagsToDatabase(List tags) { if (this.Database is null) return; @@ -229,40 +197,40 @@ public class TagDatabaseService : ObservableObject .Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList(), Notes = vm.EditingNotes, Parents = vm.EditingParents - .Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList(), + .Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList() }; - + if (tags.Count == 1) { var success = await this.GetSavingTagParents(tag); if (!success) return; tag.Validate(); } - + tagsToSave.Add((vm, tag)); } - + if (tags.Count > 1) { var sortedPairs = this.SortTagsTopologically(tagsToSave.Select(x => x.tag).ToList()) .Select(t => tagsToSave.First(x => x.tag == t)) .ToList(); - - var transaction = await this.Database.BeginTransactionAsync(); + + var transaction = await this.Database.BeginExternalTransactionAsync(); try { - foreach (var (vm, tag) in sortedPairs) + foreach (var (_, tag) in sortedPairs) { var success = await this.GetSavingTagParents(tag, tagsToSave.Select(x => x.tag).ToList()); if (!success) { - transaction.Rollback(); + transaction.RollbackAsync(); return; } - + tag.Validate(); - await this.Database.WriteTagsToDatabase([tag], transaction: transaction); + await this.Database.WriteTagToDatabase(tag, transaction); } await transaction.CommitAsync(); @@ -272,12 +240,29 @@ public class TagDatabaseService : ObservableObject await transaction.RollbackAsync(); throw; } - finally { await transaction.DisposeAsync(); } + finally + { + await transaction.DisposeAsync(); + } + } + else + { + await this.Database.WriteTagToDatabase(tagsToSave[0].tag); } - else await this.Database.WriteTagsToDatabase(tagsToSave.Select(x => x.tag).ToList()); + tagsToSave.ForEach(pair => pair.vm.CommitEdit(pair.tag)); } + private static IExporter PickExporterFromFileExt(string path) + { + var fileExt = Path.GetExtension(path); + + // ReSharper disable once ConvertIfStatementToReturnStatement + if (fileExt == FileTypes.MusicBeeTagHierarchyTemplate.FileExtension) return new MusicBeeTagHierarchyExporter(); + + throw new NotSupportedException($"File extension '{fileExt}' is not supported."); + } + private async Task GetSavingTagParents(Tag tag, List? batch = null) { if (this.Database is null) return false; @@ -299,28 +284,20 @@ public class TagDatabaseService : ObservableObject var ambiguousVm = new SaveAmbiguousViewModel(this, tag, parentTags); var dialog = new SaveAmbiguousDialog(); - var dialogOwner = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime) + var dialogOwner = + (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime) ?.Windows .FirstOrDefault(w => w.IsActive); dialog.DataContext = ambiguousVm; var result = await dialog.ShowDialog(dialogOwner!); - + if (result == null) return false; tag.ParentIds.Add(result.Id); break; } } - return true; - } - - private static IExporter PickExporterFromFileExt(string path) - { - var fileExt = Path.GetExtension(path); - - // ReSharper disable once ConvertIfStatementToReturnStatement - if (fileExt == FileTypes.MusicBeeTagHierarchyTemplate.FileExtension) return new MusicBeeTagHierarchyExporter(); - throw new NotSupportedException($"File extension '{fileExt}' is not supported."); + return true; } private void NotifyDatabasePropertiesChanged() @@ -349,6 +326,38 @@ public class TagDatabaseService : ObservableObject throw new NotSupportedException(string.Format(Resources.ErrorImportFileTypeNotSupported, fileExt)); } + private List SortTagsTopologically(List tags) + { + var sorted = new List(); + var checkedNames = new HashSet(); + var checking = new HashSet(); + + void CheckParents(Tag tag) + { + if (checking.Contains(tag)) + throw new InvalidOperationException($"Circular parent depedency detected involving {tag.Name}"); + + if (!checkedNames.Add(tag)) return; + + checking.Add(tag); + + foreach (var parentName in tag.Parents) + { + var parents = tags.Where(t => t.Name == parentName).ToList(); + if (parents.Count == 1) + CheckParents(parents[0]); + } + + checking.Remove(tag); + sorted.Add(tag); + } + + foreach (var tag in tags) + CheckParents(tag); + + return sorted; + } + private void SubscribeToEvents() { if (this.Database is null) return;