diff --git a/.gitignore b/.gitignore index 4c997e2b..de0fdc5b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ project.lock.json *.user *.userosscache *.sln.docstates +*.exe # Build results [Dd]ebug/ @@ -29,7 +30,13 @@ msbuild.log msbuild.err msbuild.wrn - +# code coverage artifacts +coverage.xml +node_modules +packages +reports +opencovertests.xml +sqltools.xml # Cross building rootfs cross/rootfs/ diff --git a/src/Microsoft.SqlTools.ServiceLayer/Connection/ConnectionService.cs b/src/Microsoft.SqlTools.ServiceLayer/Connection/ConnectionService.cs index 4f80b94c..6eca00dd 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Connection/ConnectionService.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/Connection/ConnectionService.cs @@ -9,6 +9,7 @@ using System.Data; using System.Data.Common; using System.Data.SqlClient; using System.Threading.Tasks; +using Microsoft.SqlServer.Management.Common; using Microsoft.SqlTools.EditorServices.Utility; using Microsoft.SqlTools.ServiceLayer.Connection.Contracts; using Microsoft.SqlTools.ServiceLayer.Hosting.Protocol; diff --git a/src/Microsoft.SqlTools.ServiceLayer/Hosting/ServiceHost.cs b/src/Microsoft.SqlTools.ServiceLayer/Hosting/ServiceHost.cs index 1270982f..5f5ef1df 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Hosting/ServiceHost.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/Hosting/ServiceHost.cs @@ -138,8 +138,7 @@ namespace Microsoft.SqlTools.ServiceLayer.Hosting ReferencesProvider = true, DocumentHighlightProvider = true, DocumentSymbolProvider = true, - WorkspaceSymbolProvider = true, - HoverProvider = true, + WorkspaceSymbolProvider = true, CompletionProvider = new CompletionOptions { ResolveProvider = true, diff --git a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/AutoCompleteService.cs b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/AutoCompleteService.cs index be778f92..5abe27f7 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/AutoCompleteService.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/AutoCompleteService.cs @@ -5,11 +5,20 @@ using System; using System.Collections.Generic; +using System.Data.SqlClient; using System.Threading.Tasks; +using Microsoft.SqlServer.Management.Common; +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 +49,14 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices /// /// 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 /// - public AutoCompleteService() + internal AutoCompleteService() { } #endregion - // Dictionary of unique intellisense caches for each Connection - private Dictionary caches = - new Dictionary(new ConnectionSummaryComparer()); - private Object cachesLock = new Object(); // Used when we insert/remove something from the cache dictionary - private ConnectionService connectionService = null; /// @@ -77,6 +81,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 +91,29 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices ConnectionServiceInstance.RegisterOnDisconnectTask(RemoveAutoCompleteCacheUriReference); } - /// - /// Intellisense cache count access for testing. - /// - internal int GetCacheCount() + /// + /// Auto-complete completion provider request callback + /// + /// + /// + /// + private static async Task HandleCompletionRequest( + TextDocumentPosition textDocumentPosition, + RequestContext requestContext) { - return caches.Count; + // get the current list of completion items and return to client + var scriptFile = WorkspaceService.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); } /// @@ -99,47 +123,152 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices /// public async Task RemoveAutoCompleteCacheUriReference(ConnectionSummary summary) { - await Task.Run( () => - { - lock(cachesLock) - { - IntellisenseCache cache; - if( caches.TryGetValue(summary, out cache) ) - { - cache.ReferenceCount--; - - // Remove unused caches - if( cache.ReferenceCount == 0 ) - { - caches.Remove(summary); - } - } - } - }); + // currently this method is disabled, but we need to reimplement now that the + // implementation of the 'cache' has changed. + await Task.FromResult(0); } - /// /// Update the cached autocomplete candidate list when the user connects to a database /// /// 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)) + var sqlConn = info.SqlConnection as SqlConnection; + if (sqlConn != null) { - cache = new IntellisenseCache(info.Factory, info.ConnectionDetails); - caches[cache.DatabaseInfo] = cache; + var srvConn = new ServerConnection(sqlConn); + 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.Instance.Workspace.GetFile(info.OwnerUri); + + LanguageService.Instance.ParseAndBind(scriptFile, info); } - cache.ReferenceCount++; } - - await cache.UpdateCache(); + }); + } + + /// + /// 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. + /// + /// + /// + /// + /// + 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; + } + + /// + /// Determines whether a reparse and bind is required to provide autocomplete + /// + /// + /// TEMP: Currently hard-coded to false for perf + private bool RequiresReparse(ScriptParseInfo info) + { + return false; + } + + /// + /// Converts a list of Declaration objects to CompletionItem objects + /// since VS Code expects CompletionItems but SQL Parser works with Declarations + /// + /// + /// + /// + /// + private CompletionItem[] ConvertDeclarationsToCompletionItems( + IEnumerable suggestions, + int row, + int startColumn, + int endColumn) + { + List completions = new List(); + 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(); } /// @@ -147,22 +276,48 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices /// This method does not await cache builds since it expects to return quickly /// /// - 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); } - } } diff --git a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/IntellisenseCache.cs b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/IntellisenseCache.cs deleted file mode 100644 index eea72771..00000000 --- a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/IntellisenseCache.cs +++ /dev/null @@ -1,122 +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.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 - { - /// - /// connection used to query for intellisense info - /// - private DbConnection connection; - - /// - /// Number of documents (URI's) that are using the cache for the same database. - /// The autocomplete service uses this to remove unreferenced caches. - /// - 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(); - } - - /// - /// Used to identify a database for which this cache is used - /// - public ConnectionSummary DatabaseInfo - { - get; - private set; - } - /// - /// Gets the current autocomplete candidate list - /// - public IEnumerable 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 results = new List(); - while (await reader.ReadAsync()) - { - results.Add(reader[0].ToString()); - } - - AutoCompleteList = results; - await Task.FromResult(0); - } - - public List GetAutoCompleteItems(TextDocumentPosition textDocumentPosition) - { - List completions = new List(); - - int i = 0; - - // Take a reference to the list at a point in time in case we update and replace the list - var suggestions = AutoCompleteList; - // 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, - Kind = CompletionItemKind.Keyword, - Detail = autoCompleteItem + " details", - Documentation = autoCompleteItem + " documentation", - TextEdit = new TextEdit - { - NewText = autoCompleteItem, - 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; - } - } -} diff --git a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs index f92f81ca..d414b41e 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs @@ -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; @@ -19,6 +18,9 @@ using System.Linq; 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.Common; +using Microsoft.SqlServer.Management.SqlParser; namespace Microsoft.SqlTools.ServiceLayer.LanguageServices { @@ -33,6 +35,17 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices private static readonly Lazy instance = new Lazy(() => new LanguageService()); + private Lazy> scriptParseInfoMap + = new Lazy>(() => new Dictionary()); + + internal Dictionary ScriptParseInfoMap + { + get + { + return this.scriptParseInfoMap.Value; + } + } + public static LanguageService Instance { get { return instance.Value; } @@ -67,21 +80,20 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices /// private SqlToolsContext Context { get; set; } - /// - /// The cached parse result from previous incremental parse - /// - private ParseResult prevParseResult; - #endregion #region Public Methods + /// + /// Initializes the Language Service instance + /// + /// + /// 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); @@ -109,21 +121,69 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices Context = context; } + /// + /// Parses the SQL text and binds it to the SMO metadata provider if connected + /// + /// + /// + /// + 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 parseResults = new List(); + 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; + } + /// /// Gets a list of semantic diagnostic marks for the provided script file /// /// 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; + ConnectionService.Instance.TryFindConnection( + scriptFile.ClientFilePath, + out connInfo); + + var parseResult = ParseAndBind(scriptFile, connInfo); // build a list of SQL script file markers from the errors List markers = new List(); @@ -169,17 +229,6 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices await Task.FromResult(true); } - private static async Task HandleCompletionRequest( - TextDocumentPosition textDocumentPosition, - RequestContext 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 requestContext) @@ -248,7 +297,6 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices await Task.FromResult(true); } - /// /// Handles text document change events @@ -449,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, diff --git a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/ScriptParseInfo.cs b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/ScriptParseInfo.cs new file mode 100644 index 00000000..4da2c57e --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/ScriptParseInfo.cs @@ -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 +{ + /// + /// Class for storing cached metadata regarding a parsed SQL file + /// + internal class ScriptParseInfo + { + /// + /// Gets or sets the SMO binder for schema-aware intellisense + /// + public IBinder Binder { get; set; } + + /// + /// Gets or sets the previous SQL parse result + /// + public ParseResult ParseResult { get; set; } + + /// + /// Gets or set the SMO metadata provider that's bound to the current connection + /// + /// + public SmoMetadataProvider MetadataProvider { get; set; } + + /// + /// Gets or sets the SMO metadata display info provider + /// + /// + public MetadataDisplayInfoProvider MetadataDisplayInfoProvider { get; set; } + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/Workspace/Contracts/ScriptFile.cs b/src/Microsoft.SqlTools.ServiceLayer/Workspace/Contracts/ScriptFile.cs index 8db022cc..74592ae5 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Workspace/Contracts/ScriptFile.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/Workspace/Contracts/ScriptFile.cs @@ -34,9 +34,10 @@ namespace Microsoft.SqlTools.ServiceLayer.Workspace.Contracts public string FilePath { get; private set; } /// - /// 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 /// - public string ClientFilePath { get; private set; } + public string ClientFilePath { get; internal set; } /// /// Gets or sets a boolean that determines whether @@ -52,7 +53,8 @@ namespace Microsoft.SqlTools.ServiceLayer.Workspace.Contracts public bool IsInMemory { get; private set; } /// - /// 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 /// 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; + } } /// @@ -103,8 +109,9 @@ namespace Microsoft.SqlTools.ServiceLayer.Workspace.Contracts /// /// Add a default constructor for testing /// - public ScriptFile() + internal ScriptFile() { + ClientFilePath = "test.sql"; } /// diff --git a/src/Microsoft.SqlTools.ServiceLayer/project.json b/src/Microsoft.SqlTools.ServiceLayer/project.json index 1636feae..a0a73439 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/project.json +++ b/src/Microsoft.SqlTools.ServiceLayer/project.json @@ -9,7 +9,12 @@ "Newtonsoft.Json": "9.0.1", "Microsoft.SqlServer.SqlParser": "140.1.5", "System.Data.Common": "4.1.0", - "System.Data.SqlClient": "4.1.0" + "System.Data.SqlClient": "4.1.0", + "Microsoft.SqlServer.Smo": "140.1.5", + "System.Security.SecureString": "4.0.0", + "System.Collections.Specialized": "4.0.1", + "System.ComponentModel.TypeConverter": "4.1.0", + "System.Diagnostics.TraceSource": "4.0.0" }, "frameworks": { "netcoreapp1.0": { diff --git a/test/CodeCoverage/ReplaceText.vbs b/test/CodeCoverage/ReplaceText.vbs new file mode 100644 index 00000000..31e27994 --- /dev/null +++ b/test/CodeCoverage/ReplaceText.vbs @@ -0,0 +1,55 @@ +' ReplaceText.vbs +' Copied from answer at http://stackoverflow.com/questions/1115508/batch-find-and-edit-lines-in-txt-file + +Option Explicit + +Const ForAppending = 8 +Const TristateFalse = 0 ' the value for ASCII +Const Overwrite = True + +Const WindowsFolder = 0 +Const SystemFolder = 1 +Const TemporaryFolder = 2 + +Dim FileSystem +Dim Filename, OldText, NewText +Dim OriginalFile, TempFile, Line +Dim TempFilename + +If WScript.Arguments.Count = 3 Then + Filename = WScript.Arguments.Item(0) + OldText = WScript.Arguments.Item(1) + NewText = WScript.Arguments.Item(2) +Else + Wscript.Echo "Usage: ReplaceText.vbs " + Wscript.Quit +End If + +Set FileSystem = CreateObject("Scripting.FileSystemObject") +Dim tempFolder: tempFolder = FileSystem.GetSpecialFolder(TemporaryFolder) +TempFilename = FileSystem.GetTempName + +If FileSystem.FileExists(TempFilename) Then + FileSystem.DeleteFile TempFilename +End If + +Set TempFile = FileSystem.CreateTextFile(TempFilename, Overwrite, TristateFalse) +Set OriginalFile = FileSystem.OpenTextFile(Filename) + +Do Until OriginalFile.AtEndOfStream + Line = OriginalFile.ReadLine + + If InStr(Line, OldText) > 0 Then + Line = Replace(Line, OldText, NewText) + End If + + TempFile.WriteLine(Line) +Loop + +OriginalFile.Close +TempFile.Close + +FileSystem.DeleteFile Filename +FileSystem.MoveFile TempFilename, Filename + +Wscript.Quit diff --git a/test/CodeCoverage/codecoverage.bat b/test/CodeCoverage/codecoverage.bat new file mode 100644 index 00000000..098ec1a1 --- /dev/null +++ b/test/CodeCoverage/codecoverage.bat @@ -0,0 +1,25 @@ +SET WORKINGDIR=%~dp0 + +REM clean-up results from previous run +RMDIR %WORKINGDIR%reports\ /S /Q +DEL %WORKINGDIR%coverage.xml +MKDIR reports + +REM backup current project.json +COPY /Y %WORKINGDIR%..\..\src\Microsoft.SqlTools.ServiceLayer\project.json %WORKINGDIR%..\..\src\Microsoft.SqlTools.ServiceLayer\project.json.BAK + +REM switch PDB type to Full since that is required by OpenCover for now +REM we should remove this step on OpenCover supports portable PDB +cscript /nologo ReplaceText.vbs %WORKINGDIR%..\..\src\Microsoft.SqlTools.ServiceLayer\project.json portable full + +REM rebuild the SqlToolsService project +dotnet build %WORKINGDIR%..\..\src\Microsoft.SqlTools.ServiceLayer\project.json + +REM run the tests through OpenCover and generate a report +"%WORKINGDIR%packages\OpenCover.4.6.519\tools\OpenCover.Console.exe" -register:user -target:dotnet.exe -targetargs:"test %WORKINGDIR%..\Microsoft.SqlTools.ServiceLayer.Test\project.json" -oldstyle -filter:"+[Microsoft.SqlTools.*]* -[xunit*]*" -output:coverage.xml -searchdirs:%WORKINGDIR%..\Microsoft.SqlTools.ServiceLayer.Test\bin\Debug\netcoreapp1.0 +"%WORKINGDIR%packages\ReportGenerator.2.4.5.0\tools\ReportGenerator.exe" "-reports:coverage.xml" "-targetdir:%WORKINGDIR%\reports" + +REM restore original project.json +COPY /Y %WORKINGDIR%..\..\src\Microsoft.SqlTools.ServiceLayer\project.json.BAK %WORKINGDIR%..\..\src\Microsoft.SqlTools.ServiceLayer\project.json +DEL %WORKINGDIR%..\..\src\Microsoft.SqlTools.ServiceLayer\project.json.BAK +EXIT diff --git a/test/CodeCoverage/gulpfile.js b/test/CodeCoverage/gulpfile.js new file mode 100644 index 00000000..38b1576c --- /dev/null +++ b/test/CodeCoverage/gulpfile.js @@ -0,0 +1,107 @@ +var gulp = require('gulp'); +var del = require('del'); +var request = require('request'); +var fs = require('fs'); +var gutil = require('gulp-util'); +var through = require('through2'); +var cproc = require('child_process'); +var os = require('os'); + +function nugetRestoreArgs(nupkg, options) { + var args = new Array(); + if (os.platform() != 'win32') { + args.push('./nuget.exe'); + } + args.push('restore'); + args.push(nupkg); + + var withValues = [ + 'source', + 'configFile', + 'packagesDirectory', + 'solutionDirectory', + 'msBuildVersion' + ]; + + var withoutValues = [ + 'noCache', + 'requireConsent', + 'disableParallelProcessing' + ]; + + withValues.forEach(function(prop) { + var value = options[prop]; + if(value) { + args.push('-' + prop); + args.push(value); + } + }); + + withoutValues.forEach(function(prop) { + var value = options[prop]; + if(value) { + args.push('-' + prop); + } + }); + + args.push('-noninteractive'); + + return args; +}; + +function nugetRestore(options) { + options = options || {}; + options.nuget = options.nuget || './nuget.exe'; + if (os.platform() != 'win32') { + options.nuget = 'mono'; + } + + return through.obj(function(file, encoding, done) { + var args = nugetRestoreArgs(file.path, options); + cproc.execFile(options.nuget, args, function(err, stdout) { + if (err) { + throw new gutil.PluginError('gulp-nuget', err); + } + + gutil.log(stdout.trim()); + done(null, file); + }); + }); +}; + +gulp.task('ext:nuget-download', function(done) { + if(fs.existsSync('nuget.exe')) { + return done(); + } + + request.get('http://nuget.org/nuget.exe') + .pipe(fs.createWriteStream('nuget.exe')) + .on('close', done); +}); + +gulp.task('ext:nuget-restore', function() { + + var options = { + configFile: './nuget.config', + packagesDirectory: './packages' + }; + + return gulp.src('./packages.config') + .pipe(nugetRestore(options)); +}); + + +gulp.task('ext:code-coverage', function(done) { + cproc.execFile('cmd.exe', [ '/c', 'codecoverage.bat' ], function(err, stdout) { + if (err) { + throw new gutil.PluginError('ext:code-coverage', err); + } + + gutil.log(stdout.trim()); + }); + return done(); +}); + +gulp.task('test', gulp.series('ext:nuget-download', 'ext:nuget-restore', 'ext:code-coverage')); + +gulp.task('default', gulp.series('test')); diff --git a/test/CodeCoverage/nuget.config b/test/CodeCoverage/nuget.config new file mode 100644 index 00000000..1eab8195 --- /dev/null +++ b/test/CodeCoverage/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/CodeCoverage/package.json b/test/CodeCoverage/package.json new file mode 100644 index 00000000..0d4efd10 --- /dev/null +++ b/test/CodeCoverage/package.json @@ -0,0 +1,16 @@ +{ + "name": "sqltoolsservice", + "version": "0.1.0", + "description": "SQL Tools Service Layer", + "main": "gulpfile.js", + "dependencies": { + "gulp": "github:gulpjs/gulp#4.0", + "del": "^2.2.1", + "gulp-hub": "frankwallis/gulp-hub#registry-init", + "gulp-install": "^0.6.0", + "request": "^2.73.0" + }, + "devDependencies": {}, + "author": "Microsoft", + "license": "MIT" +} diff --git a/test/CodeCoverage/packages.config b/test/CodeCoverage/packages.config new file mode 100644 index 00000000..4a7355aa --- /dev/null +++ b/test/CodeCoverage/packages.config @@ -0,0 +1,5 @@ + + + + + diff --git a/test/Microsoft.SqlTools.ServiceLayer.Test/LanguageServer/LanguageServiceTests.cs b/test/Microsoft.SqlTools.ServiceLayer.Test/LanguageServer/LanguageServiceTests.cs index 6380eb91..0725c209 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.Test/LanguageServer/LanguageServiceTests.cs +++ b/test/Microsoft.SqlTools.ServiceLayer.Test/LanguageServer/LanguageServiceTests.cs @@ -3,13 +3,26 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using System; using System.Collections.Generic; using System.Data; using System.Data.Common; +using System.Data.SqlClient; +using System.IO; +using System.Reflection; using System.Threading.Tasks; +using Microsoft.SqlServer.Management.Common; +using Microsoft.SqlServer.Management.Smo; +using Microsoft.SqlServer.Management.SmoMetadataProvider; +using Microsoft.SqlServer.Management.SqlParser; +using Microsoft.SqlServer.Management.SqlParser.Binder; +using Microsoft.SqlServer.Management.SqlParser.Intellisense; +using Microsoft.SqlServer.Management.SqlParser.MetadataProvider; +using Microsoft.SqlServer.Management.SqlParser.Parser; using Microsoft.SqlTools.ServiceLayer.Connection; using Microsoft.SqlTools.ServiceLayer.Connection.Contracts; using Microsoft.SqlTools.ServiceLayer.LanguageServices; +using Microsoft.SqlTools.ServiceLayer.Test.QueryExecution; using Microsoft.SqlTools.ServiceLayer.Test.Utility; using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts; using Microsoft.SqlTools.Test.Utility; @@ -137,6 +150,29 @@ namespace Microsoft.SqlTools.ServiceLayer.Test.LanguageServices #region "Autocomplete Tests" + // This test currently requires a live database connection to initialize + // SMO connected metadata provider. Since we don't want a live DB dependency + // in the CI unit tests this scenario is currently disabled. + //[Fact] + public void AutoCompleteFindCompletions() + { + TextDocumentPosition textDocument; + ConnectionInfo connInfo; + ScriptFile scriptFile; + Common.GetAutoCompleteTestObjects(out textDocument, out scriptFile, out connInfo); + + textDocument.Position.Character = 7; + scriptFile.Contents = "select "; + + var autoCompleteService = AutoCompleteService.Instance; + var completions = autoCompleteService.GetCompletionItems( + textDocument, + scriptFile, + connInfo); + + Assert.True(completions.Length > 0); + } + /// /// Creates a mock db command that returns a predefined result set /// @@ -164,160 +200,6 @@ namespace Microsoft.SqlTools.ServiceLayer.Test.LanguageServices return connectionMock.Object; } - /// - /// Verify that the autocomplete service returns tables for the current connection as suggestions - /// - [Fact] - public void TablesAreReturnedAsAutocompleteSuggestions() - { - // Result set for the query of database tables - Dictionary[] data = - { - new Dictionary { {"name", "master" } }, - new Dictionary { {"name", "model" } } - }; - - var mockFactory = new Mock(); - mockFactory.Setup(factory => factory.CreateSqlConnection(It.IsAny())) - .Returns(CreateMockDbConnection(new[] {data})); - - var connectionService = new ConnectionService(mockFactory.Object); - var autocompleteService = new AutoCompleteService(); - autocompleteService.ConnectionServiceInstance = connectionService; - autocompleteService.InitializeService(Microsoft.SqlTools.ServiceLayer.Hosting.ServiceHost.Instance); - - // Open a connection - // The cache should get updated as part of this - ConnectParams connectionRequest = TestObjects.GetTestConnectionParams(); - var connectionResult = connectionService.Connect(connectionRequest); - Assert.NotEmpty(connectionResult.ConnectionId); - - // Check that there is one cache created in the auto complete service - Assert.Equal(1, autocompleteService.GetCacheCount()); - - // Check that we get table suggestions for an autocomplete request - TextDocumentPosition position = new TextDocumentPosition(); - position.TextDocument = new TextDocumentIdentifier(); - position.TextDocument.Uri = connectionRequest.OwnerUri; - position.Position = new Position(); - position.Position.Line = 1; - position.Position.Character = 1; - var items = autocompleteService.GetCompletionItems(position); - Assert.Equal(2, items.Length); - Assert.Equal("master", items[0].Label); - Assert.Equal("model", items[1].Label); - } - - /// - /// Verify that only one intellisense cache is created for two documents using - /// the autocomplete service when they share a common connection. - /// - [Fact] - public void OnlyOneCacheIsCreatedForTwoDocumentsWithSameConnection() - { - var connectionService = new ConnectionService(TestObjects.GetTestSqlConnectionFactory()); - var autocompleteService = new AutoCompleteService(); - autocompleteService.ConnectionServiceInstance = connectionService; - autocompleteService.InitializeService(Microsoft.SqlTools.ServiceLayer.Hosting.ServiceHost.Instance); - - // Open two connections - ConnectParams connectionRequest1 = TestObjects.GetTestConnectionParams(); - connectionRequest1.OwnerUri = "file:///my/first/file.sql"; - ConnectParams connectionRequest2 = TestObjects.GetTestConnectionParams(); - connectionRequest2.OwnerUri = "file:///my/second/file.sql"; - var connectionResult1 = connectionService.Connect(connectionRequest1); - Assert.NotEmpty(connectionResult1.ConnectionId); - var connectionResult2 = connectionService.Connect(connectionRequest2); - Assert.NotEmpty(connectionResult2.ConnectionId); - - // Verify that only one intellisense cache is created to service both URI's - Assert.Equal(1, autocompleteService.GetCacheCount()); - } - - /// - /// Verify that two different intellisense caches and corresponding autocomplete - /// suggestions are provided for two documents with different connections. - /// - [Fact] - public void TwoCachesAreCreatedForTwoDocumentsWithDifferentConnections() - { - const string testDb1 = "my_db"; - const string testDb2 = "my_other_db"; - - // Result set for the query of database tables - Dictionary[] data1 = - { - new Dictionary { {"name", "master" } }, - new Dictionary { {"name", "model" } } - }; - - Dictionary[] data2 = - { - new Dictionary { {"name", "master" } }, - new Dictionary { {"name", "my_table" } }, - new Dictionary { {"name", "my_other_table" } } - }; - - var mockFactory = new Mock(); - mockFactory.Setup(factory => factory.CreateSqlConnection(It.Is(x => x.Contains(testDb1)))) - .Returns(CreateMockDbConnection(new[] {data1})); - mockFactory.Setup(factory => factory.CreateSqlConnection(It.Is(x => x.Contains(testDb2)))) - .Returns(CreateMockDbConnection(new[] {data2})); - - var connectionService = new ConnectionService(mockFactory.Object); - var autocompleteService = new AutoCompleteService(); - autocompleteService.ConnectionServiceInstance = connectionService; - autocompleteService.InitializeService(Microsoft.SqlTools.ServiceLayer.Hosting.ServiceHost.Instance); - - // Open connections - // The cache should get updated as part of this - ConnectParams connectionRequest = TestObjects.GetTestConnectionParams(); - connectionRequest.OwnerUri = "file:///my/first/sql/file.sql"; - connectionRequest.Connection.DatabaseName = testDb1; - var connectionResult = connectionService.Connect(connectionRequest); - Assert.NotEmpty(connectionResult.ConnectionId); - - // Check that there is one cache created in the auto complete service - Assert.Equal(1, autocompleteService.GetCacheCount()); - - // Open second connection - ConnectParams connectionRequest2 = TestObjects.GetTestConnectionParams(); - connectionRequest2.OwnerUri = "file:///my/second/sql/file.sql"; - connectionRequest2.Connection.DatabaseName = testDb2; - var connectionResult2 = connectionService.Connect(connectionRequest2); - Assert.NotEmpty(connectionResult2.ConnectionId); - - // Check that there are now two caches in the auto complete service - Assert.Equal(2, autocompleteService.GetCacheCount()); - - // Check that we get 2 different table suggestions for autocomplete requests - TextDocumentPosition position = new TextDocumentPosition(); - position.TextDocument = new TextDocumentIdentifier(); - position.TextDocument.Uri = connectionRequest.OwnerUri; - position.Position = new Position(); - position.Position.Line = 1; - position.Position.Character = 1; - - var items = autocompleteService.GetCompletionItems(position); - Assert.Equal(2, items.Length); - Assert.Equal("master", items[0].Label); - Assert.Equal("model", items[1].Label); - - TextDocumentPosition position2 = new TextDocumentPosition(); - position2.TextDocument = new TextDocumentIdentifier(); - position2.TextDocument.Uri = connectionRequest2.OwnerUri; - position2.Position = new Position(); - position2.Position.Line = 1; - position2.Position.Character = 1; - - var items2 = autocompleteService.GetCompletionItems(position2); - Assert.Equal(3, items2.Length); - Assert.Equal("master", items2[0].Label); - Assert.Equal("my_table", items2[1].Label); - Assert.Equal("my_other_table", items2[2].Label); - } - #endregion } } - diff --git a/test/Microsoft.SqlTools.ServiceLayer.Test/QueryExecution/Common.cs b/test/Microsoft.SqlTools.ServiceLayer.Test/QueryExecution/Common.cs index 6d265de2..9d2c1749 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.Test/QueryExecution/Common.cs +++ b/test/Microsoft.SqlTools.ServiceLayer.Test/QueryExecution/Common.cs @@ -7,16 +7,23 @@ using System; using System.Collections.Generic; using System.Data; using System.Data.Common; +using System.Data.SqlClient; using System.Threading; using System.Threading.Tasks; +using Microsoft.SqlServer.Management.Common; +using Microsoft.SqlServer.Management.SmoMetadataProvider; +using Microsoft.SqlServer.Management.SqlParser.Binder; +using Microsoft.SqlServer.Management.SqlParser.MetadataProvider; using Microsoft.SqlTools.ServiceLayer.Connection; using Microsoft.SqlTools.ServiceLayer.Connection.Contracts; using Microsoft.SqlTools.ServiceLayer.Hosting.Protocol; using Microsoft.SqlTools.ServiceLayer.Hosting.Protocol.Contracts; +using Microsoft.SqlTools.ServiceLayer.LanguageServices; using Microsoft.SqlTools.ServiceLayer.QueryExecution; using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; using Microsoft.SqlTools.ServiceLayer.SqlContext; using Microsoft.SqlTools.ServiceLayer.Test.Utility; +using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts; using Moq; using Moq.Protected; @@ -36,6 +43,16 @@ namespace Microsoft.SqlTools.ServiceLayer.Test.QueryExecution public const int StandardColumns = 5; + public static string TestServer { get; set; } + + public static string TestDatabase { get; set; } + + static Common() + { + TestServer = "sqltools11"; + TestDatabase = "master"; + } + public static Dictionary[] StandardTestData { get { return GetTestData(StandardRows, StandardColumns); } @@ -122,8 +139,8 @@ namespace Microsoft.SqlTools.ServiceLayer.Test.QueryExecution { UserName = "sa", Password = "Yukon900", - DatabaseName = "AdventureWorks2016CTP3_2", - ServerName = "sqltools11" + DatabaseName = Common.TestDatabase, + ServerName = Common.TestServer }; return new ConnectionInfo(CreateMockFactory(data, throwOnRead), OwnerUri, connDetails); @@ -132,7 +149,46 @@ namespace Microsoft.SqlTools.ServiceLayer.Test.QueryExecution #endregion #region Service Mocking + + public static void GetAutoCompleteTestObjects( + out TextDocumentPosition textDocument, + out ScriptFile scriptFile, + out ConnectionInfo connInfo + ) + { + textDocument = new TextDocumentPosition(); + textDocument.TextDocument = new TextDocumentIdentifier(); + textDocument.TextDocument.Uri = Common.OwnerUri; + textDocument.Position = new Position(); + textDocument.Position.Line = 0; + textDocument.Position.Character = 0; + connInfo = Common.CreateTestConnectionInfo(null, false); + + var srvConn = GetServerConnection(connInfo); + var displayInfoProvider = new MetadataDisplayInfoProvider(); + var metadataProvider = SmoMetadataProvider.CreateConnectedProvider(srvConn); + var binder = BinderProvider.CreateBinder(metadataProvider); + + LanguageService.Instance.ScriptParseInfoMap.Add(textDocument.TextDocument.Uri, + new ScriptParseInfo() + { + Binder = binder, + MetadataProvider = metadataProvider, + MetadataDisplayInfoProvider = displayInfoProvider + }); + + scriptFile = new ScriptFile(); + scriptFile.ClientFilePath = textDocument.TextDocument.Uri; + } + + public static ServerConnection GetServerConnection(ConnectionInfo connection) + { + string connectionString = ConnectionService.BuildConnectionString(connection.ConnectionDetails); + var sqlConnection = new SqlConnection(connectionString); + return new ServerConnection(sqlConnection); + } + public static ConnectionDetails GetTestConnectionDetails() { return new ConnectionDetails diff --git a/test/Microsoft.SqlTools.ServiceLayer.Test/project.json b/test/Microsoft.SqlTools.ServiceLayer.Test/project.json index 6fe58d61..06a46957 100644 --- a/test/Microsoft.SqlTools.ServiceLayer.Test/project.json +++ b/test/Microsoft.SqlTools.ServiceLayer.Test/project.json @@ -9,6 +9,10 @@ "System.Runtime.Serialization.Primitives": "4.1.1", "System.Data.Common": "4.1.0", "System.Data.SqlClient": "4.1.0", + "Microsoft.SqlServer.Smo": "140.1.5", + "System.Security.SecureString": "4.0.0", + "System.Collections.Specialized": "4.0.1", + "System.ComponentModel.TypeConverter": "4.1.0", "xunit": "2.1.0", "dotnet-test-xunit": "1.0.0-rc2-192208-24", "moq": "4.6.36-alpha",