/* * Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Extensions.Loading.Sql * File: SqlDbConnectionLoader.cs * * SqlDbConnectionLoader.cs is part of VNLib.Plugins.Extensions.Loading.Sql which is part of the larger * VNLib collection of libraries and utilities. * * VNLib.Plugins.Extensions.Loading.Sql is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * VNLib.Plugins.Extensions.Loading.Sql is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ using System; using System.Linq; using System.Data; using System.Text; using System.Data.Common; using System.Threading.Tasks; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.EntityFrameworkCore; using VNLib.Utils.Logging; using VNLib.Utils.Resources; using VNLib.Utils.Extensions; using VNLib.Plugins.Extensions.Loading.Sql.DatabaseBuilder; using VNLib.Plugins.Extensions.Loading.Sql.DatabaseBuilder.Helpers; namespace VNLib.Plugins.Extensions.Loading.Sql { /// /// Provides common basic SQL loading extensions for plugins /// public static class SqlDbConnectionLoader { public const string SQL_CONFIG_KEY = "sql"; public const string DB_PASSWORD_KEY = "db_password"; public const string SQL_PROVIDER_DLL_KEY = "provider"; private const string MAX_LEN_BYPASS_KEY = "MaxLen"; private const string TIMESTAMP_BYPASS = "TimeStamp"; /// /// Gets (or loads) the ambient sql connection factory for the current plugin /// and synchronously blocks the current thread until the connection is ready. /// /// /// The ambient factory /// /// public static Func GetConnectionFactory(this PluginBase plugin) { //Get the async factory IAsyncLazy> async = plugin.GetConnectionFactoryAsync(); //Block the current thread until the connection is ready return async.GetAwaiter().GetResult(); } /// /// Gets (or loads) the ambient sql connection factory for the current plugin /// asynchronously /// /// /// The ambient factory /// /// public static IAsyncLazy> GetConnectionFactoryAsync(this PluginBase plugin) { plugin.ThrowIfUnloaded(); //Get the provider singleton DbProvider provider = LoadingExtensions.GetOrCreateSingleton(plugin, GetDbPovider); return provider.ConnectionFactory.Value.AsLazy(); } private static DbProvider GetDbPovider(PluginBase plugin) { //Get the sql configuration scope IConfigScope sqlConf = plugin.GetConfig(SQL_CONFIG_KEY); //Get the provider dll path string dllPath = sqlConf.GetRequiredProperty(SQL_PROVIDER_DLL_KEY, k => k.GetString()!); /* * I am loading a bare object here and dynamically resolbing the required methods * insead of forcing a shared interface. This allows the external library to be * more flexible and slimmer. */ object instance = plugin.CreateServiceExternal(dllPath); return new(instance, sqlConf); } /// /// Gets (or loads) the ambient configured from /// the ambient sql factory and blocks the current thread until the options are ready /// /// /// The ambient for the current plugin /// /// /// If plugin is in debug mode, writes log data to the default log public static DbContextOptions GetContextOptions(this PluginBase plugin) { //Get the async factory IAsyncLazy async = plugin.GetContextOptionsAsync(); //Block the current thread until the connection is ready return async.GetAwaiter().GetResult(); } /// /// Gets (or loads) the ambient configured from /// the ambient sql factory /// /// /// The ambient for the current plugin /// /// /// If plugin is in debug mode, writes log data to the default log public static IAsyncLazy GetContextOptionsAsync(this PluginBase plugin) { plugin.ThrowIfUnloaded(); //Get the provider singleton DbProvider provider = LoadingExtensions.GetOrCreateSingleton(plugin, GetDbPovider); return provider.OptionsFactory.Value.AsLazy(); } /// /// Ensures the tables that back your desired DbContext exist within the configured database, /// or creates them if needed. /// /// /// /// The state object to pass to the /// A task that resolves when the tables have been created public static Task EnsureDbCreatedAsync(this PluginBase pbase, object? state) where T : IDbTableDefinition, new() { T creator = new (); return EnsureDbCreatedAsync(pbase, creator, state); } /// /// Ensures the tables that back your desired DbContext exist within the configured database, /// or creates them if needed. /// /// /// /// The instance of the to build the database from /// The state object to pass to the /// A task that resolves when the tables have been created public static async Task EnsureDbCreatedAsync(this PluginBase plugin, T dbCreator, object? state) where T : IDbTableDefinition { ArgumentNullException.ThrowIfNull(plugin); ArgumentNullException.ThrowIfNull(dbCreator); DbBuilder builder = new(); //Invoke ontbCreating to setup the dbBuilder dbCreator.OnDatabaseCreating(builder, state); //Get the abstract database from the connection type IDBCommandGenerator cb = GetCmdGenerator(plugin); //Wait for the connection factory to load Func dbConFactory = await GetConnectionFactoryAsync(plugin); //Create a new db connection await using DbConnection connection = dbConFactory(); //Compile the db command as a text Sql command string[] createComamnds = builder.BuildCreateCommand(cb); //begin connection await connection.OpenAsync(plugin.UnloadToken); //Transaction await using DbTransaction transaction = await connection.BeginTransactionAsync(IsolationLevel.Serializable, plugin.UnloadToken); //Init new text command await using DbCommand command = connection.CreateCommand(); command.Transaction = transaction; command.CommandType = CommandType.Text; foreach (string createCmd in createComamnds) { if (plugin.IsDebug()) { plugin.Log.Debug("Creating new table for {type} with command\n{cmd}", typeof(T).Name, createCmd); } //Set the command, were not using parameters, so we dont need to clear anyting command.CommandText = createCmd; //Excute the command, it may return 0 if the table's already exist _ = await command.ExecuteNonQueryAsync(plugin.UnloadToken); } //Commit transaction now were complete await transaction.CommitAsync(plugin.UnloadToken); //All done! plugin.Log.Debug("Successfully created tables for {type}", typeof(T).Name); } #region ColumnExtensions /// /// Sets the column as a PrimaryKey in the table. You may also set the /// on the property. /// /// The entity type /// /// The chainable public static IDbColumnBuilder SetIsKey(this IDbColumnBuilder builder) { //Add ourself to the primary keys list builder.ConfigureColumn(static col => col.AddToPrimaryKeys()); return builder; } /// /// Sets the column ordinal index, or column position, within the table. /// /// The entity type /// /// The column's ordinal postion with the database /// The chainable public static IDbColumnBuilder SetPosition(this IDbColumnBuilder builder, int columOridinalIndex) { //Add ourself to the primary keys list builder.ConfigureColumn(col => col.SetOrdinal(columOridinalIndex)); return builder; } /// /// Sets the auto-increment property on the column, this is just a short-cut to /// setting the properties yourself on the column. /// /// The starting (seed) of the increment parameter /// The increment/step parameter /// /// The chainable public static IDbColumnBuilder AutoIncrement(this IDbColumnBuilder builder, int seed = 1, int increment = 1) { //Set the auto-increment features builder.ConfigureColumn(col => { col.AutoIncrement = true; col.AutoIncrementSeed = seed; col.AutoIncrementStep = increment; }); return builder; } /// /// Sets the property to the desired value. This value is set /// via a if defined on the property, this method will override /// that value. /// /// Override the maxium length property on the column /// /// The chainable public static IDbColumnBuilder MaxLength(this IDbColumnBuilder builder, int maxLength) { //Set the max-length builder.ConfigureColumn(col => col.MaxLength(maxLength)); return builder; } /// /// Override the /// /// /// /// A value that indicate if you allow null in the column /// The chainable public static IDbColumnBuilder AllowNull(this IDbColumnBuilder builder, bool value) { builder.ConfigureColumn(col => col.AllowDBNull = value); return builder; } /// /// Sets the property to true /// /// The entity type /// /// The chainable public static IDbColumnBuilder Unique(this IDbColumnBuilder builder) { builder.ConfigureColumn(static col => col.Unique = true); return builder; } /// /// Sets the default value for the column /// /// The entity type /// /// The column default value /// The chainable public static IDbColumnBuilder WithDefault(this IDbColumnBuilder builder, object defaultValue) { builder.ConfigureColumn(col => col.DefaultValue = defaultValue); return builder; } /// /// Specifies this column is a RowVersion/TimeStamp for optimistic concurrency for some /// databases. /// /// This vaule is set by default if the entity property specifies a /// /// /// The entity type /// /// The chainable public static IDbColumnBuilder TimeStamp(this IDbColumnBuilder builder) { builder.ConfigureColumn(static col => col.SetTimeStamp()); return builder; } #endregion private static IDBCommandGenerator GetCmdGenerator(PluginBase plugin) { //Get the provider singleton DbProvider provider = LoadingExtensions.GetOrCreateSingleton(plugin, GetDbPovider); //See if the provider has a command builder function, otherwise try to use known defaults if (provider.HasCommandBuilder) { return provider.CommandGenerator; } else if (string.Equals(provider.ProviderName, "sqlserver", StringComparison.OrdinalIgnoreCase)) { return new MsSqlDb(); } else if (string.Equals(provider.ProviderName, "mysql", StringComparison.OrdinalIgnoreCase)) { return new MySqlDb(); } else if (string.Equals(provider.ProviderName, "sqlite", StringComparison.OrdinalIgnoreCase)) { return new SqlLiteDb(); } else { throw new NotSupportedException("This library does not support the abstract databse backend"); } } internal static bool IsPrimaryKey(this DataColumn col) => col.Table!.PrimaryKey.Contains(col); /* * I am bypassing the DataColumn.MaxLength property because it does more validation * than we need against the type and can cause unecessary issues, so im just bypassing it * for now */ internal static void MaxLength(this DataColumn column, int length) { column.ExtendedProperties[MAX_LEN_BYPASS_KEY] = length; } internal static int MaxLength(this DataColumn column) { return column.ExtendedProperties.ContainsKey(MAX_LEN_BYPASS_KEY) ? (int)column.ExtendedProperties[MAX_LEN_BYPASS_KEY] : column.MaxLength; } internal static void SetTimeStamp(this DataColumn column) { //We just need to set the key column.ExtendedProperties[TIMESTAMP_BYPASS] = null; } internal static bool IsTimeStamp(this DataColumn column) { return column.ExtendedProperties.ContainsKey(TIMESTAMP_BYPASS); } internal static void AddToPrimaryKeys(this DataColumn col) { //Add the column to the table's primary key array List cols = new(col.Table!.PrimaryKey) { col }; //Update the table primary keys now that this col has been added col.Table.PrimaryKey = cols.Distinct().ToArray(); } internal sealed class DbProvider(object instance, IConfigScope sqlConfig) { public delegate Task> AsynConBuilderDelegate(IConfigScope sqlConf); public delegate Func SyncConBuilderDelegate(IConfigScope sqlConf); public delegate DbContextOptions SyncOptBuilderDelegate(IConfigScope sqlConf); public delegate Task AsynOptBuilderDelegate(IConfigScope sqlConf); public delegate void BuildTableStringDelegate(StringBuilder builder, DataTable table); public delegate string ProviderNameDelegate(); public object Provider { get; } = instance; public IConfigScope SqlConfig { get; } = sqlConfig; /// /// A lazy async connection factory. When called, may cause invocation in the external library, /// but only once. /// public readonly Lazy>> ConnectionFactory = new(() => GetConnections(instance, sqlConfig)); /// /// A lazy async options factory. When called, may cause invocation in the external library, /// but only once. /// public readonly Lazy> OptionsFactory = new(() => GetOptions(instance, sqlConfig)); /// /// Gets the extern command generator for the external library /// public readonly IDBCommandGenerator CommandGenerator = new ExternCommandGenerator(instance); /// /// Gets the provider name from the external library /// public readonly ProviderNameDelegate ProviderNameFunc = ManagedLibrary.GetMethod(instance, "GetProviderName"); /// /// Gets a value indicating if the external library has a command builder /// public bool HasCommandBuilder => (CommandGenerator as ExternCommandGenerator)!.BuildTableString is not null; /// /// Gets the provider name from the external library /// public string ProviderName => ProviderNameFunc.Invoke(); /* * Methods below are designed to be called within a lazy/defered context and possible awaited * by mutliple threads. This causes data to be only loaded once, and then cached for future calls. */ private static Task> GetConnections(object instance, IConfigScope sqlConfig) { //Connection builder functions SyncConBuilderDelegate? SyncBuilder = ManagedLibrary.TryGetMethod(instance, "GetDbConnection"); //try sync first if (SyncBuilder is not null) { return Task.FromResult(SyncBuilder.Invoke(sqlConfig)); } //If no sync function force call async, but try to schedule it on a new thread AsynConBuilderDelegate? AsynConnectionBuilder = ManagedLibrary.GetMethod(instance, "GetDbConnectionAsync"); return Task.Run(() => AsynConnectionBuilder.Invoke(sqlConfig)); } private static Task GetOptions(object instance, IConfigScope sqlConfig) { //Options builder functions SyncOptBuilderDelegate? SyncBuilder = ManagedLibrary.TryGetMethod(instance, "GetDbOptions"); //try sync first if (SyncBuilder is not null) { return Task.FromResult(SyncBuilder.Invoke(sqlConfig)); } //If no sync function force call async, but try to schedule it on a new thread AsynOptBuilderDelegate? AsynOptionsBuilder = ManagedLibrary.GetMethod(instance, "GetDbOptionsAsync"); return Task.Run(() => AsynOptionsBuilder.Invoke(sqlConfig)); } private sealed class ExternCommandGenerator(object instance) : IDBCommandGenerator { public BuildTableStringDelegate? BuildTableString = ManagedLibrary.TryGetMethod(instance, "BuildCreateStatment"); public void BuildCreateStatment(StringBuilder builder, DataTable table) { if(BuildTableString is not null) { BuildTableString.Invoke(builder, table); } else { throw new NotSupportedException("The external library does not support table creation"); } } } } } }