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 readonly 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 _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); 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(); base.Dispose(disposing); #if DEBUG Interlocked.Decrement(ref _activeObjectsCount); #endif } internal DbCommand InnerCommand { get; } /// /// 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(); var commands = SplitCommands(); for (var i = 0; i < commands.Count - 1; i++) { commands[i] .ExecuteNonQueryCore(); } return commands[commands.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(); return SplitCommands() .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 (JetRenameHandling.TryDatabaseOperation(Connection.ConnectionString, 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(); return _connection.RowCount = InnerCommand.ExecuteNonQuery(); } /// /// 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(); var commands = SplitCommands(); for (var i = 0; i < commands.Count - 1; i++) { commands[i] .ExecuteNonQueryCore(); } return commands[commands.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 IReadOnlyList SplitCommands() { // 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 { 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 { commands.Add(this); } return commands.AsReadOnly(); } 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('$'); 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 = _topParameterRegularExpression.Replace( lastCommandText, match => Convert.ToInt32( ExtractParameter(commandText, match.Index, parameters) .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 = InnerCommand.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) { 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; } } } }