mirror of
https://github.com/ckaczor/sqltoolsservice.git
synced 2026-01-14 01:25:40 -05:00
Sending show plan graph to ADS on Result Set updated event (#1300)
* Sending showplan graph over json rpc in Result updated event Translating showplan graph into simple objects to be sent over JSON RPC * Revert "Sending showplan graph over json rpc in Result updated event" This reverts commit 2d63a625fd200d057bf6093e233f05dea440347c. * Added string for localization * Sending showplan graph over json rpc in Result updated event Translating showplan graph into simple objects to be sent over JSON RPC * Refactoring class * Removing test warning * Removing unused imports Adding copyright * Removing unused prop * removing formatted string out .strings file * Formatting files Adding Errors in show plan graph * Adding a separate event for execution plan * Now sending mulitple graphs when a batch has more than one query.
This commit is contained in:
@@ -8774,6 +8774,16 @@ namespace Microsoft.SqlTools.ServiceLayer
|
||||
return Keys.GetString(Keys.NameValuePair, name, value);
|
||||
}
|
||||
|
||||
public static string OperatorDisplayCost(double cost, int percentage)
|
||||
{
|
||||
return Keys.GetString(Keys.OperatorDisplayCost, cost, percentage);
|
||||
}
|
||||
|
||||
public static string ActualOfEstimated(string actual, string estimated, decimal percent)
|
||||
{
|
||||
return Keys.GetString(Keys.ActualOfEstimated, actual, estimated, percent);
|
||||
}
|
||||
|
||||
public static string TableNotInitializedException(string tableId)
|
||||
{
|
||||
return Keys.GetString(Keys.TableNotInitializedException, tableId);
|
||||
@@ -12107,6 +12117,12 @@ namespace Microsoft.SqlTools.ServiceLayer
|
||||
public const string SizeInTeraBytesFormat = "SizeInTeraBytesFormat";
|
||||
|
||||
|
||||
public const string OperatorDisplayCost = "OperatorDisplayCost";
|
||||
|
||||
|
||||
public const string ActualOfEstimated = "ActualOfEstimated";
|
||||
|
||||
|
||||
public const string TableNotInitializedException = "TableNotInitializedException";
|
||||
|
||||
|
||||
|
||||
@@ -4590,6 +4590,17 @@
|
||||
<value>{0} TB</value>
|
||||
<comment>Size in TeraBytes format</comment>
|
||||
</data>
|
||||
<data name="OperatorDisplayCost" xml:space="preserve">
|
||||
<value>{0:0.#######} ({1}%)</value>
|
||||
<comment> display string for the operator cost property - 0.###### - is the float number format specifier.
|
||||
Parameters: 0 - cost (double), 1 - percentage (int) </comment>
|
||||
</data>
|
||||
<data name="ActualOfEstimated" xml:space="preserve">
|
||||
<value>{0} of
|
||||
{1} ({2}%)</value>
|
||||
<comment>.
|
||||
Parameters: 0 - actual (string), 1 - estimated (string), 2 - percent (decimal) </comment>
|
||||
</data>
|
||||
<data name="TableNotInitializedException" xml:space="preserve">
|
||||
<value>Initialization is not properly done for table with id '{0}'</value>
|
||||
<comment>.
|
||||
|
||||
@@ -2213,6 +2213,10 @@ SizeInMegaBytesFormat = {0} MB
|
||||
SizeInGigaBytesFormat = {0} GB
|
||||
;Size in TeraBytes format
|
||||
SizeInTeraBytesFormat = {0} TB
|
||||
; display string for the operator cost property - 0.###### - is the float number format specifier
|
||||
OperatorDisplayCost(double cost, int percentage) = {0:0.#######} ({1}%)
|
||||
#Would like to display actual rows and estimated rows in two lines: <number_actual_rows> of\n <number_estimated_rows> (xx%)
|
||||
ActualOfEstimated(string actual, string estimated, decimal percent) = {0} of\n{1} ({2}%)
|
||||
|
||||
############################################################################
|
||||
# Table Designer
|
||||
|
||||
@@ -5631,6 +5631,20 @@
|
||||
<note>.
|
||||
Parameters: 0 - path (string), 1 - editType (string) </note>
|
||||
</trans-unit>
|
||||
<trans-unit id="OperatorDisplayCost">
|
||||
<source>{0:0.#######} ({1}%)</source>
|
||||
<target state="new">{0:0.#######} ({1}%)</target>
|
||||
<note> display string for the operator cost property - 0.###### - is the float number format specifier.
|
||||
Parameters: 0 - cost (double), 1 - percentage (int) </note>
|
||||
</trans-unit>
|
||||
<trans-unit id="ActualOfEstimated">
|
||||
<source>{0} of
|
||||
{1} ({2}%)</source>
|
||||
<target state="new">{0} of
|
||||
{1} ({2}%)</target>
|
||||
<note>.
|
||||
Parameters: 0 - actual (string), 1 - estimated (string), 2 - percent (decimal) </note>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
@@ -2,7 +2,9 @@
|
||||
// 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 Microsoft.SqlTools.Hosting.Protocol.Contracts;
|
||||
using Microsoft.SqlTools.ServiceLayer.ShowPlan;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts.ExecuteRequests
|
||||
{
|
||||
@@ -35,9 +37,17 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts.ExecuteReques
|
||||
/// </summary>
|
||||
public class ResultSetUpdatedEventParams : ResultSetEventParams
|
||||
{
|
||||
/// <summary>
|
||||
/// Execution plans for statements in the current batch.
|
||||
/// </summary>
|
||||
public List<ExecutionPlanGraph> ExecutionPlans { get; set; }
|
||||
/// <summary>
|
||||
/// Error message for exception raised while generating execution plan.
|
||||
/// </summary>
|
||||
public string ExecutionPlanErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public class ResultSetCompleteEvent
|
||||
public class ResultSetCompleteEvent
|
||||
{
|
||||
public static string MethodName { get; } = "query/resultSetComplete";
|
||||
|
||||
@@ -46,7 +56,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts.ExecuteReques
|
||||
EventType<ResultSetCompleteEventParams>.Create(MethodName);
|
||||
}
|
||||
|
||||
public class ResultSetAvailableEvent
|
||||
public class ResultSetAvailableEvent
|
||||
{
|
||||
public static string MethodName { get; } = "query/resultSetAvailable";
|
||||
|
||||
@@ -55,7 +65,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts.ExecuteReques
|
||||
EventType<ResultSetAvailableEventParams>.Create(MethodName);
|
||||
}
|
||||
|
||||
public class ResultSetUpdatedEvent
|
||||
public class ResultSetUpdatedEvent
|
||||
{
|
||||
public static string MethodName { get; } = "query/resultSetUpdated";
|
||||
|
||||
|
||||
@@ -6,19 +6,21 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.SqlTools.Hosting.Protocol;
|
||||
using Microsoft.SqlTools.ServiceLayer.Connection;
|
||||
using Microsoft.SqlTools.ServiceLayer.Connection.Contracts;
|
||||
using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts;
|
||||
using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts.ExecuteRequests;
|
||||
using Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage;
|
||||
using Microsoft.SqlTools.ServiceLayer.SqlContext;
|
||||
using Microsoft.SqlTools.ServiceLayer.Workspace;
|
||||
using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts;
|
||||
using Microsoft.SqlTools.ServiceLayer.Connection;
|
||||
using Microsoft.SqlTools.ServiceLayer.Hosting;
|
||||
using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts.ExecuteRequests;
|
||||
using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts;
|
||||
using Microsoft.SqlTools.ServiceLayer.QueryExecution.DataStorage;
|
||||
using Microsoft.SqlTools.ServiceLayer.ShowPlan;
|
||||
using Microsoft.SqlTools.ServiceLayer.SqlContext;
|
||||
using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts;
|
||||
using Microsoft.SqlTools.ServiceLayer.Workspace;
|
||||
using Microsoft.SqlTools.Utility;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
{
|
||||
@@ -136,7 +138,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
/// Internal storage of active query settings
|
||||
/// </summary>
|
||||
private readonly Lazy<ConcurrentDictionary<string, QueryExecutionSettings>> queryExecutionSettings =
|
||||
new Lazy<ConcurrentDictionary<string, QueryExecutionSettings>>(() => new ConcurrentDictionary<string, QueryExecutionSettings>());
|
||||
new Lazy<ConcurrentDictionary<string, QueryExecutionSettings>>(() => new ConcurrentDictionary<string, QueryExecutionSettings>());
|
||||
|
||||
/// <summary>
|
||||
/// Settings that will be used to execute queries. Internal for unit testing
|
||||
@@ -220,12 +222,12 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
WorkTask = Task.Run(async () =>
|
||||
{
|
||||
await InterServiceExecuteQuery(
|
||||
executeParams,
|
||||
null,
|
||||
requestContext,
|
||||
queryCreateSuccessAction,
|
||||
queryCreateFailureAction,
|
||||
null,
|
||||
executeParams,
|
||||
null,
|
||||
requestContext,
|
||||
queryCreateSuccessAction,
|
||||
queryCreateFailureAction,
|
||||
null,
|
||||
null,
|
||||
isQueryEditor(executeParams.OwnerUri));
|
||||
});
|
||||
@@ -267,7 +269,8 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
Type = ConnectionType.Default
|
||||
};
|
||||
|
||||
Task workTask = Task.Run(async () => {
|
||||
Task workTask = Task.Run(async () =>
|
||||
{
|
||||
await ConnectionService.Connect(connectParams);
|
||||
|
||||
ConnectionInfo newConn;
|
||||
@@ -328,7 +331,8 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
// remove the active query since we are done with it
|
||||
ActiveQueries.TryRemove(randomUri, out removedQuery);
|
||||
ActiveSimpleExecuteRequests.TryRemove(randomUri, out removedTask);
|
||||
ConnectionService.Disconnect(new DisconnectParams(){
|
||||
ConnectionService.Disconnect(new DisconnectParams()
|
||||
{
|
||||
OwnerUri = randomUri,
|
||||
Type = null
|
||||
});
|
||||
@@ -359,12 +363,14 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
internal Task HandleConnectionUriChangedNotification(ConnectionUriChangedParams changeUriParams,
|
||||
EventContext eventContext)
|
||||
{
|
||||
try {
|
||||
try
|
||||
{
|
||||
string OriginalOwnerUri = changeUriParams.OriginalOwnerUri;
|
||||
string NewOwnerUri = changeUriParams.NewOwnerUri;
|
||||
// Attempt to load the query
|
||||
Query query;
|
||||
if(!ActiveQueries.TryRemove(OriginalOwnerUri, out query)){
|
||||
if (!ActiveQueries.TryRemove(OriginalOwnerUri, out query))
|
||||
{
|
||||
throw new Exception("Uri: " + OriginalOwnerUri + " is not associated with an active query.");
|
||||
}
|
||||
ConnectionService.ReplaceUri(OriginalOwnerUri, NewOwnerUri);
|
||||
@@ -372,7 +378,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
ActiveQueries.TryAdd(NewOwnerUri, query);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Write(TraceEventType.Error, "Error encountered " + ex.ToString());
|
||||
return Task.FromException(ex);
|
||||
@@ -402,7 +408,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Handles a request to set query execution options
|
||||
/// </summary>
|
||||
@@ -411,7 +417,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
{
|
||||
try
|
||||
{
|
||||
string uri = queryExecutionOptionsParams.OwnerUri;
|
||||
string uri = queryExecutionOptionsParams.OwnerUri;
|
||||
if (ActiveQueryExecutionSettings.ContainsKey(uri))
|
||||
{
|
||||
QueryExecutionSettings settings;
|
||||
@@ -426,7 +432,7 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
{
|
||||
// This was unexpected, so send back as error
|
||||
await requestContext.SendError(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -712,30 +718,30 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
{
|
||||
QueryExecutionSettings settings;
|
||||
this.ActiveQueryExecutionSettings.TryRemove(uri, out settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Write(TraceEventType.Error, "Unknown error " + ex.ToString());
|
||||
}
|
||||
await Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
private Query CreateQuery(
|
||||
ExecuteRequestParamsBase executeParams,
|
||||
ConnectionInfo connInfo,
|
||||
ExecuteRequestParamsBase executeParams,
|
||||
ConnectionInfo connInfo,
|
||||
bool applyExecutionSettings)
|
||||
{
|
||||
// Attempt to get the connection for the editor
|
||||
ConnectionInfo connectionInfo;
|
||||
if (connInfo != null)
|
||||
if (connInfo != null)
|
||||
{
|
||||
connectionInfo = connInfo;
|
||||
}
|
||||
}
|
||||
else if (!ConnectionService.TryFindConnection(executeParams.OwnerUri, out connectionInfo))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(executeParams.OwnerUri), SR.QueryServiceQueryInvalidOwnerUri);
|
||||
@@ -752,11 +758,11 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
oldQuery.Dispose();
|
||||
ActiveQueries.TryRemove(executeParams.OwnerUri, out oldQuery);
|
||||
}
|
||||
|
||||
|
||||
// check if there are active query execution settings for the editor, otherwise, use the global settings
|
||||
QueryExecutionSettings settings;
|
||||
QueryExecutionSettings settings;
|
||||
if (this.ActiveQueryExecutionSettings.TryGetValue(executeParams.OwnerUri, out settings))
|
||||
{
|
||||
{
|
||||
// special-case handling for query plan options to maintain compat with query execution API parameters
|
||||
// the logic is that if either the query execute API parameters or the active query setttings
|
||||
// request a plan then enable the query option
|
||||
@@ -772,17 +778,17 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
settings.ExecutionPlanOptions = executionPlanOptions;
|
||||
}
|
||||
else
|
||||
{
|
||||
{
|
||||
settings = Settings.QueryExecutionSettings;
|
||||
settings.ExecutionPlanOptions = executeParams.ExecutionPlanOptions;
|
||||
}
|
||||
|
||||
// If we can't add the query now, it's assumed the query is in progress
|
||||
Query newQuery = new Query(
|
||||
GetSqlText(executeParams),
|
||||
connectionInfo,
|
||||
settings,
|
||||
BufferFileFactory,
|
||||
GetSqlText(executeParams),
|
||||
connectionInfo,
|
||||
settings,
|
||||
BufferFileFactory,
|
||||
executeParams.GetFullColumnSchema,
|
||||
applyExecutionSettings);
|
||||
|
||||
@@ -893,13 +899,33 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution
|
||||
// Setup the ResultSet updated callback
|
||||
ResultSet.ResultSetAsyncEventHandler resultUpdatedCallback = async r =>
|
||||
{
|
||||
|
||||
//Generating and sending an execution plan graphs if it is requested.
|
||||
List<ExecutionPlanGraph> plans = null;
|
||||
string planErrors = "";
|
||||
if (r.Summary.Complete && r.Summary.SpecialAction.ExpectYukonXMLShowPlan && r.RowCount == 1 && r.GetRow(0)[0] != null)
|
||||
{
|
||||
var xmlString = r.GetRow(0)[0].DisplayValue;
|
||||
try
|
||||
{
|
||||
plans = ShowPlanGraphUtils.CreateShowPlanGraph(xmlString);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// In case of error we are sending an empty execution plan graph with the error message.
|
||||
Logger.Write(TraceEventType.Error, String.Format("Failed to generate show plan graph{0}{1}", Environment.NewLine, ex.Message));
|
||||
planErrors = ex.Message;
|
||||
}
|
||||
|
||||
}
|
||||
ResultSetUpdatedEventParams eventParams = new ResultSetUpdatedEventParams
|
||||
{
|
||||
ResultSetSummary = r.Summary,
|
||||
OwnerUri = ownerUri
|
||||
OwnerUri = ownerUri,
|
||||
ExecutionPlans = plans,
|
||||
ExecutionPlanErrorMessage = planErrors
|
||||
};
|
||||
|
||||
Logger.Write(TraceEventType.Information, $"Result:'{r.Summary} on Query:'{ownerUri}' is updated with additional rows");
|
||||
await eventSender.SendEvent(ResultSetUpdatedEvent.Type, eventParams);
|
||||
};
|
||||
query.ResultSetUpdated += resultUpdatedCallback;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// 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;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ShowPlan
|
||||
{
|
||||
/// <summary>
|
||||
/// Execution plan graph object that is sent over JSON RPC
|
||||
/// </summary>
|
||||
public class ExecutionPlanGraph
|
||||
{
|
||||
/// <summary>
|
||||
/// Root of the execution plan tree
|
||||
/// </summary>
|
||||
public ExecutionPlanNode Root { get; set; }
|
||||
/// <summary>
|
||||
/// Underlying query for the execution plan graph
|
||||
/// </summary>
|
||||
public string Query { get; set; }
|
||||
}
|
||||
|
||||
public class ExecutionPlanNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of the node. This determines the icon that is displayed for it
|
||||
/// </summary>
|
||||
public string Type { get; set; }
|
||||
/// <summary>
|
||||
/// Cost associated with the node
|
||||
/// </summary>
|
||||
public double Cost { get; set; }
|
||||
/// <summary>
|
||||
/// Cost of the node subtree
|
||||
/// </summary>
|
||||
public double SubTreeCost { get; set; }
|
||||
/// <summary>
|
||||
/// Relative cost of the node compared to its siblings.
|
||||
/// </summary>
|
||||
public double RelativeCost { get; set; }
|
||||
/// <summary>
|
||||
/// Time take by the node operation in milliseconds
|
||||
/// </summary>
|
||||
public long? ElapsedTimeInMs { get; set; }
|
||||
/// <summary>
|
||||
/// Node properties to be shown in the tooltip
|
||||
/// </summary>
|
||||
public List<ExecutionPlanGraphElementProperties> Properties { get; set; }
|
||||
/// <summary>
|
||||
/// Display name for the node
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Description associated with the node.
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>
|
||||
/// Subtext displayed under the node name
|
||||
/// </summary>
|
||||
public string[] Subtext { get; set; }
|
||||
public List<ExecutionPlanNode> Children { get; set; }
|
||||
public List<ExecutionPlanEdges> Edges { get; set; }
|
||||
}
|
||||
|
||||
public class ExecutionPlanGraphElementProperties
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the property
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Formatted value for the property
|
||||
/// </summary>
|
||||
public string FormattedValue { get; set; }
|
||||
/// <summary>
|
||||
/// Flag to show/hide props in tooltip
|
||||
/// </summary>
|
||||
public bool ShowInTooltip { get; set; }
|
||||
/// <summary>
|
||||
/// Display order of property
|
||||
/// </summary>
|
||||
public int DisplayOrder { get; set; }
|
||||
/// <summary>
|
||||
/// Flag to indicate if the property has a longer value so that it will be shown at the bottom of the tooltip
|
||||
/// </summary>
|
||||
public bool IsLongString { get; set; }
|
||||
}
|
||||
|
||||
public class ExecutionPlanEdges
|
||||
{
|
||||
/// <summary>
|
||||
/// Count of the rows returned by the subtree of the edge.
|
||||
/// </summary>
|
||||
public double RowCount { get; set; }
|
||||
/// <summary>
|
||||
/// Size of the rows returned by the subtree of the edge.
|
||||
/// </summary>
|
||||
/// <value></value>
|
||||
public double RowSize { get; set; }
|
||||
/// <summary>
|
||||
/// Edge properties to be shown in the tooltip.
|
||||
/// </summary>
|
||||
/// <value></value>
|
||||
public List<ExecutionPlanGraphElementProperties> Properties { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
||||
//
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
{
|
||||
public class Constants
|
||||
{
|
||||
public static string Parenthesis(string text)
|
||||
{
|
||||
return string.Format("({0})", text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
|
||||
if (parentNode != null)
|
||||
{
|
||||
parentNode.Children.AddLast(node);
|
||||
parentNode.Children.Add(node);
|
||||
}
|
||||
|
||||
// Add node to the hashtable
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
{
|
||||
@@ -29,12 +31,12 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
{
|
||||
this.ID = id;
|
||||
this.properties = new PropertyDescriptorCollection(new PropertyDescriptor[0]);
|
||||
this.children = new LinkedList<Node>();
|
||||
this.childrenEdges = new LinkedList<Edge>();
|
||||
this.children = new List<Node>();
|
||||
this.childrenEdges = new List<Edge>();
|
||||
this.LogicalOpUnlocName = null;
|
||||
this.PhysicalOpUnlocName = null;
|
||||
this.root = context.Graph.Root;
|
||||
if(this.root == null)
|
||||
if (this.root == null)
|
||||
{
|
||||
this.root = this;
|
||||
}
|
||||
@@ -55,6 +57,159 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
get; set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets Node display name
|
||||
/// </summary>
|
||||
public virtual string DisplayName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.Operation == Operation.Unknown)
|
||||
{
|
||||
return String.Empty;
|
||||
}
|
||||
|
||||
// The display name can consist of two lines
|
||||
// The first line is the Physical name and the physical kind in parenthesis
|
||||
// The second line should contains either Object value or LogicalOp name.
|
||||
// The second line should not show the same content as the first line.
|
||||
|
||||
string firstLine = this["PhysicalOp"] as string;
|
||||
if (firstLine == null)
|
||||
{
|
||||
if (this.Operation == null)
|
||||
{
|
||||
return String.Empty;
|
||||
}
|
||||
|
||||
firstLine = this.Operation.DisplayName;
|
||||
}
|
||||
|
||||
// Check if the PhysicalOp is specialized to a specific kind
|
||||
string firstLineAppend = this["PhysicalOperationKind"] as string;
|
||||
if (firstLineAppend != null)
|
||||
{
|
||||
firstLine = String.Format(CultureInfo.CurrentCulture, "{0} {1}", firstLine, Constants.Parenthesis(firstLineAppend));
|
||||
}
|
||||
|
||||
|
||||
string secondLine;
|
||||
|
||||
object objectValue = this["Object"];
|
||||
if (objectValue != null)
|
||||
{
|
||||
secondLine = GetObjectNameForDisplay(objectValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
secondLine = this["LogicalOp"] as string;
|
||||
if (secondLine != null)
|
||||
{
|
||||
if (secondLine != firstLine)
|
||||
{
|
||||
// Enclose logical name in parenthesis.
|
||||
secondLine = Constants.Parenthesis(secondLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Don't show the second line if its value is the same as on the first line.
|
||||
secondLine = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return secondLine == null || secondLine.Length == 0
|
||||
? firstLine
|
||||
: String.Format(CultureInfo.CurrentCulture, "{0}\n{1}", firstLine, secondLine);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets Node description
|
||||
/// </summary>
|
||||
[DisplayOrder(2), DisplayNameDescription(SR.Keys.OperationDescriptionShort, SR.Keys.OperationDescription)]
|
||||
public string Description
|
||||
{
|
||||
get { return this.Operation.Description; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value that indicates Node parallelism.
|
||||
/// </summary>
|
||||
public bool IsParallel
|
||||
{
|
||||
get
|
||||
{
|
||||
object value = this["Parallel"];
|
||||
return value != null ? (bool)value : false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value that indicates whether the Node has warnings.
|
||||
/// </summary>
|
||||
[Browsable(false)]
|
||||
public bool HasWarnings
|
||||
{
|
||||
get
|
||||
{
|
||||
return this["Warnings"] != null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value that indicates whether the Node has critical warnings.
|
||||
/// </summary>
|
||||
private bool HasCriticalWarnings
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this["Warnings"] != null)
|
||||
{
|
||||
ExpandableObjectWrapper wrapper = this["Warnings"] as ExpandableObjectWrapper;
|
||||
if (wrapper["NoJoinPredicate"] != null)
|
||||
{
|
||||
return (bool)wrapper["NoJoinPredicate"];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if this showplan_xml has PDW cost.
|
||||
/// </summary>
|
||||
private bool HasPDWCost
|
||||
{
|
||||
get
|
||||
{
|
||||
return this["PDWAccumulativeCost"] != null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cost associated with the Node.
|
||||
/// </summary>
|
||||
[ShowInToolTip, DisplayOrder(8), DisplayNameDescription(SR.Keys.EstimatedOperatorCost, SR.Keys.EstimatedOperatorCostDescription)]
|
||||
public string DisplayCost
|
||||
{
|
||||
get
|
||||
{
|
||||
double cost = this.RelativeCost * 100;
|
||||
if (this.HasPDWCost && cost <= 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
return SR.OperatorDisplayCost(this.Cost, (int)Math.Round(cost));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cost associated with the current Node.
|
||||
/// </summary>
|
||||
@@ -95,6 +250,7 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
/// <summary>
|
||||
/// Gets the cost associated with the Node subtree.
|
||||
/// </summary>
|
||||
[ShowInToolTip, DisplayOrder(9), DisplayNameDescription(SR.Keys.EstimatedSubtreeCost, SR.Keys.EstimatedSubtreeCostDescription)]
|
||||
public double SubtreeCost
|
||||
{
|
||||
get
|
||||
@@ -116,6 +272,12 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Max Children X Position.
|
||||
/// </summary>
|
||||
public int MaxChildrenXPosition;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the operation information (localized name, description, image, etc)
|
||||
/// </summary>
|
||||
@@ -174,11 +336,20 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
/// <summary>
|
||||
/// Gets collection of node children.
|
||||
/// </summary>
|
||||
public LinkedList<Node> Children
|
||||
public List<Node> Children
|
||||
{
|
||||
get { return this.children; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets collection of node children.
|
||||
/// </summary>
|
||||
public List<Edge> Edges
|
||||
{
|
||||
get { return this.childrenEdges; }
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets current node parent.
|
||||
/// </summary>
|
||||
@@ -320,6 +491,26 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
return true;
|
||||
}
|
||||
|
||||
public long? ElapsedTimeInMs
|
||||
{
|
||||
get
|
||||
{
|
||||
long? time = null;
|
||||
var actualStatsWrapper = this["ActualTimeStatistics"] as ExpandableObjectWrapper;
|
||||
if (actualStatsWrapper != null)
|
||||
{
|
||||
var counters = actualStatsWrapper["ActualElapsedms"] as RunTimeCounters;
|
||||
if (counters != null)
|
||||
{
|
||||
var elapsedTime = counters.MaxCounter;
|
||||
long ticks = (long)elapsedTime * TimeSpan.TicksPerMillisecond;
|
||||
time = new DateTime(ticks).Millisecond;
|
||||
}
|
||||
}
|
||||
return time;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ENU name for Logical Operator
|
||||
/// </summary>
|
||||
@@ -334,6 +525,33 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
|
||||
#region Implementation details
|
||||
|
||||
/// <summary>
|
||||
/// Gets short object name for display.
|
||||
/// Since database and schema is not important and displaying table first is much useful,
|
||||
/// we are displaying object name in [Table].[Index] [Alias] format.
|
||||
/// </summary>
|
||||
/// <param name="objectProperty">Object property in the property bag</param>
|
||||
private string GetObjectNameForDisplay(object objectProperty)
|
||||
{
|
||||
string objectNameForDisplay = string.Empty;
|
||||
|
||||
Debug.Assert(objectProperty != null);
|
||||
if (objectProperty != null)
|
||||
{
|
||||
objectNameForDisplay = objectProperty.ToString();
|
||||
|
||||
ExpandableObjectWrapper objectWrapper = objectProperty as ExpandableObjectWrapper;
|
||||
Debug.Assert(objectWrapper != null);
|
||||
if (objectWrapper != null)
|
||||
{
|
||||
objectNameForDisplay = ObjectWrapperTypeConverter.MergeString(".", objectWrapper["Table"], objectWrapper["Index"]);
|
||||
objectNameForDisplay = ObjectWrapperTypeConverter.MergeString(" ", objectNameForDisplay, objectWrapper["Alias"]);
|
||||
}
|
||||
}
|
||||
|
||||
return objectNameForDisplay;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// used to compare multiple string type PropertyValue in Object properties,
|
||||
/// for ex: Server, Database, Schema, Table, Index, etc...
|
||||
@@ -358,6 +576,130 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets lines of text displayed under the icon.
|
||||
/// </summary>
|
||||
/// <returns>Array of strings.</returns>
|
||||
public string[] GetDisplayLinesOfText()
|
||||
{
|
||||
string newDisplayNameLines = this.DisplayName;
|
||||
|
||||
// cost
|
||||
double cost = this.RelativeCost * 100;
|
||||
|
||||
if (!this.HasPDWCost || cost > 0)
|
||||
{
|
||||
string costText = SR.CostFormat((int)Math.Round(cost));
|
||||
newDisplayNameLines += '\n' + costText;
|
||||
}
|
||||
|
||||
|
||||
// elapsed time in miliseconds
|
||||
string elapsedTime = GetElapsedTimeDisplayString();
|
||||
if (!String.IsNullOrEmpty(elapsedTime))
|
||||
{
|
||||
newDisplayNameLines += '\n' + elapsedTime;
|
||||
}
|
||||
|
||||
// actual/estimated rows
|
||||
string rowStatistics = GetRowStatisticsDisplayString();
|
||||
if (!String.IsNullOrEmpty(rowStatistics))
|
||||
{
|
||||
newDisplayNameLines += '\n' + rowStatistics;
|
||||
}
|
||||
|
||||
return newDisplayNameLines.Split('\n');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provide a string for the actual elapsed time if it is available
|
||||
/// </summary>
|
||||
/// <returns>formatted string of execution time</returns>
|
||||
public string GetElapsedTimeDisplayString()
|
||||
{
|
||||
string formattedTime = null;
|
||||
|
||||
var actualStatsWrapper = this["ActualTimeStatistics"] as ExpandableObjectWrapper;
|
||||
if (actualStatsWrapper != null)
|
||||
{
|
||||
var counters = actualStatsWrapper["ActualElapsedms"] as RunTimeCounters;
|
||||
if (counters != null)
|
||||
{
|
||||
var elapsedTime = counters.MaxCounter;
|
||||
long ticks = (long)elapsedTime * TimeSpan.TicksPerMillisecond;
|
||||
var time = new DateTime(ticks);
|
||||
if (ticks < 1000L * TimeSpan.TicksPerMillisecond * 60) // 60 seconds
|
||||
{
|
||||
formattedTime = time.ToString("s.fff") + "s";
|
||||
}
|
||||
else
|
||||
{
|
||||
// calculate the hours
|
||||
long hours = ticks / (1000L * TimeSpan.TicksPerMillisecond * 60 * 60); //1 hour
|
||||
formattedTime = hours.ToString() + time.ToString(":mm:ss");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formattedTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provide a string for the actual rows vs estimated rows if they are both available in the actual execution plan
|
||||
/// </summary>
|
||||
/// <returns>formatted string of actual rows vs estimated rows; or null if estimateRows or actualRows is null</returns>
|
||||
private string GetRowStatisticsDisplayString()
|
||||
{
|
||||
var actualRowsCounters = this[NodeBuilderConstants.ActualRows] as RunTimeCounters;
|
||||
ulong? actualRows = actualRowsCounters != null ? actualRowsCounters.TotalCounters : (ulong?)null;
|
||||
var estimateRows = this[NodeBuilderConstants.EstimateRows] as double?;
|
||||
var estimateExecutions = this[NodeBuilderConstants.EstimateExecutions] as double?;
|
||||
|
||||
if (estimateRows != null)
|
||||
{
|
||||
if (estimateExecutions != null)
|
||||
{
|
||||
estimateRows = estimateRows * estimateExecutions;
|
||||
}
|
||||
// we display estimate rows as integer so need round function
|
||||
estimateRows = Math.Round(estimateRows.Value);
|
||||
}
|
||||
|
||||
return GetRowStatisticsDisplayString(actualRows, estimateRows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inner function to provide a string for the actual rows vs estimated rows if they are both available in the actual execution plan
|
||||
/// </summary>
|
||||
/// <param name="actualRows">actual rows</param>
|
||||
/// <param name="estimateRows">estimated rows</param>
|
||||
/// <returns>formatted string of actual rows vs estimated rows; or null if any of the arguments is null</returns>
|
||||
private string GetRowStatisticsDisplayString(ulong? actualRows, double? estimateRows)
|
||||
{
|
||||
if (!actualRows.HasValue || !estimateRows.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// estimateRows should always to be positive, I just change it to 1 just in case since we need to calculate the percentage
|
||||
estimateRows = estimateRows > 0 ? estimateRows : 1;
|
||||
|
||||
// get the difference in percentage
|
||||
var actualString = actualRows.Value.ToString();
|
||||
var estimateString = estimateRows.Value.ToString();
|
||||
int percent = 100;
|
||||
if (estimateRows > 0)
|
||||
{
|
||||
percent = (int)(100 * ((double)actualRows / estimateRows));
|
||||
}
|
||||
|
||||
actualString = actualString.PadLeft(estimateString.Length);
|
||||
estimateString = estimateString.PadLeft(actualString.Length);
|
||||
|
||||
return SR.ActualOfEstimated(actualString, estimateString, percent);
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private variables
|
||||
@@ -367,13 +709,13 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
private double subtreeCost;
|
||||
private Operation operation;
|
||||
private PropertyDescriptorCollection properties;
|
||||
private LinkedList<Node> children;
|
||||
private List<Node> children;
|
||||
private readonly string objectProperty = NodeBuilderConstants.Object;
|
||||
private readonly string predicateProperty = NodeBuilderConstants.LogicalOp;
|
||||
private Node parent;
|
||||
private Graph graph;
|
||||
private Edge parentEdge;
|
||||
private LinkedList<Edge> childrenEdges;
|
||||
private List<Edge> childrenEdges;
|
||||
private string nodeType;
|
||||
|
||||
private Node root;
|
||||
@@ -388,9 +730,9 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
public void AddChild(Node child)
|
||||
{
|
||||
Edge edge = new Edge(this, child);
|
||||
this.childrenEdges.AddLast(edge);
|
||||
this.childrenEdges.Add(edge);
|
||||
child.parentEdge = edge;
|
||||
this.children.AddLast(child);
|
||||
this.children.Add(child);
|
||||
child.parent = this;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ namespace Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph
|
||||
set { this.propertyValue = value; }
|
||||
}
|
||||
|
||||
public string DisplayValue
|
||||
{
|
||||
get => this.Converter.ConvertToString(null, null, this.Value);
|
||||
}
|
||||
|
||||
public int DisplayOrder
|
||||
{
|
||||
get
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// 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.ComponentModel;
|
||||
using System.Linq;
|
||||
using Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ShowPlan
|
||||
{
|
||||
public class ShowPlanGraphUtils
|
||||
{
|
||||
public static List<ExecutionPlanGraph> CreateShowPlanGraph(string xml)
|
||||
{
|
||||
ShowPlanGraph.ShowPlanGraph[] graphs = ShowPlanGraph.ShowPlanGraph.ParseShowPlanXML(xml, ShowPlanGraph.ShowPlanType.Unknown);
|
||||
return graphs.Select(g => new ExecutionPlanGraph
|
||||
{
|
||||
Root = ConvertShowPlanTreeToExecutionPlanTree(g.Root),
|
||||
Query = g.Statement
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static ExecutionPlanNode ConvertShowPlanTreeToExecutionPlanTree(Node currentNode)
|
||||
{
|
||||
return new ExecutionPlanNode
|
||||
{
|
||||
Type = currentNode.Operation.Image,
|
||||
Cost = currentNode.Cost,
|
||||
SubTreeCost = currentNode.SubtreeCost,
|
||||
Description = currentNode.Description,
|
||||
Subtext = currentNode.GetDisplayLinesOfText(),
|
||||
RelativeCost = currentNode.RelativeCost,
|
||||
Properties = GetProperties(currentNode.Properties),
|
||||
Children = currentNode.Children.Select(x => ConvertShowPlanTreeToExecutionPlanTree(x)).ToList(),
|
||||
Edges = currentNode.Edges.Select(x => ConvertShowPlanEdgeToExecutionPlanEdge(x)).ToList(),
|
||||
Name = currentNode.DisplayName,
|
||||
ElapsedTimeInMs = currentNode.ElapsedTimeInMs
|
||||
};
|
||||
}
|
||||
|
||||
private static ExecutionPlanEdges ConvertShowPlanEdgeToExecutionPlanEdge(Edge edge)
|
||||
{
|
||||
return new ExecutionPlanEdges
|
||||
{
|
||||
RowCount = edge.RowCount,
|
||||
RowSize = edge.RowSize,
|
||||
Properties = GetProperties(edge.Properties)
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ExecutionPlanGraphElementProperties> GetProperties(PropertyDescriptorCollection props)
|
||||
{
|
||||
List<ExecutionPlanGraphElementProperties> propsList = new List<ExecutionPlanGraphElementProperties>();
|
||||
foreach (PropertyValue prop in props)
|
||||
{
|
||||
propsList.Add(new ExecutionPlanGraphElementProperties()
|
||||
{
|
||||
Name = prop.DisplayName,
|
||||
FormattedValue = prop.DisplayValue,
|
||||
ShowInTooltip = prop.IsBrowsable,
|
||||
DisplayOrder = prop.DisplayOrder,
|
||||
IsLongString = prop.IsLongString
|
||||
});
|
||||
}
|
||||
return propsList;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,10 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.SqlServer.DataCollection.Common;
|
||||
using Microsoft.SqlTools.Hosting.Protocol;
|
||||
using Microsoft.SqlTools.ServiceLayer.Connection.Contracts;
|
||||
using Microsoft.SqlTools.ServiceLayer.Connection.ReliableConnection;
|
||||
using Microsoft.SqlTools.ServiceLayer.Connection;
|
||||
using Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph;
|
||||
using Microsoft.SqlTools.ServiceLayer.Hosting;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ShowPlan
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
||||
//
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Microsoft.SqlTools.ServiceLayer.ShowPlan.ShowPlanGraph;
|
||||
using Microsoft.SqlTools.ServiceLayer.ShowPlan;
|
||||
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.UnitTests.ShowPlan
|
||||
@@ -16,17 +14,16 @@ namespace Microsoft.SqlTools.ServiceLayer.UnitTests.ShowPlan
|
||||
public class ShowPlanXMLTests
|
||||
{
|
||||
[Test]
|
||||
public async Task ParseXMLFileReturnsValidShowPlanGraph()
|
||||
public void ParseXMLFileReturnsValidShowPlanGraph()
|
||||
{
|
||||
Assembly assembly = Assembly.GetAssembly(typeof(ShowPlanXMLTests));
|
||||
Stream scriptStream = assembly.GetManifestResourceStream(assembly.GetName().Name + ".ShowPlan.TestExecutionPlan.xml");
|
||||
StreamReader reader = new StreamReader(scriptStream);
|
||||
string text = reader.ReadToEnd();
|
||||
var showPlanGraphs = ShowPlanGraph.ParseShowPlanXML(text, ShowPlanType.Actual);
|
||||
Assert.AreEqual(1, showPlanGraphs.Length, "Single show plan graph not generated from the test xml file");
|
||||
var testShowPlanGraph = showPlanGraphs[0];
|
||||
Assert.NotNull(testShowPlanGraph, "graph should not be null");
|
||||
Assert.NotNull(testShowPlanGraph.Root, "graph should have a root");
|
||||
var showPlanGraphs = ShowPlanGraphUtils.CreateShowPlanGraph(text);
|
||||
Assert.AreEqual(1, showPlanGraphs.Count, "exactly one show plan graph should be returned");
|
||||
Assert.NotNull(showPlanGraphs[0], "graph should not be null");
|
||||
Assert.NotNull(showPlanGraphs[0].Root, "graph should have a root");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user