using System; using System.Collections.Generic; using System.Data; using System.Data.SqlClient; using System.Diagnostics; using System.IO; using System.Linq; namespace Umbraco.Core.Persistence { /// /// Manages LocalDB databases. /// /// /// Latest version is SQL Server 2016 Express LocalDB, /// see https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/sql-server-2016-express-localdb /// which can be installed by downloading the Express installer from https://www.microsoft.com/en-us/sql-server/sql-server-downloads /// (about 5MB) then select 'download media' to download SqlLocalDB.msi (about 44MB), which you can execute. This installs /// LocalDB only. Though you probably want to install the full Express. You may also want to install SQL Server Management /// Studio which can be used to connect to LocalDB databases. /// See also https://github.com/ritterim/automation-sql which is a somewhat simpler version of this. /// internal class LocalDb { private int _version; private bool _hasVersion; private string _exe; #region Availability & Version /// /// Gets the LocalDb installed version. /// /// If more than one version is installed, returns the highest available. Returns /// the major version as an integer e.g. 11, 12... /// Thrown when LocalDb is not available. public int Version { get { EnsureVersion(); if (_version <= 0) throw new InvalidOperationException("LocalDb is not available."); return _version; } } /// /// Ensures that the LocalDb version is detected. /// private void EnsureVersion() { if (_hasVersion) return; DetectVersion(); _hasVersion = true; } /// /// Gets a value indicating whether LocalDb is available. /// public bool IsAvailable { get { EnsureVersion(); return _version > 0; } } /// /// Ensures that LocalDb is available. /// /// Thrown when LocalDb is not available. private void EnsureAvailable() { if (IsAvailable == false) throw new InvalidOperationException("LocalDb is not available."); } /// /// Detects LocalDb installed version. /// /// If more than one version is installed, the highest available is detected. private void DetectVersion() { _hasVersion = true; _version = -1; _exe = null; var programFiles = Environment.GetEnvironmentVariable("ProgramFiles"); // MS SQL Server installs in e.g. "C:\Program Files\Microsoft SQL Server", so // we want to detect it in "%ProgramFiles%\Microsoft SQL Server" - however, if // Umbraco runs as a 32bits process (e.g. IISExpress configured as 32bits) // on a 64bits system, %ProgramFiles% will point to "C:\Program Files (x86)" // and SQL Server cannot be found. But then, %ProgramW6432% will point to // the original "C:\Program Files". Using it to fix the path. // see also: MSDN doc for WOW64 implementation // var programW6432 = Environment.GetEnvironmentVariable("ProgramW6432"); if (string.IsNullOrWhiteSpace(programW6432) == false && programW6432 != programFiles) programFiles = programW6432; if (string.IsNullOrWhiteSpace(programFiles)) return; // detect 14, 13, 12, 11 for (var i = 14; i > 10; i--) { var exe = Path.Combine(programFiles, $@"Microsoft SQL Server\{i}0\Tools\Binn\SqlLocalDB.exe"); if (File.Exists(exe) == false) continue; _version = i; _exe = exe; break; } } #endregion #region Instances /// /// Gets the name of existing LocalDb instances. /// /// The name of existing LocalDb instances. /// Thrown when LocalDb is not available. public string[] GetInstances() { EnsureAvailable(); var rc = ExecuteSqlLocalDb("i", out var output, out var error); // info if (rc != 0 || error != string.Empty) return null; return output.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); } /// /// Gets a value indicating whether a LocalDb instance exists. /// /// The name of the instance. /// A value indicating whether a LocalDb instance with the specified name exists. /// Thrown when LocalDb is not available. public bool InstanceExists(string instanceName) { EnsureAvailable(); var instances = GetInstances(); return instances != null && instances.Contains(instanceName, StringComparer.OrdinalIgnoreCase); } /// /// Creates a LocalDb instance. /// /// The name of the instance. /// A value indicating whether the instance was created without errors. /// Thrown when LocalDb is not available. public bool CreateInstance(string instanceName) { EnsureAvailable(); return ExecuteSqlLocalDb($"c \"{instanceName}\"", out _, out var error) == 0 && error == string.Empty; } /// /// Drops a LocalDb instance. /// /// The name of the instance. /// A value indicating whether the instance was dropped without errors. /// Thrown when LocalDb is not available. /// /// When an instance is dropped all the attached database files are deleted. /// Successful if the instance does not exist. /// public bool DropInstance(string instanceName) { EnsureAvailable(); var instance = GetInstance(instanceName); if (instance == null) return true; instance.DropDatabases(); // else the files remain // -i force NOWAIT, -k kills return ExecuteSqlLocalDb($"p \"{instanceName}\" -i", out _, out var error) == 0 && error == string.Empty && ExecuteSqlLocalDb($"d \"{instanceName}\"", out _, out error) == 0 && error == string.Empty; } /// /// Stops a LocalDb instance. /// /// The name of the instance. /// A value indicating whether the instance was stopped without errors. /// Thrown when LocalDb is not available. /// /// Successful if the instance does not exist. /// public bool StopInstance(string instanceName) { EnsureAvailable(); if (InstanceExists(instanceName) == false) return true; // -i force NOWAIT, -k kills return ExecuteSqlLocalDb($"p \"{instanceName}\" -i", out _, out var error) == 0 && error == string.Empty; } /// /// Stops a LocalDb instance. /// /// The name of the instance. /// A value indicating whether the instance was started without errors. /// Thrown when LocalDb is not available. /// /// Failed if the instance does not exist. /// public bool StartInstance(string instanceName) { EnsureAvailable(); if (InstanceExists(instanceName) == false) return false; return ExecuteSqlLocalDb($"s \"{instanceName}\"", out _, out var error) == 0 && error == string.Empty; } /// /// Gets a LocalDb instance. /// /// The name of the instance. /// The instance with the specified name if it exists, otherwise null. /// Thrown when LocalDb is not available. public Instance GetInstance(string instanceName) { EnsureAvailable(); return InstanceExists(instanceName) ? new Instance(instanceName) : null; } #endregion #region Databases /// /// Represents a LocalDb instance. /// /// /// LocalDb is assumed to be available, and the instance is assumed to exist. /// public class Instance { private readonly string _masterCstr; /// /// Gets the name of the instance. /// public string InstanceName { get; } /// /// Initializes a new instance of the class. /// /// public Instance(string instanceName) { InstanceName = instanceName; _masterCstr = $@"Server=(localdb)\{instanceName};Integrated Security=True;"; } /// /// Gets a LocalDb connection string. /// /// The name of the database. /// The connection string for the specified database. /// /// The database should exist in the LocalDb instance. /// public string GetConnectionString(string databaseName) { return _masterCstr + $@"Database={databaseName};"; } /// /// Gets a LocalDb connection string for an attached database. /// /// The name of the database. /// The directory containing database files. /// The connection string for the specified database. /// /// The database should not exist in the LocalDb instance. /// It will be attached with its name being its MDF filename (full path), uppercased, when /// the first connection is opened, and remain attached until explicitely detached. /// public string GetAttachedConnectionString(string databaseName, string filesPath) { GetDatabaseFiles(databaseName, filesPath, out _, out _, out _, out var mdfFilename, out _); return _masterCstr + $@"AttachDbFileName='{mdfFilename}';"; } /// /// Gets the name of existing databases. /// /// The name of existing databases. public string[] GetDatabases() { var userDatabases = new List(); using (var conn = new SqlConnection(_masterCstr)) using (var cmd = conn.CreateCommand()) { conn.Open(); var databases = new Dictionary(); SetCommand(cmd, @" SELECT name, filename FROM sys.sysdatabases"); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { databases[reader.GetString(0)] = reader.GetString(1); } } foreach (var database in databases) { var dbname = database.Key; if (dbname == "master" || dbname == "tempdb" || dbname == "model" || dbname == "msdb") continue; // fixme - shall we deal with stale databases? // fixme - is it always ok to assume file names? //var mdf = database.Value; //var ldf = mdf.Replace(".mdf", "_log.ldf"); //if (staleOnly && File.Exists(mdf) && File.Exists(ldf)) // continue; //ExecuteDropDatabase(cmd, dbname, mdf, ldf); //count++; userDatabases.Add(dbname); } } return userDatabases.ToArray(); } /// /// Gets a value indicating whether a database exists. /// /// The name of the database. /// A value indicating whether a database with the specified name exists. /// /// A database exists if it is registered in the instance, and its files exist. If the database /// is registered but some of its files are missing, the database is dropped. /// public bool DatabaseExists(string databaseName) { using (var conn = new SqlConnection(_masterCstr)) using (var cmd = conn.CreateCommand()) { conn.Open(); var mdf = GetDatabase(cmd, databaseName); if (mdf == null) return false; // it can exist, even though its files have been deleted // if files exist assume all is ok (should we try to connect?) var ldf = GetLogFilename(mdf); if (File.Exists(mdf) && File.Exists(ldf)) return true; ExecuteDropDatabase(cmd, databaseName, mdf, ldf); } return false; } /// /// Creates a new database. /// /// The name of the database. /// The directory containing database files. /// A value indicating whether the database was created without errors. /// /// Failed if a database with the specified name already exists in the instance, /// or if the database files already exist in the specified directory. /// public bool CreateDatabase(string databaseName, string filesPath) { GetDatabaseFiles(databaseName, filesPath, out var logName, out _, out _, out var mdfFilename, out var ldfFilename); using (var conn = new SqlConnection(_masterCstr)) using (var cmd = conn.CreateCommand()) { conn.Open(); var mdf = GetDatabase(cmd, databaseName); if (mdf != null) return false; // cannot use parameters on CREATE DATABASE // ie "CREATE DATABASE @0 ..." does not work SetCommand(cmd, $@" CREATE DATABASE {QuotedName(databaseName)} ON (NAME=N{QuotedName(databaseName, '\'')}, FILENAME={QuotedName(mdfFilename, '\'')}) LOG ON (NAME=N{QuotedName(logName, '\'')}, FILENAME={QuotedName(ldfFilename, '\'')})"); var unused = cmd.ExecuteNonQuery(); } return true; } /// /// Drops a database. /// /// The name of the database. /// A value indicating whether the database was dropped without errors. /// /// Successful if the database does not exist. /// Deletes the database files. /// public bool DropDatabase(string databaseName) { using (var conn = new SqlConnection(_masterCstr)) using (var cmd = conn.CreateCommand()) { conn.Open(); SetCommand(cmd, @" SELECT name, filename FROM master.dbo.sysdatabases WHERE ('[' + name + ']' = @0 OR name = @0)", databaseName); var mdf = GetDatabase(cmd, databaseName); if (mdf == null) return true; ExecuteDropDatabase(cmd, databaseName, mdf); } return true; } /// /// Drops stale databases. /// /// The number of databases that were dropped. /// /// A database is considered stale when its files cannot be found. /// public int DropStaleDatabases() { return DropDatabases(true); } /// /// Drops databases. /// /// A value indicating whether to delete only stale database. /// The number of databases that were dropped. /// /// A database is considered stale when its files cannot be found. /// public int DropDatabases(bool staleOnly = false) { var count = 0; using (var conn = new SqlConnection(_masterCstr)) using (var cmd = conn.CreateCommand()) { conn.Open(); var databases = new Dictionary(); SetCommand(cmd, @" SELECT name, filename FROM sys.sysdatabases"); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { databases[reader.GetString(0)] = reader.GetString(1); } } foreach (var database in databases) { var dbname = database.Key; if (dbname == "master" || dbname == "tempdb" || dbname == "model" || dbname == "msdb") continue; var mdf = database.Value; var ldf = mdf.Replace(".mdf", "_log.ldf"); if (staleOnly && File.Exists(mdf) && File.Exists(ldf)) continue; ExecuteDropDatabase(cmd, dbname, mdf, ldf); count++; } } return count; } /// /// Detaches a database. /// /// The name of the database. /// The directory containing the database files. /// Thrown when a database with the specified name does not exist. public string DetachDatabase(string databaseName) { using (var conn = new SqlConnection(_masterCstr)) using (var cmd = conn.CreateCommand()) { conn.Open(); var mdf = GetDatabase(cmd, databaseName); if (mdf == null) throw new InvalidOperationException("Database does not exist."); DetachDatabase(cmd, databaseName); return Path.GetDirectoryName(mdf); } } /// /// Attaches a database. /// /// The name of the database. /// The directory containing database files. /// Thrown when a database with the specified name already exists. public void AttachDatabase(string databaseName, string filesPath) { using (var conn = new SqlConnection(_masterCstr)) using (var cmd = conn.CreateCommand()) { conn.Open(); var mdf = GetDatabase(cmd, databaseName); if (mdf != null) throw new InvalidOperationException("Database already exists."); AttachDatabase(cmd, databaseName, filesPath); } } /// /// Gets the file names of a database. /// /// The name of the database. /// The MDF logical name. /// The LDF logical name. /// The MDF filename. /// The LDF filename. public void GetFilenames(string databaseName, out string mdfName, out string ldfName, out string mdfFilename, out string ldfFilename) { using (var conn = new SqlConnection(_masterCstr)) using (var cmd = conn.CreateCommand()) { conn.Open(); GetFilenames(cmd, databaseName, out mdfName, out ldfName, out mdfFilename, out ldfFilename); } } /// /// Kills all existing connections. /// /// The name of the database. public void KillConnections(string databaseName) { using (var conn = new SqlConnection(_masterCstr)) using (var cmd = conn.CreateCommand()) { conn.Open(); SetCommand(cmd, @" DECLARE @sql VARCHAR(MAX); SELECT @sql = COALESCE(@sql,'') + 'kill ' + CONVERT(VARCHAR, SPId) + ';' FROM master.sys.sysprocesses WHERE DBId = DB_ID(@0) AND SPId <> @@SPId; EXEC(@sql);", databaseName); cmd.ExecuteNonQuery(); } } /// /// Gets a database. /// /// The Sql Command. /// The name of the database. /// The full filename of the MDF file, if the database exists, otherwise null. private static string GetDatabase(SqlCommand cmd, string databaseName) { SetCommand(cmd, @" SELECT name, filename FROM master.dbo.sysdatabases WHERE ('[' + name + ']' = @0 OR name = @0)", databaseName); string mdf = null; using (var reader = cmd.ExecuteReader()) { if (reader.Read()) mdf = reader.GetString(1) ?? string.Empty; while (reader.Read()) { } } return mdf; } /// /// Drops a database and its files. /// /// The Sql command. /// The name of the database. /// The name of the database (MDF) file. /// The name of the log (LDF) file. private static void ExecuteDropDatabase(SqlCommand cmd, string databaseName, string mdf, string ldf = null) { try { // cannot use parameters on ALTER DATABASE // ie "ALTER DATABASE @0 ..." does not work SetCommand(cmd, $@" ALTER DATABASE {QuotedName(databaseName)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE"); var unused1 = cmd.ExecuteNonQuery(); } catch (SqlException e) { if (e.Message.Contains("Unable to open the physical file") && e.Message.Contains("Operating system error 2:")) { // quite probably, the files were missing // yet, it should be possible to drop the database anyways // but we'll have to deal with the files } else { // no idea, throw throw; } } // cannot use parameters on DROP DATABASE // ie "DROP DATABASE @0 ..." does not work SetCommand(cmd, $@" DROP DATABASE {QuotedName(databaseName)}"); var unused2 = cmd.ExecuteNonQuery(); // be absolutely sure if (File.Exists(mdf)) File.Delete(mdf); ldf = ldf ?? GetLogFilename(mdf); if (File.Exists(ldf)) File.Delete(ldf); } /// /// Gets the log (LDF) filename corresponding to a database (MDF) filename. /// /// The MDF filename. /// private static string GetLogFilename(string mdfFilename) { if (mdfFilename.EndsWith(".mdf") == false) throw new ArgumentException("Not a valid MDF filename (no .mdf extension).", nameof(mdfFilename)); return mdfFilename.Substring(0, mdfFilename.Length - ".mdf".Length) + "_log.ldf"; } /// /// Detaches a database. /// /// The Sql command. /// The name of the database. private static void DetachDatabase(SqlCommand cmd, string databaseName) { // cannot use parameters on ALTER DATABASE // ie "ALTER DATABASE @0 ..." does not work SetCommand(cmd, $@" ALTER DATABASE {QuotedName(databaseName)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE"); var unused1 = cmd.ExecuteNonQuery(); SetCommand(cmd, @" EXEC sp_detach_db @dbname=@0", databaseName); var unused2 = cmd.ExecuteNonQuery(); } /// /// Attaches a database. /// /// The Sql command. /// The name of the database. /// The directory containing database files. private static void AttachDatabase(SqlCommand cmd, string databaseName, string filesPath) { GetDatabaseFiles(databaseName, filesPath, out var logName, out _, out _, out var mdfFilename, out var ldfFilename); // cannot use parameters on CREATE DATABASE // ie "CREATE DATABASE @0 ..." does not work SetCommand(cmd, $@" CREATE DATABASE {QuotedName(databaseName)} ON (NAME=N{QuotedName(databaseName, '\'')}, FILENAME={QuotedName(mdfFilename, '\'')}) LOG ON (NAME=N{QuotedName(logName, '\'')}, FILENAME={QuotedName(ldfFilename, '\'')}) FOR ATTACH"); var unused = cmd.ExecuteNonQuery(); } /// /// Sets a database command. /// /// The command. /// The command text. /// The command arguments. /// /// The command text must refer to arguments as @0, @1... each referring /// to the corresponding position in . /// private static void SetCommand(SqlCommand cmd, string sql, params object[] args) { cmd.CommandType = CommandType.Text; cmd.CommandText = sql; cmd.Parameters.Clear(); for (var i = 0; i < args.Length; i++) cmd.Parameters.AddWithValue("@" + i, args[i]); } /// /// Gets the file names of a database. /// /// The Sql command. /// The name of the database. /// The MDF logical name. /// The LDF logical name. /// The MDF filename. /// The LDF filename. private void GetFilenames(SqlCommand cmd, string databaseName, out string mdfName, out string ldfName, out string mdfFilename, out string ldfFilename) { mdfName = ldfName = mdfFilename = ldfFilename = null; SetCommand(cmd, @" SELECT DB_NAME(database_id), type_desc, name, physical_name FROM master.sys.master_files WHERE database_id=DB_ID(@0)", databaseName); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { var type = reader.GetString(1); if (type == "ROWS") { mdfName = reader.GetString(2); ldfName = reader.GetString(3); } else if (type == "LOG") { ldfName = reader.GetString(2); ldfFilename = reader.GetString(3); } } } } } /// /// Copy database files. /// /// The name of the source database. /// The directory containing source database files. /// The name of the target database. /// The directory containing target database files. /// The source database files extension. /// The target database files extension. /// A value indicating whether to overwrite the target files. /// A value indicating whether to delete the source files. /// /// The , , /// and parameters are optional. If they result in target being identical /// to source, no copy is performed. If is false, nothing happens, otherwise the source /// files are deleted. /// If target is not identical to source, files are copied or moved, depending on the value of . /// Extensions are used eg to copy MyDatabase.mdf to MyDatabase.mdf.temp. /// public void CopyDatabaseFiles(string databaseName, string filesPath, string targetDatabaseName = null, string targetFilesPath = null, string sourceExtension = null, string targetExtension = null, bool overwrite = false, bool delete = false) { var nop = (targetFilesPath == null || targetFilesPath == filesPath) && (targetDatabaseName == null || targetDatabaseName == databaseName) && (sourceExtension == null && targetExtension == null || sourceExtension == targetExtension); if (nop && delete == false) return; GetDatabaseFiles(databaseName, filesPath, out _, out _, out _, out var mdfFilename, out var ldfFilename); if (sourceExtension != null) { mdfFilename += "." + sourceExtension; ldfFilename += "." + sourceExtension; } if (nop) { // delete if (File.Exists(mdfFilename)) File.Delete(mdfFilename); if (File.Exists(ldfFilename)) File.Delete(ldfFilename); } else { // copy or copy+delete ie move GetDatabaseFiles(targetDatabaseName ?? databaseName, targetFilesPath ?? filesPath, out _, out _, out _, out var targetMdfFilename, out var targetLdfFilename); if (targetExtension != null) { targetMdfFilename += "." + targetExtension; targetLdfFilename += "." + targetExtension; } if (delete) { if (overwrite && File.Exists(targetMdfFilename)) File.Delete(targetMdfFilename); if (overwrite && File.Exists(targetLdfFilename)) File.Delete(targetLdfFilename); File.Move(mdfFilename, targetMdfFilename); File.Move(ldfFilename, targetLdfFilename); } else { File.Copy(mdfFilename, targetMdfFilename, overwrite); File.Copy(ldfFilename, targetLdfFilename, overwrite); } } } /// /// Gets a value indicating whether database files exist. /// /// The name of the source database. /// The directory containing source database files. /// The database files extension. /// A value indicating whether the database files exist. /// /// Extensions are used eg to copy MyDatabase.mdf to MyDatabase.mdf.temp. /// public bool DatabaseFilesExist(string databaseName, string filesPath, string extension = null) { GetDatabaseFiles(databaseName, filesPath, out _, out _, out _, out var mdfFilename, out var ldfFilename); if (extension != null) { mdfFilename += "." + extension; ldfFilename += "." + extension; } return File.Exists(mdfFilename) && File.Exists(ldfFilename); } /// /// Gets the name of the database files. /// /// The name of the database. /// The directory containing database files. /// The name of the log. /// The base filename (the MDF filename without the .mdf extension). /// The base log filename (the LDF filename without the .ldf extension). /// The MDF filename. /// The LDF filename. private static void GetDatabaseFiles(string databaseName, string filesPath, out string logName, out string baseFilename, out string baseLogFilename, out string mdfFilename, out string ldfFilename) { logName = databaseName + "_log"; baseFilename = Path.Combine(filesPath, databaseName); baseLogFilename = Path.Combine(filesPath, logName); mdfFilename = baseFilename + ".mdf"; ldfFilename = baseFilename + "_log.ldf"; } #endregion #region SqlLocalDB /// /// Executes the SqlLocalDB command. /// /// The arguments. /// The command standard output. /// The command error output. /// The process exit code. /// /// Execution is successful if the exit code is zero, and error is empty. /// private int ExecuteSqlLocalDb(string args, out string output, out string error) { if (_exe == null) // should never happen - we should not execute if not available { output = string.Empty; error = "SqlLocalDB.exe not found"; return -1; } var p = new Process { StartInfo = { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, FileName = _exe, Arguments = args, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden } }; p.Start(); output = p.StandardOutput.ReadToEnd(); error = p.StandardError.ReadToEnd(); p.WaitForExit(); return p.ExitCode; } /// /// Returns a Unicode string with the delimiters added to make the input string a valid SQL Server delimited identifier. /// /// The name to quote. /// A quote character. /// /// /// This is a C# implementation of T-SQL QUOTEDNAME. /// is optional, it can be '[' (default), ']', '\'' or '"'. /// private static string QuotedName(string name, char quote = '[') { switch (quote) { case '[': case ']': return "[" + name.Replace("]", "]]") + "]"; case '\'': return "'" + name.Replace("'", "''") + "'"; case '"': return "\"" + name.Replace("\"", "\"\"") + "\""; default: throw new NotSupportedException("Not a valid quote character."); } } #endregion } }