2025-03-13 23:06:32 +01:00
// 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
2024-02-28 15:45:30 +01:00
// DEALINGS IN THE SOFTWARE.
2013-05-11 12:03:15 +02:00
2025-04-03 15:46:20 +02:00
#nullable enable
2013-05-11 12:03:15 +02:00
using System ;
2025-03-14 14:34:56 +01:00
using System.Data ;
2013-05-11 12:03:15 +02:00
using System.Linq ;
using System.Collections.Generic ;
2019-08-02 16:42:18 -04:00
using Duplicati.Library.Common.IO ;
2025-03-14 14:34:56 +01:00
using Duplicati.Library.Utility ;
2025-04-29 10:03:57 +02:00
using Duplicati.Library.Interface ;
2019-08-02 16:42:18 -04:00
2013-05-11 12:03:15 +02:00
namespace Duplicati.Library.Main.Database
{
2013-08-22 20:52:54 +02:00
internal class LocalListDatabase : LocalDatabase
2013-05-11 12:03:15 +02:00
{
2025-04-22 14:26:46 +02:00
public LocalListDatabase ( string path , long pagecachesize )
: base ( path , "List" , false , pagecachesize )
2013-05-11 12:03:15 +02:00
{
2016-04-06 20:40:34 +02:00
ShouldCloseConnection = true ;
2013-05-11 12:03:15 +02:00
}
2025-03-14 12:13:17 +01:00
2013-05-11 12:03:15 +02:00
public interface IFileversion
{
2025-04-03 15:46:20 +02:00
string? Path { get ; }
2013-05-11 12:03:15 +02:00
IEnumerable < long > Sizes { get ; }
}
2025-03-14 12:13:17 +01:00
2013-05-30 21:54:43 +02:00
public interface IFileset
{
long Version { get ; }
2019-08-05 20:14:05 -04:00
int IsFullBackup { get ; set ; }
2013-05-30 21:54:43 +02:00
DateTime Time { get ; }
long FileCount { get ; }
long FileSizes { get ; }
}
2025-03-14 12:13:17 +01:00
2013-05-11 12:03:15 +02:00
public interface IFileSets : IDisposable
{
2013-05-30 21:54:43 +02:00
IEnumerable < IFileset > Sets { get ; }
2014-08-19 20:24:54 +02:00
IEnumerable < IFileset > QuickSets { get ; }
2025-03-14 14:34:56 +01:00
IEnumerable < IFileversion > SelectFiles ( IFilter filter );
IEnumerable < IFileversion > GetLargestPrefix ( IFilter filter );
IEnumerable < IFileversion > SelectFolderContents ( IFilter filter );
2025-03-14 12:13:17 +01:00
void TakeFirst ();
2013-05-11 12:03:15 +02:00
}
2019-08-16 21:59:46 -04:00
2013-05-11 12:03:15 +02:00
private class FileSets : IFileSets
{
2025-03-14 14:34:56 +01:00
private readonly IDbConnection m_connection ;
2013-05-11 12:03:15 +02:00
private string m_tablename ;
2018-05-23 21:18:01 -07:00
private readonly KeyValuePair < long , DateTime >[] m_filesets ;
2025-03-14 12:13:17 +01:00
2013-05-11 12:03:15 +02:00
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 ());
2016-09-15 11:39:27 +02:00
var tmp = owner . GetFilelistWhereClause ( time , versions , m_filesets );
2025-03-14 14:34:56 +01:00
var query = tmp . Item1 ;
2016-09-15 11:39:27 +02:00
var args = tmp . Item2 ;
2025-03-14 12:13:17 +01:00
using ( var cmd = m_connection . CreateCommand ())
2015-11-16 12:57:34 +01:00
{
2025-03-14 12:13:17 +01:00
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)" ));
2015-11-16 12:57:34 +01:00
}
2013-05-11 12:03:15 +02:00
}
2025-03-14 12:13:17 +01:00
2013-05-30 21:54:43 +02:00
private class Fileset : IFileset
{
public long Version { get ; private set ; }
2019-08-05 20:14:05 -04:00
public int IsFullBackup { get ; set ; }
2013-05-30 21:54:43 +02:00
public DateTime Time { get ; private set ; }
public long FileCount { get ; private set ; }
public long FileSizes { get ; private set ; }
2025-03-14 12:13:17 +01:00
2019-08-05 20:14:05 -04:00
public Fileset ( long version , int isFullBackup , DateTime time , long filecount , long filesizes )
2013-05-30 21:54:43 +02:00
{
2025-03-14 14:34:56 +01:00
Version = version ;
IsFullBackup = isFullBackup ;
Time = time ;
FileCount = filecount ;
FileSizes = filesizes ;
2013-05-30 21:54:43 +02:00
}
}
2025-03-14 12:13:17 +01:00
2013-05-11 12:03:15 +02:00
private class Fileversion : IFileversion
{
2025-03-14 14:34:56 +01:00
private readonly IDataReader m_reader ;
2025-04-03 15:46:20 +02:00
public string? Path { get ; private set ; }
2013-05-11 12:03:15 +02:00
public bool More { get ; private set ; }
2025-03-14 12:13:17 +01:00
2025-03-14 14:34:56 +01:00
public Fileversion ( IDataReader reader )
2025-03-14 12:13:17 +01:00
{
m_reader = reader ;
2025-04-03 15:46:20 +02:00
Path = reader . ConvertValueToString ( 0 );
2025-03-14 14:34:56 +01:00
More = true ;
2013-05-11 12:03:15 +02:00
}
2025-03-14 12:13:17 +01:00
2013-05-11 12:03:15 +02:00
public IEnumerable < long > Sizes
{
get
{
2025-04-03 15:46:20 +02:00
while ( More && Path == m_reader . ConvertValueToString ( 0 ))
2013-05-11 12:03:15 +02:00
{
2015-01-24 21:59:53 +01:00
yield return m_reader . ConvertValueToInt64 ( 1 , - 1 );
2025-03-14 14:34:56 +01:00
More = m_reader . Read ();
2013-05-11 12:03:15 +02:00
}
}
}
}
2025-03-14 12:13:17 +01:00
2014-03-15 01:03:15 +01:00
private class FileversionFixed : IFileversion
{
2025-04-03 15:46:20 +02:00
public string? Path { get ; internal set ; }
2014-03-15 01:03:15 +01:00
public IEnumerable < long > Sizes { get { return new long [ 0 ]; } }
}
2016-04-19 10:04:22 +02:00
2025-03-14 14:34:56 +01:00
public IEnumerable < IFileversion > GetLargestPrefix ( IFilter filter )
2016-04-19 10:04:22 +02:00
{
return GetLargestPrefix ( filter , null );
}
2025-04-03 15:46:20 +02:00
private IEnumerable < IFileversion > GetLargestPrefix ( IFilter filter , string? prefixrule )
2014-03-15 01:03:15 +01:00
{
2025-03-14 12:13:17 +01:00
using ( var tmpnames = new FilteredFilenameTable ( m_connection , filter , null ))
using ( var cmd = m_connection . CreateCommand ())
2014-03-15 01:03:15 +01:00
{
//First we trim the filelist to exclude filenames not found in any of the filesets
2025-03-14 12:13:17 +01:00
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}"") ) " ));
2016-04-19 10:04:22 +02:00
//If we have a prefix rule, apply it
if (! string . IsNullOrWhiteSpace ( prefixrule ))
2025-04-02 14:46:38 +02:00
cmd . SetCommandAndParameters ( FormatInvariant ( $@"DELETE FROM ""{tmpnames.Tablename}"" WHERE SUBSTR(""Path"", 1, {prefixrule.Length}) != @Rule" ))
. SetParameterValue ( "@Rule" , prefixrule )
. ExecuteNonQuery ();
2016-04-19 10:04:22 +02:00
2014-03-15 01:03:15 +01:00
// Then we recursively find the largest prefix
2025-04-03 16:38:51 +02:00
var v0 = cmd . ExecuteScalar ( FormatInvariant ( $@"SELECT ""Path"" FROM ""{tmpnames.Tablename}"" ORDER BY LENGTH(""Path"") DESC LIMIT 1" ));
2025-03-14 14:34:56 +01:00
var maxpath = "" ;
2014-03-15 01:03:15 +01:00
if ( v0 != null )
2025-04-03 15:46:20 +02:00
maxpath = v0 . ToString () ?? "" ;
2017-01-06 23:01:54 +01:00
2018-10-27 12:17:07 +02:00
var dirsep = Util . GuessDirSeparator ( maxpath );
2025-03-14 12:13:17 +01:00
2025-04-03 16:38:51 +02:00
var filecount = cmd . ExecuteScalarInt64 ( FormatInvariant ( $@"SELECT COUNT(*) FROM ""{tmpnames.Tablename}""" ), 0 );
2025-03-14 14:34:56 +01:00
var foundfiles = - 1L ;
2025-03-14 12:13:17 +01:00
2014-03-15 01:03:15 +01:00
//TODO: Handle FS case-sensitive?
2025-04-02 14:46:38 +02:00
cmd . SetCommandAndParameters ( FormatInvariant ( $@"SELECT COUNT(*) FROM ""{tmpnames.Tablename}"" WHERE SUBSTR(""Path"", 1, @PrefixLength) = @Prefix" ));
2025-03-14 12:13:17 +01:00
2014-03-15 01:03:15 +01:00
while ( filecount != foundfiles && maxpath . Length > 0 )
{
2019-10-05 11:34:49 -07:00
var mp = Util . AppendDirSeparator ( maxpath , dirsep );
2025-04-02 14:46:38 +02:00
foundfiles = cmd . SetParameterValue ( "@PrefixLength" , mp . Length )
. SetParameterValue ( "@Prefix" , mp )
. ExecuteScalarInt64 ( 0 );
2019-08-02 16:42:18 -04:00
2014-03-15 01:03:15 +01:00
if ( filecount != foundfiles )
{
var oldlen = maxpath . Length ;
2017-01-06 23:01:54 +01:00
var lix = maxpath . LastIndexOf ( dirsep , maxpath . Length - 2 , StringComparison . Ordinal );
2019-01-12 01:12:51 +01:00
2017-01-06 23:01:54 +01:00
maxpath = maxpath . Substring ( 0 , lix + 1 );
2017-06-06 17:23:19 +02:00
if ( string . IsNullOrWhiteSpace ( maxpath ) || maxpath . Length == oldlen || maxpath == "\\\\" )
2014-03-15 01:03:15 +01:00
maxpath = "" ;
}
}
2016-04-19 10:04:22 +02:00
2019-01-12 01:12:51 +01:00
// Special handling for Windows and multi-drive/UNC backups as they do not have a single common root
2016-04-19 10:04:22 +02:00
if ( string . IsNullOrWhiteSpace ( maxpath ) && string . IsNullOrWhiteSpace ( prefixrule ))
{
2025-04-03 15:46:20 +02:00
var paths = cmd . ExecuteReaderEnumerable ( FormatInvariant ( $@"SELECT Path FROM ""{tmpnames.Tablename}""" )). Select ( x => x . ConvertValueToString ( 0 ) ?? "" ). ToArray ();
2019-01-12 01:12:51 +01:00
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 ();
2016-04-19 10:04:22 +02:00
}
2019-08-02 16:42:18 -04:00
2025-03-14 14:34:56 +01:00
return [ new FileversionFixed { Path = maxpath == "" ? "" : Util . AppendDirSeparator ( maxpath , dirsep ) }];
2014-03-15 01:03:15 +01:00
}
2014-03-21 15:01:49 +01:00
}
2025-03-14 12:13:17 +01:00
2025-03-14 14:34:56 +01:00
private IEnumerable < string > SelectFolderEntries ( IDbCommand cmd , string prefix , string table )
2014-03-21 15:01:49 +01:00
{
2014-03-21 17:29:58 +01:00
if (! string . IsNullOrEmpty ( prefix ))
2018-10-27 12:17:07 +02:00
prefix = Util . AppendDirSeparator ( prefix , Util . GuessDirSeparator ( prefix ));
2025-03-14 12:13:17 +01:00
2014-03-21 17:29:58 +01:00
var ppl = prefix . Length ;
2025-03-14 12:13:17 +01:00
using ( var rd = cmd . ExecuteReader ( FormatInvariant ( $@"SELECT DISTINCT ""Path"" FROM ""{table}"" " )))
2014-03-21 17:29:58 +01:00
while ( rd . Read ())
{
2025-04-03 15:46:20 +02:00
var s = rd . ConvertValueToString ( 0 ) ?? "" ;
2017-11-26 10:53:14 -08:00
if (! s . StartsWith ( prefix , StringComparison . Ordinal ))
2014-03-21 17:29:58 +01:00
continue ;
2017-01-06 23:01:54 +01:00
2018-10-27 12:17:07 +02:00
var dirsep = Util . GuessDirSeparator ( s );
2017-01-06 23:01:54 +01:00
2014-03-21 17:29:58 +01:00
s = s . Substring ( ppl );
2017-01-06 23:01:54 +01:00
var ix = s . IndexOf ( dirsep , StringComparison . Ordinal );
2014-03-21 17:29:58 +01:00
if ( ix > 0 && ix != s . Length - 1 )
s = s . Substring ( 0 , ix + 1 );
yield return prefix + s ;
}
}
2025-03-14 12:13:17 +01:00
2025-03-14 14:34:56 +01:00
public IEnumerable < IFileversion > SelectFolderContents ( IFilter filter )
2014-03-21 17:29:58 +01:00
{
var tbname = "Filenames-" + Library . Utility . Utility . ByteArrayAsHexString ( Guid . NewGuid (). ToByteArray ());
try
2014-03-21 15:01:49 +01:00
{
2014-03-21 19:23:54 +01:00
string pathprefix ;
if ( filter == null || filter . Empty )
pathprefix = "" ;
2025-03-14 14:34:56 +01:00
else if ( filter as FilterExpression == null || (( FilterExpression ) filter ). Type != FilterType . Simple || (( FilterExpression ) filter ). GetSimpleList (). Length != 1 )
2017-09-25 19:43:26 -07:00
throw new ArgumentException ( "Filter for list-folder-contents must be a path prefix with no wildcards" , nameof ( filter ));
2014-03-21 19:23:54 +01:00
else
2025-03-14 14:34:56 +01:00
pathprefix = (( FilterExpression ) filter ). GetSimpleList (). First ();
2017-01-06 23:01:54 +01:00
2018-10-27 12:17:07 +02:00
var dirsep = Util . GuessDirSeparator ( pathprefix );
2017-01-06 23:01:54 +01:00
if ( pathprefix . Length > 0 || dirsep == "/" )
2018-10-27 12:17:07 +02:00
pathprefix = Util . AppendDirSeparator ( pathprefix , dirsep );
2025-03-14 12:13:17 +01:00
2025-03-14 14:34:56 +01:00
using ( var tmpnames = new FilteredFilenameTable ( m_connection , new FilterExpression ( new string [] { pathprefix + "*" }, true ), null ))
2025-03-14 12:13:17 +01:00
using ( var cmd = m_connection . CreateCommand ())
2014-03-21 17:29:58 +01:00
{
//First we trim the filelist to exclude filenames not found in any of the filesets
2025-03-14 12:13:17 +01:00
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}"") ) " ));
2014-03-21 17:29:58 +01:00
// If we had instr support this would work:
/*var distinctPaths = @"SELECT DISTINCT :1 || " +
2024-04-15 08:24:01 +02:00
@"CASE(INSTR(SUBSTR(""Path"", :2), '/')) " +
2014-03-21 17:29:58 +01:00
@"WHEN 0 THEN SUBSTR(""Path"", :2) " +
2024-04-15 08:24:01 +02:00
@"ELSE SUBSTR(""Path"", :2, INSTR(SUBSTR(path, :2), '/')) " +
2014-03-21 17:29:58 +01:00
@"END AS ""Path"", ""FilesetID"" " +
@" FROM (" + cartesianPathFileset + @")";*/
2024-04-15 08:24:01 +02:00
2014-03-21 17:29:58 +01:00
// Instead we manually iterate the paths
2025-03-14 12:13:17 +01:00
cmd . ExecuteNonQuery ( FormatInvariant ( $@"CREATE TEMPORARY TABLE ""{tbname}"" (""Path"" TEXT NOT NULL)" ));
using ( var c2 = m_connection . CreateCommand ())
2014-03-21 15:01:49 +01:00
{
2025-04-02 14:46:38 +02:00
c2 . SetCommandAndParameters ( FormatInvariant ( $@"INSERT INTO ""{tbname}"" (""Path"") VALUES (@Path)" ));
2025-03-14 12:13:17 +01:00
foreach ( var n in SelectFolderEntries ( cmd , pathprefix , tmpnames . Tablename ). Distinct ())
2025-04-02 14:46:38 +02:00
c2 . SetParameterValue ( "@Path" , n )
. ExecuteNonQuery ();
2015-11-16 12:57:34 +01:00
2025-03-14 12:13:17 +01:00
c2 . ExecuteNonQuery ( FormatInvariant ( $@"CREATE INDEX ""{tbname}_PathIndex"" ON ""{tbname}"" (""Path"")" ));
2014-03-21 15:01:49 +01:00
}
2025-03-14 12:13:17 +01:00
2014-03-21 17:29:58 +01:00
//Then we select the matching results
2025-03-14 12:13:17 +01:00
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" );
2017-11-15 15:16:25 -02:00
2025-03-14 12:13:17 +01:00
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}"") " );
2014-03-21 17:29:58 +01:00
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""" ;
2025-03-14 12:13:17 +01:00
using ( var rd = cmd . ExecuteReader ( query ))
2014-03-21 17:29:58 +01:00
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 ();
}
2025-03-14 12:13:17 +01:00
} while ( more );
2014-03-21 17:29:58 +01:00
}
2025-03-14 12:13:17 +01:00
}
2014-03-21 17:29:58 +01:00
}
finally
{
try
{
2025-03-14 12:13:17 +01:00
using ( var c = m_connection . CreateCommand ())
c . ExecuteNonQuery ( FormatInvariant ( $@"DROP TABLE IF EXISTS ""{tbname}""" ));
2014-03-21 17:29:58 +01:00
}
catch
{
}
2014-03-21 15:01:49 +01:00
}
}
2025-03-14 12:13:17 +01:00
2025-03-14 14:34:56 +01:00
public IEnumerable < IFileversion > SelectFiles ( IFilter filter )
2013-05-11 12:03:15 +02:00
{
2025-03-14 12:13:17 +01:00
using ( var tmpnames = new FilteredFilenameTable ( m_connection , filter , null ))
using ( var cmd = m_connection . CreateCommand ())
2013-05-11 12:03:15 +02:00
{
//First we trim the filelist to exclude filenames not found in any of the filesets
2025-03-14 12:13:17 +01:00
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}"") ) " ));
2013-05-11 12:03:15 +02:00
//Then we select the matching results
2025-03-14 12:13:17 +01:00
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}"") " );
2013-05-11 12:03:15 +02:00
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""" ;
2025-03-14 12:13:17 +01:00
using ( var rd = cmd . ExecuteReader ( query ))
if ( rd . Read ())
2013-05-11 12:03:15 +02:00
{
bool more ;
do
{
var f = new Fileversion ( rd );
yield return f ;
more = f . More ;
2025-03-14 12:13:17 +01:00
} while ( more );
2013-05-11 12:03:15 +02:00
}
}
}
2025-03-14 12:13:17 +01:00
2013-05-11 12:03:15 +02:00
public void TakeFirst ()
{
2025-03-14 12:13:17 +01:00
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 )" ));
2013-05-11 12:03:15 +02:00
}
2014-08-19 20:24:54 +02:00
public IEnumerable < IFileset > QuickSets
{
get
{
var dict = new Dictionary < long , long >();
2019-08-05 20:14:05 -04:00
for ( var i = 0 ; i < m_filesets . Length ; i ++)
2014-08-19 20:24:54 +02:00
dict [ m_filesets [ i ]. Key ] = i ;
2019-08-05 20:14:05 -04:00
using ( var cmd = m_connection . CreateCommand ())
2025-03-14 14:34:56 +01:00
using ( var rd = cmd . ExecuteReader ( @"SELECT DISTINCT ""ID"", ""IsFullBackup"" FROM ""Fileset"" ORDER BY ""Timestamp"" DESC " ))
while ( rd . Read ())
2014-08-19 20:24:54 +02:00
{
2025-04-03 15:46:20 +02:00
var id = rd . ConvertValueToInt64 ( 0 );
2025-03-14 14:34:56 +01:00
var backupType = rd . GetInt32 ( 1 );
var e = dict [ id ];
yield return new Fileset ( e , backupType , m_filesets [ e ]. Value , - 1L , - 1L );
2014-08-19 20:24:54 +02:00
}
}
}
2025-03-14 12:13:17 +01:00
public IEnumerable < IFileset > Sets
{
2013-05-11 12:03:15 +02:00
get
{
var dict = new Dictionary < long , long >();
2019-08-05 20:14:05 -04:00
for ( var i = 0 ; i < m_filesets . Length ; i ++)
2013-05-11 12:03:15 +02:00
dict [ m_filesets [ i ]. Key ] = i ;
2025-03-14 12:13:17 +01:00
2025-03-13 23:06:32 +01:00
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"" " );
2019-08-05 20:14:05 -04:00
using ( var cmd = m_connection . CreateCommand ())
2025-03-14 14:34:56 +01:00
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 ())
2013-05-11 12:03:15 +02:00
{
2025-04-03 15:46:20 +02:00
var id = rd . ConvertValueToInt64 ( 0 );
2025-03-14 14:34:56 +01:00
var isFullBackup = rd . GetInt32 ( 1 );
var e = dict [ id ];
var filecount = rd . ConvertValueToInt64 ( 2 , - 1L );
var filesizes = rd . ConvertValueToInt64 ( 3 , - 1L );
2019-08-05 20:14:05 -04:00
2025-03-14 14:34:56 +01:00
yield return new Fileset ( e , isFullBackup , m_filesets [ e ]. Value , filecount , filesizes );
2013-05-11 12:03:15 +02:00
}
}
}
2025-03-14 12:13:17 +01:00
2013-05-11 12:03:15 +02:00
public void Dispose ()
{
if ( m_tablename != null )
{
2025-03-14 12:13:17 +01:00
try
2019-08-21 12:55:24 -04:00
{
using ( var cmd = m_connection . CreateCommand ())
2025-03-14 12:13:17 +01:00
cmd . ExecuteNonQuery ( FormatInvariant ( @ $"DROP TABLE IF EXISTS ""{m_tablename}"" " ));
2013-05-11 12:03:15 +02:00
}
2025-03-14 12:13:17 +01:00
catch { }
2025-04-03 15:46:20 +02:00
finally { m_tablename = null !; }
2013-05-11 12:03:15 +02:00
}
2025-03-14 12:13:17 +01:00
2013-05-11 12:03:15 +02:00
}
}
2025-03-14 12:13:17 +01:00
2013-05-11 12:03:15 +02:00
public IFileSets SelectFileSets ( DateTime time , long [] versions )
{
return new FileSets ( this , time , versions );
}
2025-03-14 12:13:17 +01:00
2025-04-29 10:03:57 +02:00
/// <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)" ;
2025-04-29 15:49:38 +02:00
if ( filesetIds != null && filesetIds . Length > 0 )
countWhere += " AND fe.\"FilesetID\" IN (@FilesetIds)" ;
2025-04-29 10:03:57 +02:00
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
);
}
2013-05-11 12:03:15 +02:00
}
}