using System; using System.Collections.Generic; using System.Data; using System.Data.Common; using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; namespace EntityFrameworkCore.Jet.Data { public class JetCommand : DbCommand, ICloneable { #if DEBUG private static int _activeObjectsCount; #endif private JetConnection? _connection; private JetTransaction? _transaction; private int _outerSelectSkipEmulationViaDataReaderSkipCount; private static readonly Regex _createProcedureExpression = new Regex(@"^\s*create\s*procedure\b", RegexOptions.IgnoreCase); private static readonly Regex _topParameterRegularExpression = new Regex(@"(?<=(?:^|\s)select\s+top\s+)(?:@\w+|\?)(?=\s)", RegexOptions.IgnoreCase); private static readonly Regex _topMultiParameterRegularExpression = new Regex(@"(?<=(?:^|\s)select\s+top\s+)(?'first'@\w+|\?)(\s)(\+)(\s+)(?'sec'@\w+|\?)", RegexOptions.IgnoreCase); private static readonly Regex _topMultiArgumentRegularExpression = new Regex(@"(?<=(?:^|\s)SELECT\s+TOP\s+)(?'first'\d+|\?)(\s)(\+)(\s+)(?'sec'\d+|\?)", RegexOptions.IgnoreCase); private static readonly Regex _outerSelectTopValueRegularExpression = new Regex(@"(?<=^\s*select\s+top\s+)\d+(?=\s)", RegexOptions.IgnoreCase); private static readonly Regex _outerSelectSkipValueOrParameterRegularExpression = new Regex(@"(?<=^\s*select)\s+skip\s+(?@\w+|\?|\d+)(?=\s)", RegexOptions.IgnoreCase); private static readonly Regex _selectRowCountRegularExpression = new Regex(@"^\s*select\s*@@rowcount\s*;?\s*$", RegexOptions.IgnoreCase); private static readonly Regex _ifStatementRegex = new Regex(@"^\s*if\s*(?not)?\s*exists\s*\((?.+)\)\s*then\s*(?.*)$", RegexOptions.IgnoreCase | RegexOptions.Singleline); protected JetCommand(JetCommand source) { #if DEBUG Interlocked.Increment(ref _activeObjectsCount); #endif _connection = source._connection; _transaction = source._transaction; InnerCommand = (DbCommand)((ICloneable)source.InnerCommand).Clone(); } /// /// Initializes a new instance of the class. /// /// The command text. /// The connection. /// The transaction. internal JetCommand(JetConnection connection, string? commandText = null, JetTransaction? transaction = null) { #if DEBUG Interlocked.Increment(ref _activeObjectsCount); #endif _connection = connection ?? throw new ArgumentNullException(nameof(connection)); _transaction = transaction; InnerCommand = connection.JetFactory.InnerFactory.CreateCommand(); InnerCommand.CommandText = commandText; } protected override void Dispose(bool disposing) { if (disposing) { InnerCommand.Dispose(); foreach (var scl in SplitCommandList) { if (scl != this) scl.Dispose(disposing); } } base.Dispose(disposing); #if DEBUG Interlocked.Decrement(ref _activeObjectsCount); #endif } internal DbCommand InnerCommand { get; } internal IList SplitCommandList { get; set; } = new List(); /// /// Attempts to Cancels the command execution /// public override void Cancel() => InnerCommand.Cancel(); /// /// Gets or sets the command text. /// /// /// The command text. /// public override string CommandText { get => InnerCommand.CommandText; set => InnerCommand.CommandText = value; } /// /// Gets or sets the command timeout. /// /// /// The command timeout. /// public override int CommandTimeout { get => InnerCommand.CommandTimeout; set => InnerCommand.CommandTimeout = value; } /// /// Gets or sets the type of the command. /// /// /// The type of the command. /// public override CommandType CommandType { get => InnerCommand.CommandType; set => InnerCommand.CommandType = value; } /// /// Creates the database parameter. /// /// protected override DbParameter CreateDbParameter() => InnerCommand.CreateParameter(); /// /// Gets or sets the database connection. /// /// /// The database connection. /// protected override DbConnection? DbConnection { get => _connection; set { if (value != _connection) throw new NotSupportedException($"The {DbConnection} property cannot be changed."); } } /// /// Gets the database parameter collection. /// /// /// The database parameter collection. /// protected override DbParameterCollection DbParameterCollection => InnerCommand.Parameters; /// /// Gets or sets the database transaction. /// /// /// The database transaction. /// protected override DbTransaction? DbTransaction { get => _transaction; set => _transaction = (JetTransaction?)value; } /// /// Gets or sets a value indicating whether is design time visible. /// /// /// true if design time visible; otherwise, false. /// public override bool DesignTimeVisible { get; set; } /// /// Executes the database data reader. /// /// The behavior. /// protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) { if (Connection == null) throw new InvalidOperationException(Messages.PropertyNotInitialized(nameof(Connection))); if (Connection.State != ConnectionState.Open) throw new InvalidOperationException(Messages.CannotCallMethodInThisConnectionState("ExecuteReader", ConnectionState.Open, Connection.State)); ExpandParameters(); SplitCommandList = SplitCommands(); for (var i = 0; i < SplitCommandList.Count - 1; i++) { SplitCommandList[i] .ExecuteNonQueryCore(); } return SplitCommandList[SplitCommandList.Count - 1] .ExecuteDbDataReaderCore(behavior); } protected virtual DbDataReader ExecuteDbDataReaderCore(CommandBehavior behavior) { InnerCommand.Connection = _connection.InnerConnection; // OLE DB forces us to use an existing active transaction, if one is available. InnerCommand.Transaction = _transaction?.WrappedTransaction ?? _connection.ActiveTransaction?.WrappedTransaction; LogHelper.ShowCommandText("ExecuteDbDataReader", InnerCommand); if (JetInformationSchema.TryGetDataReaderFromInformationSchemaCommand(this, out var dataReader)) // Retrieve from store schema definition. return dataReader; if (InnerCommand.CommandType != CommandType.Text) return new JetDataReader(InnerCommand.ExecuteReader(behavior)); if ((dataReader = TryGetDataReaderForSelectRowCount(InnerCommand.CommandText)) == null) { FixupGlobalVariables(); PrepareOuterSelectSkipEmulationViaDataReader(); InlineTopParameters(); ModifyOuterSelectTopValueForOuterSelectSkipEmulationViaDataReader(); FixParameters(); dataReader = new JetDataReader(InnerCommand.ExecuteReader(behavior), _outerSelectSkipEmulationViaDataReaderSkipCount); _connection.RowCount = dataReader.RecordsAffected; } return dataReader; } /// /// Executes the non query. /// /// public override int ExecuteNonQuery() { if (Connection == null) throw new InvalidOperationException(Messages.PropertyNotInitialized(nameof(Connection))); ExpandParameters(); SplitCommandList = SplitCommands(); return SplitCommandList .Aggregate(0, (_, command) => command.ExecuteNonQueryCore()); } protected virtual int ExecuteNonQueryCore() { if (Connection == null) throw new InvalidOperationException(Messages.PropertyNotInitialized(nameof(Connection))); LogHelper.ShowCommandText("ExecuteNonQuery", InnerCommand); if (JetStoreDatabaseHandling.ProcessDatabaseOperation(this)) return 1; if (JetSchemaOperationsHandling.TryDatabaseOperation((JetConnection)Connection, InnerCommand.CommandText)) return 1; if (Connection.State != ConnectionState.Open) throw new InvalidOperationException(Messages.CannotCallMethodInThisConnectionState(nameof(ExecuteNonQuery), ConnectionState.Open, Connection.State)); InnerCommand.Connection = _connection.InnerConnection; // OLE DB forces us to use an existing active transaction, if one is available. InnerCommand.Transaction = _transaction?.WrappedTransaction ?? _connection.ActiveTransaction?.WrappedTransaction; if (InnerCommand.CommandType != CommandType.Text) return InnerCommand.ExecuteNonQuery(); if (_selectRowCountRegularExpression.Match(InnerCommand.CommandText) .Success) { return _connection.RowCount; } FixupGlobalVariables(); if (!CheckExists(InnerCommand.CommandText, out var newCommandText)) return 0; InnerCommand.CommandText = newCommandText; InlineTopParameters(); FixParameters(); _connection.RowCount = InnerCommand.ExecuteNonQuery(); // For UPDATE, INSERT, and DELETE statements, the return value is the number of rows affected by the command. // For all other types of statements, the return value is -1. If a rollback occurs, the return value is also -1. // This is how it is stated in the docs, however, the underlying connection actually seems to be returning 0 // for statements like CREATE TABLE/INDEX, EXEC etc. var commandType = newCommandText.Trim().Substring(0, 6); if (commandType.Contains("INSERT", StringComparison.OrdinalIgnoreCase) || commandType.Contains("UPDATE", StringComparison.OrdinalIgnoreCase) || commandType.Contains("DELETE", StringComparison.OrdinalIgnoreCase)) { return _connection.RowCount; } return -1; } /// /// Executes the query and returns the first column of the first row in the result set returned by the query. All other columns and rows are ignored /// /// public override object ExecuteScalar() { if (Connection == null) throw new InvalidOperationException(Messages.PropertyNotInitialized(nameof(Connection))); if (Connection.State != ConnectionState.Open) throw new InvalidOperationException(Messages.CannotCallMethodInThisConnectionState(nameof(ExecuteScalar), ConnectionState.Open, Connection.State)); ExpandParameters(); SplitCommandList = SplitCommands(); for (var i = 0; i < SplitCommandList.Count - 1; i++) { SplitCommandList[i] .ExecuteNonQueryCore(); } return SplitCommandList[SplitCommandList.Count - 1] .ExecuteScalarCore(); } protected virtual object ExecuteScalarCore() { if (Connection == null) throw new InvalidOperationException(Messages.PropertyNotInitialized(nameof(Connection))); if (Connection.State != ConnectionState.Open) throw new InvalidOperationException(Messages.CannotCallMethodInThisConnectionState(nameof(ExecuteScalar), ConnectionState.Open, Connection.State)); InnerCommand.Connection = _connection.InnerConnection; // OLE DB forces us to use an existing active transaction, if one is available. InnerCommand.Transaction = _transaction?.WrappedTransaction ?? _connection.ActiveTransaction?.WrappedTransaction; LogHelper.ShowCommandText("ExecuteScalar", InnerCommand); if (JetInformationSchema.TryGetDataReaderFromInformationSchemaCommand(this, out var dataReader)) { // Retrieve from store schema definition. if (dataReader.HasRows) { dataReader.Read(); return dataReader[0]; } return DBNull.Value; } FixupGlobalVariables(); InlineTopParameters(); FixParameters(); return InnerCommand.ExecuteScalar(); } protected virtual IList SplitCommands() { //Remove any tag lines from the sql created by ef core. Jet doesn't like comments/tags in the SQL var lines = CommandText.Split(new[] { Environment.NewLine }, StringSplitOptions.None); var filteredLines = lines.Where(line => !line.TrimStart().StartsWith("--")); CommandText = string.Join(Environment.NewLine, filteredLines).TrimStart(); // At this point, all parameters have already been expanded. var parser = new JetCommandParser(CommandText); var commandDelimiters = parser.GetStateIndices(';'); var currentCommandStart = 0; var usedParameterCount = 0; var commands = new List(); if (commandDelimiters.Count > 0) { foreach (var commandDelimiter in commandDelimiters) { var commandText = CommandText.Substring(currentCommandStart, commandDelimiter - currentCommandStart) .Trim(); if (!string.IsNullOrEmpty(commandText)) { var command = (JetCommand)((ICloneable)this).Clone(); command.CommandText = commandText; if (_createProcedureExpression.IsMatch(command.CommandText)) { command.Parameters.Clear(); } else { //don't touch parameters if EXEC stored procedure. Jet parameters do not use the @ symbol with parameter arguments in EXEC. //As such this code would not find any parameters and clear them all out. if (!commandText.StartsWith("EXEC", StringComparison.InvariantCultureIgnoreCase)) { for (var i = 0; i < usedParameterCount && command.Parameters.Count > 0; i++) { command.Parameters.RemoveAt(0); } var parameterIndices = parser.GetStateIndices( new[] { '@', '?' }, currentCommandStart, commandDelimiter - currentCommandStart); while (command.Parameters.Count > parameterIndices.Count) { command.Parameters.RemoveAt(parameterIndices.Count); } usedParameterCount += parameterIndices.Count; } } commands.Add(command); } currentCommandStart = commandDelimiter + 1; } } else { var commandText = CommandText.Trim(); if (!string.IsNullOrEmpty(commandText)) { commands.Add(this); } } return commands; } private DbDataReader TryGetDataReaderForSelectRowCount(string commandText) { if (_selectRowCountRegularExpression.Match(commandText) .Success) { var dataTable = new DataTable("Rowcount"); dataTable.Columns.Add("ROWCOUNT", typeof(int)); dataTable.Rows.Add(_connection.RowCount); return new DataTableReader(dataTable); } return null; } private bool CheckExists(string commandText, out string newCommandText) { var match = _ifStatementRegex.Match(commandText); newCommandText = commandText; if (!match.Success) return true; var not = match.Groups["not"] .Value; var sqlCheckCommand = match.Groups["sqlCheckCommand"] .Value; newCommandText = match.Groups["sqlCommand"] .Value; bool hasRows; using (var command = (JetCommand)((ICloneable)this).Clone()) { command.CommandText = sqlCheckCommand; using (var reader = command.ExecuteReader()) { hasRows = reader.HasRows; } } if (!string.IsNullOrWhiteSpace(not)) return !hasRows; return hasRows; } private void FixParameters() { var parameters = InnerCommand.Parameters; if (parameters.Count == 0) return; foreach (DbParameter parameter in parameters) { /*if (parameter.Value is TimeSpan ts) parameter.Value = JetConfiguration.TimeSpanOffset + ts;*/ if (parameter.Value is DateTime dt) { // Hack: https://github.com/fsprojects/SQLProvider/issues/191 parameter.Value = new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Kind); } } } protected virtual void FixupGlobalVariables() { var commandText = InnerCommand.CommandText; commandText = FixupIdentity(commandText); commandText = FixupRowCount(commandText); InnerCommand.CommandText = commandText; } protected virtual string FixupIdentity(string commandText) => FixupGlobalVariablePlaceholder( commandText, "@@identity", (outerCommand, placeholder) => { var command = (DbCommand)((ICloneable)outerCommand.InnerCommand).Clone(); command.CommandText = $"SELECT {placeholder}"; command.Parameters.Clear(); var identityValue = Convert.ToInt32(command.ExecuteScalar()); LogHelper.ShowInfo($"{placeholder} = {identityValue}"); return identityValue; }); protected virtual string FixupRowCount(string commandText) => FixupGlobalVariablePlaceholder(commandText, "@@rowcount", (outerCommand, placeholder) => outerCommand._connection.RowCount); protected virtual string FixupGlobalVariablePlaceholder(string commandText, string placeholder, Func valueFactory) where T : struct { var parser = new JetCommandParser(commandText); var globalVariableIndices = parser.GetStateIndices('$').Reverse(); var placeholderValue = new Lazy(() => valueFactory(this, placeholder)); var newCommandText = new StringBuilder(commandText); foreach (var globalVariableIndex in globalVariableIndices) { if (commandText.IndexOf(placeholder, globalVariableIndex, placeholder.Length, StringComparison.OrdinalIgnoreCase) > -1) { newCommandText.Remove(globalVariableIndex, placeholder.Length); newCommandText.Insert(globalVariableIndex, placeholderValue.Value); } } return newCommandText.ToString(); } private void InlineTopParameters() { // We inline all TOP clause parameters of all SELECT statements, because Jet does not support parameters // in TOP clauses. var parameters = InnerCommand.Parameters.Cast() .ToList(); if (parameters.Count > 0) { var lastCommandText = InnerCommand.CommandText; var commandText = lastCommandText; while ((commandText = _topMultiParameterRegularExpression.Replace( lastCommandText, match => { var first = match.Groups["first"]; var sec = match.Groups["sec"]; var sp = Convert.ToInt32(ExtractParameter(commandText, sec.Index, parameters).Value); var fp = Convert.ToInt32(ExtractParameter(commandText, first.Index, parameters).Value); var total = fp + sp; return total.ToString(); }, 1)) != lastCommandText) { lastCommandText = commandText; } while ((commandText = _topParameterRegularExpression.Replace( lastCommandText, match => Convert.ToInt32( ExtractParameter(commandText, match.Index, parameters) .Value) .ToString(), 1)) != lastCommandText) { lastCommandText = commandText; } while ((commandText = _topMultiArgumentRegularExpression.Replace( lastCommandText, match => { var first = match.Groups["first"]; var sec = match.Groups["sec"]; return (Convert.ToInt32(first.Value) + Convert.ToInt32(sec.Value)).ToString(); }, 1)) != lastCommandText) { lastCommandText = commandText; } InnerCommand.CommandText = commandText; InnerCommand.Parameters.Clear(); InnerCommand.Parameters.AddRange(parameters.ToArray()); } } private void ModifyOuterSelectTopValueForOuterSelectSkipEmulationViaDataReader() { // We modify the TOP clause parameter of the outer most SELECT statement if a SKIP clause was also // specified, because Jet does not support skipping records at all, but we can optionally emulate skipping // behavior for the outer most SELECT statement by controlling how the records are being fetched in // JetDataReader. if (_outerSelectSkipEmulationViaDataReaderSkipCount > 0) { InnerCommand.CommandText = _outerSelectTopValueRegularExpression.Replace( InnerCommand.CommandText, match => (int.Parse(match.Value) + _outerSelectSkipEmulationViaDataReaderSkipCount).ToString()); } } private void PrepareOuterSelectSkipEmulationViaDataReader() { // We inline the SKIP clause parameter of the outer most SELECT statement, because Jet does not support // skipping records at all, but we can optionally emulate skipping behavior for the outer most SELECT // statement by controlling how the records are being fetched in JetDataReader. var match = _outerSelectSkipValueOrParameterRegularExpression.Match(InnerCommand.CommandText); if (!match.Success) { return; } var skipValueOrParameter = match.Groups["SkipValueOrParameter"]; if (IsParameter(skipValueOrParameter.Value)) { var parameters = InnerCommand.Parameters.Cast() .ToList(); if (parameters.Count <= 0) { throw new InvalidOperationException($@"Cannot find ""{skipValueOrParameter.Value}"" parameter for SKIP clause."); } var parameter = ExtractParameter(InnerCommand.CommandText, match.Index, parameters); _outerSelectSkipEmulationViaDataReaderSkipCount = Convert.ToInt32(parameter.Value); InnerCommand.Parameters.Clear(); InnerCommand.Parameters.AddRange(parameters.ToArray()); } else { _outerSelectSkipEmulationViaDataReaderSkipCount = int.Parse(skipValueOrParameter.Value); } InnerCommand.CommandText = InnerCommand.CommandText.Remove(match.Index, match.Length); } protected virtual bool IsParameter(string fragment) => fragment.Equals("?") || fragment.Length >= 2 && fragment[0] == '@' && fragment[1] != '@'; protected virtual DbParameter ExtractParameter(string commandText, int count, List parameters) { var indices = GetParameterIndices(commandText.Substring(0, count)); var parameter = parameters[indices.Count]; parameters.RemoveAt(indices.Count); return parameter; } protected virtual void ExpandParameters() { if (_createProcedureExpression.IsMatch(InnerCommand.CommandText)) { return; } var indices = GetParameterIndices(InnerCommand.CommandText); if (indices.Count <= 0) { return; } var placeholders = GetParameterPlaceholders(InnerCommand.CommandText, indices); if (placeholders.All(t => t.Name.StartsWith("@"))) { MatchParametersAndPlaceholders(placeholders); if (JetConnection.GetDataAccessProviderType(_connection.DataAccessProviderFactory) == DataAccessProviderType.Odbc) { foreach (var placeholder in placeholders.Reverse()) { InnerCommand.CommandText = InnerCommand.CommandText .Remove(placeholder.Index, placeholder.Name.Length) .Insert(placeholder.Index, "?"); } } InnerCommand.Parameters.Clear(); InnerCommand.Parameters.AddRange( placeholders.Select(p => p.Parameter) .ToArray()); } else if (placeholders.All(t => t.Name == "?")) { throw new InvalidOperationException("Parameter placeholder count does not match parameter count."); } else { throw new InvalidOperationException("Inconsistent parameter placeholder naming used."); } } protected virtual void MatchParametersAndPlaceholders(IReadOnlyList placeholders) { var unusedParameters = InnerCommand.Parameters .Cast() .ToList(); foreach (var placeholder in placeholders) { var parameter = unusedParameters .FirstOrDefault(p => placeholder.Name.Equals(p.ParameterName, StringComparison.Ordinal)); if (parameter == null) { parameter = unusedParameters .FirstOrDefault(p => !p.ParameterName.StartsWith('@') && placeholder.Name.Substring(1).Equals(p.ParameterName, StringComparison.Ordinal)); } if (parameter != null) { placeholder.Parameter = parameter; unusedParameters.Remove(parameter); } else { parameter = placeholders .FirstOrDefault(p => placeholder.Name.Equals(p.Name, StringComparison.Ordinal)) ?.Parameter; if (parameter == null) { throw new InvalidOperationException($"Cannot find parameter with same name as parameter placeholder \"{placeholder.Name}\"."); } var newParameter = (DbParameter)(parameter as ICloneable)?.Clone(); if (newParameter == null) { throw new InvalidOperationException($"Cannot clone parameter \"{parameter.ParameterName}\"."); } placeholder.Parameter = newParameter; } } } protected virtual IReadOnlyList GetParameterPlaceholders(string commandText, IEnumerable indices) { var placeholders = new List(); foreach (var index in indices) { var match = Regex.Match(commandText.Substring(index), @"^(?:\?|@\w+)"); if (!match.Success) { throw new InvalidOperationException("Invalid parameter placeholder found."); } placeholders.Add(new ParameterPlaceholder { Index = index, Name = match.Value }); } return placeholders.AsReadOnly(); } protected virtual IReadOnlyList GetParameterIndices(string sqlFragment) => new JetCommandParser(sqlFragment) .GetStateIndices(new[] { '@', '?' }); /// /// Creates a prepared (or compiled) version of the command on the data source /// public override void Prepare() { if (Connection == null) throw new InvalidOperationException(Messages.PropertyNotInitialized(nameof(Connection))); if (Connection.State != ConnectionState.Open) throw new InvalidOperationException(Messages.CannotCallMethodInThisConnectionState(nameof(Prepare), ConnectionState.Open, Connection.State)); InnerCommand.Connection = _connection.InnerConnection; // OLE DB forces us to use an existing active transaction, if one is available. InnerCommand.Transaction = _transaction?.WrappedTransaction ?? _connection.ActiveTransaction?.WrappedTransaction; InnerCommand.Prepare(); } /// /// Gets or sets how command results are applied to the DataRow when used by the Update method of a DbDataAdapter. /// /// /// The updated row source. /// public override UpdateRowSource UpdatedRowSource { get => InnerCommand.UpdatedRowSource; set => InnerCommand.UpdatedRowSource = value; } /// /// Clones this instance. /// /// The created object object ICloneable.Clone() => new JetCommand(this); protected class ParameterPlaceholder { public int Index { get; set; } public string Name { get; set; } public DbParameter Parameter { get; set; } } } }