Clean-up the autocomplete SMO integration.

This commit is contained in:
Karl Burtram
2016-09-01 00:23:39 -07:00
parent 013498fc3d
commit 1332fd112e
11 changed files with 421 additions and 516 deletions

View File

@@ -6,10 +6,17 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.SqlServer.Management.SmoMetadataProvider;
using Microsoft.SqlServer.Management.SqlParser.Binder;
using Microsoft.SqlServer.Management.SqlParser.Intellisense;
using Microsoft.SqlServer.Management.SqlParser.MetadataProvider;
using Microsoft.SqlTools.ServiceLayer.Connection;
using Microsoft.SqlTools.ServiceLayer.Connection.Contracts;
using Microsoft.SqlTools.ServiceLayer.Hosting;
using Microsoft.SqlTools.ServiceLayer.Hosting.Protocol;
using Microsoft.SqlTools.ServiceLayer.LanguageServices.Contracts;
using Microsoft.SqlTools.ServiceLayer.SqlContext;
using Microsoft.SqlTools.ServiceLayer.Workspace;
using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts;
namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
@@ -40,19 +47,14 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
/// <summary>
/// Default, parameterless constructor.
/// TODO: Figure out how to make this truely singleton even with dependency injection for tests
/// Internal constructor for use in test cases only
/// </summary>
public AutoCompleteService()
internal AutoCompleteService()
{
}
#endregion
// Dictionary of unique intellisense caches for each Connection
private Dictionary<ConnectionSummary, IntellisenseCache> caches =
new Dictionary<ConnectionSummary, IntellisenseCache>(new ConnectionSummaryComparer());
private Object cachesLock = new Object(); // Used when we insert/remove something from the cache dictionary
private ConnectionService connectionService = null;
/// <summary>
@@ -77,6 +79,9 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
public void InitializeService(ServiceHost serviceHost)
{
// Register auto-complete request handler
serviceHost.SetRequestHandler(CompletionRequest.Type, HandleCompletionRequest);
// Register a callback for when a connection is created
ConnectionServiceInstance.RegisterOnConnectionTask(UpdateAutoCompleteCache);
@@ -84,12 +89,29 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
ConnectionServiceInstance.RegisterOnDisconnectTask(RemoveAutoCompleteCacheUriReference);
}
/// <summary>
/// Intellisense cache count access for testing.
/// </summary>
internal int GetCacheCount()
/// <summary>
/// Auto-complete completion provider request callback
/// </summary>
/// <param name="textDocumentPosition"></param>
/// <param name="requestContext"></param>
/// <returns></returns>
private static async Task HandleCompletionRequest(
TextDocumentPosition textDocumentPosition,
RequestContext<CompletionItem[]> requestContext)
{
return caches.Count;
// get the current list of completion items and return to client
var scriptFile = WorkspaceService<SqlToolsSettings>.Instance.Workspace.GetFile(
textDocumentPosition.TextDocument.Uri);
ConnectionInfo connInfo;
ConnectionService.Instance.TryFindConnection(
scriptFile.ClientFilePath,
out connInfo);
var completionItems = Instance.GetCompletionItems(
textDocumentPosition, scriptFile, connInfo);
await requestContext.SendResult(completionItems);
}
/// <summary>
@@ -99,47 +121,163 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
/// </summary>
public async Task RemoveAutoCompleteCacheUriReference(ConnectionSummary summary)
{
await Task.Run( () =>
{
lock(cachesLock)
{
IntellisenseCache cache;
if( caches.TryGetValue(summary, out cache) )
{
cache.ReferenceCount--;
await Task.FromResult(0);
// await Task.Run( () =>
// {
// lock(cachesLock)
// {
// AutoCompleteCache cache;
// if( caches.TryGetValue(summary, out cache) )
// {
// cache.ReferenceCount--;
// Remove unused caches
if( cache.ReferenceCount == 0 )
{
caches.Remove(summary);
}
}
}
});
// // Remove unused caches
// if( cache.ReferenceCount == 0 )
// {
// caches.Remove(summary);
// }
// }
// }
// });
}
/// <summary>
/// Update the cached autocomplete candidate list when the user connects to a database
/// </summary>
/// <param name="info"></param>
public async Task UpdateAutoCompleteCache(ConnectionInfo info)
{
if (info != null)
await Task.Run( () =>
{
IntellisenseCache cache;
lock(cachesLock)
if (!LanguageService.Instance.ScriptParseInfoMap.ContainsKey(info.OwnerUri))
{
if(!caches.TryGetValue(info.ConnectionDetails, out cache))
{
cache = new IntellisenseCache(info.Factory, info.ConnectionDetails);
caches[cache.DatabaseInfo] = cache;
}
cache.ReferenceCount++;
var srvConn = ConnectionService.GetServerConnection(info);
var displayInfoProvider = new MetadataDisplayInfoProvider();
var metadataProvider = SmoMetadataProvider.CreateConnectedProvider(srvConn);
var binder = BinderProvider.CreateBinder(metadataProvider);
LanguageService.Instance.ScriptParseInfoMap.Add(info.OwnerUri,
new ScriptParseInfo()
{
Binder = binder,
MetadataProvider = metadataProvider,
MetadataDisplayInfoProvider = displayInfoProvider
});
var scriptFile = WorkspaceService<SqlToolsSettings>.Instance.Workspace.GetFile(info.OwnerUri);
LanguageService.Instance.ParseAndBind(scriptFile, info);
}
await cache.UpdateCache();
});
}
/// <summary>
/// Find the position of the previous delimeter for autocomplete token replacement.
/// SQL Parser may have similar functionality in which case we'll delete this method.
/// </summary>
/// <param name="sql"></param>
/// <param name="startRow"></param>
/// <param name="startColumn"></param>
/// <returns></returns>
private int PositionOfPrevDelimeter(string sql, int startRow, int startColumn)
{
if (string.IsNullOrWhiteSpace(sql))
{
return 1;
}
int prevLineColumns = 0;
for (int i = 0; i < startRow; ++i)
{
while (sql[prevLineColumns] != '\n' && prevLineColumns < sql.Length)
{
++prevLineColumns;
}
++prevLineColumns;
}
startColumn += prevLineColumns;
if (startColumn - 1 < sql.Length)
{
while (--startColumn >= prevLineColumns)
{
if (sql[startColumn] == ' '
|| sql[startColumn] == '\t'
|| sql[startColumn] == '\n'
|| sql[startColumn] == '.'
|| sql[startColumn] == '+'
|| sql[startColumn] == '-'
|| sql[startColumn] == '*'
|| sql[startColumn] == '>'
|| sql[startColumn] == '<'
|| sql[startColumn] == '='
|| sql[startColumn] == '/'
|| sql[startColumn] == '%')
{
break;
}
}
}
return startColumn + 1 - prevLineColumns;
}
/// <summary>
/// Determines whether a reparse and bind is required to provide autocomplete
/// </summary>
/// <param name="info"></param>
/// <returns>TEMP: Currently hard-coded to false for perf</returns>
private bool RequiresReparse(ScriptParseInfo info)
{
return false;
}
/// <summary>
/// Converts a list of Declaration objects to CompletionItem objects
/// since VS Code expects CompletionItems but SQL Parser works with Declarations
/// </summary>
/// <param name="suggestions"></param>
/// <param name="cursorRow"></param>
/// <param name="cursorColumn"></param>
/// <returns></returns>
private CompletionItem[] ConvertDeclarationsToCompletionItems(
IEnumerable<Declaration> suggestions,
int row,
int startColumn,
int endColumn)
{
List<CompletionItem> completions = new List<CompletionItem>();
foreach (var autoCompleteItem in suggestions)
{
// convert the completion item candidates into CompletionItems
completions.Add(new CompletionItem()
{
Label = autoCompleteItem.Title,
Kind = CompletionItemKind.Keyword,
Detail = autoCompleteItem.Title,
Documentation = autoCompleteItem.Description,
TextEdit = new TextEdit
{
NewText = autoCompleteItem.Title,
Range = new Range
{
Start = new Position
{
Line = row,
Character = startColumn
},
End = new Position
{
Line = row,
Character = endColumn
}
}
}
});
}
return completions.ToArray();
}
/// <summary>
@@ -147,22 +285,48 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
/// This method does not await cache builds since it expects to return quickly
/// </summary>
/// <param name="textDocumentPosition"></param>
public CompletionItem[] GetCompletionItems(TextDocumentPosition textDocumentPosition)
public CompletionItem[] GetCompletionItems(
TextDocumentPosition textDocumentPosition,
ScriptFile scriptFile,
ConnectionInfo connInfo)
{
// Try to find a cache for the document's backing connection (if available)
// If we have a connection but no cache, we don't care - assuming the OnConnect and OnDisconnect listeners
// behave well, there should be a cache for any actively connected document. This also helps skip documents
// that are not backed by a SQL connection
ConnectionInfo info;
IntellisenseCache cache;
if (ConnectionServiceInstance.TryFindConnection(textDocumentPosition.TextDocument.Uri, out info)
&& caches.TryGetValue((ConnectionSummary)info.ConnectionDetails, out cache))
string filePath = textDocumentPosition.TextDocument.Uri;
// Take a reference to the list at a point in time in case we update and replace the list
if (connInfo == null
|| !LanguageService.Instance.ScriptParseInfoMap.ContainsKey(textDocumentPosition.TextDocument.Uri))
{
return cache.GetAutoCompleteItems(textDocumentPosition).ToArray();
return new CompletionItem[0];
}
return new CompletionItem[0];
// reparse and bind the SQL statement if needed
var scriptParseInfo = LanguageService.Instance.ScriptParseInfoMap[textDocumentPosition.TextDocument.Uri];
if (RequiresReparse(scriptParseInfo))
{
LanguageService.Instance.ParseAndBind(scriptFile, connInfo);
}
if (scriptParseInfo.ParseResult == null)
{
return new CompletionItem[0];
}
// get the completion list from SQL Parser
var suggestions = Resolver.FindCompletions(
scriptParseInfo.ParseResult,
textDocumentPosition.Position.Line + 1,
textDocumentPosition.Position.Character + 1,
scriptParseInfo.MetadataDisplayInfoProvider);
// convert the suggestion list to the VS Code format
return ConvertDeclarationsToCompletionItems(
suggestions,
textDocumentPosition.Position.Line,
PositionOfPrevDelimeter(
scriptFile.Contents,
textDocumentPosition.Position.Line,
textDocumentPosition.Position.Character),
textDocumentPosition.Position.Character);
}
}
}

