1094 lines
52 KiB
C#
1094 lines
52 KiB
C#
// 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.Data;
|
|
using System.Linq;
|
|
using System.Collections.Generic;
|
|
using Duplicati.Library.Common.IO;
|
|
using Duplicati.Library.Utility;
|
|
using Duplicati.Library.Interface;
|
|
|
|
namespace Duplicati.Library.Main.Database
|
|
{
|
|
internal class LocalListDatabase : LocalDatabase
|
|
{
|
|
public LocalListDatabase(string path, long pagecachesize)
|
|
: base(path, "List", false, pagecachesize)
|
|
{
|
|
ShouldCloseConnection = true;
|
|
}
|
|
|
|
public interface IFileversion
|
|
{
|
|
string? Path { get; }
|
|
IEnumerable<long> Sizes { get; }
|
|
}
|
|
|
|
public interface IFileset
|
|
{
|
|
long Version { get; }
|
|
int IsFullBackup { get; set; }
|
|
DateTime Time { get; }
|
|
long FileCount { get; }
|
|
long FileSizes { get; }
|
|
}
|
|
|
|
public interface IFileSets : IDisposable
|
|
{
|
|
IEnumerable<IFileset> Sets { get; }
|
|
IEnumerable<IFileset> QuickSets { get; }
|
|
IEnumerable<IFileversion> SelectFiles(IFilter filter);
|
|
IEnumerable<IFileversion> GetLargestPrefix(IFilter filter);
|
|
IEnumerable<IFileversion> SelectFolderContents(IFilter filter);
|
|
void TakeFirst();
|
|
}
|
|
|
|
private class FileSets : IFileSets
|
|
{
|
|
private readonly IDbConnection m_connection;
|
|
private string m_tablename;
|
|
private readonly KeyValuePair<long, DateTime>[] m_filesets;
|
|
|
|
public FileSets(LocalListDatabase owner, DateTime time, long[] versions)
|
|
{
|
|
m_connection = owner.m_connection;
|
|
m_filesets = owner.FilesetTimes.ToArray();
|
|
m_tablename = "Filesets-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray());
|
|
var tmp = owner.GetFilelistWhereClause(time, versions, m_filesets);
|
|
var query = tmp.Item1;
|
|
var args = tmp.Item2;
|
|
|
|
using (var cmd = m_connection.CreateCommand())
|
|
{
|
|
cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{m_tablename}"" AS SELECT DISTINCT ""ID"" AS ""FilesetID"", ""IsFullBackup"" AS ""IsFullBackup"" , ""Timestamp"" AS ""Timestamp"" FROM ""Fileset"" {query}"), args);
|
|
cmd.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{m_tablename}_FilesetIDTimestampIndex"" ON ""{m_tablename}"" (""FilesetID"", ""Timestamp"" DESC)"));
|
|
}
|
|
}
|
|
|
|
private class Fileset : IFileset
|
|
{
|
|
public long Version { get; private set; }
|
|
public int IsFullBackup { get; set; }
|
|
public DateTime Time { get; private set; }
|
|
public long FileCount { get; private set; }
|
|
public long FileSizes { get; private set; }
|
|
|
|
public Fileset(long version, int isFullBackup, DateTime time, long filecount, long filesizes)
|
|
{
|
|
Version = version;
|
|
IsFullBackup = isFullBackup;
|
|
Time = time;
|
|
FileCount = filecount;
|
|
FileSizes = filesizes;
|
|
}
|
|
}
|
|
|
|
private class Fileversion : IFileversion
|
|
{
|
|
private readonly IDataReader m_reader;
|
|
public string? Path { get; private set; }
|
|
public bool More { get; private set; }
|
|
|
|
public Fileversion(IDataReader reader)
|
|
{
|
|
m_reader = reader;
|
|
Path = reader.ConvertValueToString(0);
|
|
More = true;
|
|
}
|
|
|
|
public IEnumerable<long> Sizes
|
|
{
|
|
get
|
|
{
|
|
while (More && Path == m_reader.ConvertValueToString(0))
|
|
{
|
|
yield return m_reader.ConvertValueToInt64(1, -1);
|
|
More = m_reader.Read();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private class FileversionFixed : IFileversion
|
|
{
|
|
public string? Path { get; internal set; }
|
|
public IEnumerable<long> Sizes { get { return new long[0]; } }
|
|
}
|
|
|
|
public IEnumerable<IFileversion> GetLargestPrefix(IFilter filter)
|
|
{
|
|
return GetLargestPrefix(filter, null);
|
|
}
|
|
|
|
private IEnumerable<IFileversion> GetLargestPrefix(IFilter filter, string? prefixrule)
|
|
{
|
|
using (var tmpnames = new FilteredFilenameTable(m_connection, filter, null))
|
|
using (var cmd = m_connection.CreateCommand())
|
|
{
|
|
//First we trim the filelist to exclude filenames not found in any of the filesets
|
|
cmd.ExecuteNonQuery(FormatInvariant($@"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}"") ) "));
|
|
|
|
//If we have a prefix rule, apply it
|
|
if (!string.IsNullOrWhiteSpace(prefixrule))
|
|
cmd.SetCommandAndParameters(FormatInvariant($@"DELETE FROM ""{tmpnames.Tablename}"" WHERE SUBSTR(""Path"", 1, {prefixrule.Length}) != @Rule"))
|
|
.SetParameterValue("@Rule", prefixrule)
|
|
.ExecuteNonQuery();
|
|
|
|
// Then we recursively find the largest prefix
|
|
var v0 = cmd.ExecuteScalar(FormatInvariant($@"SELECT ""Path"" FROM ""{tmpnames.Tablename}"" ORDER BY LENGTH(""Path"") DESC LIMIT 1"));
|
|
var maxpath = "";
|
|
if (v0 != null)
|
|
maxpath = v0.ToString() ?? "";
|
|
|
|
var dirsep = Util.GuessDirSeparator(maxpath);
|
|
|
|
var filecount = cmd.ExecuteScalarInt64(FormatInvariant($@"SELECT COUNT(*) FROM ""{tmpnames.Tablename}"""), 0);
|
|
var foundfiles = -1L;
|
|
|
|
//TODO: Handle FS case-sensitive?
|
|
cmd.SetCommandAndParameters(FormatInvariant($@"SELECT COUNT(*) FROM ""{tmpnames.Tablename}"" WHERE SUBSTR(""Path"", 1, @PrefixLength) = @Prefix"));
|
|
|
|
while (filecount != foundfiles && maxpath.Length > 0)
|
|
{
|
|
var mp = Util.AppendDirSeparator(maxpath, dirsep);
|
|
foundfiles = cmd.SetParameterValue("@PrefixLength", mp.Length)
|
|
.SetParameterValue("@Prefix", mp)
|
|
.ExecuteScalarInt64(0);
|
|
|
|
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.ExecuteReaderEnumerable(FormatInvariant($@"SELECT Path FROM ""{tmpnames.Tablename}""")).Select(x => x.ConvertValueToString(0) ?? "").ToArray();
|
|
var roots = paths.Select(x => x.Substring(0, 1)).Distinct().Where(x => x != "\\").ToArray();
|
|
|
|
//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().ToArray();
|
|
|
|
return roots.Concat(rootsUNC).Select(x => GetLargestPrefix(filter, x).First()).Distinct().ToArray();
|
|
}
|
|
|
|
return [new FileversionFixed { Path = maxpath == "" ? "" : Util.AppendDirSeparator(maxpath, dirsep) }];
|
|
}
|
|
}
|
|
|
|
private IEnumerable<string> SelectFolderEntries(IDbCommand cmd, string prefix, string table)
|
|
{
|
|
if (!string.IsNullOrEmpty(prefix))
|
|
prefix = Util.AppendDirSeparator(prefix, Util.GuessDirSeparator(prefix));
|
|
|
|
var ppl = prefix.Length;
|
|
using (var rd = cmd.ExecuteReader(FormatInvariant($@"SELECT DISTINCT ""Path"" FROM ""{table}"" ")))
|
|
while (rd.Read())
|
|
{
|
|
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 IEnumerable<IFileversion> SelectFolderContents(IFilter filter)
|
|
{
|
|
var tbname = "Filenames-" + Library.Utility.Utility.ByteArrayAsHexString(Guid.NewGuid().ToByteArray());
|
|
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);
|
|
|
|
using (var tmpnames = new FilteredFilenameTable(m_connection, new FilterExpression(new string[] { pathprefix + "*" }, true), null))
|
|
using (var cmd = m_connection.CreateCommand())
|
|
{
|
|
//First we trim the filelist to exclude filenames not found in any of the filesets
|
|
cmd.ExecuteNonQuery(FormatInvariant($@"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}"") ) "));
|
|
|
|
// 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
|
|
cmd.ExecuteNonQuery(FormatInvariant($@"CREATE TEMPORARY TABLE ""{tbname}"" (""Path"" TEXT NOT NULL)"));
|
|
|
|
using (var c2 = m_connection.CreateCommand())
|
|
{
|
|
c2.SetCommandAndParameters(FormatInvariant($@"INSERT INTO ""{tbname}"" (""Path"") VALUES (@Path)"));
|
|
foreach (var n in SelectFolderEntries(cmd, pathprefix, tmpnames.Tablename).Distinct())
|
|
c2.SetParameterValue("@Path", n)
|
|
.ExecuteNonQuery();
|
|
|
|
c2.ExecuteNonQuery(FormatInvariant($@"CREATE INDEX ""{tbname}_PathIndex"" ON ""{tbname}"" (""Path"")"));
|
|
}
|
|
|
|
//Then we select the matching results
|
|
var filesets = FormatInvariant($@"SELECT ""FilesetID"", ""Timestamp"" FROM ""{m_tablename}"" ORDER BY ""Timestamp"" DESC");
|
|
var cartesianPathFileset = FormatInvariant($@"SELECT ""A"".""Path"", ""B"".""FilesetID"" FROM ""{tbname}"" A, ({filesets}) B ORDER BY ""A"".""Path"" ASC, ""B"".""Timestamp"" DESC");
|
|
|
|
var filesWithSizes = FormatInvariant($@"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""";
|
|
|
|
using (var rd = cmd.ExecuteReader(query))
|
|
if (rd.Read())
|
|
{
|
|
bool more;
|
|
do
|
|
{
|
|
var f = new Fileversion(rd);
|
|
if (!(string.IsNullOrWhiteSpace(f.Path) || f.Path == pathprefix))
|
|
{
|
|
yield return f;
|
|
more = f.More;
|
|
}
|
|
else
|
|
{
|
|
more = rd.Read();
|
|
}
|
|
|
|
} while (more);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
using (var c = m_connection.CreateCommand())
|
|
c.ExecuteNonQuery(FormatInvariant($@"DROP TABLE IF EXISTS ""{tbname}"""));
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
public IEnumerable<IFileversion> SelectFiles(IFilter filter)
|
|
{
|
|
using (var tmpnames = new FilteredFilenameTable(m_connection, filter, null))
|
|
using (var cmd = m_connection.CreateCommand())
|
|
{
|
|
//First we trim the filelist to exclude filenames not found in any of the filesets
|
|
cmd.ExecuteNonQuery(FormatInvariant($@"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}"") ) "));
|
|
|
|
//Then we select the matching results
|
|
var filesets = FormatInvariant($@"SELECT ""FilesetID"", ""Timestamp"" FROM ""{m_tablename}"" ORDER BY ""Timestamp"" DESC");
|
|
var cartesianPathFileset = FormatInvariant($@"SELECT ""A"".""Path"", ""B"".""FilesetID"" FROM ""{tmpnames.Tablename}"" A, ({filesets}) B ORDER BY ""A"".""Path"" ASC, ""B"".""Timestamp"" DESC");
|
|
var filesWithSizes = FormatInvariant($@"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""";
|
|
using (var rd = cmd.ExecuteReader(query))
|
|
if (rd.Read())
|
|
{
|
|
bool more;
|
|
do
|
|
{
|
|
var f = new Fileversion(rd);
|
|
yield return f;
|
|
more = f.More;
|
|
} while (more);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void TakeFirst()
|
|
{
|
|
using (var cmd = m_connection.CreateCommand())
|
|
cmd.ExecuteNonQuery(FormatInvariant($@"DELETE FROM ""{m_tablename}"" WHERE ""FilesetID"" NOT IN (SELECT ""FilesetID"" FROM ""{m_tablename}"" ORDER BY ""Timestamp"" DESC LIMIT 1 )"));
|
|
}
|
|
|
|
public IEnumerable<IFileset> QuickSets
|
|
{
|
|
get
|
|
{
|
|
var dict = new Dictionary<long, long>();
|
|
for (var i = 0; i < m_filesets.Length; i++)
|
|
dict[m_filesets[i].Key] = i;
|
|
|
|
using (var cmd = m_connection.CreateCommand())
|
|
using (var rd = cmd.ExecuteReader(@"SELECT DISTINCT ""ID"", ""IsFullBackup"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC "))
|
|
while (rd.Read())
|
|
{
|
|
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 IEnumerable<IFileset> Sets
|
|
{
|
|
get
|
|
{
|
|
var dict = new Dictionary<long, long>();
|
|
for (var i = 0; i < m_filesets.Length; i++)
|
|
dict[m_filesets[i].Key] = i;
|
|
|
|
var summation = FormatInvariant(
|
|
$@"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"" ");
|
|
|
|
using (var cmd = m_connection.CreateCommand())
|
|
using (var rd = cmd.ExecuteReader(FormatInvariant(
|
|
$@"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 "))
|
|
)
|
|
while (rd.Read())
|
|
{
|
|
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()
|
|
{
|
|
if (m_tablename != null)
|
|
{
|
|
try
|
|
{
|
|
using (var cmd = m_connection.CreateCommand())
|
|
cmd.ExecuteNonQuery(FormatInvariant(@$"DROP TABLE IF EXISTS ""{m_tablename}"" "));
|
|
}
|
|
catch { }
|
|
finally { m_tablename = null!; }
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
public IFileSets SelectFileSets(DateTime time, long[] versions)
|
|
{
|
|
return new FileSets(this, time, versions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a fileset entry in the list fileset results.
|
|
/// </summary>
|
|
/// <param name="Version">The fileset version
|
|
/// <param name="IsFullBackup">Flag indicating if the backup is a full backup or synthetic backup</param>
|
|
/// <param name="Time">The timestamp of the fileset</param>
|
|
/// <param name="FileCount">The number of files in the fileset</param>
|
|
/// <param name="FileSizes">>The total size of files in the fileset</param>
|
|
private sealed record FilesetEntry(long Version, bool? IsFullBackup, DateTime Time, long? FileCount, long? FileSizes) : IListFilesetResultFileset;
|
|
|
|
/// <summary>
|
|
/// Lists all filesets with summary data
|
|
/// </summary>
|
|
/// <returns>A collection of fileset entries</returns>
|
|
public IEnumerable<IListFilesetResultFileset> ListFilesetsExtended()
|
|
{
|
|
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;
|
|
";
|
|
|
|
using (var cmd = m_connection.CreateCommand(query))
|
|
foreach (var rd in cmd.ExecuteReaderEnumerable())
|
|
{
|
|
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(id, isFullBackup, time, filecount, filesizes);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a folder entry in the list folder results.
|
|
/// </summary>
|
|
/// <param name="Path">The path of the folder entry.</param>
|
|
/// <param name="Size">The size of the folder entry.</param>
|
|
/// <param name="IsDirectory">Indicates if the entry is a directory.</param>
|
|
/// <param name="IsSymlink">Indicates if the entry is a symbolic link.</param>
|
|
/// <param name="LastModified">The last modified date of the folder entry.</param>
|
|
private sealed record FolderEntry(string Path, long Size, bool IsDirectory, bool IsSymlink, DateTime LastModified) : IListFolderEntry;
|
|
|
|
/// <summary>
|
|
/// Lists the folder entries for a given fileset and prefix IDs.
|
|
/// </summary>
|
|
/// <param name="prefixIds">The prefix IDs to filter the folder entries.</param>
|
|
/// <param name="filesetid">The fileset ID to filter the folder entries.</param>
|
|
/// <param name="offset">The offset for pagination.</param>
|
|
/// <param name="limit">>The limit for pagination.</param>
|
|
/// <returns>A paginated result set of folder entries.</returns>
|
|
public IPaginatedResults<IListFolderEntry> ListFolder(IEnumerable<long> prefixIds, long filesetid, long offset, long limit)
|
|
{
|
|
if (offset != 0 && limit <= 0)
|
|
throw new ArgumentException("Cannot use offset without limit specified.", nameof(offset));
|
|
if (limit <= 0)
|
|
limit = long.MaxValue;
|
|
|
|
using var cmd = m_connection.CreateCommand();
|
|
|
|
if (prefixIds.Count() == 0)
|
|
return new PaginatedResults<IListFolderEntry>(0, (int)limit, 0, 0, Enumerable.Empty<IListFolderEntry>());
|
|
|
|
// 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)
|
|
.ExpandInClauseParameter("@PrefixIds", prefixIds)
|
|
.SetParameterValue("@filesetid", filesetid)
|
|
.SetParameterValue("@limit", limit)
|
|
.SetParameterValue("@offset", offset);
|
|
|
|
var results = new List<IListFolderEntry>();
|
|
foreach (var rd in cmd.ExecuteReaderEnumerable())
|
|
{
|
|
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 = 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)
|
|
")
|
|
.ExpandInClauseParameter("@PrefixIds", prefixIds)
|
|
.SetParameterValue("@filesetid", filesetid)
|
|
.ExecuteScalarInt64(0);
|
|
}
|
|
|
|
// Return paginated results
|
|
var intLimit = limit > int.MaxValue ? int.MaxValue : (int)limit;
|
|
return new PaginatedResults<IListFolderEntry>((int)(offset / limit), (int)intLimit, (int)((totalCount + limit - 1) / limit), totalCount, results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists the folder entries for a given fileset and prefix IDs.
|
|
/// </summary>
|
|
/// <param name="prefixIds">The prefix IDs to return entries for.</param>
|
|
/// <param name="offset">The offset for pagination.</param>
|
|
/// <param name="limit">>The limit for pagination.</param>
|
|
/// <returns>A paginated result set of folder entries.</returns>
|
|
public IPaginatedResults<IListFolderEntry> ListFilesetEntries(IEnumerable<long> prefixIds, long filesetid, long offset, long limit)
|
|
{
|
|
if (offset != 0 && limit <= 0)
|
|
throw new ArgumentException("Cannot use offset without limit specified.", nameof(offset));
|
|
if (limit <= 0)
|
|
limit = long.MaxValue;
|
|
|
|
using var cmd = m_connection.CreateCommand();
|
|
|
|
if (!prefixIds.Any())
|
|
return new PaginatedResults<IListFolderEntry>(0, (int)limit, 0, 0, Enumerable.Empty<IListFolderEntry>());
|
|
|
|
// Fetch entries
|
|
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)
|
|
.SetParameterValue("@FilesetId", filesetid)
|
|
.ExpandInClauseParameter("@PrefixIds", prefixIds)
|
|
.SetParameterValue("@limit", limit)
|
|
.SetParameterValue("@offset", offset);
|
|
|
|
var results = new List<IListFolderEntry>();
|
|
foreach (var rd in cmd.ExecuteReaderEnumerable())
|
|
{
|
|
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
|
|
{
|
|
totalCount = 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)
|
|
")
|
|
.SetParameterValue("@FilesetId", filesetid)
|
|
.ExpandInClauseParameter("@PrefixIds", prefixIds)
|
|
.ExecuteScalarInt64(0);
|
|
}
|
|
|
|
var intLimit = limit > int.MaxValue ? int.MaxValue : (int)limit;
|
|
return new PaginatedResults<IListFolderEntry>((int)(offset / limit), intLimit, (int)((totalCount + limit - 1) / limit), totalCount, results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the prefix IDs for a given set of folder-prefixes.
|
|
/// </summary>
|
|
/// <param name="prefixes">The prefixes to get the IDs for.</param>
|
|
/// <returns>>A list of prefix IDs.</returns>
|
|
public IEnumerable<long> GetPrefixIds(IEnumerable<string> prefixes)
|
|
{
|
|
using var cmd = m_connection.CreateCommand();
|
|
|
|
// Map folders to prefix IDs
|
|
cmd.SetCommandAndParameters($@"SELECT ""ID"" FROM ""PathPrefix"" WHERE ""Prefix"" IN (@Prefixes)")
|
|
.ExpandInClauseParameter("@Prefixes", prefixes);
|
|
|
|
return cmd.ExecuteReaderEnumerable()
|
|
.Select(x => x.ConvertValueToInt64(0))
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gathers root prefixes from the fileset entries.
|
|
/// </summary>
|
|
/// <param name="filesetid">The filesetId to get the prefixes for</param>
|
|
/// <returns>A list of root prefixes</returns>
|
|
public IEnumerable<long> GetRootPrefixes(long filesetid)
|
|
{
|
|
using var cmd = m_connection.CreateCommand();
|
|
|
|
cmd.SetCommandAndParameters(@"
|
|
WITH Prefixes AS (
|
|
SELECT DISTINCT fl.""PrefixID"", pp.""Prefix""
|
|
FROM ""FilesetEntry"" fe
|
|
INNER JOIN ""FileLookup"" fl ON fe.""FileID"" = fl.""ID""
|
|
INNER JOIN ""PathPrefix"" pp ON fl.""PrefixID"" = pp.""ID""
|
|
WHERE fe.""FilesetID"" = @filesetid
|
|
)
|
|
SELECT p1.""PrefixID""
|
|
FROM Prefixes p1
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM Prefixes p2
|
|
WHERE p2.""PrefixID"" != p1.""PrefixID""
|
|
AND p1.""Prefix"" LIKE p2.""Prefix"" || '%'
|
|
AND LENGTH(p1.""Prefix"") > LENGTH(p2.""Prefix"")
|
|
)
|
|
ORDER BY p1.""Prefix"" ASC
|
|
")
|
|
.SetParameterValue("@filesetid", filesetid);
|
|
|
|
return cmd.ExecuteReaderEnumerable()
|
|
.Select(x => x.ConvertValueToInt64(0, -1))
|
|
.Where(x => x != -1)
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gathers all prefixes and their parent prefix IDs from the fileset entries.
|
|
/// </summary>
|
|
/// <param name="filesetid">The filesetId to get the prefixes for</param>
|
|
/// <returns>A list of (PrefixID, ParentPrefixID) pairs</returns>
|
|
public IEnumerable<(long PrefixId, long ParentPrefixId)> GetRootPrefixesWithParents(long filesetid)
|
|
{
|
|
using var cmd = m_connection.CreateCommand();
|
|
|
|
var dirSeparator = Util.GuessDirSeparator(cmd.SetCommandAndParameters(@"SELECT ""Prefix"" FROM ""PathPrefix"" LIMIT 1")
|
|
.ExecuteScalar()?.ToString());
|
|
|
|
cmd.SetCommandAndParameters(@"
|
|
WITH Prefixes AS (
|
|
SELECT DISTINCT fl.""PrefixID"", pp.""Prefix""
|
|
FROM ""FilesetEntry"" fe
|
|
INNER JOIN ""FileLookup"" fl ON fe.""FileID"" = fl.""ID""
|
|
INNER JOIN ""PathPrefix"" pp ON fl.""PrefixID"" = pp.""ID""
|
|
WHERE fe.""FilesetID"" = @filesetid
|
|
),
|
|
PrefixParents AS (
|
|
SELECT
|
|
child.""PrefixID"" AS ChildPrefixID,
|
|
parent.""PrefixID"" AS ParentPrefixID,
|
|
LENGTH(parent.""Prefix"") AS ParentLength
|
|
FROM Prefixes child
|
|
JOIN Prefixes parent
|
|
ON LENGTH(child.""Prefix"") > LENGTH(parent.""Prefix"")
|
|
AND SUBSTR(child.""Prefix"", 1, LENGTH(parent.""Prefix"")) = parent.""Prefix""
|
|
AND (SUBSTR(child.""Prefix"", LENGTH(parent.""Prefix"") + 1, 1) = @dirSeparator)
|
|
)
|
|
SELECT
|
|
child.""PrefixID"",
|
|
(
|
|
SELECT ParentPrefixID
|
|
FROM PrefixParents pp
|
|
WHERE pp.ChildPrefixID = child.""PrefixID""
|
|
ORDER BY pp.ParentLength DESC
|
|
LIMIT 1
|
|
) AS ParentPrefixID
|
|
FROM Prefixes child
|
|
ORDER BY child.""Prefix"" ASC
|
|
")
|
|
.SetParameterValue("@dirSeparator", dirSeparator)
|
|
.SetParameterValue("@filesetid", filesetid);
|
|
|
|
return cmd.ExecuteReaderEnumerable()
|
|
.Select(x => (
|
|
PrefixId: x.ConvertValueToInt64(0, -1),
|
|
ParentPrefixId: x.ConvertValueToInt64(1, -1)
|
|
));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists all versions of specific file paths, optionally filtered by fileset IDs.
|
|
/// </summary>
|
|
/// <param name="paths">Paths to match exactly.</param>
|
|
/// <param name="filesetIds">Optional fileset IDs to restrict the search to.</param>
|
|
/// <param name="offset">Pagination offset.</param>
|
|
/// <param name="limit">Pagination limit.</param>
|
|
/// <returns>All matching file versions ordered by path and then version time.</returns>
|
|
public IPaginatedResults<IListFileVersion> ListFileVersions(IEnumerable<string> paths, long[]? filesetIds, long offset, long limit)
|
|
{
|
|
if (paths == null || !paths.Any())
|
|
return new PaginatedResults<IListFileVersion>(0, (int)(limit > 0 ? limit : long.MaxValue), 0, 0, Enumerable.Empty<IListFileVersion>());
|
|
|
|
if (offset != 0 && limit <= 0)
|
|
throw new ArgumentException("Cannot use offset without limit specified.", nameof(offset));
|
|
if (limit <= 0)
|
|
limit = long.MaxValue;
|
|
|
|
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 = cmd.ExecuteReaderEnumerable()
|
|
.Select((x, i) => (Version: i, FilesetId: x.ConvertValueToInt64(0)))
|
|
.ToDictionary(x => x.FilesetId, x => x.Version);
|
|
|
|
// 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.ExpandInClauseParameter("@FilesetIds", filesetIds);
|
|
|
|
var results = new List<IListFileVersion>();
|
|
foreach (var rd in cmd.ExecuteReaderEnumerable())
|
|
{
|
|
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.ExpandInClauseParameter("@FilesetIds", filesetIds);
|
|
|
|
totalCount = cmd.ExecuteScalarInt64(0);
|
|
}
|
|
|
|
var intLimit = limit > int.MaxValue ? int.MaxValue : (int)limit;
|
|
var totalPages = (int)((totalCount + limit - 1) / limit);
|
|
|
|
return new PaginatedResults<IListFileVersion>(
|
|
(int)(offset / limit),
|
|
intLimit,
|
|
totalPages,
|
|
totalCount,
|
|
results
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Searches for file versions matching a filter in the specified path prefixes.
|
|
/// </summary>
|
|
/// <param name="pathprefixes">The path prefixes to search in, or search in all if empty.</param>
|
|
/// <param name="filter">The filter to match against file paths.</param>
|
|
/// <param name="filesetIds">Optional fileset IDs to restrict the search to.</param>
|
|
/// <param name="offset">The offset for pagination.</param>
|
|
/// <param name="limit">The limit for pagination.</param>
|
|
/// <returns>All matching file versions ordered by path and then version time.</returns>
|
|
public IPaginatedResults<ISearchFileVersion> SearchEntries(IEnumerable<string>? pathprefixes, IFilter filter, long[]? filesetIds, long offset, long limit)
|
|
{
|
|
if (offset != 0 && limit <= 0)
|
|
throw new ArgumentException("Cannot use offset without limit specified.", nameof(offset));
|
|
if (limit <= 0)
|
|
limit = long.MaxValue;
|
|
|
|
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<string, object?>();
|
|
var caseWhenParts = new List<string>();
|
|
|
|
var filterExpressions = new List<FilterExpression>();
|
|
if (filter is FilterExpression fe)
|
|
{
|
|
filterExpressions.Add(fe);
|
|
}
|
|
else if (filter is JoinedFilterExpression jfe)
|
|
{
|
|
var work = new Queue<IFilter>([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{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{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<string>();
|
|
|
|
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 = cmd.ExecuteReaderEnumerable()
|
|
.Select((x, i) => (Version: i, FilesetId: x.ConvertValueToInt64(0)))
|
|
.ToDictionary(x => x.FilesetId, x => x.Version);
|
|
|
|
// 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.ExpandInClauseParameter("@FilesetIds", filesetIds);
|
|
|
|
var results = new List<ISearchFileVersion>();
|
|
foreach (var rd in cmd.ExecuteReaderEnumerable())
|
|
{
|
|
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.ExpandInClauseParameter("@FilesetIds", filesetIds);
|
|
|
|
totalCount = cmd.ExecuteScalarInt64(0);
|
|
}
|
|
|
|
var intLimit = limit > int.MaxValue ? int.MaxValue : (int)limit;
|
|
var totalPages = (int)((totalCount + limit - 1) / limit);
|
|
|
|
return new PaginatedResults<ISearchFileVersion>(
|
|
(int)(offset / limit),
|
|
intLimit,
|
|
totalPages,
|
|
totalCount,
|
|
results
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|