using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Main; using Duplicati.Library.Main.Database; using Duplicati.Library.Main.Database.Local; using Duplicati.Library.Utility; using NUnit.Framework; namespace Duplicati.UnitTest { [TestFixture] public class DirectListHandlerTests : BasicSetupHelper { [Test] public async Task ListFilesetsAsync() { var options = new Dictionary(this.TestOptions) { ["upload-unchanged-backups"] = "true" }; for (var i = 0; i < 3; i++) { using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) { TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER })); var sets = await c.ListFilesetsAsync(); Assert.That(sets.Filesets.Count(), Is.EqualTo(i + 1)); } } } [Test] public async Task ListFolderContentsAsync() { var options = new Dictionary(this.TestOptions) { ["upload-unchanged-backups"] = "true" }; var initial = new[] { "folder1/", "folder2/", "file1", "folder1/file1.txt", "folder1/file2.txt", "folder1/folder1/", "folder1/folder2/", "folder1/folder1/file1.txt", "folder1/folder2/file2.txt", "folder2/folder2/" }; var add2 = new[] { "folder3/", "folder1/folder1/file3.txt", }; var add3 = new[] { "folder1/folder1/file3.txt", "folder1/folder1/file4.txt" }; IEnumerable rootItems(IEnumerable items) => items.Where(x => !x.Contains('/') || x.IndexOf('/') == x.Length - 1); void createStructure(IEnumerable paths) { foreach (var p in paths) { var path = Path.Combine(this.DATAFOLDER, p.Replace("/", Path.DirectorySeparatorChar.ToString())); if (p.EndsWith("/")) Directory.CreateDirectory(path); else File.WriteAllText(path, p); } } var sources = initial.Take(3).ToArray(); var rounds = new[] { initial, initial.Concat(add2), initial.Concat(add3) }; using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) { foreach (var round in rounds) { createStructure(round); TestUtils.AssertResults(await c.BackupAsync(rootItems(round).Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray())); } var sets = await c.ListFilesetsAsync(); Assert.That(sets.Filesets.Count(), Is.EqualTo(rounds.Length)); } var version = 0; foreach (var round in rounds.Reverse()) { using (var c = new Controller("file://" + this.TARGETFOLDER, options.Expand(new { version = version }), null)) { var files = await c.ListFolderAsync([""], 0, 0, false); var roots = rootItems(round).Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray(); Assert.That(files.Entries.Items.Count(), Is.EqualTo(roots.Length)); Assert.That(files.Entries.Items.Count(x => x.IsDirectory), Is.EqualTo(roots.Count(x => x.EndsWith(Path.DirectorySeparatorChar)))); Assert.That(files.Entries.Items.Count(x => !x.IsDirectory), Is.EqualTo(roots.Count(x => !x.EndsWith(Path.DirectorySeparatorChar)))); Assert.That(files.Entries.Items.Count(x => roots.Any(y => x.Path.EndsWith(y))), Is.EqualTo(roots.Length)); var work = new Queue(roots); while (work.Count > 0) { var path = work.Dequeue(); var files2 = await c.ListFolderAsync(new[] { Path.Combine(this.DATAFOLDER, path.Replace("/", Path.DirectorySeparatorChar.ToString())) }, 0, 0, false); var matches = round.Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))) .Where(x => x.StartsWith(path) && x.Length > path.Length && !x.Substring(path.Length, x.Length - path.Length - 1).Contains(Path.DirectorySeparatorChar)) .ToArray(); Assert.That(files2.Entries.Items.Count(), Is.EqualTo(matches.Length)); Assert.That(files2.Entries.Items.Count(x => x.IsDirectory), Is.EqualTo(matches.Count(x => x.EndsWith(Path.DirectorySeparatorChar)))); Assert.That(files2.Entries.Items.Count(x => !x.IsDirectory), Is.EqualTo(matches.Count(x => !x.EndsWith(Path.DirectorySeparatorChar)))); Assert.That(files2.Entries.Items.Count(x => matches.Contains(x.Path)), Is.EqualTo(matches.Length)); foreach (var match in matches) if (match.EndsWith("/")) work.Enqueue(match); } } version++; } } [Test] public async Task ListFileVersions_LifecycleTestAsync() { var options = new Dictionary(this.TestOptions) { ["upload-unchanged-backups"] = "true" }; var stays = "file_stays.txt"; var modify = "file_modify.txt"; var delete = "file_delete.txt"; var create = "file_create.txt"; void createStructure(IEnumerable files) { foreach (var f in files) { var path = Path.Combine(this.DATAFOLDER, f.Replace("/", Path.DirectorySeparatorChar.ToString())); Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllText(path, f + "-contents"); } } void modifyFile(string file, string text) { var path = Path.Combine(this.DATAFOLDER, file.Replace("/", Path.DirectorySeparatorChar.ToString())); File.WriteAllText(path, text); } var rounds = new List { new[] { stays, modify, delete }, // Round 0 - initial new[] { stays, modify, create }, // Round 1 - modify, create, delete 'delete' new[] { stays, modify, create } // Round 2 - no changes }; using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) { // Round 0 - initial createStructure(rounds[0]); TestUtils.AssertResults(await c.BackupAsync(rounds[0].Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray())); // Round 1 - modify and create new file modifyFile(modify, "modified-content"); createStructure(new[] { create }); File.Delete(Path.Combine(this.DATAFOLDER, delete.Replace("/", Path.DirectorySeparatorChar.ToString()))); // Delete file TestUtils.AssertResults(await c.BackupAsync(rounds[1].Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray())); // Round 2 - no changes TestUtils.AssertResults(await c.BackupAsync(rounds[2].Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray())); } var pathsToCheck = new[] { Path.Combine(this.DATAFOLDER, stays), Path.Combine(this.DATAFOLDER, modify), Path.Combine(this.DATAFOLDER, delete), Path.Combine(this.DATAFOLDER, create) }; using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) { var allVersions = await c.ListFileVersionsAsync(pathsToCheck, 0, 0); var grouped = allVersions.FileVersions.Items.GroupBy(x => x.Path); foreach (var group in grouped) { var path = group.Key; var versions = group.OrderByDescending(x => x.Time).ToArray(); // Newest first if (path.EndsWith(stays)) Assert.That(versions.Length, Is.EqualTo(3), $"File {path} should exist in all 3 versions"); else if (path.EndsWith(modify)) Assert.That(versions.Length, Is.EqualTo(3), $"File {path} should exist in all 3 versions"); else if (path.EndsWith(delete)) Assert.That(versions.Length, Is.EqualTo(1), $"File {path} should exist only in first version before deletion"); else if (path.EndsWith(create)) Assert.That(versions.Length, Is.EqualTo(2), $"File {path} should exist in 2 versions after creation"); else Assert.Fail($"Unexpected file {path} in file versions list."); } } } [Test] public async Task SearchFilesTestAsync() { var options = new Dictionary(this.TestOptions) { ["upload-unchanged-backups"] = "true" }; var initial = new[] { "file1.txt", "folder1/", "folder1/file2.txt", "folder1/file3.txt", "folder2/", "folder2/notes.txt" }; IEnumerable rootItems(IEnumerable items) => items.Where(x => !x.Contains('/') || x.IndexOf('/') == x.Length - 1); void createStructure(IEnumerable paths) { foreach (var p in paths) { var path = Path.Combine(this.DATAFOLDER, p.Replace("/", Path.DirectorySeparatorChar.ToString())); if (p.EndsWith("/")) Directory.CreateDirectory(path); else File.WriteAllText(path, p); } } IFilter ParseFilters(string args) => args.Split(";").Select(x => (IFilter)new FilterExpression(x.Substring(1), x.StartsWith("+"))).Aggregate((x, y) => FilterExpression.Combine(x, y)); using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) { createStructure(initial); TestUtils.AssertResults(await c.BackupAsync(rootItems(initial).Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray())); // Simple Search for 'file' var search = await c.SearchEntriesAsync(null, ParseFilters("+file"), false, 0, 0, false, false); Assert.That(search.FileVersions.Items.Count(), Is.EqualTo(3)); // file1.txt, file2.txt, file3.txt // Simple Search for 'notes' var searchNotes = await c.SearchEntriesAsync(null, ParseFilters("+notes"), false, 0, 0, false, false); Assert.That(searchNotes.FileVersions.Items.Count(), Is.EqualTo(1)); Assert.That(searchNotes.FileVersions.Items.First().Path.EndsWith("notes.txt")); // Simple Search for NOT 'notes' var searchNotNotes = await c.SearchEntriesAsync(null, ParseFilters("-notes"), false, 0, 0, false, false); Assert.That(searchNotNotes.FileVersions.Items.Count(), Is.EqualTo(5)); Assert.That(searchNotNotes.FileVersions.Items.All(x => !x.Path.Contains("notes")), Is.True); // Simple Search for 'folder1' folder contents var searchFolder = await c.SearchEntriesAsync(null, ParseFilters("+folder1"), false, 0, 0, false, false); Assert.That(searchFolder.FileVersions.Items.All(x => x.Path.Contains("folder1")), Is.True); // Simple Search with exact file name var searchExact = await c.SearchEntriesAsync(null, ParseFilters("+file1.txt"), false, 0, 0, false, false); Assert.That(searchExact.FileVersions.Items.Count(), Is.EqualTo(1)); Assert.That(searchExact.FileVersions.Items.First().Path.EndsWith("file1.txt")); // Mixed include and exclude var searchMixed = await c.SearchEntriesAsync(null, ParseFilters("+file;-file2"), false, 0, 0, false, false); // Include has precedence over exclude, but we include non-matches as well Assert.That(searchMixed.FileVersions.Items.Count(), Is.EqualTo(6)); Assert.That(searchMixed.FileVersions.Items.Any(x => x.Path.Contains("file2")), Is.True); // Mixed include and exclude var searchMixed2 = await c.SearchEntriesAsync(null, ParseFilters("+file2;-file"), false, 0, 0, false, false); // Include has precedence over exclude, but we include non-matches as well Assert.That(searchMixed2.FileVersions.Items.Count(), Is.EqualTo(4)); Assert.That(searchMixed2.FileVersions.Items.Any(x => x.Path.Contains("file2")), Is.True); // Mixed include and exclude, wildcards var searchMixed3 = await c.SearchEntriesAsync(null, ParseFilters("+file2;-*"), false, 0, 0, false, false); // Include has precedence over exclude, but we include non-matches as well Assert.That(searchMixed3.FileVersions.Items.Count(), Is.EqualTo(1)); Assert.That(searchMixed3.FileVersions.Items.All(x => x.Path.Contains("file2")), Is.True); // NEW: Search inside specific folder prefix: folder1 var searchInFolder1 = await c.SearchEntriesAsync( new[] { Path.Combine(this.DATAFOLDER, "folder1") }, ParseFilters("+file"), false, 0, 0, false, false); Assert.That(searchInFolder1.FileVersions.Items.Count(), Is.EqualTo(2)); // file2.txt, file3.txt inside folder1 Assert.That(searchInFolder1.FileVersions.Items.All(x => x.Path.Contains("folder1")), Is.True); // NEW: Search inside multiple folder prefixes: folder1 and folder2 var searchInFolders = await c.SearchEntriesAsync( new[] { Path.Combine(this.DATAFOLDER, "folder1"), Path.Combine(this.DATAFOLDER, "folder2") }, ParseFilters("+file;+notes"), false, 0, 0, false, false); Assert.That(searchInFolders.FileVersions.Items.Count(), Is.EqualTo(3)); // file2.txt, file3.txt, notes.txt Assert.That(searchInFolders.FileVersions.Items.Any(x => x.Path.Contains("folder1")), Is.True); Assert.That(searchInFolders.FileVersions.Items.Any(x => x.Path.Contains("folder2")), Is.True); } } [Test] public async Task GetMinimalUniquePrefixEntries_ShouldReturnCorrectLinuxPrefixesAsync() { using var tempFile = new TempFile(); using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None) .ConfigureAwait(false); SeedTestData(db, [ "/folder1/", "/folder1/sub1/", "/folder2/", "/folder2/sub2/", "/folder3/" ]); var result = await db .GetMinimalUniquePrefixEntriesAsync(1, CancellationToken.None) .Select(e => e.Path) .ToListAsync() .ConfigureAwait(false); Assert.That(result, Does.Contain("/folder1/")); Assert.That(result, Does.Contain("/folder2/")); Assert.That(result, Does.Contain("/folder3/")); Assert.That(result.Any(x => x.Contains("sub1")), Is.False); Assert.That(result.Any(x => x.Contains("sub2")), Is.False); Assert.That(result.Count, Is.EqualTo(3)); } [Test] public async Task GetMinimalUniquePrefixEntries_ShouldReturnCorrectWindowsDrivePrefixes_Async() { using var tempFile = new TempFile(); using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None) .ConfigureAwait(false); SeedTestData(db, [ "C:\\folder1\\", "C:\\folder1\\sub1\\", "C:\\folder2\\", "D:\\otherfolder\\" ]); var result = await db .GetMinimalUniquePrefixEntriesAsync(1, CancellationToken.None) .Select(e => e.Path) .ToListAsync() .ConfigureAwait(false); Assert.That(result, Does.Contain("C:\\folder1\\")); Assert.That(result, Does.Contain("C:\\folder2\\")); Assert.That(result, Does.Contain("D:\\otherfolder\\")); Assert.That(result.Any(x => x.Contains("sub1")), Is.False); Assert.That(result.Count, Is.EqualTo(3)); } [Test] public async Task GetMinimalUniquePrefixEntries_ShouldReturnCorrectWindowsUncPrefixes_Async() { using var tempFile = new TempFile(); using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None) .ConfigureAwait(false); SeedTestData(db, [ "\\\\server\\share\\folder\\", "\\\\server\\share\\folder\\subfolder\\", "\\\\server\\share\\otherfolder\\" ]); var result = await db .GetMinimalUniquePrefixEntriesAsync(1, CancellationToken.None) .Select(e => e.Path) .ToListAsync() .ConfigureAwait(false); Assert.That(result, Does.Contain("\\\\server\\share\\folder\\")); Assert.That(result, Does.Contain("\\\\server\\share\\otherfolder\\")); Assert.That(result.Any(x => x.Contains("subfolder")), Is.False); Assert.That(result.Count, Is.EqualTo(2)); } [Test] public async Task GetMinimalUniquePrefixEntries_ShouldHandleMixedWindowsDriveAndUncPathsAsync() { using var tempFile = new TempFile(); using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None) .ConfigureAwait(false); SeedTestData(db, [ "C:\\data\\", "C:\\data\\sub1\\", "C:\\music\\", "D:\\videos\\", "\\\\server\\share\\docs\\", "\\\\server\\share\\docs\\subdoc\\", "\\\\server\\share\\pictures\\" ]); var result = await db .GetMinimalUniquePrefixEntriesAsync(1, CancellationToken.None) .Select(e => e.Path) .ToListAsync() .ConfigureAwait(false); Assert.That(result, Does.Contain("C:\\data\\")); Assert.That(result, Does.Contain("C:\\music\\")); Assert.That(result, Does.Contain("D:\\videos\\")); Assert.That(result, Does.Contain("\\\\server\\share\\docs\\")); Assert.That(result, Does.Contain("\\\\server\\share\\pictures\\")); Assert.That(result.Any(x => x.Contains("sub1")), Is.False); Assert.That(result.Any(x => x.Contains("subdoc")), Is.False); Assert.That(result.Count, Is.EqualTo(5)); } [Test] public async Task GetMinimalUniquePrefixEntries_ShouldReturnExpectedMinimalRootsAsync() { // Arrange: Prepare prefixes (minimal unique) and contents var testPrefixes = new[] { @"C:\Downloads\testsource\AA\", @"C:\Downloads\testsource\AA\readme.txt", @"C:\Downloads\testsource\AAA\", @"C:\Downloads\testsource\AAA\readme.txt", @"C:\Downloads\testsource\AAAA\", @"C:\Downloads\testsource\AAAA\readme.txt", @"C:\Downloads\Kits\", @"C:\Downloads\Kits\abc.txt", @"Y:\source\", @"Y:\source\123.bin" }; // Act using var tempFile = new TempFile(); await using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None) .ConfigureAwait(false); SeedTestData(db, testPrefixes); var resultItems = await db .GetMinimalUniquePrefixEntriesAsync(1, CancellationToken.None) .ToListAsync() .ConfigureAwait(false); var result = resultItems.Select(e => e.Path).ToList(); Assert.That(result, Does.Contain(@"C:\Downloads\testsource\AA\")); Assert.That(result, Does.Contain(@"C:\Downloads\testsource\AAA\")); Assert.That(result, Does.Contain(@"C:\Downloads\testsource\AAAA\")); Assert.That(result, Does.Contain(@"C:\Downloads\Kits\")); Assert.That(result, Does.Contain(@"Y:\source\")); Assert.That(result.Count, Is.EqualTo(5), "Expected 5 unique prefixes"); } private void SeedTestData(LocalListDatabase db, IEnumerable fullPaths) { const long filesetId = 1; long fileId = 1; using var cmd = db.Connection.CreateCommand(); // Insert fileset entry cmd.SetCommandAndParameters(@" INSERT OR IGNORE INTO Fileset (ID, OperationID, VolumeID, IsFullBackup, Timestamp) VALUES (@filesetId, 1, 1, 1, 0);") .SetParameterValue("@filesetId", filesetId) .ExecuteNonQuery(); foreach (var rawPath in fullPaths) { // Normalize path separators but preserve OS style var path = rawPath.Replace('\\', Path.DirectorySeparatorChar) .Replace('/', Path.DirectorySeparatorChar); string prefix; string relative; if (rawPath.EndsWith("/") || rawPath.EndsWith("\\")) { // Directory: entire path is prefix, no file prefix = rawPath; relative = string.Empty; } else { int lastSep = Math.Max(rawPath.LastIndexOf('/'), rawPath.LastIndexOf('\\')); if (lastSep < 0) { // No separator: file in root prefix = string.Empty; relative = rawPath; } else { prefix = rawPath.Substring(0, lastSep + 1); // include slash relative = rawPath.Substring(lastSep + 1); } } // Insert prefix if not present cmd.SetCommandAndParameters(@" INSERT OR IGNORE INTO PathPrefix (Prefix) VALUES (@prefix);") .SetParameterValue("@prefix", prefix) .ExecuteNonQuery(); long prefixId = cmd.SetCommandAndParameters("SELECT ID FROM PathPrefix WHERE Prefix = @prefix") .SetParameterValue("@prefix", prefix) .ExecuteScalarInt64(); // Insert FileLookup cmd.SetCommandAndParameters(@" INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID) VALUES (@fileId, @prefixId, @path, 1, 1);") .SetParameterValue("@fileId", fileId) .SetParameterValue("@prefixId", prefixId) .SetParameterValue("@path", relative) .ExecuteNonQuery(); // Insert FilesetEntry cmd.SetCommandAndParameters(@" INSERT INTO FilesetEntry (FilesetID, FileID, Lastmodified) VALUES (@filesetId, @fileId, 0);") .SetParameterValue("@filesetId", filesetId) .SetParameterValue("@fileId", fileId) .ExecuteNonQuery(); fileId++; } } /// /// Tests that ListFolder works correctly with a large number of prefix IDs /// that triggers the temporary table code path (when count > CHUNK_SIZE = 128). /// [Test] [Category("Database")] public async Task ListFolder_WithLargePrefixIds_UsesTemporaryTableAsync() { using var tempFile = new TempFile(); await using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None) .ConfigureAwait(false); // Insert fileset entry using (var cmd = db.Connection.CreateCommand()) { await cmd.SetCommandAndParameters(@" INSERT OR IGNORE INTO Fileset (ID, OperationID, VolumeID, IsFullBackup, Timestamp) VALUES (@filesetId, 1, 1, 1, 0);") .SetParameterValue("@filesetId", 1L) .ExecuteNonQueryAsync(); } // Create 150 prefix IDs (exceeds CHUNK_SIZE of 128) to trigger temporary table path var prefixIds = new List(); for (int i = 0; i < 150; i++) { var prefix = $"/test/prefix/{i}/"; using var cmd = db.Connection.CreateCommand(); // Insert prefix await cmd.SetCommandAndParameters(@" INSERT OR IGNORE INTO PathPrefix (Prefix) VALUES (@prefix);") .SetParameterValue("@prefix", prefix) .ExecuteNonQueryAsync(); // Get prefix ID var prefixId = cmd.SetCommandAndParameters("SELECT ID FROM PathPrefix WHERE Prefix = @prefix") .SetParameterValue("@prefix", prefix) .ExecuteScalarInt64(); prefixIds.Add(prefixId); // Insert FileLookup await cmd.SetCommandAndParameters(@" INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID) VALUES (@fileId, @prefixId, @path, 1, 1);") .SetParameterValue("@fileId", i + 1) .SetParameterValue("@prefixId", prefixId) .SetParameterValue("@path", $"file{i}.txt") .ExecuteNonQueryAsync(); // Insert FilesetEntry await cmd.SetCommandAndParameters(@" INSERT INTO FilesetEntry (FilesetID, FileID, Lastmodified) VALUES (@filesetId, @fileId, 0);") .SetParameterValue("@filesetId", 1L) .SetParameterValue("@fileId", i + 1) .ExecuteNonQueryAsync(); } // Act: Call ListFolder with 150 prefix IDs // This should trigger the temporary table code path var result = await db.ListFolderAsync(prefixIds, 1, 0, 1000, CancellationToken.None) .ConfigureAwait(false); // Assert: Should return all 150 files Assert.That(result.Items.Count(), Is.EqualTo(150)); } /// /// Tests that works correctly /// with a large number of fileset IDs that triggers the temporary table code path /// (when count > CHUNK_SIZE = 128). /// [Test] [Category("Database")] public async Task ListFileVersions_WithLargeFilesetIds_UsesTemporaryTableAsync() { // Arrange using var tempFile = new TempFile(); await using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None) .ConfigureAwait(false); // Insert operation entry using (var cmd = db.Connection.CreateCommand()) { await cmd.SetCommandAndParameters(@" INSERT OR IGNORE INTO Operation (ID, Description, Timestamp) VALUES (1, 'TestOperation', 0);") .ExecuteNonQueryAsync(); } // Create 150 filesets (exceeds CHUNK_SIZE of 128) to trigger temporary table path var filesetIds = new List(); for (int i = 0; i < 150; i++) { using var cmd = db.Connection.CreateCommand(); // Insert RemoteVolume for the fileset await cmd.SetCommandAndParameters(@" INSERT INTO RemoteVolume (ID, OperationID, Name, Type, State, Size, VerificationCount, DeleteGraceTime, ArchiveTime, LockExpirationTime) VALUES (@id, @operationId, @name, @type, @state, @size, @verificationCount, @deleteGraceTime, @archiveTime, @lockExpirationTime);") .SetParameterValue("@id", i + 1) .SetParameterValue("@operationId", 1L) .SetParameterValue("@name", $"volume{i}.zip") .SetParameterValue("@type", "Files") .SetParameterValue("@state", "Verified") .SetParameterValue("@size", 1024L) .SetParameterValue("@verificationCount", 0) .SetParameterValue("@deleteGraceTime", 0) .SetParameterValue("@archiveTime", 0) .SetParameterValue("@lockExpirationTime", 0) .ExecuteNonQueryAsync(); // Insert Fileset await cmd.SetCommandAndParameters(@" INSERT INTO Fileset (ID, OperationID, VolumeID, IsFullBackup, Timestamp) VALUES (@id, @operationId, @volumeId, @isFullBackup, @timestamp);") .SetParameterValue("@id", i + 1) .SetParameterValue("@operationId", 1L) .SetParameterValue("@volumeId", i + 1) .SetParameterValue("@isFullBackup", 1) .SetParameterValue("@timestamp", i) .ExecuteNonQueryAsync(); filesetIds.Add(i + 1); } // Insert a single file that exists in all filesets using (var cmd = db.Connection.CreateCommand()) { // Insert Blockset await cmd.SetCommandAndParameters(@" INSERT INTO Blockset (ID, Length, FullHash) VALUES (1, 1024, 'fullhash');") .ExecuteNonQueryAsync(); // Insert Metadataset await cmd.SetCommandAndParameters(@" INSERT INTO Metadataset (ID, BlocksetID) VALUES (1, 1);") .ExecuteNonQueryAsync(); // Insert PathPrefix await cmd.SetCommandAndParameters(@" INSERT INTO PathPrefix (ID, Prefix) VALUES (1, '/test/');") .ExecuteNonQueryAsync(); // Insert FileLookup await cmd.SetCommandAndParameters(@" INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID) VALUES (1, 1, 'file.txt', 1, 1);") .ExecuteNonQueryAsync(); // Insert FilesetEntry for each fileset for (int i = 0; i < 150; i++) { await cmd.SetCommandAndParameters(@" INSERT INTO FilesetEntry (FilesetID, FileID, Lastmodified) VALUES (@filesetId, @fileId, @lastModified);") .SetParameterValue("@filesetId", i + 1) .SetParameterValue("@fileId", 1) .SetParameterValue("@lastModified", 0) .ExecuteNonQueryAsync(); } } // Act: Call ListFileVersions with 150 fileset IDs // This should trigger the temporary table code path var result = await db.ListFileVersionsAsync( new[] { "file.txt" }, filesetIds.ToArray(), 0, 1000, CancellationToken.None) .ConfigureAwait(false); // Assert: Should return all 150 file versions Assert.That(result.Items.Count(), Is.EqualTo(150)); } /// /// Tests that works correctly /// with a large number of fileset IDs that triggers the temporary table code path /// (when count > CHUNK_SIZE = 128). /// [Test] [Category("Database")] public async Task SearchEntries_WithLargeFilesetIds_UsesTemporaryTableAsync() { // Arrange using var tempFile = new TempFile(); await using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None) .ConfigureAwait(false); // Insert operation entry using (var cmd = db.Connection.CreateCommand()) { await cmd.SetCommandAndParameters(@" INSERT OR IGNORE INTO Operation (ID, Description, Timestamp) VALUES (1, 'TestOperation', 0);") .ExecuteNonQueryAsync(); } // Create 150 filesets (exceeds CHUNK_SIZE of 128) to trigger temporary table path var filesetIds = new List(); for (int i = 0; i < 150; i++) { using var cmd = db.Connection.CreateCommand(); // Insert RemoteVolume for the fileset await cmd.SetCommandAndParameters(@" INSERT INTO RemoteVolume (ID, OperationID, Name, Type, State, Size, VerificationCount, DeleteGraceTime, ArchiveTime, LockExpirationTime) VALUES (@id, @operationId, @name, @type, @state, @size, @verificationCount, @deleteGraceTime, @archiveTime, @lockExpirationTime);") .SetParameterValue("@id", i + 1) .SetParameterValue("@operationId", 1L) .SetParameterValue("@name", $"volume{i}.zip") .SetParameterValue("@type", "Files") .SetParameterValue("@state", "Verified") .SetParameterValue("@size", 1024L) .SetParameterValue("@verificationCount", 0) .SetParameterValue("@deleteGraceTime", 0) .SetParameterValue("@archiveTime", 0) .SetParameterValue("@lockExpirationTime", 0) .ExecuteNonQueryAsync(); // Insert Fileset await cmd.SetCommandAndParameters(@" INSERT INTO Fileset (ID, OperationID, VolumeID, IsFullBackup, Timestamp) VALUES (@id, @operationId, @volumeId, @isFullBackup, @timestamp);") .SetParameterValue("@id", i + 1) .SetParameterValue("@operationId", 1L) .SetParameterValue("@volumeId", i + 1) .SetParameterValue("@isFullBackup", 1) .SetParameterValue("@timestamp", i) .ExecuteNonQueryAsync(); filesetIds.Add(i + 1); } // Insert files that exist in all filesets using (var cmd = db.Connection.CreateCommand()) { // Insert Blockset await cmd.SetCommandAndParameters(@" INSERT INTO Blockset (ID, Length, FullHash) VALUES (1, 1024, 'fullhash');") .ExecuteNonQueryAsync(); // Insert Metadataset await cmd.SetCommandAndParameters(@" INSERT INTO Metadataset (ID, BlocksetID) VALUES (1, 1);") .ExecuteNonQueryAsync(); // Insert PathPrefix await cmd.SetCommandAndParameters(@" INSERT INTO PathPrefix (ID, Prefix) VALUES (1, '/test/');") .ExecuteNonQueryAsync(); // Insert FileLookup entries for (int i = 0; i < 10; i++) { await cmd.SetCommandAndParameters(@" INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID) VALUES (@id, @prefixId, @path, @blocksetId, @metadataId);") .SetParameterValue("@id", i + 1) .SetParameterValue("@prefixId", 1) .SetParameterValue("@path", $"file{i}.txt") .SetParameterValue("@blocksetId", 1) .SetParameterValue("@metadataId", 1) .ExecuteNonQueryAsync(); } // Insert FilesetEntry for each fileset and file for (int i = 0; i < 150; i++) { for (int j = 0; j < 10; j++) { await cmd.SetCommandAndParameters(@" INSERT INTO FilesetEntry (FilesetID, FileID, Lastmodified) VALUES (@filesetId, @fileId, @lastModified);") .SetParameterValue("@filesetId", i + 1) .SetParameterValue("@fileId", j + 1) .SetParameterValue("@lastModified", 0) .ExecuteNonQueryAsync(); } } } // Act: Call SearchEntries with 150 fileset IDs // This should trigger the temporary table code path // Use a simple filter that matches all files (empty filter matches everything) var result = await db.SearchEntriesAsync( null, new FilterExpression("*", true), false, filesetIds.ToArray(), false, 0, 2000, CancellationToken.None) .ConfigureAwait(false); // Assert: Should return all 1500 file versions (150 filesets * 10 files) Assert.That(result.Items.Count(), Is.EqualTo(1500)); } /// /// Tests that can match values inside /// metadata JSON when is true. /// [Test] [Category("Database")] public async Task SearchEntries_WithMetadataSearch_MatchesMetadataValuesAsync() { // Arrange using var tempFile = new TempFile(); await using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None) .ConfigureAwait(false); // Insert operation entry using (var cmd = db.Connection.CreateCommand()) { await cmd.SetCommandAndParameters(@" INSERT OR IGNORE INTO Operation (ID, Description, Timestamp) VALUES (1, 'TestOperation', 0);") .ExecuteNonQueryAsync(); } // Insert RemoteVolume and Fileset using (var cmd = db.Connection.CreateCommand()) { await cmd.SetCommandAndParameters(@" INSERT INTO RemoteVolume (ID, OperationID, Name, Type, State, Size, VerificationCount, DeleteGraceTime, ArchiveTime, LockExpirationTime) VALUES (1, 1, 'volume.zip', 'Files', 'Verified', 1024, 0, 0, 0, 0);") .ExecuteNonQueryAsync(); await cmd.SetCommandAndParameters(@" INSERT INTO Fileset (ID, OperationID, VolumeID, IsFullBackup, Timestamp) VALUES (1, 1, 1, 1, 0);") .ExecuteNonQueryAsync(); } using (var cmd = db.Connection.CreateCommand()) { // Insert Blockset for the Metadataset await cmd.SetCommandAndParameters(@" INSERT INTO Blockset (ID, Length, FullHash) VALUES (1, 1024, 'fullhash');") .ExecuteNonQueryAsync(); // Insert two metadata entries await cmd.SetCommandAndParameters(@" INSERT INTO Metadataset (ID, BlocksetID, Content) VALUES (1, 1, '{""o365:Name"": ""MyDocument"", ""o365:Type"": ""DriveFile""}');") .ExecuteNonQueryAsync(); await cmd.SetCommandAndParameters(@" INSERT INTO Metadataset (ID, BlocksetID, Content) VALUES (2, 1, '{""gsuite:Name"": ""MySheet"", ""gsuite:Type"": ""Spreadsheet""}');") .ExecuteNonQueryAsync(); // Insert PathPrefix await cmd.SetCommandAndParameters(@" INSERT INTO PathPrefix (ID, Prefix) VALUES (1, '/test/');") .ExecuteNonQueryAsync(); // Insert FileLookup entries: one with metadata, one without // File 1: has metadata matching "MyDocument" await cmd.SetCommandAndParameters(@" INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID) VALUES (1, 1, 'file1.txt', 1, 1);") .ExecuteNonQueryAsync(); // File 2: has metadata matching "MySheet" (different key) await cmd.SetCommandAndParameters(@" INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID) VALUES (2, 1, 'file2.txt', 1, 2);") .ExecuteNonQueryAsync(); // File 3: no metadata await cmd.SetCommandAndParameters(@" INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID) VALUES (3, 1, 'file3.txt', 1, 0);") .ExecuteNonQueryAsync(); // Folder 4: folder with metadata matching "MyDocument" // BlocksetID = -100 is FOLDER_BLOCKSET_ID await cmd.SetCommandAndParameters(@" INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID) VALUES (4, 1, 'folder1', -100, 1);") .ExecuteNonQueryAsync(); // Insert FilesetEntry for all four entries for (int fileId = 1; fileId <= 4; fileId++) { await cmd.SetCommandAndParameters(@" INSERT INTO FilesetEntry (FilesetID, FileID, Lastmodified) VALUES (@filesetId, @fileId, @lastModified);") .SetParameterValue("@filesetId", 1) .SetParameterValue("@fileId", fileId) .SetParameterValue("@lastModified", 0) .ExecuteNonQueryAsync(); } } // Act: Search for "MyDocument" with metadata search enabled var result = await db.SearchEntriesAsync( null, new FilterExpression("MyDocument", true), false, [1], true, // searchMetadata 0, 100, CancellationToken.None) .ConfigureAwait(false); // Assert: Both file1.txt and folder1 should match (via o365:Name metadata) Assert.That(result.Items.Count(), Is.EqualTo(2)); var paths = result.Items.Select(x => x.Path).OrderBy(x => x).ToList(); Assert.That(paths[0], Is.EqualTo("/test/file1.txt")); Assert.That(paths[1], Is.EqualTo("/test/folder1")); Assert.That(result.Items.First(x => x.Path == "/test/folder1").IsDirectory, Is.True); // Act: Search for "MySheet" with metadata search enabled result = await db.SearchEntriesAsync( null, new FilterExpression("MySheet", true), false, [1], true, // searchMetadata 0, 100, CancellationToken.None) .ConfigureAwait(false); // Assert: Only file2.txt should match (via gsuite:Name metadata) Assert.That(result.Items.Count(), Is.EqualTo(1)); Assert.That(result.Items.First().Path, Is.EqualTo("/test/file2.txt")); // Act: Search for "MyDocument" with metadata search DISABLED result = await db.SearchEntriesAsync( null, new FilterExpression("MyDocument", true), false, [1], false, // searchMetadata disabled 0, 100, CancellationToken.None) .ConfigureAwait(false); // Assert: No files should match (path doesn't contain "MyDocument") Assert.That(result.Items.Count(), Is.EqualTo(0)); } } }