diff --git a/FAnsi.Core/Connections/IManagedConnection.cs b/FAnsi.Core/Connections/IManagedConnection.cs new file mode 100644 index 00000000..f53ca9c0 --- /dev/null +++ b/FAnsi.Core/Connections/IManagedConnection.cs @@ -0,0 +1,39 @@ +using System; +using System.Data.Common; +using FAnsi.Discovery; + +namespace FAnsi.Connections; + +/// +/// Wrapper for DbConnection and optional DbTransaction. +/// +public interface IManagedConnection : IDisposable +{ + /// + /// DbConnection being wrapped + /// + DbConnection Connection { get; } + + /// + /// Optional - DbTransaction being wrapped if one has been started or null + /// + DbTransaction? Transaction { get; } + + /// + /// Optional - transaction being run (See . If this is not null then should also be not null. + /// + IManagedTransaction? ManagedTransaction { get; } + + /// + /// True to close the connection in the Dispose step. If opened the connection itself during construction then this flag will default + /// to true otherwise it will default to false. + /// + bool CloseOnDispose { get; set; } + + /// + /// Creates a new shallow copy instance of the . This will point to the same + /// underlying and (if any). + /// + /// + ManagedConnection Clone(); +} \ No newline at end of file diff --git a/FAnsi.Core/Connections/IManagedTransaction.cs b/FAnsi.Core/Connections/IManagedTransaction.cs new file mode 100644 index 00000000..6e72c741 --- /dev/null +++ b/FAnsi.Core/Connections/IManagedTransaction.cs @@ -0,0 +1,30 @@ +using System.Data.Common; + +namespace FAnsi.Connections; + +/// +/// Wrapper for DbTransaction that associates it with a specific DbConnection. Helps simplify calls to information +/// methods such as DiscoveredTable.GetRowCount etc during the middle of an ongoing database transaction +/// +public interface IManagedTransaction +{ + /// + /// The DbConnection that the is running on + /// + DbConnection Connection { get; } + + /// + /// The DbTransaction being wrapped + /// + DbTransaction Transaction { get; } + + /// + /// Calls and closes/disposes the + /// + void AbandonAndCloseConnection(); + + /// + /// Calls and closes/disposes the + /// + void CommitAndCloseConnection(); +} \ No newline at end of file diff --git a/FAnsi.Core/Connections/ManagedConnection.cs b/FAnsi.Core/Connections/ManagedConnection.cs new file mode 100644 index 00000000..17e0e62b --- /dev/null +++ b/FAnsi.Core/Connections/ManagedConnection.cs @@ -0,0 +1,50 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics; +using FAnsi.Discovery; + +namespace FAnsi.Connections; + +/// +public sealed class ManagedConnection : IManagedConnection +{ + /// + public DbConnection Connection { get; } + + /// + public DbTransaction? Transaction { get; } + + /// + public IManagedTransaction? ManagedTransaction { get; } + + /// + public bool CloseOnDispose { get; set; } + + internal ManagedConnection(DiscoveredServer discoveredServer, IManagedTransaction? managedTransaction) + { + //get a new connection or use the existing one within the transaction + Connection = discoveredServer.GetConnection(managedTransaction); + + //if there is a transaction, also store the transaction + ManagedTransaction = managedTransaction; + Transaction = managedTransaction?.Transaction; + + //if there isn't a transaction then we opened a new connection, so we had better remember to close it again + if (managedTransaction != null) return; + + CloseOnDispose = true; + Debug.Assert(Connection.State == ConnectionState.Closed); + Connection.Open(); + } + + public ManagedConnection Clone() => (ManagedConnection)MemberwiseClone(); + + /// + /// Closes and disposes the DbConnection unless this class is part of an + /// + public void Dispose() + { + if (CloseOnDispose) + Connection.Dispose(); + } +} \ No newline at end of file diff --git a/FAnsi.Core/Connections/ManagedTransaction.cs b/FAnsi.Core/Connections/ManagedTransaction.cs new file mode 100644 index 00000000..100b34e0 --- /dev/null +++ b/FAnsi.Core/Connections/ManagedTransaction.cs @@ -0,0 +1,69 @@ +using System; +using System.Data.Common; +using System.Diagnostics; + +namespace FAnsi.Connections; + +/// +public sealed class ManagedTransaction : IManagedTransaction +{ + /// + public DbConnection Connection { get; } + + /// + public DbTransaction Transaction { get; } + + internal ManagedTransaction(DbConnection connection, DbTransaction transaction) + { + Connection = connection; + Transaction = transaction; + } + + private bool _closed; + + /// + /// Attempts to rollback the DbTransaction (swallowing any Exception) and closes/disposes the DbConnection + /// + public void AbandonAndCloseConnection() + { + if(_closed) + return; + + _closed = true; + + try + { + Transaction.Rollback(); + } + catch (Exception e) + { + Trace.WriteLine($"Transaction rollback failed during AbandonAndCloseConnection{e.Message}"); + } + finally + { + Connection.Close(); + Connection.Dispose(); + } + } + + /// + /// Attempts to commit the DbTransaction and then closes/disposes the DbConnection + /// + public void CommitAndCloseConnection() + { + if(_closed) + return; + + _closed = true; + + try + { + Transaction.Commit(); + } + finally + { + Connection.Close(); + Connection.Dispose(); + } + } +} \ No newline at end of file diff --git a/FAnsi.Core/DatabaseOperationArgs.cs b/FAnsi.Core/DatabaseOperationArgs.cs new file mode 100644 index 00000000..433709e6 --- /dev/null +++ b/FAnsi.Core/DatabaseOperationArgs.cs @@ -0,0 +1,150 @@ +using System; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using FAnsi.Connections; +using FAnsi.Discovery; + +namespace FAnsi; + +/// +/// Arguments for facilitating long running sql operations which the user/system might want to cancel mid way through. +/// +public sealed class DatabaseOperationArgs +{ + /// + /// If using an ongoing connection/transaction. Otherwise null. + /// + public IManagedTransaction? TransactionIfAny { get; set; } + + /// + /// Time to allow to run before cancelling (this is db timeout and doesn't affect ) + /// + public int TimeoutInSeconds { get; set; } + + /// + /// Optional, if provided all commands interacting with these args should cancel if the command was cancelled + /// + public CancellationToken CancellationToken; + + public DatabaseOperationArgs() + { + + } + public DatabaseOperationArgs(IManagedTransaction transactionIfAny, int timeoutInSeconds, + CancellationToken cancellationToken) + { + TransactionIfAny = transactionIfAny; + CancellationToken = cancellationToken; + TimeoutInSeconds = timeoutInSeconds; + } + + /// + /// Sets the timeout and cancellation on then runs with the + /// (if any) and blocks till the call completes. + /// + /// + /// + /// + public int ExecuteNonQuery(DbCommand cmd) + { + return Execute(cmd, ()=>cmd.ExecuteNonQueryAsync(CancellationToken)); + } + /// + /// Sets the timeout and cancellation on then runs with the + /// (if any) and blocks till the call completes. + /// + /// + /// + /// + public object? ExecuteScalar(DbCommand cmd) + { + return Execute(cmd, ()=>cmd.ExecuteScalarAsync(CancellationToken)); + } + + private T Execute(DbCommand cmd, Func> method) + { + Hydrate(cmd); + var t = method(); + + try + { + switch (t.Status) + { + case TaskStatus.Faulted: + throw t.Exception?? new Exception("Task crashed without Exception!"); + case TaskStatus.Canceled: + throw new OperationCanceledException(); + default: + t.Wait(); + break; + } + } + catch (AggregateException e) + { + if (e.InnerExceptions.Count == 1) + throw e.InnerExceptions[0]; + + throw; + } + + if (!t.IsCompleted) + cmd.Cancel(); + + if (t.Exception == null) return t.Result; + + if (t.Exception.InnerExceptions.Count == 1) + throw t.Exception.InnerExceptions[0]; + + throw t.Exception; + } + + public void Fill(DbDataAdapter da, DbCommand cmd, DataTable dt) + { + Hydrate(cmd); + + CancellationToken.ThrowIfCancellationRequested(); + + if(CancellationToken.CanBeCanceled) + dt.RowChanged += ThrowIfCancelled; + + da.Fill(dt); + CancellationToken.ThrowIfCancellationRequested(); + } + + private void ThrowIfCancelled(object sender, DataRowChangeEventArgs e) + { + CancellationToken.ThrowIfCancellationRequested(); + } + + private void Hydrate(DbCommand cmd) + { + cmd.CommandTimeout = TimeoutInSeconds; + CancellationToken.ThrowIfCancellationRequested(); + } + + /// + /// Opens a new connection or passes back an existing opened connection (that matches + /// ). This command should be wrapped in a using statement + /// + /// + /// + public IManagedConnection GetManagedConnection(DiscoveredTable table) => GetManagedConnection(table.Database.Server); + + /// + /// Opens a new connection or passes back an existing opened connection (that matches + /// ). This command should be wrapped in a using statement + /// + /// + /// + public IManagedConnection GetManagedConnection(DiscoveredDatabase database) => GetManagedConnection(database.Server); + + /// + /// Opens a new connection or passes back an existing opened connection (that matches + /// ). This command should be wrapped in a using statement + /// + /// + /// + public IManagedConnection GetManagedConnection(DiscoveredServer server) => server.GetManagedConnection(TransactionIfAny); +} \ No newline at end of file diff --git a/FAnsi.Core/DatabaseType.cs b/FAnsi.Core/DatabaseType.cs new file mode 100644 index 00000000..e4de8b88 --- /dev/null +++ b/FAnsi.Core/DatabaseType.cs @@ -0,0 +1,27 @@ +namespace FAnsi; + +/// +/// Describes a specific DBMS implementation you are talking to +/// +public enum DatabaseType +{ + /// + /// Any Microsoft Sql Server database (e.g. Express etc). Does not include Access. + /// + MicrosoftSQLServer, + + /// + /// My Sql database engine. + /// + MySql, + + /// + /// Oracle database engine + /// + Oracle, + + /// + /// PostgreSql database engine + /// + PostgreSql +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/BulkCopy.cs b/FAnsi.Core/Discovery/BulkCopy.cs new file mode 100644 index 00000000..1d7c6b10 --- /dev/null +++ b/FAnsi.Core/Discovery/BulkCopy.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Threading; +using FAnsi.Connections; +using TypeGuesser; +using TypeGuesser.Deciders; + +namespace FAnsi.Discovery; + +/// +public abstract class BulkCopy : IBulkCopy +{ + public CultureInfo Culture { get; } + + /// + /// The database connection on which the bulk insert operation is underway + /// + protected readonly IManagedConnection Connection; + + /// + /// The target table on the database server to which records are being uploaded + /// + protected readonly DiscoveredTable TargetTable; + + /// + /// The cached columns found on the . If you alter the table midway through a bulk insert you must + /// call to refresh this. + /// + protected DiscoveredColumn[] TargetTableColumns => _targetTableColumns.Value; + + private Lazy _targetTableColumns; + + /// + /// When calling GetMapping if there are DataColumns in the input table that you are trying to bulk insert that are not matched + /// in the destination table then the default behaviour is to throw a KeyNotFoundException. Set this to false to ignore that + /// behaviour. This will result in loosing data from your DataTable. + /// + /// Defaults to false + /// + public bool AllowUnmatchedInputColumns { get; private set; } + + /// + public DateTimeTypeDecider DateTimeDecider { get; protected set; } + + /// + /// Begins a new bulk copy operation in which one or more data tables are uploaded to the . The API entrypoint for this is + /// . + /// + /// + /// + /// + /// For parsing string date expressions etc + protected BulkCopy(DiscoveredTable targetTable, IManagedConnection connection, CultureInfo culture) + { + Culture = culture; + TargetTable = targetTable; + Connection = connection; + _targetTableColumns = new Lazy( + () => TargetTable.DiscoverColumns(Connection.ManagedTransaction), + LazyThreadSafetyMode.ExecutionAndPublication); + AllowUnmatchedInputColumns = false; + DateTimeDecider = new DateTimeTypeDecider(culture); + } + + /// + public virtual int Timeout { get; set; } + + /// + /// Updates . Call if you are making modifications to the midway through a bulk insert. + /// + public void InvalidateTableSchema() + { + _targetTableColumns = new Lazy( + () => TargetTable.DiscoverColumns(Connection.ManagedTransaction), + LazyThreadSafetyMode.ExecutionAndPublication); + } + + /// + /// Closes the connection and completes the bulk insert operation (including committing the transaction). If this method is not called + /// then the records may not be committed. + /// + public virtual void Dispose() + { + GC.SuppressFinalize(this); + Connection.Dispose(); + } + + /// + public virtual int Upload(DataTable dt) + { + TargetTable.Database.Helper.ThrowIfObjectColumns(dt); + + ConvertStringTypesToHardTypes(dt); + + return UploadImpl(dt); + } + + public abstract int UploadImpl(DataTable dt); + + /// + /// Replaces all string representations for data types that can be problematic/ambiguous (e.g. DateTime or TimeSpan) + /// into hard typed objects using appropriate decider e.g. . + /// + /// + protected void ConvertStringTypesToHardTypes(DataTable dt) + { + var dict = GetMapping(dt.Columns.Cast(), out _); + + var factory = new TypeDeciderFactory(Culture); + + //These are the problematic Types + var deciders = factory.Dictionary; + + //for each column in the destination + foreach (var (dataColumn, discoveredColumn) in dict) + { + //if the destination column is a problematic type + var dataType = discoveredColumn.DataType?.GetCSharpDataType(); + if (dataType == null || !deciders.TryGetValue(dataType, out var decider)) continue; + //if it's already not a string then that's fine (hopefully it's a legit Type e.g. DateTime!) + if (dataColumn.DataType != typeof(string)) + continue; + + //create a new column hard typed to DateTime + var newColumn = dt.Columns.Add($"{dataColumn.ColumnName}_{Guid.NewGuid()}", dataType); + + //if it's a DateTime decider then guess DateTime culture based on values in the table + if (decider is DateTimeTypeDecider) + { + //also use this one in case the user has set up explicit stuff on it e.g. Culture/Settings + decider = DateTimeDecider; + DateTimeDecider.GuessDateFormat(dt.Rows.Cast().Take(500).Select(r => r[dataColumn] as string).OfType()); + } + + + foreach (DataRow dr in dt.Rows) + try + { + //parse the value + dr[newColumn] = dr[dataColumn] is string v ? decider.Parse(v) ?? DBNull.Value : DBNull.Value; + } + catch (Exception ex) + { + throw new Exception($"Failed to parse value '{dr[dataColumn]}' in column '{dataColumn}'", ex); + } + + //if the DataColumn is part of the Primary Key of the DataTable (in memory) + //then we need to update the primary key to include the new column not the old one + if (dt.PrimaryKey != null && dt.PrimaryKey.Contains(dataColumn)) + dt.PrimaryKey = dt.PrimaryKey.Except(new[] { dataColumn }).Union(new[] { newColumn }).ToArray(); + + var oldOrdinal = dataColumn.Ordinal; + + //drop the original column + dt.Columns.Remove(dataColumn); + + //rename the hard typed column to match the old column name + newColumn.ColumnName = dataColumn.ColumnName; + if (oldOrdinal != -1) + newColumn.SetOrdinal(oldOrdinal); + } + } + + /// + /// Returns a case insensitive mapping between columns in your DataTable that you are trying to upload and the columns that actually exist in the destination + /// table. + /// This overload gives you a list of all unmatched destination columns, these should be given null/default automatically by your database API + /// Throws if there are unmatched input columns unless is true. + /// + /// + /// + /// + protected Dictionary GetMapping(IEnumerable inputColumns, out DiscoveredColumn[] unmatchedColumnsInDestination) + { + var mapping = new Dictionary(); + + foreach (var colInSource in inputColumns) + { + var match = TargetTableColumns.SingleOrDefault(c => c.GetRuntimeName().Equals(colInSource.ColumnName, StringComparison.CurrentCultureIgnoreCase)); + + if (match == null) + { + if (!AllowUnmatchedInputColumns) + throw new ColumnMappingException(string.Format(FAnsiStrings.BulkCopy_ColumnNotInDestinationTable, colInSource.ColumnName, TargetTable)); + + //user is ignoring the fact there are unmatched items in DataTable! + } + else + mapping.Add(colInSource, match); + } + + //unmatched columns in the destination is fine, these usually get populated with the default column values or nulls + unmatchedColumnsInDestination = TargetTableColumns.Except(mapping.Values).ToArray(); + + return mapping; + } + + /// + /// Returns a case insensitive mapping between columns in your DataTable that you are trying to upload and the columns that actually exist in the destination + /// table. + /// Throws if there are unmatched input columns unless is true. + /// + /// + /// + protected Dictionary GetMapping(IEnumerable inputColumns) => GetMapping(inputColumns, out _); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/ColumnMappingException.cs b/FAnsi.Core/Discovery/ColumnMappingException.cs new file mode 100644 index 00000000..6b002dfd --- /dev/null +++ b/FAnsi.Core/Discovery/ColumnMappingException.cs @@ -0,0 +1,21 @@ +using System; +using System.Data; + +namespace FAnsi.Discovery; + +/// +/// Thrown when a given column requested could not be matched in the destination table e.g. when inserting data in a +/// into a during a operation +/// +public sealed class ColumnMappingException : Exception +{ + public ColumnMappingException(string msg):base(msg) + { + + } + + public ColumnMappingException(string msg, Exception innerException):base(msg,innerException) + { + + } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/ConnectionStringDefaults/ConnectionStringKeywordAccumulator.cs b/FAnsi.Core/Discovery/ConnectionStringDefaults/ConnectionStringKeywordAccumulator.cs new file mode 100644 index 00000000..8302dbbd --- /dev/null +++ b/FAnsi.Core/Discovery/ConnectionStringDefaults/ConnectionStringKeywordAccumulator.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using FAnsi.Implementation; + +namespace FAnsi.Discovery.ConnectionStringDefaults; + +/// +/// Gathers keywords for use in building connection strings for a given . Once created you can add keywords and then apply the template +/// to new novel connection strings (see ). +/// +/// Also handles connection string keyword aliases (where two words mean the same thing) +/// +/// +/// Initialises a new blank instance that does nothing. Call to adjust the template connection string options. +/// +/// +public sealed class ConnectionStringKeywordAccumulator(DatabaseType databaseType) +{ + /// + /// describing what implementation of DbConnectionStringBuilder is being manipulated + /// + public DatabaseType DatabaseType { get; private set; } = databaseType; + + private readonly Dictionary> _keywords = new(StringComparer.CurrentCultureIgnoreCase); + private readonly DbConnectionStringBuilder _builder = ImplementationManager.GetImplementation(databaseType).GetBuilder(); + + /// + /// Adds a new connection string option (which must be compatible with ) + /// + /// + /// + /// + public void AddOrUpdateKeyword(string keyword, string value, ConnectionStringKeywordPriority priority) + { + var collision = GetCollisionWithKeyword(keyword,value); + + if (collision != null) + { + //if there is already a semantically equivalent keyword.... + + //if it is of lower or equal priority + if (_keywords[collision].Item2 <= priority) + _keywords[collision] = Tuple.Create(value, priority); //update it + + //either way don't record it as a new keyword + return; + } + + //if we have not got that keyword yet + if(!_keywords.TryAdd(keyword, Tuple.Create(value, priority)) && _keywords[keyword].Item2 <= priority) + //or the keyword that was previously specified had a lower priority + _keywords[keyword] = Tuple.Create(value, priority); //update it with the new value + } + + /// + /// Returns the best alias for or null if there are no known aliases. This is because some builders allow multiple keys for changing the same underlying + /// property. + /// + /// + /// + /// + private string? GetCollisionWithKeyword(string keyword, string value) + { + ArgumentNullException.ThrowIfNull(keyword); + ArgumentNullException.ThrowIfNull(value); + + //let's evaluate this alleged keyword! + _builder.Clear(); + + try + { + //Make sure it is supported by the connection string builder + _builder.Add(keyword, value); + } + catch (NotSupportedException ex) + { + //don't output the value since that could be a password + throw new ArgumentException(string.Format(FAnsiStrings.ConnectionStringKeyword_ValueNotSupported, keyword),ex); + } + + //now iterate all the keys we had before and add those too, if the key count doesn't change for any of them we know it's a duplicate semantically + if (_builder.Keys == null) return null; + + foreach (var current in _keywords) + { + var keysBefore = _builder.Keys.Count; + + _builder.Add(current.Key, current.Value.Item1); + + //key count in builder didn't change despite there being new values added + if (_builder.Keys.Count == keysBefore) + return current.Key; + } + + //no collisions + return null; + } + + /// + /// Adds the currently configured keywords to the connection string builder. + /// + public void EnforceOptions(DbConnectionStringBuilder connectionStringBuilder) + { + foreach (var keyword in _keywords) + connectionStringBuilder[keyword.Key] = keyword.Value.Item1; + } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/ConnectionStringDefaults/ConnectionStringKeywordPriority.cs b/FAnsi.Core/Discovery/ConnectionStringDefaults/ConnectionStringKeywordPriority.cs new file mode 100644 index 00000000..9635ced1 --- /dev/null +++ b/FAnsi.Core/Discovery/ConnectionStringDefaults/ConnectionStringKeywordPriority.cs @@ -0,0 +1,38 @@ +namespace FAnsi.Discovery.ConnectionStringDefaults; + +/// +/// For use with , allows different parts of of your codebase to specify different values +/// of required keywords (e.g. AllowUserVariables) and overwrite one another based on priority. +/// +public enum ConnectionStringKeywordPriority +{ + /// + /// Lowest priority e.g. settings defined in app config / global const parameters etc that you are happy to be overriden elsewhere + /// + SystemDefaultLow, + /// + /// Lowest priority e.g. settings defined in app config / global const parameters etc that you are happy to be overriden elsewhere + /// + SystemDefaultMedium, + /// + /// Lowest priority e.g. settings defined in app config / global const parameters etc that you are happy to be overriden elsewhere + /// + SystemDefaultHigh, + + /// + /// User specified overrides for System Default settings. + /// + UserOverride, + + /// + /// High level priority, the C# object being used is specifying a required keyword for it to operate correctly. This overrides + /// user settings and system defaults (but not ) + /// + ObjectOverride, + + /// + /// Highest priority for keywords. This is settings that cannot be unset/overriden by anyone else and are required + /// for the API to work e.g. AllowUserVariables in MySql + /// + ApiRule +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/Constraints/CascadeRule.cs b/FAnsi.Core/Discovery/Constraints/CascadeRule.cs new file mode 100644 index 00000000..e9b71a70 --- /dev/null +++ b/FAnsi.Core/Discovery/Constraints/CascadeRule.cs @@ -0,0 +1,32 @@ +namespace FAnsi.Discovery.Constraints; + +/// +/// Describes the action performed when a DELETE or UPDATE command is executed on a field tied to a foreign key constraint +/// +public enum CascadeRule +{ + /// + /// Action is not known or understood + /// + Unknown, + + /// + /// Child rows are deleted when Parent row is deleted + /// + Delete, + + /// + /// No action is taken (may result in DELETE command failing when deleting Parent rows) + /// + NoAction, + + /// + /// Child rows are set to NULL when Parent row is deleted + /// + SetNull, + + /// + /// Child rows are set to the field's default value when Parent row is deleted + /// + SetDefault +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/Constraints/DiscoveredRelationship.cs b/FAnsi.Core/Discovery/Constraints/DiscoveredRelationship.cs new file mode 100644 index 00000000..de2d3b4b --- /dev/null +++ b/FAnsi.Core/Discovery/Constraints/DiscoveredRelationship.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FAnsi.Connections; + +namespace FAnsi.Discovery.Constraints; + +/// +/// A foreign key relationship between two database tables. +/// +/// +/// Internal API constructor intended for Implementation classes, instead use instead. +/// +/// +/// +/// +/// +public sealed class DiscoveredRelationship(string fkName, DiscoveredTable pkTable, DiscoveredTable fkTable, CascadeRule deleteRule) +{ + /// + /// The name of the foreign key constraint in the database e.g. FK_Table1_Table2 + /// + public string Name { get; private set; } = fkName; + + /// + /// The table in which the primary key is declared. This is the parent table. + /// + public DiscoveredTable PrimaryKeyTable { get; } = pkTable; + + /// + /// The table which contains child records. + /// + public DiscoveredTable ForeignKeyTable { get; } = fkTable; + + /// + /// Mapping of primary key column(s) in to foreign key column(s) in . If there are more than one entry + /// then the foreign key is a composite key. + /// + public Dictionary Keys { get; } = []; + + /// + /// Describes what happens to records in the when thier parent records (in the ) are deleted. + /// + public CascadeRule CascadeDelete { get; private set; } = deleteRule; + + private DiscoveredColumn[]? _pkColumns; + private DiscoveredColumn[]? _fkColumns; + + /// + /// Discovers and adds the provided pair to . Column names must be members of and (respectively) + /// + /// + /// + /// + public void AddKeys(string primaryKeyCol, string foreignKeyCol, IManagedTransaction? transaction = null) + { + _pkColumns ??= PrimaryKeyTable.DiscoverColumns(transaction); + _fkColumns ??= ForeignKeyTable.DiscoverColumns(transaction); + + Keys.Add( + _pkColumns.Single(c => c.GetRuntimeName().Equals(primaryKeyCol, StringComparison.CurrentCultureIgnoreCase)), + _fkColumns.Single(c => c.GetRuntimeName().Equals(foreignKeyCol, StringComparison.CurrentCultureIgnoreCase)) + ); + } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/Constraints/RelationshipTopologicalSort.cs b/FAnsi.Core/Discovery/Constraints/RelationshipTopologicalSort.cs new file mode 100644 index 00000000..97d5dc09 --- /dev/null +++ b/FAnsi.Core/Discovery/Constraints/RelationshipTopologicalSort.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using FAnsi.Exceptions; + +namespace FAnsi.Discovery.Constraints; + +/// +/// Helps resolve a dependency order between a collection of tables with interlinking foreign key constraints. Implements Khan's algorithm. +/// +public sealed class RelationshipTopologicalSort +{ + /// + /// The dependency order from least dependent (isolated tables and parent tables) to most (child tables then grandchild tables). + /// + public IReadOnlyList Order => new ReadOnlyCollection(_sortedList); + + private readonly List _sortedList; + + /// + /// Connects to the database and discovers relationships between then generates a sort order of dependency in which + /// all primary key tables should appear before thier respective foreign key tables. + /// + /// Calling this method will result in database queries being executed (to discover keys) + /// + /// Sort order is based on Khan's algorithm (https://en.wikipedia.org/wiki/Topological_sorting) + /// + /// + /// + public RelationshipTopologicalSort(IEnumerable tables) + { + var nodes = new HashSet(tables); + var edges = new HashSet>(); + + foreach (var relationship in nodes + .Select(table => table.DiscoverRelationships().Where(r => nodes.Contains(r.ForeignKeyTable))) + .SelectMany(static relevantRelationships => relevantRelationships)) + edges.Add(Tuple.Create(relationship.PrimaryKeyTable, relationship.ForeignKeyTable)); + + _sortedList = TopologicalSort(nodes, edges); + } + + /// + /// Topological Sorting (Kahn's algorithm) + /// + /// https://en.wikipedia.org/wiki/Topological_sorting + /// + /// All nodes of directed acyclic graph. + /// All edges of directed acyclic graph. + /// Sorted node in topological order. + private static List TopologicalSort(IEnumerable nodes, HashSet> edges) where T : IEquatable + { + // Empty list that will contain the sorted elements + var l = new List(); + + // Set of all nodes with no incoming edges + var s = new HashSet(nodes.Where(n => edges.All(e => !e.Item2.Equals(n)))); + + // while S is non-empty do + while (s.Count != 0) + { + + // remove a node n from S + var n = s.First(); + s.Remove(n); + + // add n to tail of L + l.Add(n); + + // for each node m with an edge e from n to m do + foreach (var e in edges.Where(e => e.Item1.Equals(n)).ToList()) + { + var m = e.Item2; + + // remove edge e from the graph + edges.Remove(e); + + // if m has no other incoming edges then + if (edges.All(me => !me.Item2.Equals(m))) + // insert m into S + s.Add(m); + } + } + + // if graph has edges then + if (edges.Count != 0) + // return error (graph has at least one cycle) + throw new CircularDependencyException(FAnsiStrings.RelationshipTopologicalSort_FoundCircularDependencies); + + + // return L (a topologically sorted order) + return l; + } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DatabaseColumnRequest.cs b/FAnsi.Core/Discovery/DatabaseColumnRequest.cs new file mode 100644 index 00000000..ab864f9d --- /dev/null +++ b/FAnsi.Core/Discovery/DatabaseColumnRequest.cs @@ -0,0 +1,75 @@ +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.TypeTranslation; +using FAnsi.Naming; +using TypeGuesser; + +namespace FAnsi.Discovery; + +/// +/// Request to create a column in a DatabaseType agnostic manner. This class exists to let you declare a field called X where the data type is wide enough +/// to store strings up to 10 characters long (For example) without having to worry that it is varchar(10) in SqlServer but varchar2(10) in Oracle. +/// +/// Type specification is defined in the DatabaseTypeRequest but can also be specified explicitly (e.g. 'varchar(10)'). +/// +public sealed class DatabaseColumnRequest(string columnName, DatabaseTypeRequest? typeRequested, bool allowNulls = true) + : ISupplementalColumnInformation, IHasRuntimeName +{ + /// + /// The fixed string proprietary data type to use. This overrides if specified. + /// + /// See also + /// + public string? ExplicitDbType { get; set; } + + + public string ColumnName { get; set; } = columnName; + + /// + /// The cross database platform type descriptor for the column e.g. 'able to store strings up to 18 in length'. + /// + /// This is ignored if you have specified an + /// + /// See also + /// + public DatabaseTypeRequest? TypeRequested { get; set; } = typeRequested; + + /// + /// True to create a column which is nullable + /// + public bool AllowNulls { get; set; } = allowNulls; + + /// + /// True to include the column as part of the tables primary key + /// + public bool IsPrimaryKey { get; set; } + + /// + /// True to create a column with auto incrementing number values in this column (autonum / identity etc) + /// + public bool IsAutoIncrement { get; set; } + + /// + /// Set to create a default constraint on the column which calls the given scalar function + /// + public MandatoryScalarFunctions Default { get; set; } + + /// + /// Applies only if the is string based. Setting this will override the default collation and specify + /// a specific collation. The value specified must be an installed collation supported by the DBMS + /// + public string? Collation { get; set; } + + public DatabaseColumnRequest(string columnName, string explicitDbType, bool allowNulls = true) : this(columnName, (DatabaseTypeRequest?)null, allowNulls) + { + ExplicitDbType = explicitDbType; + } + + /// + /// Returns if set or uses the to generate a proprietary type name for + /// + /// + /// + public string GetSQLDbType(ITypeTranslater typeTranslater) => ExplicitDbType??typeTranslater.GetSQLDBTypeForCSharpType(TypeRequested); + + public string GetRuntimeName() => ColumnName; +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredColumn.cs b/FAnsi.Core/Discovery/DiscoveredColumn.cs new file mode 100644 index 00000000..9a6f963a --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredColumn.cs @@ -0,0 +1,136 @@ +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Naming; +using TypeGuesser; + +namespace FAnsi.Discovery; + +/// +/// Cross database type reference to a Column in a Table +/// +/// +/// Internal API constructor intended for Implementation classes, instead use instead. +/// +/// +/// +/// +public sealed class DiscoveredColumn(DiscoveredTable table, string name, bool allowsNulls) : IHasFullyQualifiedNameToo, + ISupplementalColumnInformation +{ + /// + /// The on which the was found + /// + public DiscoveredTable Table { get; } = table; + + /// + /// Stateless helper class with DBMS specific implementation of the logic required by . + /// + public readonly IDiscoveredColumnHelper Helper = table.Helper.GetColumnHelper(); + + /// + /// True if the column allows rows with nulls in this column + /// + public readonly bool AllowNulls = allowsNulls; + + /// + /// True if the column is part of the primary key (a primary key can consist of mulitple columns) + /// + public bool IsPrimaryKey { get; set; } + + /// + /// True if the column is an auto incrementing default number e.g. IDENTITY. This will not handle roundabout ways of declaring + /// auto increment e.g. sequences in Oracle, DEFAULT constraints etc. + /// + public bool IsAutoIncrement { get; set; } + + /// + /// The DBMS proprietary column specific collation e.g. "Latin1_General_CS_AS_KS_WS" + /// + public string? Collation { get; set; } + + /// + /// The data type of the column found (includes String Length and Scale/Precision). + /// + public DiscoveredDataType? DataType { get; set; } + + /// + /// The character set of the column (if char) + /// + public string? Format { get; set; } + + private readonly string _name = name; + private readonly IQuerySyntaxHelper _querySyntaxHelper = table.Database.Server.GetQuerySyntaxHelper(); + + /// + /// The unqualified name of the column e.g. "MyCol" + /// + /// + public string GetRuntimeName() => _querySyntaxHelper.GetRuntimeName(_name); + + /// + /// The fully qualified name of the column e.g. [MyDb].dbo.[MyTable].[MyCol] or `MyDb`.`MyCol` + /// + /// + public string GetFullyQualifiedName() => _querySyntaxHelper.EnsureFullyQualified(Table.Database.GetRuntimeName(), + Table.Schema, Table.GetRuntimeName(), GetRuntimeName(), Table is DiscoveredTableValuedFunction); + + + /// + /// Returns the SQL code required to fetch the values from the table + /// + /// The number of records to return + /// If true adds a WHERE statement to throw away null values + /// + public string GetTopXSql(int topX, bool discardNulls) => Helper.GetTopXSqlForColumn(Table.Database, Table, this, topX, discardNulls); + + /// + /// Returns the name of the column + /// + /// + public override string ToString() => _name; + + /// + /// Generates a primed with the of this column. This can be used to inspect new + /// untyped (string) data to determine whether it will fit into the column. + /// + /// + public Guesser GetGuesser() => Table.GetQuerySyntaxHelper().TypeTranslater.GetGuesserFor(this); + + /// + /// Based on column name and Table + /// + /// + /// + private bool Equals(DiscoveredColumn other) => string.Equals(_name, other._name) && Equals(Table, other.Table); + + /// + /// Based on column name and Table + /// + /// + /// + public override bool Equals(object? obj) + { + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + return Equals((DiscoveredColumn)obj); + } + + /// + /// Based on column name and Table + /// + /// + public override int GetHashCode() + { + unchecked + { + return ((_name?.GetHashCode() ?? 0) * 397) ^ (Table?.GetHashCode() ?? 0); + } + } + + /// + /// Returns the wrapped e.g. "[MyCol]" name of the column including escaping e.g. if you wanted to name a column "][nquisitor" (which would return "[]][nquisitor]"). Use to return the full name including table/database/schema. + /// + /// + public string? GetWrappedName() => Table.GetQuerySyntaxHelper().EnsureWrapped(GetRuntimeName()); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredDataType.cs b/FAnsi.Core/Discovery/DiscoveredDataType.cs new file mode 100644 index 00000000..5d6c1e25 --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredDataType.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using FAnsi.Connections; +using FAnsi.Discovery.TypeTranslation; +using FAnsi.Exceptions; +using TypeGuesser; + +namespace FAnsi.Discovery; + +/// +/// Cross database type reference to a Data Type string (e.g. varchar(30), varbinary(100) etc) of a Column in a Table +/// +public sealed class DiscoveredDataType +{ + private readonly DiscoveredColumn? _column; + + /// + /// The proprietary DBMS name for the datatype e.g. varchar2(100) for Oracle, datetime2 for Sql Server etc. + /// + public string SQLType { get; set; } + + /// + /// All values read from the database record retrieved when assembling the data type (E.g. the cells of the sys.columns record) + /// + public Dictionary ProprietaryDatatype = []; + + /// + /// API constructor, instead use instead. + /// + /// All the values in r will be copied into the Dictionary property of this class called ProprietaryDatatype + /// Your inferred SQL data type for it e.g. varchar(50) + /// The column it belongs to, can be null e.g. if your data type belongs to a DiscoveredParameter instead + public DiscoveredDataType(DbDataReader r, string sqlType, DiscoveredColumn? column) + { + SQLType = sqlType; + _column = column; + + for (var i = 0; i < r.FieldCount; i++) + ProprietaryDatatype.Add(r.GetName(i), r.GetValue(i)); + } + + /// + /// Returns the maximum string length supported by the described data type or -1 if it isn't a string + /// Returns if the string type has no real limit e.g. "text" + /// + /// + public int GetLengthIfString() => _column?.Table.Database.Server.Helper.GetQuerySyntaxHelper().TypeTranslater.GetLengthIfString(SQLType) ?? -1; + + /// + /// Returns the Scale/Precision of the data type. Only applies to decimal(x,y) types not basic types e.g. int. + /// + /// Returns null if the datatype is not floating point + /// + /// + public DecimalSize? GetDecimalSize() => _column?.Table.Database.Server.Helper.GetQuerySyntaxHelper().TypeTranslater.GetDigitsBeforeAndAfterDecimalPointIfDecimal(SQLType); + + /// + /// Returns the System.Type that should be used to store values read out of columns of this data type (See + /// + /// + [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] + public Type? GetCSharpDataType() => _column?.Table.Database.Server.GetQuerySyntaxHelper().TypeTranslater.GetCSharpTypeForSQLDBType(SQLType); + + /// + /// Returns the + /// + /// + public override string ToString() => SQLType; + + /// + /// Creates and runs an ALTER TABLE statement which will increase the size of a char column to support longer string values than it currently does. + /// + /// Throws if the column is not a char type or the is smaller than the current column size + /// + /// + /// + /// + /// + public void Resize(int newSize, IManagedTransaction? managedTransaction = null) + { + var toReplace = GetLengthIfString(); + + if(newSize == toReplace) + return; + + if(newSize < toReplace) + throw new InvalidResizeException(string.Format(FAnsiStrings.DiscoveredDataType_Resize_CannotResizeSmaller, SQLType, newSize)); + + var newType = SQLType.Replace(toReplace.ToString(), newSize.ToString()); + + AlterTypeTo(newType, managedTransaction); + } + + /// + /// Creates and runs an ALTER TABLE statement which will increase the size of a decimal column to support larger Precision/Scale values than it currently does. + /// If you want decimal(4,2) then pass =2 and =2 + /// + /// Throws if the column is not a decimal type or the new size is smaller than the current column size + /// + /// The number of decimal places before the . you want represented e.g. for decimal(5,3) specify 2 + /// The number of decimal places after the . you want represented e.g. for decimal(5,3,) specify 3 + /// + /// + /// + public void Resize(int numberOfDigitsBeforeDecimalPoint, int numberOfDigitsAfterDecimalPoint, IManagedTransaction? managedTransaction = null) + { + var toReplace = GetDecimalSize(); + + if (toReplace == null || toReplace.IsEmpty) + throw new InvalidResizeException(string.Format(FAnsiStrings.DiscoveredDataType_Resize_DataType_cannot_be_resized_to_decimal_because_it_is_of_data_type__0_, SQLType)); + + if (toReplace.NumbersBeforeDecimalPlace > numberOfDigitsBeforeDecimalPoint) + throw new InvalidResizeException(string.Format(FAnsiStrings.DiscoveredDataType_Resize_Cannot_shrink_column__number_of_digits_before_the_decimal_point_is_currently__0__and_you_asked_to_set_it_to__1___Current_SQLType_is__2__, toReplace.NumbersBeforeDecimalPlace, numberOfDigitsBeforeDecimalPoint, SQLType)); + + if (toReplace.NumbersAfterDecimalPlace> numberOfDigitsAfterDecimalPoint) + throw new InvalidResizeException(string.Format(FAnsiStrings.DiscoveredDataType_Resize_Cannot_shrink_column__number_of_digits_after_the_decimal_point_is_currently__0__and_you_asked_to_set_it_to__1___Current_SQLType_is__2__, toReplace.NumbersAfterDecimalPlace, numberOfDigitsAfterDecimalPoint, SQLType)); + + var newDataType = _column?.Table.GetQuerySyntaxHelper() + .TypeTranslater.GetSQLDBTypeForCSharpType(new DatabaseTypeRequest(typeof(decimal), null, + new DecimalSize(numberOfDigitsBeforeDecimalPoint, numberOfDigitsAfterDecimalPoint))) ?? + throw new InvalidOperationException($"Failed to calculate new DB type"); + + AlterTypeTo(newDataType, managedTransaction); + } + + /// + /// Creates and runs an ALTER TABLE statement to change the data type to the + /// + /// Consider using instead + /// + /// The data type you want to change to e.g. "varchar(max)" + /// + /// The time to wait before giving up on the command (See + /// + public void AlterTypeTo(string newType, IManagedTransaction? managedTransaction = null,int alterTimeoutInSeconds = 500) + { + if(_column == null) + throw new NotSupportedException(FAnsiStrings.DiscoveredDataType_AlterTypeTo_Cannot_resize_DataType_because_it_does_not_have_a_reference_to_a_Column_to_which_it_belongs); + + var server = _column.Table.Database.Server; + using (var connection = server.GetManagedConnection(managedTransaction)) + { + var sql = _column.Helper.GetAlterColumnToSql(_column, newType, _column.AllowNulls); + try + { + using var cmd = server.Helper.GetCommand(sql, connection.Connection, connection.Transaction); + cmd.CommandTimeout = alterTimeoutInSeconds; + cmd.ExecuteNonQuery(); + } + catch (Exception e) + { + throw new AlterFailedException(string.Format(FAnsiStrings.DiscoveredDataType_AlterTypeTo_Failed_to_send_resize_SQL__0_, sql), e); + } + } + + SQLType = newType; + } + + /// + /// Returns true if the describes the same as this + /// + /// + /// + private bool Equals(DiscoveredDataType other) => string.Equals(SQLType, other.SQLType); + + /// + /// Equality based on + /// + /// + public override int GetHashCode() => SQLType != null ? SQLType.GetHashCode() : 0; + + /// + /// Equality based on + /// + /// + public override bool Equals(object? obj) + { + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + return Equals((DiscoveredDataType)obj); + } + +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredDatabase.cs b/FAnsi.Core/Discovery/DiscoveredDatabase.cs new file mode 100644 index 00000000..2cc2230b --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredDatabase.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using FAnsi.Connections; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.TableCreation; +using FAnsi.Naming; +using TypeGuesser; + +namespace FAnsi.Discovery; + +/// +/// Cross database type reference to a specific database on a database server. Allows you to create tables, drop check existance etc. +/// +public sealed class DiscoveredDatabase : IHasRuntimeName, IMightNotExist +{ + private readonly string _database; + private readonly IQuerySyntaxHelper _querySyntaxHelper; + + /// + /// The server on which the database exists + /// + public DiscoveredServer Server { get; } + + /// + /// Stateless helper class with DBMS specific implementation of the logic required by . + /// + public IDiscoveredDatabaseHelper Helper { get; } + + /// + /// API constructor, instead use instead. + /// + /// + /// + /// + internal DiscoveredDatabase(DiscoveredServer server, string database, IQuerySyntaxHelper querySyntaxHelper) + { + Server = server; + _database = database; + _querySyntaxHelper = querySyntaxHelper; + Helper = server.Helper.GetDatabaseHelper(); + + _querySyntaxHelper.ValidateDatabaseName(database); + } + + /// + /// Connects to the server and returns a list of tables/views found as . If you know your table exists and you only want to find one you + /// can use instead. + /// + /// true to also return views (See ) + /// Optional - if provided the database query will be sent using the connection/transaction provided + /// + public DiscoveredTable[] DiscoverTables(bool includeViews, IManagedTransaction? transaction = null) + { + using var managedConnection = Server.GetManagedConnection(transaction); + return + Helper.ListTables(this, _querySyntaxHelper, managedConnection.Connection, GetRuntimeName(), + includeViews, managedConnection.Transaction).ToArray(); + } + + /// + /// Connects to the server and returns a list of table valued functions found as . If you know your function exists and you + /// only want to find one you can use instead. + /// + /// Optional - if provided the database query will be sent using the connection/transaction provided + /// + public IEnumerable DiscoverTableValuedFunctions(IManagedTransaction? transaction = null) + { + using var managedConnection = Server.GetManagedConnection(transaction); + return + Helper.ListTableValuedFunctions(this, _querySyntaxHelper, managedConnection.Connection, + GetRuntimeName(), managedConnection.Transaction).ToArray(); + } + + /// + /// Returns the name of the database without any qualifiers + /// + /// + public string GetRuntimeName() => _querySyntaxHelper.GetRuntimeName(_database); + + /// + /// Returns the wrapped e.g. "[MyDatabase]" name of the database including escaping e.g. if you wanted to name a database "][nquisitor" (which would return "[]][nquisitor]"). + /// + /// + public string? GetWrappedName() => _querySyntaxHelper.EnsureWrapped(GetRuntimeName()); + + /// + /// Creates an expectation (See ) that there is a table with the given name in the database. + /// This method does not query the database or confirm it exists. + /// + /// See also + /// + /// The runtime name (not qualified) of the table / view / function you are looking for + /// Optional - The schema (if supported by DBMS) it exists in. This is NOT the database e.g. in [MyDb].[dbo].[MyTable] the schema is "dbo". + /// If in doubt leave blank + /// What you are looking for (normal table, view or table valued function) + /// + public DiscoveredTable ExpectTable(string tableName, string? schema = null, TableType tableType = TableType.Table) + { + if (tableType == TableType.TableValuedFunction) + return ExpectTableValuedFunction(tableName, schema); + + return new DiscoveredTable(this, tableName, _querySyntaxHelper, schema, tableType); + } + + /// + public DiscoveredTableValuedFunction ExpectTableValuedFunction(string tableName,string? schema = null) => new(this, tableName, _querySyntaxHelper, schema); + + /// + /// Connects to the database and returns a list of stored proceedures found as objects + /// + /// + public IEnumerable DiscoverStoredprocedures() => Helper.ListStoredprocedures(Server.Builder,GetRuntimeName()); + + /// + /// Returns the name of the database + /// + /// + public override string ToString() => _database; + + /// + /// Connects to the server and enumerates the databases to see whether the currently described database exists. + /// + /// Database level operations are usually not transaction bound so be very careful about setting a parameter for this + /// + public bool Exists(IManagedTransaction? transaction = null) + { + return Server.DiscoverDatabases().Any(db => db.GetRuntimeName()?.Equals(GetRuntimeName(), StringComparison.InvariantCultureIgnoreCase) == true); + } + + /// + /// Drops the database from the server (deletes). There is no going back after calling this method unless you have a database backup. + /// + public void Drop() + { + if (!Exists()) + throw new InvalidOperationException(string.Format(FAnsiStrings.DiscoveredDatabase_DatabaseDoesNotExistSoCannotBeDropped, this)); + + // Pass in a copy of ourself, the Drop can mutate the connection string which can cause nasty side-effects (because many classes, e.g. attachers, hold references to these objects) + Helper.DropDatabase(new DiscoveredDatabase(Server, _database, _querySyntaxHelper)); + } + + /// + /// Return key value pairs which describe attributes of the database e.g. available space, physical location etc. + /// + /// + public Dictionary DescribeDatabase() => Helper.DescribeDatabase(Server.Builder, GetRuntimeName()); + + /// + /// Creates the database referenced by this object. + /// + /// True to check if the database exists first and Drop if it does + public void Create(bool dropFirst = false) + { + if (dropFirst && Exists()) + Drop(); + + Server.CreateDatabase(GetRuntimeName()); + } + + /// + /// Assembles and runs a CREATE TABLE sql statement and returns the table created as a . + /// + /// The unqualified name for the table you want to create e.g. "MyTable" + /// List of columns you want in your table + /// Optional - The schema (if supported by DBMS) to create in. This is NOT the database e.g. in [MyDb].[dbo].[MyTable] the schema is "dbo". + /// If in doubt leave blank + /// Last minute delegate class for modifying the data types prior to executing SQL + /// The table created + public DiscoveredTable CreateTable(string tableName, DatabaseColumnRequest[] columns, string? schema = null, IDatabaseColumnRequestAdjuster? adjuster = null) => + CreateTable(new CreateTableArgs(this,tableName, schema) + { + Adjuster = adjuster, + ExplicitColumnDefinitions = columns + }); + + /// + /// Assembles and runs a CREATE TABLE sql statement and returns the table created as a . + /// + /// The unqualified name for the table you want to create e.g. "MyTable" + /// List of columns you want in your table + /// Last minute delegate class for modifying the data types prior to executing SQL + /// Creates a single foreign key between the table created (parent) and a child table. Columns in this parameter + /// must be a subset of + /// + /// Key is the foreign key column (and the table the constraint will be put on). + /// Value is the primary key table column (which the constraint reference points to) + /// + /// + /// True to set CASCADE DELETE on the foreign key created by + /// The table created + public DiscoveredTable CreateTable(string tableName, DatabaseColumnRequest[] columns, Dictionary foreignKeyPairs, bool cascadeDelete, IDatabaseColumnRequestAdjuster? adjuster = null) => + CreateTable(new CreateTableArgs(this, tableName, null, foreignKeyPairs, cascadeDelete) + { + Adjuster = adjuster, + ExplicitColumnDefinitions = columns + }); + + /// + /// Assembles and runs a CREATE TABLE sql statement based on the data/columns in and returns the table created as a . + /// + /// This will also INSERT the data in into the table created unless is true + /// + /// The unqualified name for the table you want to create e.g. "MyTable" + /// Data on which to base the CREATE statement on. Supports untyped (string) data e.g. "1", "101" "200" would create an int column + /// True to bulk insert the Rows in the after issuing CREATE. False to create only the empty schema + /// Last minute delegate class for modifying the table columns data types prior to executing SQL + /// Optional - Override descisions made about columns in the by specify an explicit type etc + /// The table created + public DiscoveredTable CreateTable(string tableName, DataTable dt, DatabaseColumnRequest[]? explicitColumnDefinitions = null, bool createEmpty = false,IDatabaseColumnRequestAdjuster? adjuster = null) => + CreateTable(new CreateTableArgs(this, tableName, null, dt, createEmpty) + { + ExplicitColumnDefinitions = explicitColumnDefinitions, + Adjuster = adjuster + }); + + /// + /// Assembles and runs a CREATE TABLE sql statement and returns the table created as a . + /// + public DiscoveredTable CreateTable(CreateTableArgs args) => Helper.CreateTable(args); + + /// + /// Creates a table in the database big enough to store the supplied DataTable with appropriate types. + /// + /// The computers used to determine column types + /// + /// + /// + /// + /// + /// + public DiscoveredTable CreateTable(out Dictionary? typeDictionary, string tableName, DataTable dt, DatabaseColumnRequest[]? explicitColumnDefinitions = null, bool createEmpty = false, IDatabaseColumnRequestAdjuster? adjuster = null) + { + var args = new CreateTableArgs(this, tableName, null, dt, createEmpty) + { + Adjuster = adjuster, + ExplicitColumnDefinitions = explicitColumnDefinitions + }; + var table = Helper.CreateTable(args); + + if (!args.TableCreated) + throw new Exception(FAnsiStrings.DiscoveredDatabase_CreateTableDidNotPopulateTableCreatedProperty); + + typeDictionary = args.ColumnCreationLogic; + + return table; + } + + + /// + /// Creates a new schema within the database if the DBMS supports it (Sql Server does, MySql doesn't) and it does not already exist. Schema + /// is a layer below server and database but above table it groups tables within a single database. + /// + /// + /// + public void CreateSchema(string name) + { + Helper.CreateSchema(this,name); + } + + /// + /// Detach this DiscoveredDatabase and returns the data path where the files are stored (local to the DBMS server). + /// + /// NOTE: you must know how to map this data path to a shared path you can access! + /// + /// Local drive data path where the files are stored + public DirectoryInfo? Detach() => Helper.Detach(this); + + /// + /// Creates a local (to the DBMS server) backup of the database. Implementations may vary but should be the simplest database type + /// e.g. MyDb.mdf should result in an incremental backup MyDb.bak. + /// + /// + public void CreateBackup(string backupName) + { + Helper.CreateBackup(this,backupName); + } + + /// + /// Equality based on Server and database name + /// + /// + /// + private bool Equals(DiscoveredDatabase other) => Equals(Server, other.Server) && string.Equals(_database, other._database,StringComparison.OrdinalIgnoreCase); + + /// + /// Equality based on Server and database name + /// + /// + /// + public override bool Equals(object? obj) + { + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + return Equals((DiscoveredDatabase)obj); + } + + /// + /// Based on Server and database name + /// + /// + public override int GetHashCode() + { + unchecked + { + return ((Server != null ? Server.GetHashCode() : 0) * 397) ^ (_database != null ? StringComparer.OrdinalIgnoreCase.GetHashCode(_database) : 0); + } + } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredDatabaseHelper.cs b/FAnsi.Core/Discovery/DiscoveredDatabaseHelper.cs new file mode 100644 index 00000000..59640def --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredDatabaseHelper.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.TableCreation; +using FAnsi.Extensions; +using FAnsi.Implementation; +using FAnsi.Naming; +using TypeGuesser; + +namespace FAnsi.Discovery; + +/// +/// DBMS specific implementation of all functionality that relates to interacting with existing databases (dropping databases, creating tables, finding stored procedures etc). For +/// database creation see +/// +public abstract class DiscoveredDatabaseHelper:IDiscoveredDatabaseHelper +{ + public abstract IEnumerable ListTables(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, DbConnection connection, + string database, bool includeViews, DbTransaction? transaction = null); + + public abstract IEnumerable ListTableValuedFunctions(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, + DbConnection connection, string database, DbTransaction? transaction = null); + + public abstract IEnumerable ListStoredprocedures(DbConnectionStringBuilder builder, string database); + public abstract IDiscoveredTableHelper GetTableHelper(); + public abstract void DropDatabase(DiscoveredDatabase database); + public abstract Dictionary DescribeDatabase(DbConnectionStringBuilder builder, string database); + + public DiscoveredTable CreateTable(CreateTableArgs args) + { + var typeDictionary = new Dictionary(StringComparer.CurrentCultureIgnoreCase); + + var columns = new List(); + var customRequests = args.ExplicitColumnDefinitions != null + ? args.ExplicitColumnDefinitions.ToList() + : []; + + if(args.DataTable != null) + { + + ThrowIfObjectColumns(args.DataTable); + + //If we have a data table from which to create the table from + foreach (DataColumn column in args.DataTable.Columns) + { + //do we have an explicit overriding column definition? + var overriding = customRequests.SingleOrDefault(c => c.ColumnName.Equals(column.ColumnName,StringComparison.CurrentCultureIgnoreCase)); + + //yes + if (overriding != null) + { + columns.Add(overriding); + customRequests.Remove(overriding); + + //Type requested is a proper FAnsi type (e.g. string, at least 5 long) + var request = overriding.TypeRequested; + + if (request is null) + if (!string.IsNullOrWhiteSpace(overriding.ExplicitDbType)) + { + //Type is for an explicit SQL Type e.g. varchar(5) + + //Translate the sql type to a FAnsi type definition + var tt = args.Database.Server.GetQuerySyntaxHelper().TypeTranslater; + + request = tt.GetDataTypeRequestForSQLDBType(overriding.ExplicitDbType); + } + else + throw new Exception(string.Format(FAnsiStrings.DiscoveredDatabaseHelper_CreateTable_DatabaseColumnRequestMustHaveEitherTypeRequestedOrExplicitDbType, column)); + + var guesser = GetGuesser(request); + CopySettings(guesser, args); + typeDictionary.Add(overriding.ColumnName, guesser); + } + else + { + //no, work out the column definition using a guesser + var guesser = GetGuesser(column); + guesser.Culture = args.Culture; + + CopySettings(guesser,args); + + guesser.AdjustToCompensateForValues(column); + + //if DoNotRetype is set on the column adjust the requested CSharpType to be the original type + if (column.GetDoNotReType()) + guesser.Guess.CSharpType = column.DataType; + + typeDictionary.Add(column.ColumnName,guesser); + + columns.Add(new DatabaseColumnRequest(column.ColumnName, guesser.Guess, column.AllowDBNull) { IsPrimaryKey = args.DataTable.PrimaryKey.Contains(column)}); + } + } + } + else + { + //If no DataTable is provided just use the explicitly requested columns + columns = customRequests; + } + + args.Adjuster?.AdjustColumns(columns); + + //Get the table creation SQL + var bodySql = GetCreateTableSql(args.Database, args.TableName, [.. columns], args.ForeignKeyPairs, args.CascadeDelete, args.Schema); + + //connect to the server and send it + var server = args.Database.Server; + + using (var con = server.GetConnection()) + { + con.Open(); + + ExecuteBatchNonQuery(bodySql, con); + } + + //Get reference to the newly created table + var tbl = args.Database.ExpectTable(args.TableName, args.Schema); + + //unless we are being asked to create it empty then upload the DataTable to it + if(args.DataTable != null && !args.CreateEmpty) + { + using var bulk = tbl.BeginBulkInsert(args.Culture); + bulk.DateTimeDecider.Settings.ExplicitDateFormats = args.GuessSettings.ExplicitDateFormats; + bulk.Upload(args.DataTable); + } + + + args.OnTableCreated(typeDictionary); + + return tbl; + } + + private static void CopySettings(Guesser guesser, CreateTableArgs args) + { + //cannot change the instance so have to copy across the values. If this gets new properties that's a problem + //See tests GuessSettings_CopyProperties + guesser.Settings.CharCanBeBoolean = args.GuessSettings.CharCanBeBoolean; + guesser.Settings.ExplicitDateFormats = args.GuessSettings.ExplicitDateFormats; + } + + /// + /// Throws an if the contains with + /// the of + /// + /// + public void ThrowIfObjectColumns(DataTable dt) + { + var objCol = dt.Columns.Cast().FirstOrDefault(static c => c.DataType == typeof(object)); + + if(objCol != null) + throw new NotSupportedException( + string.Format( + FAnsiStrings.DataTable_Column__0__was_of_DataType__1___this_is_not_allowed___Use_String_for_untyped_data, + objCol.ColumnName, + objCol.DataType + )); + } + + /// + public abstract void CreateSchema(DiscoveredDatabase discoveredDatabase, string name); + + protected virtual Guesser GetGuesser(DataColumn column) => new(); + + protected virtual Guesser GetGuesser(DatabaseTypeRequest request) => new(request); + + public virtual string GetCreateTableSql(DiscoveredDatabase database, string tableName, + DatabaseColumnRequest[] columns, Dictionary? foreignKeyPairs, + bool cascadeDelete, string? schema) + { + if (string.IsNullOrWhiteSpace(tableName)) + throw new ArgumentNullException(nameof(tableName),FAnsiStrings.DiscoveredDatabaseHelper_GetCreateTableSql_Table_name_cannot_be_null); + + var bodySql = new StringBuilder(); + + var server = database.Server; + var syntaxHelper = server.GetQuerySyntaxHelper(); + + syntaxHelper.ValidateTableName(tableName); + + foreach (var c in columns) + syntaxHelper.ValidateColumnName(c.ColumnName); + + //the name sans brackets (hopefully they didn't pass any brackets) + tableName = syntaxHelper.GetRuntimeName(tableName); + + //the name fully specified e.g. [db]..[tbl] or `db`.`tbl` - See Test HorribleColumnNames + var fullyQualifiedName = syntaxHelper.EnsureFullyQualified(database.GetRuntimeName(), schema, tableName); + + bodySql.AppendLine($"CREATE TABLE {fullyQualifiedName}("); + + foreach (var col in columns) + { + var datatype = col.GetSQLDbType(syntaxHelper.TypeTranslater); + + //add the column name and accompanying datatype + bodySql.AppendLine($"{GetCreateTableSqlLineForColumn(col, datatype, syntaxHelper)},"); + } + + var pks = columns.Where(static c => c.IsPrimaryKey).ToArray(); + if (pks.Length != 0) + bodySql.Append(GetPrimaryKeyDeclarationSql(tableName, pks,syntaxHelper)); + + if (foreignKeyPairs != null) + { + bodySql.AppendLine(); + bodySql.AppendLine(GetForeignKeyConstraintSql(tableName, syntaxHelper, + foreignKeyPairs.ToDictionary(static k => (IHasRuntimeName)k.Key, static v => v.Value), cascadeDelete, null)); + } + + var toReturn = bodySql.ToString().TrimEnd('\r', '\n', ','); + + toReturn += $"){Environment.NewLine}"; + + return toReturn; + } + + /// + /// Return the line that represents the given for slotting into a CREATE statement SQL e.g. "description varchar(20)" + /// + /// + /// + /// + /// + protected virtual string GetCreateTableSqlLineForColumn(DatabaseColumnRequest col, string datatype, IQuerySyntaxHelper syntaxHelper) => $"{syntaxHelper.EnsureWrapped(col.ColumnName)} {datatype} {(col.Default != MandatoryScalarFunctions.None ? $"default {syntaxHelper.GetScalarFunctionSql(col.Default)}" : "")} {(string.IsNullOrWhiteSpace(col.Collation) ? "" : $"COLLATE {col.Collation}")} {(col.AllowNulls && !col.IsPrimaryKey ? " NULL" : " NOT NULL")} {(col.IsAutoIncrement ? syntaxHelper.GetAutoIncrementKeywordIfAny() : "")}"; + + public virtual string GetForeignKeyConstraintSql(string foreignTable, IQuerySyntaxHelper syntaxHelper, + Dictionary foreignKeyPairs, bool cascadeDelete, string? constraintName) + { + var primaryKeyTable = foreignKeyPairs.Values.Select(static v => v.Table).Distinct().Single(); + + constraintName ??= GetForeignKeyConstraintNameFor(foreignTable, primaryKeyTable.GetRuntimeName()); + + //@" CONSTRAINT FK_PersonOrder FOREIGN KEY (PersonID) REFERENCES Persons(PersonID) on delete cascade"; + return + $""" + CONSTRAINT {constraintName} FOREIGN KEY ({string.Join(",", foreignKeyPairs.Keys.Select(k => syntaxHelper.EnsureWrapped(k.GetRuntimeName())))}) + REFERENCES {primaryKeyTable.GetFullyQualifiedName()}({string.Join(",", foreignKeyPairs.Values.Select(v => syntaxHelper.EnsureWrapped(v.GetRuntimeName())))}) {(cascadeDelete ? " on delete cascade" : "")} + """; + } + + public string GetForeignKeyConstraintNameFor(DiscoveredTable foreignTable, DiscoveredTable primaryTable) => GetForeignKeyConstraintNameFor(foreignTable.GetRuntimeName(), primaryTable.GetRuntimeName()); + + private static string GetForeignKeyConstraintNameFor(string foreignTable, string primaryTable) => + MakeSensibleConstraintName("FK_", $"{foreignTable}_{primaryTable}"); + + public abstract DirectoryInfo? Detach(DiscoveredDatabase database); + + public abstract void CreateBackup(DiscoveredDatabase discoveredDatabase, string backupName); + + private static string GetPrimaryKeyDeclarationSql(string tableName, IEnumerable pks, + IQuerySyntaxHelper syntaxHelper) => + $" CONSTRAINT {MakeSensibleConstraintName("PK_", tableName)} PRIMARY KEY ({string.Join(",", pks.Select(c => syntaxHelper.EnsureWrapped(c.ColumnName)))}),{Environment.NewLine}"; + + private static string MakeSensibleConstraintName(string prefix, string tableName) + { + var constraintName = QuerySyntaxHelper.MakeHeaderNameSensible(tableName); + + if (!string.IsNullOrWhiteSpace(constraintName)) return $"{prefix}{constraintName}"; + + var r = new Random(); + constraintName = $"Constraint{r.Next(10000)}"; + return $"{prefix}{constraintName}"; + } + + public void ExecuteBatchNonQuery(string sql, DbConnection conn, DbTransaction? transaction = null, int timeout = 30) + { + ExecuteBatchNonQuery(sql, conn, transaction, out _, timeout); + } + + private static readonly string[] separator = ["\n", "\r"]; + + /// + /// Executes the given SQL against the database + sends GO delimited statements as separate batches + /// + /// Collection of SQL queries which can be separated by the use of "GO" on a line (works for all DBMS) + /// + /// + /// Line number the batch started at and the time it took to complete it + /// Timeout in seconds to run each batch in the + public void ExecuteBatchNonQuery(string sql, DbConnection conn, DbTransaction? transaction, out Dictionary performanceFigures, int timeout = 30) + { + performanceFigures = []; + + var sqlBatch = new StringBuilder(); + + var helper = ImplementationManager.GetImplementation(conn).GetServerHelper(); + + using var cmd = helper.GetCommand(string.Empty, conn, transaction); + var hadToOpen = false; + + if (conn.State != ConnectionState.Open) + { + + conn.Open(); + hadToOpen = true; + } + + var lineNumber = 1; + + sql += "\nGO"; // make sure last batch is executed. + try + { + foreach (var line in sql.Split(separator, StringSplitOptions.RemoveEmptyEntries)) + { + lineNumber++; + + if (line.Trim().Equals("GO",StringComparison.CurrentCultureIgnoreCase)) + { + var executeSql = sqlBatch.ToString(); + if (string.IsNullOrWhiteSpace(executeSql)) + continue; + + if (!performanceFigures.ContainsKey(lineNumber)) + performanceFigures.Add(lineNumber, new Stopwatch()); + performanceFigures[lineNumber].Start(); + + cmd.CommandText = executeSql; + cmd.CommandTimeout = timeout; + cmd.ExecuteNonQuery(); + + performanceFigures[lineNumber].Stop(); + sqlBatch.Clear(); + } + else + { + sqlBatch.AppendLine(line); + } + } + } + finally + { + if (hadToOpen) + conn.Close(); + } + } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredParameter.cs b/FAnsi.Core/Discovery/DiscoveredParameter.cs new file mode 100644 index 00000000..c6fca8c3 --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredParameter.cs @@ -0,0 +1,17 @@ +namespace FAnsi.Discovery; + +/// +/// Cross database type reference to a Parameter (e.g. of a Table valued function / stored procedure). +/// +public sealed class DiscoveredParameter(string parameterName) +{ + /// + /// SQL name of parameter e.g. @bob for Sql Server + /// + public string ParameterName { get; set; } = parameterName; + + /// + /// The the parameter is declared as e.g. varchar(10) + /// + public DiscoveredDataType? DataType { get; set; } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredServer.cs b/FAnsi.Core/Discovery/DiscoveredServer.cs new file mode 100644 index 00000000..35a4bab5 --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredServer.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using FAnsi.Connections; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Exceptions; +using FAnsi.Implementation; + +namespace FAnsi.Discovery; + +/// +/// Cross database type reference to a database server. Allows you to get connections, create commands, list databases etc. +/// +public sealed class DiscoveredServer : IMightNotExist +{ + /// + /// Stores connection string State (which server the refers to. + /// + public DbConnectionStringBuilder Builder { get; set; } + + /// + /// The currently used database, if any + /// + private DiscoveredDatabase? _currentDatabase; + + /// + /// Stateless helper class with DBMS specific implementation of the logic required by . + /// + public IDiscoveredServerHelper Helper { get; } + + /// + /// Returns (indicates what DBMS the is pointed at). + /// + public DatabaseType DatabaseType => Helper.DatabaseType; + + /// + /// The server's name as specified in e.g. localhost\sqlexpress + /// + public string? Name => Helper.GetServerName(Builder); + + /// + /// Returns the username portion of if specified + /// + public string? ExplicitUsernameIfAny => Helper.GetExplicitUsernameIfAny(Builder); + + /// + /// Returns the password portion of if specified + /// + public string? ExplicitPasswordIfAny => Helper.GetExplicitPasswordIfAny(Builder); + + /// + /// Creates a new server pointed at the server. + /// + /// must have a loaded implementation for the DBMS type + /// + /// Determines the connection string and e.g. MySqlConnectionStringBuilder = DatabaseType.MySql + /// + public DiscoveredServer(DbConnectionStringBuilder builder) + { + Helper = ImplementationManager.GetImplementation(builder).GetServerHelper(); + + //give helper a chance to mutilate the builder if he wants (also gives us a new copy of the builder in case anyone external modifies the old reference) + Builder = Helper.GetConnectionStringBuilder(builder.ConnectionString); + } + + /// + /// Creates a new server pointed at the which should be a server of DBMS + /// + /// must have a loaded implementation for the DBMS type + /// + /// + /// + /// + public DiscoveredServer(string? connectionString, DatabaseType databaseType) + { + Helper = ImplementationManager.GetImplementation(databaseType).GetServerHelper(); + Builder = Helper.GetConnectionStringBuilder(connectionString); + } + + /// + /// Creates a new server pointed at the which should be a server of DBMS + /// + /// must have a loaded implementation for the DBMS type + /// + /// The server to connect to e.g. "localhost\sqlexpress" + /// The default database to connect into/query (see ) + /// The DBMS provider type + /// Optional username to set in the connection string + /// Optional password to set in the connection string + /// + public DiscoveredServer(string server, string? database, DatabaseType databaseType, string usernameIfAny, string passwordIfAny) + { + Helper = ImplementationManager.GetImplementation(databaseType).GetServerHelper(); + + Builder = Helper.GetConnectionStringBuilder(server, database, usernameIfAny, passwordIfAny); + + if (!string.IsNullOrWhiteSpace(database)) + _currentDatabase = ExpectDatabase(database); + } + + + /// + /// Returns a new unopened connection to the server. Use to start sending + /// without having to cast. + /// + /// Optional - when provided returns the instead of opening a new one + /// + public DbConnection GetConnection(IManagedTransaction? transaction = null) => transaction != null ? transaction.Connection : Helper.GetConnection(Builder); + + /// + public DbCommand GetCommand(string sql, IManagedConnection managedConnection) + { + var cmd = Helper.GetCommand(sql, managedConnection.Connection); + cmd.Transaction = managedConnection.Transaction; + return cmd; + } + + /// + /// Returns a new of the correct DBMS type for the of the server + /// + /// Can be null, command text for the + /// Correctly typed connection for the . (See ) + /// Optional - if provided the will be set to the + /// + public DbCommand GetCommand(string sql, DbConnection con, IManagedTransaction? transaction = null) + { + var cmd = Helper.GetCommand(sql, con); + + if (transaction != null) + cmd.Transaction = transaction.Transaction; + + return cmd; + } + + /// + /// Returns a new of the current of the server with the given + /// . + /// + /// + /// + private DbParameter GetParameter(string parameterName) => Helper.GetParameter(parameterName); + + /// + /// Returns a new of the correct of the server. Also adds it + /// to the of and sets it's + /// + /// + /// + /// + /// + public DbParameter AddParameterWithValueToCommand(string parameterName, DbCommand command, object valueForParameter) + { + var dbParameter = GetParameter(parameterName); + dbParameter.Value = valueForParameter; + command.Parameters.Add(dbParameter); + return dbParameter; + } + + /// + /// Creates an expectation (See ) that there is a database with the given name on the server. + /// This method does not query the database or confirm it exists. + /// + /// See also , etc + /// + /// + /// + public DiscoveredDatabase ExpectDatabase(string database) + { + GetQuerySyntaxHelper().ValidateDatabaseName(database); + + var builder = Helper.ChangeDatabase(Builder, database); + var server = new DiscoveredServer(builder); + return new DiscoveredDatabase(server, database, Helper.GetQuerySyntaxHelper()); + } + + /// + /// Attempts to connect to the server. Throws if the server did not respond within + /// . + /// + /// + /// + /// + public void TestConnection(int timeoutInMillis = 10000) + { + using var con = Helper.GetConnection(Builder); + using (var tokenSource = new CancellationTokenSource(timeoutInMillis)) + using (var openTask = con.OpenAsync(tokenSource.Token)) + { + try + { + openTask.Wait(timeoutInMillis, tokenSource.Token); + } + catch (OperationCanceledException e) + { + throw new TimeoutException( + string.Format( + FAnsiStrings + .DiscoveredServer_TestConnection_Could_not_connect_to_server___0___after_timeout_of__1__milliseconds_, + Name, timeoutInMillis), e); + } + catch (AggregateException e) + { + if (openTask.IsCanceled) + throw new TimeoutException( + string.Format( + FAnsiStrings + .DiscoveredServer_TestConnection_Could_not_connect_to_server___0___after_timeout_of__1__milliseconds_, + Name, timeoutInMillis), e); + + throw; + } + } + + con.Close(); + } + + /// + /// Attempts to connect to the server giving up after (if supported by DBMS). + /// + /// This differs from in that it specifies the timeout in the connection string (if possible) and waits for the + /// server to shut down the connection rather than using a . + /// + /// + /// + /// + /// + public bool RespondsWithinTime(int timeoutInSeconds, out Exception? exception) => Helper.RespondsWithinTime(Builder, timeoutInSeconds, out exception); + + /// + /// Connects to the server and returns a list of databases found as objects + /// + /// + public IEnumerable DiscoverDatabases() => Helper.ListDatabases(Builder) + .Select(database => new DiscoveredDatabase(this, database, Helper.GetQuerySyntaxHelper())).ToArray(); + + /// + /// Returns true/false based on . This method will use the default timeout of (e.g. 3 seconds). + /// + /// NOTE: Returns false if any Exception is thrown during + /// + /// Optional - if provided this method returns true (existence cannot be checked mid transaction). + /// + public bool Exists(IManagedTransaction? transaction = null) + { + if (transaction != null) + return true; + + try + { + TestConnection(); + return true; + } + catch (AggregateException) + { + return false; + } + catch (TimeoutException) + { + return false; + } + } + + /// + /// Returns a new correctly Typed for the + /// + /// + /// + public DbDataAdapter GetDataAdapter(DbCommand cmd) => Helper.GetDataAdapter(cmd); + + /// + /// Returns a new correctly Typed for the + /// + /// + public DbDataAdapter GetDataAdapter(string command, DbConnection con) => GetDataAdapter(GetCommand(command, con)); + + /// + /// Returns the database that is currently pointed at. + /// + /// + public DiscoveredDatabase? GetCurrentDatabase() + { + //Is the database name persisted in the connection string? + var dbName = Helper.GetCurrentDatabase(Builder); + + //yes + if (!string.IsNullOrWhiteSpace(dbName)) + return ExpectDatabase(dbName); + + //no (e.g. Oracle or no default database specified in connection string) + return _currentDatabase; //yes use that one + } + + /// + /// Edits the connection string (See ) to allow async operations. Depending on DBMS this may have + /// no effect (e.g. Sql Server needs AsynchronousProcessing and MultipleActiveResultSets but Oracle / MySql do not need + /// any special keywords) + /// + public void EnableAsync() + { + Builder = Helper.EnableAsync(Builder); + } + + /// + /// Edits the connection string (See ) to open connections to the . + /// + /// NOTE: Generally it is better to use instead and interact with the new object + /// + /// + public void ChangeDatabase(string newDatabase) + { + //change the connection string to point to the newDatabase + Builder = Helper.ChangeDatabase(Builder, newDatabase); + + //for DBMS that do not persist database in connection string (Oracle), we must persist this change + _currentDatabase = ExpectDatabase(newDatabase); + } + + /// + /// Returns the server + /// + /// + public override string? ToString() => Name; + + /// + /// Creates a new database with the given . + /// + /// In the case of Oracle this is a user+schema (See https://stackoverflow.com/questions/880230/difference-between-a-user-and-a-schema-in-oracle) + /// + /// + /// + public DiscoveredDatabase CreateDatabase(string newDatabaseName) + { + //the database we will create - it's ok DiscoveredDatabase is IMightNotExist + var db = ExpectDatabase(newDatabaseName); + + Helper.CreateDatabase(Builder, db); + + if (!db.Exists()) + throw new Exception(string.Format(FAnsiStrings.DiscoveredServer_CreateDatabase_Helper___0___tried_to_create_database___1___but_the_database_didn_t_exist_after_the_creation_attempt, Helper.GetType().Name, newDatabaseName)); + + return db; + } + + /// + /// Opens a new to the server and starts a . These are packaged into an which + /// should be wrapped with a using statement since it is . + /// + /// + public IManagedConnection BeginNewTransactedConnection() => new ManagedConnection(this, Helper.BeginTransaction(Builder)) { CloseOnDispose = true }; + + /// + /// Opens a new or reuses an existing one (if is provided). + /// + /// The returned object should be used in a using statement since it is + /// + /// + /// + public IManagedConnection GetManagedConnection(IManagedTransaction? transaction = null) => new ManagedConnection(this, transaction); + + /// + /// Returns helper for generating queries compatible with the DBMS (See ) e.g. TOP X, column qualifiers, what the parameter + /// symbol is etc. + /// + /// + public IQuerySyntaxHelper GetQuerySyntaxHelper() => Helper.GetQuerySyntaxHelper(); + + /// + /// Return key value pairs which describe attributes of the server e.g. version, available drive space etc + /// + /// + public Dictionary DescribeServer() => Helper.DescribeServer(Builder); + + /// + /// Equality based on Builder.ConnectionString and DatabaseType + /// + /// + /// + private bool Equals(DiscoveredServer other) + { + if (Builder == null || other.Builder == null) + return Equals(Builder, other.Builder) && DatabaseType == other.DatabaseType; + + //server is the same if they are pointed at the same server + return string.Equals(Builder.ConnectionString, other.Builder.ConnectionString, StringComparison.OrdinalIgnoreCase) && DatabaseType == other.DatabaseType; + } + + /// + /// Equality based on Builder.ConnectionString and DatabaseType + /// + /// + /// + public override bool Equals(object? obj) + { + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + return Equals((DiscoveredServer)obj); + } + + /// + /// Hashcode built from DatabaseType + /// + /// + public override int GetHashCode() => DatabaseType.GetHashCode(); + + /// + /// Returns the version number of the DBMS e.g. MySql 5.7 + /// + /// + public Version? GetVersion() => Helper.GetVersion(this); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredServerHelper.cs b/FAnsi.Core/Discovery/DiscoveredServerHelper.cs new file mode 100644 index 00000000..53700ce3 --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredServerHelper.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Threading; +using FAnsi.Connections; +using FAnsi.Discovery.ConnectionStringDefaults; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Naming; + +namespace FAnsi.Discovery; + +/// +/// DBMS specific implementation of all functionality that relates to interacting with existing server (testing connections, creating databases, etc). +/// +public abstract partial class DiscoveredServerHelper(DatabaseType databaseType) : IDiscoveredServerHelper +{ + private static readonly Dictionary ConnectionStringKeywordAccumulators = []; + + /// + /// Register a system-wide rule that all connection strings of should include the given . + /// + /// + /// + /// + /// Resolves conflicts when multiple calls are made for the same at different times + public static void AddConnectionStringKeyword(DatabaseType databaseType, string keyword, string value, ConnectionStringKeywordPriority priority) + { + if (!ConnectionStringKeywordAccumulators.ContainsKey(databaseType)) + ConnectionStringKeywordAccumulators.Add(databaseType, new ConnectionStringKeywordAccumulator(databaseType)); + + ConnectionStringKeywordAccumulators[databaseType].AddOrUpdateKeyword(keyword, value, priority); + } + + /// + public abstract DbCommand GetCommand(string s, DbConnection con, DbTransaction? transaction = null); + + /// + public abstract DbDataAdapter GetDataAdapter(DbCommand cmd); + + /// + public abstract DbCommandBuilder GetCommandBuilder(DbCommand cmd); + + /// + public abstract DbParameter GetParameter(string parameterName); + + public abstract DbConnection GetConnection(DbConnectionStringBuilder builder); + + public DbConnectionStringBuilder GetConnectionStringBuilder(string? connectionString) + { + var builder = GetConnectionStringBuilderImpl(connectionString); + EnforceKeywords(builder); + + return builder; + } + + /// + public DbConnectionStringBuilder GetConnectionStringBuilder(string server, string? database, string username, string password) + { + var builder = GetConnectionStringBuilderImpl(server, database, username, password); + EnforceKeywords(builder); + return builder; + } + + /// + /// Modifies the with the connection string keywords + /// specified in . Override to + /// perform last second changes to connection strings. + /// + /// + protected virtual void EnforceKeywords(DbConnectionStringBuilder builder) + { + //if we have any keywords to enforce + if (ConnectionStringKeywordAccumulators.TryGetValue(DatabaseType, out var accumulator)) + accumulator.EnforceOptions(builder); + } + + protected abstract DbConnectionStringBuilder GetConnectionStringBuilderImpl(string connectionString, string? database, string username, string password); + protected abstract DbConnectionStringBuilder GetConnectionStringBuilderImpl(string? connectionString); + + + protected abstract string ServerKeyName { get; } + protected abstract string DatabaseKeyName { get; } + protected virtual string ConnectionTimeoutKeyName => "ConnectionTimeout"; + + public string? GetServerName(DbConnectionStringBuilder builder) + { + var s = (string)builder[ServerKeyName]; + return string.IsNullOrWhiteSpace(s) ? null : s; + } + + public DbConnectionStringBuilder ChangeServer(DbConnectionStringBuilder builder, string newServer) + { + builder[ServerKeyName] = newServer; + return builder; + } + + public virtual string? GetCurrentDatabase(DbConnectionStringBuilder builder) => (string)builder[DatabaseKeyName]; + + public virtual DbConnectionStringBuilder ChangeDatabase(DbConnectionStringBuilder builder, string newDatabase) + { + var newBuilder = GetConnectionStringBuilder(builder.ConnectionString); + newBuilder[DatabaseKeyName] = newDatabase; + return newBuilder; + } + + public abstract IEnumerable ListDatabases(DbConnectionStringBuilder builder); + public abstract IEnumerable ListDatabases(DbConnection con); + + public async IAsyncEnumerable ListDatabasesAsync(DbConnectionStringBuilder builder, [EnumeratorCancellation] CancellationToken token) + { + //list the database on the server + await using var con = GetConnection(builder); + + //this will work or timeout + await con.OpenAsync(token); + + foreach (var db in ListDatabases(con)) + yield return db; + } + + public abstract DbConnectionStringBuilder EnableAsync(DbConnectionStringBuilder builder); + + public abstract IDiscoveredDatabaseHelper GetDatabaseHelper(); + public abstract IQuerySyntaxHelper GetQuerySyntaxHelper(); + + public abstract void CreateDatabase(DbConnectionStringBuilder builder, IHasRuntimeName newDatabaseName); + + public ManagedTransaction BeginTransaction(DbConnectionStringBuilder builder) + { + var con = GetConnection(builder); + con.Open(); + var transaction = con.BeginTransaction(); + + return new ManagedTransaction(con, transaction); + } + + public DatabaseType DatabaseType { get; private set; } = databaseType; + public abstract Dictionary DescribeServer(DbConnectionStringBuilder builder); + + public bool RespondsWithinTime(DbConnectionStringBuilder builder, int timeoutInSeconds, out Exception? exception) + { + try + { + var copyBuilder = GetConnectionStringBuilder(builder.ConnectionString); + copyBuilder[ConnectionTimeoutKeyName] = timeoutInSeconds; + + using var con = GetConnection(copyBuilder); + con.Open(); + + con.Close(); + + exception = null; + return true; + } + catch (Exception e) + { + exception = e; + return false; + } + } + + public abstract string? GetExplicitUsernameIfAny(DbConnectionStringBuilder builder); + public abstract string? GetExplicitPasswordIfAny(DbConnectionStringBuilder builder); + public abstract Version? GetVersion(DiscoveredServer server); + + private static readonly Regex RVagueVersion = RVagueVersionRe(); + + /// + /// Number of seconds to allow to run for before timing out. + /// Defaults to 30. + /// + public static int CreateDatabaseTimeoutInSeconds { get; set;} = 30; + + /// + /// Returns a new by parsing the . If the string + /// is a valid version the full version string is represented otherwise a regex match is used to find + /// numbers with dots separating them (e.g. 1.2.3 / 5.1 etc). + /// + /// + /// + protected static Version? CreateVersionFromString(string versionString) + { + if (Version.TryParse(versionString, out var result)) + return result; + + var m = RVagueVersion.Match(versionString); + return m.Success ? Version.Parse(m.Value) : + //whatever the string was it didn't even remotely resemble a Version + null; + } + + [GeneratedRegex(@"\d+\.\d+(\.\d+)?(\.\d+)?", RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex RVagueVersionRe(); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredStoredprocedure.cs b/FAnsi.Core/Discovery/DiscoveredStoredprocedure.cs new file mode 100644 index 00000000..6fea4515 --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredStoredprocedure.cs @@ -0,0 +1,9 @@ +namespace FAnsi.Discovery; + +/// +/// Cross database type reference to a stored procedure (function) on a database. +/// +public sealed class DiscoveredStoredprocedure(string name) +{ + public string Name { get; set; } = name; +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredTable.cs b/FAnsi.Core/Discovery/DiscoveredTable.cs new file mode 100644 index 00000000..abd83e05 --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredTable.cs @@ -0,0 +1,575 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using System.Threading; +using FAnsi.Connections; +using FAnsi.Discovery.Constraints; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Naming; +using TypeGuesser; + +namespace FAnsi.Discovery; + +/// +/// Cross database type reference to a Table (or view) in a Database. Use TableType to determine whether it is a view or a table. Allows you to check +/// existence, drop, add columns, get row counts etc. +/// +public class DiscoveredTable : IHasFullyQualifiedNameToo, IMightNotExist, IHasQuerySyntaxHelper, IEquatable +{ + protected string TableName; + + /// + /// Helper for generating queries compatible with the DBMS the table exists in (e.g. TOP X, column qualifiers, what the parameter symbol is etc). + /// + protected readonly IQuerySyntaxHelper QuerySyntaxHelper; + + /// + /// The database on which the table exists + /// + public readonly DiscoveredDatabase Database; + + /// + /// Stateless helper class with DBMS specific implementation of the logic required by . + /// + public readonly IDiscoveredTableHelper Helper; + + /// + /// Schema of the the table exists in (or null). This is NOT the database e.g. in [MyDb].[dbo].[MyTable] the schema is "dbo". + /// + /// Null if not supported by the DBMS (e.g. MySql) + /// + public readonly string? Schema; + + /// + /// Whether the table referenced is a normal table, view or table valued function (see derived class ) + /// + public readonly TableType TableType; + + /// + /// Internal API constructor intended for Implementation classes, instead use instead. + /// + /// + /// + /// + /// + /// + public DiscoveredTable(DiscoveredDatabase database, string table, IQuerySyntaxHelper querySyntaxHelper, string? schema = null, TableType tableType = TableType.Table) + { + TableName = table; + Helper = database.Helper.GetTableHelper(); + Database = database; + Schema = schema; + TableType = tableType; + + QuerySyntaxHelper = querySyntaxHelper; + + QuerySyntaxHelper.ValidateTableName(TableName); + } + + /// + /// Checks that the exists then lists the tables in the database to confirm this table exists on the server + /// + /// Optional - if set the connection to list tables will be sent on the connection on which the current + /// is open + /// + public virtual bool Exists(IManagedTransaction? transaction = null) + { + if (!Database.Exists()) + return false; + + return Database.DiscoverTables(TableType == TableType.View, transaction) + .Any(t => t.GetRuntimeName().Equals(GetRuntimeName(), StringComparison.InvariantCultureIgnoreCase)); + } + + /// + /// Returns the unqualified name of the table e.g. "MyTable" + /// + /// + public virtual string GetRuntimeName() => QuerySyntaxHelper.GetRuntimeName(TableName); + + /// + /// Returns the fully qualified (including schema if appropriate) name of the table e.g. [MyDb].dbo.[MyTable] or `MyDb`.`MyTable` + /// + /// + public virtual string GetFullyQualifiedName() => QuerySyntaxHelper.EnsureFullyQualified(Database.GetRuntimeName(), Schema, GetRuntimeName()); + + /// + /// Returns the wrapped e.g. "[MyTbl]" name of the table including escaping e.g. if you wanted to name a table "][nquisitor" (which would return "[]][nquisitor]"). Use to return the full name including table/database/schema. + /// + /// + public string GetWrappedName() => QuerySyntaxHelper.EnsureWrapped(GetRuntimeName()); + + /// + /// Connects to the server and returns a list of columns found in the table as . + /// + /// Optional - if set the connection to list tables will be sent on the connection on which the current + /// is open + /// + public DiscoveredColumn[] DiscoverColumns(IManagedTransaction? managedTransaction = null) + { + using var connection = Database.Server.GetManagedConnection(managedTransaction); + return Helper.DiscoverColumns(this, connection, Database.GetRuntimeName()).ToArray(); + } + + /// + /// Returns the table name + /// + /// + public override string ToString() => TableName; + + /// + /// Gets helper for generating queries compatible with the DBMS the table exists in (e.g. TOP X, column qualifiers, what the parameter symbol is etc). + /// + /// + public IQuerySyntaxHelper GetQuerySyntaxHelper() => QuerySyntaxHelper; + + /// + /// Returns from the on the server. This is not not case sensitive. Requires + /// connecting to the database. + /// + /// The column you want to find + /// Optional - if set the connection to list tables will be sent on the connection on which the current + /// is open + /// + public DiscoveredColumn DiscoverColumn(string specificColumnName, IManagedTransaction? transaction = null) + { + try + { + return DiscoverColumns(transaction).Single(c => c.GetRuntimeName().Equals(QuerySyntaxHelper.GetRuntimeName(specificColumnName), StringComparison.InvariantCultureIgnoreCase)); + } + catch (InvalidOperationException e) + { + throw new ColumnMappingException(string.Format( + FAnsiStrings.DiscoveredTable_DiscoverColumn_DiscoverColumn_failed__could_not_find_column_called___0___in_table___1__, specificColumnName, + TableName), e); + } + } + + /// + public string GetTopXSql(int topX) => Helper.GetTopXSqlForTable(this, topX); + + + /// + /// Returns up to 2,147,483,647 records from the table as a . + /// + /// The maximum number of records to return from the table + /// True to set constraints on the returned e.g. AllowDBNull based on the table + /// schema of the + /// Optional - if set the connection to fetch the data will be sent on the connection on which the current is open + /// + public DataTable GetDataTable(int topX = int.MaxValue, bool enforceTypesAndNullness = true, IManagedTransaction? transaction = null) => GetDataTable(new DatabaseOperationArgs { TransactionIfAny = transaction }, topX, enforceTypesAndNullness); + + public DataTable GetDataTable(DatabaseOperationArgs args, int topX = int.MaxValue, bool enforceTypesAndNullness = true) + { + var dt = new DataTable(); + + if (enforceTypesAndNullness) + foreach (var c in DiscoverColumns(args.TransactionIfAny)) + { + var col = dt.Columns.Add(c.GetRuntimeName()); + col.AllowDBNull = c.AllowNulls; + col.DataType = c.DataType.GetCSharpDataType(); + } + + Helper.FillDataTableWithTopX(args, this, topX, dt); + + return dt; + } + + /// + /// Drops (deletes) the table from the database. This is irreversible unless you have a database backup. + /// + public virtual void Drop() + { + using var connection = Database.Server.GetManagedConnection(); + Helper.DropTable(connection.Connection, this); + } + + public int GetRowCount(IManagedTransaction? transaction = null) => GetRowCount(new DatabaseOperationArgs { TransactionIfAny = transaction }); + + /// + /// Returns the estimated number of rows in the table. This may use a short cut e.g. consulting sys.partitions in Sql + /// Server (https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-partitions-transact-sql?view=sql-server-2017) + /// + /// Options for the operation e.g timeout, using existing connection etc + /// + public int GetRowCount(DatabaseOperationArgs args) => Helper.GetRowCount(args, this); + + /// + /// Returns true if there are no rows in the table + /// + /// Optional - if set the query will be sent on the connection on which the current is open + /// + public bool IsEmpty(IManagedTransaction? transaction = null) => IsEmpty(new DatabaseOperationArgs { TransactionIfAny = transaction }); + + /// + /// Returns true if there are no rows in the table + /// + /// + /// + public bool IsEmpty(DatabaseOperationArgs args) => Helper.IsEmpty(args, this); + + /// + /// Creates and runs an ALTER TABLE SQL statement that adds a new column to the table + /// + /// The unqualified name for the new column e.g. "MyCol2" + /// The data type for the new column + /// True to allow null + /// The length of time to wait in seconds before giving up (See ) + public void AddColumn(string name, DatabaseTypeRequest type, bool allowNulls, int timeoutInSeconds) + { + AddColumn(name, type, allowNulls, new DatabaseOperationArgs { TimeoutInSeconds = timeoutInSeconds }); + } + + /// + /// Creates and runs an ALTER TABLE SQL statement that adds a new column to the table + /// + /// The unqualified name for the new column e.g. "MyCol2" + /// The data type for the new column + /// True to allow null + /// + public void AddColumn(string name, DatabaseTypeRequest type, bool allowNulls, DatabaseOperationArgs args) + { + AddColumn(name, Database.Server.GetQuerySyntaxHelper().TypeTranslater.GetSQLDBTypeForCSharpType(type), allowNulls, args); + } + + /// + /// Creates and runs an ALTER TABLE SQL statement that adds a new column to the table + /// + /// The unqualified name for the new column e.g. "MyCol2" + /// The proprietary SQL data type for the new column + /// True to allow null + /// The length of time to wait in seconds before giving up (See ) + public void AddColumn(string name, string databaseType, bool allowNulls, int timeoutInSeconds) + { + AddColumn(name, databaseType, allowNulls, new DatabaseOperationArgs { TimeoutInSeconds = timeoutInSeconds }); + } + + public void AddColumn(string name, string databaseType, bool allowNulls, DatabaseOperationArgs args) + { + Helper.AddColumn(args, this, name, databaseType, allowNulls); + } + + /// + /// Creates and runs an ALTER TABLE SQL statement to drop the given column from the table + /// + /// The column to drop + public void DropColumn(DiscoveredColumn column) + { + using var connection = Database.Server.GetManagedConnection(); + Helper.DropColumn(connection.Connection, column); + } + + /// + /// Creates a new object for bulk inserting records into the table. You should use a using block since is . + /// Depending on implementation, records may not be committed to the server until the is disposed. + /// + /// Optional - records inserted should form part of the supplied ongoing transaction + /// + public IBulkCopy BeginBulkInsert(IManagedTransaction? transaction = null) => BeginBulkInsert(CultureInfo.CurrentCulture, transaction); + + /// + /// Creates a new object for bulk inserting records into the table. You should use a using block since is . + /// Depending on implementation, records may not be committed to the server until the is disposed. + /// + /// + /// Optional - records inserted should form part of the supplied ongoing transaction + /// + public IBulkCopy BeginBulkInsert(CultureInfo culture, IManagedTransaction? transaction = null) + { + Database.Server.EnableAsync(); + var connection = Database.Server.GetManagedConnection(transaction); + return Helper.BeginBulkInsert(this, connection, culture); + } + + /// + /// Creates and runs a TRUNCATE TABLE SQL statement to delete all rows from the table. Depending on DBMS and table constraints this might fail (e.g. if there are + /// foreign key constraints on the table). + /// + public void Truncate() + { + Helper.TruncateTable(this); + } + + /// + /// Deletes all EXACT duplicate rows from the table leaving only unique records. This is method may not be transaction/threadsafe + /// + /// The length of time to allow for the command to complete (See ) + public void MakeDistinct(int timeoutInSeconds = 30) + { + MakeDistinct(new DatabaseOperationArgs { TimeoutInSeconds = timeoutInSeconds }); + } + + /// + /// Deletes all EXACT duplicate rows from the table leaving only unique records. This is method may not be transaction/threadsafe + /// + /// Options for timeout, transaction etc + public void MakeDistinct(DatabaseOperationArgs args) + { + Helper.MakeDistinct(args, this); + } + + + /// + /// Scripts the table columns, optionally adjusting for nullability / identity etc. Optionally translates the SQL to run and create a table in a different + /// database / database language / table name + /// + /// Does not include foreign key constraints, dependant tables, CHECK constraints etc + /// + /// True if the resulting script should exclude any primary keys + /// True if the resulting script should always allow nulls into columns + /// True if the resulting script should replace identity columns with int in the generated SQL + /// Optional, If provided the SQL generated will be adjusted to create the alternate table instead (which could include going cross server type e.g. MySql to Sql Server) + /// When using this parameter the table must not exist yet, use destinationDiscoveredDatabase.ExpectTable("MyYetToExistTable") + /// + public string ScriptTableCreation(bool dropPrimaryKeys, bool dropNullability, bool convertIdentityToInt, DiscoveredTable? toCreateTable = null) => Helper.ScriptTableCreation(this, dropPrimaryKeys, dropNullability, convertIdentityToInt, toCreateTable); + + /// + /// Issues a database command to rename the table on the database server. + /// + /// + public void Rename(string newName) + { + using var connection = Database.Server.GetManagedConnection(); + Helper.RenameTable(this, newName, connection); + TableName = newName; + } + + /// + /// Creates a primary key on the table if none exists yet + /// + /// Columns that should become part of the primary key + public void CreatePrimaryKey(params DiscoveredColumn[] discoverColumns) + { + CreatePrimaryKey(new DatabaseOperationArgs(), discoverColumns); + } + + /// + /// Creates a primary key on the table if none exists yet + /// + /// The number of seconds to wait for the operation to complete + /// Columns that should become part of the primary key + public void CreatePrimaryKey(int timeoutInSeconds, params DiscoveredColumn[] discoverColumns) + { + CreatePrimaryKey(new DatabaseOperationArgs { TimeoutInSeconds = timeoutInSeconds }, discoverColumns); + } + + /// + /// Creates a primary key on the table if none exists yet + /// + /// Optional ongoing transaction to use (leave null if not needed) + /// Token for cancelling the command mid execution (leave null if not needed) + /// The number of seconds to wait for the operation to complete + /// Columns that should become part of the primary key + public void CreatePrimaryKey(IManagedTransaction? transaction, CancellationToken token, int timeoutInSeconds, params DiscoveredColumn[] discoverColumns) + { + Helper.CreatePrimaryKey(new DatabaseOperationArgs + { + TransactionIfAny = transaction, + CancellationToken = token, + TimeoutInSeconds = timeoutInSeconds + }, this, discoverColumns); + } + + /// + /// Creates an index on the table + /// + /// + /// + /// + public void CreateIndex(string indexName, DiscoveredColumn[] discoverColumns, bool isUnique = false) + { + CreateIndex(new DatabaseOperationArgs(), indexName, discoverColumns, isUnique); + } + + /// + /// Creates an index on the table + /// + /// + /// + /// + /// + public void CreateIndex(DatabaseOperationArgs args, string indexName, DiscoveredColumn[] discoverColumns, bool isUnique = false) + { + Helper.CreateIndex(args, this, indexName, discoverColumns, isUnique); + } + + /// + /// Drops the specified index from the discovered table + /// + /// + public void DropIndex(string indexName) + { + DropIndex(new DatabaseOperationArgs(), indexName); + } + + /// + /// Drops the specified index from the discovered table + /// + /// + /// + public void DropIndex(DatabaseOperationArgs args, string indexName) + { + Helper.DropIndex(args, this, indexName); + } + + public void CreatePrimaryKey(DatabaseOperationArgs args, params DiscoveredColumn[] discoverColumns) + { + Helper.CreatePrimaryKey(args, this, discoverColumns); + } + + /// + /// Inserts the values specified into the database table and returns the last autonum identity generated (or 0 if none present) + /// + /// + /// + /// + public int Insert(Dictionary toInsert, IManagedTransaction? transaction = null) => Insert(toInsert, null, transaction); + + /// + /// Inserts the values specified into the database table and returns the last autonum identity generated (or 0 if none present) + /// + /// + /// + /// + /// + public int Insert(Dictionary toInsert, CultureInfo? culture, IManagedTransaction? transaction = null) + { + var syntaxHelper = GetQuerySyntaxHelper(); + var server = Database.Server; + + var _parameterNames = syntaxHelper.GetParameterNamesFor(toInsert.Keys.ToArray(), static c => c.GetRuntimeName()); + + using var connection = Database.Server.GetManagedConnection(transaction); + var sql = + $"INSERT INTO {GetFullyQualifiedName()}({string.Join(",", toInsert.Keys.Select(c => syntaxHelper.EnsureWrapped(c.GetRuntimeName())))}) VALUES ({string.Join(",", toInsert.Keys.Select(c => _parameterNames[c]))})"; + + using var cmd = server.Helper.GetCommand(sql, connection.Connection, connection.Transaction); + foreach (var p in toInsert + .Select(kvp => new { kvp, parameter = server.Helper.GetParameter(_parameterNames[kvp.Key]) }) + .Select(t => + GetQuerySyntaxHelper().GetParameter(t.parameter, t.kvp.Key, t.kvp.Value, culture))) + cmd.Parameters.Add(p); + + return Helper.ExecuteInsertReturningIdentity(this, cmd, connection.ManagedTransaction); + } + + /// + /// Overload which will discover the columns by name for you. + /// + /// + /// ongoing transaction this insert should be part of + /// + public int Insert(Dictionary toInsert, IManagedTransaction? transaction = null) => Insert(toInsert, null, transaction); + + /// + /// Overload which will discover the columns by name for you. + /// + /// + /// + /// ongoing transaction this insert should be part of + /// + public int Insert(Dictionary toInsert, CultureInfo? culture, IManagedTransaction? transaction = null) + { + var cols = DiscoverColumns(transaction); + + var foundColumns = new Dictionary(); + + foreach (var k in toInsert.Keys) + { + var match = + cols.SingleOrDefault(c => c.GetRuntimeName().Equals(k, StringComparison.InvariantCultureIgnoreCase)) ?? + throw new ColumnMappingException(string.Format( + FAnsiStrings + .DiscoveredTable_Insert_Insert_failed__could_not_find_column_called___0___in_table___1__, k, + TableName)); + + foundColumns.Add(match, toInsert[k]); + } + + return Insert(foundColumns, culture, transaction); + } + /// + /// See + /// + public DbCommand GetCommand(string s, DbConnection con, DbTransaction? transaction = null) => Database.Server.Helper.GetCommand(s, con, transaction); + + /// + /// Returns all foreign keys where this table is the parent table (i.e. the primary key table). + /// + /// + /// + public DiscoveredRelationship[] DiscoverRelationships(IManagedTransaction? transaction = null) + { + using var connection = Database.Server.GetManagedConnection(transaction); + return Helper.DiscoverRelationships(this, connection.Connection, transaction).ToArray(); + } + + /// + /// Based on table name, schema, database and TableType + /// + /// + /// + public bool Equals(DiscoveredTable? other) + { + if (other is null) return false; + + return + string.Equals(TableName, other.TableName, StringComparison.OrdinalIgnoreCase) + && string.Equals(GetSchemaWithDefaultForNull(), other.GetSchemaWithDefaultForNull(), StringComparison.OrdinalIgnoreCase) + && Equals(Database, other.Database) && TableType == other.TableType; + } + + private string? GetSchemaWithDefaultForNull() => + //for "dbo, "" and null are all considered the same + string.IsNullOrWhiteSpace(Schema) ? GetQuerySyntaxHelper().GetDefaultSchemaIfAny() : Schema; + + /// + /// Based on table name, schema, database and TableType + /// + /// + /// + public override bool Equals(object? obj) + { + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + return Equals((DiscoveredTable)obj); + } + + /// + /// Based on table name, schema, database and TableType + /// + /// + public override int GetHashCode() + { + unchecked + { + var hashCode = StringComparer.OrdinalIgnoreCase.GetHashCode(GetSchemaWithDefaultForNull() ?? string.Empty); + hashCode = (hashCode * 397) ^ (Database != null ? Database.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (int)TableType; + return hashCode; + } + } + + public DiscoveredRelationship AddForeignKey(DiscoveredColumn foreignKey, DiscoveredColumn primaryKey, bool cascadeDeletes, string? constraintName = null, DatabaseOperationArgs? args = null) => AddForeignKey(new Dictionary { { foreignKey, primaryKey } }, cascadeDeletes, constraintName, args); + + /// + /// + /// + /// + /// Key is the foreign key column (and the table the constraint will be put on). + /// Value is the primary key table column (which the constraint reference points to) + /// + /// Specify an explicit name for the foreign key, leave null to pick one arbitrarily + /// Options for timeout, transaction etc + /// + public DiscoveredRelationship AddForeignKey(Dictionary foreignKeyPairs, + bool cascadeDeletes, string? constraintName = null, DatabaseOperationArgs? args = null) => + Helper.AddForeignKey(args ?? new DatabaseOperationArgs(), foreignKeyPairs, cascadeDeletes, constraintName); + +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredTableHelper.cs b/FAnsi.Core/Discovery/DiscoveredTableHelper.cs new file mode 100644 index 00000000..d9740a6d --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredTableHelper.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using FAnsi.Connections; +using FAnsi.Discovery.Constraints; +using FAnsi.Exceptions; +using FAnsi.Naming; + +namespace FAnsi.Discovery; + +/// +/// DBMS specific implementation of all functionality that relates to interacting with existing tables (altering, dropping, truncating etc). For table creation +/// see . +/// +public abstract class DiscoveredTableHelper : IDiscoveredTableHelper +{ + public abstract string GetTopXSqlForTable(IHasFullyQualifiedNameToo table, int topX); + + public abstract IEnumerable DiscoverColumns(DiscoveredTable discoveredTable, IManagedConnection connection, string database); + + public abstract IDiscoveredColumnHelper GetColumnHelper(); + public virtual void DropTable(DbConnection connection, DiscoveredTable tableToDrop) + { + var sql = tableToDrop.TableType switch + { + TableType.Table => "DROP TABLE {0}", + TableType.View => "DROP VIEW {0}", + TableType.TableValuedFunction => throw new NotSupportedException(), + _ => throw new ArgumentOutOfRangeException(nameof(tableToDrop), "Unknown TableType") + }; + + using var cmd = tableToDrop.GetCommand(string.Format(sql, tableToDrop.GetFullyQualifiedName()), connection); + cmd.ExecuteNonQuery(); + } + + public abstract void DropFunction(DbConnection connection, DiscoveredTableValuedFunction functionToDrop); + public abstract void DropColumn(DbConnection connection, DiscoveredColumn columnToDrop); + + public virtual void AddColumn(DatabaseOperationArgs args, DiscoveredTable table, string name, string dataType, bool allowNulls) + { + var syntax = table.GetQuerySyntaxHelper(); + + using var con = args.GetManagedConnection(table); + using var cmd = table.Database.Server.GetCommand( + $"ALTER TABLE {table.GetFullyQualifiedName()} ADD {syntax.EnsureWrapped(name)} {dataType} {(allowNulls ? "NULL" : "NOT NULL")}", con); + args.ExecuteNonQuery(cmd); + } + + public virtual int GetRowCount(DatabaseOperationArgs args, DiscoveredTable table) + { + using var connection = args.GetManagedConnection(table); + using var cmd = table.Database.Server.GetCommand($"SELECT count(*) FROM {table.GetFullyQualifiedName()}", connection); + return Convert.ToInt32(args.ExecuteScalar(cmd)); + } + + public abstract IEnumerable DiscoverTableValuedFunctionParameters(DbConnection connection, DiscoveredTableValuedFunction discoveredTableValuedFunction, DbTransaction? transaction); + + public abstract IBulkCopy BeginBulkInsert(DiscoveredTable discoveredTable, IManagedConnection connection, CultureInfo culture); + + public virtual void TruncateTable(DiscoveredTable discoveredTable) + { + var server = discoveredTable.Database.Server; + using var con = server.GetConnection(); + con.Open(); + using var cmd = server.GetCommand($"TRUNCATE TABLE {discoveredTable.GetFullyQualifiedName()}", con); + cmd.ExecuteNonQuery(); + } + + /// + public string ScriptTableCreation(DiscoveredTable table, bool dropPrimaryKeys, bool dropNullability, bool convertIdentityToInt, DiscoveredTable? toCreateTable = null) + { + var columns = new List(); + + foreach (var c in table.DiscoverColumns()) + { + var sqlType = c.DataType.SQLType; + + if (c.IsAutoIncrement && convertIdentityToInt) + sqlType = "int"; + + var isToDifferentDatabaseType = toCreateTable != null && toCreateTable.Database.Server.DatabaseType != table.Database.Server.DatabaseType; + + + //translate types + if (isToDifferentDatabaseType) + { + var fromtt = table.Database.Server.GetQuerySyntaxHelper().TypeTranslater; + var tott = toCreateTable?.Database.Server.GetQuerySyntaxHelper().TypeTranslater ?? throw new InvalidOperationException($"Unable to retrieve type translator for {toCreateTable}"); + + sqlType = fromtt.TranslateSQLDBType(c.DataType.SQLType, tott); + } + + var colRequest = new DatabaseColumnRequest(c.GetRuntimeName(), sqlType, c.AllowNulls || dropNullability) + { + IsPrimaryKey = c.IsPrimaryKey && !dropPrimaryKeys, + IsAutoIncrement = c.IsAutoIncrement && !convertIdentityToInt + }; + + colRequest.AllowNulls = colRequest.AllowNulls && !colRequest.IsAutoIncrement; + + //if there is a collation + if (!string.IsNullOrWhiteSpace(c.Collation) && (toCreateTable == null || !isToDifferentDatabaseType)) + //if the script is to be run on a database of the same type + //then specify that the column should use the live collation + colRequest.Collation = c.Collation; + + columns.Add(colRequest); + } + + var destinationTable = toCreateTable ?? table; + + var schema = toCreateTable != null ? toCreateTable.Schema : table.Schema; + + return table.Database.Helper.GetCreateTableSql(destinationTable.Database, destinationTable.GetRuntimeName(), [.. columns], null, false, schema); + } + + public virtual bool IsEmpty(DatabaseOperationArgs args, DiscoveredTable discoveredTable) => GetRowCount(args, discoveredTable) == 0; + + public virtual void RenameTable(DiscoveredTable discoveredTable, string newName, IManagedConnection connection) + { + if (discoveredTable.TableType != TableType.Table) + throw new NotSupportedException(string.Format(FAnsiStrings.DiscoveredTableHelper_RenameTable_Rename_is_not_supported_for_TableType__0_, discoveredTable.TableType)); + + discoveredTable.GetQuerySyntaxHelper().ValidateTableName(newName); + + using var cmd = discoveredTable.Database.Server.Helper.GetCommand(GetRenameTableSql(discoveredTable, newName), connection.Connection, connection.Transaction); + cmd.ExecuteNonQuery(); + } + + public virtual void CreateIndex(DatabaseOperationArgs args, DiscoveredTable table, string indexName, DiscoveredColumn[] columns, bool isUnique = false) + { + var syntax = table.GetQuerySyntaxHelper(); + + using var connection = args.GetManagedConnection(table); + try + { + var unique = isUnique ? "UNIQUE " : ""; + var columnNameList = string.Join(" , ", columns.Select(c => syntax.EnsureWrapped(c.GetRuntimeName()))); + var sql = + $"CREATE {unique}INDEX {indexName} ON {table.GetFullyQualifiedName()} ({columnNameList})"; + + using var cmd = table.Database.Server.Helper.GetCommand(sql, connection.Connection, connection.Transaction); + args.ExecuteNonQuery(cmd); + } + catch (Exception e) + { + throw new AlterFailedException(string.Format(FAnsiStrings.DiscoveredTableHelper_CreateIndex_Failed, table), e); + } + } + + public virtual void DropIndex(DatabaseOperationArgs args, DiscoveredTable table, string indexName) + { + using var connection = args.GetManagedConnection(table); + try + { + + var sql = + $"DROP INDEX {indexName} ON {table.GetFullyQualifiedName()}"; + + using var cmd = table.Database.Server.Helper.GetCommand(sql, connection.Connection, connection.Transaction); + args.ExecuteNonQuery(cmd); + } + catch (Exception e) + { + throw new AlterFailedException(string.Format(FAnsiStrings.DiscoveredTableHelper_DropIndex_Failed, table), e); + } + } + + public virtual void CreatePrimaryKey(DatabaseOperationArgs args, DiscoveredTable table, DiscoveredColumn[] discoverColumns) + { + var syntax = table.GetQuerySyntaxHelper(); + + using var connection = args.GetManagedConnection(table); + try + { + + var sql = + $"ALTER TABLE {table.GetFullyQualifiedName()} ADD PRIMARY KEY ({string.Join(",", discoverColumns.Select(c => syntax.EnsureWrapped(c.GetRuntimeName())))})"; + + using var cmd = table.Database.Server.Helper.GetCommand(sql, connection.Connection, connection.Transaction); + args.ExecuteNonQuery(cmd); + } + catch (Exception e) + { + throw new AlterFailedException(string.Format(FAnsiStrings.DiscoveredTableHelper_CreatePrimaryKey_Failed_to_create_primary_key_on_table__0__using_columns___1__, table, string.Join(",", discoverColumns.Select(static c => c.GetRuntimeName()))), e); + } + } + + public virtual int ExecuteInsertReturningIdentity(DiscoveredTable discoveredTable, DbCommand cmd, IManagedTransaction? transaction = null) + { + cmd.CommandText += ";SELECT @@IDENTITY"; + + var result = cmd.ExecuteScalar(); + + if (result == DBNull.Value || result == null) + return 0; + + return Convert.ToInt32(result); + } + + public abstract IEnumerable DiscoverRelationships(DiscoveredTable table, DbConnection connection, IManagedTransaction? transaction = null); + + public virtual void FillDataTableWithTopX(DatabaseOperationArgs args, DiscoveredTable table, int topX, DataTable dt) + { + var sql = GetTopXSqlForTable(table, topX); + + using var con = args.GetManagedConnection(table); + using var cmd = table.Database.Server.GetCommand(sql, con); + using var da = table.Database.Server.GetDataAdapter(cmd); + args.Fill(da, cmd, dt); + } + + /// + public virtual DiscoveredRelationship AddForeignKey(DatabaseOperationArgs args, Dictionary foreignKeyPairs, bool cascadeDeletes, string? constraintName = null) + { + var foreignTables = foreignKeyPairs.Select(static c => c.Key.Table).Distinct().ToArray(); + var primaryTables = foreignKeyPairs.Select(static c => c.Value.Table).Distinct().ToArray(); + + if (primaryTables.Length != 1 || foreignTables.Length != 1) + throw new ArgumentException("Primary and foreign keys must all belong to the same table", nameof(foreignKeyPairs)); + + + var primary = primaryTables[0]; + var foreign = foreignTables[0]; + + constraintName ??= primary.Database.Helper.GetForeignKeyConstraintNameFor(foreign, primary); + + var constraintBit = primary.Database.Helper.GetForeignKeyConstraintSql(foreign.GetRuntimeName(), primary.GetQuerySyntaxHelper(), + foreignKeyPairs + .ToDictionary(static k => (IHasRuntimeName)k.Key, static v => v.Value), cascadeDeletes, constraintName); + + var sql = $""" + ALTER TABLE {foreign.GetFullyQualifiedName()} + ADD {constraintBit} + """; + + using (var con = args.GetManagedConnection(primary)) + { + try + { + using var cmd = primary.Database.Server.GetCommand(sql, con); + args.ExecuteNonQuery(cmd); + } + catch (Exception e) + { + throw new AlterFailedException($"Failed to create relationship using SQL:{sql}", e); + } + } + + return primary.DiscoverRelationships(args.TransactionIfAny).Single( + r => r.Name.Equals(constraintName, StringComparison.CurrentCultureIgnoreCase) + ); + } + + protected abstract string GetRenameTableSql(DiscoveredTable discoveredTable, string newName); + + public virtual void MakeDistinct(DatabaseOperationArgs args, DiscoveredTable discoveredTable) + { + var server = discoveredTable.Database.Server; + + //if it's got a primary key then it's distinct! job done + if (discoveredTable.DiscoverColumns().Any(static c => c.IsPrimaryKey)) + return; + + var tableName = discoveredTable.GetFullyQualifiedName(); + var tempTable = discoveredTable.Database.ExpectTable($"{discoveredTable.GetRuntimeName()}_DistinctingTemp").GetFullyQualifiedName(); + + + using var con = args.TransactionIfAny == null + ? server.BeginNewTransactedConnection() + : //start a new transaction + args.GetManagedConnection(server); + using (var cmdDistinct = + server.GetCommand( + string.Format("CREATE TABLE {1} AS SELECT distinct * FROM {0}", tableName, tempTable), con)) + args.ExecuteNonQuery(cmdDistinct); + + //this is the point of no return so don't cancel after this point + using (var cmdTruncate = server.GetCommand($"DELETE FROM {tableName}", con)) + { + cmdTruncate.CommandTimeout = args.TimeoutInSeconds; + cmdTruncate.ExecuteNonQuery(); + } + + using (var cmdBack = server.GetCommand($"INSERT INTO {tableName} (SELECT * FROM {tempTable})", con)) + { + cmdBack.CommandTimeout = args.TimeoutInSeconds; + cmdBack.ExecuteNonQuery(); + } + + using (var cmdDropDistinctTable = server.GetCommand($"DROP TABLE {tempTable}", con)) + { + cmdDropDistinctTable.CommandTimeout = args.TimeoutInSeconds; + cmdDropDistinctTable.ExecuteNonQuery(); + } + + //if we opened a new transaction we should commit it + if (args.TransactionIfAny == null) + con.ManagedTransaction?.CommitAndCloseConnection(); + } + + public virtual bool RequiresLength(string columnType) => + columnType.ToLowerInvariant() switch + { + "binary" => true, + "bit" => false, + "char" => true, + "image" => true, + "nchar" => true, + "nvarchar" => true, + "varbinary" => true, + "varchar" => true, + "numeric" => true, + _ => false + }; + + public static bool HasPrecisionAndScale(string columnType) => + columnType.ToLowerInvariant() switch + { + "decimal" => true, + "numeric" => true, + _ => false + }; +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/DiscoveredTableValuedFunction.cs b/FAnsi.Core/Discovery/DiscoveredTableValuedFunction.cs new file mode 100644 index 00000000..6d4ba20b --- /dev/null +++ b/FAnsi.Core/Discovery/DiscoveredTableValuedFunction.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using FAnsi.Connections; +using FAnsi.Discovery.QuerySyntax; + +namespace FAnsi.Discovery; + +/// +/// Cross database type reference to a Table valued function in a Database (actually currently only supported by Microsoft Sql Server). For views see +/// DiscoveredTable +/// +public sealed class DiscoveredTableValuedFunction(DiscoveredDatabase database, + string functionName, + IQuerySyntaxHelper querySyntaxHelper, + string? schema = null) : DiscoveredTable(database, functionName, querySyntaxHelper, + schema, TableType.TableValuedFunction) +{ + public override bool Exists(IManagedTransaction? transaction = null) + { + return Database.DiscoverTableValuedFunctions(transaction).Any(f => f.GetRuntimeName().Equals(GetRuntimeName())); + } + + public override string GetRuntimeName() => QuerySyntaxHelper.GetRuntimeName(TableName); + + public override string GetFullyQualifiedName() + { + //This is pretty inefficient that we have to go discover these in order to tell you how to invoke the table! + var parameters = string.Join(",", DiscoverParameters().Select(static p => p.ParameterName)); + + //Note that we do not give the parameters values, the client must decide appropriate values and put them in correspondingly named variables + return $"{Database.GetRuntimeName()}..{GetRuntimeName()}({parameters})"; + } + + public override string ToString() => TableName; + + public override void Drop() + { + using var connection = Database.Server.GetManagedConnection(); + Helper.DropFunction(connection.Connection, this); + } + + public IEnumerable DiscoverParameters(ManagedTransaction? transaction = null) + { + using var connection = Database.Server.GetManagedConnection(transaction); + foreach (var param in Helper.DiscoverTableValuedFunctionParameters(connection.Connection, this, + connection.Transaction)) + yield return param; + } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/Discovery.cd b/FAnsi.Core/Discovery/Discovery.cd new file mode 100644 index 00000000..f5114fb2 --- /dev/null +++ b/FAnsi.Core/Discovery/Discovery.cd @@ -0,0 +1,114 @@ + + + + + + + + + ACJQAAAAAAAAAAAEgCgAEAwGECAKYIEAAQAABFFAIAA= + Discovery\DiscoveredServer.cs + + + + + + + + + + + + + CABAAAAAAIAQAEIEhgIAAAAAAIAAAMSAAAABAgEAAAA= + Discovery\DiscoveredDatabase.cs + + + + + + + + + + + + + + GABAAEgAAEASAgAFhCAAAAMEAIwIIICSAAAAAAFAhAA= + Discovery\DiscoveredTable.cs + + + + + + + + + + + + + + AARAIAAAAAIAAEAEgBAAQAAAABwAAICAAAAAAAACBAA= + Discovery\DiscoveredColumn.cs + + + + + + + + + + + + + + AAAAAAEEAAAAAAAEkAFAAAAAAAAAAIAAAIAACAEAAAA= + Discovery\DiscoveredDataType.cs + + + + + + + + + + + + AAAEAAAAAAAQAAAEAAAAABAAAAQAAACAAAAAAAEAAAA= + Discovery\DiscoveredTableValuedFunction.cs + + + + + + ACIQAIQBAAAAAACAACACAAAUAAICIAEACQAAAHBIAAA= + Discovery\IDiscoveredServerHelper.cs + + + + + + AAABAAAAAAAAAgIAFoAAAAAAAAAAAAEAAAABAACAAAA= + Discovery\IDiscoveredDatabaseHelper.cs + + + + + + GABAAAAEAAICAAAJAEEAEAYgAIgAIAAQAAAAAIAAAAA= + Discovery\IDiscoveredTableHelper.cs + + + + + + AAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAA= + Discovery\IDiscoveredColumnHelper.cs + + + + \ No newline at end of file diff --git a/FAnsi.Core/Discovery/IBulkCopy.cs b/FAnsi.Core/Discovery/IBulkCopy.cs new file mode 100644 index 00000000..de9e86c0 --- /dev/null +++ b/FAnsi.Core/Discovery/IBulkCopy.cs @@ -0,0 +1,38 @@ +using System; +using System.Data; +using TypeGuesser.Deciders; + +namespace FAnsi.Discovery; + +/// +/// Cross database type implementation of Bulk Insert. Each database API handles this differently, the differences are abstracted here through this +/// interface such that the programmer doesn't need to know what type of database he is uploading a DataTable to in order for it to still work. +/// +public interface IBulkCopy : IDisposable +{ + /// + /// Upload all rows in the to the destination table. + /// + /// + /// number of rows written or number of rows affected (may not match row count if triggers etc are abound) + int Upload(DataTable dt); + + /// + /// The timeout in seconds for each call to (implementation depends on derrived classes). + /// + int Timeout { get; set; } + + /// + /// Notifies the that the table schema has been changed mid insert! e.g. a column changing data type. This change must have taken place on the same + /// DbTransaction as the bulkc copy. + /// + void InvalidateTableSchema(); + + + /// + /// Determines how strings are parsed into objects (including culture). This is used whenever the target database column + /// is a date type and the data in the column is of string type. + /// + DateTimeTypeDecider DateTimeDecider{get;} + +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/IDiscoveredColumnHelper.cs b/FAnsi.Core/Discovery/IDiscoveredColumnHelper.cs new file mode 100644 index 00000000..8f3a02f2 --- /dev/null +++ b/FAnsi.Core/Discovery/IDiscoveredColumnHelper.cs @@ -0,0 +1,12 @@ +using FAnsi.Naming; + +namespace FAnsi.Discovery; + +/// +/// Contains all the DatabaseType specific implementation logic required by DiscoveredColumn. +/// +public interface IDiscoveredColumnHelper +{ + string GetTopXSqlForColumn(IHasRuntimeName database, IHasFullyQualifiedNameToo table, IHasRuntimeName column, int topX, bool discardNulls); + string GetAlterColumnToSql(DiscoveredColumn column, string newType, bool allowNulls); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/IDiscoveredDatabaseHelper.cs b/FAnsi.Core/Discovery/IDiscoveredDatabaseHelper.cs new file mode 100644 index 00000000..ec8df6f9 --- /dev/null +++ b/FAnsi.Core/Discovery/IDiscoveredDatabaseHelper.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.IO; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.TableCreation; +using FAnsi.Naming; + +namespace FAnsi.Discovery; + +/// +/// Contains all the DatabaseType specific implementation logic required by DiscoveredDatabase. +/// +public interface IDiscoveredDatabaseHelper +{ + IEnumerable ListTables(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, DbConnection connection, string database, bool includeViews, DbTransaction? transaction = null); + IEnumerable ListTableValuedFunctions(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, DbConnection connection, string database, DbTransaction? transaction = null); + + IEnumerable ListStoredprocedures(DbConnectionStringBuilder builder, string database); + + IDiscoveredTableHelper GetTableHelper(); + void DropDatabase(DiscoveredDatabase database); + + Dictionary DescribeDatabase(DbConnectionStringBuilder builder, string database); + + DiscoveredTable CreateTable(CreateTableArgs args); + + string GetCreateTableSql(DiscoveredDatabase database, string tableName, DatabaseColumnRequest[] columns, + Dictionary? foreignKeyPairs, bool cascadeDelete, string? schema = null); + + /// + /// Generates foreign key creation SQL such that it can be slotted into either a CREATE TABLE statement OR a ALTER TABLE statement + /// + /// The foreign table on which to declare the constraint + /// The language to use e.g. for wrapping entity names + /// The columns to match up, key must be either or . + /// + /// Key is the foreign key column (and the table the constraint will be put on). + /// Value is the primary key table column (which the constraint reference points to) + /// + /// True to add the on delete cascade rules + /// The name of the new constraint to create or null to use default + /// + string GetForeignKeyConstraintSql(string foreignTable, IQuerySyntaxHelper syntaxHelper, + Dictionary foreignKeyPairs, bool cascadeDelete, string? constraintName = null); + + DirectoryInfo? Detach(DiscoveredDatabase database); + void CreateBackup(DiscoveredDatabase discoveredDatabase, string backupName); + + /// + /// Gets a sensible name for a foreign key constraint between the two tables + /// + /// + /// + /// + string GetForeignKeyConstraintNameFor(DiscoveredTable foreignTable, DiscoveredTable primaryTable); + + /// + /// Throws an if the contains with + /// the of + /// + /// + void ThrowIfObjectColumns(DataTable dt); + + /// + /// Creates a new schema within the database if the DBMS supports it (Sql Server does, MySql doesn't) and it does not already exist. Schema + /// is a layer below server and database but above table it groups tables within a single database. + /// + /// + /// Database to create schema in + /// + /// + void CreateSchema(DiscoveredDatabase discoveredDatabase, string name); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/IDiscoveredServerHelper.cs b/FAnsi.Core/Discovery/IDiscoveredServerHelper.cs new file mode 100644 index 00000000..a2703de9 --- /dev/null +++ b/FAnsi.Core/Discovery/IDiscoveredServerHelper.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using FAnsi.Connections; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Naming; + +namespace FAnsi.Discovery; + +/// +/// Contains all the DatabaseType specific implementation logic required by DiscoveredServer. +/// +public interface IDiscoveredServerHelper +{ + /// + DbCommand GetCommand(string s, DbConnection con, DbTransaction? transaction = null); + + DbDataAdapter GetDataAdapter(DbCommand cmd); + DbCommandBuilder GetCommandBuilder(DbCommand cmd); + DbParameter GetParameter(string parameterName); + DbConnection GetConnection(DbConnectionStringBuilder builder); + + DbConnectionStringBuilder GetConnectionStringBuilder(string? connectionString); + + /// + /// Returns a new connection string builder with the supplied parameters. Note that if a concept is not supported in the + /// implementation then the value will not appear in the connection string (e.g. Oracle + /// does not support specifying a to connect to). + /// + /// The server/datasource to connect to e.g. "localhost\sqlexpress" + /// Optional database to connect to e.g. "master" + /// Optional username to set in connection string (otherwise integrated security will be used - if supported) + /// Optional password to set in connection string (otherwise integrated security will be used - if supported) + /// + DbConnectionStringBuilder GetConnectionStringBuilder(string server, string? database, string username, string password); + + string? GetServerName(DbConnectionStringBuilder builder); + DbConnectionStringBuilder ChangeServer(DbConnectionStringBuilder builder, string newServer); + + string? GetCurrentDatabase(DbConnectionStringBuilder builder); + DbConnectionStringBuilder ChangeDatabase(DbConnectionStringBuilder builder, string newDatabase); + + DbConnectionStringBuilder EnableAsync(DbConnectionStringBuilder builder); + + IEnumerable ListDatabases(DbConnectionStringBuilder builder); + IEnumerable ListDatabases(DbConnection con); + IAsyncEnumerable ListDatabasesAsync(DbConnectionStringBuilder builder, CancellationToken token); + + IDiscoveredDatabaseHelper GetDatabaseHelper(); + IQuerySyntaxHelper GetQuerySyntaxHelper(); + + void CreateDatabase(DbConnectionStringBuilder builder, IHasRuntimeName newDatabaseName); + + ManagedTransaction BeginTransaction(DbConnectionStringBuilder builder); + DatabaseType DatabaseType { get; } + Dictionary DescribeServer(DbConnectionStringBuilder builder); + bool RespondsWithinTime(DbConnectionStringBuilder builder, int timeoutInSeconds, out Exception? exception); + + string? GetExplicitUsernameIfAny(DbConnectionStringBuilder builder); + string? GetExplicitPasswordIfAny(DbConnectionStringBuilder builder); + Version? GetVersion(DiscoveredServer server); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/IDiscoveredTableHelper.cs b/FAnsi.Core/Discovery/IDiscoveredTableHelper.cs new file mode 100644 index 00000000..5dabb0ac --- /dev/null +++ b/FAnsi.Core/Discovery/IDiscoveredTableHelper.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using FAnsi.Connections; +using FAnsi.Discovery.Constraints; +using FAnsi.Naming; + +namespace FAnsi.Discovery; + +/// +/// Contains all the DatabaseType specific implementation logic required by DiscoveredTable. +/// +public interface IDiscoveredTableHelper +{ + /// + /// The table to fetch records from + string GetTopXSqlForTable(IHasFullyQualifiedNameToo table, int topX); + + IEnumerable DiscoverColumns(DiscoveredTable discoveredTable, IManagedConnection connection, string database); + + IDiscoveredColumnHelper GetColumnHelper(); + + void DropTable(DbConnection connection, DiscoveredTable tableToDrop); + void DropFunction(DbConnection connection, DiscoveredTableValuedFunction functionToDrop); + void DropColumn(DbConnection connection, DiscoveredColumn columnToDrop); + + void AddColumn(DatabaseOperationArgs args,DiscoveredTable table, string name, string dataType, bool allowNulls); + + int GetRowCount(DatabaseOperationArgs args, DiscoveredTable table); + + IEnumerable DiscoverTableValuedFunctionParameters(DbConnection connection, DiscoveredTableValuedFunction discoveredTableValuedFunction, DbTransaction? transaction); + + IBulkCopy BeginBulkInsert(DiscoveredTable discoveredTable, IManagedConnection connection,CultureInfo culture); + + void TruncateTable(DiscoveredTable discoveredTable); + void MakeDistinct(DatabaseOperationArgs args,DiscoveredTable discoveredTable); + + /// + string ScriptTableCreation(DiscoveredTable constraints, bool dropPrimaryKeys, bool dropNullability, bool convertIdentityToInt, DiscoveredTable? toCreateTable = null); + bool IsEmpty(DatabaseOperationArgs args, DiscoveredTable discoveredTable); + void RenameTable(DiscoveredTable discoveredTable, string newName, IManagedConnection connection); + + void CreateIndex(DatabaseOperationArgs args, DiscoveredTable table, string indexName, DiscoveredColumn[] columns, bool unique = false); + void DropIndex(DatabaseOperationArgs args, DiscoveredTable table, string indexName); + void CreatePrimaryKey(DatabaseOperationArgs args, DiscoveredTable columns, DiscoveredColumn[] discoverColumns); + int ExecuteInsertReturningIdentity(DiscoveredTable discoveredTable, DbCommand cmd, IManagedTransaction? transaction=null); + IEnumerable DiscoverRelationships(DiscoveredTable discoveredTable,DbConnection connection, IManagedTransaction? transaction = null); + void FillDataTableWithTopX(DatabaseOperationArgs args,DiscoveredTable table, int topX, DataTable dt); + + + /// + /// Creates a new primary key relationship in a foreign key table that points to a primary key table (which must have a primary key) + /// + /// + /// Columns to join up. + /// Key is the foreign key column (and the table the constraint will be put on). + /// Value is the primary key table column (which the constraint reference points to) + /// + /// + /// The name to give the foreign key constraint created, if null then a default name will be picked e.g. FK_Tbl1_Tbl2 + /// Options for timeout, transaction etc + /// + DiscoveredRelationship AddForeignKey(DatabaseOperationArgs args, Dictionary foreignKeyPairs, bool cascadeDeletes,string? constraintName =null); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/IMightNotExist.cs b/FAnsi.Core/Discovery/IMightNotExist.cs new file mode 100644 index 00000000..f7bd64ba --- /dev/null +++ b/FAnsi.Core/Discovery/IMightNotExist.cs @@ -0,0 +1,19 @@ +using FAnsi.Connections; + +namespace FAnsi.Discovery; + +/// +/// Describes a database object that might not exist. You can use methods that have keyword 'Expect' (e.g. DiscoveredServer.ExpectDatabase("bob")) to return +/// an object (DiscoveredDatabase in the example) without first checking that they exist. Call IMightNotExist.Exists to confirm whether it still exists. +/// +/// The opposite approach is to use 'Discover' methods e.g. DiscoveredServer.DiscoverDatabases() to return all the DiscoveredDatabases found on the server. +/// +public interface IMightNotExist +{ + /// + /// Returns true if the object can be reached (e.g. connected to). + /// + /// + /// + bool Exists(IManagedTransaction? transaction = null); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/ISupplementalColumnInformation.cs b/FAnsi.Core/Discovery/ISupplementalColumnInformation.cs new file mode 100644 index 00000000..47bca0b2 --- /dev/null +++ b/FAnsi.Core/Discovery/ISupplementalColumnInformation.cs @@ -0,0 +1,23 @@ +namespace FAnsi.Discovery; + +/// +/// Interface for all objects which describe a column e.g. and record/request relevant DDL level flags +/// e.g. . +/// +public interface ISupplementalColumnInformation +{ + /// + /// Records whether the column in the underlying database table this record points at is part of the primary key or not. + /// + bool IsPrimaryKey { get; set; } + + /// + /// Records whether the column in the underlying database table this record points at is an anto increment identity column + /// + bool IsAutoIncrement { get; set; } + + /// + /// Records the collation of the column in the underlying database table this record points at if explicitly declared by dbms (only applicable for char datatypes) + /// + string? Collation { get; set; } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AggregateCustomLineCollection.cs b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AggregateCustomLineCollection.cs new file mode 100644 index 00000000..73d13341 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AggregateCustomLineCollection.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FAnsi.Discovery.QuerySyntax.Aggregation; + +/// +/// A collection of which together make a GROUP BY with an optional Calendar table column +/// and optional dynamic pivot column +/// +public sealed class AggregateCustomLineCollection +{ + public List Lines { get; set; } + public IQueryAxis? Axis { get; set; } + public IQuerySyntaxHelper SyntaxHelper { get; } + + public AggregateCustomLineCollection(List queryLines, IQueryAxis? axisIfAny, IQuerySyntaxHelper querySyntaxHelper) + { + Lines = queryLines; + Axis = axisIfAny; + SyntaxHelper = querySyntaxHelper; + + Validate(); + } + + private void Validate() + { + //if we have any axis bits + if (Axis == null && AxisSelect == null && AxisGroupBy == null) return; + //we must have all the axis bits + if(AxisSelect == null || AxisGroupBy is null or null) + throw new AggregateCustomLineCollectionException(FAnsiStrings.AggregateCustomLineCollection_Validate_AggregateCustomLineCollection_is_missing_some__but_not_all__Axis_components); + } + + /// + /// The single aggregate function line e.g. "count(distinct chi) as Fish," + /// + public CustomLine? CountSelect => Lines.SingleOrDefault(static l => l.Role == CustomLineRole.CountFunction && l.LocationToInsert == QueryComponent.QueryTimeColumn); + + /// + /// The (optional) single line of SELECT SQL which is the Axis join column e.g. "[MyDb]..[mytbl].[AdmissionDate] as Admt," + /// + public CustomLine? AxisSelect => Lines.SingleOrDefault(static l => l.Role == CustomLineRole.Axis && l.LocationToInsert == QueryComponent.QueryTimeColumn); + + + /// + /// The (optional) single line of GROUP BY SQL which matches exactly the SQL of + /// + public CustomLine? AxisGroupBy => Lines.SingleOrDefault(static l => l.LocationToInsert == QueryComponent.GroupBy && l.Role == CustomLineRole.Axis); + + /// + /// The (optional) single line of SELECT SQL which is the dynamic pivot column e.g. "[MyDb]..[mytbl].[Healthboard] as hb," + /// + public CustomLine? PivotSelect => Lines.SingleOrDefault(static l => l.Role == CustomLineRole.Pivot && l.LocationToInsert == QueryComponent.QueryTimeColumn); + + + /// + /// The (optional) single line of ORDER BY SQL which restricts which records are returned when doing a dynamic pivot e.g. only dynamic pivot on the + /// top 5 drugs ordered by SUM of prescriptions + /// + public CustomLine? TopXOrderBy => Lines.SingleOrDefault(static l => l.LocationToInsert == QueryComponent.OrderBy && l.Role == CustomLineRole.TopX); + + /// + /// Returns all concatenated SQL for all between the inclusive boundaries from/to + /// + /// inclusive start section from which to return rows + /// inclusive end section from which to return rows + public string GetLines(QueryComponent from, QueryComponent to) + { + return string.Join(Environment.NewLine, + Lines.Where(c => c.LocationToInsert >= from && c.LocationToInsert <= to)); + } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AggregateCustomLineCollectionException.cs b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AggregateCustomLineCollectionException.cs new file mode 100644 index 00000000..974b9745 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AggregateCustomLineCollectionException.cs @@ -0,0 +1,9 @@ +using System; + +namespace FAnsi.Discovery.QuerySyntax.Aggregation; + +/// +/// Thrown when a is created in an illegal state (e.g. an axis is defined but no corresponding +/// SELECT / GROUP by are provided. +/// +public sealed class AggregateCustomLineCollectionException(string msg) : Exception(msg); \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AggregateHelper.cs b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AggregateHelper.cs new file mode 100644 index 00000000..fa2de438 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AggregateHelper.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FAnsi.Discovery.QuerySyntax.Aggregation; + +public abstract class AggregateHelper:IAggregateHelper +{ + public string BuildAggregate(List queryLines, IQueryAxis? axisIfAny) + { + var lines = new AggregateCustomLineCollection(queryLines, axisIfAny, GetQuerySyntaxHelper()); + + //no axis no pivot + if (lines.AxisSelect == null && lines.PivotSelect == null) + return BuildBasicAggregate(lines); + + //axis (no pivot) + if (lines.PivotSelect == null) + return BuildAxisAggregate(lines); + + //pivot (no axis) + if (lines.AxisSelect == null) + return BuildPivotOnlyAggregate(lines,GetPivotOnlyNonPivotColumn(lines)); + + //pivot and axis + return BuildPivotAndAxisAggregate(lines); + } + + private static CustomLine GetPivotOnlyNonPivotColumn(AggregateCustomLineCollection query) + { + var nonPivotColumn = query.Lines.Where(static l => l.LocationToInsert == QueryComponent.QueryTimeColumn && l.Role == CustomLineRole.None).ToArray(); + if(nonPivotColumn.Length != 1) + throw new Exception("Pivot is only valid when there are 3 SELECT columns, an aggregate (e.g. count(*)), a pivot and a final column"); + + return nonPivotColumn[0]; + } + + protected abstract IQuerySyntaxHelper GetQuerySyntaxHelper(); + + protected static string BuildBasicAggregate(AggregateCustomLineCollection query) => string.Join(Environment.NewLine, query.Lines); + + /// + /// Builds an SQL GROUP BY query in from the lines in where records are counted and put into + /// buckets according to the interval defined in based on the date SQL + /// (usually a column name) in + /// + /// + /// + protected abstract string BuildAxisAggregate(AggregateCustomLineCollection query); + + protected abstract string BuildPivotOnlyAggregate(AggregateCustomLineCollection query,CustomLine nonPivotColumn); + + protected abstract string BuildPivotAndAxisAggregate(AggregateCustomLineCollection query); + + + /// + /// Changes the axis column in the GROUP BY section of the query (e.g. "[MyDb]..[mytbl].[AdmissionDate],") and + /// the axis column in the SELECT section of the query (e.g. "[MyDb]..[mytbl].[AdmissionDate] as Admt,") with + /// the appropriate axis increment (e.g. "YEAR([MyDb]..[mytbl].[AdmissionDate])," and "YEAR([MyDb]..[mytbl].[AdmissionDate]) as Admt,") + /// + /// + /// + protected void WrapAxisColumnWithDatePartFunction(AggregateCustomLineCollection query, string axisColumnAlias) + { + if(string.IsNullOrWhiteSpace(axisColumnAlias)) + throw new ArgumentNullException(nameof(axisColumnAlias)); + + var axisGroupBy = query.AxisGroupBy; + var axisColumnWithoutAlias = query.AxisSelect.GetTextWithoutAlias(query.SyntaxHelper); + + var axisColumnEndedWithComma = query.AxisSelect.Text.EndsWith(','); + query.AxisSelect.Text = + $"{GetDatePartOfColumn(query.Axis?.AxisIncrement ?? throw new InvalidOperationException("No axis in query"), axisColumnWithoutAlias)} AS {axisColumnAlias}{(axisColumnEndedWithComma ? "," : "")}"; + + var groupByEndedWithComma = axisGroupBy.Text.EndsWith(','); + axisGroupBy.Text = GetDatePartOfColumn(query.Axis.AxisIncrement, axisColumnWithoutAlias) + (groupByEndedWithComma ? "," : ""); + } + + public abstract string GetDatePartOfColumn(AxisIncrement increment, string columnSql); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AxisIncrement.cs b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AxisIncrement.cs new file mode 100644 index 00000000..11304aa4 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/AxisIncrement.cs @@ -0,0 +1,27 @@ +namespace FAnsi.Discovery.QuerySyntax.Aggregation; + +/// +/// Describes a date/time axis granularity. +/// +public enum AxisIncrement +{ + /// + /// field should contain values expressed down to the individual day + /// + Day = 1, + + /// + /// field should contain values expressed down to the individual month + /// + Month = 2, + + /// + /// field should contain values expressed down to the individual year + /// + Year = 3, + + /// + /// field should contain values expressed down to the individual quarter + /// + Quarter=4 +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/Aggregation/IAggregateHelper.cs b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/IAggregateHelper.cs new file mode 100644 index 00000000..7a9e047a --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/IAggregateHelper.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace FAnsi.Discovery.QuerySyntax.Aggregation; + +/// +/// Cross Database Type class for turning a collection of arbitrary sql lines (CustomLine) into a Group by query. The query can include an axis calendar +/// table and can include a dynamic pivot. See AggregateDataBasedTests for expected inputs/outputs. +/// +/// Because building a dynamic pivot / calendar table for a group by is so different in each DatabaseType the input is basically just a collection of strings +/// with roles and it is up to the implementation to resolve them into something that will run. The basic case (no axis and no pivot) should be achievable +/// just by concatenating the CustomLines. +/// +public interface IAggregateHelper +{ + /// + /// Returns an SQL statement that can be run on the DBMS being implemented that runs a GROUP BY query. Optionally this query must join to a calendar table + /// defined by . Optionally this query must include a dynamic pivot column. + /// + /// + /// + /// + string BuildAggregate(List queryLines, IQueryAxis? axisIfAny); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/Aggregation/IQueryAxis.cs b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/IQueryAxis.cs new file mode 100644 index 00000000..02058234 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/IQueryAxis.cs @@ -0,0 +1,15 @@ +namespace FAnsi.Discovery.QuerySyntax.Aggregation; + +/// +/// Describes the requirement for a Calendar Table in a Group By query. The calendar should go between the two dates in increments of the AxisIncrement. +/// Records returned by the Group By query should be grouped by the Calendar table. +/// +/// A Calendar Table ensures a consistent axis in the DataTable returned by the sql query (avoids skipping months/years where there are no dates in the +/// data set being queried). Implementation logic for Calendar Tables varies wildly depending on database engine (See IAggregateHelper). +/// +public interface IQueryAxis +{ + string? EndDate { get; } + string? StartDate { get; } + AxisIncrement AxisIncrement { get; } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/Aggregation/QueryAxis.cs b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/QueryAxis.cs new file mode 100644 index 00000000..865b80c4 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/Aggregation/QueryAxis.cs @@ -0,0 +1,12 @@ +namespace FAnsi.Discovery.QuerySyntax.Aggregation; + +/// +public sealed class QueryAxis : IQueryAxis +{ + /// + public string? EndDate { get; set; } + /// + public string? StartDate { get; set; } + /// + public AxisIncrement AxisIncrement { get; set; } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/CustomLine.cs b/FAnsi.Core/Discovery/QuerySyntax/CustomLine.cs new file mode 100644 index 00000000..97aa0d04 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/CustomLine.cs @@ -0,0 +1,50 @@ +using System; + +namespace FAnsi.Discovery.QuerySyntax; + +/// +/// An arbitrary string to be injected into an SQL query being built by an ISqlQueryBuilder. This is needed to handle differences in Database Query Engine Implementations +/// e.g. Top X is done as part of SELECT in Microsoft Sql Server (e.g. select top x * from bob) while in MySql it is done as part of Postfix (e.g. select * from bob LIMIT 1) +/// (See IQuerySyntaxHelper.HowDoWeAchieveTopX). +/// +/// Each CustomLine must have an QueryComponent of the Query that it relates to (LocationToInsert) and may have a CustomLineRole. +/// +/// AggregateBuilder relies heavily on CustomLine because of the complexity of cross database platform GROUP BY (e.g. dynamic pivot with calendar table). Basically converting +/// the entire query into CustomLines and passing off implementation to the specific database engine (See IAggregateHelper.BuildAggregate). +/// +public sealed class CustomLine(string text, QueryComponent locationToInsert) +{ + public string Text { get; set; } = string.IsNullOrWhiteSpace(text) ? text : text.Trim(); + public QueryComponent LocationToInsert { get; set; } = locationToInsert; + + public CustomLineRole Role { get; set; } + + /// + /// The line of code that caused the CustomLine to be created, this can be a StackTrace passed into the constructor or calculated automatically by CustomLine + /// + public string StackTrace { get; private set; } = Environment.StackTrace; + + public override string ToString() => Text; + + /// + /// Returns the section of which does not include any alias e.g. returns "UPPER('a')" from "UPPER('a') as a" + /// + /// + /// + public string GetTextWithoutAlias(IQuerySyntaxHelper syntaxHelper) + { + syntaxHelper.SplitLineIntoSelectSQLAndAlias(Text, out var withoutAlias, out _); + return withoutAlias; + } + + /// + /// Returns the alias section of e.g. returns "a" from "UPPER('a') as a" + /// + /// + /// + public string? GetAliasFromText(IQuerySyntaxHelper syntaxHelper) + { + syntaxHelper.SplitLineIntoSelectSQLAndAlias(Text, out _, out var alias); + return string.IsNullOrWhiteSpace(alias) ? null : alias; + } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/CustomLineRole.cs b/FAnsi.Core/Discovery/QuerySyntax/CustomLineRole.cs new file mode 100644 index 00000000..a2c89772 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/CustomLineRole.cs @@ -0,0 +1,10 @@ +namespace FAnsi.Discovery.QuerySyntax; + +public enum CustomLineRole +{ + None = 0, + Axis, + Pivot, + TopX, + CountFunction //count(*) column or group by count(*) or sum(mycol) or anything that is symantically an SQL aggregate function +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/IHasQuerySyntaxHelper.cs b/FAnsi.Core/Discovery/QuerySyntax/IHasQuerySyntaxHelper.cs new file mode 100644 index 00000000..5a2940b6 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/IHasQuerySyntaxHelper.cs @@ -0,0 +1,17 @@ +namespace FAnsi.Discovery.QuerySyntax; + +/// +/// Object which works with a known DatabaseType and therefore has an associated IQuerySyntaxHelper (e.g. DatabaseType.MicrosoftSQLServer and +/// MicrosoftQuerySyntaxHelper). +/// +/// When implementing this class you most likely want to start with 'new QuerySyntaxHelperFactory().Create(DatabaseType);' +/// +public interface IHasQuerySyntaxHelper +{ + /// + /// Returns a of the correct depending on what remote database the declaring + /// class is pointed at. + /// + /// + IQuerySyntaxHelper GetQuerySyntaxHelper(); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/IQuerySyntaxHelper.cs b/FAnsi.Core/Discovery/QuerySyntax/IQuerySyntaxHelper.cs new file mode 100644 index 00000000..c97cfc98 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/IQuerySyntaxHelper.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using FAnsi.Discovery.QuerySyntax.Aggregation; +using FAnsi.Discovery.QuerySyntax.Update; +using FAnsi.Discovery.TypeTranslation; +using TypeGuesser; + +namespace FAnsi.Discovery.QuerySyntax; + +/// +/// Cross database type functionality for helping build SQL commands that will work regardless of DatabaseType (Microsoft Sql Server / MySql etc). Describes +/// how to translate broad requirements like 'database type capable of storing strings up to 10 characters long' into a specific implementation e.g. +/// 'varchar(10)' in Microsoft SQL Server and 'varchar2(10)' in Oracle (See ITypeTranslater). +/// +/// Also includes features such as qualifying database entities [MyDatabase]..[MyTable].[MyColumn] in Sql Server vs `MyDatabase`.`MyTable`.`MyColumn` in +/// MySql. +/// +/// Also includes methods for dealing with no n Ansi standard functionality e.g. TOP X , MD5 etc +/// +/// +public interface IQuerySyntaxHelper +{ + ITypeTranslater TypeTranslater { get; } + + /// + /// Creates parameters names from the column names in the collection. Use this for INSERT etc commands to avoid SQL injection and handle creepy column + /// names with spaces or reserved keywords + /// + /// + /// Function to convert the to a string e.g. c.ColumnName if DataColumn + /// + Dictionary GetParameterNamesFor(T[] columns, Func toStringFunc) where T : notnull; + + IAggregateHelper AggregateHelper { get; } + IUpdateHelper UpdateHelper { get; set; } + + + /// + /// The character that is used to qualify database entity names e.g. "[" for "[My Table]" + /// + string OpenQualifier { get; } + /// + /// The character that is used to end qualifying database entity names e.g. "]" for "[My Table]". For some DBMS this is the same as + /// + string CloseQualifier { get; } + + /// + /// Separator between table and column names (and database, schema etc). Usually "." + /// + string DatabaseTableSeparator { get; } + + /// + /// Characters which are not permitted in column names by FAnsi + /// + char[] IllegalNameChars { get; } + + char ParameterSymbol { get; } + + [return: NotNullIfNotNull(nameof(s))] + string? GetRuntimeName(string? s); + + bool TryGetRuntimeName(string s, out string? name); + + DatabaseType DatabaseType { get; } + + /// + /// True if the DBMS supports SQL declared parameters (e.g. "DECLARE @bob varchar(10)") whose values can be changed in SQL. False if the only way to + /// get parameters in SQL is by injecting them at the application level e.g. . + /// + /// + bool SupportsEmbeddedParameters(); + + /// + /// Ensures that the supplied single entity object e.g. "mytable" , "mydatabase, "[mydatabase]", "`mydatabase` etc is returned wrapped in appropriate qualifiers for + /// the database we are providing syntax for. Returns string unchanged if null or whitespace. + /// + /// + /// + [return: NotNullIfNotNull(nameof(databaseOrTableName))] + string? EnsureWrapped(string? databaseOrTableName); + + string EnsureFullyQualified(string? databaseName, string? schemaName, string tableName); + string EnsureFullyQualified(string? databaseName, string? schemaName, string tableName, string columnName, bool isTableValuedFunction = false); + + /// + /// Returns the given escaped e.g. doubling up single quotes. Does not add any wrapping. + /// + /// + /// + string Escape(string sql); + + TopXResponse HowDoWeAchieveTopX(int x); + string GetParameterDeclaration(string proposedNewParameterName, DatabaseTypeRequest request); + string GetParameterDeclaration(string proposedNewParameterName, string sqlType); + + bool IsValidParameterName(string parameterSQL); + + string AliasPrefix { get; } + + /// + /// The maximum number of characters allowed in database names according to the DBMS + /// + int MaximumDatabaseLength { get; } + + /// + /// The maximum number of characters allowed in table names according to the DBMS + /// + int MaximumTableLength { get; } + + /// + /// The maximum number of characters allowed in column names according to the DBMS + /// + int MaximumColumnLength { get; } + + /// + /// Boolean false encoded appropriately for the DBMS (either 0 or FALSE depending) + /// + public string False { get; } + + /// + /// Boolean true encoded appropriately for the DBMS (either 1 or TRUE depending) + /// + public string True { get; } + + bool SplitLineIntoSelectSQLAndAlias(string lineToSplit, out string selectSQL, out string? alias); + + string GetScalarFunctionSql(MandatoryScalarFunctions function); + string GetSensibleEntityNameFromString(string potentiallyDodgyName); + + /// + /// Takes a line line " count(*) " and returns "count" and "*" + /// Also handles LTRIM(RTRIM(FishFishFish)) by returning "LTRIM" and "RTRIM(FishFishFish)" + /// + /// + /// + /// + /// If was badly formed, blank etc + void SplitLineIntoOuterMostMethodAndContents(string lineToSplit, out string method, out string contents); + + /// + /// The SQL that would be valid for a CREATE TABLE statement that would result in a given column becoming auto increment e.g. "IDENTITY(1,1)" + /// + /// + string GetAutoIncrementKeywordIfAny(); + + /// + /// Get a list of functions to SQL code (including parameter names). This is used primarily in autocomplete situations where the user wants to + /// know the available functions within the targeted dbms. + /// + /// + Dictionary GetSQLFunctionsDictionary(); + + bool IsBasicallyNull(object value); + bool IsTimeout(Exception exception); + + HashSet GetReservedWords(); + + /// + /// Returns SQL that will wrap a single line of SQL with the SQL to calculate MD5 hash e.g. change `MyTable`.`MyColumn` to md5(`MyTable`.`MyColumn`) + /// The SQL might include transform functions e.g. UPPER etc + /// + /// + /// + string HowDoWeAchieveMd5(string selectSql); + + /// + /// Gets a DbParameter hard typed with the correct DbType for the discoveredColumn and the Value set to the correct Value representation (e.g. DBNull for nulls or whitespace). + /// Also handles converting DateTime representations since many DBMS are a bit rubbish at that + /// + /// + /// The column the parameter is for loading - this is used to determine the DbType for the paramter + /// The value to populate into the command, this will be converted to DBNull.Value if the value is nullish + /// + DbParameter GetParameter(DbParameter p, DiscoveredColumn discoveredColumn, object value); + + /// + /// Gets a DbParameter hard typed with the correct DbType for the discoveredColumn and the Value set to the correct Value representation (e.g. DBNull for nulls or whitespace). + /// Also handles converting DateTime representations since many DBMS are a bit rubbish at that + /// + /// + /// The column the parameter is for loading - this is used to determine the DbType for the paramter + /// The value to populate into the command, this will be converted to DBNull.Value if the value is nullish + /// + /// + DbParameter GetParameter(DbParameter p, DiscoveredColumn discoveredColumn, object value, CultureInfo? culture); + + /// + /// Throws if the supplied name is invalid (because it is too long or contains unsupported characters) + /// + /// + void ValidateDatabaseName(string? database); + + /// + /// Throws if the supplied name is invalid (because it is too long or contains unsupported characters) + /// + void ValidateTableName(string tableName); + + /// + /// Throws if the supplied name is invalid (because it is too long or contains unsupported characters) + /// + void ValidateColumnName(string columnName); + + /// + /// Returns false if the supplied name is invalid (because it is too long or contains unsupported characters) + /// + bool IsValidDatabaseName(string databaseName, [NotNullWhen(false)] out string? reason); + + /// + /// Returns false if the supplied name is invalid (because it is too long or contains unsupported characters) + /// + bool IsValidTableName(string tableName, [NotNullWhen(false)] out string? reason); + + /// + /// Returns false if the supplied name is invalid (because it is too long or contains unsupported characters) + /// + bool IsValidColumnName(string columnName, [NotNullWhen(false)] out string? reason); + + + /// + /// The default schema into which tables are created if none is specified e.g. "dbo" in Sql Server. + /// If schemas are not supported (e.g. MySql) then null is returned + /// + /// + string? GetDefaultSchemaIfAny(); +} + +public enum MandatoryScalarFunctions +{ + None = 0, + + /// + /// A scalar function which must return todays datetime. Must be valid as a column default too + /// + GetTodaysDate, + + /// + /// A scalar function which must return a new random GUID. + /// + GetGuid, + + /// + /// A scalar function which must take a single argument (column name) and return the length of values in it + /// + Len +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/QueryComponent.cs b/FAnsi.Core/Discovery/QuerySyntax/QueryComponent.cs new file mode 100644 index 00000000..e7014a6f --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/QueryComponent.cs @@ -0,0 +1,18 @@ +namespace FAnsi.Discovery.QuerySyntax; + +public enum QueryComponent +{ + None = 0, + VariableDeclaration, + SELECT, + SET, + QueryTimeColumn, + FROM, //do not use for CustomLines - use only for WhatIsOnLine etc + JoinInfoJoin, + WHERE, //relates to sql to be treated as a final bit of WHERE sql located after any containers. Expect to have either AND or WHERE automatically injected into your start by QueryBuilders + GroupBy, + Having, + OrderBy, + Postfix //after everything else in the query (including WHERE containers and any ORDER BYs) + +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/QuerySyntaxHelperFactory.cs b/FAnsi.Core/Discovery/QuerySyntax/QuerySyntaxHelperFactory.cs new file mode 100644 index 00000000..c6ead0a6 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/QuerySyntaxHelperFactory.cs @@ -0,0 +1,11 @@ +using FAnsi.Implementation; + +namespace FAnsi.Discovery.QuerySyntax; + +/// +/// Translates a DatabaseType into the correct IQuerySyntaxHelper. +/// +public static class QuerySyntaxHelperFactory +{ + public static IQuerySyntaxHelper Create(DatabaseType type) => ImplementationManager.GetImplementation(type).GetQuerySyntaxHelper(); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/RuntimeNameException.cs b/FAnsi.Core/Discovery/QuerySyntax/RuntimeNameException.cs new file mode 100644 index 00000000..de964e9b --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/RuntimeNameException.cs @@ -0,0 +1,12 @@ +using System; + +namespace FAnsi.Discovery.QuerySyntax; + +/// +/// thrown when there is a problem with the name of an object (e.g. a column / table) or when one could not be calculated from a piece of SQL +/// +/// +/// Creates a new instance of the Exception with the given +/// +/// +public sealed class RuntimeNameException(string message) : Exception(message); \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/TopXResponse.cs b/FAnsi.Core/Discovery/QuerySyntax/TopXResponse.cs new file mode 100644 index 00000000..b53e84f3 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/TopXResponse.cs @@ -0,0 +1,11 @@ +namespace FAnsi.Discovery.QuerySyntax; + +/// +/// Describes how to achieve a 'Select Top X from Table' query (return only the first X matching records for the query). It includes the SQL text required to +/// achieve it (e.g. 'Top X' in Sql Server vs 'LIMIT 10' in MySql) along with where it has to appear in the query (See QueryComponent). +/// +public sealed class TopXResponse(string sql, QueryComponent location) +{ + public string SQL { get; } = sql; + public QueryComponent Location { get; } = location; +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/Update/IUpdateHelper.cs b/FAnsi.Core/Discovery/QuerySyntax/Update/IUpdateHelper.cs new file mode 100644 index 00000000..d91fec61 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/Update/IUpdateHelper.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace FAnsi.Discovery.QuerySyntax.Update; + +public delegate string UpdateStatementSqlGetter(string table2Alias); + +/// +/// Cross Database Type class for turning a collection of arbitrary sql lines (CustomLine) into an UPDATE query where no suitable ANSI solution exists. For example +/// updating a table using a join to another table where the relationship is n..n. +/// +/// Look at UpdateHelper.permissableLocations to determine which CustomLines you are allowed to pass in. +/// +public interface IUpdateHelper +{ + /// + /// Joins the two tables (which could be a self join) and updates table1 using values computed from table2. The join will be a straight up join i.e. not left/right/inner. + /// IMPORTANT: All CustomLines should use the table aliases t1 and t2 e.g. t1.MyCol = T2.MyCol + /// + /// + /// The table to UPDATE + /// The table to join against (which might be a self join) + /// All SET, WHERE and JoinInfo lines needed to build the query, columns should be specified using the alias t1.colX and t2.colY instead of the full names of + /// table1/table2. if you have multiple WHERE lines then they will be ANDed. To avoid this you can concatenate your CustomLines together yourself and serve only one to this + /// method(e.g. to use OR) + /// + string BuildUpdate(DiscoveredTable table1, DiscoveredTable table2,List lines); + +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntax/Update/UpdateHelper.cs b/FAnsi.Core/Discovery/QuerySyntax/Update/UpdateHelper.cs new file mode 100644 index 00000000..03471d6d --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntax/Update/UpdateHelper.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FAnsi.Discovery.QuerySyntax.Update; + +public abstract class UpdateHelper:IUpdateHelper +{ + /// + /// You only have to support CustomLines that fulfil this role in the query i.e. no parameter support etc + /// + private readonly QueryComponent[] _permissableLocations = [QueryComponent.SET, QueryComponent.JoinInfoJoin, QueryComponent.WHERE]; + + public string BuildUpdate(DiscoveredTable table1, DiscoveredTable table2, List lines) + { + if(lines.Any(l => !_permissableLocations.Contains(l.LocationToInsert))) + throw new NotSupportedException(); + + return BuildUpdateImpl(table1, table2, lines); + } + + protected abstract string BuildUpdateImpl(DiscoveredTable table1, DiscoveredTable table2, List lines); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/QuerySyntaxHelper.cs b/FAnsi.Core/Discovery/QuerySyntaxHelper.cs new file mode 100644 index 00000000..ee6ed770 --- /dev/null +++ b/FAnsi.Core/Discovery/QuerySyntaxHelper.cs @@ -0,0 +1,515 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.QuerySyntax.Aggregation; +using FAnsi.Discovery.QuerySyntax.Update; +using FAnsi.Discovery.TypeTranslation; +using TypeGuesser; + +namespace FAnsi.Discovery; + +/// +public abstract partial class QuerySyntaxHelper( + ITypeTranslater translater, + IAggregateHelper aggregateHelper, + IUpdateHelper updateHelper, + DatabaseType databaseType) + : IQuerySyntaxHelper +{ + private static readonly System.Buffers.SearchValues BracketSearcher = System.Buffers.SearchValues.Create("()"); + + public virtual string DatabaseTableSeparator => "."; + + /// + public abstract int MaximumDatabaseLength { get; } + + /// + public abstract int MaximumTableLength { get; } + + /// + public abstract int MaximumColumnLength { get; } + + /// + public virtual char[] IllegalNameChars { get; } = ['.', '(', ')']; + + /// + public virtual string False => "0"; // 0 works as false for everything except Postgres + + /// + public virtual string True => "1"; // 1 works as true for everything except Postgres + + /// + /// Regex for identifying parameters in blocks of SQL (starts with @ or : (Oracle) + /// + /// + private static readonly Regex ParameterNamesRegex = ParameterNamesRe(); + + /// + /// Symbols (for all database types) which denote wrapped entity names e.g. [dbo].[mytable] contains qualifiers '[' and ']' + /// + public static readonly char[] TableNameQualifiers = ['[', ']', '`', '"']; + + /// + public abstract string OpenQualifier { get; } + + /// + public abstract string CloseQualifier { get; } + + public ITypeTranslater TypeTranslater { get; private set; } = translater; + + private readonly Dictionary factories = []; + + public IAggregateHelper AggregateHelper { get; private set; } = aggregateHelper; + public IUpdateHelper UpdateHelper { get; set; } = updateHelper; + public DatabaseType DatabaseType { get; private set; } = databaseType; + + public virtual char ParameterSymbol => '@'; + + private static string GetAliasConst() => " AS "; + + public string AliasPrefix => GetAliasConst(); + + //Only look at the start of the string or following an equals or white space and stop at word boundaries + private static readonly Regex ParameterNameRegex = new($@"(?:^|[\s+\-*/\\=(,])+{ParameterNamesRegex}\b"); + + /// + /// Lists the names of all parameters required by the supplied whereSql e.g. @bob = 'bob' would return "@bob" + /// + /// the SQL you want to determine the parameter names in + /// parameter names that are required by the SQL + public static HashSet GetAllParameterNamesFromQuery(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return []; + + var toReturn = new HashSet(ParameterNameRegex.Matches(query).Cast().Select(static match => match.Groups[1].Value.Trim()), StringComparer.InvariantCultureIgnoreCase); + return toReturn; + } + + public static string GetParameterNameFromDeclarationSQL(string parameterSQL) + { + if (!ParameterNamesRegex.IsMatch(parameterSQL)) + throw new Exception($"ParameterSQL does not match regex pattern:{ParameterNamesRegex}"); + + return ParameterNamesRegex.Match(parameterSQL).Value.Trim(); + } + + public bool IsValidParameterName(string parameterSQL) => ParameterNamesRegex.IsMatch(parameterSQL); + + [return: NotNullIfNotNull(nameof(s))] + public virtual string? GetRuntimeName(string? s) + { + if (string.IsNullOrWhiteSpace(s)) + return s; + + //if it is an aliased entity e.g. AS fish then we should return fish (this is the case for table valued functions and not much else) + if (SplitLineIntoSelectSQLAndAlias(s.Trim(), out _, out var alias)) + return alias; + + //it doesn't have an alias, e.g. it's `MyDatabase`.`mytable` or something + + //if it's "count(1)" or something then that's a problem! + if (s.AsSpan().IndexOfAny(BracketSearcher) != -1) + throw new RuntimeNameException( + $"Could not determine runtime name for Sql:'{s}'. It had brackets and no alias. Try adding ' as mycol' to the end."); + + //Last symbol with no whitespace + var lastWord = s[(s.LastIndexOf('.') + 1)..].Trim(); + + if (string.IsNullOrWhiteSpace(lastWord) || lastWord.Length < 2) + return lastWord; + + //trim off any brackets e.g. return "My Table" for "[My Table]" + if (lastWord.StartsWith(OpenQualifier, StringComparison.Ordinal) && lastWord.EndsWith(CloseQualifier, StringComparison.Ordinal)) + return UnescapeWrappedNameBody(lastWord[1..^1]); + + return lastWord; + } + + /// + /// Removes qualifiers/escape sequences in the suplied . This should for example convert MySql double backtick escape sequences fi``sh into singles (fi`sh). + /// + /// Method is only called after a successful detection and stripping of and + /// + /// A wrapped name after it has had the opening and closing qualifiers stripped off e.g. "Fi``sh" + /// The final runtime name unescaped e.g. "Fi`sh" + protected virtual string UnescapeWrappedNameBody(string name) => name; + + public virtual bool TryGetRuntimeName(string s, out string? name) + { + try + { + name = GetRuntimeName(s); + return true; + } + catch (RuntimeNameException) + { + name = null; + return false; + } + } + + public abstract bool SupportsEmbeddedParameters(); + + public string? EnsureWrapped(string? databaseOrTableName) + { + if (string.IsNullOrWhiteSpace(databaseOrTableName)) + return databaseOrTableName; + + if (databaseOrTableName.Contains(DatabaseTableSeparator)) + throw new Exception(string.Format(FAnsiStrings.QuerySyntaxHelper_EnsureWrapped_String_passed_to_EnsureWrapped___0___contained_separators__not_allowed____Prohibited_Separator_is___1__, databaseOrTableName, DatabaseTableSeparator)); + + return EnsureWrappedImpl(databaseOrTableName); + } + + public abstract string EnsureWrappedImpl(string databaseOrTableName); + + public abstract string EnsureFullyQualified(string? databaseName, string? schema, string tableName); + + public virtual string EnsureFullyQualified(string? databaseName, string? schema, string tableName, string columnName, bool isTableValuedFunction = false) => + isTableValuedFunction ? $"{GetRuntimeName(tableName)}.{GetRuntimeName(columnName)}" + : //table valued functions do not support database name being in the column level selection list area of sql queries + $"{EnsureFullyQualified(databaseName, schema, tableName)}.{EnsureWrapped(GetRuntimeName(columnName))}"; + + public virtual string Escape(string sql) => string.IsNullOrWhiteSpace(sql) ? sql : sql.Replace("'", "''"); + public abstract TopXResponse HowDoWeAchieveTopX(int x); + + public virtual string GetParameterDeclaration(string proposedNewParameterName, DatabaseTypeRequest request) => GetParameterDeclaration(proposedNewParameterName, TypeTranslater.GetSQLDBTypeForCSharpType(request)); + + public virtual HashSet GetReservedWords() => new(StringComparer.CurrentCultureIgnoreCase); + + public abstract string GetParameterDeclaration(string proposedNewParameterName, string sqlType); + + /// + /// Splits the given into + /// + /// + /// + /// + /// + public virtual bool SplitLineIntoSelectSQLAndAlias(string lineToSplit, out string selectSQL, [NotNullWhen(true)] out string? alias) + { + //Ths line is expected to be some SELECT sql so remove trailing whitespace and commas etc + lineToSplit = lineToSplit.TrimEnd(',', ' ', '\n', '\r'); + + var matches = AliasRegex().Matches(lineToSplit); + + switch (matches.Count) + { + case > 1: + throw new SyntaxErrorException(string.Format(FAnsiStrings.QuerySyntaxHelper_SplitLineIntoSelectSQLAndAlias_, matches.Count, lineToSplit)); + case 0: + selectSQL = lineToSplit; + alias = null; + return false; + } + + //match is an unwrapped alias + var unqualifiedAlias = matches[0].Groups[2].Value; + var qualifiedAlias = matches[0].Groups[4].Value; + + alias = string.IsNullOrWhiteSpace(unqualifiedAlias) ? qualifiedAlias : unqualifiedAlias; + alias = alias.Trim(); + selectSQL = lineToSplit[..matches[0].Index].Trim(); + return true; + } + + public abstract string GetScalarFunctionSql(MandatoryScalarFunctions function); + + /// + public void SplitLineIntoOuterMostMethodAndContents(string lineToSplit, out string method, out string contents) + { + if (string.IsNullOrWhiteSpace(lineToSplit)) + throw new ArgumentException( + FAnsiStrings.QuerySyntaxHelper_SplitLineIntoOuterMostMethodAndContents_Line_must_not_be_blank, + nameof(lineToSplit)); + + if (lineToSplit.Count(static c => c.Equals('(')) != lineToSplit.Count(static c => c.Equals(')'))) + throw new ArgumentException( + FAnsiStrings + .QuerySyntaxHelper_SplitLineIntoOuterMostMethodAndContents_The_number_of_opening_and_closing_parentheses_must_match, + nameof(lineToSplit)); + + var firstBracket = lineToSplit.IndexOf('('); + + if (firstBracket == -1) + throw new ArgumentException( + FAnsiStrings + .QuerySyntaxHelper_SplitLineIntoOuterMostMethodAndContents_Line_must_contain_at_least_one_pair_of_parentheses, + nameof(lineToSplit)); + + method = lineToSplit[..firstBracket].Trim(); + + var lastBracket = lineToSplit.LastIndexOf(')'); + + var length = lastBracket - (firstBracket + 1); + + contents = length == 0 ? "" : //it's something like count() + lineToSplit.Substring(firstBracket + 1, length).Trim(); + } + + public static string MakeHeaderNameSensible(string header) + { + if (string.IsNullOrWhiteSpace(header)) + return header; + + //replace anything that isn't a digit, letter or underscore with emptiness (except spaces - these will go but first...) + //also accept anything above ASCII 256 + var r = HeaderNameCharRegex(); + + var adjustedHeader = r.Replace(header, ""); + + var sb = new StringBuilder(adjustedHeader); + + //Camel case after spaces + for (var i = 0; i < sb.Length; i++) + //if we are looking at a space + if (sb[i] == ' ' && i + 1 < sb.Length && sb[i + 1] >= 'a' && sb[i + 1] <= 'z') //and there is another character + //and that character is a lower case letter + sb[i + 1] = char.ToUpper(sb[i + 1]); + + adjustedHeader = sb.ToString().Replace(" ", ""); + + //if it starts with a digit (illegal) put an underscore before it + if (StartsDigitsRe().IsMatch(adjustedHeader)) + adjustedHeader = $"_{adjustedHeader}"; + + return adjustedHeader; + } + + public string GetSensibleEntityNameFromString(string? potentiallyDodgyName) + { + potentiallyDodgyName = GetRuntimeName(potentiallyDodgyName); + + //replace anything that isn't a digit, letter or underscore with underscores + var r = NotAlphaNumRe(); + var adjustedHeader = r.Replace(potentiallyDodgyName ?? string.Empty, "_"); + + //if it starts with a digit (illegal) put an underscore before it + if (StartsDigitsRe().IsMatch(adjustedHeader)) + adjustedHeader = $"_{adjustedHeader}"; + + return adjustedHeader; + } + + public abstract string GetAutoIncrementKeywordIfAny(); + public abstract Dictionary GetSQLFunctionsDictionary(); + + public bool IsBasicallyNull(object value) + { + if (value is string stringValue) + return string.IsNullOrWhiteSpace(stringValue); + + return value == null || value == DBNull.Value; + } + + public virtual bool IsTimeout(Exception exception) => + /* + //todo doesn't work with .net standard + var oleE = exception as OleDbException; + + if (oleE != null && oleE.ErrorCode == -2147217871) + return true;*/ + exception.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase); + + public abstract string HowDoWeAchieveMd5(string selectSql); + + + public DbParameter GetParameter(DbParameter p, DiscoveredColumn discoveredColumn, object value, CultureInfo? culture) + { + try + { + culture ??= CultureInfo.InvariantCulture; + + if (!factories.ContainsKey(culture)) + factories.Add(culture, new TypeDeciderFactory(culture)); + + var tt = TypeTranslater; + p.DbType = tt.GetDbTypeForSQLDBType(discoveredColumn.DataType.SQLType); + var cSharpType = tt.GetCSharpTypeForSQLDBType(discoveredColumn.DataType.SQLType); + + if (IsBasicallyNull(value)) + p.Value = DBNull.Value; + else if (value is string strVal && factories[culture].IsSupported(cSharpType)) //if the input is a string and it's for a hard type e.g. TimeSpan + { + var decider = factories[culture].Create(cSharpType); + var o = decider.Parse(strVal); + + if (o is DateTime d) o = FormatDateTimeForDbParameter(d); + + //Not all DBMS support DBParameter.Value = new TimeSpan(...); + if (o is TimeSpan t) o = FormatTimespanForDbParameter(t); + + + p.Value = o; + } + else + p.Value = value; + } + catch (Exception ex) + { + throw new Exception(string.Format(FAnsiStrings.QuerySyntaxHelper_GetParameter_Could_not_GetParameter_for_column___0__, discoveredColumn.GetFullyQualifiedName()), ex); + } + + return p; + } + + public void ValidateDatabaseName(string? databaseName) + { + if (!IsValidDatabaseName(databaseName, out var reason)) + throw new RuntimeNameException(reason); + } + public void ValidateTableName(string tableName) + { + if (!IsValidTableName(tableName, out var reason)) + throw new RuntimeNameException(reason); + } + public void ValidateColumnName(string columnName) + { + if (!IsValidColumnName(columnName, out var reason)) + throw new RuntimeNameException(reason); + } + + public bool IsValidDatabaseName(string? databaseName, [NotNullWhen(false)] out string? reason) + { + reason = ValidateName(databaseName, "Database", MaximumDatabaseLength); + return string.IsNullOrWhiteSpace(reason); + } + + public bool IsValidTableName(string tableName, [NotNullWhen(false)] out string? reason) + { + reason = ValidateName(tableName, "Table", MaximumTableLength); + return string.IsNullOrWhiteSpace(reason); + } + + public bool IsValidColumnName(string columnName, [NotNullWhen(false)] out string? reason) + { + reason = ValidateName(columnName, "Column", MaximumColumnLength); + return string.IsNullOrWhiteSpace(reason); + } + + public virtual string? GetDefaultSchemaIfAny() => null; + + /// + /// returns null if the name is valid. Otherwise a string describing why it is invalid. + /// + /// + /// Type of object being validated e.g. "Database", "Table" etc + /// + /// + private string? ValidateName(string? candidate, string objectType, int maximumLengthAllowed) + { + if (string.IsNullOrWhiteSpace(candidate)) + return string.Format(FAnsiStrings.QuerySyntaxHelper_ValidateName__0__name_cannot_be_blank, objectType); + + if (candidate.Length > maximumLengthAllowed) + return string.Format(FAnsiStrings.QuerySyntaxHelper_ValidateName__0__name___1___is_too_long_for_the_DBMS___2__supports_maximum_length_of__3__, + objectType, candidate[..maximumLengthAllowed], DatabaseType, maximumLengthAllowed); + + if (candidate.IndexOfAny(IllegalNameChars) != -1) + return string.Format( + FAnsiStrings.QuerySyntaxHelper_ValidateName__0__name___1___contained_unsupported__by_FAnsi__characters___Unsupported_characters_are__2_, + objectType, candidate, new string(IllegalNameChars)); + + return null; + } + + + public DbParameter GetParameter(DbParameter p, DiscoveredColumn discoveredColumn, object value) => GetParameter(p, discoveredColumn, value, null); + + /// + /// + /// Return the appropriate value such that it can be put into a DbParameter.Value field and be succesfully inserted into a + /// column in the database designed to represent datetime fields (without date). + /// + /// Default behaviour is to return unaltered but some DBMS require alterations e.g. UTC tinkering + /// + /// + /// + protected virtual object FormatDateTimeForDbParameter(DateTime dateTime) => dateTime; + + /// + /// Return the appropriate value such that it can be put into a DbParameter.Value field and be succesfully inserted into a + /// column in the database designed to represent time fields (without date). + /// + /// + /// + protected virtual object FormatTimespanForDbParameter(TimeSpan timeSpan) => timeSpan; + + #region Equality Members + protected bool Equals(QuerySyntaxHelper other) + { + if (other == null) + return false; + + return GetType() == other.GetType(); + } + + public override bool Equals(object? obj) + { + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + return Equals((QuerySyntaxHelper)obj); + } + + public override int GetHashCode() => GetType().GetHashCode(); + + #endregion + + public Dictionary GetParameterNamesFor(T[] columns, Func toStringFunc) where T : notnull + { + var toReturn = new Dictionary(); + + var reservedKeywords = GetReservedWords(); + + + //sensible parameter names have no spaces or symbols! + var sensibleParameterNamesInclude = sensibleParameterNamesIncludeRe(); + + for (var i = 0; i < columns.Length; i++) + { + var c = columns[i]; + var columnName = toStringFunc(c); + + if (columnName is null || !sensibleParameterNamesInclude.IsMatch(columnName)) //if column name is "_:_" or something + toReturn.Add(c, $"{ParameterSymbol}p{i}"); + else + toReturn.Add(c, ParameterSymbol + (reservedKeywords.Contains(columnName) ? $"{columnName}1" : columnName)); //if column is reserved keyword or normal name + } + + return toReturn; + } + + [GeneratedRegex(@"^\w*$")] + private static partial Regex sensibleParameterNamesIncludeRe(); + + //whitespace followed by as and more whitespace + //Then any word (optionally bounded by a table name qualifier) + //alias is a word + //(w+) + //alias is a wrapped word e.g. [hey hey]. In this case we must allow anything between the brackets that is not closing bracket + //[[`""]([^[`""]+)[]`""] + [GeneratedRegex("""\s+as\s+((\w+)|([[`"]([^[`"]+)[]`"]))$""", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex AliasRegex(); + + [GeneratedRegex("([@:][A-Za-z0-9_]*)\\s?", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex ParameterNamesRe(); + + [GeneratedRegex("[^A-Za-z0-9_ \u0101-\uFFFF]")] + private static partial Regex HeaderNameCharRegex(); + + [GeneratedRegex("^[0-9]")] + private static partial Regex StartsDigitsRe(); + + [GeneratedRegex("[^A-Za-z0-9_]")] + private static partial Regex NotAlphaNumRe(); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/TableCreation/CreateTableArgs.cs b/FAnsi.Core/Discovery/TableCreation/CreateTableArgs.cs new file mode 100644 index 00000000..bb7fbfdf --- /dev/null +++ b/FAnsi.Core/Discovery/TableCreation/CreateTableArgs.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using TypeGuesser; + +namespace FAnsi.Discovery.TableCreation; + +/// +/// Determines the behaviour of . This includes how columns are assigned data types, whether foreign keys +/// are created etc. +/// +/// +/// Create a table with the given name. Set your columns in +/// +public sealed class CreateTableArgs(DiscoveredDatabase database, string tableName, string? schema) +{ + /// + /// The destination database in which to create the table + /// + public DiscoveredDatabase Database { get; set; } = database; + + /// + /// Name you want the table to have once created + /// + public string TableName { get; private set; } = tableName; + + /// + /// Schema of the to create the table in. This is NOT the database e.g. in [MyDb].[dbo].[MyTable] the schema is "dbo". If in doubt leave blank + /// + public string? Schema { get; private set; } = schema; + + /// + /// Optional - Columns are normally created based on supplied DataTable data rows. If this is set then the Type specified here will + /// be used instead. + /// + public DatabaseColumnRequest[]? ExplicitColumnDefinitions { get; set; } + + /// + /// Set this to make last minute changes to column datatypes before table creation + /// + public IDatabaseColumnRequestAdjuster? Adjuster { get; set; } + + /// + /// Link between columns that you want to create in your table and existing columns () that + /// should be paired with a foreign key constraint. + /// + /// Key is the foreign key column (and the table the constraint will be put on). + /// Value is the primary key table column (which the constraint reference points to) + /// + public Dictionary? ForeignKeyPairs { get; set; } + + /// + /// When creating a foreign key constraint (See ) determines whether ON DELETE CASCADE should be set. + /// + public bool CascadeDelete { get; set; } + + /// + /// The data to use to determine table schema and load into the newly created table (unless is set). + /// + public DataTable? DataTable { get; set; } + + /// + /// When creating the table, do not upload any rows supplied in + /// + public bool CreateEmpty { get; set; } + + /// + /// True if the table has been created + /// + public bool TableCreated { get; private set; } + + /// + /// Customise guessing behaviour + /// + public GuessSettings GuessSettings { get; set; } = GuessSettingsFactory.Create(); + + /// + /// Populated after the table has been created (See ), list of the used to create the columns in the table. + /// This will be null if no was provided when creating the table + /// + public Dictionary? ColumnCreationLogic { get; private set; } + + /// + /// Used to determine what how to parse untyped strings in (if building schema from data table). + /// + public CultureInfo Culture { get; set; } = CultureInfo.CurrentCulture; + + /// + /// Create a table with the given name. Set your columns in + /// + public CreateTableArgs(DiscoveredDatabase database, string tableName, string? schema, + Dictionary foreignKeyPairs, bool cascadeDelete) + : this(database, tableName, schema) + { + ForeignKeyPairs = foreignKeyPairs; + CascadeDelete = cascadeDelete; + } + + /// + /// Create a table with the given name based on the columns and data in the provided . If you want to override the + /// data type of a given column set + /// + public CreateTableArgs(DiscoveredDatabase database, string tableName, string? schema, DataTable dataTable, bool createEmpty) + : this(database, tableName, schema) + { + DataTable = dataTable; + CreateEmpty = createEmpty; + } + + /// + /// Create a table with the given name based on the columns and data in the provided . If you want to override the + /// data type of a given column set + /// + public CreateTableArgs(DiscoveredDatabase database, string tableName, string schema,DataTable dataTable, bool createEmpty, Dictionary foreignKeyPairs, bool cascadeDelete) + : this(database, tableName, schema,dataTable,createEmpty) + { + ForeignKeyPairs = foreignKeyPairs; + CascadeDelete = cascadeDelete; + } + + /// + /// Declare that the table has been created and the provided were used to determine the column schema + /// + /// + public void OnTableCreated(Dictionary columnsCreated) + { + ColumnCreationLogic = columnsCreated; + TableCreated = true; + } +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/TableCreation/IDatabaseColumnRequestAdjuster.cs b/FAnsi.Core/Discovery/TableCreation/IDatabaseColumnRequestAdjuster.cs new file mode 100644 index 00000000..a22354f4 --- /dev/null +++ b/FAnsi.Core/Discovery/TableCreation/IDatabaseColumnRequestAdjuster.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace FAnsi.Discovery.TableCreation; + +/// +/// Performs last minute changes on a set of columns that are about to be created. This might include padding the maximum size of strings, using +/// varchar instead of int/DateTime etc. +/// +public interface IDatabaseColumnRequestAdjuster +{ + /// + /// Implement to make last minute changes to the columns in the table being created + /// + /// + void AdjustColumns(List columns); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/TableCreation/TableCreation.cd b/FAnsi.Core/Discovery/TableCreation/TableCreation.cd new file mode 100644 index 00000000..bb620cf8 --- /dev/null +++ b/FAnsi.Core/Discovery/TableCreation/TableCreation.cd @@ -0,0 +1,35 @@ + + + + + + + + + AAAEAggAAEAAAgAAAAhABAAUAQABAAAAAQAAAQAAAAA= + Discovery\TableCreation\CreateTableArgs.cs + + + + + + + + + + + + AARAIAAAAAIAAEAEgAAAQAAAABwAAIDAAAAAAAACBAA= + Discovery\DiscoveredColumn.cs + + + + + + + AAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + Discovery\TableCreation\IDatabaseColumnRequestAdjuster.cs + + + + \ No newline at end of file diff --git a/FAnsi.Core/Discovery/TableType.cs b/FAnsi.Core/Discovery/TableType.cs new file mode 100644 index 00000000..aef9b079 --- /dev/null +++ b/FAnsi.Core/Discovery/TableType.cs @@ -0,0 +1,22 @@ +namespace FAnsi.Discovery; + +/// +/// The nature of a queryable asset on the server (e.g. table, view, table valued function) +/// +public enum TableType +{ + /// + /// A persistent query (view) run on a table on demand + /// + View, + + /// + /// A physical table on a database server + /// + Table, + + /// + /// A proceedural function which returns rows based on 0 or more parameters and acts like a table (DBMS specific). + /// + TableValuedFunction +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/TypeTranslation/ITypeTranslater.cs b/FAnsi.Core/Discovery/TypeTranslation/ITypeTranslater.cs new file mode 100644 index 00000000..ffe5dd9a --- /dev/null +++ b/FAnsi.Core/Discovery/TypeTranslation/ITypeTranslater.cs @@ -0,0 +1,84 @@ +using System; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using TypeGuesser; + +namespace FAnsi.Discovery.TypeTranslation; + +/// +/// Cross database type functionality for translating between database proprietary datatypes e.g. varchar (varchar2 in Oracle) and the C# Type (and vice +/// versa). +/// +/// When translating into a database type from a C# Type you also need to know additonal information e.g. how long is the maximum length of a string, how much +/// scale/precision should a decimal have. This is represented by the DatabaseTypeRequest class. +/// +/// +public interface ITypeTranslater +{ + /// + /// DatabaseTypeRequest is turned into the proprietary string e.g. A DatabaseTypeRequest with CSharpType = typeof(DateTime) is translated into + /// 'datetime2' in Microsoft SQL Server but 'datetime' in MySql server. + /// + /// + /// + string GetSQLDBTypeForCSharpType(DatabaseTypeRequest request); + + /// + /// Returns the System.Data.DbType (e.g. DbType.String) for the specified proprietary database type (e.g. "varchar(max)") + /// + /// + /// + /// + DbType GetDbTypeForSQLDBType(string sqlType); + + + /// + /// Translates a database proprietary type e.g. 'decimal(10,2)' into a C# type e.g. 'typeof(decimal)' + /// + /// + /// The C# Type which can be used to store values of this database type + [return: + DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicFields)] + Type GetCSharpTypeForSQLDBType(string? sqlType); + + /// + /// Translates a database proprietary type e.g. 'decimal(10,2)' into a C# type e.g. 'typeof(decimal)' + /// + /// Returns null if no the is not understood + /// + /// + /// The C# Type which can be used to store values of this database type + [return: + DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicFields)] + Type? TryGetCSharpTypeForSQLDBType(string sqlType); + + /// + /// Returns true if the string could be reconciled into a known C# Type. Do not use this + /// for testing if a given random string is likely to be accepted by the DBMS. You should only pass Types that actually + /// resolve. + /// + /// See also + /// + /// A DBMS type name which you want the API to guess a C# Type for. + /// True if FAnsi has a C# Type representation for the supplied + bool IsSupportedSQLDBType(string sqlType); + + DatabaseTypeRequest GetDataTypeRequestForSQLDBType(string sqlType); + + Guesser GetGuesserFor(DiscoveredColumn discoveredColumn); + + int GetLengthIfString(string sqlType); + DecimalSize? GetDigitsBeforeAndAfterDecimalPointIfDecimal(string sqlType); + + /// + /// Translates the given sqlType which must be an SQL string compatible with this TypeTranslater e.g. varchar(10) into the destination ITypeTranslater + /// e.g. Varchar2(10) if destinationTypeTranslater was Oracle. Even if both this and the destination are the same you might find a different datatype + /// due to translation preference and Type merging e.g. text might change to varchar(max) + /// + /// + /// + /// + string TranslateSQLDBType(string sqlType, ITypeTranslater destinationTypeTranslater); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/TypeTranslation/TypeNotMappedException.cs b/FAnsi.Core/Discovery/TypeTranslation/TypeNotMappedException.cs new file mode 100644 index 00000000..a80c3799 --- /dev/null +++ b/FAnsi.Core/Discovery/TypeTranslation/TypeNotMappedException.cs @@ -0,0 +1,8 @@ +using System; + +namespace FAnsi.Discovery.TypeTranslation; + +/// +/// Thrown when a given C# Type is not mapped to a known DBMS proprietary type by FAnsi or vice versa. +/// +public sealed class TypeNotMappedException(string msg) : Exception(msg); \ No newline at end of file diff --git a/FAnsi.Core/Discovery/TypeTranslation/TypeTranslater.cs b/FAnsi.Core/Discovery/TypeTranslation/TypeTranslater.cs new file mode 100644 index 00000000..939df941 --- /dev/null +++ b/FAnsi.Core/Discovery/TypeTranslation/TypeTranslater.cs @@ -0,0 +1,447 @@ +using System; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using TypeGuesser; + +namespace FAnsi.Discovery.TypeTranslation; + +/// +public abstract partial class TypeTranslater : ITypeTranslater +{ + private const string StringSizeRegexPattern = @"\(([0-9]+)\)"; + private const string DecimalsBeforeAndAfterPattern = @"\(([0-9]+),([0-9]+)\)"; + + //Take special note of the use or absence of ^ in the regex to do Contains or StartsWith + //Ideally don't use $ (end of string) since databases can stick extraneous stuff on the end in many cases + + private static readonly Regex BitRegex = BitRegexImpl(); + private static readonly Regex BoolRegex = BoolRegexImpl(); + protected Regex ByteRegex = ByteRegexImpl(); + protected Regex SmallIntRegex = SmallIntRe(); + protected Regex IntRegex = IntRe(); + protected Regex LongRegex = LongRe(); + protected readonly Regex DateRegex; + protected Regex TimeRegex = TimeRe(); + private static readonly Regex StringRegex = StringRe(); + private static readonly Regex ByteArrayRegex = ByteArrayRe(); + private static readonly Regex FloatingPointRegex = FloatingPointRe(); + private static readonly Regex GuidRegex = GuidRe(); + + /// + /// The maximum number of characters to declare explicitly in the char type (e.g. varchar(500)) before instead declaring the text/varchar(max) etc type + /// appropriate to the database engine being targeted + /// + private readonly int MaxStringWidthBeforeMax; + + /// + /// The size to declare string fields when the API user has neglected to supply a length. This should be high, if you want to avoid lots of extra long columns + /// use to determine the required length/type at runtime. + /// + private readonly int StringWidthWhenNotSupplied; + + /// + /// + /// + /// + /// + /// + protected TypeTranslater(Regex dateRegex, int maxStringWidthBeforeMax, int stringWidthWhenNotSupplied) + { + DateRegex = dateRegex; + MaxStringWidthBeforeMax = maxStringWidthBeforeMax; + StringWidthWhenNotSupplied = stringWidthWhenNotSupplied; + } + + public string GetSQLDBTypeForCSharpType(DatabaseTypeRequest request) + { + var t = request.CSharpType; + + if (t == typeof(bool) || t == typeof(bool?)) + return GetBoolDataType(); + + if (t == typeof(byte)) + return GetByteDataType(); + + if (t == typeof(short) || t == typeof(short) || t == typeof(ushort) || t == typeof(short?) || t == typeof(ushort?)) + return GetSmallIntDataType(); + + if (t == typeof(int) || t == typeof(int) || t == typeof(uint) || t == typeof(int?) || t == typeof(uint?)) + return GetIntDataType(); + + if (t == typeof(long) || t == typeof(ulong) || t == typeof(long?) || t == typeof(ulong?)) + return GetBigIntDataType(); + + if (t == typeof(float) || t == typeof(float?) || t == typeof(double) || + t == typeof(double?) || t == typeof(decimal) || + t == typeof(decimal?)) + return GetFloatingPointDataType(request.Size); + + if (t == typeof(string)) return request.Unicode ? GetUnicodeStringDataType(request.Width) : GetStringDataType(request.Width); + + if (t == typeof(DateTime) || t == typeof(DateTime?)) + return GetDateDateTimeDataType(); + + if (t == typeof(TimeSpan) || t == typeof(TimeSpan?)) + return GetTimeDataType(); + + if (t == typeof(byte[])) + return GetByteArrayDataType(); + + if (t == typeof(Guid)) + return GetGuidDataType(); + + throw new TypeNotMappedException(string.Format(FAnsiStrings.TypeTranslater_GetSQLDBTypeForCSharpType_Unsure_what_SQL_type_to_use_for_CSharp_Type___0_____TypeTranslater_was___1__, t.Name, GetType().Name)); + } + + private static string GetByteArrayDataType() => "varbinary(max)"; + + private static string GetByteDataType() => "tinyint"; + + private static string GetFloatingPointDataType(DecimalSize decimalSize) + { + if (decimalSize == null || decimalSize.IsEmpty) + return "decimal(20,10)"; + + return $"decimal({decimalSize.Precision},{decimalSize.Scale})"; + } + + protected virtual string GetDateDateTimeDataType() => "datetime"; + + protected string GetStringDataType(int? maxExpectedStringWidth) + { + if (maxExpectedStringWidth == null) + return GetStringDataTypeImpl(StringWidthWhenNotSupplied); + + if (maxExpectedStringWidth > MaxStringWidthBeforeMax) + return GetStringDataTypeWithUnlimitedWidth(); + + return GetStringDataTypeImpl(maxExpectedStringWidth.Value); + } + + protected virtual string GetStringDataTypeImpl(int maxExpectedStringWidth) => $"varchar({maxExpectedStringWidth})"; + + public abstract string GetStringDataTypeWithUnlimitedWidth(); + + + private string GetUnicodeStringDataType(int? maxExpectedStringWidth) + { + if (maxExpectedStringWidth == null) + return GetUnicodeStringDataTypeImpl(StringWidthWhenNotSupplied); + + if (maxExpectedStringWidth > MaxStringWidthBeforeMax) + return GetUnicodeStringDataTypeWithUnlimitedWidth(); + + return GetUnicodeStringDataTypeImpl(maxExpectedStringWidth.Value); + } + + protected virtual string GetUnicodeStringDataTypeImpl(int maxExpectedStringWidth) => $"nvarchar({maxExpectedStringWidth})"; + + public abstract string GetUnicodeStringDataTypeWithUnlimitedWidth(); + + protected virtual string GetTimeDataType() => "time"; + + protected virtual string GetBoolDataType() => "boolean"; + + protected virtual string GetSmallIntDataType() => "smallint"; + + protected virtual string GetIntDataType() => "int"; + + protected virtual string GetBigIntDataType() => "bigint"; + + private static string GetGuidDataType() => "uniqueidentifier"; + + /// + [return: + DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicFields)] + public Type GetCSharpTypeForSQLDBType(string? sqlType) => + TryGetCSharpTypeForSQLDBType(sqlType) ?? + throw new TypeNotMappedException(string.Format( + FAnsiStrings + .TypeTranslater_GetCSharpTypeForSQLDBType_No_CSharp_type_mapping_exists_for_SQL_type___0____TypeTranslater_was___1___, + sqlType, GetType().Name)); + + /// + [return: + DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicFields)] + public Type? TryGetCSharpTypeForSQLDBType(string? sqlType) + { + if (string.IsNullOrWhiteSpace(sqlType)) return null; + + if (IsBit(sqlType)) + return typeof(bool); + + if (IsByte(sqlType)) + return typeof(byte); + + if (IsSmallInt(sqlType)) + return typeof(short); + + if (IsInt(sqlType)) + return typeof(int); + + if (IsLong(sqlType)) + return typeof(long); + + if (IsFloatingPoint(sqlType)) + return typeof(decimal); + + if (IsString(sqlType)) + return typeof(string); + + if (IsDate(sqlType)) + return typeof(DateTime); + + if (IsTime(sqlType)) + return typeof(TimeSpan); + + if (IsByteArray(sqlType)) + return typeof(byte[]); + + if (IsGuid(sqlType)) + return typeof(Guid); + + return null; + } + + /// + public bool IsSupportedSQLDBType(string sqlType) => TryGetCSharpTypeForSQLDBType(sqlType) != null; + + /// + public DbType GetDbTypeForSQLDBType(string sqlType) + { + + if (IsBit(sqlType)) + return DbType.Boolean; + + if (IsByte(sqlType)) + return DbType.Byte; + + if (IsSmallInt(sqlType)) + return DbType.Int16; + + if (IsInt(sqlType)) + return DbType.Int32; + + if (IsLong(sqlType)) + return DbType.Int64; + + if (IsFloatingPoint(sqlType)) + return DbType.Decimal; + + if (IsString(sqlType)) + return DbType.String; + + if (IsDate(sqlType)) + return DbType.DateTime; + + if (IsTime(sqlType)) + return DbType.Time; + + if (IsByteArray(sqlType)) + return DbType.Object; + + if (IsGuid(sqlType)) + return DbType.Guid; + + throw new TypeNotMappedException(string.Format( + FAnsiStrings + .TypeTranslater_GetCSharpTypeForSQLDBType_No_CSharp_type_mapping_exists_for_SQL_type___0____TypeTranslater_was___1___, + sqlType, GetType().Name)); + } + + public virtual DatabaseTypeRequest GetDataTypeRequestForSQLDBType(string sqlType) + { + var cSharpType = GetCSharpTypeForSQLDBType(sqlType); + + var digits = GetDigitsBeforeAndAfterDecimalPointIfDecimal(sqlType); + + var lengthIfString = GetLengthIfString(sqlType); + + //lengthIfString should still be populated even for digits etc because it might be that we have to fallback from "1.2" which is decimal(2,1) to varchar(3) if we see "F" appearing + if (digits != null) + lengthIfString = Math.Max(lengthIfString, digits.ToStringLength()); + + if (cSharpType == typeof(DateTime)) + lengthIfString = GetStringLengthForDateTime(); + + if (cSharpType == typeof(TimeSpan)) + lengthIfString = GetStringLengthForTimeSpan(); + + var request = new DatabaseTypeRequest(cSharpType, lengthIfString, digits); + + if (cSharpType == typeof(string)) + request.Unicode = IsUnicode(sqlType); + + return request; + } + + /// + /// Returns true if the (proprietary DBMS type) is a unicode string type e.g. "nvarchar". Otherwise returns false + /// e.g. "varchar" + /// + /// + /// + private static bool IsUnicode(string sqlType) => sqlType != null && sqlType.StartsWith("n", StringComparison.CurrentCultureIgnoreCase); + + public virtual Guesser GetGuesserFor(DiscoveredColumn discoveredColumn) => GetGuesserFor(discoveredColumn, 0); + + protected Guesser GetGuesserFor(DiscoveredColumn discoveredColumn, int extraLengthPerNonAsciiCharacter) + { + var reqType = GetDataTypeRequestForSQLDBType(discoveredColumn.DataType.SQLType); + return new Guesser(reqType) + { + ExtraLengthPerNonAsciiCharacter = extraLengthPerNonAsciiCharacter + }; + } + + public virtual int GetLengthIfString(string sqlType) + { + if (string.IsNullOrWhiteSpace(sqlType)) + return -1; + + if (sqlType.Contains("(max)", StringComparison.OrdinalIgnoreCase) || sqlType.ToLower().Equals("text") || sqlType.ToLower().Equals("ntext")) + return int.MaxValue; + + if (sqlType.Contains("char", StringComparison.OrdinalIgnoreCase)) + { + var match = StringSizeRegex().Match(sqlType); + if (match.Success) + return int.Parse(match.Groups[1].Value); + } + + return -1; + } + + public DecimalSize? GetDigitsBeforeAndAfterDecimalPointIfDecimal(string sqlType) + { + if (string.IsNullOrWhiteSpace(sqlType)) + return null; + + var match = DecimalsBeforeAndAfterRe().Match(sqlType); + if (!match.Success) return null; + + var precision = int.Parse(match.Groups[1].Value); + var scale = int.Parse(match.Groups[2].Value); + return new DecimalSize(precision - scale, scale); + } + + public string TranslateSQLDBType(string sqlType, ITypeTranslater destinationTypeTranslater) + { + //e.g. data_type is datetime2 (i.e. Sql Server), this returns System.DateTime + var requested = GetDataTypeRequestForSQLDBType(sqlType); + + //this then returns datetime (e.g. mysql) + return destinationTypeTranslater.GetSQLDBTypeForCSharpType(requested); + } + + + /// + /// Return the number of characters required to not truncate/lose any data when altering a column from time (e.g. TIME etc) to varchar(x). Return + /// x such that the column does not lose integrity. This is needed when dynamically discovering what size to make a column by streaming data into a table. + /// if we see many times and nulls we will decide to use a time column then we see strings and have to convert the column to a varchar column without loosing the + /// currently loaded data. + /// + /// + private static int GetStringLengthForTimeSpan() => + /* + * + * To determine this you can run the following SQL: + + create table omgTimes ( +dt time +) + +insert into omgTimes values (CONVERT(TIME, GETDATE())) + +select * from omgTimes + +alter table omgTimes alter column dt varchar(100) + +select LEN(dt) from omgTimes + + + * + * */ + 16; //e.g. "13:10:58.2300000" + + /// + /// Return the number of characters required to not truncate/loose any data when altering a column from datetime (e.g. datetime2, DATE etc) to varchar(x). Return + /// x such that the column does not lose integrity. This is needed when dynamically discovering what size to make a column by streaming data into a table. + /// if we see many dates and nulls we will decide to use a date column then we see strings and have to convert the column to a varchar column without loosing the + /// currently loaded data. + /// + /// + private static int GetStringLengthForDateTime() => + /* + To determine this you can run the following SQL: + +create table omgdates ( +dt datetime2 +) + +insert into omgdates values (getdate()) + +select * from omgdates + +alter table omgdates alter column dt varchar(100) + +select LEN(dt) from omgdates + */ + Guesser.MinimumLengthRequiredForDateStringRepresentation; //e.g. "2018-01-30 13:05:45.1266667" + + protected virtual bool IsBit(string sqlType) => BitRegex.IsMatch(sqlType); + + protected virtual bool IsBool(string sqlType) => BoolRegex.IsMatch(sqlType); + protected bool IsByte(string sqlType) => ByteRegex.IsMatch(sqlType); + + protected virtual bool IsSmallInt(string sqlType) => SmallIntRegex.IsMatch(sqlType); + + protected virtual bool IsInt(string sqlType) => IntRegex.IsMatch(sqlType); + + protected virtual bool IsLong(string sqlType) => LongRegex.IsMatch(sqlType); + + protected bool IsDate(string sqlType) => DateRegex.IsMatch(sqlType); + + protected bool IsTime(string sqlType) => TimeRegex.IsMatch(sqlType); + + protected virtual bool IsString(string sqlType) => StringRegex.IsMatch(sqlType); + + protected virtual bool IsByteArray(string sqlType) => ByteArrayRegex.IsMatch(sqlType); + + protected virtual bool IsFloatingPoint(string sqlType) => FloatingPointRegex.IsMatch(sqlType); + + private static bool IsGuid(string sqlType) => GuidRegex.IsMatch(sqlType); + + [GeneratedRegex(StringSizeRegexPattern)] + private static partial Regex StringSizeRegex(); + [GeneratedRegex("^(bit)|(bool)|(boolean)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex BitRegexImpl(); + + [GeneratedRegex("^bool", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BoolRegexImpl(); + + [GeneratedRegex("^tinyint", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + + private static partial Regex ByteRegexImpl(); + [GeneratedRegex("^uniqueidentifier", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex GuidRe(); + [GeneratedRegex("^smallint", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex SmallIntRe(); + [GeneratedRegex("^(int)|(integer)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex IntRe(); + [GeneratedRegex("^bigint", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex LongRe(); + [GeneratedRegex("^time$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex TimeRe(); + [GeneratedRegex("(char)|(text)|(xml)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex StringRe(); + [GeneratedRegex("(binary)|(blob)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex ByteArrayRe(); + [GeneratedRegex("^(float)|(decimal)|(numeric)|(real)|(money)|(smallmoney)|(double)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex FloatingPointRe(); + [GeneratedRegex(DecimalsBeforeAndAfterPattern)] + private static partial Regex DecimalsBeforeAndAfterRe(); +} \ No newline at end of file diff --git a/FAnsi.Core/Discovery/TypeTranslation/TypeTranslation.cd b/FAnsi.Core/Discovery/TypeTranslation/TypeTranslation.cd new file mode 100644 index 00000000..f6520cf3 --- /dev/null +++ b/FAnsi.Core/Discovery/TypeTranslation/TypeTranslation.cd @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + EtAAECiWEAA8gAgMEBAQMACAEDAFwDAQCgKSSCCAdgA= + Discovery\TypeTranslation\TypeTranslater.cs + + + + + + + gEMYIIGCEIAQSBAggAABBASKAAgkgpGAkAAAABFiAZk= + Discovery\QuerySyntaxHelper.cs + + + + + + + + + + + + + AAQAAAAQACAAAAAAgABAKAAAABAAAACAAAAAAAACAAA= + Discovery\DatabaseColumnRequest.cs + + + + + + + + + + AARAIAAAAAIAAEAEgAAAQAAAABwAAIDAAAAAAAACBAA= + Discovery\DiscoveredColumn.cs + + + + + + + + + + + AAAAAAEEAAAAAAAEkAFAAAAAAAAAAIAAAIAACAEAAAA= + Discovery\DiscoveredDataType.cs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AAAAAACEAAAAAAgAEBAAIAAAACAAACAAAAAAAAAABgA= + Discovery\TypeTranslation\ITypeTranslater.cs + + + + + + AEMQIIGAEIAACBAgAAABBACKAAgkghGAEAAAAABgAYk= + Discovery\QuerySyntax\IQuerySyntaxHelper.cs + + + + \ No newline at end of file diff --git a/FAnsi.Core/Exceptions/AlterFailedException.cs b/FAnsi.Core/Exceptions/AlterFailedException.cs new file mode 100644 index 00000000..315082d0 --- /dev/null +++ b/FAnsi.Core/Exceptions/AlterFailedException.cs @@ -0,0 +1,8 @@ +using System; + +namespace FAnsi.Exceptions; + +/// +/// Thrown when a schema alter statement fails +/// +public sealed class AlterFailedException(string message, Exception inner) : Exception(message, inner); \ No newline at end of file diff --git a/FAnsi.Core/Exceptions/CircularDependencyException.cs b/FAnsi.Core/Exceptions/CircularDependencyException.cs new file mode 100644 index 00000000..3c21f4b9 --- /dev/null +++ b/FAnsi.Core/Exceptions/CircularDependencyException.cs @@ -0,0 +1,8 @@ +using System; + +namespace FAnsi.Exceptions; + +/// +/// Thrown when the foreign key constraints between a set of tables result in a circular reference (A depends on B, B depends on C, C depends on A) +/// +public sealed class CircularDependencyException(string msg) : Exception(msg); \ No newline at end of file diff --git a/FAnsi.Core/Exceptions/ImplementationNotFoundException.cs b/FAnsi.Core/Exceptions/ImplementationNotFoundException.cs new file mode 100644 index 00000000..135eb114 --- /dev/null +++ b/FAnsi.Core/Exceptions/ImplementationNotFoundException.cs @@ -0,0 +1,5 @@ +using System; + +namespace FAnsi.Exceptions; + +public sealed class ImplementationNotFoundException(string message) : Exception(message); \ No newline at end of file diff --git a/FAnsi.Core/Exceptions/InvalidResizeException.cs b/FAnsi.Core/Exceptions/InvalidResizeException.cs new file mode 100644 index 00000000..0035047f --- /dev/null +++ b/FAnsi.Core/Exceptions/InvalidResizeException.cs @@ -0,0 +1,8 @@ +using System; + +namespace FAnsi.Exceptions; + +/// +/// Exception thrown when you ask to resize a column to a size that is smaller than it's current size +/// +public sealed class InvalidResizeException(string s) : Exception(s); \ No newline at end of file diff --git a/FAnsi.Core/Extensions/DataColumnExtensions.cs b/FAnsi.Core/Extensions/DataColumnExtensions.cs new file mode 100644 index 00000000..5f49b505 --- /dev/null +++ b/FAnsi.Core/Extensions/DataColumnExtensions.cs @@ -0,0 +1,39 @@ +using System.Data; + +namespace FAnsi.Extensions; + +public static class DataColumnExtensions +{ + /// + /// Extended Property you can set in () to true to suppress Parsing and + /// Type Guessing for specific string columns. Use to set this + /// + public const string DoNotReTypeExtendedProperty = "DoNotReType"; + + /// + /// Sets an on to indicate to FAnsi + /// that it should not attempt to change the Type of the column e.g. when creating a table based on the + /// . + /// + /// Method has no effect on columns where the is not + /// + /// + /// True to prevent retyping, false to allow it + public static void SetDoNotReType(this DataColumn dc, bool value) + { + if(!dc.ExtendedProperties.ContainsKey(DoNotReTypeExtendedProperty)) + dc.ExtendedProperties.Add(DoNotReTypeExtendedProperty, value); + else + dc.ExtendedProperties[DoNotReTypeExtendedProperty] = value; + } + + /// + /// Returns true if the of the lists + /// true for and the is string + /// + /// + /// + public static bool GetDoNotReType(this DataColumn dc) => + dc.DataType == typeof(string) && + dc.ExtendedProperties[DoNotReTypeExtendedProperty] is true; +} \ No newline at end of file diff --git a/FAnsi.Core/Extensions/DataTableExtensions.cs b/FAnsi.Core/Extensions/DataTableExtensions.cs new file mode 100644 index 00000000..c4c1c71b --- /dev/null +++ b/FAnsi.Core/Extensions/DataTableExtensions.cs @@ -0,0 +1,20 @@ +using System.Data; + +namespace FAnsi.Extensions; + +public static class DataTableExtensions +{ + /// + /// Sets an on all string columns of to indicate to FAnsi + /// that it should not attempt to change the Type of the string columns e.g. when creating a table. + /// + /// Method has no effect on columns where the is not + /// + /// + /// True to prevent datatype changes, false to allow + public static void SetDoNotReType(this DataTable dt, bool value) + { + foreach (DataColumn dc in dt.Columns) + dc.SetDoNotReType(value); + } +} \ No newline at end of file diff --git a/FAnsi.Core/FAnsi.Core.csproj b/FAnsi.Core/FAnsi.Core.csproj new file mode 100644 index 00000000..e59b4caf --- /dev/null +++ b/FAnsi.Core/FAnsi.Core.csproj @@ -0,0 +1,73 @@ + + + HIC.FAnsi.Core + 0.0.7 + HIC.FAnsi.Core + Health Informatics Centre - University of Dundee + Health Informatics Centre - University of Dundee + https://github.com/HicServices/FAnsiSql + GPL-3.0-or-later + false + Core interfaces and base functionality for FAnsiSql - a database management/ETL library that allows you to perform common SQL operations without having to know which Database Management System (DBMS) you are targetting. + Ansi,SQL,Core + HIC.FAnsi.Core + Health Informatics Centre, University of Dundee + HIC.FAnsi.Core + Core interfaces and base functionality for FAnsiSql + Copyright © 2019-2025 + false + true + true + CS1591 + en-GB + embedded + README.md + + + 1 + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + True + True + FAnsiStrings.resx + + + SR.resx + True + True + + + + + PublicResXFileCodeGenerator + FAnsiStrings.Designer.cs + + + Designer + SR.Designer.cs + PublicResXFileCodeGenerator + + + + + + + \ No newline at end of file diff --git a/FAnsi.Core/FAnsiStrings.Designer.cs b/FAnsi.Core/FAnsiStrings.Designer.cs new file mode 100644 index 00000000..6d74d4ea --- /dev/null +++ b/FAnsi.Core/FAnsiStrings.Designer.cs @@ -0,0 +1,496 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace FAnsi { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class FAnsiStrings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal FAnsiStrings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("FAnsi.FAnsiStrings", typeof(FAnsiStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to AggregateCustomLineCollection is missing some (but not all) Axis components. + /// + public static string AggregateCustomLineCollection_Validate_AggregateCustomLineCollection_is_missing_some__but_not_all__Axis_components { + get { + return ResourceManager.GetString("AggregateCustomLineCollection_Validate_AggregateCustomLineCollection_is_missing_s" + + "ome__but_not_all__Axis_components", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Column '{0}' appears in bulk insert DataTable but not in destination table '{1}'. + /// + public static string BulkCopy_ColumnNotInDestinationTable { + get { + return ResourceManager.GetString("BulkCopy_ColumnNotInDestinationTable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connection string keyword '{0}' did not support supplied value. + /// + public static string ConnectionStringKeyword_ValueNotSupported { + get { + return ResourceManager.GetString("ConnectionStringKeyword_ValueNotSupported", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not combine Types '{0}' and '{1}' because they were of differing Types and neither Type appeared in the PreferenceOrder. + /// + public static string DatabaseTypeRequest_Max_Could_not_combine_Types___0___and___1___because_they_were_of_differing_Types_and_neither_Type_appeared_in_the_PreferenceOrder { + get { + return ResourceManager.GetString("DatabaseTypeRequest_Max_Could_not_combine_Types___0___and___1___because_they_were" + + "_of_differing_Types_and_neither_Type_appeared_in_the_PreferenceOrder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to DataTable column {0} was of DataType {1}, this is not allowed. Use String for untyped data. + /// + public static string DataTable_Column__0__was_of_DataType__1___this_is_not_allowed___Use_String_for_untyped_data { + get { + return ResourceManager.GetString("DataTable_Column__0__was_of_DataType__1___this_is_not_allowed___Use_String_for_un" + + "typed_data", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not parse '{0}' to a valid DateTime. + /// + public static string DateTimeTypeDecider_ParseImpl_Could_not_parse___0___to_a_valid_DateTime { + get { + return ResourceManager.GetString("DateTimeTypeDecider_ParseImpl_Could_not_parse___0___to_a_valid_DateTime", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to DecideTypesForStrings abstract base was not passed any typesSupported by implementing derived class. + /// + public static string DecideTypesForStrings_DecideTypesForStrings_DecideTypesForStrings_abstract_base_was_not_passed_any_typesSupported_by_implementing_derived_class { + get { + return ResourceManager.GetString("DecideTypesForStrings_DecideTypesForStrings_DecideTypesForStrings_abstract_base_w" + + "as_not_passed_any_typesSupported_by_implementing_derived_class", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not parse string value '{0}' with Decider Type:{1}. + /// + public static string DecideTypesForStrings_Parse_Could_not_parse_string_value___0___with_Decider_Type__1_ { + get { + return ResourceManager.GetString("DecideTypesForStrings_Parse_Could_not_parse_string_value___0___with_Decider_Type_" + + "_1_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to CreateTable completed but TableCreated property was not correctly set by the implementing Helper API. + /// + public static string DiscoveredDatabase_CreateTableDidNotPopulateTableCreatedProperty { + get { + return ResourceManager.GetString("DiscoveredDatabase_CreateTableDidNotPopulateTableCreatedProperty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Database {0} does not exist so cannot be dropped. + /// + public static string DiscoveredDatabase_DatabaseDoesNotExistSoCannotBeDropped { + get { + return ResourceManager.GetString("DiscoveredDatabase_DatabaseDoesNotExistSoCannotBeDropped", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to DatabaseColumnRequest for column {0} must have either TypeRequested or ExplicitDbType. + /// + public static string DiscoveredDatabaseHelper_CreateTable_DatabaseColumnRequestMustHaveEitherTypeRequestedOrExplicitDbType { + get { + return ResourceManager.GetString("DiscoveredDatabaseHelper_CreateTable_DatabaseColumnRequestMustHaveEitherTypeReque" + + "stedOrExplicitDbType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Table name cannot be null. + /// + public static string DiscoveredDatabaseHelper_GetCreateTableSql_Table_name_cannot_be_null { + get { + return ResourceManager.GetString("DiscoveredDatabaseHelper_GetCreateTableSql_Table_name_cannot_be_null", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot resize DataType because it does not have a reference to a Column to which it belongs (possibly you are trying to resize a data type associated with a TableValuedFunction Parameter?). + /// + public static string DiscoveredDataType_AlterTypeTo_Cannot_resize_DataType_because_it_does_not_have_a_reference_to_a_Column_to_which_it_belongs { + get { + return ResourceManager.GetString("DiscoveredDataType_AlterTypeTo_Cannot_resize_DataType_because_it_does_not_have_a_" + + "reference_to_a_Column_to_which_it_belongs", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to send resize SQL:{0}. + /// + public static string DiscoveredDataType_AlterTypeTo_Failed_to_send_resize_SQL__0_ { + get { + return ResourceManager.GetString("DiscoveredDataType_AlterTypeTo_Failed_to_send_resize_SQL__0_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot shrink column, number of digits after the decimal point is currently {0} and you asked to set it to {1} (Current SQLType is {2}). + /// + public static string DiscoveredDataType_Resize_Cannot_shrink_column__number_of_digits_after_the_decimal_point_is_currently__0__and_you_asked_to_set_it_to__1___Current_SQLType_is__2__ { + get { + return ResourceManager.GetString("DiscoveredDataType_Resize_Cannot_shrink_column__number_of_digits_after_the_decima" + + "l_point_is_currently__0__and_you_asked_to_set_it_to__1___Current_SQLType_is__2__" + + "", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot shrink column, number of digits before the decimal point is currently {0} and you asked to set it to {1} (Current SQLType is {2}). + /// + public static string DiscoveredDataType_Resize_Cannot_shrink_column__number_of_digits_before_the_decimal_point_is_currently__0__and_you_asked_to_set_it_to__1___Current_SQLType_is__2__ { + get { + return ResourceManager.GetString("DiscoveredDataType_Resize_Cannot_shrink_column__number_of_digits_before_the_decim" + + "al_point_is_currently__0__and_you_asked_to_set_it_to__1___Current_SQLType_is__2_" + + "_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Illegal attempt to resize column to a smaller size. You can only grow columns. Request was to convert from '{0}' to '{1}'. + /// + public static string DiscoveredDataType_Resize_CannotResizeSmaller { + get { + return ResourceManager.GetString("DiscoveredDataType_Resize_CannotResizeSmaller", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to DataType cannot be resized to decimal because it is of data type {0}. + /// + public static string DiscoveredDataType_Resize_DataType_cannot_be_resized_to_decimal_because_it_is_of_data_type__0_ { + get { + return ResourceManager.GetString("DiscoveredDataType_Resize_DataType_cannot_be_resized_to_decimal_because_it_is_of_" + + "data_type__0_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Helper '{0}' tried to create database '{1}' but the database didn't exist after the creation attempt. + /// + public static string DiscoveredServer_CreateDatabase_Helper___0___tried_to_create_database___1___but_the_database_didn_t_exist_after_the_creation_attempt { + get { + return ResourceManager.GetString("DiscoveredServer_CreateDatabase_Helper___0___tried_to_create_database___1___but_t" + + "he_database_didn_t_exist_after_the_creation_attempt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not connect to server '{0}' after timeout of {1} milliseconds). + /// + public static string DiscoveredServer_TestConnection_Could_not_connect_to_server___0___after_timeout_of__1__milliseconds_ { + get { + return ResourceManager.GetString("DiscoveredServer_TestConnection_Could_not_connect_to_server___0___after_timeout_o" + + "f__1__milliseconds_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to DiscoverColumn failed, could not find column called '{0}' in table '{1}'. + /// + public static string DiscoveredTable_DiscoverColumn_DiscoverColumn_failed__could_not_find_column_called___0___in_table___1__ { + get { + return ResourceManager.GetString("DiscoveredTable_DiscoverColumn_DiscoverColumn_failed__could_not_find_column_calle" + + "d___0___in_table___1__", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Insert failed, could not find column called '{0}' in table '{1}'. + /// + public static string DiscoveredTable_Insert_Insert_failed__could_not_find_column_called___0___in_table___1__ { + get { + return ResourceManager.GetString("DiscoveredTable_Insert_Insert_failed__could_not_find_column_called___0___in_table" + + "___1__", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to create index on table '{0}'. + /// + public static string DiscoveredTableHelper_CreateIndex_Failed { + get { + return ResourceManager.GetString("DiscoveredTableHelper_CreateIndex_Failed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to create primary key on table {0} using columns ({1}). + /// + public static string DiscoveredTableHelper_CreatePrimaryKey_Failed_to_create_primary_key_on_table__0__using_columns___1__ { + get { + return ResourceManager.GetString("DiscoveredTableHelper_CreatePrimaryKey_Failed_to_create_primary_key_on_table__0__" + + "using_columns___1__", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to drop index on table '{0}'. + /// + public static string DiscoveredTableHelper_DropIndex_Failed { + get { + return ResourceManager.GetString("DiscoveredTableHelper_DropIndex_Failed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to fetch index names for the table '{0}'. + /// + public static string DiscoveredTableHelper_GetIndexes_Failed { + get { + return ResourceManager.GetString("DiscoveredTableHelper_GetIndexes_Failed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rename is not supported for TableType {0}. + /// + public static string DiscoveredTableHelper_RenameTable_Rename_is_not_supported_for_TableType__0_ { + get { + return ResourceManager.GetString("DiscoveredTableHelper_RenameTable_Rename_is_not_supported_for_TableType__0_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Guesser does not support being passed hard typed objects (e.g. int) mixed with untyped objects (e.g. string). We were adjusting to compensate for object '{0}' which is of Type '{1}', we were previously passed a '{2}' type. + /// + public static string Guesser_AdjustToCompensateForValue_GuesserPassedMixedTypeValues { + get { + return ResourceManager.GetString("Guesser_AdjustToCompensateForValue_GuesserPassedMixedTypeValues", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No Type Decider exists for Type:{0}. + /// + public static string Guesser_ThrowIfNotSupported_No_Type_Decider_exists_for_Type__0_ { + get { + return ResourceManager.GetString("Guesser_ThrowIfNotSupported_No_Type_Decider_exists_for_Type__0_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No implementation found for ADO.Net object of Type {0}. + /// + public static string ImplementationManager_GetImplementation_No_implementation_found_for_ADO_Net_object_of_Type__0_ { + get { + return ResourceManager.GetString("ImplementationManager_GetImplementation_No_implementation_found_for_ADO_Net_objec" + + "t_of_Type__0_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No implementation found for DatabaseType {0}. + /// + public static string ImplementationManager_GetImplementation_No_implementation_found_for_DatabaseType__0_ { + get { + return ResourceManager.GetString("ImplementationManager_GetImplementation_No_implementation_found_for_DatabaseType_" + + "_0_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Directory '{0}'did not exist. + /// + public static string ImplementationManager_Load_Directory___0__did_not_exist { + get { + return ResourceManager.GetString("ImplementationManager_Load_Directory___0__did_not_exist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to String passed to EnsureWrapped '{0}' contained separators (not allowed). Prohibited Separator is '{1}'. + /// + public static string QuerySyntaxHelper_EnsureWrapped_String_passed_to_EnsureWrapped___0___contained_separators__not_allowed____Prohibited_Separator_is___1__ { + get { + return ResourceManager.GetString("QuerySyntaxHelper_EnsureWrapped_String_passed_to_EnsureWrapped___0___contained_se" + + "parators__not_allowed____Prohibited_Separator_is___1__", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not GetParameter for column '{0}'. See InnerException for details. + /// + public static string QuerySyntaxHelper_GetParameter_Could_not_GetParameter_for_column___0__ { + get { + return ResourceManager.GetString("QuerySyntaxHelper_GetParameter_Could_not_GetParameter_for_column___0__", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Line must contain at least one pair of parentheses. + /// + public static string QuerySyntaxHelper_SplitLineIntoOuterMostMethodAndContents_Line_must_contain_at_least_one_pair_of_parentheses { + get { + return ResourceManager.GetString("QuerySyntaxHelper_SplitLineIntoOuterMostMethodAndContents_Line_must_contain_at_le" + + "ast_one_pair_of_parentheses", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Line must not be blank. + /// + public static string QuerySyntaxHelper_SplitLineIntoOuterMostMethodAndContents_Line_must_not_be_blank { + get { + return ResourceManager.GetString("QuerySyntaxHelper_SplitLineIntoOuterMostMethodAndContents_Line_must_not_be_blank", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The number of opening and closing parentheses must match. + /// + public static string QuerySyntaxHelper_SplitLineIntoOuterMostMethodAndContents_The_number_of_opening_and_closing_parentheses_must_match { + get { + return ResourceManager.GetString("QuerySyntaxHelper_SplitLineIntoOuterMostMethodAndContents_The_number_of_opening_a" + + "nd_closing_parentheses_must_match", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not split SQL line because there were {0} instances of the alias regex. Sql line was: + ///{1}. + /// + public static string QuerySyntaxHelper_SplitLineIntoSelectSQLAndAlias_ { + get { + return ResourceManager.GetString("QuerySyntaxHelper_SplitLineIntoSelectSQLAndAlias_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} name '{1}' contained unsupported (by FAnsi) characters. Unsupported characters are:{2}. + /// + public static string QuerySyntaxHelper_ValidateName__0__name___1___contained_unsupported__by_FAnsi__characters___Unsupported_characters_are__2_ { + get { + return ResourceManager.GetString("QuerySyntaxHelper_ValidateName__0__name___1___contained_unsupported__by_FAnsi__ch" + + "aracters___Unsupported_characters_are__2_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} name '{1}' is too long for the DBMS ({2} supports maximum length of {3}). + /// + public static string QuerySyntaxHelper_ValidateName__0__name___1___is_too_long_for_the_DBMS___2__supports_maximum_length_of__3__ { + get { + return ResourceManager.GetString("QuerySyntaxHelper_ValidateName__0__name___1___is_too_long_for_the_DBMS___2__suppo" + + "rts_maximum_length_of__3__", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} name cannot be blank. + /// + public static string QuerySyntaxHelper_ValidateName__0__name_cannot_be_blank { + get { + return ResourceManager.GetString("QuerySyntaxHelper_ValidateName__0__name_cannot_be_blank", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Found at least one cycle in relationship dependency. + /// + public static string RelationshipTopologicalSort_FoundCircularDependencies { + get { + return ResourceManager.GetString("RelationshipTopologicalSort_FoundCircularDependencies", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to DataType {0} does not have an associated IDecideTypesForStrings. + /// + public static string TypeDeciderFactory_Create_DataType__0__does_not_have_an_associated_IDecideTypesForStrings { + get { + return ResourceManager.GetString("TypeDeciderFactory_Create_DataType__0__does_not_have_an_associated_IDecideTypesFo" + + "rStrings", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No CSharp type mapping exists for SQL type '{0}'. TypeTranslater was '{1}'). + /// + public static string TypeTranslater_GetCSharpTypeForSQLDBType_No_CSharp_type_mapping_exists_for_SQL_type___0____TypeTranslater_was___1___ { + get { + return ResourceManager.GetString("TypeTranslater_GetCSharpTypeForSQLDBType_No_CSharp_type_mapping_exists_for_SQL_ty" + + "pe___0____TypeTranslater_was___1___", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unsure what SQL type to use for CSharp Type '{0}'. TypeTranslater was '{1}'. + /// + public static string TypeTranslater_GetSQLDBTypeForCSharpType_Unsure_what_SQL_type_to_use_for_CSharp_Type___0_____TypeTranslater_was___1__ { + get { + return ResourceManager.GetString("TypeTranslater_GetSQLDBTypeForCSharpType_Unsure_what_SQL_type_to_use_for_CSharp_T" + + "ype___0_____TypeTranslater_was___1__", resourceCulture); + } + } + } +} diff --git a/FAnsi.Core/FAnsiStrings.resx b/FAnsi.Core/FAnsiStrings.resx new file mode 100644 index 00000000..88009b71 --- /dev/null +++ b/FAnsi.Core/FAnsiStrings.resx @@ -0,0 +1,258 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Column '{0}' appears in bulk insert DataTable but not in destination table '{1}' + + + Connection string keyword '{0}' did not support supplied value + We tell them the keyword that is bad but not the value because that could leak secure info e.g. port numbers? IP or passwords? + + + Found at least one cycle in relationship dependency + + + Database {0} does not exist so cannot be dropped + + + CreateTable completed but TableCreated property was not correctly set by the implementing Helper API + + + DatabaseColumnRequest for column {0} must have either TypeRequested or ExplicitDbType + + + Table name cannot be null + + + Illegal attempt to resize column to a smaller size. You can only grow columns. Request was to convert from '{0}' to '{1}' + + + DataType cannot be resized to decimal because it is of data type {0} + + + Cannot shrink column, number of digits before the decimal point is currently {0} and you asked to set it to {1} (Current SQLType is {2}) + + + Cannot shrink column, number of digits after the decimal point is currently {0} and you asked to set it to {1} (Current SQLType is {2}) + + + Cannot resize DataType because it does not have a reference to a Column to which it belongs (possibly you are trying to resize a data type associated with a TableValuedFunction Parameter?) + + + Could not parse '{0}' to a valid DateTime + + + DecideTypesForStrings abstract base was not passed any typesSupported by implementing derived class + + + Could not parse string value '{0}' with Decider Type:{1} + + + DataType {0} does not have an associated IDecideTypesForStrings + + + Could not combine Types '{0}' and '{1}' because they were of differing Types and neither Type appeared in the PreferenceOrder + + + Guesser does not support being passed hard typed objects (e.g. int) mixed with untyped objects (e.g. string). We were adjusting to compensate for object '{0}' which is of Type '{1}', we were previously passed a '{2}' type + + + No Type Decider exists for Type:{0} + + + Unsure what SQL type to use for CSharp Type '{0}'. TypeTranslater was '{1}' + + + No CSharp type mapping exists for SQL type '{0}'. TypeTranslater was '{1}') + + + Failed to send resize SQL:{0} + + + Could not connect to server '{0}' after timeout of {1} milliseconds) + + + Helper '{0}' tried to create database '{1}' but the database didn't exist after the creation attempt + + + Insert failed, could not find column called '{0}' in table '{1}' + + + DiscoverColumn failed, could not find column called '{0}' in table '{1}' + + + Rename is not supported for TableType {0} + + + Failed to create primary key on table {0} using columns ({1}) + + + String passed to EnsureWrapped '{0}' contained separators (not allowed). Prohibited Separator is '{1}' + + + Could not split SQL line because there were {0} instances of the alias regex. Sql line was: +{1} + + + Line must not be blank + + + The number of opening and closing parentheses must match + + + Line must contain at least one pair of parentheses + + + Could not GetParameter for column '{0}'. See InnerException for details + + + {0} name cannot be blank + {0} is an database object type e.g. "Database", "Table" etc + + + {0} name '{1}' is too long for the DBMS ({2} supports maximum length of {3}) + + + {0} name '{1}' contained unsupported (by FAnsi) characters. Unsupported characters are:{2} + + + Directory '{0}'did not exist + + + No implementation found for DatabaseType {0} + + + No implementation found for ADO.Net object of Type {0} + + + AggregateCustomLineCollection is missing some (but not all) Axis components + + + DataTable column {0} was of DataType {1}, this is not allowed. Use String for untyped data + + + Unable to create index on table '{0}' + + + Unable to drop index on table '{0}' + + + Unable to fetch index names for the table '{0}' + + \ No newline at end of file diff --git a/FAnsi.Core/Implementation/IImplementation.cs b/FAnsi.Core/Implementation/IImplementation.cs new file mode 100644 index 00000000..f172d338 --- /dev/null +++ b/FAnsi.Core/Implementation/IImplementation.cs @@ -0,0 +1,17 @@ +using System.Data.Common; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; + +namespace FAnsi.Implementation; + +public interface IImplementation +{ + DbConnectionStringBuilder GetBuilder(); + IDiscoveredServerHelper GetServerHelper(); + + bool IsFor(DatabaseType databaseType); + bool IsFor(DbConnectionStringBuilder builder); + bool IsFor(DbConnection connection); + + IQuerySyntaxHelper GetQuerySyntaxHelper(); +} \ No newline at end of file diff --git a/FAnsi.Core/Implementation/Implementation.cs b/FAnsi.Core/Implementation/Implementation.cs new file mode 100644 index 00000000..886e0785 --- /dev/null +++ b/FAnsi.Core/Implementation/Implementation.cs @@ -0,0 +1,21 @@ +using System.Data.Common; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; + +namespace FAnsi.Implementation; + +public abstract class Implementation(DatabaseType type) : IImplementation + where T : DbConnectionStringBuilder, new() +{ + public virtual DbConnectionStringBuilder GetBuilder() => new T(); + + public abstract IDiscoveredServerHelper GetServerHelper(); + + public virtual bool IsFor(DatabaseType databaseType) => type == databaseType; + + public virtual bool IsFor(DbConnectionStringBuilder builder) => builder is T; + + public abstract bool IsFor(DbConnection connection); + + public abstract IQuerySyntaxHelper GetQuerySyntaxHelper(); +} \ No newline at end of file diff --git a/FAnsi.Core/Implementation/ImplementationManager.cs b/FAnsi.Core/Implementation/ImplementationManager.cs new file mode 100644 index 00000000..2df7a1d7 --- /dev/null +++ b/FAnsi.Core/Implementation/ImplementationManager.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Data.Common; +using System.Linq; +using System.Reflection; +using FAnsi.Exceptions; + +namespace FAnsi.Implementation; + +/// +/// Handles detecting and loading implementations +/// +public sealed class ImplementationManager +{ + private static readonly ImplementationManager Instance=new(); + + /// + /// Collection of all the currently loaded API . Normally you only want 1 implementation per DBMS. + /// + private readonly List _implementations; + + private ImplementationManager() + { + _implementations = []; + } + + /// + /// loads all implementations in the assembly hosting the + /// + /// + public static void Load() where T:IImplementation,new() + { + var loading = new T(); + if (!Instance._implementations.Contains(loading)) + Instance._implementations.Add(loading); + } + + + public static IImplementation GetImplementation(DatabaseType databaseType) + { + return GetImplementation(i => i.IsFor(databaseType), + string.Format( + FAnsiStrings.ImplementationManager_GetImplementation_No_implementation_found_for_DatabaseType__0_, + databaseType)); + } + + public static IImplementation GetImplementation(DbConnectionStringBuilder connectionStringBuilder) + { + return GetImplementation(i => i.IsFor(connectionStringBuilder), + string.Format( + FAnsiStrings + .ImplementationManager_GetImplementation_No_implementation_found_for_ADO_Net_object_of_Type__0_, + connectionStringBuilder.GetType())); + } + + public static IImplementation GetImplementation(DbConnection connection) + { + return GetImplementation(i => i.IsFor(connection), + string.Format( + FAnsiStrings + .ImplementationManager_GetImplementation_No_implementation_found_for_ADO_Net_object_of_Type__0_, + connection.GetType())); + } + private static IImplementation GetImplementation(Func condition, string errorIfNotFound) => Instance?._implementations.FirstOrDefault(condition)??throw new ImplementationNotFoundException(errorIfNotFound); + + /// + /// Returns all currently loaded implementations or null if no implementations have been loaded + /// + /// + public static ReadOnlyCollection GetImplementations() => Instance._implementations.AsReadOnly(); + + /// + /// Clears all currently loaded + /// + public static void Clear() + { + Instance._implementations.Clear(); + } + + [Obsolete("MEF is dead")] + public static void Load(params Assembly[] _) + { + } +} \ No newline at end of file diff --git a/FAnsi.Core/Naming/IHasFullyQualifiedNameToo.cs b/FAnsi.Core/Naming/IHasFullyQualifiedNameToo.cs new file mode 100644 index 00000000..b89adcea --- /dev/null +++ b/FAnsi.Core/Naming/IHasFullyQualifiedNameToo.cs @@ -0,0 +1,16 @@ +namespace FAnsi.Naming; + +/// +/// A ojbect which has a Fully Qualified Name. Fully Qualified Names are database strings in which the full path (database,name,table,column) are specified. +/// For example a 'Fully Qualified Name' for a table in Microsoft Sql Server could be '[MyDatabase]..[MyTable]'. A 'Fully Qualified Name' for a column could +/// be '[MyDatabase]..[MyTable].[MyColumn]'. The 'Runtime Name' for the previous 2 examples would be 'MyTable' and 'MyColumn' respectively. +/// +public interface IHasFullyQualifiedNameToo:IHasRuntimeName +{ + /// + /// Returns the fully qualified name of the object including both the database, table and (if applicable) column name e.g. "[MyDatabase]..[MyTable].[MyColumn]". + /// The returned value should be wraped with the appropriate qualifier characters such that it will be valid in SQL queries even if it has spaces, starts with numbers etc + /// + /// + string GetFullyQualifiedName(); +} \ No newline at end of file diff --git a/FAnsi.Core/Naming/IHasRuntimeName.cs b/FAnsi.Core/Naming/IHasRuntimeName.cs new file mode 100644 index 00000000..e3a15822 --- /dev/null +++ b/FAnsi.Core/Naming/IHasRuntimeName.cs @@ -0,0 +1,17 @@ +namespace FAnsi.Naming; + +/// +/// Interface for an object which references a database location (e.g. a column or a table or a database etc). The 'RuntimeName' is defined as an unqualified string +/// as it could be used at runtime e.g. in an DbDataReader. So for example a TableInfo called '[MyDb]..[MyTbl]' would have a 'RuntimeName' of 'MyTbl'. +/// +/// This also must take into account aliases so an ExtractionInformation class defined as 'UPPER([MyDb]..[MyTbl].[Name]) as CapsName' would have a 'RuntimeName' +/// of 'CapsName'. +/// +public interface IHasRuntimeName +{ + /// + /// Returns the name of a table/column without qualifiers e.g. returns "Name" for the column "[MyDatabase]..[MyTable].[Name]" + /// + /// + string? GetRuntimeName(); +} \ No newline at end of file diff --git a/FAnsi.Core/SR.Designer.cs b/FAnsi.Core/SR.Designer.cs new file mode 100644 index 00000000..b2b311a4 --- /dev/null +++ b/FAnsi.Core/SR.Designer.cs @@ -0,0 +1,133 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace FAnsi { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class SR { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + public SR() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("FAnsi.SR", typeof(SR).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to BulkInsert failed on data row {0}:{1}. + /// + public static string MicrosoftSQLBulkCopy_AttemptLineByLineInsert_BulkInsert_failed_on_data_row__0___1_ { + get { + return ResourceManager.GetString("MicrosoftSQLBulkCopy_AttemptLineByLineInsert_BulkInsert_failed_on_data_row__0___1" + + "_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to BulkInsert failed on data row {0} the complaint was about source column <<{1}>> which had value <<{2}>> destination data type was <<{3}>>{4}{5}. + /// + public static string MicrosoftSQLBulkCopy_AttemptLineByLineInsert_BulkInsert_failed_on_data_row__0__the_complaint_was_about_source_column____1____which_had_value____2____destination_data_type_was____3____4__5_ { + get { + return ResourceManager.GetString("MicrosoftSQLBulkCopy_AttemptLineByLineInsert_BulkInsert_failed_on_data_row__0__th" + + "e_complaint_was_about_source_column____1____which_had_value____2____destination_" + + "data_type_was____3____4__5_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to First Pass Exception:. + /// + public static string MicrosoftSQLBulkCopy_AttemptLineByLineInsert_First_Pass_Exception_ { + get { + return ResourceManager.GetString("MicrosoftSQLBulkCopy_AttemptLineByLineInsert_First_Pass_Exception_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Second Pass Exception: Bulk insert failed but when we tried to repeat it a line at a time it worked. + /// + public static string MicrosoftSQLBulkCopy_AttemptLineByLineInsert_Second_Pass_Exception__Bulk_insert_failed_but_when_we_tried_to_repeat_it_a_line_at_a_time_it_worked { + get { + return ResourceManager.GetString("MicrosoftSQLBulkCopy_AttemptLineByLineInsert_Second_Pass_Exception__Bulk_insert_f" + + "ailed_but_when_we_tried_to_repeat_it_a_line_at_a_time_it_worked", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Second Pass Exception: Failed to load data row {0} the following values were rejected by the database: {1}{2}{3}. + /// + public static string MicrosoftSQLBulkCopy_AttemptLineByLineInsert_Second_Pass_Exception__Failed_to_load_data_row__0__the_following_values_were_rejected_by_the_database___1__2__3_ { + get { + return ResourceManager.GetString("MicrosoftSQLBulkCopy_AttemptLineByLineInsert_Second_Pass_Exception__Failed_to_loa" + + "d_data_row__0__the_following_values_were_rejected_by_the_database___1__2__3_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to bulk insert:{0}. + /// + public static string MicrosoftSQLBulkCopy_BulkInsertWithBetterErrorMessages_Failed_to_bulk_insert__0_ { + get { + return ResourceManager.GetString("MicrosoftSQLBulkCopy_BulkInsertWithBetterErrorMessages_Failed_to_bulk_insert__0_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to bulk insert batch, line by line investigation also failed. InnerException[0] is the original Exception, InnerException[1] is the line by line failure. + /// + public static string MicrosoftSQLBulkCopy_BulkInsertWithBetterErrorMessages_Failed_to_bulk_insert_batch__line_by_line_investigation_also_failed___InnerException_0__is_the_original_Exception__InnerException_1__is_the_line_by_line_failure { + get { + return ResourceManager.GetString("MicrosoftSQLBulkCopy_BulkInsertWithBetterErrorMessages_Failed_to_bulk_insert_batc" + + "h__line_by_line_investigation_also_failed___InnerException_0__is_the_original_Ex" + + "ception__InnerException_1__is_the_line_by_line_failure", resourceCulture); + } + } + } +} diff --git a/FAnsi.Core/SR.resx b/FAnsi.Core/SR.resx new file mode 100644 index 00000000..526c88e8 --- /dev/null +++ b/FAnsi.Core/SR.resx @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + BulkInsert failed on data row {0} the complaint was about source column <<{1}>> which had value <<{2}>> destination data type was <<{3}>>{4}{5} + + + BulkInsert failed on data row {0}:{1} + + + Second Pass Exception: Failed to load data row {0} the following values were rejected by the database: {1}{2}{3} + + + Second Pass Exception: Bulk insert failed but when we tried to repeat it a line at a time it worked + + + Failed to bulk insert:{0} + + + Failed to bulk insert batch, line by line investigation also failed. InnerException[0] is the original Exception, InnerException[1] is the line by line failure + + + First Pass Exception: + + \ No newline at end of file diff --git a/FAnsi.Legacy/FAnsi.Legacy.csproj b/FAnsi.Legacy/FAnsi.Legacy.csproj new file mode 100644 index 00000000..1641488e --- /dev/null +++ b/FAnsi.Legacy/FAnsi.Legacy.csproj @@ -0,0 +1,48 @@ + + + HIC.FAnsiSql + 0.0.7 + HIC.FAnsiSql + Health Informatics Centre - University of Dundee + Health Informatics Centre - University of Dundee + https://github.com/HicServices/FAnsiSql + GPL-3.0-or-later + false + FAnsiSql is a database management/ETL library that allows you to perform common SQL operations without having to know which Database Management System (DBMS) you are targetting (e.g. Sql Server, My Sql, Oracle). This legacy package includes all supported DBMS implementations. + Ansi,SQL + HIC.FAnsiSql + Health Informatics Centre, University of Dundee + HIC.FAnsiSql + FAnsiSql legacy package with all DBMS implementations included + Copyright © 2019-2025 + false + true + true + CS1591 + en-GB + embedded + README.md + + + 1 + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/Aggregation/MicrosoftSQLAggregateHelper.cs b/FAnsi.MicrosoftSql/Aggregation/MicrosoftSQLAggregateHelper.cs new file mode 100644 index 00000000..46b294ff --- /dev/null +++ b/FAnsi.MicrosoftSql/Aggregation/MicrosoftSQLAggregateHelper.cs @@ -0,0 +1,360 @@ +using System; +using System.Linq; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.QuerySyntax.Aggregation; + +namespace FAnsi.Implementations.MicrosoftSQL.Aggregation; + +public sealed class MicrosoftSQLAggregateHelper : AggregateHelper +{ + private static string GetDateAxisTableDeclaration(IQueryAxis axis) + { + //if pivot dimension is set then this code appears inside dynamic SQL constant string that will be Exec'd so we have to escape single quotes + var startDateSql = axis.StartDate; + var endDateSql = axis.EndDate; + + return $""" + + DECLARE @startDate DATE + DECLARE @endDate DATE + + SET @startDate = {startDateSql} + SET @endDate = {endDateSql} + + DECLARE @dateAxis TABLE + ( + dt DATE + ) + + DECLARE @currentDate DATE = @startDate + + WHILE @currentDate <= @endDate + BEGIN + INSERT INTO @dateAxis + SELECT @currentDate + + SET @currentDate = DATEADD({axis.AxisIncrement}, 1, @currentDate) + + END + + """; + + } + + /// + /// Takes the field name/transform from the dataset and wraps it with the date adjustment function specified by the AxisIncrement + /// + /// + /// + /// + public override string GetDatePartOfColumn(AxisIncrement increment, string columnSql) => + increment switch + { + AxisIncrement.Day => + $" Convert(date, {columnSql})" //Handles when there are times in the field by always converting to date + , + AxisIncrement.Month => $" CONVERT(nvarchar(7),{columnSql},126)" //returns 2015-01 + , + AxisIncrement.Year => $" YEAR({columnSql})" //returns 2015 + , + AxisIncrement.Quarter => + $" DATENAME(year, {columnSql}) +'Q' + DATENAME(quarter,{columnSql})" //returns 2015Q1 + , + _ => throw new ArgumentOutOfRangeException(nameof(increment)) + }; + + /// + /// Gives you the equivalency check for the given axis joined to column1 column. Use this in the JOIN SQL generated by AggregateBuilder + /// + /// Step size (day, month, year, quarter) + /// The column name or transform from the dataset + /// The axis column e.g. axis.dt + /// + public string GetDatePartBasedEqualsBetweenColumns(AxisIncrement increment, string column1, string column2) => + increment switch + { + AxisIncrement.Day => + $"{GetDatePartOfColumn(increment, column1)}={column2}" //truncate any time off column1, column2 is the axis column which never has time anyway + , + AxisIncrement.Month => + $"YEAR({column1}) = YEAR({column2}) AND MONTH({column1}) = MONTH({column2})" //for performance + , + AxisIncrement.Year => + $"{GetDatePartOfColumn(increment, column1)}={GetDatePartOfColumn(increment, column2)}", + AxisIncrement.Quarter => + $"YEAR({column1}) = YEAR({column2}) AND DATEPART(QUARTER, {column1}) = DATEPART(QUARTER, {column2})", + _ => throw new ArgumentOutOfRangeException(nameof(increment)) + }; + + protected override IQuerySyntaxHelper GetQuerySyntaxHelper() => MicrosoftQuerySyntaxHelper.Instance; + + protected override string BuildAxisAggregate(AggregateCustomLineCollection query) + { + var countAlias = query.CountSelect.GetAliasFromText(query.SyntaxHelper); + var axisColumnAlias = query.AxisSelect.GetAliasFromText(query.SyntaxHelper) ?? "joinDt"; + + WrapAxisColumnWithDatePartFunction(query,axisColumnAlias); + + + return string.Format( + """ + + {0} + {1} + + SELECT + {2} AS joinDt,dataset.{3} + FROM + @dateAxis axis + LEFT JOIN + ( + {4} + ) dataset + ON dataset.{5} = {2} + ORDER BY + {2} + + """ + , + string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert < QueryComponent.SELECT)), + GetDateAxisTableDeclaration(query.Axis), + + GetDatePartOfColumn(query.Axis.AxisIncrement, "axis.dt"), + countAlias, + + //the entire query + string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert is >= QueryComponent.SELECT and <= QueryComponent.Having)), + axisColumnAlias + ).Trim(); + } + + protected override string BuildPivotAndAxisAggregate(AggregateCustomLineCollection query) + { + var syntaxHelper = query.SyntaxHelper; + + var part1 = GetPivotPart1(query, out var pivotAlias, out var countAlias, out var axisColumnAlias); + + //The dynamic query in which we assemble a query string and EXECUTE it + var part2 = string.Format(""" + + /*DYNAMIC PIVOT*/ + declare @Query varchar(MAX) + + SET @Query = ' + {0} + {1} + + /*Would normally be Select * but must make it IsNull to ensure we see 0s instead of null*/ + select '+@FinalSelectList+' + from + ( + + SELECT + {5} as joinDt, + {4}, + {3} + FROM + @dateAxis axis + LEFT JOIN + ( + {2} + )ds + on {5} = ds.{6} + ) s + PIVOT + ( + sum({3}) + for {4} in ('+@Columns+') --The dynamic Column list we just fetched at top of query + ) piv + ORDER BY + joinDt' + + EXECUTE(@Query) + + """, + syntaxHelper.Escape(string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert < QueryComponent.SELECT))), + syntaxHelper.Escape(GetDateAxisTableDeclaration(query.Axis)), + + //the entire select query up to the end of the group by (omitting any Top X) + syntaxHelper.Escape(string.Join(Environment.NewLine, query.Lines.Where(static c => + c.LocationToInsert is >= QueryComponent.SELECT and < QueryComponent.OrderBy && + c.Role != CustomLineRole.TopX))), + + syntaxHelper.Escape(countAlias), + syntaxHelper.Escape(pivotAlias), + syntaxHelper.Escape(GetDatePartOfColumn(query.Axis.AxisIncrement,"axis.dt")), + axisColumnAlias + ); + + return part1 + part2; + } + + protected override string BuildPivotOnlyAggregate(AggregateCustomLineCollection query, CustomLine nonPivotColumn) + { + var syntaxHelper = query.SyntaxHelper; + + var part1 = GetPivotPart1(query, out var pivotAlias, out var countAlias, out _); + + syntaxHelper.SplitLineIntoSelectSQLAndAlias(nonPivotColumn.Text, out var nonPivotColumnSelect, out var nonPivotColumnAlias); + + //ensure we have an alias for the non pivot column + if (string.IsNullOrWhiteSpace(nonPivotColumnAlias)) + nonPivotColumnAlias = syntaxHelper.GetRuntimeName(nonPivotColumnSelect); + + //The dynamic query in which we assemble a query string and EXECUTE it + var part2 = string.Format(""" + + /*DYNAMIC PIVOT*/ + declare @Query varchar(MAX) + + SET @Query = ' + {0} + + /*Would normally be Select * but must make it IsNull to ensure we see 0s instead of null*/ + select + {1}, + '+@FinalSelectList+' + from + ( + {2} + ) s + PIVOT + ( + sum({3}) + for {4} in ('+@Columns+') --The dynamic Column list we just fetched at top of query + + ) piv + ORDER BY + {1}' + + EXECUTE(@Query) + + """, + //anything before the SELECT (i.e. parameters) + syntaxHelper.Escape(string.Join(Environment.NewLine, + query.Lines.Where(static c => c.LocationToInsert < QueryComponent.SELECT))), + syntaxHelper.Escape(nonPivotColumnAlias), + + //the entire select query up to the end of the group by (omitting any Top X) + syntaxHelper.Escape(string.Join(Environment.NewLine, query.Lines.Where(static c => + c.LocationToInsert is >= QueryComponent.SELECT and < QueryComponent.OrderBy && + c.Role != CustomLineRole.TopX))), + + syntaxHelper.Escape(countAlias), + syntaxHelper.Escape(pivotAlias)); + + return part1 + part2; + } + + private string GetPivotPart1(AggregateCustomLineCollection query, out string pivotAlias, out string countAlias, out string? axisColumnAlias) + { + var syntaxHelper = query.SyntaxHelper; + + //find the pivot column e.g. 'hb_extract AS Healthboard' + var pivotSelectLine = query.PivotSelect; + var pivotSqlWithoutAlias = pivotSelectLine.GetTextWithoutAlias(syntaxHelper); + pivotAlias = pivotSelectLine.GetAliasFromText(syntaxHelper); + + //ensure it has an RHS + if (string.IsNullOrWhiteSpace(pivotAlias)) + pivotAlias = syntaxHelper.GetRuntimeName(pivotSqlWithoutAlias); + + var countSqlWithoutAlias = query.CountSelect.GetTextWithoutAlias(syntaxHelper); + countAlias = query.CountSelect.GetAliasFromText(syntaxHelper); + + var axisColumnWithoutAlias = query.AxisSelect?.GetTextWithoutAlias(query.SyntaxHelper); + axisColumnAlias = query.AxisSelect?.GetAliasFromText(query.SyntaxHelper) ?? "joinDt"; + + //if there is an axis we don't want to pivot on values that are outside that axis restriction. + if(query.Axis != null) + WrapAxisColumnWithDatePartFunction(query, axisColumnAlias); + else + { + axisColumnAlias = null; + axisColumnWithoutAlias = null; + } + + //Part 1 is where we get all the unique values from the pivot column (after applying the WHERE logic) + + var anyFilters = query.Lines.Any(static l => l.LocationToInsert == QueryComponent.WHERE); + + var orderBy = $"{countSqlWithoutAlias} desc"; + + if (query.TopXOrderBy != null) + orderBy = query.TopXOrderBy.Text; + + var havingSqlIfAny = string.Join(Environment.NewLine, + query.Lines.Where(static l => l.LocationToInsert == QueryComponent.Having).Select(static l => l.Text)); + + var part1 = string.Format( + """ + + /*DYNAMICALLY FETCH COLUMN VALUES FOR USE IN PIVOT*/ + DECLARE @Columns as VARCHAR(MAX) + {0} + + /*Get distinct values of the PIVOT Column if you have columns with values T and F and Z this will produce [T],[F],[Z] and you will end up with a pivot against these values*/ + set @Columns = ( + {1} + ',' + QUOTENAME({2}) as [text()] + {3} + {4} + {5} ( {2} IS NOT NULL and {2} <> '' {7}) + group by + {2} + {8} + order by + {6} + FOR XML PATH(''), root('MyString'),type + ).value('/MyString[1]','varchar(max)') + + set @Columns = SUBSTRING(@Columns,2,LEN(@Columns)) + + DECLARE @FinalSelectList as VARCHAR(MAX) + SET @FinalSelectList = {9} + + --Split up that pesky string in tsql which has the column names up into array elements again + DECLARE @value varchar(8000) + DECLARE @pos INT + DECLARE @len INT + set @pos = 0 + set @len = 0 + + WHILE CHARINDEX('],', @Columns +',', @pos+1)>0 + BEGIN + set @len = CHARINDEX('],[', @Columns +'],[', @pos+1) - @pos + set @value = SUBSTRING(@Columns, @pos+1, @len) + + --We are constructing a version that turns: '[fish],[lama]' into 'ISNULL([fish],0) as [fish], ISNULL([lama],0) as [lama]' + SET @FinalSelectList = @FinalSelectList + ', ISNULL(' + @value + ',0) as ' + @value + + set @pos = CHARINDEX('],[', @Columns +'],[', @pos+@len) +1 + END + + if LEFT(@FinalSelectList,1) = ',' + SET @FinalSelectList = RIGHT(@FinalSelectList,LEN(@FinalSelectList)-1) + + + """, + //select SQL and parameter declarations + string.Join(Environment.NewLine, query.Lines.Where(static l => l.LocationToInsert < QueryComponent.SELECT)), + string.Join(Environment.NewLine, query.Lines.Where(static l => l.LocationToInsert == QueryComponent.SELECT)), + pivotSqlWithoutAlias, + + //FROM and JOINs that are not to the calendar table + string.Join(Environment.NewLine, + query.Lines.Where(static l => + l.LocationToInsert == QueryComponent.FROM || (l.LocationToInsert == QueryComponent.JoinInfoJoin && + l.Role != CustomLineRole.Axis))), + string.Join(Environment.NewLine, query.Lines.Where(static l => l.LocationToInsert == QueryComponent.WHERE)), + anyFilters ? "AND" : "WHERE", + orderBy, + axisColumnWithoutAlias == null ? "": $"AND {axisColumnWithoutAlias} is not null", + havingSqlIfAny, + query.Axis != null ? "'joinDt'":"''" + ); + return part1; + } + + +} \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/FAnsi.MicrosoftSql.csproj b/FAnsi.MicrosoftSql/FAnsi.MicrosoftSql.csproj new file mode 100644 index 00000000..94498e90 --- /dev/null +++ b/FAnsi.MicrosoftSql/FAnsi.MicrosoftSql.csproj @@ -0,0 +1,45 @@ + + + HIC.FAnsi.MicrosoftSql + 0.0.7 + HIC.FAnsi.MicrosoftSql + Health Informatics Centre - University of Dundee + Health Informatics Centre - University of Dundee + https://github.com/HicServices/FAnsiSql + GPL-3.0-or-later + false + Microsoft SQL Server implementation for FAnsiSql + Ansi,SQL,SqlServer,MicrosoftSQL + HIC.FAnsi.MicrosoftSql + Health Informatics Centre, University of Dundee + HIC.FAnsi.MicrosoftSql + Microsoft SQL Server implementation for FAnsiSql + Copyright © 2019-2025 + false + true + true + CS1591 + en-GB + embedded + README.md + + + 1 + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/MicrosoftQuerySyntaxHelper.cs b/FAnsi.MicrosoftSql/MicrosoftQuerySyntaxHelper.cs new file mode 100644 index 00000000..612a6fdf --- /dev/null +++ b/FAnsi.MicrosoftSql/MicrosoftQuerySyntaxHelper.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Implementations.MicrosoftSQL.Aggregation; +using FAnsi.Implementations.MicrosoftSQL.Update; +using Microsoft.Data.SqlClient; + +namespace FAnsi.Implementations.MicrosoftSQL; + +/// +public sealed class MicrosoftQuerySyntaxHelper : QuerySyntaxHelper +{ + public static readonly MicrosoftQuerySyntaxHelper Instance = new(); + private MicrosoftQuerySyntaxHelper() : base(MicrosoftSQLTypeTranslater.Instance, new MicrosoftSQLAggregateHelper(), new MicrosoftSQLUpdateHelper(), DatabaseType.MicrosoftSQLServer) + { + } + + /// + /// Maximum database name length. This is less than 128 in order to allow for "_logs" etc getting appended to end. + /// See: https://stackoverflow.com/a/5096245/4824531 + /// + public override int MaximumDatabaseLength => 100; + public override int MaximumTableLength => 128; + public override int MaximumColumnLength => 128; + + public override string OpenQualifier => "["; + + public override string CloseQualifier => "]"; + + public override TopXResponse HowDoWeAchieveTopX(int x) => new($"TOP {x}", QueryComponent.SELECT); + + public override string GetParameterDeclaration(string proposedNewParameterName, string sqlType) => $"DECLARE {proposedNewParameterName} AS {sqlType};"; + + public override string GetScalarFunctionSql(MandatoryScalarFunctions function) => + function switch + { + MandatoryScalarFunctions.GetTodaysDate => "GETDATE()", + MandatoryScalarFunctions.GetGuid => "newid()", + MandatoryScalarFunctions.Len => "LEN", + _ => throw new ArgumentOutOfRangeException(nameof(function)) + }; + + public override string GetAutoIncrementKeywordIfAny() => "IDENTITY(1,1)"; + + public override Dictionary GetSQLFunctionsDictionary() => + new() + { + { "left", "LEFT ( character_expression , integer_expression )" }, + { "right", "RIGHT ( character_expression , integer_expression )" }, + { "upper", "UPPER ( character_expression )" }, + { "substring","SUBSTRING ( expression ,start , length ) "}, + { "dateadd","DATEADD (datepart , number , date )"}, + { "datediff", "DATEDIFF ( datepart , startdate , enddate ) "}, + { "getdate", "GETDATE()"}, + { "cast", "CAST ( expression AS data_type [ ( length ) ] )"}, + { "convert","CONVERT ( data_type [ ( length ) ] , expression [ , style ] ) "}, + { "case","CASE WHEN x=y THEN 'something' WHEN x=z THEN 'something2' ELSE 'something3' END"} + }; + + public override bool IsTimeout(Exception exception) + { + if (exception is not SqlException sqlE) return base.IsTimeout(exception); + + return sqlE.Number switch + { + -2 or 11 or 1205 => true, + //yup, I've seen this behaviour from Sql Server. ExceptionMessage of " " and .Number of + 3617 when string.IsNullOrWhiteSpace(sqlE.Message) => true, + _ => base.IsTimeout(exception) + }; + } + + public override string HowDoWeAchieveMd5(string selectSql) => $"CONVERT(NVARCHAR(32),HASHBYTES('MD5', CONVERT(varbinary,{selectSql})),2)"; + + public override string GetDefaultSchemaIfAny() => "dbo"; + + public override bool SupportsEmbeddedParameters() => true; + + public override string EnsureWrappedImpl(string databaseOrTableName) => $"[{GetRuntimeNameWithDoubledClosingSquareBrackets(databaseOrTableName)}]"; + + + protected override string UnescapeWrappedNameBody(string name) => name.Replace("]]", "]"); + + /// + /// Returns the runtime name of the string with all ending square brackets escaped by doubling up (but resulting string is not wrapped itself) + /// + /// + /// + private string? GetRuntimeNameWithDoubledClosingSquareBrackets(string s) => GetRuntimeName(s)?.Replace("]", "]]"); + + public override string EnsureFullyQualified(string? databaseName, string? schema, string tableName) + { + //if there is no schema address it as db..table (which is the same as db.dbo.table in Microsoft SQL Server) + if (string.IsNullOrWhiteSpace(schema)) + return + $"{EnsureWrapped(GetRuntimeName(databaseName))}{DatabaseTableSeparator}{DatabaseTableSeparator}{EnsureWrapped(GetRuntimeName(tableName))}"; + + //there is a schema so add it in + return + $"{EnsureWrapped(GetRuntimeName(databaseName))}{DatabaseTableSeparator}{EnsureWrapped(GetRuntimeName(schema))}{DatabaseTableSeparator}{EnsureWrapped(GetRuntimeName(tableName))}"; + } + + public override string EnsureFullyQualified(string? databaseName, string? schema, string tableName, string columnName, bool isTableValuedFunction = false) + { + if (isTableValuedFunction) + return GetRuntimeName(tableName) + DatabaseTableSeparator + EnsureWrapped(GetRuntimeName(columnName));//table valued functions do not support database name being in the column level selection list area of sql queries + + return EnsureFullyQualified(databaseName, schema, tableName) + DatabaseTableSeparator + EnsureWrapped(GetRuntimeName(columnName)); + } +} \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/MicrosoftSQLBulkCopy.cs b/FAnsi.MicrosoftSql/MicrosoftSQLBulkCopy.cs new file mode 100644 index 00000000..a6f29c19 --- /dev/null +++ b/FAnsi.MicrosoftSql/MicrosoftSQLBulkCopy.cs @@ -0,0 +1,270 @@ +using System; +using System.Data; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using FAnsi.Connections; +using FAnsi.Discovery; +using Microsoft.Data.SqlClient; + +namespace FAnsi.Implementations.MicrosoftSQL; + +public sealed partial class MicrosoftSQLBulkCopy : BulkCopy +{ + private readonly SqlBulkCopy _bulkCopy; + private static readonly Regex ColumnLevelComplaint = ColumnLevelComplaintRe(); + + + public MicrosoftSQLBulkCopy(DiscoveredTable targetTable, IManagedConnection connection, CultureInfo culture) : base(targetTable, connection, + culture) + { + var options = SqlBulkCopyOptions.KeepIdentity | SqlBulkCopyOptions.TableLock; + if (connection.Transaction == null) + options |= SqlBulkCopyOptions.UseInternalTransaction; + _bulkCopy = new SqlBulkCopy((SqlConnection)connection.Connection, options, (SqlTransaction?)connection.Transaction) + { + BulkCopyTimeout = 50000, + DestinationTableName = targetTable.GetFullyQualifiedName() + }; + } + + public override int UploadImpl(DataTable dt) + { + _bulkCopy.BulkCopyTimeout = Timeout; + + _bulkCopy.ColumnMappings.Clear(); + foreach (var (key, value) in GetMapping(dt.Columns.Cast())) + _bulkCopy.ColumnMappings.Add(key.ColumnName, value.GetRuntimeName()); + + return BulkInsertWithBetterErrorMessages(_bulkCopy, dt, TargetTable.Database.Server); + } + + private int BulkInsertWithBetterErrorMessages(SqlBulkCopy insert, DataTable dt, DiscoveredServer serverForLineByLineInvestigation) + { + var rowsWritten = 0; + EmptyStringsToNulls(dt); + InspectDataTableForFloats(dt); + ConvertStringTypesToHardTypes(dt); + + try + { + //send data read to server + insert.WriteToServer(dt); + rowsWritten += dt.Rows.Count; + + return rowsWritten; + } + catch (Exception e) + { + //user does not want to replay the load one line at a time to get more specific error messages + if (serverForLineByLineInvestigation != null) + { + Exception better; + try + { + //we can attempt line by line insert to find the bad row + better = AttemptLineByLineInsert(e, insert, dt, serverForLineByLineInvestigation); + } + catch (Exception exception) + { + throw new AggregateException( + SR + .MicrosoftSQLBulkCopy_BulkInsertWithBetterErrorMessages_Failed_to_bulk_insert_batch__line_by_line_investigation_also_failed___InnerException_0__is_the_original_Exception__InnerException_1__is_the_line_by_line_failure, + e, exception); + } + throw better; + } + + if (BcpColIdToString(insert, e as SqlException, out var result1, out _)) + throw new Exception( + string.Format( + SR.MicrosoftSQLBulkCopy_BulkInsertWithBetterErrorMessages_Failed_to_bulk_insert__0_, + result1), e); //but we can still give him a better message than "bcp colid 1 was bad"! + + throw; + } + } + + /// + /// Creates a new transaction and does one line at a time bulk insertions of the to determine which line (and value) + /// is causing the problem. Transaction is always rolled back. + /// + /// + /// + /// + /// + /// + /// + private Exception AttemptLineByLineInsert(Exception e, SqlBulkCopy insert, DataTable dt, DiscoveredServer serverForLineByLineInvestigation) + { + var line = 1; + var firstPass = ExceptionToListOfInnerMessages(e, true); + firstPass = firstPass.Replace(Environment.NewLine, $"{Environment.NewLine}\t"); + firstPass = Environment.NewLine + SR.MicrosoftSQLBulkCopy_AttemptLineByLineInsert_First_Pass_Exception_ + Environment.NewLine + firstPass; + + //have to use a new object because current one could have a broken transaction associated with it + using var con = (SqlConnection)serverForLineByLineInvestigation.GetConnection(); + con.Open(); + var investigationTransaction = con.BeginTransaction("Investigate BulkCopyFailure"); + using (var investigationOneLineAtATime = new SqlBulkCopy(con, SqlBulkCopyOptions.KeepIdentity, investigationTransaction)) + { + investigationOneLineAtATime.DestinationTableName = insert.DestinationTableName; + + foreach (SqlBulkCopyColumnMapping m in insert.ColumnMappings) + investigationOneLineAtATime.ColumnMappings.Add(m); + + //try a line at a time + foreach (DataRow dr in dt.Rows) + try + { + investigationOneLineAtATime.WriteToServer(new[] { dr }); //try one line + line++; + } + catch (Exception exception) + { + if (BcpColIdToString(investigationOneLineAtATime, exception as SqlException, out var result, out var badMapping)) + { + if (badMapping is null || !dt.Columns.Contains(badMapping.SourceColumn)) + return new Exception( + string.Format( + SR + .MicrosoftSQLBulkCopy_AttemptLineByLineInsert_BulkInsert_failed_on_data_row__0___1_, + line, result), e); + + var sourceValue = dr[badMapping.SourceColumn]; + var destColumn = TargetTableColumns.SingleOrDefault(c => c.GetRuntimeName().Equals(badMapping.DestinationColumn)); + + if (destColumn != null) + return new FileLoadException( + string.Format(SR.MicrosoftSQLBulkCopy_AttemptLineByLineInsert_BulkInsert_failed_on_data_row__0__the_complaint_was_about_source_column____1____which_had_value____2____destination_data_type_was____3____4__5_, line, badMapping.SourceColumn, sourceValue, destColumn.DataType, Environment.NewLine, result), exception); + + return new Exception(string.Format(SR.MicrosoftSQLBulkCopy_AttemptLineByLineInsert_BulkInsert_failed_on_data_row__0___1_, line, result), e); + } + + return new FileLoadException( + string.Format(SR.MicrosoftSQLBulkCopy_AttemptLineByLineInsert_Second_Pass_Exception__Failed_to_load_data_row__0__the_following_values_were_rejected_by_the_database___1__2__3_, line, Environment.NewLine, string.Join(Environment.NewLine, dr.ItemArray), firstPass), + exception); + } + + //it worked... how!? + investigationTransaction.Rollback(); + con.Close(); + } + + return new Exception(SR.MicrosoftSQLBulkCopy_AttemptLineByLineInsert_Second_Pass_Exception__Bulk_insert_failed_but_when_we_tried_to_repeat_it_a_line_at_a_time_it_worked + firstPass, e); + } + + /// + /// Inspects exception message for references to bcp client colid and displays the user recognizable name of the column. + /// + /// + /// The Exception you caught. If null method returns false and output variables are null. + /// + /// + /// + private static bool BcpColIdToString(SqlBulkCopy insert, SqlException? ex, out string? newMessage, out SqlBulkCopyColumnMapping? badMapping) + { + var match = ColumnLevelComplaint.Match(ex?.Message ?? ""); + if (ex == null || !match.Success) + { + newMessage = null; + badMapping = null; + return false; + } + + //it counts from 1 not 0. Also it isn't an index into insert.ColumnMappings. It's an index into a private field! + var columnItHates = Convert.ToInt32(match.Groups[1].Value) - 1; + + try + { + var fi = typeof(SqlBulkCopy).GetField("_sortedColumnMappings", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new NullReferenceException(); + var sortedColumns = fi.GetValue(insert); + var items = (object[])(sortedColumns?.GetType().GetField("_items", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(sortedColumns) ?? throw new NullReferenceException()); + + var itemData = items[columnItHates].GetType().GetField("_metadata", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new NullReferenceException(); + var metadata = itemData.GetValue(items[columnItHates]) ?? throw new NullReferenceException(); + + var destinationColumn = (string?)metadata.GetType().GetField("column", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(metadata) ?? throw new NullReferenceException(); + + var length = metadata.GetType().GetField("length", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(metadata); + + badMapping = insert.ColumnMappings.Cast() + .SingleOrDefault(m => string.Equals(m.DestinationColumn, destinationColumn, StringComparison.CurrentCultureIgnoreCase)); + + newMessage = ex.Message.Insert(match.Index + match.Length, + $"(Source Column <<{badMapping?.SourceColumn??"unknown"}>> Dest Column <<{destinationColumn}>> which has MaxLength of {length})"); + + return true; + } + catch (NullReferenceException) + { + //private fields in SqlBulkCopy have changed name? + newMessage = ex.Message; + badMapping = null; + return false; + } + } + + private static void EmptyStringsToNulls(DataTable dt) + { + foreach (var col in dt.Columns.Cast().Where(static c => c.DataType == typeof(string))) + foreach (var row in dt.Rows.Cast() + .Select(row => new { row, o = row[col] }) + .Where(static t => t.o != DBNull.Value && t.o != null && string.IsNullOrWhiteSpace(t.o.ToString())) + .Select(static t => t.row)) + row[col] = DBNull.Value; + } + + [Pure] + private static string ExceptionToListOfInnerMessages(Exception e, bool includeStackTrace = false) + { + var message = new StringBuilder(e.Message); + if (includeStackTrace) + { + message.AppendLine(); + message.Append(e.StackTrace); + } + + if (e is ReflectionTypeLoadException reflectionTypeLoadException) + foreach (var loaderException in reflectionTypeLoadException.LoaderExceptions.OfType()) + { + message.AppendLine(); + message.Append(ExceptionToListOfInnerMessages(loaderException, includeStackTrace)); + } + + if (e.InnerException == null) return message.ToString(); + + message.AppendLine(); + message.Append(ExceptionToListOfInnerMessages(e.InnerException, includeStackTrace)); + return message.ToString(); + } + + private static void InspectDataTableForFloats(DataTable dt) + { + //are there any float or float? columns + var floatColumnNames = dt.Columns.Cast().Where(static c => c.DataType == typeof(float) || c.DataType == typeof(float?)).Select(static c => c.ColumnName).ToArray(); + if (floatColumnNames.Length!=0) + throw new NotSupportedException( + $"Found float column(s) in data table, SQLServer does not support floats in bulk insert, instead you should use doubles otherwise you will end up with the value 0.85 turning into :0.850000023841858 in your database. Float column(s) were:{string.Join(",", floatColumnNames)}"); + + //are there any object columns + var objectColumns = dt.Columns.Cast().Where(static c => c.DataType == typeof(object)).Select(static col => col.Ordinal).ToArray(); + + //do any of the object columns have floats or float? in them? + for (var i = 0;i < Math.Min(100, dt.Rows.Count);i++) + { + var bad = objectColumns.Select(c => dt.Rows[i][c]) + .FirstOrDefault(static t => t is float); + if (bad != null) + throw new NotSupportedException( + $"Found float value {bad} in data table, SQLServer does not support floats in bulk insert, instead you should use doubles otherwise you will end up with the value 0.85 turning into :0.850000023841858 in your database"); + } + } + + [GeneratedRegex("bcp client for colid (\\d+)", RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex ColumnLevelComplaintRe(); +} \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/MicrosoftSQLColumnHelper.cs b/FAnsi.MicrosoftSql/MicrosoftSQLColumnHelper.cs new file mode 100644 index 00000000..ba167349 --- /dev/null +++ b/FAnsi.MicrosoftSql/MicrosoftSQLColumnHelper.cs @@ -0,0 +1,45 @@ +using System.Text; +using FAnsi.Discovery; +using FAnsi.Naming; + +namespace FAnsi.Implementations.MicrosoftSQL; + +public sealed class MicrosoftSQLColumnHelper : IDiscoveredColumnHelper +{ + public string GetTopXSqlForColumn(IHasRuntimeName database, IHasFullyQualifiedNameToo table, IHasRuntimeName column, int topX, bool discardNulls) + { + var syntax = MicrosoftQuerySyntaxHelper.Instance; + + //[dbx].[table] + var sql = + new StringBuilder($"SELECT TOP {topX} {syntax.EnsureWrapped(column.GetRuntimeName())} FROM {table.GetFullyQualifiedName()}"); + + if (discardNulls) + sql.Append($" WHERE {syntax.EnsureWrapped(column.GetRuntimeName())} IS NOT NULL"); + + return sql.ToString(); + } + + public string GetAlterColumnToSql(DiscoveredColumn column, string newType, bool allowNulls) + { + if (column.DataType.SQLType != "bit" || newType == "bit") + return + $"ALTER TABLE {column.Table.GetFullyQualifiedName()} ALTER COLUMN {column.GetWrappedName()} {newType} {(allowNulls ? "NULL" : "NOT NULL")}"; + + var sb = new StringBuilder(); + //go via string because SQL server cannot handle turning bit to int (See test BooleanResizingTest) + //Fails on Sql Server even when column is all null or there are no rows + /* + DROP TABLE T + CREATE TABLE T (A bit NULL) + alter table T alter column A datetime2 null + */ + + sb.AppendLine( + $"ALTER TABLE {column.Table.GetFullyQualifiedName()} ALTER COLUMN {column.GetWrappedName()} varchar(4000) {(allowNulls ? "NULL" : "NOT NULL")}"); + sb.AppendLine( + $"ALTER TABLE {column.Table.GetFullyQualifiedName()} ALTER COLUMN {column.GetWrappedName()} {newType} {(allowNulls ? "NULL" : "NOT NULL")}"); + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/MicrosoftSQLDatabaseHelper.cs b/FAnsi.MicrosoftSql/MicrosoftSQLDatabaseHelper.cs new file mode 100644 index 00000000..52a00495 --- /dev/null +++ b/FAnsi.MicrosoftSql/MicrosoftSQLDatabaseHelper.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.IO; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using Microsoft.Data.SqlClient; + +namespace FAnsi.Implementations.MicrosoftSQL; + +public sealed class MicrosoftSQLDatabaseHelper: DiscoveredDatabaseHelper +{ + /// + /// True to attempt sending "ALTER DATABASE MyDatabase SET SINGLE_USER WITH ROLLBACK IMMEDIATE" + /// before DROP DATABASE command when using . + /// Defaults to true. This command makes dropping databases more robust so is recommended but + /// is not supported by some servers (e.g. Microsoft Azure) + /// + public static bool SetSingleUserWhenDroppingDatabases { get; set; } = true; + + public override IEnumerable ListTables(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, DbConnection connection, string database, bool includeViews, DbTransaction? transaction = null) + { + if (connection.State == ConnectionState.Closed) + throw new InvalidOperationException("Expected connection to be open"); + + using var cmd = new SqlCommand($"use {querySyntaxHelper.EnsureWrapped(database)}; EXEC sp_tables", (SqlConnection) connection); + cmd.Transaction = transaction as SqlTransaction; + + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var schema = r["TABLE_OWNER"] as string; + + if (schema is + //it's a system table + "sys" or "INFORMATION_SCHEMA") continue; + + //add views if we are including them + if (includeViews && r["TABLE_TYPE"].Equals("VIEW") && + querySyntaxHelper.IsValidTableName((string)r["TABLE_NAME"], out _)) + yield return new DiscoveredTable(parent, (string)r["TABLE_NAME"], querySyntaxHelper, schema, + TableType.View); + + //add tables + if (r["TABLE_TYPE"].Equals("TABLE") && + querySyntaxHelper.IsValidTableName((string)r["TABLE_NAME"], out _)) + yield return new DiscoveredTable(parent, (string)r["TABLE_NAME"], querySyntaxHelper, schema); + } + } + + public override IEnumerable ListTableValuedFunctions(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, DbConnection connection, string database, DbTransaction? transaction = null) + { + using (DbCommand cmd = new SqlCommand( + $"use {querySyntaxHelper.EnsureWrapped(database)};select name,\r\n (select name from sys.schemas s where s.schema_id = o.schema_id) as schema_name\r\n from sys.objects o\r\nWHERE type_desc = 'SQL_INLINE_TABLE_VALUED_FUNCTION' OR type_desc = 'SQL_TABLE_VALUED_FUNCTION' OR type_desc ='CLR_TABLE_VALUED_FUNCTION'", (SqlConnection)connection)) + + { + cmd.Transaction = transaction; + + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var schema = r["schema_name"] as string; + + if (string.Equals("dbo", schema)) + schema = null; + var name = r["name"].ToString(); + if (name != null) + yield return new DiscoveredTableValuedFunction(parent, name, querySyntaxHelper, schema); + } + } + } + + public override IEnumerable ListStoredprocedures(DbConnectionStringBuilder builder, string database) + { + var querySyntaxHelper = MicrosoftQuerySyntaxHelper.Instance; + + using var con = new SqlConnection(builder.ConnectionString); + con.Open(); + using var cmdFindStoredprocedure = + new SqlCommand($"use {querySyntaxHelper.EnsureWrapped(database)}; SELECT * FROM sys.procedures", con); + var result = cmdFindStoredprocedure.ExecuteReader(); + + while (result.Read()) + yield return new DiscoveredStoredprocedure((string)result["name"]); + } + + public override IDiscoveredTableHelper GetTableHelper() => new MicrosoftSQLTableHelper(); + + public override void DropDatabase(DiscoveredDatabase database) + { + var userIsCurrentlyInDatabase = database.Server.GetCurrentDatabase().GetRuntimeName().Equals(database.GetRuntimeName()); + + var serverConnectionBuilder = new SqlConnectionStringBuilder(database.Server.Builder.ConnectionString); + if (userIsCurrentlyInDatabase) + serverConnectionBuilder.InitialCatalog = "master"; + + // Create a new server so we don't mutate database.Server and cause a whole lot of side-effects in other code, e.g. attachers + var server = new DiscoveredServer(serverConnectionBuilder); + var databaseToDrop = database.GetWrappedName(); + + try + { + // try dropping the db with single user mode enabled if the user wanted + DropDatabase(databaseToDrop, server, SetSingleUserWhenDroppingDatabases); + } + catch (Exception) + { + // failed to drop... maybe it dropped anyway though? + if (SetSingleUserWhenDroppingDatabases && database.Exists()) + // try without the single user mode bit + DropDatabase(databaseToDrop, server, false); + else + throw; + } + + SqlConnection.ClearAllPools(); + } + + /// + /// Sends a DROP database command to the . Optionally sets to SINGLE_USER + /// first in order to more reliably drop the database. + /// + /// + /// + /// + private static void DropDatabase(string databaseToDrop, DiscoveredServer server, bool setSingleUserModeFirst) + { + var sql = setSingleUserModeFirst ? $"ALTER DATABASE {databaseToDrop} SET SINGLE_USER WITH ROLLBACK IMMEDIATE{Environment.NewLine}" + : ""; + sql += $"DROP DATABASE {databaseToDrop}"; + + using var con = (SqlConnection)server.GetConnection(); + con.Open(); + using var cmd = new SqlCommand(sql, con); + cmd.ExecuteNonQuery(); + } + + public override Dictionary DescribeDatabase(DbConnectionStringBuilder builder, string database) + { + var toReturn = new Dictionary(); + + using var con = new SqlConnection(builder.ConnectionString); + con.Open(); + con.ChangeDatabase(database); + using var ds = new DataSet(); + using (var cmd = new SqlCommand("exec sp_spaceused", con)) + using (var da = new SqlDataAdapter(cmd)) + da.Fill(ds); + + toReturn.Add(ds.Tables[0].Columns[0].ColumnName, ds.Tables[0].Rows[0][0].ToString() ?? string.Empty); + toReturn.Add(ds.Tables[0].Columns[1].ColumnName, ds.Tables[1].Rows[0][1].ToString() ?? string.Empty); + + toReturn.Add(ds.Tables[1].Columns[0].ColumnName, ds.Tables[1].Rows[0][0].ToString() ?? string.Empty); + toReturn.Add(ds.Tables[1].Columns[1].ColumnName, ds.Tables[1].Rows[0][1].ToString() ?? string.Empty); + toReturn.Add(ds.Tables[1].Columns[2].ColumnName, ds.Tables[1].Rows[0][2].ToString() ?? string.Empty); + + return toReturn; + } + + public override DirectoryInfo? Detach(DiscoveredDatabase database) + { + const string getDefaultSqlServerDatabaseDirectory = """ + SELECT LEFT(physical_name,LEN(physical_name)-CHARINDEX('\',REVERSE(physical_name))+1) + FROM sys.master_files mf + INNER JOIN sys.[databases] d + ON mf.[database_id] = d.[database_id] + WHERE d.[name] = 'master' AND type = 0 + """; + + string dataFolder; + + // Create a new server so we don't mutate database.Server and cause a whole lot of side-effects in other code, e.g. attachers + var server = database.Server; + var databaseToDetach = database.GetWrappedName(); + + // set in simple recovery and truncate all logs! + var sql = + $"ALTER DATABASE {databaseToDetach} SET RECOVERY SIMPLE; {Environment.NewLine}DBCC SHRINKFILE ({databaseToDetach}, 1)"; + using var con = (SqlConnection)server.GetConnection(); + con.Open(); + using (var cmd = new SqlCommand(sql, con)) + cmd.ExecuteNonQuery(); + + // other operations must be done on master + server.ChangeDatabase("master"); + + // set single user before detaching + sql = $"ALTER DATABASE {databaseToDetach} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;"; + using (var cmd = new SqlCommand(sql, con)) + cmd.ExecuteNonQuery(); + + var dbLiteralName = database.Server.GetQuerySyntaxHelper().Escape(database.GetRuntimeName()); + + // detach! + sql = $@"EXEC sys.sp_detach_db '{dbLiteralName}';"; + using(var cmd = new SqlCommand(sql, con)) + cmd.ExecuteNonQuery(); + + // get data-files path from SQL Server + using(var cmd = new SqlCommand(getDefaultSqlServerDatabaseDirectory, con)) + dataFolder = (string)cmd.ExecuteScalar(); + + return dataFolder == null ? null : new DirectoryInfo(dataFolder); + } + + public override void CreateBackup(DiscoveredDatabase discoveredDatabase,string backupName) + { + var server = discoveredDatabase.Server; + using var con = server.GetConnection(); + con.Open(); + + var sql = string.Format( + "BACKUP DATABASE {0} TO DISK = '{0}.bak' WITH INIT , NOUNLOAD , NAME = N'{1}', NOSKIP , STATS = 10, NOFORMAT", + discoveredDatabase.GetWrappedName(),backupName); + + using var cmd = server.GetCommand(sql,con); + cmd.ExecuteNonQuery(); + } + + public override void CreateSchema(DiscoveredDatabase discoveredDatabase, string? name) + { + var syntax = discoveredDatabase.Server.GetQuerySyntaxHelper(); + var runtimeName = syntax.GetRuntimeName(name); + name = syntax.EnsureWrapped(name); + + using var con = discoveredDatabase.Server.GetConnection(); + con.Open(); + + var sql = $""" + if not exists (select 1 from sys.schemas where name = '{runtimeName}') + EXEC('CREATE SCHEMA {name}') + """; + + using var cmd = discoveredDatabase.Server.GetCommand(sql, con); + cmd.ExecuteNonQuery(); + } +} \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/MicrosoftSQLImplementation.cs b/FAnsi.MicrosoftSql/MicrosoftSQLImplementation.cs new file mode 100644 index 00000000..88ada1ef --- /dev/null +++ b/FAnsi.MicrosoftSql/MicrosoftSQLImplementation.cs @@ -0,0 +1,17 @@ +using System.Data.Common; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Implementation; +using Microsoft.Data.SqlClient; + +namespace FAnsi.Implementations.MicrosoftSQL; + +public sealed class MicrosoftSQLImplementation() + : Implementation(DatabaseType.MicrosoftSQLServer) +{ + public override IDiscoveredServerHelper GetServerHelper() => MicrosoftSQLServerHelper.Instance; + + public override bool IsFor(DbConnection conn) => conn is SqlConnection; + + public override IQuerySyntaxHelper GetQuerySyntaxHelper() => MicrosoftQuerySyntaxHelper.Instance; +} \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/MicrosoftSQLServerHelper.cs b/FAnsi.MicrosoftSql/MicrosoftSQLServerHelper.cs new file mode 100644 index 00000000..7748d549 --- /dev/null +++ b/FAnsi.MicrosoftSql/MicrosoftSQLServerHelper.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Naming; +using Microsoft.Data.SqlClient; + +namespace FAnsi.Implementations.MicrosoftSQL; + +public sealed class MicrosoftSQLServerHelper : DiscoveredServerHelper +{ + public static readonly MicrosoftSQLServerHelper Instance = new(); + private MicrosoftSQLServerHelper() : base(DatabaseType.MicrosoftSQLServer) + { + } + + //the name of the properties on DbConnectionStringBuilder that correspond to server and database + protected override string ServerKeyName => "Data Source"; + protected override string DatabaseKeyName => "Initial Catalog"; + + protected override string ConnectionTimeoutKeyName => "Connect Timeout"; + + #region Up Typing + public override DbCommand GetCommand(string s, DbConnection con, DbTransaction? transaction = null) => new SqlCommand(s, (SqlConnection)con, transaction as SqlTransaction); + + public override DbDataAdapter GetDataAdapter(DbCommand cmd) => new SqlDataAdapter((SqlCommand)cmd); + + public override DbCommandBuilder GetCommandBuilder(DbCommand cmd) => new SqlCommandBuilder((SqlDataAdapter)GetDataAdapter(cmd)); + + public override DbParameter GetParameter(string parameterName) => new SqlParameter(parameterName, null); + + public override DbConnection GetConnection(DbConnectionStringBuilder builder) => new SqlConnection(builder.ConnectionString); + + protected override DbConnectionStringBuilder GetConnectionStringBuilderImpl(string? connectionString) => new SqlConnectionStringBuilder(connectionString); + + protected override DbConnectionStringBuilder GetConnectionStringBuilderImpl(string server, string? database, string username, string password) + { + var toReturn = new SqlConnectionStringBuilder { DataSource = server }; + if (!string.IsNullOrWhiteSpace(username)) + { + toReturn.UserID = username; + toReturn.Password = password; + } + else + toReturn.IntegratedSecurity = true; + + if (!string.IsNullOrWhiteSpace(database)) + toReturn.InitialCatalog = database; + + return toReturn; + } + public static string GetDatabaseNameFrom(DbConnectionStringBuilder builder) => ((SqlConnectionStringBuilder)builder).InitialCatalog; + + #endregion + + + public override IEnumerable ListDatabases(DbConnectionStringBuilder builder) + { + //create a copy so as not to corrupt the original + var b = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = "master", + ConnectTimeout = 5 + }; + + using var con = new SqlConnection(b.ConnectionString); + con.Open(); + foreach (var listDatabase in ListDatabases(con)) yield return listDatabase; + } + + public override IEnumerable ListDatabases(DbConnection con) + { + using var cmd = GetCommand("select name [Database] from master..sysdatabases", con); + using var r = cmd.ExecuteReader(); + while (r.Read()) + yield return (string)r["Database"]; + } + + public override DbConnectionStringBuilder EnableAsync(DbConnectionStringBuilder builder) + { + var b = (SqlConnectionStringBuilder)builder; + + b.MultipleActiveResultSets = true; + + return b; + } + + public override IDiscoveredDatabaseHelper GetDatabaseHelper() => new MicrosoftSQLDatabaseHelper(); + + public override IQuerySyntaxHelper GetQuerySyntaxHelper() => MicrosoftQuerySyntaxHelper.Instance; + + public override void CreateDatabase(DbConnectionStringBuilder builder, IHasRuntimeName newDatabaseName) + { + var b = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = "master" + }; + + var syntax = MicrosoftQuerySyntaxHelper.Instance; + + + using var con = new SqlConnection(b.ConnectionString); + con.Open(); + using var cmd = new SqlCommand($"CREATE DATABASE {syntax.EnsureWrapped(newDatabaseName.GetRuntimeName())}", con); + cmd.CommandTimeout = CreateDatabaseTimeoutInSeconds; + cmd.ExecuteNonQuery(); + } + + public override Dictionary DescribeServer(DbConnectionStringBuilder builder) + { + var toReturn = new Dictionary(); + + using var con = new SqlConnection(builder.ConnectionString); + con.Open(); + + //For more info you could run + //SELECT * FROM sys.databases WHERE name = 'AdventureWorks2012'; but there might not be a database? + + try + { + using var dt = new DataTable(); + using (var cmd = new SqlCommand("EXEC master..xp_fixeddrives", con)) + using (var da = new SqlDataAdapter(cmd)) + da.Fill(dt); + + foreach (DataRow row in dt.Rows) + toReturn.Add($"Free Space Drive{row[0]}", $"{row[1]}"); + } + catch (Exception) + { + toReturn.Add("Free Space ", "Unknown"); + } + + + return toReturn; + } + + public override string? GetExplicitUsernameIfAny(DbConnectionStringBuilder builder) + { + var u = ((SqlConnectionStringBuilder)builder).UserID; + return string.IsNullOrWhiteSpace(u) ? null : u; + } + + public override string? GetExplicitPasswordIfAny(DbConnectionStringBuilder builder) + { + var pwd = ((SqlConnectionStringBuilder)builder).Password; + return string.IsNullOrWhiteSpace(pwd) ? null : pwd; + } + + protected override void EnforceKeywords(DbConnectionStringBuilder builder) + { + base.EnforceKeywords(builder); + + var msb = (SqlConnectionStringBuilder)builder; + + // if user has specified a keyword that indicates Azure authentication + // then disable IntegratedSecurity + if (msb.Authentication != SqlAuthenticationMethod.NotSpecified) msb.IntegratedSecurity = false; + } + + public override Version? GetVersion(DiscoveredServer server) + { + using var con = server.GetConnection(); + con.Open(); + using var cmd = server.GetCommand("SELECT @@VERSION", con); + using var r = cmd.ExecuteReader(); + if (r.Read()) + return r[0] == DBNull.Value ? null : CreateVersionFromString((string)r[0]); + + return null; + } +} \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/MicrosoftSQLTableHelper.cs b/FAnsi.MicrosoftSql/MicrosoftSQLTableHelper.cs new file mode 100644 index 00000000..f4d8c2fd --- /dev/null +++ b/FAnsi.MicrosoftSql/MicrosoftSQLTableHelper.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using FAnsi.Connections; +using FAnsi.Discovery; +using FAnsi.Discovery.Constraints; +using FAnsi.Exceptions; +using FAnsi.Naming; +using Microsoft.Data.SqlClient; + +namespace FAnsi.Implementations.MicrosoftSQL; + +public sealed partial class MicrosoftSQLTableHelper : DiscoveredTableHelper +{ + public override IEnumerable DiscoverColumns(DiscoveredTable discoveredTable, IManagedConnection connection, string database) + { + //don't bother looking for pks if it is a table valued function + var pks = discoveredTable is DiscoveredTableValuedFunction + ? null + : ListPrimaryKeys(connection, discoveredTable).ToHashSet(); + + using var cmd = discoveredTable.GetCommand( + $"use [{database}];\r\nSELECT \r\nsys.columns.name AS COLUMN_NAME,\r\n sys.types.name AS TYPE_NAME,\r\n sys.columns.collation_name AS COLLATION_NAME,\r\n sys.columns.max_length as LENGTH,\r\n sys.columns.scale as SCALE,\r\n sys.columns.is_identity,\r\n sys.columns.is_nullable,\r\n sys.columns.precision as PRECISION,\r\nsys.columns.collation_name\r\nfrom sys.columns \r\njoin \r\nsys.types on sys.columns.user_type_id = sys.types.user_type_id\r\nwhere object_id = OBJECT_ID(@tableName)", connection.Connection, connection.Transaction); + var p = cmd.CreateParameter(); + p.ParameterName = "@tableName"; + p.Value = GetObjectName(discoveredTable); + cmd.Parameters.Add(p); + + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var isNullable = Convert.ToBoolean(r["is_nullable"]); + + //if it is a table valued function prefix the column name with the table valued function name + var columnName = discoveredTable is DiscoveredTableValuedFunction + ? $"{discoveredTable.GetRuntimeName()}.{r["COLUMN_NAME"]}" + : r["COLUMN_NAME"].ToString(); + + var toAdd = new DiscoveredColumn(discoveredTable, columnName, isNullable) + { + IsAutoIncrement = Convert.ToBoolean(r["is_identity"]), + Collation = r["collation_name"] as string + }; + toAdd.DataType = new DiscoveredDataType(r, GetSQLType_FromSpColumnsResult(r), toAdd); + toAdd.IsPrimaryKey = pks?.Contains(toAdd.GetRuntimeName()) ?? false; + yield return toAdd; + } + } + + /// + /// Returns the table name suitable for being passed into OBJECT_ID including schema if any + /// + /// + /// + private static string? GetObjectName(DiscoveredTable table) + { + var syntax = table.GetQuerySyntaxHelper(); + + var objectName = syntax.EnsureWrapped(table.GetRuntimeName()); + + return table.Schema != null ? $"{syntax.EnsureWrapped(table.Schema)}.{objectName}" : objectName; + } + + public override IDiscoveredColumnHelper GetColumnHelper() => new MicrosoftSQLColumnHelper(); + + public override void DropTable(DbConnection connection, DiscoveredTable tableToDrop) + { + SqlCommand cmd; + + switch (tableToDrop.TableType) + { + case TableType.View: + if (connection.Database != tableToDrop.Database.GetRuntimeName()) + connection.ChangeDatabase(tableToDrop.GetRuntimeName()); + + if (!connection.Database.ToLower().Equals(tableToDrop.Database.GetRuntimeName().ToLower())) + throw new NotSupportedException( + $"Cannot drop view {tableToDrop} because it exists in database {tableToDrop.Database.GetRuntimeName()} while the current current database connection is pointed at database:{connection.Database} (use .ChangeDatabase on the connection first) - SQL Server does not support cross database view dropping"); + + cmd = new SqlCommand($"DROP VIEW {tableToDrop.GetWrappedName()}", (SqlConnection)connection); + break; + case TableType.Table: + cmd = new SqlCommand($"DROP TABLE {tableToDrop.GetFullyQualifiedName()}", (SqlConnection)connection); + break; + case TableType.TableValuedFunction: + DropFunction(connection, (DiscoveredTableValuedFunction)tableToDrop); + return; + default: + throw new ArgumentOutOfRangeException(nameof(tableToDrop), $"Unknown table type {tableToDrop.TableType}"); + } + + using (cmd) + cmd.ExecuteNonQuery(); + } + + public override void DropFunction(DbConnection connection, DiscoveredTableValuedFunction functionToDrop) + { + using var cmd = new SqlCommand($"DROP FUNCTION {functionToDrop.Schema ?? "dbo"}.{functionToDrop.GetRuntimeName()}", (SqlConnection)connection); + cmd.ExecuteNonQuery(); + } + + public override void DropColumn(DbConnection connection, DiscoveredColumn columnToDrop) + { + using var cmd = new SqlCommand( + $"ALTER TABLE {columnToDrop.Table.GetFullyQualifiedName()} DROP column {columnToDrop.GetWrappedName()}", (SqlConnection)connection); + cmd.ExecuteNonQuery(); + } + + + public override IEnumerable DiscoverTableValuedFunctionParameters(DbConnection connection, + DiscoveredTableValuedFunction discoveredTableValuedFunction, DbTransaction? transaction) + { + if (connection.State != ConnectionState.Open) + throw new ArgumentException($@"Connection state was {connection.State} but had to be Open", nameof(connection)); + + const string query = """ + select + sys.parameters.name AS name, + sys.types.name AS TYPE_NAME, + sys.parameters.max_length AS LENGTH, + sys.types.collation_name AS COLLATION_NAME, + sys.parameters.scale AS SCALE, + sys.parameters.precision AS PRECISION + from + sys.parameters + join + sys.types on sys.parameters.user_type_id = sys.types.user_type_id + where object_id = OBJECT_ID(@tableName) + """; + + using var cmd = discoveredTableValuedFunction.GetCommand(query, connection); + var p = cmd.CreateParameter(); + p.ParameterName = "@tableName"; + p.Value = GetObjectName(discoveredTableValuedFunction); + cmd.Parameters.Add(p); + + cmd.Transaction = transaction; + + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var name = r["name"].ToString(); + if (name != null) + yield return new DiscoveredParameter(name) + { + DataType = new DiscoveredDataType(r, GetSQLType_FromSpColumnsResult(r), null) + }; + } + } + + public override IBulkCopy BeginBulkInsert(DiscoveredTable discoveredTable, IManagedConnection connection, CultureInfo culture) => new MicrosoftSQLBulkCopy(discoveredTable, connection, culture); + + public override void CreatePrimaryKey(DatabaseOperationArgs args, DiscoveredTable table, DiscoveredColumn[] discoverColumns) + { + try + { + using var connection = args.GetManagedConnection(table); + var columnHelper = GetColumnHelper(); + foreach (var alterSql in discoverColumns.Where(static dc => dc.AllowNulls).Select(col => columnHelper.GetAlterColumnToSql(col, col.DataType.SQLType, false))) + { + using var alterCmd = table.GetCommand(alterSql, connection.Connection, connection.Transaction); + args.ExecuteNonQuery(alterCmd); + } + } + catch (Exception e) + { + throw new AlterFailedException(string.Format(FAnsiStrings.DiscoveredTableHelper_CreatePrimaryKey_Failed_to_create_primary_key_on_table__0__using_columns___1__, table, string.Join(",", discoverColumns.Select(static c => c.GetRuntimeName()))), e); + } + + base.CreatePrimaryKey(args, table, discoverColumns); + } + + public override DiscoveredRelationship[] DiscoverRelationships(DiscoveredTable table, DbConnection connection, IManagedTransaction? transaction = null) + { + var toReturn = new Dictionary(); + + const string sql = "exec sp_fkeys @pktable_name = @table, @pktable_qualifier=@database, @pktable_owner=@schema"; + + using (var cmd = table.GetCommand(sql, connection)) + { + if (transaction != null) + cmd.Transaction = transaction.Transaction; + + var p = cmd.CreateParameter(); + p.ParameterName = "@table"; + p.Value = table.GetRuntimeName(); + p.DbType = DbType.String; + cmd.Parameters.Add(p); + + p = cmd.CreateParameter(); + p.ParameterName = "@schema"; + p.Value = table.Schema ?? "dbo"; + p.DbType = DbType.String; + cmd.Parameters.Add(p); + + p = cmd.CreateParameter(); + p.ParameterName = "@database"; + p.Value = table.Database.GetRuntimeName(); + p.DbType = DbType.String; + cmd.Parameters.Add(p); + + using var dt = new DataTable(); + var da = table.Database.Server.GetDataAdapter(cmd); + da.Fill(dt); + + foreach (DataRow r in dt.Rows) + { + var fkName = r["FK_NAME"].ToString() ?? throw new InvalidOperationException("Null foreign key name returned"); + + //could be a 2+ columns foreign key? + if (!toReturn.TryGetValue(fkName, out var current)) + { + var pkdb = r["PKTABLE_QUALIFIER"].ToString() ?? throw new InvalidOperationException("Null primary key database name returned"); + var pkschema = r["PKTABLE_OWNER"].ToString(); + var pktableName = r["PKTABLE_NAME"].ToString() ?? throw new InvalidOperationException("Null primary key table name returned"); + + var pktable = table.Database.Server.ExpectDatabase(pkdb).ExpectTable(pktableName, pkschema); + + var fkdb = r["FKTABLE_QUALIFIER"].ToString() ?? throw new InvalidOperationException("Null foreign key database name returned"); + var fkschema = r["FKTABLE_OWNER"].ToString(); + var fktableName = r["FKTABLE_NAME"].ToString() ?? throw new InvalidOperationException("Null foreign key name returned"); + + var fktable = table.Database.Server.ExpectDatabase(fkdb).ExpectTable(fktableName, fkschema); + + var deleteRuleInt = Convert.ToInt32(r["DELETE_RULE"]); + + var deleteRule = deleteRuleInt switch + { + 0 => CascadeRule.Delete, + 1 => CascadeRule.NoAction, + 2 => CascadeRule.SetNull, + 3 => CascadeRule.SetDefault, + _ => CascadeRule.Unknown + }; + + /* + https://docs.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-fkeys-transact-sql?view=sql-server-2017 + + 0=CASCADE changes to foreign key. + 1=NO ACTION changes if foreign key is present. + 2 = set null + 3 = set default*/ + + current = new DiscoveredRelationship(fkName, pktable, fktable, deleteRule); + toReturn.Add(current.Name, current); + } + + current.AddKeys(r["PKCOLUMN_NAME"].ToString(), r["FKCOLUMN_NAME"].ToString(), transaction); + } + } + + return [.. toReturn.Values]; + + } + + protected override string GetRenameTableSql(DiscoveredTable discoveredTable, string newName) + { + var oldName = discoveredTable.GetWrappedName(); + + var syntax = discoveredTable.GetQuerySyntaxHelper(); + + if (!string.IsNullOrWhiteSpace(discoveredTable.Schema)) + oldName = $"{syntax.EnsureWrapped(discoveredTable.Schema)}.{oldName}"; + + return $"exec sp_rename '{syntax.Escape(oldName)}', '{syntax.Escape(newName)}'"; + } + + public override void MakeDistinct(DatabaseOperationArgs args, DiscoveredTable discoveredTable) + { + var syntax = discoveredTable.GetQuerySyntaxHelper(); + + const string sql = """ + DELETE f + FROM ( + SELECT ROW_NUMBER() OVER (PARTITION BY {0} ORDER BY {0}) AS RowNum + FROM {1} + + ) as f + where RowNum > 1 + """; + + var columnList = string.Join(",", + discoveredTable.DiscoverColumns().Select(c => syntax.EnsureWrapped(c.GetRuntimeName()))); + + var sqlToExecute = string.Format(sql, columnList, discoveredTable.GetFullyQualifiedName()); + + var server = discoveredTable.Database.Server; + + using var con = args.GetManagedConnection(server); + using var cmd = server.GetCommand(sqlToExecute, con); + args.ExecuteNonQuery(cmd); + } + + + public override string GetTopXSqlForTable(IHasFullyQualifiedNameToo table, int topX) => $"SELECT TOP {topX} * FROM {table.GetFullyQualifiedName()}"; + + private string GetSQLType_FromSpColumnsResult(DbDataReader r) + { + var columnType = r["TYPE_NAME"] as string; + + if (columnType == "text") + return "varchar(max)"; + + var lengthQualifier = ""; + + if (HasPrecisionAndScale(columnType ?? throw new InvalidOperationException("Null type name returned"))) + lengthQualifier = $"({r["PRECISION"]},{r["SCALE"]})"; + else if (RequiresLength(columnType)) lengthQualifier = $"({AdjustForUnicodeAndNegativeOne(columnType, Convert.ToInt32(r["LENGTH"]))})"; + + return columnType + lengthQualifier; + } + + private static object AdjustForUnicodeAndNegativeOne(string columnType, int length) + { + if (length == -1) + return "max"; + + if (UnicodeRegex().IsMatch(columnType)) + return length / 2; + + return length; + } + + private static IEnumerable ListPrimaryKeys(IManagedConnection con, DiscoveredTable table) + { + const string query = """ + SELECT i.name AS IndexName, + OBJECT_NAME(ic.OBJECT_ID) AS TableName, + COL_NAME(ic.OBJECT_ID,ic.column_id) AS ColumnName, + c.is_identity + FROM sys.indexes AS i + INNER JOIN sys.index_columns AS ic + INNER JOIN sys.columns AS c ON ic.object_id = c.object_id AND ic.column_id = c.column_id + ON i.OBJECT_ID = ic.OBJECT_ID + AND i.index_id = ic.index_id + WHERE (i.is_primary_key = 1) AND ic.OBJECT_ID = OBJECT_ID(@tableName) + ORDER BY OBJECT_NAME(ic.OBJECT_ID), ic.key_ordinal + """; + + using var cmd = table.GetCommand(query, con.Connection); + var p = cmd.CreateParameter(); + p.ParameterName = "@tableName"; + p.Value = GetObjectName(table); + cmd.Parameters.Add(p); + + cmd.Transaction = con.Transaction; + using var r = cmd.ExecuteReader(); + while (r.Read()) + yield return (string)r["ColumnName"]; + + r.Close(); + } + + [GeneratedRegex("n(varchar|char|text)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex UnicodeRegex(); +} \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/MicrosoftSQLTypeTranslater.cs b/FAnsi.MicrosoftSql/MicrosoftSQLTypeTranslater.cs new file mode 100644 index 00000000..3fd44379 --- /dev/null +++ b/FAnsi.MicrosoftSql/MicrosoftSQLTypeTranslater.cs @@ -0,0 +1,33 @@ +using System.Text.RegularExpressions; +using FAnsi.Discovery.TypeTranslation; + +namespace FAnsi.Implementations.MicrosoftSQL; + +public sealed partial class MicrosoftSQLTypeTranslater : TypeTranslater +{ + public static readonly MicrosoftSQLTypeTranslater Instance = new(); + + private static readonly Regex AlsoBinaryRegex = AlsoBinaryRe(); + + private MicrosoftSQLTypeTranslater() : base(DateRe(), 8000, 4000) + { + } + + /// + /// Microsoft SQL lacks any sane boolean support, so use a 'BIT' instead + /// + /// + protected override string GetBoolDataType() => "bit"; + + protected override string GetDateDateTimeDataType() => "datetime2"; + + public override string GetStringDataTypeWithUnlimitedWidth() => "varchar(max)"; + + public override string GetUnicodeStringDataTypeWithUnlimitedWidth() => "nvarchar(max)"; + + protected override bool IsByteArray(string sqlType) => base.IsByteArray(sqlType) || AlsoBinaryRegex.IsMatch(sqlType); + [GeneratedRegex("date", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex DateRe(); + [GeneratedRegex("(image)|(timestamp)|(rowversion)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex AlsoBinaryRe(); +} \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/README.md b/FAnsi.MicrosoftSql/README.md new file mode 100644 index 00000000..867d6976 --- /dev/null +++ b/FAnsi.MicrosoftSql/README.md @@ -0,0 +1,48 @@ +# Microsoft Sql Server FAnsi Implementation + +# Feature Completeness + +- Server + - [X] Check Exists + - [X] Describe + - [X] List Databases + +- Database + - [X] Create + - [X] Drop + - [X] Backup + - [X] Detach + - [X] List Tables + - [X] List Table Valued Functions + - [X] List Stored Proceedures + +- Table + - [X] Create + - [X] Drop + - [X] Script Table Structure + - [X] MakeDistinct + - [X] Bulk Insert + - [X] Rename + - [X] List Foreign Keys + +- Column + - [X] Alter + +- Data Types + - [X] [Translation](./../../Documentation/TypeTranslation.md) + - [X] Bit + - [X] String + - [X] TimeSpan + - [X] Decimal + - [X] Date + - [X] Auto Increment + - [X] Unicode + +- Query + - [X] Top X + - [X] JOIN UPDATE + +- Aggregation + - [X] Basic GROUP BY + - [X] Calendar/Axis Table GROUP BY + - [X] Dynamic Pivot GROUP BY \ No newline at end of file diff --git a/FAnsi.MicrosoftSql/Update/MicrosoftSQLUpdateHelper.cs b/FAnsi.MicrosoftSql/Update/MicrosoftSQLUpdateHelper.cs new file mode 100644 index 00000000..98ba0d90 --- /dev/null +++ b/FAnsi.MicrosoftSql/Update/MicrosoftSQLUpdateHelper.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.QuerySyntax.Update; + +namespace FAnsi.Implementations.MicrosoftSQL.Update; + +public sealed class MicrosoftSQLUpdateHelper:UpdateHelper +{ + protected override string BuildUpdateImpl(DiscoveredTable table1, DiscoveredTable table2, List lines) + { + return $""" + UPDATE t1 + SET + {string.Join($", {Environment.NewLine}", lines.Where(static l => l.LocationToInsert == QueryComponent.SET).Select(static c => c.Text))} + FROM {table1.GetFullyQualifiedName()} AS t1 + INNER JOIN {table2.GetFullyQualifiedName()} AS t2 + ON {string.Join(" AND ", lines.Where(static l => l.LocationToInsert == QueryComponent.JoinInfoJoin).Select(static c => c.Text))} + WHERE + {string.Join(" AND ", lines.Where(static l => l.LocationToInsert == QueryComponent.WHERE).Select(static c => c.Text))} + """; + + } +} \ No newline at end of file diff --git a/FAnsi.MySql/Aggregation/MySqlAggregateHelper.cs b/FAnsi.MySql/Aggregation/MySqlAggregateHelper.cs new file mode 100644 index 00000000..7fed9fb3 --- /dev/null +++ b/FAnsi.MySql/Aggregation/MySqlAggregateHelper.cs @@ -0,0 +1,440 @@ +using System; +using System.Linq; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.QuerySyntax.Aggregation; + +namespace FAnsi.Implementations.MySql.Aggregation; + +public sealed class MySqlAggregateHelper : AggregateHelper +{ + public static readonly MySqlAggregateHelper Instance = new(); + private MySqlAggregateHelper() { } + private static string GetDateAxisTableDeclaration(IQueryAxis axis) + { + //if the axis is days then there are likely to be thousands of them but if we start adding thousands of years + //mysql date falls over with overflow exceptions + var thousands = + axis.AxisIncrement == AxisIncrement.Day ? + """ + JOIN + (SELECT 0 thousands + UNION ALL SELECT 1000 UNION ALL SELECT 2000 UNION ALL SELECT 3000 + UNION ALL SELECT 4000 UNION ALL SELECT 5000 UNION ALL SELECT 6000 + UNION ALL SELECT 7000 UNION ALL SELECT 8000 UNION ALL SELECT 9000 + ) thousands + """ : ""; + + var plusThousands = axis.AxisIncrement == AxisIncrement.Day ? "+ thousands" : ""; + + //QueryComponent.JoinInfoJoin + return + $""" + + + SET @startDate = {axis.StartDate}; + SET @endDate = {axis.EndDate}; + + drop temporary table if exists dateAxis; + + create temporary table dateAxis + ( + dt DATE + ); + + insert into dateAxis + + SELECT distinct (@startDate + INTERVAL c.number {axis.AxisIncrement}) AS date + FROM (SELECT singles + tens + hundreds {plusThousands} number FROM + ( SELECT 0 singles + UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 + UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 + UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 + ) singles JOIN + (SELECT 0 tens + UNION ALL SELECT 10 UNION ALL SELECT 20 UNION ALL SELECT 30 + UNION ALL SELECT 40 UNION ALL SELECT 50 UNION ALL SELECT 60 + UNION ALL SELECT 70 UNION ALL SELECT 80 UNION ALL SELECT 90 + ) tens JOIN + (SELECT 0 hundreds + UNION ALL SELECT 100 UNION ALL SELECT 200 UNION ALL SELECT 300 + UNION ALL SELECT 400 UNION ALL SELECT 500 UNION ALL SELECT 600 + UNION ALL SELECT 700 UNION ALL SELECT 800 UNION ALL SELECT 900 + ) hundreds + {thousands} + ORDER BY number DESC) c + WHERE c.number BETWEEN 0 and 10000; + + delete from dateAxis where dt > @endDate; + """; + } + + public override string GetDatePartOfColumn(AxisIncrement increment, string columnSql) + { + return increment switch + { + AxisIncrement.Day => $"DATE({columnSql})", + AxisIncrement.Month => $"DATE_FORMAT({columnSql},'%Y-%m')", + AxisIncrement.Year => $"YEAR({columnSql})", + AxisIncrement.Quarter => $"CONCAT(YEAR({columnSql}),'Q',QUARTER({columnSql}))", + _ => throw new ArgumentOutOfRangeException(nameof(increment)) + }; + } + + + protected override IQuerySyntaxHelper GetQuerySyntaxHelper() => MySqlQuerySyntaxHelper.Instance; + + protected override string BuildAxisAggregate(AggregateCustomLineCollection query) + { + var countAlias = query.CountSelect.GetAliasFromText(query.SyntaxHelper); + var axisColumnAlias = query.AxisSelect.GetAliasFromText(query.SyntaxHelper) ?? "joinDt"; + + WrapAxisColumnWithDatePartFunction(query, axisColumnAlias); + + + return string.Format( + """ + + {0} + {1} + + SELECT + {2} AS joinDt,dataset.{3} + FROM + dateAxis + LEFT JOIN + ( + {4} + ) dataset + ON dataset.{5} = {2} + ORDER BY + {2} + + """ + , + string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert < QueryComponent.SELECT)), + GetDateAxisTableDeclaration(query.Axis), + + GetDatePartOfColumn(query.Axis.AxisIncrement, "dateAxis.dt"), + countAlias, + + //the entire query + string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert is >= QueryComponent.SELECT and <= QueryComponent.Having)), + axisColumnAlias + ).Trim(); + + } + + protected override string BuildPivotAndAxisAggregate(AggregateCustomLineCollection query) + { + var axisColumnWithoutAlias = query.AxisSelect.GetTextWithoutAlias(query.SyntaxHelper); + var part1 = GetPivotPart1(query); + + return string.Format(""" + + {0} + + {1} + + {2} + + SET @sql = + + CONCAT( + ' + SELECT + {3} as joinDt,',@columnsSelectFromDataset,' + FROM + dateAxis + LEFT JOIN + ( + {4} + {5} AS joinDt, + ' + ,@columnsSelectCases, + ' + {6} + group by + {5} + ) dataset + ON {3} = dataset.joinDt + ORDER BY + {3} + '); + + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + """, + string.Join(Environment.NewLine, query.Lines.Where(static l => l.LocationToInsert < QueryComponent.SELECT)), + GetDateAxisTableDeclaration(query.Axis), + part1, + query.SyntaxHelper.Escape(GetDatePartOfColumn(query.Axis.AxisIncrement, "dateAxis.dt")), + string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert == QueryComponent.SELECT)), + + //the from including all table joins and where but no calendar table join + query.SyntaxHelper.Escape(GetDatePartOfColumn(query.Axis.AxisIncrement, axisColumnWithoutAlias)), + + //the order by (should be count so that heavy populated columns come first) + string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert is >= QueryComponent.FROM and <= QueryComponent.WHERE).Select(x => query.SyntaxHelper.Escape(x.Text))) + ); + } + + protected override string BuildPivotOnlyAggregate(AggregateCustomLineCollection query, CustomLine nonPivotColumn) + { + var part1 = GetPivotPart1(query); + + var joinAlias = nonPivotColumn.GetAliasFromText(query.SyntaxHelper); + + return string.Format(""" + + {0} + + {1} + + SET @sql = + + CONCAT( + ' + SELECT + {2}',@columnsSelectCases,' + + {3} + GROUP BY + {4} + ORDER BY + {4} + {5} + '); + + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + """, + string.Join(Environment.NewLine, query.Lines.Where(static l => l.LocationToInsert < QueryComponent.SELECT)), + part1, + nonPivotColumn, + + //everything inclusive of FROM but stopping before GROUP BY + query.SyntaxHelper.Escape(string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert is >= QueryComponent.FROM and < QueryComponent.GroupBy))), + + joinAlias, + + //any HAVING SQL + query.SyntaxHelper.Escape(string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert == QueryComponent.Having))) + ); + } + + /// + /// Returns the section of the PIVOT which identifies unique values. For MySql this is done by assembling a massive CASE statement. + /// + /// + /// + private static string GetPivotPart1(AggregateCustomLineCollection query) + { + var pivotSqlWithoutAlias = query.PivotSelect.GetTextWithoutAlias(query.SyntaxHelper); + + var countSqlWithoutAlias = query.CountSelect.GetTextWithoutAlias(query.SyntaxHelper); + + query.SyntaxHelper.SplitLineIntoOuterMostMethodAndContents(countSqlWithoutAlias, out var aggregateMethod, + out var aggregateParameter); + + if (aggregateParameter.Equals("*")) + aggregateParameter = "1"; + + + //if there is an axis we must ensure we only pull pivot values where the values appear in that axis range + var whereDateColumnNotNull = ""; + + if (query.AxisSelect != null) + { + var axisColumnWithoutAlias = query.AxisSelect.GetTextWithoutAlias(query.SyntaxHelper); + + whereDateColumnNotNull += query.Lines.Any(static l => l.LocationToInsert == QueryComponent.WHERE) ? "AND " : "WHERE "; + whereDateColumnNotNull += $"{axisColumnWithoutAlias} IS NOT NULL"; + } + + //work out how to order the pivot columns + var orderBy = $"{countSqlWithoutAlias} desc"; //default, order by the count(*) / sum(*) etc column desc + + //theres an explicit topX so order by it verbatim instead + var topXOrderByLine = + query.Lines.SingleOrDefault(static c => c.LocationToInsert == QueryComponent.OrderBy && c.Role == CustomLineRole.TopX); + if (topXOrderByLine != null) + orderBy = topXOrderByLine.Text; + + //if theres a topX limit postfix line (See MySqlQuerySyntaxHelper.HowDoWeAchieveTopX) add that too + var topXLimitLine = + query.Lines.SingleOrDefault(static c => c.LocationToInsert == QueryComponent.Postfix && c.Role == CustomLineRole.TopX); + var topXLimitSqlIfAny = topXLimitLine != null ? topXLimitLine.Text : ""; + + var havingSqlIfAny = string.Join(Environment.NewLine, + query.Lines.Where(static l => l.LocationToInsert == QueryComponent.Having).Select(static l => l.Text)); + + return string.Format(""" + + SET SESSION group_concat_max_len = 1000000; + + DROP TEMPORARY TABLE IF EXISTS pivotValues; + + /*Get the unique values in the pivot column into a temporary table ordered by size of the count*/ + CREATE TEMPORARY TABLE pivotValues AS ( + SELECT + {1} as piv + {3} + {4} + group by + {1} + {7} + order by + {6} + {5} + ); + + /* Build case when x='fish' then 1 else null end as 'fish', case when x='cammel' then 1 end as 'cammel' etc*/ + SET @columnsSelectCases = NULL; + SELECT + GROUP_CONCAT( + CONCAT( + '{0}(case when {1} = ', QUOTE(pivotValues.piv), ' then {2} else null end) AS `', pivotValues.piv,'`' + ) + ) INTO @columnsSelectCases + FROM + pivotValues; + + /* Build dataset.fish, dataset.cammel etc*/ + SET @columnsSelectFromDataset = NULL; + SELECT + GROUP_CONCAT( + CONCAT( + 'dataset.`', pivotValues.piv,'`') + ) INTO @columnsSelectFromDataset + FROM + pivotValues; + + """, + aggregateMethod, + pivotSqlWithoutAlias, + aggregateParameter, + + //the from including all table joins and where but no calendar table join + string.Join(Environment.NewLine, + query.Lines.Where(static l => + l.LocationToInsert is >= QueryComponent.FROM and <= QueryComponent.WHERE && + l.Role != CustomLineRole.Axis)), + whereDateColumnNotNull, + topXLimitSqlIfAny, + orderBy, + havingSqlIfAny + ); + + } + + + //so janky to double select GROUP_Concat just so we can get dataset* except join.dt -- can we do it once into @columns then again into the other + + //use mysql; + + // SET @startDate = '1920-01-01'; + // SET @endDate = now(); + + // drop temporary table if exists dateAxis; + + // create temporary table dateAxis + // ( + // dt DATE + // ); + + //insert into dateAxis + + // SELECT distinct (@startDate + INTERVAL c.number Year) AS date + //FROM (SELECT singles + tens + hundreds number FROM + //( SELECT 0 singles + //UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 + //UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 + //UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 + //) singles JOIN + //(SELECT 0 tens + //UNION ALL SELECT 10 UNION ALL SELECT 20 UNION ALL SELECT 30 + //UNION ALL SELECT 40 UNION ALL SELECT 50 UNION ALL SELECT 60 + //UNION ALL SELECT 70 UNION ALL SELECT 80 UNION ALL SELECT 90 + //) tens JOIN + //(SELECT 0 hundreds + //UNION ALL SELECT 100 UNION ALL SELECT 200 UNION ALL SELECT 300 + //UNION ALL SELECT 400 UNION ALL SELECT 500 UNION ALL SELECT 600 + //UNION ALL SELECT 700 UNION ALL SELECT 800 UNION ALL SELECT 900 + //) hundreds + //ORDER BY number DESC) c + //WHERE c.number BETWEEN 0 and 1000; + + //delete from dateAxis where dt > @endDate; + + //SET SESSION group_concat_max_len = 1000000; + + //SET @columns = NULL; + //SELECT + // GROUP_CONCAT(DISTINCT + // CONCAT( + // 'count(case when `test`.`biochemistry`.`hb_extract` = \'', + // b.`Pivot`, + // \'' then 1 else null end) AS `', + // b.`Pivot`,'`' + // ) order by b.`CountName` desc + // ) INTO @columns + //FROM + //( + //select `test`.`biochemistry`.`hb_extract` AS Pivot, count(*) AS CountName + //FROM + //`test`.`biochemistry` + //group by `test`.`biochemistry`.`hb_extract` + //) as b; + + + //SET @columnNames = NULL; + //SELECT + // GROUP_CONCAT(DISTINCT + // CONCAT( + // 'dataset.`',b.`Pivot`,'`') order by b.`CountName` desc + // ) INTO @columnNames + //FROM + //( + //select `test`.`biochemistry`.`hb_extract` AS Pivot, count(*) AS CountName + //FROM + //`test`.`biochemistry` + //group by `test`.`biochemistry`.`hb_extract` + //) as b; + + + + + //SET @sql = + + + //CONCAT( + //' + //SELECT + //YEAR(dateAxis.dt) AS joinDt,',@columnNames,' + //FROM + //dateAxis + //LEFT JOIN + //( + // /*HbsByYear*/ + //SELECT + // YEAR(`test`.`biochemistry`.`sample_date`) AS joinDt, + //' + // ,@columns, + //' + //FROM + //`test`.`biochemistry` + //group by + //YEAR(`test`.`biochemistry`.`sample_date`) + //) dataset + //ON dataset.joinDt = YEAR(dateAxis.dt) + //ORDER BY + //YEAR(dateAxis.dt) + //'); + + //PREPARE stmt FROM @sql; + //EXECUTE stmt; + //DEALLOCATE PREPARE stmt; + +} \ No newline at end of file diff --git a/FAnsi.MySql/FAnsi.MySql.csproj b/FAnsi.MySql/FAnsi.MySql.csproj new file mode 100644 index 00000000..124f6577 --- /dev/null +++ b/FAnsi.MySql/FAnsi.MySql.csproj @@ -0,0 +1,45 @@ + + + HIC.FAnsi.MySql + 0.0.7 + HIC.FAnsi.MySql + Health Informatics Centre - University of Dundee + Health Informatics Centre - University of Dundee + https://github.com/HicServices/FAnsiSql + GPL-3.0-or-later + false + MySQL implementation for FAnsiSql + Ansi,SQL,MySQL + HIC.FAnsi.MySql + Health Informatics Centre, University of Dundee + HIC.FAnsi.MySql + MySQL implementation for FAnsiSql + Copyright © 2019-2025 + false + true + true + CS1591 + en-GB + embedded + README.md + + + 1 + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + \ No newline at end of file diff --git a/FAnsi.MySql/MySqlBulkCopy.cs b/FAnsi.MySql/MySqlBulkCopy.cs new file mode 100644 index 00000000..39b41da0 --- /dev/null +++ b/FAnsi.MySql/MySqlBulkCopy.cs @@ -0,0 +1,135 @@ +using System; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using FAnsi.Connections; +using FAnsi.Discovery; +using MySqlConnector; + +namespace FAnsi.Implementations.MySql; + +/// +/// Inserts rows into MySql table using extended INSERT commands. 'LOAD DATA IN FILE' is not used because it doesn't respect table constraints, can be disabled +/// on the server and generally can go wrong in a large number of ways. +/// +public sealed partial class MySqlBulkCopy(DiscoveredTable targetTable, IManagedConnection connection, CultureInfo culture) + : BulkCopy(targetTable, connection, culture) +{ + public static int BulkInsertBatchTimeoutInSeconds { get; set; } = 0; + + public override int UploadImpl(DataTable dt) + { + var ourTrans = Connection.Transaction == null ? Connection.Connection.BeginTransaction(IsolationLevel.ReadUncommitted) : null; + var matchedColumns = GetMapping(dt.Columns.Cast()); + var affected = 0; + + int maxPacket; + using (var packetQ = new MySqlCommand("select @@max_allowed_packet", (MySqlConnection)Connection.Connection, (MySqlTransaction?)(Connection.Transaction ?? ourTrans))) + maxPacket = Convert.ToInt32(packetQ.ExecuteScalar()); + using var cmd = new MySqlCommand("", (MySqlConnection)Connection.Connection, + (MySqlTransaction?)(Connection.Transaction ?? ourTrans)); + if (BulkInsertBatchTimeoutInSeconds != 0) + cmd.CommandTimeout = BulkInsertBatchTimeoutInSeconds; + + var commandPrefix = + $"INSERT INTO {TargetTable.GetFullyQualifiedName()}({string.Join(",", matchedColumns.Values.Select(static c => + $"`{c.GetRuntimeName()}`"))}) VALUES "; + + var sb = new StringBuilder(commandPrefix, 1 << 22); + + var matches = matchedColumns.Keys.Select(column => (matchedColumns[column].DataType.SQLType, column.Ordinal)).ToArray(); + foreach (DataRow dr in dt.Rows) + { + sb.Append('('); + + var dr1 = dr; + + sb.AppendJoin(',', matches.Select(m => ConstructIndividualValue(m.SQLType, dr1[m.Ordinal]))); + + sb.AppendLine("),"); + + //don't let command get too long + if (sb.Length * 2 < maxPacket) continue; + + cmd.CommandText = sb.ToString().TrimEnd(',', '\r', '\n'); + affected += cmd.ExecuteNonQuery(); + sb.Clear(); + sb.Append(commandPrefix); + } + + //send final batch + if (sb.Length > commandPrefix.Length) + { + cmd.CommandText = sb.ToString().TrimEnd(',', '\r', '\n'); + affected += cmd.ExecuteNonQuery(); + sb.Clear(); + } + + ourTrans?.Commit(); + return affected; + } + + private string ConstructIndividualValue(string dataType, object value) + { + dataType = dataType.ToUpper(); + dataType = BracketsRe().Replace(dataType, "").Trim(); + + if (value is DateTime valueDateTime) + switch (dataType) + { + case "DATE": + return $"'{valueDateTime:yyyy-MM-dd}'"; + case "TIMESTAMP" or "DATETIME": + return $"'{valueDateTime:yyyy-MM-dd HH:mm:ss}'"; + case "TIME": + return $"'{valueDateTime:HH:mm:ss}'"; + } + + if (value == null || value == DBNull.Value) + return "NULL"; + + return ConstructIndividualValue(dataType, value.ToString()); + } + + private string ConstructIndividualValue(string dataType, string value) + { + return dataType switch + { + "BIT" => value, + //Numbers + "INT" => $"{value}", + "TINYINT" => $"{value}", + "SMALLINT" => $"{value}", + "MEDIUMINT" => $"{value}", + "BIGINT" => $"{value}", + "FLOAT" => $"{value}", + "DOUBLE" => $"{value}", + "DECIMAL" => $"{value}", + //Text + "CHAR" => $"'{MySqlHelper.EscapeString(value)}'", + "VARCHAR" => $"'{MySqlHelper.EscapeString(value)}'", + "BLOB" => $"'{MySqlHelper.EscapeString(value)}'", + "TEXT" => $"'{MySqlHelper.EscapeString(value)}'", + "TINYBLOB" => $"'{MySqlHelper.EscapeString(value)}'", + "TINYTEXT" => $"'{MySqlHelper.EscapeString(value)}'", + "MEDIUMBLOB" => $"'{MySqlHelper.EscapeString(value)}'", + "MEDIUMTEXT" => $"'{MySqlHelper.EscapeString(value)}'", + "LONGBLOB" => $"'{MySqlHelper.EscapeString(value)}'", + "LONGTEXT" => $"'{MySqlHelper.EscapeString(value)}'", + "ENUM" => $"'{MySqlHelper.EscapeString(value)}'", + //Dates/times + "DATE" => $"'{(DateTime?)DateTimeDecider.Parse(value):yyyy-MM-dd}'", + "TIMESTAMP" => $"'{(DateTime?)DateTimeDecider.Parse(value):yyyy-MM-dd HH:mm:ss}'", + "DATETIME" => $"'{(DateTime?)DateTimeDecider.Parse(value):yyyy-MM-dd HH:mm:ss}'", + "TIME" => $"'{(DateTime?)DateTimeDecider.Parse(value):HH:mm:ss}'", + "YEAR2" => $"'{(DateTime?)DateTimeDecider.Parse(value):yy}'", + "YEAR4" => $"'{(DateTime?)DateTimeDecider.Parse(value):yyyy}'", + _ => $"'{MySqlHelper.EscapeString(value)}'" + }; + } + + [GeneratedRegex("\\(.*\\)")] + private static partial Regex BracketsRe(); +} \ No newline at end of file diff --git a/FAnsi.MySql/MySqlColumnHelper.cs b/FAnsi.MySql/MySqlColumnHelper.cs new file mode 100644 index 00000000..dc386a25 --- /dev/null +++ b/FAnsi.MySql/MySqlColumnHelper.cs @@ -0,0 +1,33 @@ +using System.Text; +using FAnsi.Discovery; +using FAnsi.Naming; + +namespace FAnsi.Implementations.MySql; + +public sealed class MySqlColumnHelper : IDiscoveredColumnHelper +{ + public static readonly MySqlColumnHelper Instance = new(); + private MySqlColumnHelper() {} + + public string GetTopXSqlForColumn(IHasRuntimeName database, IHasFullyQualifiedNameToo table, IHasRuntimeName column, int topX, bool discardNulls) + { + var syntax = MySqlQuerySyntaxHelper.Instance; + + var sql = new StringBuilder(); + + sql.Append($"SELECT {syntax.EnsureWrapped(column.GetRuntimeName())} FROM {table.GetFullyQualifiedName()}"); + + if (discardNulls) + sql.Append($" WHERE {syntax.EnsureWrapped(column.GetRuntimeName())} IS NOT NULL"); + + sql.Append($" LIMIT {topX}"); + return sql.ToString(); + } + + public string GetAlterColumnToSql(DiscoveredColumn column, string newType, bool allowNulls) + { + var syntax = column.Table.Database.Server.GetQuerySyntaxHelper(); + return + $"ALTER TABLE {column.Table.GetFullyQualifiedName()} MODIFY COLUMN {syntax.EnsureWrapped(column.GetRuntimeName())} {newType} {(allowNulls ? "NULL" : "NOT NULL")}"; + } +} \ No newline at end of file diff --git a/FAnsi.MySql/MySqlDatabaseHelper.cs b/FAnsi.MySql/MySqlDatabaseHelper.cs new file mode 100644 index 00000000..41b96ce3 --- /dev/null +++ b/FAnsi.MySql/MySqlDatabaseHelper.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.IO; +using System.Linq; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using MySqlConnector; + +namespace FAnsi.Implementations.MySql; + +public sealed class MySqlDatabaseHelper : DiscoveredDatabaseHelper +{ + public override IEnumerable ListTableValuedFunctions(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, + DbConnection connection, string database, DbTransaction? transaction = null) => + Enumerable.Empty(); + + public override DiscoveredStoredprocedure[] ListStoredprocedures(DbConnectionStringBuilder builder, string database) => throw new NotImplementedException(); + + public override IDiscoveredTableHelper GetTableHelper() => MySqlTableHelper.Instance; + + public override void DropDatabase(DiscoveredDatabase database) + { + using var con = (MySqlConnection) database.Server.GetConnection(); + con.Open(); + using var cmd = new MySqlCommand($"DROP DATABASE `{database.GetRuntimeName()}`",con); + cmd.ExecuteNonQuery(); + } + + public override Dictionary DescribeDatabase(DbConnectionStringBuilder builder, string database) + { + var mysqlBuilder = (MySqlConnectionStringBuilder) builder; + + return new Dictionary + { + { "UserID", mysqlBuilder.UserID }, + { "Server", mysqlBuilder.Server }, + { "Database", mysqlBuilder.Database } + }; + } + + protected override string GetCreateTableSqlLineForColumn(DatabaseColumnRequest col, string datatype, IQuerySyntaxHelper syntaxHelper) => + //if it is not unicode then that's fine + col.TypeRequested?.Unicode != true ? base.GetCreateTableSqlLineForColumn(col, datatype, syntaxHelper) : + //MySql unicode is not a data type it's a character set/collation only + $"{syntaxHelper.EnsureWrapped(col.ColumnName)} {datatype} CHARACTER SET utf8mb4 {(col.Default != MandatoryScalarFunctions.None ? $"default {syntaxHelper.GetScalarFunctionSql(col.Default)}" : "")} COLLATE {col.Collation ?? "utf8mb4_bin"} {(col is { AllowNulls: true, IsPrimaryKey: false } ? " NULL" : " NOT NULL")} {(col.IsAutoIncrement ? syntaxHelper.GetAutoIncrementKeywordIfAny() : "")}"; + + public override DirectoryInfo Detach(DiscoveredDatabase database) => throw new NotImplementedException(); + + public override void CreateBackup(DiscoveredDatabase discoveredDatabase, string backupName) + { + throw new NotImplementedException(); + } + + public override void CreateSchema(DiscoveredDatabase discoveredDatabase, string name) + { + + } + + public override IEnumerable ListTables(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, DbConnection connection, string database, bool includeViews, DbTransaction? transaction = null) + { + if (connection.State == ConnectionState.Closed) + throw new InvalidOperationException("Expected connection to be open"); + + var tables = new List(); + + using (var cmd = new MySqlCommand($"SHOW FULL TABLES in `{database}`", (MySqlConnection) connection)) + { + cmd.Transaction = transaction as MySqlTransaction; + + var r = cmd.ExecuteReader(); + while (r.Read()) + { + var isView = (string)r[1] == "VIEW"; + + //if we are skipping views + if(isView && !includeViews) + continue; + + //skip invalid table names + if(!querySyntaxHelper.IsValidTableName((string)r[0],out _)) + continue; + + tables.Add(new DiscoveredTable(parent,(string)r[0],querySyntaxHelper,null,isView ? TableType.View : TableType.Table));//this table fieldname will be something like Tables_in_mydbwhatevernameitis + } + } + + return tables.ToArray(); + } + +} \ No newline at end of file diff --git a/FAnsi.MySql/MySqlImplementation.cs b/FAnsi.MySql/MySqlImplementation.cs new file mode 100644 index 00000000..61247727 --- /dev/null +++ b/FAnsi.MySql/MySqlImplementation.cs @@ -0,0 +1,16 @@ +using System.Data.Common; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Implementation; +using MySqlConnector; + +namespace FAnsi.Implementations.MySql; + +public sealed class MySqlImplementation() : Implementation(DatabaseType.MySql) +{ + public override IDiscoveredServerHelper GetServerHelper() => MySqlServerHelper.Instance; + + public override bool IsFor(DbConnection connection) => connection is MySqlConnection; + + public override IQuerySyntaxHelper GetQuerySyntaxHelper() => MySqlQuerySyntaxHelper.Instance; +} \ No newline at end of file diff --git a/FAnsi.MySql/MySqlQuerySyntaxHelper.cs b/FAnsi.MySql/MySqlQuerySyntaxHelper.cs new file mode 100644 index 00000000..f2712105 --- /dev/null +++ b/FAnsi.MySql/MySqlQuerySyntaxHelper.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Text; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Implementations.MySql.Aggregation; +using FAnsi.Implementations.MySql.Update; + +namespace FAnsi.Implementations.MySql; + +public sealed class MySqlQuerySyntaxHelper : QuerySyntaxHelper +{ + public static readonly MySqlQuerySyntaxHelper Instance = new(); + public override int MaximumDatabaseLength => 64; + public override int MaximumTableLength => 64; + public override int MaximumColumnLength => 64; + + + + public override string OpenQualifier => "`"; + + public override string CloseQualifier => "`"; + + private MySqlQuerySyntaxHelper() : base(MySqlTypeTranslater.Instance, MySqlAggregateHelper.Instance,MySqlUpdateHelper.Instance,DatabaseType.MySql)//no specific type translation required + { + } + + public override bool SupportsEmbeddedParameters() => true; + + public override string EnsureWrappedImpl(string databaseOrTableName) => $"`{GetRuntimeNameWithDoubledBackticks(databaseOrTableName)}`"; + + /// + /// Returns the runtime name of the string with all backticks escaped (but resulting string is not wrapped in backticks itself) + /// + /// + /// + private string? GetRuntimeNameWithDoubledBackticks(string s) => GetRuntimeName(s)?.Replace("`", "``"); + + protected override string UnescapeWrappedNameBody(string name) => name.Replace("``", "`"); + + public override string EnsureFullyQualified(string? databaseName, string? schema, string tableName) + { + //if there is no schema address it as db..table (which is the same as db.dbo.table in Microsoft SQL Server) + if (!string.IsNullOrWhiteSpace(schema)) + throw new NotSupportedException("Schema (e.g. .dbo.) is not supported by MySql"); + + return $"{EnsureWrapped(databaseName)}{DatabaseTableSeparator}{EnsureWrapped(tableName)}"; + } + + public override TopXResponse HowDoWeAchieveTopX(int x) => new($"LIMIT {x}",QueryComponent.Postfix); + + public override string GetParameterDeclaration(string proposedNewParameterName, string sqlType) => + //MySql doesn't require parameter declaration you just start using it like javascript + $"/* {proposedNewParameterName} */"; + + public override string Escape(string sql) + { + // https://dev.mysql.com/doc/refman/8.0/en/string-literals.html + var r = new StringBuilder(sql.Length); + foreach (var c in sql) + r.Append(c switch + { + '\0' => "\\0", + '\'' => "\\'", + '"' => "\"", + '\b' => "\\b", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + '\u001a' => "\\Z", + '\\' => "\\", + // Pattern matching only: + // '%' => "\\%", + // '_' => "\\_", + _ => $"{c}" + }); + return r.ToString(); + } + + public override string GetScalarFunctionSql(MandatoryScalarFunctions function) => + function switch + { + MandatoryScalarFunctions.GetTodaysDate => //this works at least as of 5.7.19 + "now()", + MandatoryScalarFunctions.GetGuid => //using this as defaults in columns requires MySql 8 (2018) + "(uuid())", + MandatoryScalarFunctions.Len => "LENGTH", + _ => throw new ArgumentOutOfRangeException(nameof(function)) + }; + + public override string GetAutoIncrementKeywordIfAny() => "AUTO_INCREMENT"; + + public override Dictionary GetSQLFunctionsDictionary() => Functions; + + private static readonly Dictionary Functions = new() + { + {"left", "LEFT ( string , length)"}, + {"right", "RIGHT ( string , length )"}, + {"upper", "UPPER ( string )"}, + {"substring", "SUBSTR ( str ,start , length ) "}, + {"dateadd", "DATE_ADD (date, INTERVAL value unit)"}, + {"datediff", "DATEDIFF ( date1 , date2) "}, + {"getdate", "now()"}, + {"now", "now()"}, + {"cast", "CAST ( value AS type )"}, + {"convert", "CONVERT ( value, type ) "}, + {"case", "CASE WHEN x=y THEN 'something' WHEN x=z THEN 'something2' ELSE 'something3' END"} + }; + + public override string HowDoWeAchieveMd5(string selectSql) => $"md5({selectSql})"; +} \ No newline at end of file diff --git a/FAnsi.MySql/MySqlServerHelper.cs b/FAnsi.MySql/MySqlServerHelper.cs new file mode 100644 index 00000000..45a73ae1 --- /dev/null +++ b/FAnsi.MySql/MySqlServerHelper.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using FAnsi.Discovery; +using FAnsi.Discovery.ConnectionStringDefaults; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Naming; +using MySqlConnector; + +namespace FAnsi.Implementations.MySql; + +public sealed class MySqlServerHelper : DiscoveredServerHelper +{ + public static readonly MySqlServerHelper Instance = new(); + static MySqlServerHelper() + { + AddConnectionStringKeyword(DatabaseType.MySql, "AllowUserVariables", "True", ConnectionStringKeywordPriority.ApiRule); + AddConnectionStringKeyword(DatabaseType.MySql, "CharSet", "utf8", ConnectionStringKeywordPriority.ApiRule); + } + + private MySqlServerHelper() : base(DatabaseType.MySql) + { + } + + protected override string ServerKeyName => "Server"; + protected override string DatabaseKeyName => "Database"; + + #region Up Typing + public override DbCommand GetCommand(string s, DbConnection con, DbTransaction? transaction = null) => + new MySqlCommand(s, con as MySqlConnection, transaction as MySqlTransaction); + + public override DbDataAdapter GetDataAdapter(DbCommand cmd) => new MySqlDataAdapter(cmd as MySqlCommand ?? + throw new ArgumentException("Incorrect command type", nameof(cmd))); + + public override DbCommandBuilder GetCommandBuilder(DbCommand cmd) => + new MySqlCommandBuilder((MySqlDataAdapter)GetDataAdapter(cmd)); + + public override DbParameter GetParameter(string parameterName) => new MySqlParameter(parameterName, null); + + public override DbConnection GetConnection(DbConnectionStringBuilder builder) => + new MySqlConnection(builder.ConnectionString); + + protected override DbConnectionStringBuilder GetConnectionStringBuilderImpl(string? connectionString) => + connectionString != null + ? new MySqlConnectionStringBuilder(connectionString) + : new MySqlConnectionStringBuilder(); + + protected override DbConnectionStringBuilder GetConnectionStringBuilderImpl(string server, string? database, string username, string password) + { + var toReturn = new MySqlConnectionStringBuilder + { + Server = server + }; + + if (!string.IsNullOrWhiteSpace(database)) + toReturn.Database = database; + + if (!string.IsNullOrWhiteSpace(username)) + { + toReturn.UserID = username; + toReturn.Password = password; + } + + return toReturn; + } + + #endregion + + public override DbConnectionStringBuilder EnableAsync(DbConnectionStringBuilder builder) => builder; //no special stuff required? + + public override IDiscoveredDatabaseHelper GetDatabaseHelper() => new MySqlDatabaseHelper(); + + public override IQuerySyntaxHelper GetQuerySyntaxHelper() => MySqlQuerySyntaxHelper.Instance; + + public override void CreateDatabase(DbConnectionStringBuilder builder, IHasRuntimeName newDatabaseName) + { + var b = (MySqlConnectionStringBuilder)GetConnectionStringBuilder(builder.ConnectionString); + b.Database = null; + + using var con = new MySqlConnection(b.ConnectionString); + con.Open(); + using var cmd = GetCommand($"CREATE DATABASE `{newDatabaseName.GetRuntimeName()}`", con); + cmd.CommandTimeout = CreateDatabaseTimeoutInSeconds; + cmd.ExecuteNonQuery(); + } + + public override Dictionary DescribeServer(DbConnectionStringBuilder builder) => throw new NotImplementedException(); + + public override string GetExplicitUsernameIfAny(DbConnectionStringBuilder builder) => ((MySqlConnectionStringBuilder)builder).UserID; + + public override string GetExplicitPasswordIfAny(DbConnectionStringBuilder builder) => ((MySqlConnectionStringBuilder)builder).Password; + + public override Version? GetVersion(DiscoveredServer server) + { + using var con = server.GetConnection(); + con.Open(); + using var cmd = server.GetCommand("show variables like \"version\"", con); + using var r = cmd.ExecuteReader(); + if (r.Read()) + return r["Value"] == DBNull.Value ? null : CreateVersionFromString((string)r["Value"]); + + return null; + } + + public override IEnumerable ListDatabases(DbConnectionStringBuilder builder) + { + var b = (MySqlConnectionStringBuilder)GetConnectionStringBuilder(builder.ConnectionString); + b.Database = null; + + using var con = new MySqlConnection(b.ConnectionString); + con.Open(); + foreach (var listDatabase in ListDatabases(con)) yield return listDatabase; + } + + public override IEnumerable ListDatabases(DbConnection con) + { + using var cmd = GetCommand("show databases;", con); + using var r = cmd.ExecuteReader(); + while (r.Read()) + yield return (string)r["Database"]; + } +} \ No newline at end of file diff --git a/FAnsi.MySql/MySqlTableHelper.cs b/FAnsi.MySql/MySqlTableHelper.cs new file mode 100644 index 00000000..08e87340 --- /dev/null +++ b/FAnsi.MySql/MySqlTableHelper.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Text.RegularExpressions; +using FAnsi.Connections; +using FAnsi.Discovery; +using FAnsi.Discovery.Constraints; +using FAnsi.Naming; +using MySqlConnector; + +namespace FAnsi.Implementations.MySql; + +public sealed partial class MySqlTableHelper : DiscoveredTableHelper +{ + public static readonly MySqlTableHelper Instance = new(); + + private MySqlTableHelper() {} + + private static readonly Regex IntParentheses = IntParenthesesRe(); + private static readonly Regex SmallintParentheses = SmallintParenthesesRe(); + private static readonly Regex BitParentheses = BitParenthesesRe(); + + public override IEnumerable DiscoverColumns(DiscoveredTable discoveredTable, IManagedConnection connection, + string database) + { + var tableName = discoveredTable.GetRuntimeName(); + + using var cmd = discoveredTable.Database.Server.Helper.GetCommand( + """ + SELECT * FROM information_schema.`COLUMNS` + WHERE table_schema = @db + AND table_name = @tbl + """, connection.Connection); + cmd.Transaction = connection.Transaction; + + var p = new MySqlParameter("@db", MySqlDbType.String) + { + Value = discoveredTable.Database.GetRuntimeName() + }; + cmd.Parameters.Add(p); + + p = new MySqlParameter("@tbl", MySqlDbType.String) + { + Value = discoveredTable.GetRuntimeName() + }; + cmd.Parameters.Add(p); + + using var r = cmd.ExecuteReader(); + if (!r.HasRows) + throw new Exception($"Could not find any columns for table {tableName} in database {database}"); + + while (r.Read()) + { + var toAdd = new DiscoveredColumn(discoveredTable, (string) r["COLUMN_NAME"],YesNoToBool(r["IS_NULLABLE"])); + + if (r["COLUMN_KEY"].Equals("PRI")) + toAdd.IsPrimaryKey = true; + + toAdd.IsAutoIncrement = r["Extra"] as string == "auto_increment"; + toAdd.Collation = r["COLLATION_NAME"] as string; + + //todo the only way to know if something in MySql is unicode is by r["character_set_name"] + + + toAdd.DataType = new DiscoveredDataType(r, TrimIntDisplayValues(r["COLUMN_TYPE"].ToString()), toAdd); + + yield return toAdd; + } + + r.Close(); + } + + private static bool YesNoToBool(object o) + { + if (o is bool b) + return b; + + if (o == null || o == DBNull.Value) + return false; + + return o.ToString() switch + { + "NO" => false, + "YES" => true, + _ => Convert.ToBoolean(o) + }; + } + + + private static string TrimIntDisplayValues(string? type) + { + if (string.IsNullOrWhiteSpace(type)) + return ""; + + //See comments of int(5) means display 5 digits only it doesn't prevent storing larger numbers: https://stackoverflow.com/a/5634147/4824531 + + if (IntParentheses.IsMatch(type)) + return IntParentheses.Replace(type, "int"); + + if (SmallintParentheses.IsMatch(type)) + return SmallintParentheses.Replace(type, "smallint"); + + if (BitParentheses.IsMatch(type)) + return BitParentheses.Replace(type, "bit"); + + return type; + } + + public override IDiscoveredColumnHelper GetColumnHelper() => MySqlColumnHelper.Instance; + + public override void DropColumn(DbConnection connection, DiscoveredColumn columnToDrop) + { + using var cmd = new MySqlCommand( + $"alter table {columnToDrop.Table.GetFullyQualifiedName()} drop column {columnToDrop.GetWrappedName()}", (MySqlConnection)connection); + cmd.ExecuteNonQuery(); + } + + + public override IEnumerable DiscoverTableValuedFunctionParameters(DbConnection connection, + DiscoveredTableValuedFunction discoveredTableValuedFunction, DbTransaction? transaction) => + throw new NotImplementedException(); + + public override IBulkCopy BeginBulkInsert(DiscoveredTable discoveredTable, IManagedConnection connection, + CultureInfo culture) => new MySqlBulkCopy(discoveredTable, connection, culture); + + public override DiscoveredRelationship[] DiscoverRelationships(DiscoveredTable table, DbConnection connection,IManagedTransaction? transaction = null) + { + var toReturn = new Dictionary(); + + const string sql = """ + SELECT DISTINCT + u.CONSTRAINT_NAME, + u.TABLE_SCHEMA, + u.TABLE_NAME, + u.COLUMN_NAME, + u.REFERENCED_TABLE_SCHEMA, + u.REFERENCED_TABLE_NAME, + u.REFERENCED_COLUMN_NAME, + c.DELETE_RULE + FROM + INFORMATION_SCHEMA.KEY_COLUMN_USAGE u + INNER JOIN + INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS c ON c.CONSTRAINT_NAME = u.CONSTRAINT_NAME + WHERE + u.REFERENCED_TABLE_SCHEMA = @db AND + u.REFERENCED_TABLE_NAME = @tbl + """; + + using (var cmd = new MySqlCommand(sql, (MySqlConnection)connection, (MySqlTransaction?)transaction?.Transaction)) + { + var p = new MySqlParameter("@db", MySqlDbType.String) + { + Value = table.Database.GetRuntimeName() + }; + cmd.Parameters.Add(p); + + p = new MySqlParameter("@tbl", MySqlDbType.String) + { + Value = table.GetRuntimeName() + }; + cmd.Parameters.Add(p); + + using var dt = new DataTable(); + var da = table.Database.Server.GetDataAdapter(cmd); + da.Fill(dt); + + foreach (DataRow r in dt.Rows) + { + var fkName = r["CONSTRAINT_NAME"].ToString(); + if (fkName == null) continue; + + //could be a 2+ columns foreign key? + if (!toReturn.TryGetValue(fkName, out var current)) + { + var pkDb = r["REFERENCED_TABLE_SCHEMA"].ToString(); + var pkTableName = r["REFERENCED_TABLE_NAME"].ToString(); + + var fkDb = r["TABLE_SCHEMA"].ToString(); + var fkTableName = r["TABLE_NAME"].ToString(); + + if (pkDb == null || pkTableName == null || fkDb == null || fkTableName == null) continue; + + var pktable = table.Database.Server.ExpectDatabase(pkDb).ExpectTable(pkTableName); + var fktable = table.Database.Server.ExpectDatabase(fkDb).ExpectTable(fkTableName); + + //https://dev.mysql.com/doc/refman/8.0/en/referential-constraints-table.html + var deleteRuleString = r["DELETE_RULE"].ToString(); + + var deleteRule = deleteRuleString switch + { + "CASCADE" => CascadeRule.Delete, + "NO ACTION" => CascadeRule.NoAction, + "RESTRICT" => CascadeRule.NoAction, + "SET NULL" => CascadeRule.SetNull, + "SET DEFAULT" => CascadeRule.SetDefault, + _ => CascadeRule.Unknown + }; + + current = new DiscoveredRelationship(fkName, pktable, fktable, deleteRule); + toReturn.Add(current.Name, current); + } + + var colName = r["COLUMN_NAME"].ToString(); + var refName = r["REFERENCED_COLUMN_NAME"].ToString(); + if (colName != null && refName != null) + current.AddKeys(refName, colName, transaction); + } + } + + return [.. toReturn.Values]; + } + + protected override string GetRenameTableSql(DiscoveredTable discoveredTable, string newName) + { + var syntax = discoveredTable.GetQuerySyntaxHelper(); + + return $"RENAME TABLE {discoveredTable.GetWrappedName()} TO {syntax.EnsureWrapped(newName)};"; + } + + public override string GetTopXSqlForTable(IHasFullyQualifiedNameToo table, int topX) => $"SELECT * FROM {table.GetFullyQualifiedName()} LIMIT {topX}"; + + + public override void DropFunction(DbConnection connection, DiscoveredTableValuedFunction functionToDrop) + { + throw new NotImplementedException(); + } + + [GeneratedRegex(@"^int\(\d+\)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex IntParenthesesRe(); + + [GeneratedRegex(@"^smallint\(\d+\)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex SmallintParenthesesRe(); + + [GeneratedRegex(@"^bit\(\d+\)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex BitParenthesesRe(); +} \ No newline at end of file diff --git a/FAnsi.MySql/MySqlTypeTranslater.cs b/FAnsi.MySql/MySqlTypeTranslater.cs new file mode 100644 index 00000000..7b98a5c9 --- /dev/null +++ b/FAnsi.MySql/MySqlTypeTranslater.cs @@ -0,0 +1,76 @@ +using System; +using System.Text.RegularExpressions; +using FAnsi.Discovery.TypeTranslation; + +namespace FAnsi.Implementations.MySql; + +public sealed partial class MySqlTypeTranslater : TypeTranslater +{ + public static readonly MySqlTypeTranslater Instance = new(); + + //yup that's right!, long is string (MEDIUMTEXT) + //https://dev.mysql.com/doc/refman/8.0/en/other-vendor-data-types.html + private static readonly Regex AlsoBitRegex = AlsoBitRe(); + private static readonly Regex AlsoStringRegex = AlsoStringRe(); + private static readonly Regex AlsoFloatingPoint = AlsoFloatingPointRe(); + + private MySqlTypeTranslater() : base(DateRe(), 4000, 4000) + { + //match bigint and bigint(20) etc + ByteRegex = ByteRe(); + SmallIntRegex = SmallIntRe(); + IntRegex = IntRe(); + LongRegex = LongRe(); + } + + public override int GetLengthIfString(string sqlType) => + sqlType.ToUpperInvariant() switch + { + "TINYTEXT" => 1 << 8, + "TEXT" => 1 << 16, + "MEDIUMTEXT" => 1 << 24, + "LONGTEXT" => int.MaxValue, // Should be 1<<32 but that overflows... + _ => AlsoStringRegex.IsMatch(sqlType) ? int.MaxValue : base.GetLengthIfString(sqlType) + }; + + public override string GetStringDataTypeWithUnlimitedWidth() => "longtext"; + + public override string GetUnicodeStringDataTypeWithUnlimitedWidth() => "longtext"; + + protected override string GetUnicodeStringDataTypeImpl(int maxExpectedStringWidth) => $"varchar({maxExpectedStringWidth})"; + + protected override bool IsBool(string sqlType) => base.IsBool(sqlType) || sqlType.StartsWith("tinyint(1)", StringComparison.InvariantCultureIgnoreCase); + + protected override bool IsInt(string sqlType) => + //not an int + !sqlType.StartsWith("int8", StringComparison.InvariantCultureIgnoreCase) && base.IsInt(sqlType); + + protected override bool IsString(string sqlType) + { + if (sqlType.Contains("binary",StringComparison.InvariantCultureIgnoreCase)) + return false; + + return base.IsString(sqlType) || AlsoStringRegex.IsMatch(sqlType); + } + + protected override bool IsFloatingPoint(string sqlType) => base.IsFloatingPoint(sqlType) || AlsoFloatingPoint.IsMatch(sqlType); + + protected override bool IsBit(string sqlType) => base.IsBit(sqlType) || AlsoBitRegex.IsMatch(sqlType); + + [GeneratedRegex(@"tinyint\(1\)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex AlsoBitRe(); + [GeneratedRegex("(long)|(enum)|(set)|(text)|(mediumtext)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex AlsoStringRe(); + [GeneratedRegex(@"^(tinyint)|(int1)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex ByteRe(); + [GeneratedRegex("^(dec)|(fixed)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex AlsoFloatingPointRe(); + [GeneratedRegex(@"^(smallint)|(int2)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex SmallIntRe(); + [GeneratedRegex(@"^(int)|(mediumint)|(middleint)|(int3)|(int4)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex IntRe(); + [GeneratedRegex(@"^(bigint)|(int8)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex LongRe(); + [GeneratedRegex(@"(date)|(timestamp)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex DateRe(); +} \ No newline at end of file diff --git a/FAnsi.MySql/README.md b/FAnsi.MySql/README.md new file mode 100644 index 00000000..87002ac5 --- /dev/null +++ b/FAnsi.MySql/README.md @@ -0,0 +1,48 @@ +# MySql FAnsi Implementation + +# Feature Completeness + +- Server + - [X] Check Exists + - [ ] Describe + - [X] List Databases + +- Database + - [X] Create + - [X] Drop + - [ ] Backup + - [ ] Detach + - [X] List Tables + - [ ] List Table Valued Functions + - [ ] List Stored Proceedures + +- Table + - [X] Create + - [X] Drop + - [X] Script Table Structure + - [X] MakeDistinct + - [X] Bulk Insert + - [X] Rename + - [X] List Foreign Keys + +- Column + - [X] Alter + +- Data Types + - [X] [Translation](./../../Documentation/TypeTranslation.md) + - [X] Bit + - [X] String + - [X] TimeSpan + - [X] Decimal + - [X] Date + - [X] Auto Increment + - [ ] Unicode + +- Query + - [X] Top X + - [X] JOIN UPDATE + +- Aggregation + - [X] Basic GROUP BY + - [X] Calendar/Axis Table GROUP BY + - [X] Dynamic Pivot GROUP BY \ No newline at end of file diff --git a/FAnsi.MySql/Update/MySqlUpdateHelper.cs b/FAnsi.MySql/Update/MySqlUpdateHelper.cs new file mode 100644 index 00000000..1f4d2184 --- /dev/null +++ b/FAnsi.MySql/Update/MySqlUpdateHelper.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.QuerySyntax.Update; + +namespace FAnsi.Implementations.MySql.Update; + +public sealed class MySqlUpdateHelper : UpdateHelper +{ + public static readonly MySqlUpdateHelper Instance=new(); + private MySqlUpdateHelper() {} + protected override string BuildUpdateImpl(DiscoveredTable table1, DiscoveredTable table2, List lines) => + $""" + UPDATE {table1.GetFullyQualifiedName()} t1 + join {table2.GetFullyQualifiedName()} t2 + on + {string.Join(" AND ", lines.Where(static l => l.LocationToInsert == QueryComponent.JoinInfoJoin).Select(static c => c.Text))} + SET + {string.Join($", {Environment.NewLine}", lines.Where(static l => l.LocationToInsert == QueryComponent.SET).Select(static c => c.Text))} + WHERE + {string.Join(" AND ", lines.Where(static l => l.LocationToInsert == QueryComponent.WHERE).Select(static c => c.Text))} + """; +} \ No newline at end of file diff --git a/FAnsi.Oracle/Aggregation/OracleAggregateHelper.cs b/FAnsi.Oracle/Aggregation/OracleAggregateHelper.cs new file mode 100644 index 00000000..56b362f1 --- /dev/null +++ b/FAnsi.Oracle/Aggregation/OracleAggregateHelper.cs @@ -0,0 +1,145 @@ +using System; +using System.Linq; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.QuerySyntax.Aggregation; + +namespace FAnsi.Implementations.Oracle.Aggregation; + +public sealed class OracleAggregateHelper : AggregateHelper +{ + public static readonly OracleAggregateHelper Instance=new(); + private OracleAggregateHelper() {} + protected override IQuerySyntaxHelper GetQuerySyntaxHelper() => OracleQuerySyntaxHelper.Instance; + + public override string GetDatePartOfColumn(AxisIncrement increment, string columnSql) => + increment switch + { + AxisIncrement.Day => columnSql, + AxisIncrement.Month => $"to_char({columnSql},'YYYY-MM')", + AxisIncrement.Year => $"to_number(to_char({columnSql},'YYYY'))", + AxisIncrement.Quarter => $"to_char({columnSql},'YYYY') || 'Q' || to_char({columnSql},'Q')", + _ => throw new ArgumentOutOfRangeException(nameof(increment)) + }; + + private static string GetDateAxisTableDeclaration(IQueryAxis axis) + { + //https://stackoverflow.com/questions/8374959/how-to-populate-calendar-table-in-oracle + + //expect the date to be either '2010-01-01' or a function that evaluates to a date e.g. CURRENT_TIMESTAMP + + var startDateSql = + //is it a date in some format or other? + DateTime.TryParse(axis.StartDate.Trim('\'', '"'), out var start) + ? $"to_date('{start:yyyyMMdd}','yyyymmdd')" + : $"to_date(to_char({axis.StartDate}, 'YYYYMMDD'), 'yyyymmdd')"; //assume its some Oracle specific syntax that results in a date + + var endDateSql = DateTime.TryParse(axis.EndDate.Trim('\'', '"'), out var end) + ? $"to_date('{end:yyyyMMdd}','yyyymmdd')" + : $"to_date(to_char({axis.EndDate}, 'YYYYMMDD'), 'yyyymmdd')"; //assume its some Oracle specific syntax that results in a date e.g. CURRENT_TIMESTAMP + + return axis.AxisIncrement switch + { + AxisIncrement.Year => $""" + + with calendar as ( + select add_months({startDateSql},12* (rownum - 1)) as dt + from dual + connect by rownum <= 1+ + floor(months_between({endDateSql}, {startDateSql}) /12) + ) + """, + AxisIncrement.Day => $""" + + with calendar as ( + select {startDateSql} + (rownum - 1) as dt + from dual + connect by rownum <= 1+ + floor({endDateSql} - {startDateSql}) + ) + """, + AxisIncrement.Month => $""" + + with calendar as ( + select add_months({startDateSql},rownum - 1) as dt + from dual + connect by rownum <= 1+ + floor(months_between({endDateSql}, {startDateSql})) + ) + """, + AxisIncrement.Quarter => $""" + + with calendar as ( + select add_months({startDateSql},3* (rownum - 1)) as dt + from dual + connect by rownum <= 1+ + floor(months_between({endDateSql}, {startDateSql}) /3) + ) + """, + _ => throw new NotImplementedException() + }; + } + + protected override string BuildAxisAggregate(AggregateCustomLineCollection query) + { + //we are trying to produce something like this: + /* +with calendar as ( + select add_months(to_date('20010101','yyyymmdd'),12* (rownum - 1)) as dt + from dual + connect by rownum <= 1+ +floor(months_between(to_date(to_char(CURRENT_TIMESTAMP, 'YYYYMMDD'), 'yyyymmdd'), to_date('20010101','yyyymmdd')) /12) +) +select +to_char(dt ,'YYYY') dt, +count(*) NumRecords +from calendar +join +"TEST"."HOSPITALADMISSIONS" on +to_char(dt ,'YYYY') = to_char("TEST"."HOSPITALADMISSIONS"."ADMISSION_DATE" ,'YYYY') +group by +dt +order by dt*/ + + var countAlias = query.CountSelect.GetAliasFromText(query.SyntaxHelper); + var axisColumnAlias = query.AxisSelect.GetAliasFromText(query.SyntaxHelper) ?? "joinDt"; + + WrapAxisColumnWithDatePartFunction(query, axisColumnAlias); + + var calendar = GetDateAxisTableDeclaration(query.Axis); + + return string.Format( + """ + + {0} + {1} + SELECT + {2} AS "joinDt",dataset.{3} + FROM + calendar + LEFT JOIN + ( + {4} + ) dataset + ON dataset.{5} = {2} + ORDER BY + {2} + + """, + //add everything pre SELECT + string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert < QueryComponent.SELECT)), + //then add the calendar + calendar, + GetDatePartOfColumn(query.Axis.AxisIncrement, "dt"), + countAlias, + //the entire query + string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert is >= QueryComponent.SELECT and <= QueryComponent.Having)), + axisColumnAlias + + ); + + } + + protected override string BuildPivotOnlyAggregate(AggregateCustomLineCollection query, CustomLine nonPivotColumn) => throw new NotImplementedException(); + + protected override string BuildPivotAndAxisAggregate(AggregateCustomLineCollection query) => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/FAnsi.Oracle/FAnsi.Oracle.csproj b/FAnsi.Oracle/FAnsi.Oracle.csproj new file mode 100644 index 00000000..871f4332 --- /dev/null +++ b/FAnsi.Oracle/FAnsi.Oracle.csproj @@ -0,0 +1,45 @@ + + + HIC.FAnsi.Oracle + 0.0.7 + HIC.FAnsi.Oracle + Health Informatics Centre - University of Dundee + Health Informatics Centre - University of Dundee + https://github.com/HicServices/FAnsiSql + GPL-3.0-or-later + false + Oracle implementation for FAnsiSql + Ansi,SQL,Oracle + HIC.FAnsi.Oracle + Health Informatics Centre, University of Dundee + HIC.FAnsi.Oracle + Oracle implementation for FAnsiSql + Copyright © 2019-2025 + false + true + true + CS1591 + en-GB + embedded + README.md + + + 1 + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + \ No newline at end of file diff --git a/FAnsi.Oracle/OracleBulkCopy.cs b/FAnsi.Oracle/OracleBulkCopy.cs new file mode 100644 index 00000000..2ba62fa7 --- /dev/null +++ b/FAnsi.Oracle/OracleBulkCopy.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using FAnsi.Connections; +using FAnsi.Discovery; +using Oracle.ManagedDataAccess.Client; + +namespace FAnsi.Implementations.Oracle; + +internal sealed class OracleBulkCopy(DiscoveredTable targetTable, IManagedConnection connection, CultureInfo culture) : BulkCopy(targetTable, connection, culture) +{ + private readonly DiscoveredServer _server = targetTable.Database.Server; + + public override int UploadImpl(DataTable dt) + { + //don't run an insert if there are 0 rows + if (dt.Rows.Count == 0) + return 0; + + var syntaxHelper = _server.GetQuerySyntaxHelper(); + var tt = syntaxHelper.TypeTranslater; + + //if the column name is a reserved keyword e.g. "Comment" we need to give it a new name + var parameterNames = syntaxHelper.GetParameterNamesFor(dt.Columns.Cast().ToArray(), static c => c.ColumnName); + + var affectedRows = 0; + + var mapping = GetMapping(dt.Columns.Cast()); + + var dateColumns = new HashSet(); + + var sql = string.Format("INSERT INTO " + TargetTable.GetFullyQualifiedName() + "({0}) VALUES ({1})", + string.Join(",", mapping.Values.Select(static c => c.GetWrappedName())), + string.Join(",", mapping.Keys.Select(c => parameterNames[c])) + ); + + + using var cmd = (OracleCommand)_server.GetCommand(sql, Connection); + //send all the data at once + cmd.ArrayBindCount = dt.Rows.Count; + + foreach (var (dataColumn, discoveredColumn) in mapping) + { + var p = _server.AddParameterWithValueToCommand(parameterNames[dataColumn], cmd, DBNull.Value); + p.DbType = tt.GetDbTypeForSQLDBType(discoveredColumn.DataType.SQLType); + + switch (p.DbType) + { + case DbType.DateTime: + dateColumns.Add(dataColumn); + break; + case DbType.Boolean: + p.DbType = DbType.Int32; // JS 2023-05-11 special case since we don't have a true boolean type in Oracle, but use 0/1 instead + break; + } + } + + var values = mapping.Keys.ToDictionary(static c => c, static _ => new List()); + + foreach (DataRow dataRow in dt.Rows) + //populate parameters for current row + foreach (var col in mapping.Keys) + { + var val = dataRow[col]; + + if (val is string stringVal && string.IsNullOrWhiteSpace(stringVal)) + val = null; + else if (val == DBNull.Value) + val = null; + else if (dateColumns.Contains(col)) + val = val is string s ? (DateTime?)DateTimeDecider.Parse(s) : Convert.ToDateTime(dataRow[col]); + + if (col.DataType == typeof(bool) && val is bool b) + values[col].Add(b ? 1 : 0); + else + values[col].Add(val); + } + + foreach (var col in mapping.Keys) + { + var param = cmd.Parameters[parameterNames[col]]; + param.Value = values[col].ToArray(); + } + + //send query + affectedRows += cmd.ExecuteNonQuery(); + return affectedRows; + } +} \ No newline at end of file diff --git a/FAnsi.Oracle/OracleColumnHelper.cs b/FAnsi.Oracle/OracleColumnHelper.cs new file mode 100644 index 00000000..d9ae878e --- /dev/null +++ b/FAnsi.Oracle/OracleColumnHelper.cs @@ -0,0 +1,37 @@ +using System.Text; +using FAnsi.Discovery; +using FAnsi.Naming; + +namespace FAnsi.Implementations.Oracle; + +public sealed class OracleColumnHelper : IDiscoveredColumnHelper +{ + public static readonly OracleColumnHelper Instance = new(); + private OracleColumnHelper() {} + public string GetTopXSqlForColumn(IHasRuntimeName database, IHasFullyQualifiedNameToo table, IHasRuntimeName column, int topX, bool discardNulls) + { + var syntax = OracleQuerySyntaxHelper.Instance; + + var sql = new StringBuilder($"SELECT {syntax.EnsureWrapped(column.GetRuntimeName())} FROM {table.GetFullyQualifiedName()}"); + + if (discardNulls) + sql.Append($" WHERE {syntax.EnsureWrapped(column.GetRuntimeName())} IS NOT NULL"); + + sql.Append($" OFFSET 0 ROWS FETCH NEXT {topX} ROWS ONLY"); + return sql.ToString(); + } + + public string GetAlterColumnToSql(DiscoveredColumn column, string newType, bool allowNulls) + { + var syntax = column.Table.Database.Server.GetQuerySyntaxHelper(); + + var sb = new StringBuilder( + $"ALTER TABLE {column.Table.GetFullyQualifiedName()} MODIFY {syntax.EnsureWrapped(column.GetRuntimeName())} {newType} "); + + //If you are already null then Oracle will complain (https://www.techonthenet.com/oracle/errors/ora01451.php) + if (allowNulls != column.AllowNulls) + sb.Append(allowNulls ? "NULL" : "NOT NULL"); + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/FAnsi.Oracle/OracleDatabaseHelper.cs b/FAnsi.Oracle/OracleDatabaseHelper.cs new file mode 100644 index 00000000..9c9c1058 --- /dev/null +++ b/FAnsi.Oracle/OracleDatabaseHelper.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.IO; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using Oracle.ManagedDataAccess.Client; +using TypeGuesser; + +namespace FAnsi.Implementations.Oracle; + +public sealed class OracleDatabaseHelper : DiscoveredDatabaseHelper +{ + public static readonly OracleDatabaseHelper Instance=new(); + private OracleDatabaseHelper(){} + public override IDiscoveredTableHelper GetTableHelper() => OracleTableHelper.Instance; + + public override void DropDatabase(DiscoveredDatabase database) + { + using var con = (OracleConnection)database.Server.GetConnection(); + con.Open(); + using var cmd = new OracleCommand($"DROP USER \"{database.GetRuntimeName()}\" CASCADE ",con); + cmd.ExecuteNonQuery(); + } + + public override Dictionary DescribeDatabase(DbConnectionStringBuilder builder, string database) => throw new NotImplementedException(); + + protected override string GetCreateTableSqlLineForColumn(DatabaseColumnRequest col, string datatype, IQuerySyntaxHelper syntaxHelper) + { + if (col.IsAutoIncrement) + return $"{col.ColumnName} INTEGER {syntaxHelper.GetAutoIncrementKeywordIfAny()}"; + if (datatype.Equals("bigint", StringComparison.OrdinalIgnoreCase)) + return $"{col.ColumnName} NUMBER(19,0)"; + + return base.GetCreateTableSqlLineForColumn(col, datatype, syntaxHelper); + } + + public override DirectoryInfo Detach(DiscoveredDatabase database) => throw new NotImplementedException(); + + public override void CreateBackup(DiscoveredDatabase discoveredDatabase, string backupName) + { + throw new NotImplementedException(); + } + + public override IEnumerable ListTables(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, DbConnection connection, string database, bool includeViews, DbTransaction? transaction = null) + { + //find all the tables + using (var cmd = new OracleCommand($"SELECT table_name FROM all_tables where owner='{database}'", (OracleConnection)connection)) + { + cmd.Transaction = transaction as OracleTransaction; + + var r = cmd.ExecuteReader(); + + while (r.Read()) + //skip invalid table names + if (querySyntaxHelper.IsValidTableName((string)r["table_name"], out _)) + yield return new DiscoveredTable(parent, (string)r["table_name"], querySyntaxHelper); + } + + //find all the views + if (!includeViews) yield break; + + using (var cmd = new OracleCommand($"SELECT view_name FROM all_views where owner='{database}'", + (OracleConnection)connection)) + { + cmd.Transaction = transaction as OracleTransaction; + var r = cmd.ExecuteReader(); + + while (r.Read()) + { + var name = (string)r["view_name"]; + if (querySyntaxHelper.IsValidTableName(name, out _)) + yield return new DiscoveredTable(parent, name, querySyntaxHelper, null, + TableType.View); + } + } + } + + public override IEnumerable ListTableValuedFunctions(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, + DbConnection connection, string database, DbTransaction? transaction = null) => + Array.Empty(); + + public override DiscoveredStoredprocedure[] ListStoredprocedures(DbConnectionStringBuilder builder, string database) => []; + + protected override Guesser GetGuesser(DatabaseTypeRequest request) => + new(request) + {ExtraLengthPerNonAsciiCharacter = OracleTypeTranslater.ExtraLengthPerNonAsciiCharacter}; + + public override void CreateSchema(DiscoveredDatabase discoveredDatabase, string name) + { + //Oracle doesn't really have schemas especially since a User is a Database + } + + protected override Guesser GetGuesser(DataColumn column) => new() {ExtraLengthPerNonAsciiCharacter = OracleTypeTranslater.ExtraLengthPerNonAsciiCharacter}; +} \ No newline at end of file diff --git a/FAnsi.Oracle/OracleImplementation.cs b/FAnsi.Oracle/OracleImplementation.cs new file mode 100644 index 00000000..cce6b8a6 --- /dev/null +++ b/FAnsi.Oracle/OracleImplementation.cs @@ -0,0 +1,16 @@ +using System.Data.Common; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Implementation; +using Oracle.ManagedDataAccess.Client; + +namespace FAnsi.Implementations.Oracle; + +public sealed class OracleImplementation() : Implementation(DatabaseType.Oracle) +{ + public override IDiscoveredServerHelper GetServerHelper() => OracleServerHelper.Instance; + + public override bool IsFor(DbConnection connection) => connection is OracleConnection; + + public override IQuerySyntaxHelper GetQuerySyntaxHelper() => OracleQuerySyntaxHelper.Instance; +} \ No newline at end of file diff --git a/FAnsi.Oracle/OracleQuerySyntaxHelper.cs b/FAnsi.Oracle/OracleQuerySyntaxHelper.cs new file mode 100644 index 00000000..62aaa3d0 --- /dev/null +++ b/FAnsi.Oracle/OracleQuerySyntaxHelper.cs @@ -0,0 +1,570 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Implementations.Oracle.Aggregation; +using FAnsi.Implementations.Oracle.Update; + +namespace FAnsi.Implementations.Oracle; + +public sealed class OracleQuerySyntaxHelper : QuerySyntaxHelper +{ + public static readonly OracleQuerySyntaxHelper Instance = new(); + public override int MaximumDatabaseLength => 30; // JS 2023-05-11 Can be longer, but Oracle RAC limits to 30 + public override int MaximumTableLength => 30; + public override int MaximumColumnLength => 30; + + + public override string OpenQualifier => "\""; + + public override string CloseQualifier => "\""; + + private OracleQuerySyntaxHelper() : base(OracleTypeTranslater.Instance, OracleAggregateHelper.Instance, OracleUpdateHelper.Instance, DatabaseType.Oracle)//no custom translater + { + } + + public override char ParameterSymbol => ':'; + + [return: NotNullIfNotNull(nameof(s))] + public override string? GetRuntimeName(string? s) + { + var answer = base.GetRuntimeName(s); + + return string.IsNullOrWhiteSpace(answer) ? s : + //upper it because oracle loves uppercase stuff + answer.Trim('"').ToUpper(); + } + + public override bool SupportsEmbeddedParameters() => false; + + public override string EnsureWrappedImpl(string databaseOrTableName) => $"\"{GetRuntimeName(databaseOrTableName)}\""; + + public override string EnsureFullyQualified(string? databaseName, string? schema, string tableName) + { + //if there is no schema address it as db..table (which is the same as db.dbo.table in Microsoft SQL Server) + if (!string.IsNullOrWhiteSpace(schema)) + throw new NotSupportedException("Schema (e.g. .dbo. not supported by Oracle)"); + + return $"\"{GetRuntimeName(databaseName)}\"{DatabaseTableSeparator}\"{GetRuntimeName(tableName)}\""; + } + + public override string EnsureFullyQualified(string? databaseName, string? schema, string tableName, string columnName, bool isTableValuedFunction = false) => $"{EnsureFullyQualified(databaseName, schema, tableName)}.\"{GetRuntimeName(columnName)}\""; + + public override TopXResponse HowDoWeAchieveTopX(int x) => new($"OFFSET 0 ROWS FETCH NEXT {x} ROWS ONLY", QueryComponent.Postfix); + + public override string GetParameterDeclaration(string proposedNewParameterName, string sqlType) => throw new NotSupportedException(); + + public override HashSet GetReservedWords() => ReservedWords; + + public override string GetScalarFunctionSql(MandatoryScalarFunctions function) => + function switch + { + MandatoryScalarFunctions.GetTodaysDate => "CURRENT_TIMESTAMP", + MandatoryScalarFunctions.GetGuid => "SYS_GUID()", + MandatoryScalarFunctions.Len => "LENGTH", + _ => throw new ArgumentOutOfRangeException(nameof(function)) + }; + + /// + /// Works in Oracle 12c+ only https://oracle-base.com/articles/12c/identity-columns-in-oracle-12cr1 + /// + /// + public override string GetAutoIncrementKeywordIfAny() => + //this is handled in + " GENERATED ALWAYS AS IDENTITY"; + + public override Dictionary GetSQLFunctionsDictionary() => []; + + public override string HowDoWeAchieveMd5(string selectSql) => $"RAWTOHEX(standard_hash({selectSql}, 'MD5'))"; + + protected override object FormatTimespanForDbParameter(TimeSpan timeSpan) => + //Value must be a DateTime even if DBParameter is of Type DbType.Time + Convert.ToDateTime(timeSpan.ToString()); + + private static readonly HashSet ReservedWords = new(new[] + { + + "ACCESS", + "ACCOUNT", + "ACTIVATE", + "ADD", + "ADMIN", + "ADVISE", + "AFTER", + "ALL", + "ALL_ROWS", + "ALLOCATE", + "ALTER", + "ANALYZE", + "AND", + "ANY", + "ARCHIVE", + "ARCHIVELOG", + "ARRAY", + "AS", + "ASC", + "AT", + "AUDIT", + "AUTHENTICATED", + "AUTHORIZATION", + "AUTOEXTEND", + "AUTOMATIC", + "BACKUP", + "BECOME", + "BEFORE", + "BEGIN", + "BETWEEN", + "BFILE", + "BITMAP", + "BLOB", + "BLOCK", + "BODY", + "BY", + "CACHE", + "CACHE_INSTANCES", + "CANCEL", + "CASCADE", + "CAST", + "CFILE", + "CHAINED", + "CHANGE", + "CHAR", + "CHAR_CS", + "CHARACTER", + "CHECK", + "CHECKPOINT", + "CHOOSE", + "CHUNK", + "CLEAR", + "CLOB", + "CLONE", + "CLOSE", + "CLOSE_CACHED_OPEN_CURSORS", + "CLUSTER", + "COALESCE", + "COLUMN", + "COLUMNS", + "COMMENT", + "COMMIT", + "COMMITTED", + "COMPATIBILITY", + "COMPILE", + "COMPLETE", + "COMPOSITE_LIMIT", + "COMPRESS", + "COMPUTE", + "CONNECT", + "CONNECT_TIME", + "CONSTRAINT", + "CONSTRAINTS", + "CONTENTS", + "CONTINUE", + "CONTROLFILE", + "CONVERT", + "COST", + "CPU_PER_CALL", + "CPU_PER_SESSION", + "CREATE", + "CURRENT", + "CURRENT_SCHEMA", + "CURREN_USER", + "CURSOR", + "CYCLE", + "DANGLING", + "DATABASE", + "DATAFILE", + "DATAFILES", + "DATAOBJNO", + "DATE", + "DBA", + "DBHIGH", + "DBLOW", + "DBMAC", + "DEALLOCATE", + "DEBUG", + "DEC", + "DECIMAL", + "DECLARE", + "DEFAULT", + "DEFERRABLE", + "DEFERRED", + "DEGREE", + "DELETE", + "DEREF", + "DESC", + "DIRECTORY", + "DISABLE", + "DISCONNECT", + "DISMOUNT", + "DISTINCT", + "DISTRIBUTED", + "DML", + "DOUBLE", + "DROP", + "DUMP", + "EACH", + "ELSE", + "ENABLE", + "END", + "ENFORCE", + "ENTRY", + "ESCAPE", + "EXCEPT", + "EXCEPTIONS", + "EXCHANGE", + "EXCLUDING", + "EXCLUSIVE", + "EXECUTE", + "EXISTS", + "EXPIRE", + "EXPLAIN", + "EXTENT", + "EXTENTS", + "EXTERNALLY", + "FAILED_LOGIN_ATTEMPTS", + "FALSE", + "FAST", + "FILE", + "FIRST_ROWS", + "FLAGGER", + "FLOAT", + "FLOB", + "FLUSH", + "FOR", + "FORCE", + "FOREIGN", + "FREELIST", + "FREELISTS", + "FROM", + "FULL", + "FUNCTION", + "GLOBAL", + "GLOBALLY", + "GLOBAL_NAME", + "GRANT", + "GROUP", + "GROUPS", + "HASH", + "HASHKEYS", + "HAVING", + "HEADER", + "HEAP", + "IDENTIFIED", + "IDGENERATORS", + "IDLE_TIME", + "IF", + "IMMEDIATE", + "IN", + "INCLUDING", + "INCREMENT", + "INDEX", + "INDEXED", + "INDEXES", + "INDICATOR", + "IND_PARTITION", + "INITIAL", + "INITIALLY", + "INITRANS", + "INSERT", + "INSTANCE", + "INSTANCES", + "INSTEAD", + "INT", + "INTEGER", + "INTERMEDIATE", + "INTERSECT", + "INTO", + "IS", + "ISOLATION", + "ISOLATION_LEVEL", + "KEEP", + "KEY", + "KILL", + "LABEL", + "LAYER", + "LESS", + "LEVEL", + "LIBRARY", + "LIKE", + "LIMIT", + "LINK", + "LIST", + "LOB", + "LOCAL", + "LOCK", + "LOCKED", + "LOG", + "LOGFILE", + "LOGGING", + "LOGICAL_READS_PER_CALL", + "LOGICAL_READS_PER_SESSION", + "LONG", + "MANAGE", + "MASTER", + "MAX", + "MAXARCHLOGS", + "MAXDATAFILES", + "MAXEXTENTS", + "MAXINSTANCES", + "MAXLOGFILES", + "MAXLOGHISTORY", + "MAXLOGMEMBERS", + "MAXSIZE", + "MAXTRANS", + "MAXVALUE", + "MIN", + "MEMBER", + "MINIMUM", + "MINEXTENTS", + "MINUS", + "MINVALUE", + "MLSLABEL", + "MLS_LABEL_FORMAT", + "MODE", + "MODIFY", + "MOUNT", + "MOVE", + "MTS_DISPATCHERS", + "MULTISET", + "NATIONAL", + "NCHAR", + "NCHAR_CS", + "NCLOB", + "NEEDED", + "NESTED", + "NETWORK", + "NEW", + "NEXT", + "NOARCHIVELOG", + "NOAUDIT", + "NOCACHE", + "NOCOMPRESS", + "NOCYCLE", + "NOFORCE", + "NOLOGGING", + "NOMAXVALUE", + "NOMINVALUE", + "NONE", + "NOORDER", + "NOOVERRIDE", + "NOPARALLEL", + "NOPARALLEL", + "NOREVERSE", + "NORMAL", + "NOSORT", + "NOT", + "NOTHING", + "NOWAIT", + "NULL", + "NUMBER", + "NUMERIC", + "NVARCHAR2", + "OBJECT", + "OBJNO", + "OBJNO_REUSE", + "OF", + "OFF", + "OFFLINE", + "OID", + "OIDINDEX", + "OLD", + "ON", + "ONLINE", + "ONLY", + "OPCODE", + "OPEN", + "OPTIMAL", + "OPTIMIZER_GOAL", + "OPTION", + "OR", + "ORDER", + "ORGANIZATION", + "OSLABEL", + "OVERFLOW", + "OWN", + "PACKAGE", + "PARALLEL", + "PARTITION", + "PASSWORD", + "PASSWORD_GRACE_TIME", + "PASSWORD_LIFE_TIME", + "PASSWORD_LOCK_TIME", + "PASSWORD_REUSE_MAX", + "PASSWORD_REUSE_TIME", + "PASSWORD_VERIFY_FUNCTION", + "PCTFREE", + "PCTINCREASE", + "PCTTHRESHOLD", + "PCTUSED", + "PCTVERSION", + "PERCENT", + "PERMANENT", + "PLAN", + "PLSQL_DEBUG", + "POST_TRANSACTION", + "PRECISION", + "PRESERVE", + "PRIMARY", + "PRIOR", + "PRIVATE", + "PRIVATE_SGA", + "PRIVILEGE", + "PRIVILEGES", + "PROCEDURE", + "PROFILE", + "PUBLIC", + "PURGE", + "QUEUE", + "QUOTA", + "RANGE", + "RAW", + "RBA", + "READ", + "READUP", + "REAL", + "REBUILD", + "RECOVER", + "RECOVERABLE", + "RECOVERY", + "REF", + "REFERENCES", + "REFERENCING", + "REFRESH", + "RENAME", + "REPLACE", + "RESET", + "RESETLOGS", + "RESIZE", + "RESOURCE", + "RESTRICTED", + "RETURN", + "RETURNING", + "REUSE", + "REVERSE", + "REVOKE", + "ROLE", + "ROLES", + "ROLLBACK", + "ROW", + "ROWID", + "ROWNUM", + "ROWS", + "RULE", + "SAMPLE", + "SAVEPOINT", + "SB4", + "SCAN_INSTANCES", + "SCHEMA", + "SCN", + "SCOPE", + "SD_ALL", + "SD_INHIBIT", + "SD_SHOW", + "SEGMENT", + "SEG_BLOCK", + "SEG_FILE", + "SELECT", + "SEQUENCE", + "SERIALIZABLE", + "SESSION", + "SESSION_CACHED_CURSORS", + "SESSIONS_PER_USER", + "SET", + "SHARE", + "SHARED", + "SHARED_POOL", + "SHRINK", + "SIZE", + "SKIP", + "SKIP_UNUSABLE_INDEXES", + "SMALLINT", + "SNAPSHOT", + "SOME", + "SORT", + "SPECIFICATION", + "SPLIT", + "SQL_TRACE", + "STANDBY", + "START", + "STATEMENT_ID", + "STATISTICS", + "STOP", + "STORAGE", + "STORE", + "STRUCTURE", + "SUCCESSFUL", + "SWITCH", + "SYS_OP_ENFORCE_NOT_NULL$", + "SYS_OP_NTCIMG$", + "SYNONYM", + "SYSDATE", + "SYSDBA", + "SYSOPER", + "SYSTEM", + "TABLE", + "TABLES", + "TABLESPACE", + "TABLESPACE_NO", + "TABNO", + "TEMPORARY", + "THAN", + "THE", + "THEN", + "THREAD", + "TIMESTAMP", + "TIME", + "TO", + "TOPLEVEL", + "TRACE", + "TRACING", + "TRANSACTION", + "TRANSITIONAL", + "TRIGGER", + "TRIGGERS", + "TRUE", + "TRUNCATE", + "TX", + "TYPE", + "UB2", + "UBA", + "UID", + "UNARCHIVED", + "UNDO", + "UNION", + "UNIQUE", + "UNLIMITED", + "UNLOCK", + "UNRECOVERABLE", + "UNTIL", + "UNUSABLE", + "UNUSED", + "UPDATABLE", + "UPDATE", + "USAGE", + "USE", + "USER", + "USING", + "VALIDATE", + "VALIDATION", + "VALUE", + "VALUES", + "VARCHAR", + "VARCHAR2", + "VARYING", + "VIEW", + "WHEN", + "WHENEVER", + "WHERE", + "WITH", + "WITHOUT", + "WORK", + "WRITE", + "WRITEDOWN", + "WRITEUP", + "XID", + "YEAR", + "ZONE" + }, StringComparer.CurrentCultureIgnoreCase); + +} \ No newline at end of file diff --git a/FAnsi.Oracle/OracleServerHelper.cs b/FAnsi.Oracle/OracleServerHelper.cs new file mode 100644 index 00000000..cf00ce56 --- /dev/null +++ b/FAnsi.Oracle/OracleServerHelper.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Naming; +using Oracle.ManagedDataAccess.Client; + +namespace FAnsi.Implementations.Oracle; + +public sealed class OracleServerHelper : DiscoveredServerHelper +{ + public static readonly OracleServerHelper Instance=new(); + private OracleServerHelper() : base(DatabaseType.Oracle) + { + } + + protected override string ServerKeyName => "DATA SOURCE"; + protected override string DatabaseKeyName => "USER ID"; //ok is this really what oracle does? + + + protected override string ConnectionTimeoutKeyName => "Connection Timeout"; + + #region Up Typing + public override DbCommand GetCommand(string s, DbConnection con, DbTransaction? transaction = null) => new OracleCommand(s, con as OracleConnection) {Transaction = transaction as OracleTransaction}; + + public override DbDataAdapter GetDataAdapter(DbCommand cmd) => new OracleDataAdapter((OracleCommand) cmd); + + public override DbCommandBuilder GetCommandBuilder(DbCommand cmd) => new OracleCommandBuilder((OracleDataAdapter) GetDataAdapter(cmd)); + + public override DbParameter GetParameter(string parameterName) => new OracleParameter(parameterName,null); + + public override DbConnection GetConnection(DbConnectionStringBuilder builder) => new OracleConnection(builder.ConnectionString); + + protected override DbConnectionStringBuilder GetConnectionStringBuilderImpl(string? connectionString) => + new OracleConnectionStringBuilder(connectionString); + + #endregion + + protected override DbConnectionStringBuilder GetConnectionStringBuilderImpl(string server, string? database, string username, string password) + { + var toReturn = new OracleConnectionStringBuilder { DataSource = server }; + + if (string.IsNullOrWhiteSpace(username)) + toReturn.UserID = "/"; + else + { + toReturn.UserID = username; + toReturn.Password = password; + } + + return toReturn; + } + + public override DbConnectionStringBuilder ChangeDatabase(DbConnectionStringBuilder builder, string newDatabase) => + //does not apply to oracle since user = database but we create users with random passwords + builder; + + public override DbConnectionStringBuilder EnableAsync(DbConnectionStringBuilder builder) => builder; + + public override IDiscoveredDatabaseHelper GetDatabaseHelper() => OracleDatabaseHelper.Instance; + + public override IQuerySyntaxHelper GetQuerySyntaxHelper() => OracleQuerySyntaxHelper.Instance; + + public override string? GetCurrentDatabase(DbConnectionStringBuilder builder) => + //Oracle does not persist database as a connection string (only server). + null; + + public override void CreateDatabase(DbConnectionStringBuilder builder, IHasRuntimeName newDatabaseName) + { + using var con = new OracleConnection(builder.ConnectionString); + con.UseHourOffsetForUnsupportedTimezone = true; + con.Open(); + //create a new user with a random password!!! - go oracle this makes perfect sense database=user! + using(var cmd = new OracleCommand( + $"CREATE USER \"{newDatabaseName.GetRuntimeName()}\" IDENTIFIED BY pwd{Guid.NewGuid().ToString().Replace("-", "")[..27]}" //oracle only allows 30 character passwords + , con)) + { + cmd.CommandTimeout = CreateDatabaseTimeoutInSeconds; + cmd.ExecuteNonQuery(); + } + + + using(var cmd = new OracleCommand( + $"ALTER USER \"{newDatabaseName.GetRuntimeName()}\" quota unlimited on system", con)) + { + cmd.CommandTimeout = CreateDatabaseTimeoutInSeconds; + cmd.ExecuteNonQuery(); + } + + + using(var cmd = new OracleCommand( + $"ALTER USER \"{newDatabaseName.GetRuntimeName()}\" quota unlimited on users", con)) + { + cmd.CommandTimeout = CreateDatabaseTimeoutInSeconds; + cmd.ExecuteNonQuery(); + } + } + + public override Dictionary DescribeServer(DbConnectionStringBuilder builder) => throw new NotImplementedException(); + + public override string GetExplicitUsernameIfAny(DbConnectionStringBuilder builder) => ((OracleConnectionStringBuilder) builder).UserID; + + public override string GetExplicitPasswordIfAny(DbConnectionStringBuilder builder) => ((OracleConnectionStringBuilder)builder).Password; + + public override Version? GetVersion(DiscoveredServer server) + { + using var tcon = server.GetConnection(); + if (tcon is not OracleConnection con) throw new ArgumentException("Oracle helper called on non-Oracle server",nameof(server)); + + con.UseHourOffsetForUnsupportedTimezone = true; + con.Open(); + using var cmd = server.GetCommand("SELECT * FROM v$version WHERE BANNER like 'Oracle Database%'",con); + using var r = cmd.ExecuteReader(); + return !r.Read() || r[0] == DBNull.Value ? null: CreateVersionFromString((string)r[0]); + } + + public override IEnumerable ListDatabases(DbConnectionStringBuilder builder) + { + //todo do we have to edit the builder in here in case it is pointed at nothing? + using var con = new OracleConnection(builder.ConnectionString); + con.UseHourOffsetForUnsupportedTimezone = true; + con.Open(); + foreach (var listDatabase in ListDatabases(con)) yield return listDatabase; + } + + public override IEnumerable ListDatabases(DbConnection con) + { + using var cmd = GetCommand("select * from all_users", con); + using var r = cmd.ExecuteReader(); + while (r.Read()) + yield return (string)r["username"]; + } +} \ No newline at end of file diff --git a/FAnsi.Oracle/OracleTableHelper.cs b/FAnsi.Oracle/OracleTableHelper.cs new file mode 100644 index 00000000..4d79afe4 --- /dev/null +++ b/FAnsi.Oracle/OracleTableHelper.cs @@ -0,0 +1,336 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using FAnsi.Connections; +using FAnsi.Discovery; +using FAnsi.Discovery.Constraints; +using FAnsi.Exceptions; +using FAnsi.Naming; +using Oracle.ManagedDataAccess.Client; + +namespace FAnsi.Implementations.Oracle; + +public sealed class OracleTableHelper : DiscoveredTableHelper +{ + public static readonly OracleTableHelper Instance=new(); + private OracleTableHelper() {} + + public override string GetTopXSqlForTable(IHasFullyQualifiedNameToo table, int topX) => $"SELECT * FROM {table.GetFullyQualifiedName()} OFFSET 0 ROWS FETCH NEXT {topX} ROWS ONLY"; + + public override DiscoveredColumn[] DiscoverColumns(DiscoveredTable discoveredTable, IManagedConnection connection, string database) + { + var server = discoveredTable.Database.Server; + + var columns = new List(); + var tableName = discoveredTable.GetRuntimeName(); + + using (var cmd = server.Helper.GetCommand(""" + SELECT * + FROM all_tab_cols + WHERE table_name = :table_name AND owner =:owner AND HIDDEN_COLUMN <> 'YES' + + """, connection.Connection)) + { + cmd.Transaction = connection.Transaction; + + cmd.Parameters.Add(new OracleParameter("table_name", OracleDbType.Varchar2) + { + Value = tableName + }); + cmd.Parameters.Add(new OracleParameter("owner", OracleDbType.Varchar2) + { + Value = database + }); + + using var r = cmd.ExecuteReader(); + if (!r.HasRows) + throw new Exception($"Could not find any columns for table {tableName} in database {database}"); + + while (r.Read()) + { + var toAdd = new DiscoveredColumn(discoveredTable, (string)r["COLUMN_NAME"], r["NULLABLE"].ToString() != "N") { Format = r["CHARACTER_SET_NAME"] as string }; + toAdd.DataType = new DiscoveredDataType(r, GetSQLType_From_all_tab_cols_Result(r), toAdd); + columns.Add(toAdd); + } + } + + + //get auto increment information + using (var cmd = + new OracleCommand( + "select table_name,column_name from ALL_TAB_IDENTITY_COLS WHERE table_name = :table_name AND owner =:owner", + (OracleConnection) connection.Connection)) + { + cmd.Transaction = (OracleTransaction?)connection.Transaction; + cmd.Parameters.Add(new OracleParameter("table_name", OracleDbType.Varchar2) + { + Value = tableName + }); + cmd.Parameters.Add(new OracleParameter("owner", OracleDbType.Varchar2) + { + Value = database + }); + + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var colName = r["column_name"].ToString(); + var match = columns.Single(c => c.GetRuntimeName().Equals(colName, StringComparison.CurrentCultureIgnoreCase)); + match.IsAutoIncrement = true; + } + } + + + //get primary key information + using(var cmd = new OracleCommand(""" + SELECT cols.table_name, cols.column_name, cols.position, cons.status, cons.owner + FROM all_constraints cons, all_cons_columns cols + WHERE cols.table_name = :table_name AND cols.owner = :owner + AND cons.constraint_type = 'P' + AND cons.constraint_name = cols.constraint_name + AND cons.owner = cols.owner + ORDER BY cols.table_name, cols.position + """, (OracleConnection) connection.Connection)) + { + cmd.Transaction = (OracleTransaction?)connection.Transaction; + cmd.Parameters.Add(new OracleParameter("table_name", OracleDbType.Varchar2) + { + Value = tableName + }); + cmd.Parameters.Add(new OracleParameter("owner", OracleDbType.Varchar2) + { + Value = database + }); + + using var r = cmd.ExecuteReader(); + while (r.Read()) + columns.Single(c => c.GetRuntimeName().Equals(r["COLUMN_NAME"])).IsPrimaryKey = true;//mark all primary keys as primary + } + + + return [.. columns]; + } + + public override void DropIndex(DatabaseOperationArgs args, DiscoveredTable table, string indexName) + { + using var connection = args.GetManagedConnection(table); + try + { + + var sql = + $"DROP INDEX {indexName}"; + + using var cmd = table.Database.Server.Helper.GetCommand(sql, connection.Connection, connection.Transaction); + args.ExecuteNonQuery(cmd); + } + catch (Exception e) + { + throw new AlterFailedException(string.Format(FAnsiStrings.DiscoveredTableHelper_DropIndex_Failed, table), e); + } + } + + public override IDiscoveredColumnHelper GetColumnHelper() => OracleColumnHelper.Instance; + + public override void DropColumn(DbConnection connection, DiscoveredColumn columnToDrop) + { + using var cmd = new OracleCommand( + $"ALTER TABLE {columnToDrop.Table.GetFullyQualifiedName()} DROP COLUMN {columnToDrop.GetWrappedName()}", (OracleConnection)connection); + cmd.ExecuteNonQuery(); + } + + private static string GetBasicTypeFromOracleType(IDataRecord r) + { + int? precision = null; + int? scale = null; + int? dataLength = null; //in bytes + + if (r["DATA_SCALE"] != DBNull.Value) + scale = Convert.ToInt32(r["DATA_SCALE"]); + if (r["DATA_PRECISION"] != DBNull.Value) + precision = Convert.ToInt32(r["DATA_PRECISION"]); + if(r["DATA_LENGTH"] != DBNull.Value) + dataLength = Convert.ToInt32(r["DATA_LENGTH"]); + + switch (r["DATA_TYPE"] as string) + { + //All the ways that you can use the number keyword https://docs.oracle.com/cd/B28359_01/server.111/b28318/datatype.htm#CNCPT1832 + case "NUMBER": + if (scale == 0 && precision == null) + return "int"; + if (precision != null && scale != null) + return "decimal"; + + if (dataLength == null) + throw new InvalidOperationException( + $"Found Oracle NUMBER datatype with scale {(scale != null ? scale.ToString() : "DBNull.Value")} and precision {(precision != null ? precision.ToString() : "DBNull.Value")}, did not know what datatype to use to represent it"); + + return "double"; + case "FLOAT": + return "double"; + default: + return r["DATA_TYPE"].ToString()?.ToLower() ?? throw new InvalidOperationException("Null DATA_TYPE in db"); + } + } + + private string GetSQLType_From_all_tab_cols_Result(DbDataReader r) + { + var columnType = GetBasicTypeFromOracleType(r); + + var lengthQualifier = ""; + + if (HasPrecisionAndScale(columnType)) + lengthQualifier = $"({r["DATA_PRECISION"]},{r["DATA_SCALE"]})"; + else + if (RequiresLength(columnType)) + lengthQualifier = $"({r["DATA_LENGTH"]})"; + + return columnType + lengthQualifier; + } + + public override void DropFunction(DbConnection connection, DiscoveredTableValuedFunction functionToDrop) + { + throw new NotImplementedException(); + } + + public override IEnumerable DiscoverTableValuedFunctionParameters(DbConnection connection, + DiscoveredTableValuedFunction discoveredTableValuedFunction, DbTransaction? transaction) => + throw new NotImplementedException(); + + public override IBulkCopy BeginBulkInsert(DiscoveredTable discoveredTable, IManagedConnection connection,CultureInfo culture) => new OracleBulkCopy(discoveredTable,connection,culture); + + public override int ExecuteInsertReturningIdentity(DiscoveredTable discoveredTable, DbCommand cmd, IManagedTransaction? transaction = null) + { + var autoIncrement = discoveredTable.DiscoverColumns(transaction).SingleOrDefault(static c => c.IsAutoIncrement); + + if (autoIncrement == null) + return Convert.ToInt32(cmd.ExecuteScalar()); + + var p = discoveredTable.Database.Server.Helper.GetParameter("identityOut"); + p.Direction = ParameterDirection.Output; + p.DbType = DbType.Int32; + + cmd.Parameters.Add(p); + + cmd.CommandText += $" RETURNING {autoIncrement} INTO :identityOut;"; + + cmd.CommandText = + $"BEGIN {Environment.NewLine}{cmd.CommandText}{Environment.NewLine}COMMIT;{Environment.NewLine}END;"; + + cmd.ExecuteNonQuery(); + + + return Convert.ToInt32(p.Value); + } + + public override DiscoveredRelationship[] DiscoverRelationships(DiscoveredTable table, DbConnection connection, + IManagedTransaction? transaction = null) + { + var toReturn = new Dictionary(); + + const string sql = """ + + SELECT DISTINCT a.table_name + , a.column_name + , a.constraint_name + , c.owner + , c.delete_rule + , c.r_owner + , c_pk.table_name r_table_name + , c_pk.constraint_name r_pk + , cc_pk.column_name r_column_name + FROM all_cons_columns a + JOIN all_constraints c ON (a.owner = c.owner AND a.constraint_name = c.constraint_name ) + JOIN all_constraints c_pk ON (c.r_owner = c_pk.owner AND c.r_constraint_name = c_pk.constraint_name ) + JOIN all_cons_columns cc_pk on (cc_pk.constraint_name = c_pk.constraint_name AND cc_pk.owner = c_pk.owner AND cc_pk.position = a.position) + WHERE c.constraint_type = 'R' + AND UPPER(c.r_owner) = UPPER(:DatabaseName) + AND UPPER(c_pk.table_name) = UPPER(:TableName) + """; + + + using (var cmd = new OracleCommand(sql, (OracleConnection) connection)) + { + cmd.Parameters.Add(new OracleParameter(":DatabaseName", OracleDbType.Varchar2) + { + Value = table.Database.GetRuntimeName() + }); + cmd.Parameters.Add(new OracleParameter(":TableName", OracleDbType.Varchar2) + { + Value = table.GetRuntimeName() + }); + + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var fkName = r["constraint_name"].ToString(); + if (fkName == null) continue; + + //could be a 2+ columns foreign key? + if (!toReturn.TryGetValue(fkName, out var current)) + { + var pkDb = r["r_owner"].ToString(); + var pkTableName = r["r_table_name"].ToString(); + + var fkDb = r["owner"].ToString(); + var fkTableName = r["table_name"].ToString(); + + if (pkDb == null || fkDb == null || pkTableName == null || fkTableName == null) continue; + + var pktable = table.Database.Server.ExpectDatabase(pkDb).ExpectTable(pkTableName); + var fktable = table.Database.Server.ExpectDatabase(fkDb).ExpectTable(fkTableName); + + //https://dev.mysql.com/doc/refman/8.0/en/referential-constraints-table.html + var deleteRuleString = r["delete_rule"].ToString(); + + var deleteRule = deleteRuleString switch + { + "CASCADE" => CascadeRule.Delete, + "NO ACTION" => CascadeRule.NoAction, + "RESTRICT" => CascadeRule.NoAction, + "SET NULL" => CascadeRule.SetNull, + "SET DEFAULT" => CascadeRule.SetDefault, + _ => CascadeRule.Unknown + }; + + current = new DiscoveredRelationship(fkName, pktable, fktable, deleteRule); + toReturn.Add(current.Name, current); + } + + var colName = r["r_column_name"].ToString(); + var foreignName = r["column_name"].ToString(); + if (colName != null && foreignName != null) + current.AddKeys(colName, foreignName, transaction); + } + } + + return [.. toReturn.Values]; + } + + public override void FillDataTableWithTopX(DatabaseOperationArgs args,DiscoveredTable table, int topX, DataTable dt) + { + using var con = args.GetManagedConnection(table); + ((OracleConnection)con.Connection).PurgeStatementCache(); + + var cols = table.DiscoverColumns(); + + //apparently * doesn't fly with Oracle DataAdapter + var sql = + $"SELECT {string.Join(",", cols.Select(static c => c.GetFullyQualifiedName()).ToArray())} FROM {table.GetFullyQualifiedName()} OFFSET 0 ROWS FETCH NEXT {topX} ROWS ONLY"; + + using var cmd = table.Database.Server.GetCommand(sql, con); + using var da = table.Database.Server.GetDataAdapter(cmd); + args.Fill(da,cmd, dt); + } + + + protected override string GetRenameTableSql(DiscoveredTable discoveredTable, string? newName) + { + newName = discoveredTable.GetQuerySyntaxHelper().EnsureWrapped(newName); + return $@"alter table {discoveredTable.GetFullyQualifiedName()} rename to {newName}"; + } + + public override bool RequiresLength(string columnType) => base.RequiresLength(columnType) || columnType.Equals("varchar2", StringComparison.CurrentCultureIgnoreCase); +} \ No newline at end of file diff --git a/FAnsi.Oracle/OracleTypeTranslater.cs b/FAnsi.Oracle/OracleTypeTranslater.cs new file mode 100644 index 00000000..87e9deff --- /dev/null +++ b/FAnsi.Oracle/OracleTypeTranslater.cs @@ -0,0 +1,88 @@ +using System; +using System.Text.RegularExpressions; +using FAnsi.Discovery; +using FAnsi.Discovery.TypeTranslation; +using TypeGuesser; + +namespace FAnsi.Implementations.Oracle; + +public sealed partial class OracleTypeTranslater:TypeTranslater +{ + public static readonly OracleTypeTranslater Instance = new(); + private static readonly Regex AlsoFloatingPointRegex = AlsoFloatingPointRegexImpl(); + private static readonly Regex AlsoByteArrayRegex = AlsoByteArrayRegexImpl(); + + public const int ExtraLengthPerNonAsciiCharacter = 3; + + /// + /// Oracle specific string types, these are all max length as returned by + /// + private static readonly Regex AlsoStringRegex = AlsoStringRegexImpl(); + + + private OracleTypeTranslater() : base(DateRegexImpl(), 4000, 4000) + { + } + + protected override string GetStringDataTypeImpl(int maxExpectedStringWidth) => $"varchar2({maxExpectedStringWidth})"; + + protected override string GetUnicodeStringDataTypeImpl(int maxExpectedStringWidth) => $"nvarchar2({maxExpectedStringWidth})"; + + public override string GetStringDataTypeWithUnlimitedWidth() => "CLOB"; + + public override string GetUnicodeStringDataTypeWithUnlimitedWidth() => "NCLOB"; + + protected override string GetTimeDataType() => "TIMESTAMP"; + + /// + /// Oracle doesn't have a native bit type. You can only approximate it with char(1) or number(1) and optionally an independent named CHECK constraint if necessary to enforce it + /// See https://stackoverflow.com/questions/2426145/oracles-lack-of-a-bit-datatype-for-table-columns + /// + /// + protected override string GetBoolDataType() => "number(1)"; + + protected override string GetSmallIntDataType() => "number(5)"; + protected override string GetIntDataType() => "number(10)"; + protected override string GetBigIntDataType() => "number(19)"; + + /// + /// Oracle doesn't have a bit character type. You can only approximate it with char(1) or number(1) + /// See https://stackoverflow.com/questions/2426145/oracles-lack-of-a-bit-datatype-for-table-columns + /// + /// + /// + protected override bool IsBit(string sqlType) => sqlType.Equals("decimal(1,0)",StringComparison.InvariantCultureIgnoreCase); + + protected override bool IsString(string sqlType) => + !sqlType.Contains("RAW", StringComparison.InvariantCultureIgnoreCase) && + (base.IsString(sqlType) || AlsoStringRegex.IsMatch(sqlType)); + + protected override bool IsFloatingPoint(string sqlType) => base.IsFloatingPoint(sqlType) || AlsoFloatingPointRegex.IsMatch(sqlType); + + public override int GetLengthIfString(string sqlType) => AlsoStringRegex.IsMatch(sqlType) ? int.MaxValue : base.GetLengthIfString(sqlType); + + protected override bool IsSmallInt(string sqlType) => + //yup you ask for one of these, you will get a NUMBER(38) https://docs.oracle.com/cd/A58617_01/server.804/a58241/ch5.htm + sqlType.Equals("decimal(5,0)", StringComparison.InvariantCultureIgnoreCase) || + (!sqlType.StartsWith("SMALLINT", StringComparison.InvariantCultureIgnoreCase) && base.IsSmallInt(sqlType)); + + protected override bool IsInt(string sqlType) => + //yup you ask for one of these, you will get a NUMBER(38) https://docs.oracle.com/cd/A58617_01/server.804/a58241/ch5.htm + sqlType.Equals("decimal(10,0)",StringComparison.InvariantCultureIgnoreCase) || sqlType.StartsWith("SMALLINT", StringComparison.InvariantCultureIgnoreCase) || base.IsInt(sqlType); + + protected override bool IsLong(string sqlType) => sqlType.Equals("decimal(19,0)", StringComparison.InvariantCultureIgnoreCase) || base.IsLong(sqlType); + protected override bool IsByteArray(string sqlType) => base.IsByteArray(sqlType) || AlsoByteArrayRegex.IsMatch(sqlType); + + protected override string GetDateDateTimeDataType() => "DATE"; + + public override Guesser GetGuesserFor(DiscoveredColumn discoveredColumn) => base.GetGuesserFor(discoveredColumn, ExtraLengthPerNonAsciiCharacter); + + [GeneratedRegex("^([N]?CLOB)|(LONG)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex AlsoStringRegexImpl(); + [GeneratedRegex("^(NUMBER)|(DEC)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex AlsoFloatingPointRegexImpl(); + [GeneratedRegex("(BFILE)|(BLOB)|(RAW)|(ROWID)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex AlsoByteArrayRegexImpl(); + [GeneratedRegex("(date)|(timestamp)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex DateRegexImpl(); +} \ No newline at end of file diff --git a/FAnsi.Oracle/README.md b/FAnsi.Oracle/README.md new file mode 100644 index 00000000..b40bc00a --- /dev/null +++ b/FAnsi.Oracle/README.md @@ -0,0 +1,69 @@ +# Oracle FAnsi Implementation + +# Feature Completeness + +- Server + - [X] Check Exists + - [ ] Describe + - [X] List Databases + +- Database + - [X] Create + - [X] Drop + - [ ] Backup + - [ ] Detach + - [X] List Tables + - [ ] List Table Valued Functions + - [ ] List Stored Proceedures + +- Table + - [X] Create + - [X] Drop + - [X] Script Table Structure + - [ ] MakeDistinct + - [X] Bulk Insert + - [X] Rename + - [X] List Foreign Keys + +- Column + - [X] Alter + +- Data Types + - [X] [Translation](./../../Documentation/TypeTranslation.md) + - [ ] Bit + - [X] String + - [ ] TimeSpan + - [X] Decimal + - [X] Date + - [X] Auto Increment (Requires Oracle 12c) + - [X] Unicode + +- Query + - [X] Top X (Requires Oracle 12c) + - [X] JOIN UPDATE + +- Aggregation + - [X] Basic GROUP BY + - [X] Calendar/Axis Table GROUP BY + - [ ] Dynamic Pivot GROUP BY + + +##Issues +Oracle [does not have a bit data type](https://asktom.oracle.com/pls/asktom/f?p=100:11:0::::P11_QUESTION_ID:6263249199595#876972400346931526). If you ask to create a bool column you will get a varchar2(5) + +```csharp +DiscoveredDatabase db = GetTestDatabase(DatabaseType.Oracle); +DiscoveredTable table = db.CreateTable("MyTable", + new[] + { + new DatabaseColumnRequest("MyCol", new DatabaseTypeRequest(typeof(bool))) + }); + +var col = table.DiscoverColumn("MyCol"); +Assert.AreEqual("varchar2(5)", col.DataType.SQLType); +``` + +Oracle does not have a discrete time datatype. both [date and timestamp](https://docs.oracle.com/cd/B28359_01/server.111/b28318/datatype.htm#CNCPT413) store full date/times. It is possible to use `interval` for this purpose but that type is very flexible (which isn't a problem for creating the column but it is a problem for discovering a column and making a descision about whether it is TimeSpan or DateTime). + + + diff --git a/FAnsi.Oracle/Update/OracleUpdateHelper.cs b/FAnsi.Oracle/Update/OracleUpdateHelper.cs new file mode 100644 index 00000000..e2b7b703 --- /dev/null +++ b/FAnsi.Oracle/Update/OracleUpdateHelper.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.QuerySyntax.Update; + +namespace FAnsi.Implementations.Oracle.Update; + +public sealed class OracleUpdateHelper : UpdateHelper +{ + public static readonly OracleUpdateHelper Instance = new(); + private OracleUpdateHelper() {} + protected override string BuildUpdateImpl(DiscoveredTable table1, DiscoveredTable table2, List lines) + { + + // This implementation is based on: + // https://stackoverflow.com/a/32748797/4824531 + + /*MERGE INTO table1 t1 +USING +( +-- For more complicated queries you can use WITH clause here +SELECT * FROM table2 +)t2 +ON(t1.id = t2.id) +WHEN MATCHED THEN UPDATE SET +t1.name = t2.name, +t1.desc = t2.desc;*/ + + + return string.Format( + """ + MERGE INTO {1} t1 + USING + ( + SELECT * FROM {2} + )t2 + on ({3}) + WHEN MATCHED THEN UPDATE SET + {0} + WHERE + {4} + """, + string.Join($", {Environment.NewLine}", lines.Where(static l => l.LocationToInsert == QueryComponent.SET).Select(static c => c.Text)), + table1.GetFullyQualifiedName(), + table2.GetFullyQualifiedName(), + string.Join(" AND ", lines.Where(static l => l.LocationToInsert == QueryComponent.JoinInfoJoin).Select(static c => c.Text)), + string.Join(" AND ", lines.Where(static l => l.LocationToInsert == QueryComponent.WHERE).Select(static c => c.Text))); + } +} \ No newline at end of file diff --git a/FAnsi.PostgreSql/Aggregation/PostgreSqlAggregateHelper.cs b/FAnsi.PostgreSql/Aggregation/PostgreSqlAggregateHelper.cs new file mode 100644 index 00000000..2bf0d2cc --- /dev/null +++ b/FAnsi.PostgreSql/Aggregation/PostgreSqlAggregateHelper.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.QuerySyntax.Aggregation; + +namespace FAnsi.Implementations.PostgreSql.Aggregation; + +public sealed class PostgreSqlAggregateHelper : AggregateHelper +{ + public static readonly PostgreSqlAggregateHelper Instance = new(); + private PostgreSqlAggregateHelper(){} + protected override IQuerySyntaxHelper GetQuerySyntaxHelper() => PostgreSqlSyntaxHelper.Instance; + + protected override string BuildAxisAggregate(AggregateCustomLineCollection query) + { + var interval = query.Axis?.AxisIncrement switch + { + AxisIncrement.Day => "1 day", + AxisIncrement.Month => "1 month", + AxisIncrement.Year => "1 year", + AxisIncrement.Quarter => "3 months", + _ => throw new ArgumentOutOfRangeException(nameof(query),$"Invalid AxisIncrement {query.Axis?.AxisIncrement}") + }; + + var countAlias = query.CountSelect.GetAliasFromText(query.SyntaxHelper); + var axisColumnAlias = query.AxisSelect.GetAliasFromText(query.SyntaxHelper) ?? "joinDt"; + + WrapAxisColumnWithDatePartFunction(query,axisColumnAlias); + + var sql = + string.Format(""" + + {0} + SELECT + {1} AS "joinDt",dataset.{6} + FROM + generate_series({3}, + {4}, + interval '{5}') + LEFT JOIN + ( + {2} + ) dataset + ON dataset.{7} = {1} + ORDER BY + {1} + + """, + //Anything before the SELECT + string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert < QueryComponent.SELECT)), + GetDatePartOfColumn(query.Axis.AxisIncrement, "generate_series.date"), + //the entire query + string.Join(Environment.NewLine, query.Lines.Where(static c => c.LocationToInsert is >= QueryComponent.SELECT and <= QueryComponent.Having)), query.Axis.StartDate, + query.Axis.EndDate, + interval, + countAlias, + axisColumnAlias); + + return sql; + } + + protected override string BuildPivotOnlyAggregate(AggregateCustomLineCollection query, CustomLine nonPivotColumn) => throw new NotImplementedException(); + + protected override string BuildPivotAndAxisAggregate(AggregateCustomLineCollection query) => throw new NotImplementedException(); + + public override string GetDatePartOfColumn(AxisIncrement increment, string columnSql) => + increment switch + { + AxisIncrement.Day => $"{columnSql}::date", + AxisIncrement.Month => $"to_char({columnSql},'YYYY-MM')", + AxisIncrement.Year => $"date_part('year', {columnSql})", + AxisIncrement.Quarter => $"to_char({columnSql},'YYYY\"Q\"Q')", + _ => throw new ArgumentOutOfRangeException(nameof(increment), increment, null) + }; +} \ No newline at end of file diff --git a/FAnsi.PostgreSql/FAnsi.PostgreSql.csproj b/FAnsi.PostgreSql/FAnsi.PostgreSql.csproj new file mode 100644 index 00000000..7374d901 --- /dev/null +++ b/FAnsi.PostgreSql/FAnsi.PostgreSql.csproj @@ -0,0 +1,45 @@ + + + HIC.FAnsi.PostgreSql + 0.0.7 + HIC.FAnsi.PostgreSql + Health Informatics Centre - University of Dundee + Health Informatics Centre - University of Dundee + https://github.com/HicServices/FAnsiSql + GPL-3.0-or-later + false + PostgreSQL implementation for FAnsiSql + Ansi,SQL,PostgreSQL + HIC.FAnsi.PostgreSql + Health Informatics Centre, University of Dundee + HIC.FAnsi.PostgreSql + PostgreSQL implementation for FAnsiSql + Copyright © 2019-2025 + false + true + true + CS1591 + en-GB + embedded + README.md + + + 1 + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + \ No newline at end of file diff --git a/FAnsi.PostgreSql/PostgreSqlBulkCopy.cs b/FAnsi.PostgreSql/PostgreSqlBulkCopy.cs new file mode 100644 index 00000000..d070225d --- /dev/null +++ b/FAnsi.PostgreSql/PostgreSqlBulkCopy.cs @@ -0,0 +1,56 @@ +using System; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Text; +using FAnsi.Connections; +using FAnsi.Discovery; +using Npgsql; + +namespace FAnsi.Implementations.PostgreSql; + +public sealed class PostgreSqlBulkCopy(DiscoveredTable discoveredTable, IManagedConnection connection, CultureInfo culture) : BulkCopy(discoveredTable,connection,culture) +{ + public override int UploadImpl(DataTable dt) + { + var con = (NpgsqlConnection) Connection.Connection; + + var matchedColumns = GetMapping(dt.Columns.Cast()); + + //see https://www.npgsql.org/doc/copy.html + var sb = new StringBuilder(); + + sb.Append("COPY "); + sb.Append(TargetTable.GetFullyQualifiedName()); + sb.Append(" ("); + sb.AppendJoin(",", matchedColumns.Values.Select(static v => v.GetWrappedName())); + sb.Append(')'); + sb.Append(" FROM STDIN (FORMAT BINARY)"); + + var tt = PostgreSqlTypeTranslater.Instance; + + var dataColumns = matchedColumns.Keys.ToArray(); + var types = matchedColumns.Keys.Select(v => tt.GetNpgsqlDbTypeForCSharpType(v.DataType)).ToArray(); + + using (var import = con.BeginBinaryImport(sb.ToString())) + { + foreach (DataRow r in dt.Rows) + { + import.StartRow(); + + for (var index = 0; index < dataColumns.Length; index++) + { + var dc = dataColumns[index]; + if (r[dc] == DBNull.Value) + import.WriteNull(); + else + import.Write(r[dc],types[index]); + } + } + + import.Complete(); + } + + return dt.Rows.Count; + } +} \ No newline at end of file diff --git a/FAnsi.PostgreSql/PostgreSqlColumnHelper.cs b/FAnsi.PostgreSql/PostgreSqlColumnHelper.cs new file mode 100644 index 00000000..6d94c0a0 --- /dev/null +++ b/FAnsi.PostgreSql/PostgreSqlColumnHelper.cs @@ -0,0 +1,39 @@ +using System.Text; +using FAnsi.Discovery; +using FAnsi.Naming; + +namespace FAnsi.Implementations.PostgreSql; + +public sealed class PostgreSqlColumnHelper : IDiscoveredColumnHelper +{ + public static readonly PostgreSqlColumnHelper Instance = new(); + private PostgreSqlColumnHelper(){} + public string GetTopXSqlForColumn(IHasRuntimeName database, IHasFullyQualifiedNameToo table, + IHasRuntimeName column, int topX, + bool discardNulls) + { + var syntax = PostgreSqlSyntaxHelper.Instance; + + var sql = new StringBuilder($"SELECT {syntax.EnsureWrapped(column.GetRuntimeName())} FROM {table.GetFullyQualifiedName()}"); + + if (discardNulls) + sql.Append($" WHERE {syntax.EnsureWrapped(column.GetRuntimeName())} IS NOT NULL"); + + sql.Append($" fetch first {topX} rows only"); + return sql.ToString(); + } + + public string GetAlterColumnToSql(DiscoveredColumn column, string newType, bool allowNulls) + { + var syntax = column.Table.Database.Server.GetQuerySyntaxHelper(); + + var sb = new StringBuilder($@"ALTER TABLE {column.Table.GetFullyQualifiedName()} ALTER COLUMN {syntax.EnsureWrapped(column.GetRuntimeName())} TYPE {newType};"); + + var newNullability = allowNulls ? "NULL" : "NOT NULL"; + + if (allowNulls != column.AllowNulls) + sb.AppendFormat( + $@"ALTER TABLE {column.Table.GetFullyQualifiedName()} ALTER COLUMN {syntax.EnsureWrapped(column.GetRuntimeName())} SET {newNullability}"); + return sb.ToString(); + } +} \ No newline at end of file diff --git a/FAnsi.PostgreSql/PostgreSqlDatabaseHelper.cs b/FAnsi.PostgreSql/PostgreSqlDatabaseHelper.cs new file mode 100644 index 00000000..00f29bee --- /dev/null +++ b/FAnsi.PostgreSql/PostgreSqlDatabaseHelper.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.Linq; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using Npgsql; + +namespace FAnsi.Implementations.PostgreSql; + +public sealed class PostgreSqlDatabaseHelper : DiscoveredDatabaseHelper +{ + public static readonly PostgreSqlDatabaseHelper Instance = new(); + private PostgreSqlDatabaseHelper(){} + + public override IEnumerable ListTables(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, DbConnection connection, + string database, bool includeViews, DbTransaction? transaction = null) + { + + const string sqlTables = """ + SELECT + * + FROM + pg_catalog.pg_tables + WHERE + schemaname != 'pg_catalog' + AND schemaname != 'information_schema'; + """; + + + const string sqlViews = """ + SELECT + * + FROM + pg_catalog.pg_views + WHERE + schemaname != 'pg_catalog' + AND schemaname != 'information_schema'; + """; + + var tables = new List(); + + using (var cmd = new NpgsqlCommand(sqlTables, (NpgsqlConnection) connection)) + { + cmd.Transaction = transaction as NpgsqlTransaction; + + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + //its a system table + var schema = r["schemaname"] as string; + + if(querySyntaxHelper.IsValidTableName((string)r["tablename"], out _)) + tables.Add(new DiscoveredTable(parent, (string)r["tablename"], querySyntaxHelper, schema)); + } + } + + if (includeViews) + { + using var cmd = new NpgsqlCommand(sqlViews, (NpgsqlConnection)connection); + cmd.Transaction = transaction as NpgsqlTransaction; + + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + //it's a system table + var schema = r["schemaname"] as string; + + if(querySyntaxHelper.IsValidTableName((string)r["viewname"], out _)) + tables.Add(new DiscoveredTable(parent, (string)r["viewname"], querySyntaxHelper, schema, TableType.View)); + } + } + + return tables.ToArray(); + } + + public override IEnumerable ListTableValuedFunctions(DiscoveredDatabase parent, IQuerySyntaxHelper querySyntaxHelper, + DbConnection connection, string database, DbTransaction? transaction = null) => + Enumerable.Empty(); + + public override DiscoveredStoredprocedure[] + ListStoredprocedures(DbConnectionStringBuilder builder, string database) => + Array.Empty(); + + public override IDiscoveredTableHelper GetTableHelper() => PostgreSqlTableHelper.Instance; + + public override void DropDatabase(DiscoveredDatabase database) + { + var master = database.Server.ExpectDatabase("postgres"); + + NpgsqlConnection.ClearAllPools(); + + using (var con = (NpgsqlConnection) master.Server.GetConnection()) + { + con.Open(); + + // https://dba.stackexchange.com/a/11895 + + using(var cmd = new NpgsqlCommand($"UPDATE pg_database SET datallowconn = 'false' WHERE datname = '{database.GetRuntimeName()}';",con)) + cmd.ExecuteNonQuery(); + + using(var cmd = new NpgsqlCommand($""" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '{database.GetRuntimeName()}'; + """ + ,con)) + cmd.ExecuteNonQuery(); + + using(var cmd = new NpgsqlCommand($"DROP DATABASE \"{database.GetRuntimeName()}\"",con)) + cmd.ExecuteNonQuery(); + } + + NpgsqlConnection.ClearAllPools(); + + } + + public override Dictionary DescribeDatabase(DbConnectionStringBuilder builder, string database) => throw new NotImplementedException(); + + protected override string GetCreateTableSqlLineForColumn(DatabaseColumnRequest col, string datatype, IQuerySyntaxHelper syntaxHelper) => + //Collations generally have to be in quotes (unless maybe they are very weird user generated ones?) + $"{syntaxHelper.EnsureWrapped(col.ColumnName)} {datatype} {(col.Default != MandatoryScalarFunctions.None ? $"default {syntaxHelper.GetScalarFunctionSql(col.Default)}" : "")} {(string.IsNullOrWhiteSpace(col.Collation) ? "" : $"COLLATE \"{col.Collation.Trim('"')}\"")} {(col.AllowNulls && !col.IsPrimaryKey ? " NULL" : " NOT NULL")} {(col.IsAutoIncrement ? syntaxHelper.GetAutoIncrementKeywordIfAny() : "")}"; + + public override DirectoryInfo Detach(DiscoveredDatabase database) => throw new NotImplementedException(); + + public override void CreateBackup(DiscoveredDatabase discoveredDatabase, string backupName) + { + throw new NotImplementedException(); + } + + public override void CreateSchema(DiscoveredDatabase discoveredDatabase, string name) + { + using var con = discoveredDatabase.Server.GetConnection(); + con.Open(); + + var syntax = discoveredDatabase.Server.GetQuerySyntaxHelper(); + + var sql = $@"create schema if not exists {syntax.EnsureWrapped(name)}"; + + using var cmd = discoveredDatabase.Server.GetCommand(sql, con); + cmd.ExecuteNonQuery(); + } +} \ No newline at end of file diff --git a/FAnsi.PostgreSql/PostgreSqlImplementation.cs b/FAnsi.PostgreSql/PostgreSqlImplementation.cs new file mode 100644 index 00000000..a0cb960d --- /dev/null +++ b/FAnsi.PostgreSql/PostgreSqlImplementation.cs @@ -0,0 +1,16 @@ +using System.Data.Common; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Implementation; +using Npgsql; + +namespace FAnsi.Implementations.PostgreSql; + +public sealed class PostgreSqlImplementation() : Implementation(DatabaseType.PostgreSql) +{ + public override IDiscoveredServerHelper GetServerHelper() => PostgreSqlServerHelper.Instance; + + public override bool IsFor(DbConnection connection) => connection is NpgsqlConnection; + + public override IQuerySyntaxHelper GetQuerySyntaxHelper() => PostgreSqlSyntaxHelper.Instance; +} \ No newline at end of file diff --git a/FAnsi.PostgreSql/PostgreSqlServerHelper.cs b/FAnsi.PostgreSql/PostgreSqlServerHelper.cs new file mode 100644 index 00000000..c65ffe55 --- /dev/null +++ b/FAnsi.PostgreSql/PostgreSqlServerHelper.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Naming; +using Npgsql; + +namespace FAnsi.Implementations.PostgreSql; + +public sealed class PostgreSqlServerHelper : DiscoveredServerHelper +{ + public static readonly PostgreSqlServerHelper Instance = new(); + + private PostgreSqlServerHelper() : base(DatabaseType.PostgreSql) + { + } + + protected override DbConnectionStringBuilder GetConnectionStringBuilderImpl(string? connectionString) => + new NpgsqlConnectionStringBuilder(connectionString); + + protected override string ServerKeyName => "Host"; + protected override string DatabaseKeyName => "Database"; + protected override string ConnectionTimeoutKeyName => "Timeout"; + + + public override DbConnectionStringBuilder EnableAsync(DbConnectionStringBuilder builder) => + //nothing special we need to turn on + builder; + + public override IDiscoveredDatabaseHelper GetDatabaseHelper() => PostgreSqlDatabaseHelper.Instance; + + public override IQuerySyntaxHelper GetQuerySyntaxHelper() => PostgreSqlSyntaxHelper.Instance; + + public override void CreateDatabase(DbConnectionStringBuilder builder, IHasRuntimeName newDatabaseName) + { + using var con = new NpgsqlConnection(builder.ConnectionString); + con.Open(); + using var cmd = GetCommand($"CREATE DATABASE \"{newDatabaseName.GetRuntimeName()}\"", con); + cmd.CommandTimeout = CreateDatabaseTimeoutInSeconds; + cmd.ExecuteNonQuery(); + } + + public override Dictionary DescribeServer(DbConnectionStringBuilder builder) => throw new NotImplementedException(); + + public override string? GetExplicitUsernameIfAny(DbConnectionStringBuilder builder) => ((NpgsqlConnectionStringBuilder)builder).Username; + + public override string? GetExplicitPasswordIfAny(DbConnectionStringBuilder builder) => ((NpgsqlConnectionStringBuilder)builder).Password; + + public override Version? GetVersion(DiscoveredServer server) + { + using var con = new NpgsqlConnection(server.Builder.ConnectionString); + con.Open(); + return con.PostgreSqlVersion; + } + + + public override IEnumerable ListDatabases(DbConnectionStringBuilder builder) + { + using var con = new NpgsqlConnection(builder.ConnectionString); + con.Open(); + foreach (var listDatabase in ListDatabases(con)) yield return listDatabase; + } + + public override IEnumerable ListDatabases(DbConnection con) + { + using var cmd = GetCommand("SELECT datname FROM pg_database;", con); + using var r = cmd.ExecuteReader(); + while (r.Read()) + yield return (string)r["datname"]; + } + + public override DbCommand GetCommand(string s, DbConnection con, DbTransaction? transaction = null) => new NpgsqlCommand(s, (NpgsqlConnection) + con, (NpgsqlTransaction?)transaction); + + public override DbDataAdapter GetDataAdapter(DbCommand cmd) => new NpgsqlDataAdapter((NpgsqlCommand)cmd); + + public override DbCommandBuilder GetCommandBuilder(DbCommand cmd) => new NpgsqlCommandBuilder(new NpgsqlDataAdapter((NpgsqlCommand)cmd)); + + public override DbParameter GetParameter(string parameterName) => new NpgsqlParameter { ParameterName = parameterName }; + + public override DbConnection GetConnection(DbConnectionStringBuilder builder) => new NpgsqlConnection(builder.ConnectionString); + + protected override DbConnectionStringBuilder GetConnectionStringBuilderImpl(string server, string? database, + string username, string password) + { + var toReturn = new NpgsqlConnectionStringBuilder + { + Host = server + }; + + if (!string.IsNullOrWhiteSpace(username)) + { + toReturn.Username = username; + toReturn.Password = password; + } + + if (!string.IsNullOrWhiteSpace(database)) + toReturn.Database = database; + + return toReturn; + } +} \ No newline at end of file diff --git a/FAnsi.PostgreSql/PostgreSqlSyntaxHelper.cs b/FAnsi.PostgreSql/PostgreSqlSyntaxHelper.cs new file mode 100644 index 00000000..b87ab6c8 --- /dev/null +++ b/FAnsi.PostgreSql/PostgreSqlSyntaxHelper.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Implementations.PostgreSql.Aggregation; +using FAnsi.Implementations.PostgreSql.Update; + +namespace FAnsi.Implementations.PostgreSql; + +public sealed class PostgreSqlSyntaxHelper : QuerySyntaxHelper +{ + public static readonly PostgreSqlSyntaxHelper Instance = new(); + private PostgreSqlSyntaxHelper() : base(PostgreSqlTypeTranslater.Instance, PostgreSqlAggregateHelper.Instance, PostgreSqlUpdateHelper.Instance, DatabaseType.PostgreSql) + { + } + + public override int MaximumDatabaseLength => 63; + public override int MaximumTableLength => 63; + public override int MaximumColumnLength => 63; + + public const string DefaultPostgresSchema = "public"; + + public override string OpenQualifier => "\""; + + public override string CloseQualifier => "\""; + + /// + public override string False => "FALSE"; // 'FALSE' is the string representation of the boolean false in PostgreSql, unlike 0 for the others + + /// + public override string True => "TRUE"; // 'TRUE' is the string representation of the boolean true in PostgreSql, unlike 1 for the others + + public override bool SupportsEmbeddedParameters() => false; + + protected override object FormatDateTimeForDbParameter(DateTime dateTime) => + // Starting with 4.0.0 npgsql crashes if it has to read a DateTime Unspecified Kind + // See : https://github.com/npgsql/efcore.pg/issues/2000 + // Also it doesn't support DateTime.Kind.Local + dateTime.Kind == DateTimeKind.Unspecified ? dateTime.ToUniversalTime() : dateTime; + + public override string EnsureWrappedImpl(string databaseOrTableName) => $"\"{GetRuntimeNameWithDoubledDoubleQuotes(databaseOrTableName)}\""; + + /// + /// Returns the runtime name of the string with all double quotes escaped (but resulting string is not wrapped itself) + /// + /// + /// + private string? GetRuntimeNameWithDoubledDoubleQuotes(string s) => GetRuntimeName(s)?.Replace("\"", "\"\""); + + protected override string UnescapeWrappedNameBody(string name) => name.Replace("\"\"", "\""); + + public override string EnsureFullyQualified(string? databaseName, string? schema, string tableName) => + //if there is no schema address it as db..table (which is the same as db.dbo.table in Microsoft SQL Server) + $"{EnsureWrapped(databaseName)}{DatabaseTableSeparator}{(string.IsNullOrWhiteSpace(schema) ? DefaultPostgresSchema : EnsureWrapped(schema))}{DatabaseTableSeparator}{EnsureWrapped(tableName)}"; + + public override string EnsureFullyQualified(string? databaseName, string? schema, string tableName, string columnName, + bool isTableValuedFunction = false) + { + if (isTableValuedFunction) + return $"{EnsureWrapped(tableName)}.{EnsureWrapped(GetRuntimeName(columnName))}"; //table valued functions do not support database name being in the column level selection list area of sql queries + + return $"{EnsureFullyQualified(databaseName, schema, tableName)}.\"{GetRuntimeName(columnName)}\""; + } + + + public override TopXResponse HowDoWeAchieveTopX(int x) => new($"fetch first {x} rows only", QueryComponent.Postfix); + + public override string GetParameterDeclaration(string proposedNewParameterName, string sqlType) => throw new NotSupportedException(); + + public override string GetScalarFunctionSql(MandatoryScalarFunctions function) => + function switch + { + MandatoryScalarFunctions.GetTodaysDate => "now()", + MandatoryScalarFunctions.GetGuid => "gen_random_uuid()" //requires pgcrypto e.g. CREATE EXTENSION pgcrypto; + , + MandatoryScalarFunctions.Len => "LENGTH", + _ => throw new ArgumentOutOfRangeException(nameof(function)) + }; + + public override string GetAutoIncrementKeywordIfAny() => "GENERATED ALWAYS AS IDENTITY"; + + public override Dictionary GetSQLFunctionsDictionary() => []; + + public override string HowDoWeAchieveMd5(string selectSql) => $"MD5({selectSql})"; + + public override string GetDefaultSchemaIfAny() => "public"; +} \ No newline at end of file diff --git a/FAnsi.PostgreSql/PostgreSqlTableHelper.cs b/FAnsi.PostgreSql/PostgreSqlTableHelper.cs new file mode 100644 index 00000000..a03efe7b --- /dev/null +++ b/FAnsi.PostgreSql/PostgreSqlTableHelper.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using System.Configuration.Internal; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using FAnsi.Connections; +using FAnsi.Discovery; +using FAnsi.Discovery.Constraints; +using FAnsi.Exceptions; +using FAnsi.Naming; +using Npgsql; + +namespace FAnsi.Implementations.PostgreSql; + +public sealed class PostgreSqlTableHelper : DiscoveredTableHelper +{ + public static readonly PostgreSqlTableHelper Instance = new(); + + private PostgreSqlTableHelper() + { + } + + public override string GetTopXSqlForTable(IHasFullyQualifiedNameToo table, int topX) => $"SELECT * FROM {table.GetFullyQualifiedName()} FETCH FIRST {topX} ROWS ONLY"; + + public override IEnumerable DiscoverColumns(DiscoveredTable discoveredTable, IManagedConnection connection, string database) + { + var pks = + //don't bother looking for pks if it is a table valued function + discoveredTable is DiscoveredTableValuedFunction ? null : ListPrimaryKeys(connection, discoveredTable).ToHashSet(); + + const string sqlColumns = """ + SELECT * + FROM information_schema.columns + WHERE table_schema = @schemaName + AND table_name = @tableName; + """; + + using var cmd = + discoveredTable.GetCommand(sqlColumns, connection.Connection, connection.Transaction); + var p = cmd.CreateParameter(); + p.ParameterName = "@tableName"; + p.Value = discoveredTable.GetRuntimeName(); + cmd.Parameters.Add(p); + + var p2 = cmd.CreateParameter(); + p2.ParameterName = "@schemaName"; + p2.Value = string.IsNullOrWhiteSpace(discoveredTable.Schema) ? PostgreSqlSyntaxHelper.DefaultPostgresSchema : discoveredTable.Schema; + cmd.Parameters.Add(p2); + + + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var isNullable = Equals(r["is_nullable"] , "YES"); + + //if it is a table valued function prefix the column name with the table valued function name + var columnName = discoveredTable is DiscoveredTableValuedFunction + ? $"{discoveredTable.GetRuntimeName()}.{r["column_name"]}" + : r["column_name"].ToString(); + + if (columnName == null) continue; + + var toAdd = new DiscoveredColumn(discoveredTable, columnName, isNullable) + { + IsAutoIncrement = Equals(r["is_identity"],"YES"), + Collation = r["collation_name"] as string + }; + toAdd.DataType = new DiscoveredDataType(r, GetSQLType_FromSpColumnsResult(r), toAdd); + + toAdd.IsPrimaryKey = pks?.Contains(toAdd.GetRuntimeName()) ?? false; + yield return toAdd; + } + } + + private static IEnumerable ListPrimaryKeys(IManagedConnection con, DiscoveredTable table) + { + const string query = """ + SELECT + pg_attribute.attname, + format_type(pg_attribute.atttypid, pg_attribute.atttypmod) + FROM pg_index, pg_class, pg_attribute + WHERE + pg_class.oid = @tableName::regclass AND + indrelid = pg_class.oid AND + pg_attribute.attrelid = pg_class.oid AND + pg_attribute.attnum = any(pg_index.indkey) + AND indisprimary + """; + + using var cmd = table.GetCommand(query, con.Connection); + cmd.Transaction = con.Transaction; + + var p = cmd.CreateParameter(); + p.ParameterName = "@tableName"; + p.Value = table.GetFullyQualifiedName(); + cmd.Parameters.Add(p); + + using var r = cmd.ExecuteReader(); + while(r.Read()) + yield return (string)r["attname"]; + + r.Close(); + } + + private static string GetSQLType_FromSpColumnsResult(DbDataReader r) + { + var columnType = r["data_type"] as string; + var lengthQualifier = ""; + + if (HasPrecisionAndScale(columnType ?? string.Empty)) + lengthQualifier = $"({r["numeric_precision"]},{r["numeric_scale"]})"; + else if (r["character_maximum_length"] != DBNull.Value) lengthQualifier = $"({Convert.ToInt32(r["character_maximum_length"])})"; + + return columnType + lengthQualifier; + } + + public override void DropIndex(DatabaseOperationArgs args, DiscoveredTable table, string indexName) + { + using var connection = args.GetManagedConnection(table); + try + { + + var sql = + $"DROP INDEX {indexName}"; + + using var cmd = table.Database.Server.Helper.GetCommand(sql, connection.Connection, connection.Transaction); + args.ExecuteNonQuery(cmd); + } + catch (Exception e) + { + throw new AlterFailedException(string.Format(FAnsiStrings.DiscoveredTableHelper_DropIndex_Failed, table), e); + } + } + + public override IDiscoveredColumnHelper GetColumnHelper() => PostgreSqlColumnHelper.Instance; + + public override void DropFunction(DbConnection connection, DiscoveredTableValuedFunction functionToDrop) => + throw new NotImplementedException(); + + public override void DropColumn(DbConnection connection, DiscoveredColumn columnToDrop) + { + using var cmd = new NpgsqlCommand( + $""" + ALTER TABLE {columnToDrop.Table.GetFullyQualifiedName()} + DROP COLUMN {columnToDrop.GetWrappedName()}; + """,(NpgsqlConnection) connection); + cmd.ExecuteNonQuery(); + } + + public override IEnumerable DiscoverTableValuedFunctionParameters(DbConnection connection, + DiscoveredTableValuedFunction discoveredTableValuedFunction, DbTransaction? transaction) => + throw new NotImplementedException(); + + public override IBulkCopy BeginBulkInsert(DiscoveredTable discoveredTable, IManagedConnection connection, CultureInfo culture) => new PostgreSqlBulkCopy(discoveredTable, connection,culture); + + public override int ExecuteInsertReturningIdentity(DiscoveredTable discoveredTable, DbCommand cmd, + IManagedTransaction? transaction = null) + { + var autoIncrement = discoveredTable.DiscoverColumns(transaction).SingleOrDefault(static c => c.IsAutoIncrement); + + if(autoIncrement != null) + cmd.CommandText += $" RETURNING {autoIncrement.GetFullyQualifiedName()};"; + + var result = cmd.ExecuteScalar(); + + if (result == DBNull.Value || result == null) + return 0; + + return Convert.ToInt32(result); + } + + public override DiscoveredRelationship[] DiscoverRelationships(DiscoveredTable table, DbConnection connection, + IManagedTransaction? transaction = null) + { + const string sql = """ + select c.constraint_name + , x.table_schema as foreign_table_schema + , x.table_name as foreign_table_name + , x.column_name as foreign_column_name + , y.table_schema + , y.table_name + , y.column_name + , delete_rule + from information_schema.referential_constraints c + join information_schema.key_column_usage x + on x.constraint_name = c.constraint_name + join information_schema.key_column_usage y + on y.ordinal_position = x.position_in_unique_constraint + and y.constraint_name = c.unique_constraint_name + where + y.table_name=@tableName AND + y.table_schema=@schema + order by c.constraint_name, x.ordinal_position + """; + + + var toReturn = new Dictionary(); + + using (var cmd = table.GetCommand(sql, connection, transaction?.Transaction)) + { + var p = cmd.CreateParameter(); + p.ParameterName = "@tableName"; + p.Value = table.GetRuntimeName(); + cmd.Parameters.Add(p); + + var p2 = cmd.CreateParameter(); + p2.ParameterName = "@schema"; + p2.Value = string.IsNullOrWhiteSpace(table.Schema)? PostgreSqlSyntaxHelper.DefaultPostgresSchema : table.Schema; + cmd.Parameters.Add(p2); + + //fill data table to avoid multiple active readers + using var dt = new DataTable(); + using (var da = new NpgsqlDataAdapter((NpgsqlCommand)cmd)) + da.Fill(dt); + + foreach (DataRow r in dt.Rows) + { + var fkName = r["constraint_name"].ToString(); + if (fkName == null) continue; + + //could be a 2+ columns foreign key? + if (!toReturn.TryGetValue(fkName, out var current)) + { + var pkDb = table.Database.GetRuntimeName(); + var pkSchema = r["table_schema"].ToString(); + var pkTableName = r["table_name"].ToString(); + + var fkSchema = r["foreign_table_schema"].ToString(); + var fkTableName = r["foreign_table_name"].ToString(); + + if (pkTableName == null || fkTableName == null) continue; + + var pktable = table.Database.Server.ExpectDatabase(pkDb).ExpectTable(pkTableName, pkSchema); + var fktable = table.Database.Server.ExpectDatabase(pkDb).ExpectTable(fkTableName, fkSchema); + + var deleteRuleString = r["delete_rule"].ToString(); + + var deleteRule = deleteRuleString switch + { + "CASCADE" => CascadeRule.Delete, + "NO ACTION" => CascadeRule.NoAction, + "RESTRICT" => CascadeRule.NoAction, + "SET NULL" => CascadeRule.SetNull, + "SET DEFAULT" => CascadeRule.SetDefault, + _ => CascadeRule.Unknown + }; + + current = new DiscoveredRelationship(fkName, pktable, fktable, deleteRule); + toReturn.Add(current.Name, current); + } + + var colName = r["column_name"].ToString(); + var foreignName = r["foreign_column_name"].ToString(); + if (colName != null && foreignName != null) + current.AddKeys(colName, foreignName, transaction); + } + } + + return [.. toReturn.Values]; + } + + protected override string GetRenameTableSql(DiscoveredTable discoveredTable, string newName) + { + var syntax = PostgreSqlSyntaxHelper.Instance; + return $"ALTER TABLE {discoveredTable.GetFullyQualifiedName()} RENAME TO {syntax.EnsureWrapped(newName)}"; + } +} \ No newline at end of file diff --git a/FAnsi.PostgreSql/PostgreSqlTypeTranslater.cs b/FAnsi.PostgreSql/PostgreSqlTypeTranslater.cs new file mode 100644 index 00000000..92c6f8ab --- /dev/null +++ b/FAnsi.PostgreSql/PostgreSqlTypeTranslater.cs @@ -0,0 +1,70 @@ +using System; +using System.Text.RegularExpressions; +using FAnsi.Discovery.TypeTranslation; +using NpgsqlTypes; + +namespace FAnsi.Implementations.PostgreSql; + +public sealed partial class PostgreSqlTypeTranslater : TypeTranslater +{ + public static readonly PostgreSqlTypeTranslater Instance = new(); + + private PostgreSqlTypeTranslater() : base(DateRegexImpl(), 8000, 4000) + { + TimeRegex = TimeRegexImpl(); //space is important + } + + public override string GetStringDataTypeWithUnlimitedWidth() => "text"; + + protected override string GetUnicodeStringDataTypeImpl(int maxExpectedStringWidth) => GetStringDataType(maxExpectedStringWidth); + + public override string GetUnicodeStringDataTypeWithUnlimitedWidth() => "text"; + + protected override string GetDateDateTimeDataType() => "timestamp"; + + public NpgsqlDbType GetNpgsqlDbTypeForCSharpType(Type t) + { + + if (t == typeof(bool) || t == typeof(bool?)) + return NpgsqlDbType.Boolean; + + if (t == typeof(byte)) + return NpgsqlDbType.Bytea; + + if (t == typeof(short) || t == typeof(short) || t == typeof(ushort) || t == typeof(short?) || t == typeof(ushort?)) + return NpgsqlDbType.Smallint; + + if (t == typeof(int) || t == typeof(int) || t == typeof(uint) || t == typeof(int?) || t == typeof(uint?)) + return NpgsqlDbType.Integer; + + if (t == typeof (long) || t == typeof(ulong) || t == typeof(long?) || t == typeof(ulong?)) + return NpgsqlDbType.Bigint; + + if (t == typeof(float) || t == typeof(float?) || t == typeof(double) || + t == typeof(double?)) + return NpgsqlDbType.Double; + + if (t == typeof(decimal) || t == typeof(decimal?)) + return NpgsqlDbType.Numeric; + + if (t == typeof(string)) + return NpgsqlDbType.Text; + + if (t == typeof(DateTime) || t == typeof(DateTime?)) + return NpgsqlDbType.Timestamp; + + if (t == typeof(TimeSpan) || t == typeof(TimeSpan?)) + return NpgsqlDbType.Time; + + if (t == typeof(Guid)) + return NpgsqlDbType.Uuid; + + throw new TypeNotMappedException(string.Format(FAnsiStrings.TypeTranslater_GetSQLDBTypeForCSharpType_Unsure_what_SQL_type_to_use_for_CSharp_Type___0_____TypeTranslater_was___1__, t.Name, GetType().Name)); + + } + + [GeneratedRegex("timestamp", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex DateRegexImpl(); + [GeneratedRegex("^time ", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex TimeRegexImpl(); +} \ No newline at end of file diff --git a/FAnsi.PostgreSql/README.md b/FAnsi.PostgreSql/README.md new file mode 100644 index 00000000..31fcdd3a --- /dev/null +++ b/FAnsi.PostgreSql/README.md @@ -0,0 +1,51 @@ +# Postgres FAnsi Implementation + +# Feature Completeness + +- Server + - [X] Check Exists + - [ ] Describe + - [X] List Databases + +- Database + - [X] Create + - [X] Drop + - [ ] Backup + - [ ] Detach + - [X] List Tables + - [ ] List Table Valued Functions + - [ ] List Stored Proceedures + +- Table + - [X] Create + - [X] Drop + - [X] Script Table Structure + - [X] MakeDistinct + - [X] Bulk Insert + - [X] Rename + - [X] List Foreign Keys + +- Column + - [X] Alter + +- Data Types + - [X] [Translation](./../../Documentation/TypeTranslation.md) + - [X] Bit + - [X] String + - [X] TimeSpan + - [X] Decimal + - [X] Date + - [X] Auto Increment + - [X] Unicode + +- Query + - [X] Top X + - [X] JOIN UPDATE + +- Aggregation + - [X] Basic GROUP BY + - [X] Calendar/Axis Table GROUP BY + - [ ] Dynamic Pivot GROUP BY + + +##Issues diff --git a/FAnsi.PostgreSql/Update/PostgreSqlUpdateHelper.cs b/FAnsi.PostgreSql/Update/PostgreSqlUpdateHelper.cs new file mode 100644 index 00000000..22ac2099 --- /dev/null +++ b/FAnsi.PostgreSql/Update/PostgreSqlUpdateHelper.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; +using FAnsi.Discovery.QuerySyntax.Update; + +namespace FAnsi.Implementations.PostgreSql.Update; + +public sealed class PostgreSqlUpdateHelper : UpdateHelper +{ + public static readonly PostgreSqlUpdateHelper Instance = new(); + private PostgreSqlUpdateHelper(){} + + protected override string BuildUpdateImpl(DiscoveredTable table1, DiscoveredTable table2, List lines) + { + //https://stackoverflow.com/a/7869611 + var joinSql = string.Join(" AND ", + lines.Where(static l => l.LocationToInsert == QueryComponent.JoinInfoJoin).Select(static c => c.Text)); + + var whereSql = string.Join(" AND ", + lines.Where(static l => l.LocationToInsert == QueryComponent.WHERE).Select(static c => c.Text)); + + return string.Format( + """ + UPDATE {1} AS t1 + SET + {0} + FROM + {2} AS t2 + WHERE + {3} + {4} + {5} + + """, + + string.Join($", {Environment.NewLine}",lines.Where(static l=>l.LocationToInsert == QueryComponent.SET) + .Select(static c => + //seems like you cant specify the table alias in the SET section of the query + c.Text.Replace("t1.",""))), + table1.GetFullyQualifiedName(), + table2.GetFullyQualifiedName(), + joinSql, + !string.IsNullOrWhiteSpace(whereSql) ? "AND" :"", + !string.IsNullOrWhiteSpace(whereSql) ? $"({whereSql})" :"" + ); + + } +} \ No newline at end of file