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; #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; var programFiles = Environment.GetEnvironmentVariable("ProgramFiles"); if (programFiles == null) return; // detect 14, 13, 12, 11 for (var i = 14; i > 10; i--) { var path = Path.Combine(programFiles, string.Format(@"Microsoft SQL Server\{0}0\Tools\Binn\SqlLocalDB.exe", i)); if (File.Exists(path) == false) continue; _version = i; 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(); string output, error; var rc = ExecuteSqlLocalDb("i", out output, out 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); } /// /// 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(); string output, error; return ExecuteSqlLocalDb(string.Format("c \"{0}\"", instanceName), out output, out 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 string output, error; return ExecuteSqlLocalDb(string.Format("p \"{0}\" -i", instanceName), out output, out error) == 0 && error == string.Empty && ExecuteSqlLocalDb(string.Format("d \"{0}\"", instanceName), out output, 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 string output, error; return ExecuteSqlLocalDb(string.Format("p \"{0}\" -i", instanceName), out output, out 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; string output, error; return ExecuteSqlLocalDb(string.Format("s \"{0}\"", instanceName), out output, out 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; private set; } /// /// Initializes a new instance of the class. /// /// public Instance(string instanceName) { InstanceName = instanceName; _masterCstr = string.Format(@"Server=(localdb)\{0};Integrated Security=True;", instanceName); } /// /// 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 + string.Format(@"Database={0};", 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) { string logName, baseFilename, baseLogFilename, mdfFilename, ldfFilename; GetDatabaseFiles(databaseName, filesPath, out logName, out baseFilename, out baseLogFilename, out mdfFilename, out ldfFilename); return _masterCstr + string.Format(@"AttachDbFileName='{0}';", 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) { string logName, baseFilename, baseLogFilename, mdfFilename, ldfFilename; GetDatabaseFiles(databaseName, filesPath, out logName, out baseFilename, out baseLogFilename, out mdfFilename, out 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, string.Format(@" CREATE DATABASE {0} ON (NAME=N{1}, FILENAME={2}) LOG ON (NAME=N{3}, FILENAME={4})", QuotedName(databaseName), QuotedName(databaseName, '\''), QuotedName(mdfFilename, '\''), QuotedName(logName, '\''), 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, string.Format(@" ALTER DATABASE {0} SET SINGLE_USER WITH ROLLBACK IMMEDIATE", QuotedName(databaseName))); 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, string.Format(@" DROP DATABASE {0}", 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).", "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, string.Format(@" ALTER DATABASE {0} SET SINGLE_USER WITH ROLLBACK IMMEDIATE", QuotedName(databaseName))); 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) { string logName, baseFilename, baseLogFilename, mdfFilename, ldfFilename; GetDatabaseFiles(databaseName, filesPath, out logName, out baseFilename, out baseLogFilename, out mdfFilename, out ldfFilename); // cannot use parameters on CREATE DATABASE // ie "CREATE DATABASE @0 ..." does not work SetCommand(cmd, string.Format(@" CREATE DATABASE {0} ON (NAME=N{1}, FILENAME={2}) LOG ON (NAME=N{3}, FILENAME={4}) FOR ATTACH", QuotedName(databaseName), QuotedName(databaseName, '\''), QuotedName(mdfFilename, '\''), QuotedName(logName, '\''), QuotedName(ldfFilename, '\''))); 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; string logName, baseFilename, baseLogFilename, mdfFilename, ldfFilename; GetDatabaseFiles(databaseName, filesPath, out logName, out baseFilename, out baseLogFilename, out mdfFilename, out 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 string targetLogName, targetBaseFilename, targetLogFilename, targetMdfFilename, targetLdfFilename; GetDatabaseFiles(targetDatabaseName ?? databaseName, targetFilesPath ?? filesPath, out targetLogName, out targetBaseFilename, out targetLogFilename, out targetMdfFilename, out 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) { string logName, baseFilename, baseLogFilename, mdfFilename, ldfFilename; GetDatabaseFiles(databaseName, filesPath, out logName, out baseFilename, out baseLogFilename, out mdfFilename, out 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) { var programFiles = Environment.GetEnvironmentVariable("ProgramFiles"); if (programFiles == null) { output = string.Empty; error = "SqlLocalDB.exe not found"; return -1; } var path = Path.Combine(programFiles, string.Format(@"Microsoft SQL Server\{0}0\Tools\Binn\SqlLocalDB.exe", _version)); var p = new Process { StartInfo = { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, FileName = path, 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 } }