View File

@@ -1,135 +0,0 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using System.Collections.Generic;
using System.Data;
using System.Data.Common;
using System.Threading.Tasks;
using Microsoft.SqlServer.Management.SqlParser.Intellisense;
using Microsoft.SqlTools.ServiceLayer.Connection;
using Microsoft.SqlTools.ServiceLayer.Connection.Contracts;
using Microsoft.SqlTools.ServiceLayer.LanguageServices.Contracts;
using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts;
namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
{
internal class IntellisenseCache
{
/// <summary>
/// connection used to query for intellisense info
/// </summary>
private DbConnection connection;
/// <summary>
/// Number of documents (URI's) that are using the cache for the same database.
/// The autocomplete service uses this to remove unreferenced caches.
/// </summary>
public int ReferenceCount { get; set; }
public IntellisenseCache(ISqlConnectionFactory connectionFactory, ConnectionDetails connectionDetails)
{
ReferenceCount = 0;
DatabaseInfo = connectionDetails.Clone();
// TODO error handling on this. Intellisense should catch or else the service should handle
connection = connectionFactory.CreateSqlConnection(ConnectionService.BuildConnectionString(connectionDetails));
connection.Open();
}
/// <summary>
/// Used to identify a database for which this cache is used
/// </summary>
public ConnectionSummary DatabaseInfo
{
get;
private set;
}
/// <summary>
/// Gets the current autocomplete candidate list
/// </summary>
public IEnumerable<string> AutoCompleteList { get; private set; }
public async Task UpdateCache()
{
DbCommand command = connection.CreateCommand();
command.CommandText = "SELECT name FROM sys.tables";
command.CommandTimeout = 15;
command.CommandType = CommandType.Text;
var reader = await command.ExecuteReaderAsync();
List<string> results = new List<string>();
while (await reader.ReadAsync())
{
results.Add(reader[0].ToString());
}
AutoCompleteList = results;
await Task.FromResult(0);
}
public List<CompletionItem> GetAutoCompleteItems(TextDocumentPosition textDocumentPosition)
{
List<CompletionItem> completions = new List<CompletionItem>();
// Take a reference to the list at a point in time in case we update and replace the list
//var suggestions = AutoCompleteList;
if (!LanguageService.Instance.ScriptParseInfoMap.ContainsKey(textDocumentPosition.TextDocument.Uri))
{
return completions;
}
var scriptParseInfo = LanguageService.Instance.ScriptParseInfoMap[textDocumentPosition.TextDocument.Uri];
var suggestions = Resolver.FindCompletions(
scriptParseInfo.ParseResult,
textDocumentPosition.Position.Line + 1,
textDocumentPosition.Position.Character + 1,
scriptParseInfo.MetadataDisplayInfoProvider);
int i = 0;
// the completion list will be null is user not connected to server
if (this.AutoCompleteList != null)
{
foreach (var autoCompleteItem in suggestions)
{
// convert the completion item candidates into CompletionItems
completions.Add(new CompletionItem()
{
Label = autoCompleteItem.Title,
Kind = CompletionItemKind.Keyword,
Detail = autoCompleteItem.Title + " details",
Documentation = autoCompleteItem.Title + " documentation",
TextEdit = new TextEdit
{
NewText = autoCompleteItem.Title,
Range = new Range
{
Start = new Position
{
Line = textDocumentPosition.Position.Line,
Character = textDocumentPosition.Position.Character
},
End = new Position
{
Line = textDocumentPosition.Position.Line,
Character = textDocumentPosition.Position.Character + 5
}
}
}
});
// only show 50 items
if (++i == 50)
{
break;
}
}
}
return completions;
}
}
}

