//****************************************************************************************************** // DatabaseConfigurationLoader.cs - Gbtc // // Copyright © 2014, Grid Protection Alliance. All Rights Reserved. // // Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See // the NOTICE file distributed with this work for additional information regarding copyright ownership. // The GPA licenses this file to you under the MIT License (MIT), the "License"; you may // not use this file except in compliance with the License. You may obtain a copy of the License at: // // http://www.opensource.org/licenses/MIT // // Unless agreed to in writing, the subject software distributed under the License is distributed on an // "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the // License for the specific language governing permissions and limitations. // // Code Modification History: // ---------------------------------------------------------------------------------------------------- // 04/04/2014 - Stephen C. Wills // Generated original version of source code. // //****************************************************************************************************** using System; using System.Collections.Generic; using System.Data; using System.Linq; using System.Reflection; using GSF.Data; using GSF.IO; using GSF.Security; using GSF.Units; namespace GSF.TimeSeries.Configuration { /// /// Defines a method signature for a bootstrap data source operation. /// /// Connection to database. /// Formatted node ID Guid query string. /// Latest version of the configuration to which data operations were previously applied. /// Optional data operation arguments. /// Reference to host status message function. /// Reference to host process exception function. public delegate void DataOperationFunction(AdoDataConnection database, string nodeIDQueryString, ulong trackingVersion, string arguments, Action statusMessage, Action processException); /// /// Represents a configuration loader that gets its configuration from a database connection. /// public class DatabaseConfigurationLoader : IConfigurationLoader, IDisposable { #region [ Members ] // Events /// /// Occurs when the configuration loader has a message to provide about its current status. /// public event EventHandler> StatusMessage; /// /// Occurs when the configuration loader encounters a non-catastrophic exception. /// public event EventHandler> ProcessException; // Fields private string m_connectionString; private string m_dataProviderString; private string m_nodeIDQueryString; private AdoDataConnection m_database; #endregion #region [ Properties ] /// /// Gets or sets the connection string which /// defines how to connect to the database. /// public string ConnectionString { get { return m_connectionString; } set { m_connectionString = value; } } /// /// Gets or sets the data provider string, which determines the /// .NET types to use when opening connections to the database. /// public string DataProviderString { get { return m_dataProviderString; } set { m_dataProviderString = value; } } /// /// Gets or sets the string to use in queries when filtering results by node ID. /// public string NodeIDQueryString { get { return m_nodeIDQueryString; } set { m_nodeIDQueryString = value; } } /// /// Gets the flag that indicates whether augmentation is supported by this configuration loader. /// public bool CanAugment { get { try { int trackedTables = 0; Execute(database => trackedTables = Convert.ToInt32(database.Connection.ExecuteScalar("SELECT COUNT(*) FROM TrackedTable"))); return trackedTables > 0; } catch { return false; } } } #endregion #region [ Methods ] /// /// Opens the database connection. /// public void Open() { using (m_database) { m_database = new AdoDataConnection(m_connectionString, m_dataProviderString); OnStatusMessage("Database connection opened."); } } /// /// Loads the entire configuration data set from scratch. /// /// The configuration data set. public DataSet Load() { DataSet configuration = null; Execute(database => { ulong latestVersion; DataTable entities; configuration = new DataSet("Iaon"); latestVersion = GetLatestVersion(0LU); ExecuteDataOperations(); // Load configuration entities defined in database entities = GetEntities(); // Add configuration entities table to system configuration for reference configuration.Tables.Add(entities.Copy()); // Add each configuration entity to the system configuration foreach (DataRow entityRow in entities.Rows) configuration.Tables.Add(LoadTable(entityRow)); AddVersion(configuration, latestVersion); ExtractSecurityContext(); }); return configuration; } /// /// Augments the given configuration data set with the changes /// tracked since the version of the given configuration data set. /// /// The configuration data set to be augmented. public void Augment(DataSet configuration) { // Get the version of the configuration so we know // which changes in the change table need to be updated ulong currentVersion = GetVersion(configuration); Execute(database => { ulong latestVersion; DataTable entities; string sourceName; string runtimeName; DataTable trackedChanges; string[] trackedTables; int changeCount; DataTable entityTable; string primaryKeyColumn; HashSet primaryKeys; DataRow[] entityChanges; Dictionary unchangedRecordIndexes; Dictionary changedRecords; List rowsToRemove; Ticks operationStartTime; Time operationElapsedTime; bool success; // First check if we can augment if (!CanAugment) throw new InvalidOperationException("Unable to augment configuration because it is not supported."); // First thing we do is get the latest version available from the TrackedChanges // table. This will be the version we are upgrading to. We need to do this first // to prevent potential race conditions that might occur between this and other // processes that may be modifying the database that could result in failure to // update changes to an augmented table latestVersion = GetLatestVersion(currentVersion); // Execute data operations before loading anything else from the database ExecuteDataOperations(currentVersion); // Load configuration entities defined in database entities = GetEntities(); // If any tables exist in the configuration data set that do not exist // in the ConfigurationEntity table, they were either added at runtime // or removed from ConfigurationEntity. In either case, they can be // safely removed from the configuration data set foreach (DataTable table in configuration.Tables.Cast().ToList()) { if (entities.Select().All(row => row["RuntimeName"].ToNonNullString() != table.TableName)) configuration.Tables.Remove(table); } // Add configuration entities table to system configuration for reference configuration.Tables.Add(entities.Copy()); // Get the changes since the current version of the configuration data set trackedChanges = GetTrackedChanges(currentVersion); if (TrackedChangesAreValid(currentVersion)) { // If there is a gap between the version of this configuration and the minimum version // in the changes table, there might be some changes that were unaccounted for. Therefore, // we pretend there are no tracked tables so that all tables will be reloaded from scratch if (trackedChanges.Select().Select(row => Convert.ToUInt64(row["ID"])).DefaultIfEmpty(currentVersion + 1LU).Min() != currentVersion + 1LU) { currentVersion = ulong.MinValue; trackedTables = new string[0]; } else { trackedTables = database.Connection.RetrieveData(database.AdapterType, "SELECT Name FROM TrackedTable").Select() .Select(row => row["Name"].ToNonNullString()) .ToArray(); } } else { // If there is any tracked change version in the changes table that is smaller than the current // version, there may have been an unexpected manual database change or migration. Therefore, // we pretend there are no tracked tables so that all tables will be reloaded from scratch currentVersion = ulong.MinValue; trackedTables = new string[0]; } foreach (DataRow entityRow in entities.Rows) { success = false; // Get the source name and runtime name of the table we are attempting to augment sourceName = entityRow["SourceName"].ToNonNullString(); runtimeName = entityRow["RuntimeName"].ToNonNullString(); // A table can only be augmented if the table is tracked and if it exists in the current configuration if (configuration.Tables.Contains(runtimeName) && trackedTables.Contains(sourceName)) { try { // Get the list of changes specific to this table entityChanges = trackedChanges.Select(string.Format("TableName = '{0}'", sourceName)); // If there are no changes to this table, there is nothing to do if (entityChanges.Length > 0) { // Track the amount of time it takes to do this operation operationStartTime = DateTime.UtcNow.Ticks; // Get the actual table to be augmented out of the current configuration entityTable = configuration.Tables[runtimeName]; // Get the name of the column containing the primary key for that table primaryKeyColumn = entityChanges.Select(row => row["PrimaryKeyColumn"].ToNonNullString()).First(); // Get the distinct list of valid primary keys primaryKeys = new HashSet(entityChanges.Select(row => row["PrimaryKeyValue"]).Where(primaryKey => primaryKey != null && primaryKey != DBNull.Value).Select(primaryKey => primaryKey.ToString()), StringComparer.CurrentCultureIgnoreCase); // Get the relevant records from the entity table as well as the updated values from the database unchangedRecordIndexes = entityTable.Rows.Cast().Select((row, index) => Tuple.Create(row[primaryKeyColumn].ToString(), index)).Where(tuple => primaryKeys.Contains(tuple.Item1)).ToDictionary(tuple => tuple.Item1, tuple => tuple.Item2, StringComparer.CurrentCultureIgnoreCase); changedRecords = GetChangedRecords(sourceName, primaryKeyColumn, currentVersion).Rows.Cast().ToDictionary(row => row[primaryKeyColumn].ToString(), row => row, StringComparer.CurrentCultureIgnoreCase); rowsToRemove = new List(); // Go through the list of distinct primary key values and query // the database for the latest changes to each of the modified records changeCount = 0; foreach (string primaryKey in primaryKeys) { int unchangedIndex; DataRow changedRow; if (changedRecords.TryGetValue(primaryKey, out changedRow)) { // Record was inserted or modified - add or // update it in the augmented configuration if (!unchangedRecordIndexes.TryGetValue(primaryKey, out unchangedIndex)) { unchangedIndex = entityTable.Rows.Count; entityTable.Rows.Add(entityTable.NewRow()); } Update(entityTable.Rows[unchangedIndex], changedRow); } else { // Record was removed so we hang onto the index so we can // remove it from the augmented configuration at the end if (unchangedRecordIndexes.TryGetValue(primaryKey, out unchangedIndex)) rowsToRemove.Add(unchangedIndex); } changeCount++; } // Remove rows in descending index order so that indexes // don't change for rows that need to be removed foreach (int index in rowsToRemove.OrderByDescending(index => index)) { int lastIndex = entityTable.Rows.Count - 1; if (index != lastIndex) Update(entityTable.Rows[index], entityTable.Rows[lastIndex]); entityTable.Rows.RemoveAt(lastIndex); } // Get the amount of time it took to augment the table operationElapsedTime = (DateTime.UtcNow.Ticks - operationStartTime).ToSeconds(); // Display information to the user OnStatusMessage(string.Format("Loaded {0} change{1} to \"{2}\" in {3}...", changeCount, changeCount == 1 ? "" : "s", runtimeName, operationElapsedTime.ToString(2))); } success = true; } catch (Exception ex) { OnStatusMessage(string.Format("WARNING: Unable to augment {0} table due to exception: {1}", runtimeName, ex.Message)); OnProcessException(ex); } } if (!success) { // If we were unable to augment the table, we need to // drop the current configuration and load the whole table if (configuration.Tables.Contains(runtimeName)) configuration.Tables.Remove(runtimeName); configuration.Tables.Add(LoadTable(entityRow)); } } AddVersion(configuration, latestVersion); try { database.Connection.ExecuteNonQuery(string.Format("DELETE FROM TrackedChange WHERE ID <= {0}", latestVersion)); } catch (Exception ex) { OnStatusMessage(string.Format("WARNING: Unable to curtail the TrackedChange table due to exception: {0}", ex.Message)); OnProcessException(ex); } }); } /// /// Closes the database connection. /// public void Close() { if ((object)m_database != null) { m_database.Dispose(); m_database = null; OnStatusMessage("Database connection closed."); } } /// /// Releases all the resources used by the object. /// public void Dispose() { Close(); } private void ExecuteDataOperations(ulong trackingVersion = ulong.MinValue) { Execute(database => { string assemblyName = "", typeName = "", methodName = "", arguments; Assembly assembly; Type type; MethodInfo method; foreach (DataRow row in database.Connection.RetrieveData(database.AdapterType, string.Format("SELECT * FROM DataOperation WHERE (NodeID IS NULL OR NodeID={0}) AND Enabled <> 0 ORDER BY LoadOrder", m_nodeIDQueryString)).Rows) { try { OnStatusMessage(string.Format("Executing startup data operation \"{0}\".", row["Description"].ToNonNullString("Unlabeled"))); // Load data operation parameters assemblyName = row["AssemblyName"].ToNonNullString(); typeName = row["TypeName"].ToNonNullString(); methodName = row["MethodName"].ToNonNullString(); arguments = row["Arguments"].ToNonNullString(); if (string.IsNullOrWhiteSpace(assemblyName)) throw new InvalidOperationException("Data operation assembly name was not defined."); if (string.IsNullOrWhiteSpace(typeName)) throw new InvalidOperationException("Data operation type name was not defined."); if (string.IsNullOrWhiteSpace(methodName)) throw new InvalidOperationException("Data operation method name was not defined."); // Load data operation from containing assembly and type assembly = Assembly.LoadFrom(FilePath.GetAbsolutePath(assemblyName)); type = assembly.GetType(typeName); method = type.GetMethod(methodName, BindingFlags.IgnoreCase | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.InvokeMethod); // Execute data operation via loaded assembly method ((DataOperationFunction)Delegate.CreateDelegate(typeof(DataOperationFunction), method))(database, m_nodeIDQueryString, trackingVersion, arguments, OnStatusMessage, OnProcessException); } catch (Exception ex) { OnStatusMessage(string.Format("WARNING: Failed to execute startup data operation \"{0} [{1}::{2}()]\" due to exception: {3}", assemblyName, typeName, methodName, ex.Message)); OnProcessException(ex); } } }); } private DataTable LoadTable(DataRow entityRow) { DataTable destination = null; Execute(database => { DataTable source; DataColumnCollection columns; Dictionary columnIndex; Ticks operationStartTime; Time operationElapsedTime; // Load configuration entity data filtered by node ID operationStartTime = DateTime.UtcNow.Ticks; source = database.Connection.RetrieveData(database.AdapterType, string.Format("SELECT * FROM {0} WHERE NodeID={1}", entityRow["SourceName"], m_nodeIDQueryString)); operationElapsedTime = (DateTime.UtcNow.Ticks - operationStartTime).ToSeconds(); // Update table name as defined in configuration entity source.TableName = entityRow["RuntimeName"].ToString(); OnStatusMessage(string.Format("Loaded {0} row{1} from \"{2}\" in {3}...", source.Rows.Count, source.Rows.Count == 1 ? "" : "s", source.TableName, operationElapsedTime.ToString(2))); operationStartTime = DateTime.UtcNow.Ticks; // Clone data source destination = source.Clone(); // Get destination column collection columns = destination.Columns; // Remove redundant node ID column columns.Remove("NodeID"); // Pre-cache column index translation after removal of NodeID column to speed data copy columnIndex = new Dictionary(); foreach (DataColumn column in columns) { columnIndex[column.Ordinal] = source.Columns[column.ColumnName].Ordinal; } // Manually copy-in each row into table foreach (DataRow sourceRow in source.Rows) { DataRow newRow = destination.NewRow(); // Copy each column of data in the current row for (int x = 0; x < columns.Count; x++) { newRow[x] = sourceRow[columnIndex[x]]; } // Add new row to destination table destination.Rows.Add(newRow); } operationElapsedTime = (DateTime.UtcNow.Ticks - operationStartTime).ToSeconds(); OnStatusMessage(string.Format("{0} configuration pre-cache completed in {1}.", source.TableName, operationElapsedTime.ToString(2))); }); return destination; } private DataTable GetEntities() { DataTable entities = null; Execute(database => { entities = database.Connection.RetrieveData(database.AdapterType, "SELECT * FROM ConfigurationEntity WHERE Enabled <> 0 ORDER BY LoadOrder"); entities.TableName = "ConfigurationEntity"; }); return entities; } private ulong GetVersion(DataSet configuration) { try { return configuration.Tables["ConfigurationDataSet"].Select() .Select(row => row.ConvertField("Version")) .First(); } catch { return ulong.MinValue; } } private ulong GetLatestVersion(ulong currentVersion) { ulong version = ulong.MinValue; Execute(database => { try { string query = string.Format("SELECT CASE WHEN COUNT(ID) = 0 THEN {0} ELSE MAX(ID) END FROM TrackedChange", currentVersion); version = Convert.ToUInt64(database.Connection.ExecuteScalar(query)); } catch { version = ulong.MinValue; } }); return version; } private bool TrackedChangesAreValid(ulong currentVersion) { bool changesAreValid = false; Execute(database => { try { string query = string.Format("SELECT COUNT(ID) FROM TrackedChange WHERE ID < {0}", currentVersion); changesAreValid = Convert.ToInt32(database.Connection.ExecuteScalar(query)) == 0; } catch { changesAreValid = false; } }); return changesAreValid; } private DataTable GetTrackedChanges(ulong currentVersion) { DataTable table = null; Execute(database => table = database.Connection.RetrieveData(database.AdapterType, string.Format("SELECT * FROM TrackedChange WHERE ID > {0}", currentVersion))); return table; } private DataTable GetChangedRecords(string tableName, string primaryKeyColumn, ulong currentVersion) { DataTable changes = null; Execute(database => { string query = string.Format("SELECT * FROM {0} WHERE {1} IN (SELECT PrimaryKeyValue FROM TrackedChange WHERE TableName = '{0}' AND ID > {2}) AND NodeID = {3}", tableName, primaryKeyColumn, currentVersion, m_nodeIDQueryString); changes = database.Connection.RetrieveData(database.AdapterType, query); }); return changes; } private void Update(DataRow row, DataRow updates) { foreach (DataColumn column in row.Table.Columns) { if (updates.Table.Columns.Contains(column.ColumnName)) row[column] = updates[column.ColumnName]; } } private void AddVersion(DataSet configuration, ulong version) { DataTable configurationDataSetTable = configuration.Tables.Add("ConfigurationDataSet"); configurationDataSetTable.Columns.Add("Version"); configurationDataSetTable.Rows.Add(version); } private void ExtractSecurityContext() { Execute(database => { Ticks operationStartTime; Time operationElapsedTime; // Extract and begin cache of current security context - this does not require an existing security provider OnStatusMessage("Preparing current security context..."); operationStartTime = DateTime.UtcNow.Ticks; AdoSecurityProvider.ExtractSecurityContext(database.Connection, OnProcessException); operationElapsedTime = (DateTime.UtcNow.Ticks - operationStartTime).ToSeconds(); OnStatusMessage(string.Format("Security context prepared in {0}.", operationElapsedTime.ToString(2))); OnStatusMessage("Database configuration successfully loaded."); }); } private void Execute(Action action) { bool isOpen = (object)m_database != null; try { if (!isOpen) Open(); action(m_database); } finally { if (!isOpen) Close(); } } private void OnStatusMessage(string message) { if ((object)StatusMessage != null) StatusMessage(this, new EventArgs(message)); } private void OnProcessException(Exception ex) { if ((object)ProcessException != null) ProcessException(this, new EventArgs(ex)); } #endregion } }