using System;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace DbTools.Model {
internal class Database {
public string ConnectionString { get; set; }
public string SqlScript { get; set; }
public TableCollection Tables { get; set; } = new TableCollection();
public IDbConnection DbConnection { get; private set; }
public Database() { }
public Database(string connectionString) {
ConnectionString = connectionString;
}
public Database(IDbConnection dbConnection, bool importImmediately = false) {
DbConnection = dbConnection;
ConnectionString = dbConnection.ConnectionString;
// Give the user the option to import later to avoid unnecessary work
if (importImmediately) {
importFromSqlite();
}
}
///
/// Loads the specified SQL script and initializes an in-memory SQLite database using the script.
///
/// This method creates a temporary SQLite database file, sets up a connection string,
/// and executes the provided SQL script to initialize the database. The connection string for the database is
/// stored in the property.
/// The SQL script to be executed for creating and populating the database.
/// A representing the asynchronous operation.
public void LoadSql(string sql) {
if (!sql.Contains("-- Generated with DbTools")) {
throw new ArgumentException("The provided SQL script does not appear to be generated by DbTools.");
}
ParseTablesFromSql(sql);
SqlScript = ToSql();
}
///
/// Determines whether a table with the specified name exists in the collection.
///
/// The name of the table to search for. The comparison is case-insensitive.
/// if a table with the specified name exists; otherwise, .
public bool ContainsTable(string tableName) {
return Tables.Contains(tableName);
}
///
/// Gets the with the specified table name, or null if no matching table is found.
///
/// The name of the table to retrieve. The comparison is case-insensitive.
///
public Table this[string tableName] {
get {
return Tables[tableName];
}
}
///
/// Parses the provided SQL script to extract table definitions, along with their associated indexes and
/// triggers.
///
/// This method processes the SQL script line by line to identify and parse table, index,
/// and trigger definitions. It supports SQL scripts that include "CREATE TABLE", "CREATE INDEX", and "CREATE
/// TRIGGER" statements. The method yields each parsed table as it is processed, allowing for efficient
/// streaming of results. NOTE: This method requires the SQL script to be in the expected format, which can
/// be generated using this project.
/// The SQL script containing table, index, and trigger definitions. The script must be in a valid SQL format.
/// An enumerable collection of objects, each representing a table parsed from the SQL
/// script. The collection includes the table's structure, indexes, and triggers as defined in the script.
public IEnumerable
ParseTablesFromSql(string sql) {
Table table = null;
StringBuilder sb = new StringBuilder();
Dictionary> indexes = new Dictionary>();
Dictionary> triggers = new Dictionary>();
bool inTable = false;
foreach (string line in Regex.Split(sql, "\\r\\n")) {
if (string.IsNullOrEmpty(line) || line.StartsWith("--")) {
continue;
}
string trimmedLine = Regex.Replace(line.Trim(), @"\s+", " ");
if (trimmedLine.ToUpper().StartsWith("CREATE TABLE ")) {
// Start a new table
var match = Regex.Match(trimmedLine, "CREATE TABLE( IF NOT EXISTS)? (\\w*) .*\\(");
if (match.Success) {
string tableName = match.Groups[2].Value.Trim();
table = new Table() {
TableName = tableName
};
sb = new StringBuilder();
sb.AppendLine(trimmedLine);
inTable = true;
}
continue;
}
// We assume indexes are always single line
if (trimmedLine.ToUpper().StartsWith("CREATE INDEX ")) {
var matches = Regex.Match(trimmedLine, "CREATE INDEX( IF NOT EXISTS)? (\\w*) ON (\\w*).*\\);");
if (matches.Success) {
string tableName = matches.Groups[3].Value.Trim();
string indexName = matches.Groups[2].Value.Trim();
if (indexes.ContainsKey(tableName)) {
indexes[tableName].Add(indexName + ";" + trimmedLine);
} else {
indexes[tableName] = new List { indexName + ";" + trimmedLine };
}
continue;
}
}
// We assume triggers are always single line
if (trimmedLine.ToUpper().StartsWith("CREATE TRIGGER ")) {
var matches = Regex.Match(trimmedLine, "CREATE TRIGGER( IF NOT EXISTS)? (\\w*) .* ON (\\w*).*END;");
if (matches.Success) {
string tableName = matches.Groups[3].Value.Trim();
string triggerName = matches.Groups[2].Value.Trim();
if (triggers.ContainsKey(tableName)) {
triggers[tableName].Add(triggerName + ";" + trimmedLine);
} else {
triggers[tableName] = new List { triggerName + ";" + trimmedLine };
}
}
continue;
}
// Inside a table definition, accumulate lines
if (!inTable) {
if (trimmedLine == ");") {
// End of table definition
sb.AppendLine(trimmedLine);
table.ParseSql(sb.ToString());
Tables.Add(table);
inTable = false;
yield return table;
} else {
sb.AppendLine("\t" + trimmedLine);
}
}
}
// Append indexes and triggers to their respective tables
appendIndexes(indexes);
appendTriggers(triggers);
}
///
/// Builds and returns an SQL statement based on the currently loaded database connection.
///
/// A boolean value indicating whether the generated SQL statement should include a conditional check to ensure
/// the existence of the target object before performing the operation. to include the
/// conditional check; otherwise, .
/// A string containing the generated SQL statement.
public string BuildSql(bool includeIfNotExist = false) {
importFromSqlite();
return ToSql(includeIfNotExist);
}
///
/// Generates a SQL script for the current database schema.
///
/// The generated script includes metadata such as the generation timestamp and is
/// appended to the existing value. The method processes all tables in the schema and
/// generates their corresponding SQL definitions.
/// A value indicating whether to include conditional checks (e.g., "IF NOT EXISTS") in the generated SQL
/// script.
/// A string containing the generated SQL script, including all tables in the current schema.
public string ToSql(bool includeIfNotExist = false) {
StringBuilder sb = new StringBuilder();
sb.AppendLine("--");
sb.AppendLine("-- Generated with DbTools on " + DateTime.Now.ToString("f"));
sb.AppendLine("--");
foreach (var table in Tables.GetTables()) {
sb.AppendLine(table.FullSql());
}
SqlScript += sb.ToString();
return SqlScript;
}
///
/// Imports the schema and metadata from the connected SQLite database into the application's internal
/// structures.
///
/// This method reads the SQLite database schema, including tables, indexes, and
/// triggers, and populates the application's internal collections with the corresponding metadata. It
/// processes the `sqlite_master` table to extract the necessary information and organizes it into tables,
/// indexes, and triggers. The method clears any existing table metadata before importing new data. It
/// also formats the SQL definitions for tables to improve readability. This method assumes that
/// the database connection is already open and accessible. Ensure that the connection is valid before calling
/// this method.
/// if the import operation completes successfully.
private bool importFromSqlite() {
DbConnection.Open();
Tables.Clear();
Dictionary> indexes = new Dictionary>();
Dictionary> triggers = new Dictionary>();
using (var cmd = DbConnection.CreateCommand()) {
cmd.CommandText = "select * from sqlite_master";
using (var reader = cmd.ExecuteReader()) {
while (reader.Read()) {
if (reader["tbl_name"]?.ToString() == "sqlite_sequence" || string.IsNullOrEmpty(reader["sql"]?.ToString())) { continue; }
string recordType = reader["type"]?.ToString();
if (recordType == "table") {
string sql = reader["sql"]?.ToString();
string tableName = reader["tbl_name"]?.ToString();
Match m = Regex.Match(sql, "CREATE TABLE \\S+ \\((.*)\\)", RegexOptions.Singleline);
if (!m.Success) {
Trace.TraceWarning("Unable to match regex on table " + tableName);
continue;
}
string tableSql = "";
int startIndex = m.Groups[1].Index;
int length = m.Groups[1].Length;
string columns = Regex.Replace(m.Groups[1].Value, "\\s{2,}", " ");
columns = Regex.Replace(columns.Replace(", ", ",").Replace(",\n", ","), ",(?!\\d+\\))", ",\r\n\t");
tableSql += sql.Substring(0, startIndex) + "\r\n\t" +
columns.Trim() + "\r\n" +
sql.Substring(startIndex + length) + ";";
Table table = new Table() {
TableName = reader["tbl_name"]?.ToString(),
CreateTableSql = tableSql,
};
Tables.Add(table);
} else if (recordType == "index") {
string tableName = reader["tbl_name"]?.ToString();
string indexName = reader["name"]?.ToString();
string indexSql = reader["sql"]?.ToString();
if (indexes.ContainsKey(tableName)) {
indexes[tableName].Add(indexName + ";" + indexSql);
} else {
indexes[tableName] = new List { indexName + ";" + indexSql };
}
} else if (recordType == "trigger") {
string tableName = reader["tbl_name"]?.ToString();
string triggerName = reader["name"]?.ToString();
string triggerSql = reader["sql"]?.ToString();
if (triggers.ContainsKey(tableName)) {
triggers[tableName].Add(triggerName + ";" + triggerSql);
} else {
triggers[tableName] = new List { triggerName + ";" + triggerSql };
}
}
}
}
}
appendIndexes(indexes);
appendTriggers(triggers);
return true;
}
///
/// Appends index definitions to the corresponding tables in the database schema.
///
/// This method updates the Indexes collection of each table in the Tables
/// dictionary with the provided index definitions. If a table does not exist in the Tables dictionary,
/// it is skipped. Only valid index definitions (those containing both a name and a SQL definition) are
/// added.
/// A dictionary where the key is the name of a table, and the value is a list of index definitions. Each index
/// definition is a semicolon-separated string, where the first part is the index name and the second part is
/// the index SQL definition.
private void appendIndexes(Dictionary> indexes) {
foreach (string index in indexes.Keys) {
Table table = Tables[index];
if (table != null) {
foreach (string indexSql in indexes[index]) {
var parts = indexSql.Split(new char[] { ';' }, 2);
if (parts.Length == 2) {
table.Indexes[parts[0]] = parts[1];
}
}
}
}
}
///
/// Appends trigger definitions to the corresponding tables in the database schema.
///
/// This method updates the collection for each table
/// specified in the dictionary. If a table does not exist in the collection, it is skipped. Only trigger definitions with a valid format (name and SQL body
/// separated by a semicolon) are added.
/// A dictionary where each key represents the name of a table, and the associated value is a list of trigger
/// definitions for that table. Each trigger definition is a string containing the trigger name and its SQL
/// body, separated by a semicolon.
private void appendTriggers(Dictionary> triggers) {
foreach (string trigger in triggers.Keys) {
Table table = Tables[trigger];
if (table != null) {
foreach (string triggerSql in triggers[trigger]) {
var parts = triggerSql.Split(new char[] { ';' }, 2);
if (parts.Length == 2) {
table.Triggers[parts[0]] = parts[1];
}
}
}
}
}
}
}