View File

@@ -5,7 +5,6 @@
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.SqlTools.EditorServices.Utility;
@@ -20,9 +19,8 @@ using Microsoft.SqlServer.Management.SqlParser.Parser;
using Location = Microsoft.SqlTools.ServiceLayer.Workspace.Contracts.Location;
using Microsoft.SqlTools.ServiceLayer.Connection;
using Microsoft.SqlServer.Management.SqlParser.Binder;
using Microsoft.SqlServer.Management.SmoMetadataProvider;
using Microsoft.SqlServer.Management.Common;
using Microsoft.SqlServer.Management.SqlParser.MetadataProvider;
using Microsoft.SqlServer.Management.SqlParser;
namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
{
@@ -40,17 +38,6 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
private Lazy<Dictionary<string, ScriptParseInfo>> scriptParseInfoMap
= new Lazy<Dictionary<string, ScriptParseInfo>>(() => new Dictionary<string, ScriptParseInfo>());
internal class ScriptParseInfo
{
public IBinder Binder { get; set; }
public ParseResult ParseResult { get; set; }
public SmoMetadataProvider MetadataProvider { get; set; }
public MetadataDisplayInfoProvider MetadataDisplayInfoProvider { get; set; }
}
internal Dictionary<string, ScriptParseInfo> ScriptParseInfoMap
{
get
@@ -93,21 +80,20 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
/// <returns></returns>
private SqlToolsContext Context { get; set; }
/// <summary>
/// The cached parse result from previous incremental parse
/// </summary>
private ParseResult prevParseResult;
#endregion
#region Public Methods
/// <summary>
/// Initializes the Language Service instance
/// </summary>
/// <param name="serviceHost"></param>
/// <param name="context"></param>
public void InitializeService(ServiceHost serviceHost, SqlToolsContext context)
{
// Register the requests that this service will handle
serviceHost.SetRequestHandler(DefinitionRequest.Type, HandleDefinitionRequest);
serviceHost.SetRequestHandler(ReferencesRequest.Type, HandleReferencesRequest);
serviceHost.SetRequestHandler(CompletionRequest.Type, HandleCompletionRequest);
serviceHost.SetRequestHandler(CompletionResolveRequest.Type, HandleCompletionResolveRequest);
serviceHost.SetRequestHandler(SignatureHelpRequest.Type, HandleSignatureHelpRequest);
serviceHost.SetRequestHandler(DocumentHighlightRequest.Type, HandleDocumentHighlightRequest);
@@ -135,52 +121,69 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
Context = context;
}
/// <summary>
/// Parses the SQL text and binds it to the SMO metadata provider if connected
/// </summary>
/// <param name="filePath"></param>
/// <param name="sqlText"></param>
/// <returns></returns>
public ParseResult ParseAndBind(ScriptFile scriptFile, ConnectionInfo connInfo)
{
ScriptParseInfo parseInfo = null;
if (this.ScriptParseInfoMap.ContainsKey(scriptFile.ClientFilePath))
{
parseInfo = this.ScriptParseInfoMap[scriptFile.ClientFilePath];
}
// parse current SQL file contents to retrieve a list of errors
ParseOptions parseOptions = new ParseOptions();
ParseResult parseResult = Parser.IncrementalParse(
scriptFile.Contents,
parseInfo != null ? parseInfo.ParseResult : null,
parseOptions);
// save previous result for next incremental parse
if (parseInfo != null)
{
parseInfo.ParseResult = parseResult;
}
if (connInfo != null)
{
try
{
List<ParseResult> parseResults = new List<ParseResult>();
parseResults.Add(parseResult);
parseInfo.Binder.Bind(
parseResults,
connInfo.ConnectionDetails.DatabaseName,
BindMode.Batch);
}
catch (ConnectionException)
{
Logger.Write(LogLevel.Error, "Hit connection exception while binding - disposing binder object...");
}
catch (SqlParserInternalBinderError)
{
Logger.Write(LogLevel.Error, "Hit connection exception while binding - disposing binder object...");
}
}
return parseResult;
}
/// <summary>
/// Gets a list of semantic diagnostic marks for the provided script file
/// </summary>
/// <param name="scriptFile"></param>
public ScriptFileMarker[] GetSemanticMarkers(ScriptFile scriptFile)
{
// parse current SQL file contents to retrieve a list of errors
ParseOptions parseOptions = new ParseOptions();
ParseResult parseResult = Parser.IncrementalParse(
scriptFile.Contents,
prevParseResult,
parseOptions);
// save previous result for next incremental parse
this.prevParseResult = parseResult;
ConnectionInfo connInfo;
bool isConnected = ConnectionService.Instance.TryFindConnection(scriptFile.ClientFilePath, out connInfo);
if (isConnected)
{
if (!this.ScriptParseInfoMap.ContainsKey(scriptFile.ClientFilePath))
{
var srvConn = ConnectionService.GetServerConnection(connInfo);
var metadataProvider = SmoMetadataProvider.CreateConnectedProvider(srvConn);
var binder = BinderProvider.CreateBinder(metadataProvider);
var displayInfoProvider = new MetadataDisplayInfoProvider();
this.ScriptParseInfoMap.Add(scriptFile.ClientFilePath,
new ScriptParseInfo()
{
Binder = binder,
ParseResult = parseResult,
MetadataProvider = metadataProvider,
MetadataDisplayInfoProvider = displayInfoProvider
});
}
ScriptParseInfo parseInfo = this.ScriptParseInfoMap[scriptFile.ClientFilePath];
parseInfo.ParseResult = parseResult;
List<ParseResult> parseResults = new List<ParseResult>();
parseResults.Add(parseResult);
parseInfo.Binder.Bind(
parseResults,
connInfo.ConnectionDetails.DatabaseName,
BindMode.Batch);
}
ConnectionService.Instance.TryFindConnection(
scriptFile.ClientFilePath,
out connInfo);
var parseResult = ParseAndBind(scriptFile, connInfo);
// build a list of SQL script file markers from the errors
List<ScriptFileMarker> markers = new List<ScriptFileMarker>();
@@ -226,17 +229,6 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
await Task.FromResult(true);
}
private static async Task HandleCompletionRequest(
TextDocumentPosition textDocumentPosition,
RequestContext<CompletionItem[]> requestContext)
{
Logger.Write(LogLevel.Verbose, "HandleCompletionRequest");
// get the current list of completion items and return to client
var completionItems = AutoCompleteService.Instance.GetCompletionItems(textDocumentPosition);
await requestContext.SendResult(completionItems);
}
private static async Task HandleCompletionResolveRequest(
CompletionItem completionItem,
RequestContext<CompletionItem> requestContext)
@@ -305,7 +297,6 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
await Task.FromResult(true);
}
/// <summary>
/// Handles text document change events
@@ -506,7 +497,6 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
Message = scriptFileMarker.Message,
Range = new Range
{
// TODO: What offsets should I use?
Start = new Position
{
Line = scriptFileMarker.ScriptRegion.StartLineNumber - 1,

View File

@@ -0,0 +1,40 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using Microsoft.SqlServer.Management.SqlParser.Binder;
using Microsoft.SqlServer.Management.SqlParser.MetadataProvider;
using Microsoft.SqlServer.Management.SqlParser.Parser;
using Microsoft.SqlServer.Management.SmoMetadataProvider;
namespace Microsoft.SqlTools.ServiceLayer.LanguageServices
{
/// <summary>
/// Class for storing cached metadata regarding a parsed SQL file
/// </summary>
internal class ScriptParseInfo
{
/// <summary>
/// Gets or sets the SMO binder for schema-aware intellisense
/// </summary>
public IBinder Binder { get; set; }
/// <summary>
/// Gets or sets the previous SQL parse result
/// </summary>
public ParseResult ParseResult { get; set; }
/// <summary>
/// Gets or set the SMO metadata provider that's bound to the current connection
/// </summary>
/// <returns></returns>
public SmoMetadataProvider MetadataProvider { get; set; }
/// <summary>
/// Gets or sets the SMO metadata display info provider
/// </summary>
/// <returns></returns>
public MetadataDisplayInfoProvider MetadataDisplayInfoProvider { get; set; }
}
}

View File

@@ -34,9 +34,10 @@ namespace Microsoft.SqlTools.ServiceLayer.Workspace.Contracts
public string FilePath { get; private set; }
/// <summary>
/// Gets the path which the editor client uses to identify this file.
/// Gets or sets the path which the editor client uses to identify this file.
/// Setter for testing purposes only
/// </summary>
public string ClientFilePath { get; private set; }
public string ClientFilePath { get; internal set; }
/// <summary>
/// Gets or sets a boolean that determines whether
@@ -52,7 +53,8 @@ namespace Microsoft.SqlTools.ServiceLayer.Workspace.Contracts
public bool IsInMemory { get; private set; }
/// <summary>
/// Gets a string containing the full contents of the file.
/// Gets or sets a string containing the full contents of the file.
/// Setter for testing purposes only
/// </summary>
public string Contents
{
@@ -60,6 +62,10 @@ namespace Microsoft.SqlTools.ServiceLayer.Workspace.Contracts
{
return string.Join("\r\n", this.FileLines);
}
set
{
this.FileLines = value != null ? value.Split('\n') : null;
}
}
/// <summary>