// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. #nullable enable using System; using System.Linq; using System.Collections.Generic; using System.Threading.Tasks; using Duplicati.Library.Common.IO; using Duplicati.Library.Utility; using Duplicati.Library.Interface; using Microsoft.Data.Sqlite; using System.Threading; using System.Runtime.CompilerServices; namespace Duplicati.Library.Main.Database { /// /// A local database that stores file lists and filesets. /// internal class LocalListDatabase : LocalDatabase { /// /// Creates a new instance of the LocalListDatabase. /// /// The path to the database file. /// An optional existing database instance to use. Used to mimic constructor chaining. /// A cancellation token to cancel the operation. /// A task that when awaited contains a new instance of LocalListDatabase. public static async Task CreateAsync(string path, LocalListDatabase? dbnew, CancellationToken token) { dbnew ??= new LocalListDatabase(); dbnew = (LocalListDatabase) await CreateLocalDatabaseAsync(path, "List", false, dbnew, token) .ConfigureAwait(false); dbnew.ShouldCloseConnection = true; return dbnew; } /// /// Interface for file versions. /// public interface IFileversion { /// /// Gets the path of the file, or null if not available. /// string? Path { get; } /// /// Asynchronously retrieves the sizes of the file versions. /// /// A cancellation token to cancel the operation. /// An asynchronous enumerable of file sizes. IAsyncEnumerable Sizes(CancellationToken token); } /// /// Interface for filesets. /// public interface IFileset { /// /// Gets the version of the fileset. /// long Version { get; } /// /// Gets or sets a value indicating whether the fileset is a full backup. /// int IsFullBackup { get; set; } /// /// Gets the timestamp of the fileset. /// DateTime Time { get; } /// /// Gets the count of files in the fileset. /// long FileCount { get; } /// /// Gets the total sizes of files in the fileset. /// long FileSizes { get; } } /// /// Interface for managing file sets in the local database. /// public interface IFileSets : IDisposable, IAsyncDisposable { /// /// Gets all of the filesets in the database as an asynchronous enumerable, including file count and total size. /// /// A cancellation token to cancel the operation. /// An asynchronous enumerable of filesets. IAsyncEnumerable Sets(CancellationToken token); /// /// Gets all of the filesets in the database as an asynchronous enumerable, omitting file count and total size. /// /// A cancellation token to cancel the operation. /// An asynchronous enumerable of filesets. IAsyncEnumerable QuickSets(CancellationToken token); /// /// Gets the files in the filesets that match the provided filter as an asynchronous enumerable. /// /// The filter to apply to the files. /// A cancellation token to cancel the operation. /// An asynchronous enumerable of file versions representing the selected files. IAsyncEnumerable SelectFiles(IFilter filter, CancellationToken token); /// /// Gets the largest prefix of files in the filesets that match the provided filter as an asynchronous enumerable. /// /// The filter to apply to the files. /// A cancellation token to cancel the operation. /// An asynchronous enumerable of file versions representing the largest prefix. IAsyncEnumerable GetLargestPrefix(IFilter filter, CancellationToken token); /// /// Selects the contents of a folder in the filesets that match the provided filter as an asynchronous enumerable. /// /// The filter to apply to the folder contents. /// A cancellation token to cancel the operation. /// An asynchronous enumerable of file versions representing the folder contents. IAsyncEnumerable SelectFolderContents(IFilter filter, CancellationToken token); /// /// Deletes all filesets except the most recent one. /// /// A cancellation token to cancel the operation. /// A task that completes when the filesets have been deleted. Task TakeFirst(CancellationToken token); } /// /// Creates a new instance of the class implementing the interface. /// private class FileSets : IFileSets { /// /// The database instance to execute commands against. /// private LocalDatabase m_db = null!; /// /// The name of the temporary table used to store filesets. /// private string m_tablename = null!; /// /// An array of key-value pairs representing filesets, where the key is the fileset ID and the value is the timestamp. /// private KeyValuePair[] m_filesets = []; /// /// Private constructor to prevent instantiation without using CreateAsync. /// [Obsolete("Calling this constructor will throw an exception. Use CreateAsync instead.")] public FileSets(LocalListDatabase owner, DateTime time, long[] versions) { throw new NotSupportedException("Use CreateAsync instead."); } /// /// Private constructor to prevent instantiation without using CreateAsync. /// private FileSets() { } /// /// Asynchronously creates a new instance of the class. /// /// The owner database instance. /// The time to filter filesets by. /// An array of versions to filter filesets by. /// A cancellation token to cancel the operation. /// A task that when awaited contains a new instance of . public static async Task CreateAsync(LocalListDatabase owner, DateTime time, long[] versions, CancellationToken token) { var fs = new FileSets { m_db = owner, m_filesets = await owner .FilesetTimes(token) .ToArrayAsync(cancellationToken: token) .ConfigureAwait(false), m_tablename = $"Filesets-{Library.Utility.Utility.GetHexGuid()}" }; var (Query, Values) = await owner.GetFilelistWhereClause(time, versions, fs.m_filesets, false, token) .ConfigureAwait(false); await using (var cmd = fs.m_db.Connection.CreateCommand()) { await cmd.ExecuteNonQueryAsync($@" CREATE TEMPORARY TABLE ""{fs.m_tablename}"" AS SELECT DISTINCT ""ID"" AS ""FilesetID"", ""IsFullBackup"" AS ""IsFullBackup"", ""Timestamp"" AS ""Timestamp"" FROM ""Fileset"" {Query} ", Values, token) .ConfigureAwait(false); await cmd.ExecuteNonQueryAsync($@" CREATE INDEX ""{fs.m_tablename}_FilesetIDTimestampIndex"" ON ""{fs.m_tablename}"" ( ""FilesetID"", ""Timestamp"" DESC ) ", token) .ConfigureAwait(false); } return fs; } /// /// Internal class representing a fileset. /// private class Fileset : IFileset { /// /// The backup version this fileset is part of. /// public long Version { get; private set; } /// /// Indicates whether this fileset is a full backup (1) or a partial backup (0). /// public int IsFullBackup { get; set; } /// /// The timestamp of the fileset, indicating when it was created. /// public DateTime Time { get; private set; } /// /// The count of files in this fileset. /// public long FileCount { get; private set; } /// /// The total sizes of files in this fileset. /// public long FileSizes { get; private set; } /// /// Initializes a new instance of the class. /// /// The backup version of the fileset. /// Indicates whether this fileset is a full backup (1) or a partial backup (0). /// The timestamp of the fileset, indicating when it was created. /// The count of files in this fileset. /// The total sizes of files in this fileset. public Fileset(long version, int isFullBackup, DateTime time, long filecount, long filesizes) { Version = version; IsFullBackup = isFullBackup; Time = time; FileCount = filecount; FileSizes = filesizes; } } /// /// Implementation of the interface. /// private class Fileversion : IFileversion { /// /// The SqliteDataReader used to read file version data. /// private readonly SqliteDataReader m_reader; /// /// The path of the file, or null if not available. /// public string? Path { get; private set; } /// /// Indicates whether there are more file versions to read. /// public bool More { get; private set; } /// /// Initializes a new instance of the class. /// /// The SqliteDataReader used to read file version data. public Fileversion(SqliteDataReader reader) { m_reader = reader; Path = reader.ConvertValueToString(0); More = true; } /// public async IAsyncEnumerable Sizes([EnumeratorCancellation] CancellationToken token) { while (More && Path == m_reader.ConvertValueToString(0)) { yield return m_reader.ConvertValueToInt64(1, -1); More = await m_reader.ReadAsync(token).ConfigureAwait(false); } } } /// /// Internal class representing a fixed file version with no sizes. /// private class FileversionFixed : IFileversion { /// public string? Path { get; internal set; } /// public IAsyncEnumerable Sizes(CancellationToken token) => AsyncEnumerable.Empty(); } /// public IAsyncEnumerable GetLargestPrefix(IFilter filter, CancellationToken token) { return GetLargestPrefix(filter, null, token); } /// /// Recursively finds the largest prefix of files in the filesets that match the provided filter. /// /// The filter to apply to the files. /// The prefix rule to apply, or null if no prefix rule is specified. /// A cancellation token to cancel the operation. /// An asynchronous enumerable of file versions representing the largest prefix. private async IAsyncEnumerable GetLargestPrefix(IFilter filter, string? prefixrule, [EnumeratorCancellation] CancellationToken token) { await using var tmpnames = await FilteredFilenameTable .CreateFilteredFilenameTableAsync(m_db, filter, token) .ConfigureAwait(false); await using var cmd = m_db.Connection.CreateCommand(); //First we trim the filelist to exclude filenames not found in any of the filesets await cmd.ExecuteNonQueryAsync($@" DELETE FROM ""{tmpnames.Tablename}"" WHERE ""Path"" NOT IN ( SELECT DISTINCT ""Path"" FROM ""File"", ""FilesetEntry"" WHERE ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND ""FilesetEntry"".""FilesetID"" IN ( SELECT ""FilesetID"" FROM ""{m_tablename}"" ) ) ", token) .ConfigureAwait(false); //If we have a prefix rule, apply it if (!string.IsNullOrWhiteSpace(prefixrule)) await cmd.SetCommandAndParameters($@" DELETE FROM ""{tmpnames.Tablename}"" WHERE SUBSTR(""Path"", 1, {Library.Utility.Utility.FormatInvariantValue(prefixrule.Length)}) != @Rule ") .SetParameterValue("@Rule", prefixrule) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); // Then we recursively find the largest prefix var v0 = await cmd.ExecuteScalarAsync($@" SELECT ""Path"" FROM ""{tmpnames.Tablename}"" ORDER BY LENGTH(""Path"") DESC LIMIT 1 ", token) .ConfigureAwait(false); var maxpath = ""; if (v0 != null) maxpath = v0.ToString() ?? ""; var dirsep = Util.GuessDirSeparator(maxpath); var filecount = await cmd.ExecuteScalarInt64Async($@" SELECT COUNT(*) FROM ""{tmpnames.Tablename}"" ", 0, token) .ConfigureAwait(false); var foundfiles = -1L; //TODO: Handle FS case-sensitive? await cmd.SetCommandAndParameters($@" SELECT COUNT(*) FROM ""{tmpnames.Tablename}"" WHERE SUBSTR(""Path"", 1, @PrefixLength) = @Prefix ") .PrepareAsync(token) .ConfigureAwait(false); while (filecount != foundfiles && maxpath.Length > 0) { var mp = Util.AppendDirSeparator(maxpath, dirsep); foundfiles = await cmd .SetParameterValue("@PrefixLength", mp.Length) .SetParameterValue("@Prefix", mp) .ExecuteScalarInt64Async(0, token) .ConfigureAwait(false); if (filecount != foundfiles) { var oldlen = maxpath.Length; var lix = maxpath.LastIndexOf(dirsep, maxpath.Length - 2, StringComparison.Ordinal); maxpath = maxpath.Substring(0, lix + 1); if (string.IsNullOrWhiteSpace(maxpath) || maxpath.Length == oldlen || maxpath == "\\\\") maxpath = ""; } } // Special handling for Windows and multi-drive/UNC backups as they do not have a single common root if (string.IsNullOrWhiteSpace(maxpath) && string.IsNullOrWhiteSpace(prefixrule)) { var paths = cmd.ExecuteReaderEnumerableAsync($@" SELECT ""Path"" FROM ""{tmpnames.Tablename}"" ", token) .Select(x => x.ConvertValueToString(0) ?? ""); var roots = paths .Select(x => x.Substring(0, 1)) .Distinct() .Where(x => x != "\\"); //unc path like \\server.domain\ var regexUNCPrefix = new System.Text.RegularExpressions.Regex(@"^\\\\.*?\\"); var rootsUNC = paths .Select(x => regexUNCPrefix.Match(x)) .Where(x => x.Success) .Select(x => x.Value) .Distinct(); var result = roots .Concat(rootsUNC) .Select(x => GetLargestPrefix(filter, x, token) .FirstAsync()) .Distinct(); await foreach (var el in result.ConfigureAwait(false)) yield return await el; } yield return new FileversionFixed { Path = maxpath == "" ? "" : Util.AppendDirSeparator(maxpath, dirsep) }; } /// /// Asynchronously retrieves all folder entries from the specified table that match the given prefix. /// /// The SqliteCommand to execute. /// The prefix to filter folder entries by. /// The name of the table to query. /// A cancellation token to cancel the operation. /// An asynchronous enumerable of folder entries. private static async IAsyncEnumerable SelectFolderEntries(SqliteCommand cmd, string prefix, string table, [EnumeratorCancellation] CancellationToken token) { if (!string.IsNullOrEmpty(prefix)) prefix = Util.AppendDirSeparator(prefix, Util.GuessDirSeparator(prefix)); var ppl = prefix.Length; cmd.SetCommandAndParameters($@" SELECT DISTINCT ""Path"" FROM ""{table}"" "); await using var rd = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); while (await rd.ReadAsync(token).ConfigureAwait(false)) { var s = rd.ConvertValueToString(0) ?? ""; if (!s.StartsWith(prefix, StringComparison.Ordinal)) continue; var dirsep = Util.GuessDirSeparator(s); s = s.Substring(ppl); var ix = s.IndexOf(dirsep, StringComparison.Ordinal); if (ix > 0 && ix != s.Length - 1) s = s.Substring(0, ix + 1); yield return prefix + s; } } /// public async IAsyncEnumerable SelectFolderContents(IFilter filter, [EnumeratorCancellation] CancellationToken token) { var tbname = $"Filenames-{Library.Utility.Utility.GetHexGuid()}"; try { string pathprefix; if (filter == null || filter.Empty) pathprefix = ""; else if (filter as FilterExpression == null || ((FilterExpression)filter).Type != FilterType.Simple || ((FilterExpression)filter).GetSimpleList().Length != 1) throw new ArgumentException("Filter for list-folder-contents must be a path prefix with no wildcards", nameof(filter)); else pathprefix = ((FilterExpression)filter).GetSimpleList().First(); var dirsep = Util.GuessDirSeparator(pathprefix); if (pathprefix.Length > 0 || dirsep == "/") pathprefix = Util.AppendDirSeparator(pathprefix, dirsep); await using var tmpnames = await FilteredFilenameTable .CreateFilteredFilenameTableAsync(m_db, new FilterExpression(new string[] { pathprefix + "*" }, true), token) .ConfigureAwait(false); await using var cmd = m_db.Connection.CreateCommand(); //First we trim the filelist to exclude filenames not found in any of the filesets await cmd.ExecuteNonQueryAsync($@" DELETE FROM ""{tmpnames.Tablename}"" WHERE ""Path"" NOT IN ( SELECT DISTINCT ""Path"" FROM ""File"", ""FilesetEntry"" WHERE ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND ""FilesetEntry"".""FilesetID"" IN ( SELECT ""FilesetID"" FROM ""{m_tablename}"" ) ) ", token) .ConfigureAwait(false); // If we had instr support this would work: /*var distinctPaths = @"SELECT DISTINCT :1 || " + @"CASE(INSTR(SUBSTR(""Path"", :2), '/')) " + @"WHEN 0 THEN SUBSTR(""Path"", :2) " + @"ELSE SUBSTR(""Path"", :2, INSTR(SUBSTR(path, :2), '/')) " + @"END AS ""Path"", ""FilesetID"" " + @" FROM (" + cartesianPathFileset + @")";*/ // Instead we manually iterate the paths await cmd.ExecuteNonQueryAsync($@" CREATE TEMPORARY TABLE ""{tbname}"" ( ""Path"" TEXT NOT NULL ) ", token) .ConfigureAwait(false); await using (var c2 = m_db.Connection.CreateCommand()) { c2.SetCommandAndParameters($@" INSERT INTO ""{tbname}"" (""Path"") VALUES (@Path) "); await foreach (var n in SelectFolderEntries(cmd, pathprefix, tmpnames.Tablename, token).Distinct().ConfigureAwait(false)) await c2 .SetParameterValue("@Path", n) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); await c2.ExecuteNonQueryAsync($@" CREATE INDEX ""{tbname}_PathIndex"" ON ""{tbname}"" (""Path"") ", token) .ConfigureAwait(false); } //Then we select the matching results var filesets = $@" SELECT ""FilesetID"", ""Timestamp"" FROM ""{m_tablename}"" ORDER BY ""Timestamp"" DESC "; var cartesianPathFileset = $@" SELECT ""A"".""Path"", ""B"".""FilesetID"" FROM ""{tbname}"" ""A"", ({filesets}) ""B"" ORDER BY ""A"".""Path"" ASC, ""B"".""Timestamp"" DESC "; var filesWithSizes = $@" SELECT ""Length"", ""FilesetEntry"".""FilesetID"", ""File"".""Path"" FROM ""Blockset"", ""FilesetEntry"", ""File"" WHERE ""File"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND FilesetEntry.""FilesetID"" IN ( SELECT DISTINCT ""FilesetID"" FROM ""{m_tablename}"" ) "; var query = @$" SELECT ""C"".""Path"", ""D"".""Length"", ""C"".""FilesetID"" FROM ({cartesianPathFileset}) ""C"" LEFT OUTER JOIN ({filesWithSizes}) ""D"" ON ""C"".""FilesetID"" = ""D"".""FilesetID"" AND ""C"".""Path"" = ""D"".""Path"" "; await using var rd = await cmd .ExecuteReaderAsync(query, token) .ConfigureAwait(false); if (await rd.ReadAsync(token).ConfigureAwait(false)) { bool more; do { var f = new Fileversion(rd); if (!(string.IsNullOrWhiteSpace(f.Path) || f.Path == pathprefix)) { yield return f; more = f.More; } else { more = await rd.ReadAsync(token) .ConfigureAwait(false); } } while (more); } } finally { try { await using var c = m_db.Connection.CreateCommand(); await c.ExecuteNonQueryAsync($@"DROP TABLE IF EXISTS ""{tbname}""", token) .ConfigureAwait(false); } catch { } } } /// public async IAsyncEnumerable SelectFiles(IFilter filter, [EnumeratorCancellation] CancellationToken token) { await using var tmpnames = await FilteredFilenameTable .CreateFilteredFilenameTableAsync(m_db, filter, token) .ConfigureAwait(false); await using var cmd = m_db.Connection.CreateCommand(); //First we trim the filelist to exclude filenames not found in any of the filesets await cmd.ExecuteNonQueryAsync($@" DELETE FROM ""{tmpnames.Tablename}"" WHERE ""Path"" NOT IN ( SELECT DISTINCT ""Path"" FROM ""File"", ""FilesetEntry"" WHERE ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND ""FilesetEntry"".""FilesetID"" IN ( SELECT ""FilesetID"" FROM ""{m_tablename}"" ) ) ", token) .ConfigureAwait(false); //Then we select the matching results var filesets = $@" SELECT ""FilesetID"", ""Timestamp"" FROM ""{m_tablename}"" ORDER BY ""Timestamp"" DESC "; var cartesianPathFileset = $@" SELECT ""A"".""Path"", ""B"".""FilesetID"" FROM ""{tmpnames.Tablename}"" ""A"", ({filesets}) ""B"" ORDER BY ""A"".""Path"" ASC, ""B"".""Timestamp"" DESC "; var filesWithSizes = $@" SELECT ""Length"", ""FilesetEntry"".""FilesetID"", ""File"".""Path"" FROM ""Blockset"", ""FilesetEntry"", ""File"" WHERE ""File"".""BlocksetID"" = ""Blockset"".""ID"" AND ""FilesetEntry"".""FileID"" = ""File"".""ID"" AND FilesetEntry.""FilesetID"" IN ( SELECT DISTINCT ""FilesetID"" FROM ""{m_tablename}"" ) "; var query = @$" SELECT ""C"".""Path"", ""D"".""Length"", ""C"".""FilesetID"" FROM ({cartesianPathFileset}) ""C"" LEFT OUTER JOIN ({filesWithSizes}) ""D"" ON ""C"".""FilesetID"" = ""D"".""FilesetID"" AND ""C"".""Path"" = ""D"".""Path"" "; await using var rd = await cmd.ExecuteReaderAsync(query, token) .ConfigureAwait(false); if (await rd.ReadAsync(token).ConfigureAwait(false)) { bool more; do { var f = new Fileversion(rd); yield return f; more = f.More; } while (more); } } /// public async Task TakeFirst(CancellationToken token) { await using var cmd = m_db.Connection.CreateCommand(); await cmd.ExecuteNonQueryAsync($@" DELETE FROM ""{m_tablename}"" WHERE ""FilesetID"" NOT IN ( SELECT ""FilesetID"" FROM ""{m_tablename}"" ORDER BY ""Timestamp"" DESC LIMIT 1 ) ", token) .ConfigureAwait(false); } /// public async IAsyncEnumerable QuickSets([EnumeratorCancellation] CancellationToken token) { var dict = new Dictionary(); for (var i = 0; i < m_filesets.Length; i++) dict[m_filesets[i].Key] = i; await using var cmd = m_db.Connection.CreateCommand(); await using var rd = await cmd.ExecuteReaderAsync(@" SELECT DISTINCT ""ID"", ""IsFullBackup"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC ", token) .ConfigureAwait(false); while (await rd.ReadAsync(token).ConfigureAwait(false)) { var id = rd.ConvertValueToInt64(0); var backupType = rd.GetInt32(1); var e = dict[id]; yield return new Fileset( e, backupType, m_filesets[e].Value, -1L, -1L ); } } /// public async IAsyncEnumerable Sets([EnumeratorCancellation] CancellationToken token) { var dict = new Dictionary(); for (var i = 0; i < m_filesets.Length; i++) dict[m_filesets[i].Key] = i; var summation = $@" SELECT ""A"".""FilesetID"" AS ""FilesetID"", COUNT(*) AS ""FileCount"", SUM(""C"".""Length"") AS ""FileSizes"" FROM ""FilesetEntry"" ""A"", ""File"" ""B"", ""Blockset"" ""C"" WHERE ""A"".""FileID"" = ""B"".""ID"" AND ""B"".""BlocksetID"" = ""C"".""ID"" AND ""A"".""FilesetID"" IN ( SELECT DISTINCT ""FilesetID"" FROM ""{m_tablename}"") GROUP BY ""A"".""FilesetID"" "; await using var cmd = m_db.Connection.CreateCommand(m_db.Transaction); await using var rd = await cmd.ExecuteReaderAsync($@" SELECT DISTINCT ""A"".""FilesetID"", ""A"".""IsFullBackup"", ""B"".""FileCount"", ""B"".""FileSizes"" FROM ""{m_tablename}"" ""A"" LEFT OUTER JOIN ( {summation} ) ""B"" ON ""A"".""FilesetID"" = ""B"".""FilesetID"" ORDER BY ""A"".""Timestamp"" DESC ", token) .ConfigureAwait(false); while (await rd.ReadAsync(token).ConfigureAwait(false)) { var id = rd.ConvertValueToInt64(0); var isFullBackup = rd.GetInt32(1); var e = dict[id]; var filecount = rd.ConvertValueToInt64(2, -1L); var filesizes = rd.ConvertValueToInt64(3, -1L); yield return new Fileset( e, isFullBackup, m_filesets[e].Value, filecount, filesizes ); } } public void Dispose() { DisposeAsync().AsTask().Await(); } public async ValueTask DisposeAsync() { if (m_tablename != null) { try { await using var cmd = m_db.Connection.CreateCommand(); await cmd .ExecuteNonQueryAsync(@$"DROP TABLE IF EXISTS ""{m_tablename}"" ", default) .ConfigureAwait(false); } catch { } finally { m_tablename = null!; } } } } /// /// Creates a new instance of with the specified time and versions. /// /// The time to filter filesets by. /// An array of versions to filter filesets by. /// A task that when awaited returns a new instance of . public async Task SelectFileSets(DateTime time, long[] versions, CancellationToken token) { return await FileSets.CreateAsync(this, time, versions, token) .ConfigureAwait(false); } /// /// Represents a fileset entry in the list fileset results. /// /// The fileset version. /// The fileset ID /// Flag indicating if the backup is a full backup or synthetic backup. /// The timestamp of the fileset. /// The number of files in the fileset. /// >The total size of files in the fileset. private sealed record FilesetEntry(long Version, long ID, bool? IsFullBackup, DateTime Time, long? FileCount, long? FileSizes) : IListFilesetResultFileset; /// /// Lists all filesets with summary data. /// /// A cancellation token to cancel the operation. /// An asynchronous enumerable of fileset entries. public async IAsyncEnumerable ListFilesetsExtended([EnumeratorCancellation] CancellationToken token) { const string query = @" SELECT ""f"".""ID"" AS ""FilesetID"", ""f"".""Timestamp"", ""f"".""IsFullBackup"", COUNT(""fe"".""FileID"") AS ""NumberOfFiles"", COALESCE(SUM(""b"".""Length""), 0) AS ""TotalFileSize"" FROM ""Fileset"" ""f"" LEFT JOIN ""FilesetEntry"" ""fe"" ON ""f"".""ID"" = ""fe"".""FilesetID"" LEFT JOIN ""FileLookup"" ""fl"" ON ""fe"".""FileID"" = ""fl"".""ID"" LEFT JOIN ""Blockset"" ""b"" ON ""fl"".""BlocksetID"" = ""b"".""ID"" GROUP BY ""f"".""ID"" ORDER BY ""f"".""Timestamp"" DESC; "; var version = 0L; await using var cmd = m_connection.CreateCommand(query); await foreach (var rd in cmd.ExecuteReaderEnumerableAsync(token).ConfigureAwait(false)) { var id = rd.ConvertValueToInt64(0); var time = ParseFromEpochSeconds(rd.ConvertValueToInt64(1)); var isFullBackup = rd.GetInt32(2) == BackupType.FULL_BACKUP; var filecount = rd.ConvertValueToInt64(3, -1L); var filesizes = rd.ConvertValueToInt64(4, -1L); yield return new FilesetEntry( version++, id, isFullBackup, time, filecount, filesizes ); } } /// /// Represents a folder entry in the list folder results. /// /// The path of the folder entry. /// The size of the folder entry. /// Indicates if the entry is a directory. /// Indicates if the entry is a symbolic link. /// The last modified date of the folder entry. private sealed record FolderEntry(string Path, long Size, bool IsDirectory, bool IsSymlink, DateTime LastModified) : IListFolderEntry; /// /// Lists the folder entries for a given fileset and prefix IDs. /// /// The prefix IDs to filter the folder entries. /// The fileset ID to filter the folder entries. /// The offset for pagination. /// >The limit for pagination. /// A cancellation token to cancel the operation. /// A task that when awaited returns a paginated result set of folder entries. public async Task> ListFolder(IEnumerable prefixIds, long filesetid, long offset, long limit, CancellationToken token) { if (offset != 0 && limit <= 0) throw new ArgumentException("Cannot use offset without limit specified.", nameof(offset)); if (limit <= 0) limit = long.MaxValue; await using var cmd = m_connection.CreateCommand(); if (prefixIds.Count() == 0) return new PaginatedResults(0, (int)limit, 0, 0, Enumerable.Empty()); // Then query the matching files cmd.SetCommandAndParameters($@" SELECT ""pp"".""Prefix"" || ""fl"".""Path"" AS ""FullPath"", ""b"".""Length"", (CASE WHEN ""fl"".""BlocksetID"" = @FolderBlocksetId THEN 1 ELSE 0 END) AS ""IsDirectory"", (CASE WHEN ""fl"".""BlocksetID"" = @SymlinkBlocksetId THEN 1 ELSE 0 END) AS ""IsSymlink"", ""fe"".""Lastmodified"" FROM ""FilesetEntry"" ""fe"" INNER JOIN ""FileLookup"" ""fl"" ON ""fe"".""FileID"" = ""fl"".""ID"" INNER JOIN ""PathPrefix"" ""pp"" ON ""fl"".""PrefixID"" = ""pp"".""ID"" LEFT JOIN ""Blockset"" ""b"" ON ""fl"".""BlocksetID"" = ""b"".""ID"" WHERE ""fe"".""FilesetID"" = @filesetid AND ""fl"".""PrefixID"" IN (@PrefixIds) ORDER BY ""pp"".""Prefix"" ASC, ""fl"".""Path"" ASC LIMIT @limit OFFSET @offset ") .SetParameterValue("@FolderBlocksetId", FOLDER_BLOCKSET_ID) .SetParameterValue("@SymlinkBlocksetId", SYMLINK_BLOCKSET_ID) .ExpandInClauseParameterMssqlite("@PrefixIds", prefixIds) .SetParameterValue("@filesetid", filesetid) .SetParameterValue("@limit", limit) .SetParameterValue("@offset", offset); var results = new List(); await foreach (var rd in cmd.ExecuteReaderEnumerableAsync(token).ConfigureAwait(false)) { var path = rd.ConvertValueToString(0) ?? string.Empty; var size = rd.ConvertValueToInt64(1); var isDir = rd.GetInt32(2) != 0; var isSymlink = rd.GetInt32(3) != 0; var lastModified = new DateTime(rd.ConvertValueToInt64(4, 0), DateTimeKind.Utc); results.Add(new FolderEntry( path, size, isDir, isSymlink, lastModified )); } long totalCount; if (offset != 0) { // Only calculate the total for the first query totalCount = -1; } else if (results.Count <= limit) { // If we have all results, we already know the total totalCount = results.Count; } else { // Get the total count of files for the given fileset and prefix IDs totalCount = await cmd.SetCommandAndParameters($@" SELECT COUNT(*) FROM ""FilesetEntry"" ""fe"" INNER JOIN ""FileLookup"" ""fl"" ON ""fe"".""FileID"" = ""fl"".""ID"" WHERE ""fe"".""FilesetID"" = @filesetid AND ""fl"".""PrefixID"" IN (@PrefixIds) ") .ExpandInClauseParameterMssqlite("@PrefixIds", prefixIds) .SetParameterValue("@filesetid", filesetid) .ExecuteScalarInt64Async(0, token) .ConfigureAwait(false); } // Return paginated results var intLimit = limit > int.MaxValue ? int.MaxValue : (int)limit; return new PaginatedResults((int)(offset / limit), (int)intLimit, (int)((totalCount + limit - 1) / limit), totalCount, results); } /// /// Gets the prefix IDs for a given set of folder-prefixes. /// /// The prefixes to get the IDs for. /// A cancellation token to cancel the operation. /// An asynchronous enumerable of prefix IDs. public async IAsyncEnumerable GetPrefixIds(IEnumerable prefixes, [EnumeratorCancellation] CancellationToken token) { await using var cmd = m_connection.CreateCommand(); // Map folders to prefix IDs cmd.SetCommandAndParameters($@" SELECT ""ID"" FROM ""PathPrefix"" WHERE ""Prefix"" IN (@Prefixes) ") .ExpandInClauseParameter("@Prefixes", prefixes); await foreach (var rd in cmd.ExecuteReaderEnumerableAsync(token).ConfigureAwait(false)) { yield return rd.ConvertValueToInt64(0); } } /// /// Gets the minimal unique prefix entries for a given fileset ID. /// This returns the roots of all unique paths in the fileset. /// /// The fileset id. /// A cancellation token to cancel the operation. /// A task that when awaited returns a list of the entries that are unique roots. public async IAsyncEnumerable GetMinimalUniquePrefixEntries(long filesetId, [EnumeratorCancellation] CancellationToken token) { await using var cmd = m_connection.CreateCommand(); cmd.SetCommandAndParameters(@" SELECT pp.""Prefix"" || fl.""Path"" AS ""FullPath"", b.""Length"", CASE WHEN fl.""BlocksetID"" = -100 THEN 1 ELSE 0 END AS ""IsDirectory"", CASE WHEN fl.""BlocksetID"" = -200 THEN 1 ELSE 0 END AS ""IsSymlink"", fe.""Lastmodified"" FROM ""FilesetEntry"" fe JOIN ""FileLookup"" fl ON fe.""FileID"" = fl.""ID"" JOIN ""PathPrefix"" pp ON fl.""PrefixID"" = pp.""ID"" LEFT JOIN ""Blockset"" b ON fl.""BlocksetID"" = b.""ID"" WHERE fe.""FilesetID"" = @FilesetId ORDER BY ""FullPath""; ") .SetTransaction(m_rtr) .SetParameterValue("@FilesetId", filesetId); string? lastRoot = null; await foreach (var rd in cmd.ExecuteReaderEnumerableAsync(token)) { var path = rd.ConvertValueToString(0) ?? string.Empty; if (lastRoot == null || !path.StartsWith(lastRoot, StringComparison.Ordinal)) { lastRoot = path; var size = rd.ConvertValueToInt64(1, -1); var isDir = rd.GetInt32(2) != 0; var isSymlink = rd.GetInt32(3) != 0; var lastModified = new DateTime(rd.ConvertValueToInt64(4, 0), DateTimeKind.Utc); yield return new FolderEntry(path, size, isDir, isSymlink, lastModified); } } } /// /// Lists all versions of specific file paths, optionally filtered by fileset IDs. /// /// Paths to match exactly. /// Optional fileset IDs to restrict the search to. /// Pagination offset. /// Pagination limit. /// A cancellation token to cancel the operation. /// A task that when awaited returns all matching file versions ordered by path and then version time. public async Task> ListFileVersions(IEnumerable paths, long[]? filesetIds, long offset, long limit, CancellationToken token) { if (paths == null || !paths.Any()) return new PaginatedResults(0, (int)(limit > 0 ? limit : long.MaxValue), 0, 0, Enumerable.Empty()); if (offset != 0 && limit <= 0) throw new ArgumentException("Cannot use offset without limit specified.", nameof(offset)); if (limit <= 0) limit = long.MaxValue; await using var cmd = m_connection.CreateCommand(); var countWhere = "WHERE fl.\"Path\" IN (@Paths)"; if (filesetIds != null && filesetIds.Length > 0) countWhere += " AND fe.\"FilesetID\" IN (@FilesetIds)"; cmd.SetCommandAndParameters(@" SELECT ""ID"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC "); var versionMap = await cmd .ExecuteReaderEnumerableAsync(token) .Select((x, i) => (Version: i, FilesetId: x.ConvertValueToInt64(0))) .ToDictionaryAsync(x => x.FilesetId, x => x.Version, cancellationToken: token) .ConfigureAwait(false); // Then fetch the actual data var fetchSql = @$" SELECT ""fe"".""FilesetID"", ""f"".""Timestamp"", ""fl"".""Path"", COALESCE(""b"".""Length"", 0) AS ""Size"", CASE WHEN ""fl"".""BlocksetID"" = @FolderBlocksetId THEN 1 ELSE 0 END AS ""IsDirectory"", CASE WHEN ""fl"".""BlocksetID"" = @SymlinkBlocksetId THEN 1 ELSE 0 END AS ""IsSymlink"", ""fe"".""Lastmodified"" FROM ""FilesetEntry"" ""fe"" INNER JOIN ""FileLookup"" ""fl"" ON ""fe"".""FileID"" = ""fl"".""ID"" INNER JOIN ""Fileset"" ""f"" ON ""fe"".""FilesetID"" = ""f"".""ID"" LEFT JOIN ""Blockset"" ""b"" ON ""fl"".""BlocksetID"" = ""b"".""ID"" {countWhere} ORDER BY ""fl"".""Path"" ASC, ""f"".""Timestamp"" ASC LIMIT @limit OFFSET @offset "; cmd.SetCommandAndParameters(fetchSql) .ExpandInClauseParameter("@Paths", paths) .SetParameterValue("@FolderBlocksetId", FOLDER_BLOCKSET_ID) .SetParameterValue("@SymlinkBlocksetId", SYMLINK_BLOCKSET_ID) .SetParameterValue("@limit", limit) .SetParameterValue("@offset", offset); if (filesetIds != null && filesetIds.Length > 0) cmd.ExpandInClauseParameterMssqlite("@FilesetIds", filesetIds); var results = new List(); await foreach (var rd in cmd.ExecuteReaderEnumerableAsync(token).ConfigureAwait(false)) { var version = versionMap[rd.ConvertValueToInt64(0, -1)]; var time = new DateTime(rd.ConvertValueToInt64(1, 0), DateTimeKind.Utc); var path = rd.ConvertValueToString(2) ?? string.Empty; var size = rd.ConvertValueToInt64(3, 0); var isDirectory = rd.GetInt32(4) != 0; var isSymlink = rd.GetInt32(5) != 0; var lastModified = new DateTime(rd.ConvertValueToInt64(6, 0), DateTimeKind.Utc); results.Add(new ListFileVersion( version, time, path, size, isDirectory, isSymlink, lastModified )); } long totalCount; if (offset != 0) { // Only calculate the total for the first query totalCount = -1; } else if (results.Count <= limit) { // If we have all results, we already know the total totalCount = results.Count; } else { // Get total count var countSql = @" SELECT COUNT(*) FROM ""FilesetEntry"" ""fe"" INNER JOIN ""FileLookup"" ""fl"" ON ""fe"".""FileID"" = ""fl"".""ID"" "; cmd.SetCommandAndParameters(countSql + "\n" + countWhere) .ExpandInClauseParameter("@Paths", paths); if (filesetIds != null && filesetIds.Length > 0) cmd.ExpandInClauseParameterMssqlite("@FilesetIds", filesetIds); totalCount = await cmd.ExecuteScalarInt64Async(0, token) .ConfigureAwait(false); } var intLimit = limit > int.MaxValue ? int.MaxValue : (int)limit; var totalPages = (int)((totalCount + limit - 1) / limit); return new PaginatedResults( (int)(offset / limit), intLimit, totalPages, totalCount, results ); } /// /// Searches for file versions matching a filter in the specified path prefixes. /// /// The path prefixes to search in, or search in all if empty. /// The filter to match against file paths. /// Optional fileset IDs to restrict the search to. /// The offset for pagination. /// The limit for pagination. /// A cancellation token to cancel the operation. /// A task that when awaited returns all matching file versions ordered by path and then version time. public async Task> SearchEntries(IEnumerable? pathprefixes, IFilter filter, long[]? filesetIds, long offset, long limit, CancellationToken token) { if (offset != 0 && limit <= 0) throw new ArgumentException("Cannot use offset without limit specified.", nameof(offset)); if (limit <= 0) limit = long.MaxValue; await using var cmd = m_connection.CreateCommand(); // Analyze the filter to determine the default behavior FilterExpression.AnalyzeFilters(filter, out var includes, out var excludes); var defaultExclude = includes && !excludes; // true = exclude unmatched, false = include unmatched var defaultBehavior = defaultExclude ? 1 : 0; var caseSensitive = false; //TODO: Should we expose this? Or use: Library.Utility.Utility.IsFSCaseSensitive? var filterProps = new Dictionary(); var caseWhenParts = new List(); var filterExpressions = new List(); if (filter is FilterExpression fe) { filterExpressions.Add(fe); } else if (filter is JoinedFilterExpression jfe) { var work = new Queue([jfe.First, jfe.Second]); while (work.Count > 0) { var current = work.Dequeue(); if (current is FilterExpression fe1) { filterExpressions.Add(fe1); } else if (current is JoinedFilterExpression jfe1) { work.Enqueue(jfe1.First); work.Enqueue(jfe1.Second); } else { throw new UserInformationException($"Filter type {current.GetType()} is not supported.", "FilterTypeNotSupported"); } } } foreach (var filterExpression in filterExpressions) { if (filterExpression.Type == FilterType.Empty) { // No additional CASE needed } else if (filterExpression.Type == FilterType.Simple) { var parts = filterExpression.GetSimpleList(); foreach (var part in parts) { var propName = $"@Part{Library.Utility.Utility.FormatInvariantValue(filterProps.Count)}"; filterProps[propName] = part; if (caseSensitive) caseWhenParts.Add($@" WHEN instr(LOWER(pp.""Prefix"" || fl.""Path""), LOWER({propName})) > 0 THEN {(filterExpression.Result ? "0" : "1")} "); else caseWhenParts.Add($@" WHEN instr(pp.""Prefix"" || fl.""Path"", {propName}) > 0 THEN {(filterExpression.Result ? "0" : "1")} "); } } else if (filterExpression.Type == FilterType.Wildcard) { var parts = filterExpression.GetSimpleList(); foreach (var part in parts) { var propName = $"@Part{Library.Utility.Utility.FormatInvariantValue(filterProps.Count)}"; filterProps[propName] = part; if (caseSensitive) caseWhenParts.Add($@" WHEN LOWER(pp.""Prefix"" || fl.""Path"") GLOB LOWER({propName}) THEN {(filterExpression.Result ? "0" : "1")} "); else caseWhenParts.Add($@" WHEN pp.""Prefix"" || fl.""Path"" GLOB {propName} THEN {(filterExpression.Result ? "0" : "1")} "); } } else { throw new UserInformationException($"Filter type {filterExpression.Type} is not supported.", "FilterTypeNotSupported"); } } // Build WHERE clauses var whereClauses = new List(); if (pathprefixes != null && pathprefixes.Any()) whereClauses.Add(@"pp.""Prefix"" IN (@PathPrefixes)"); if (filesetIds != null && filesetIds.Length > 0) whereClauses.Add(@"fe.""FilesetID"" IN (@FilesetIds)"); if (caseWhenParts.Any()) { whereClauses.Add($@" COALESCE( CASE {string.Join("\n ", caseWhenParts)} ELSE NULL END, @DefaultBehavior ) = 0 "); } else { // No CASE parts, fallback to default behavior directly if (defaultBehavior == 1) { // Default is EXCLUDE -> exclude everything whereClauses.Add("0 = 1"); } } var whereClause = string.Join("\n AND ", whereClauses); cmd.SetCommandAndParameters(@" SELECT ""ID"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC "); var versionMap = await cmd .ExecuteReaderEnumerableAsync(token) .Select((x, i) => (Version: i, FilesetId: x.ConvertValueToInt64(0))) .ToDictionaryAsync(x => x.FilesetId, x => x.Version, cancellationToken: token) .ConfigureAwait(false); // Fetch results var fetchSql = $@" SELECT ""fe"".""FilesetID"", ""f"".""Timestamp"", ""pp"".""Prefix"" || ""fl"".""Path"" AS ""FullPath"", COALESCE(""b"".""Length"", 0) AS ""Size"", CASE WHEN ""fl"".""BlocksetID"" = @FolderBlocksetId THEN 1 ELSE 0 END AS ""IsDirectory"", CASE WHEN ""fl"".""BlocksetID"" = @SymlinkBlocksetId THEN 1 ELSE 0 END AS ""IsSymlink"", ""fe"".""Lastmodified"" FROM ""FilesetEntry"" ""fe"" INNER JOIN ""FileLookup"" ""fl"" ON ""fe"".""FileID"" = ""fl"".""ID"" INNER JOIN ""Fileset"" ""f"" ON ""fe"".""FilesetID"" = ""f"".""ID"" LEFT JOIN ""Blockset"" ""b"" ON ""fl"".""BlocksetID"" = ""b"".""ID"" INNER JOIN ""PathPrefix"" ""pp"" ON ""fl"".""PrefixID"" = ""pp"".""ID"" WHERE {whereClause} ORDER BY ""pp"".""Prefix"" ASC, ""fl"".""Path"" ASC, ""f"".""Timestamp"" ASC LIMIT @limit OFFSET @offset "; cmd.SetCommandAndParameters(fetchSql) .SetParameterValues(filterProps) .SetParameterValue("@DefaultBehavior", defaultBehavior) .SetParameterValue("@FolderBlocksetId", FOLDER_BLOCKSET_ID) .SetParameterValue("@SymlinkBlocksetId", SYMLINK_BLOCKSET_ID) .SetParameterValue("@limit", limit) .SetParameterValue("@offset", offset); if (pathprefixes != null && pathprefixes.Any()) cmd.ExpandInClauseParameter("@PathPrefixes", pathprefixes); if (filesetIds != null && filesetIds.Length > 0) cmd.ExpandInClauseParameterMssqlite("@FilesetIds", filesetIds); var results = new List(); await foreach (var rd in cmd.ExecuteReaderEnumerableAsync(token).ConfigureAwait(false)) { var version = versionMap[rd.ConvertValueToInt64(0, -1)]; var time = new DateTime(rd.ConvertValueToInt64(1, 0), DateTimeKind.Utc); var path = rd.ConvertValueToString(2) ?? string.Empty; var size = rd.ConvertValueToInt64(3, 0); var isDirectory = rd.GetInt32(4) != 0; var isSymlink = rd.GetInt32(5) != 0; var lastModified = new DateTime(rd.ConvertValueToInt64(6, 0), DateTimeKind.Utc); // We cannot know exactly where the match occurred here // (unless we do another pass), so just use Range(0,0) for now results.Add(new SearchFileVersion(version, time, path, size, isDirectory, isSymlink, lastModified, new Range(0, 0))); } long totalCount; if (offset != 0) { // Only calculate the total for the first query totalCount = -1; } else if (results.Count <= limit) { // If we have all results, we already know the total totalCount = results.Count; } else { // Calculate the total var countSql = $@" SELECT COUNT(*) FROM ""FilesetEntry"" ""fe"" INNER JOIN ""FileLookup"" ""fl"" ON ""fe"".""FileID"" = ""fl"".""ID"" INNER JOIN ""PathPrefix"" ""pp"" ON ""fl"".""PrefixID"" = ""pp"".""ID"" WHERE {whereClause} "; cmd.SetCommandAndParameters(countSql) .SetParameterValues(filterProps) .SetParameterValue("@DefaultBehavior", defaultBehavior); if (pathprefixes != null && pathprefixes.Any()) cmd.ExpandInClauseParameter("@PathPrefixes", pathprefixes); if (filesetIds != null && filesetIds.Length > 0) cmd.ExpandInClauseParameterMssqlite("@FilesetIds", filesetIds); totalCount = await cmd.ExecuteScalarInt64Async(0, token) .ConfigureAwait(false); } var intLimit = limit > int.MaxValue ? int.MaxValue : (int)limit; var totalPages = (int)((totalCount + limit - 1) / limit); return new PaginatedResults( (int)(offset / limit), intLimit, totalPages, totalCount, results ); } } }