mirror of
https://github.com/ckaczor/sqltoolsservice.git
synced 2026-01-14 09:59:48 -05:00
It's an overhaul of the Save As mechanism to utilize the file reader/writer classes to better align with the patterns laid out by the rest of the query execution. Why make this change? This change makes our code base more uniform and adherent to the patterns/paradigms we've set up. This change also helps with the encapsulation of the classes to "separate the concerns" of each component of the save as function.
* Replumbing the save as execution to pass the call down the query stack as QueryExecutionService->Query->Batch->ResultSet
* Each layer performs it's own parameter checking
* QueryExecutionService checks if the query exists
* Query checks if the batch exists
* Batch checks if the result set exists
* ResultSet checks if the row counts are valid and if the result set has been executed
* Success/Failure delegates are passed down the chain as well
* Determination of whether a save request is a "selection" moved to the SaveResultsRequest class to eliminate duplication of code and creation of utility classes
* Making the IFileStream* classes more generic
* Removing the requirements of max characters to store from the GetWriter method, and moving it into the constructor for the temporary buffer writer - the values have been moved to the settings and given defaults
* Removing the individual type writers from IFileStreamWriter
* Removing the individual type writers from IFIleStreamReader
* Adding a new overload for WriteRow to IFileStreamWriter that will write out data, given a row's worth of data and the list of columns
* Creating a new IFileStreamFactory that creates a reader/writer pair for reading from the temporary files and writing to CSV files
* Creating a new IFileStreamFactory that creates a reader/writer pair for reading from the temporary files and writing to JSON files
* Dramatically simplified the CSV encoding functionality
* Removed duplicated logic for saving in different types and condensed down to a single chain that only differs based on what type of factory is provided
* Removing the logic for managing the list of save as tasks, since the ResultSet now performs the actual saving work, there's no real need to expose the internals of the ResultSet
* Adding new strings to the sr.strings file for save as error messages
* Completely rewriting the unit tests for the save as mechanism. Very fine grained unit tests now that should cover majority of cases (aside from race conditions)
* Refactoring maxchars params into settings and out of file stream factory
* Removing write*/read* methods from file stream readers/writers
* Migrating the CSV save as to the resultset
* Tweaks to unit testing to eliminate writing files to disk
* WIP, moving to a base class for save results writers
* Everything is wired up and compiles
* Adding unit tests for CSV encoding
* Adding unit tests for CSV and Json writers
* Adding tests to the result set for saving
* Refactor to throw exceptions on errors instead of calling failure handler
* Unit tests for batch/query argument in range
* Unit tests
* Adding service integration unit tests
* Final polish, copyright notices, etc
* Adding NULL logic
* Fixing issue of unicode to utf8
* Fixing issues as per @kburtram code review comments
* Adding files that got broken?
395 lines
14 KiB
C#
395 lines
14 KiB
C#
//
|
|
// Copyright (c) Microsoft. All rights reserved.
|
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
|
|
using System;
|
|
using System.Data.Common;
|
|
using System.Data.SqlClient;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.SqlServer.Management.SqlParser.Parser;
|
|
using Microsoft.SqlTools.ServiceLayer.Connection;
|
|
using Microsoft.SqlTools.ServiceLayer.Connection.ReliableConnection;
|
|
using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts;
|
|
using Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage;
|
|
using Microsoft.SqlTools.ServiceLayer.SqlContext;
|
|
using Microsoft.SqlTools.ServiceLayer.Utility;
|
|
|
|
namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
|
{
|
|
/// <summary>
|
|
/// Internal representation of an active query
|
|
/// </summary>
|
|
public class Query : IDisposable
|
|
{
|
|
/// <summary>
|
|
/// "Error" code produced by SQL Server when the database context (name) for a connection changes.
|
|
/// </summary>
|
|
private const int DatabaseContextChangeErrorNumber = 5701;
|
|
|
|
#region Member Variables
|
|
|
|
/// <summary>
|
|
/// Cancellation token source, used for cancelling async db actions
|
|
/// </summary>
|
|
private readonly CancellationTokenSource cancellationSource;
|
|
|
|
/// <summary>
|
|
/// For IDisposable implementation, whether or not this object has been disposed
|
|
/// </summary>
|
|
private bool disposed;
|
|
|
|
/// <summary>
|
|
/// The connection info associated with the file editor owner URI, used to create a new
|
|
/// connection upon execution of the query
|
|
/// </summary>
|
|
private readonly ConnectionInfo editorConnection;
|
|
|
|
/// <summary>
|
|
/// Whether or not the execute method has been called for this query
|
|
/// </summary>
|
|
private bool hasExecuteBeenCalled;
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Constructor for a query
|
|
/// </summary>
|
|
/// <param name="queryText">The text of the query to execute</param>
|
|
/// <param name="connection">The information of the connection to use to execute the query</param>
|
|
/// <param name="settings">Settings for how to execute the query, from the user</param>
|
|
/// <param name="outputFactory">Factory for creating output files</param>
|
|
public Query(string queryText, ConnectionInfo connection, QueryExecutionSettings settings, IFileStreamFactory outputFactory)
|
|
{
|
|
// Sanity check for input
|
|
Validate.IsNotNullOrEmptyString(nameof(queryText), queryText);
|
|
Validate.IsNotNull(nameof(connection), connection);
|
|
Validate.IsNotNull(nameof(settings), settings);
|
|
Validate.IsNotNull(nameof(outputFactory), outputFactory);
|
|
|
|
// Initialize the internal state
|
|
QueryText = queryText;
|
|
editorConnection = connection;
|
|
cancellationSource = new CancellationTokenSource();
|
|
|
|
// Process the query into batches
|
|
ParseResult parseResult = Parser.Parse(queryText, new ParseOptions
|
|
{
|
|
BatchSeparator = settings.BatchSeparator
|
|
});
|
|
// NOTE: We only want to process batches that have statements (ie, ignore comments and empty lines)
|
|
var batchSelection = parseResult.Script.Batches
|
|
.Where(batch => batch.Statements.Count > 0)
|
|
.Select((batch, index) =>
|
|
new Batch(batch.Sql,
|
|
new SelectionData(
|
|
batch.StartLocation.LineNumber - 1,
|
|
batch.StartLocation.ColumnNumber - 1,
|
|
batch.EndLocation.LineNumber - 1,
|
|
batch.EndLocation.ColumnNumber - 1),
|
|
index, outputFactory));
|
|
Batches = batchSelection.ToArray();
|
|
}
|
|
|
|
#region Events
|
|
|
|
/// <summary>
|
|
/// Event to be called when a batch is completed.
|
|
/// </summary>
|
|
public event Batch.BatchAsyncEventHandler BatchCompleted;
|
|
|
|
/// <summary>
|
|
/// Event to be called when a batch starts execution.
|
|
/// </summary>
|
|
public event Batch.BatchAsyncEventHandler BatchStarted;
|
|
|
|
/// <summary>
|
|
/// Delegate type for callback when a query connection fails
|
|
/// </summary>
|
|
/// <param name="message">Error message for the failing query</param>
|
|
public delegate Task QueryAsyncErrorEventHandler(string message);
|
|
|
|
/// <summary>
|
|
/// Callback for when the query has completed successfully
|
|
/// </summary>
|
|
public event QueryAsyncEventHandler QueryCompleted;
|
|
|
|
/// <summary>
|
|
/// Callback for when the query has failed
|
|
/// </summary>
|
|
public event QueryAsyncEventHandler QueryFailed;
|
|
|
|
/// <summary>
|
|
/// Callback for when the query connection has failed
|
|
/// </summary>
|
|
public event QueryAsyncErrorEventHandler QueryConnectionException;
|
|
|
|
/// <summary>
|
|
/// Event to be called when a resultset has completed.
|
|
/// </summary>
|
|
public event ResultSet.ResultSetAsyncEventHandler ResultSetCompleted;
|
|
|
|
#endregion
|
|
|
|
#region Properties
|
|
|
|
/// <summary>
|
|
/// Delegate type for callback when a query completes or fails
|
|
/// </summary>
|
|
/// <param name="q">The query that completed</param>
|
|
public delegate Task QueryAsyncEventHandler(Query q);
|
|
|
|
/// <summary>
|
|
/// The batches underneath this query
|
|
/// </summary>
|
|
internal Batch[] Batches { get; set; }
|
|
|
|
/// <summary>
|
|
/// The summaries of the batches underneath this query
|
|
/// </summary>
|
|
public BatchSummary[] BatchSummaries
|
|
{
|
|
get
|
|
{
|
|
if (!HasExecuted)
|
|
{
|
|
throw new InvalidOperationException("Query has not been executed.");
|
|
}
|
|
return Batches.Select(b => b.Summary).ToArray();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Storage for the async task for execution. Set as internal in order to await completion
|
|
/// in unit tests.
|
|
/// </summary>
|
|
internal Task ExecutionTask { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Whether or not the query has completed executed, regardless of success or failure
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Don't touch the setter unless you're doing unit tests!
|
|
/// </remarks>
|
|
public bool HasExecuted
|
|
{
|
|
get { return Batches.Length == 0 ? hasExecuteBeenCalled : Batches.All(b => b.HasExecuted); }
|
|
internal set
|
|
{
|
|
hasExecuteBeenCalled = value;
|
|
foreach (var batch in Batches)
|
|
{
|
|
batch.HasExecuted = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The text of the query to execute
|
|
/// </summary>
|
|
public string QueryText { get; set; }
|
|
|
|
#endregion
|
|
|
|
#region Public Methods
|
|
|
|
/// <summary>
|
|
/// Cancels the query by issuing the cancellation token
|
|
/// </summary>
|
|
public void Cancel()
|
|
{
|
|
// Make sure that the query hasn't completed execution
|
|
if (HasExecuted)
|
|
{
|
|
throw new InvalidOperationException(SR.QueryServiceCancelAlreadyCompleted);
|
|
}
|
|
|
|
// Issue the cancellation token for the query
|
|
cancellationSource.Cancel();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Launches the asynchronous process for executing the query
|
|
/// </summary>
|
|
public void Execute()
|
|
{
|
|
ExecutionTask = Task.Run(ExecuteInternal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a subset of the result sets
|
|
/// </summary>
|
|
/// <param name="batchIndex">The index for selecting the batch item</param>
|
|
/// <param name="resultSetIndex">The index for selecting the result set</param>
|
|
/// <param name="startRow">The starting row of the results</param>
|
|
/// <param name="rowCount">How many rows to retrieve</param>
|
|
/// <returns>A subset of results</returns>
|
|
public Task<ResultSetSubset> GetSubset(int batchIndex, int resultSetIndex, int startRow, int rowCount)
|
|
{
|
|
// Sanity check to make sure that the batch is within bounds
|
|
if (batchIndex < 0 || batchIndex >= Batches.Length)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(batchIndex), SR.QueryServiceSubsetBatchOutOfRange);
|
|
}
|
|
|
|
return Batches[batchIndex].GetSubset(resultSetIndex, startRow, rowCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves the requested results to a file format of the user's choice
|
|
/// </summary>
|
|
/// <param name="saveParams">Parameters for the save as request</param>
|
|
/// <param name="fileFactory">
|
|
/// Factory for creating the reader/writer pair for the requested output format
|
|
/// </param>
|
|
/// <param name="successHandler">Delegate to call when the request completes successfully</param>
|
|
/// <param name="failureHandler">Delegate to call if the request fails</param>
|
|
public void SaveAs(SaveResultsRequestParams saveParams, IFileStreamFactory fileFactory,
|
|
ResultSet.SaveAsAsyncEventHandler successHandler, ResultSet.SaveAsFailureAsyncEventHandler failureHandler)
|
|
{
|
|
// Sanity check to make sure that the batch is within bounds
|
|
if (saveParams.BatchIndex < 0 || saveParams.BatchIndex >= Batches.Length)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(saveParams.BatchIndex), SR.QueryServiceSubsetBatchOutOfRange);
|
|
}
|
|
|
|
Batches[saveParams.BatchIndex].SaveAs(saveParams, fileFactory, successHandler, failureHandler);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Private Helpers
|
|
|
|
/// <summary>
|
|
/// Executes this query asynchronously and collects all result sets
|
|
/// </summary>
|
|
private async Task ExecuteInternal()
|
|
{
|
|
// Mark that we've internally executed
|
|
hasExecuteBeenCalled = true;
|
|
|
|
// Don't actually execute if there aren't any batches to execute
|
|
if (Batches.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Open up a connection for querying the database
|
|
string connectionString = ConnectionService.BuildConnectionString(editorConnection.ConnectionDetails);
|
|
// TODO: Don't create a new connection every time, see TFS #834978
|
|
using (DbConnection conn = editorConnection.Factory.CreateSqlConnection(connectionString))
|
|
{
|
|
try
|
|
{
|
|
await conn.OpenAsync();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
this.HasExecuted = true;
|
|
if (QueryConnectionException != null)
|
|
{
|
|
await QueryConnectionException(exception.Message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
ReliableSqlConnection sqlConn = conn as ReliableSqlConnection;
|
|
if (sqlConn != null)
|
|
{
|
|
// Subscribe to database informational messages
|
|
sqlConn.GetUnderlyingConnection().InfoMessage += OnInfoMessage;
|
|
}
|
|
|
|
try
|
|
{
|
|
// We need these to execute synchronously, otherwise the user will be very unhappy
|
|
foreach (Batch b in Batches)
|
|
{
|
|
b.BatchStart += BatchStarted;
|
|
b.BatchCompletion += BatchCompleted;
|
|
b.ResultSetCompletion += ResultSetCompleted;
|
|
await b.Execute(conn, cancellationSource.Token);
|
|
}
|
|
|
|
// Call the query execution callback
|
|
if (QueryCompleted != null)
|
|
{
|
|
await QueryCompleted(this);
|
|
}
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Call the query failure callback
|
|
if (QueryFailed != null)
|
|
{
|
|
await QueryFailed(this);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (sqlConn != null)
|
|
{
|
|
// Subscribe to database informational messages
|
|
sqlConn.GetUnderlyingConnection().InfoMessage -= OnInfoMessage;
|
|
}
|
|
}
|
|
|
|
// TODO: Close connection after eliminating using statement for above TODO
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handler for database messages during query execution
|
|
/// </summary>
|
|
private void OnInfoMessage(object sender, SqlInfoMessageEventArgs args)
|
|
{
|
|
SqlConnection conn = sender as SqlConnection;
|
|
if (conn == null)
|
|
{
|
|
throw new InvalidOperationException(SR.QueryServiceMessageSenderNotSql);
|
|
}
|
|
|
|
foreach (SqlError error in args.Errors)
|
|
{
|
|
// Did the database context change (error code 5701)?
|
|
if (error.Number == DatabaseContextChangeErrorNumber)
|
|
{
|
|
ConnectionService.Instance.ChangeConnectionDatabaseContext(editorConnection.OwnerUri, conn.Database);
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region IDisposable Implementation
|
|
|
|
public void Dispose()
|
|
{
|
|
Dispose(true);
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
protected virtual void Dispose(bool disposing)
|
|
{
|
|
if (disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (disposing)
|
|
{
|
|
cancellationSource.Dispose();
|
|
foreach (Batch b in Batches)
|
|
{
|
|
b.Dispose();
|
|
}
|
|
}
|
|
|
|
disposed = true;
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|