mirror of
https://github.com/ckaczor/sqltoolsservice.git
synced 2026-02-08 01:28:29 -05:00
Adds new graph comparison request handler to the Execution Plan Service. (#1438)
* Adds new graph comparison request handler to the Execution Plan Service. * Code review changes * Renames execution plan compare params, result, and request classes.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// 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.ExecutionPlan.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// An ExecutionGraphComparisonResult is composed of an execution plan node, but has additional properties
|
||||
/// to keep track of matching ExecutionGraphComparisonResult nodes for execution plan nodes present in the
|
||||
/// the graph being compared against. This class also features a group index that can assist
|
||||
/// with coloring similar sections of execution plans in the UI.
|
||||
/// </summary>
|
||||
public class ExecutionGraphComparisonResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The base ExecutionPlanNode for the ExecutionGraphComparisonResult.
|
||||
/// </summary>
|
||||
public ExecutionPlanNode BaseNode { get; set; }
|
||||
/// <summary>
|
||||
/// The children of the ExecutionGraphComparisonResult.
|
||||
/// </summary>
|
||||
public List<ExecutionGraphComparisonResult> Children { get; set; } = new List<ExecutionGraphComparisonResult>();
|
||||
/// <summary>
|
||||
/// The group index of the ExecutionGraphComparisonResult.
|
||||
/// </summary>
|
||||
public int GroupIndex { get; set; }
|
||||
/// <summary>
|
||||
/// Flag to indicate if the ExecutionGraphComparisonResult has a matching node in the compared execution plan.
|
||||
/// </summary>
|
||||
public bool HasMatch { get; set; }
|
||||
/// <summary>
|
||||
/// List of matching nodes for the ExecutionGraphComparisonResult.
|
||||
/// </summary>
|
||||
public List<ExecutionGraphComparisonResult> MatchingNodes { get; set; } = new List<ExecutionGraphComparisonResult>();
|
||||
/// <summary>
|
||||
/// The parent of the ExecutionGraphComparisonResult.
|
||||
/// </summary>
|
||||
public ExecutionGraphComparisonResult ParentNode { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
||||
//
|
||||
|
||||
using Microsoft.SqlTools.Hosting.Protocol.Contracts;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan.Contracts
|
||||
{
|
||||
public class ExecutionPlanComparisonParams
|
||||
{
|
||||
/// <summary>
|
||||
/// First query execution plan for comparison.
|
||||
/// </summary>
|
||||
public ExecutionPlanGraphInfo FirstExecutionPlanGraphInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Second query execution plan for comparison.
|
||||
/// </summary>
|
||||
public ExecutionPlanGraphInfo SecondExecutionPlanGraphInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Flag to indicate if the database name should be ignored
|
||||
/// during comparisons.
|
||||
/// </summary>
|
||||
public bool IgnoreDatabaseName { get; set; }
|
||||
}
|
||||
|
||||
public class ExecutionPlanComparisonResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Created ExecutionGraphComparisonResult for the first execution plan
|
||||
/// </summary>
|
||||
public ExecutionGraphComparisonResult FirstComparisonResult { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Created ExecutionGraphComparisonResult for the second execution plan
|
||||
/// </summary>
|
||||
public ExecutionGraphComparisonResult SecondComparisonResult { get; set; }
|
||||
}
|
||||
|
||||
public class ExecutionPlanComparisonRequest
|
||||
{
|
||||
public static readonly
|
||||
RequestType<ExecutionPlanComparisonParams, ExecutionPlanComparisonResult> Type =
|
||||
RequestType<ExecutionPlanComparisonParams, ExecutionPlanComparisonResult>.Create("executionPlan/compareExecutionPlanGraph");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// Execution plan graph object that is sent over JSON RPC
|
||||
@@ -32,6 +32,10 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
|
||||
public class ExecutionPlanNode
|
||||
{
|
||||
/// <summary>
|
||||
/// ID for the node.
|
||||
/// </summary>
|
||||
public int ID { get; set; }
|
||||
/// <summary>
|
||||
/// Type of the node. This determines the icon that is displayed for it
|
||||
/// </summary>
|
||||
@@ -146,7 +150,7 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
public string QueryWithDescription { get; set; }
|
||||
}
|
||||
|
||||
public class ExecutionPlanGraphInfo
|
||||
public class ExecutionPlanGraphInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// File contents
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.SqlTools.Hosting.Protocol.Contracts;
|
||||
using Microsoft.SqlTools.ServiceLayer.ExecutionPlan.Contracts;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
{
|
||||
|
||||
@@ -10,14 +10,15 @@ using System.Linq;
|
||||
using Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan;
|
||||
using Microsoft.SqlTools.Utility;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.SqlTools.ServiceLayer.ExecutionPlan.Contracts;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
{
|
||||
public class ShowPlanGraphUtils
|
||||
public class ExecutionPlanGraphUtils
|
||||
{
|
||||
public static List<ExecutionPlanGraph> CreateShowPlanGraph(string xml, string fileName)
|
||||
{
|
||||
ShowPlan.ShowPlanGraph[] graphs = ShowPlan.ShowPlanGraph.ParseShowPlanXML(xml, ShowPlan.ShowPlanType.Unknown);
|
||||
ShowPlanGraph[] graphs = ShowPlanGraph.ParseShowPlanXML(xml, ShowPlan.ShowPlanType.Unknown);
|
||||
return graphs.Select(g => new ExecutionPlanGraph
|
||||
{
|
||||
Root = ConvertShowPlanTreeToExecutionPlanTree(g.Root),
|
||||
@@ -31,10 +32,11 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static ExecutionPlanNode ConvertShowPlanTreeToExecutionPlanTree(Node currentNode)
|
||||
public static ExecutionPlanNode ConvertShowPlanTreeToExecutionPlanTree(Node currentNode)
|
||||
{
|
||||
return new ExecutionPlanNode
|
||||
{
|
||||
ID = currentNode.ID,
|
||||
Type = currentNode.Operation.Image,
|
||||
Cost = currentNode.Cost,
|
||||
SubTreeCost = currentNode.SubtreeCost,
|
||||
@@ -49,7 +51,7 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
};
|
||||
}
|
||||
|
||||
private static ExecutionPlanEdges ConvertShowPlanEdgeToExecutionPlanEdge(Edge edge)
|
||||
public static ExecutionPlanEdges ConvertShowPlanEdgeToExecutionPlanEdge(Edge edge)
|
||||
{
|
||||
return new ExecutionPlanEdges
|
||||
{
|
||||
@@ -59,7 +61,7 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ExecutionPlanGraphPropertyBase> GetProperties(PropertyDescriptorCollection props)
|
||||
public static List<ExecutionPlanGraphPropertyBase> GetProperties(PropertyDescriptorCollection props)
|
||||
{
|
||||
List<ExecutionPlanGraphPropertyBase> propsList = new List<ExecutionPlanGraphPropertyBase>();
|
||||
foreach (PropertyValue prop in props)
|
||||
@@ -96,7 +98,7 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
return propsList;
|
||||
}
|
||||
|
||||
private static List<ExecutionPlanRecommendation> ParseRecommendations(ShowPlan.ShowPlanGraph g, string fileName)
|
||||
private static List<ExecutionPlanRecommendation> ParseRecommendations(ShowPlanGraph g, string fileName)
|
||||
{
|
||||
return g.Description.MissingIndices.Select(mi => new ExecutionPlanRecommendation
|
||||
{
|
||||
@@ -148,5 +150,58 @@ GO
|
||||
return String.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public static void CopyMatchingNodesIntoSkeletonDTO(ExecutionGraphComparisonResult destRoot, ExecutionGraphComparisonResult srcRoot)
|
||||
{
|
||||
var srcGraphLookupTable = srcRoot.CreateSkeletonLookupTable();
|
||||
|
||||
var queue = new Queue<ExecutionGraphComparisonResult>();
|
||||
queue.Enqueue(destRoot);
|
||||
|
||||
while (queue.Count != 0)
|
||||
{
|
||||
var curNode = queue.Dequeue();
|
||||
|
||||
for (int index = 0; index < curNode.MatchingNodes.Count; ++index)
|
||||
{
|
||||
var matchingId = curNode.MatchingNodes[index].BaseNode.ID;
|
||||
var matchingNode = srcGraphLookupTable[matchingId];
|
||||
|
||||
curNode.MatchingNodes[index] = matchingNode;
|
||||
}
|
||||
|
||||
foreach (var child in curNode.Children)
|
||||
{
|
||||
queue.Enqueue(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class ExecutionGraphComparisonResultExtensions
|
||||
{
|
||||
public static Dictionary<int, ExecutionGraphComparisonResult> CreateSkeletonLookupTable(this ExecutionGraphComparisonResult node)
|
||||
{
|
||||
var skeletonNodeTable = new Dictionary<int, ExecutionGraphComparisonResult>();
|
||||
var queue = new Queue<ExecutionGraphComparisonResult>();
|
||||
queue.Enqueue(node);
|
||||
|
||||
while (queue.Count != 0)
|
||||
{
|
||||
var curNode = queue.Dequeue();
|
||||
|
||||
if (!skeletonNodeTable.ContainsKey(curNode.BaseNode.ID))
|
||||
{
|
||||
skeletonNodeTable[curNode.BaseNode.ID] = curNode;
|
||||
}
|
||||
|
||||
foreach (var child in curNode.Children)
|
||||
{
|
||||
queue.Enqueue(child);
|
||||
}
|
||||
}
|
||||
|
||||
return skeletonNodeTable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,15 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.SqlTools.Hosting.Protocol;
|
||||
using Microsoft.SqlTools.ServiceLayer.ExecutionPlan.Contracts;
|
||||
using Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan;
|
||||
using Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan.Comparison;
|
||||
using Microsoft.SqlTools.ServiceLayer.Hosting;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
{
|
||||
/// <summary>
|
||||
/// Main class for Migration Service functionality
|
||||
/// Main class for Execution Plan Service functionality
|
||||
/// </summary>
|
||||
public sealed class ExecutionPlanService : IDisposable
|
||||
{
|
||||
@@ -20,9 +23,9 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Construct a new MigrationService instance with default parameters
|
||||
/// Construct a new Execution Plan Service instance with default parameters
|
||||
/// </summary>
|
||||
public ExecutionPlanService()
|
||||
private ExecutionPlanService()
|
||||
{
|
||||
}
|
||||
|
||||
@@ -38,26 +41,23 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
/// Service host object for sending/receiving requests/events.
|
||||
/// Internal for testing purposes.
|
||||
/// </summary>
|
||||
internal IProtocolEndpoint ServiceHost
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
internal IProtocolEndpoint ServiceHost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the ShowPlan Service instance
|
||||
/// Initializes the Execution Plan Service instance
|
||||
/// </summary>
|
||||
public void InitializeService(ServiceHost serviceHost)
|
||||
{
|
||||
this.ServiceHost = serviceHost;
|
||||
this.ServiceHost.SetRequestHandler(GetExecutionPlanRequest.Type, HandleGetExecutionPlan);
|
||||
ServiceHost = serviceHost;
|
||||
ServiceHost.SetRequestHandler(GetExecutionPlanRequest.Type, HandleGetExecutionPlan);
|
||||
ServiceHost.SetRequestHandler(ExecutionPlanComparisonRequest.Type, HandleExecutionPlanComparisonRequest);
|
||||
}
|
||||
|
||||
private async Task HandleGetExecutionPlan(GetExecutionPlanParams requestParams, RequestContext<GetExecutionPlanResult> requestContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var plans = ShowPlanGraphUtils.CreateShowPlanGraph(requestParams.GraphInfo.GraphFileContent, "");
|
||||
var plans = ExecutionPlanGraphUtils.CreateShowPlanGraph(requestParams.GraphInfo.GraphFileContent, "");
|
||||
await requestContext.SendResult(new GetExecutionPlanResult
|
||||
{
|
||||
Graphs = plans
|
||||
@@ -70,7 +70,46 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the ShowPlan Service
|
||||
/// Handles requests for color matching similar nodes.
|
||||
/// </summary>
|
||||
internal async Task HandleExecutionPlanComparisonRequest(
|
||||
ExecutionPlanComparisonParams requestParams,
|
||||
RequestContext<ExecutionPlanComparisonResult> requestContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var firstGraphSet = ShowPlanGraph.ParseShowPlanXML(requestParams.FirstExecutionPlanGraphInfo.GraphFileContent, ShowPlanType.Unknown);
|
||||
var firstRootNode = firstGraphSet?[0]?.Root;
|
||||
|
||||
var secondGraphSet = ShowPlanGraph.ParseShowPlanXML(requestParams.SecondExecutionPlanGraphInfo.GraphFileContent, ShowPlanType.Unknown);
|
||||
var secondRootNode = secondGraphSet?[0]?.Root;
|
||||
|
||||
var manager = new SkeletonManager();
|
||||
var firstSkeletonNode = manager.CreateSkeleton(firstRootNode);
|
||||
var secondSkeletonNode = manager.CreateSkeleton(secondRootNode);
|
||||
manager.ColorMatchingSections(firstSkeletonNode, secondSkeletonNode, requestParams.IgnoreDatabaseName);
|
||||
|
||||
var firstGraphComparisonResultDTO = firstSkeletonNode.ConvertToDTO();
|
||||
var secondGraphComparisonResultDTO = secondSkeletonNode.ConvertToDTO();
|
||||
ExecutionPlanGraphUtils.CopyMatchingNodesIntoSkeletonDTO(firstGraphComparisonResultDTO, secondGraphComparisonResultDTO);
|
||||
ExecutionPlanGraphUtils.CopyMatchingNodesIntoSkeletonDTO(secondGraphComparisonResultDTO, firstGraphComparisonResultDTO);
|
||||
|
||||
var result = new ExecutionPlanComparisonResult()
|
||||
{
|
||||
FirstComparisonResult = firstGraphComparisonResultDTO,
|
||||
SecondComparisonResult = secondGraphComparisonResultDTO
|
||||
};
|
||||
|
||||
await requestContext.SendResult(result);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await requestContext.SendError(e.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the Execution Plan Service
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan.Comparison
|
||||
/// </summary>
|
||||
/// <param name="root">Node to construct skeleton of</param>
|
||||
/// <returns>SkeletonNode with children representing logical descendants of the input node</returns>
|
||||
public SkeletonNode CreateSkeleton(Node root)
|
||||
public SkeletonNode CreateSkeleton(Node root)
|
||||
{
|
||||
Node rootNode = root;
|
||||
var childCount = root.Children.Count;
|
||||
@@ -36,7 +36,7 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan.Comparison
|
||||
}
|
||||
return skeletonParent;
|
||||
}
|
||||
else if (childCount == 1)
|
||||
else if (childCount == 1)
|
||||
{
|
||||
if (!ShouldIgnoreDuringComparison(rootNode))
|
||||
{
|
||||
@@ -63,10 +63,14 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan.Comparison
|
||||
public bool AreSkeletonsEquivalent(SkeletonNode root1, SkeletonNode root2, bool ignoreDatabaseName)
|
||||
{
|
||||
if (root1 == null && root2 == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (root1 == null || root2 == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!root1.BaseNode.IsLogicallyEquivalentTo(root2.BaseNode, ignoreDatabaseName))
|
||||
{
|
||||
@@ -80,7 +84,7 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan.Comparison
|
||||
while (childIterator < root1.Children.Count)
|
||||
{
|
||||
var checkMatch = AreSkeletonsEquivalent(root1.Children.ElementAt(childIterator), root2.Children.ElementAt(childIterator), ignoreDatabaseName);
|
||||
if (!checkMatch)
|
||||
if (!checkMatch)
|
||||
{
|
||||
// at least one pair of children (ie inner.Child1 & outer.Child1) didn't match; stop checking rest
|
||||
return false;
|
||||
|
||||
@@ -4,19 +4,21 @@
|
||||
//
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.SqlTools.ServiceLayer.ExecutionPlan.Contracts;
|
||||
|
||||
namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan.Comparison
|
||||
{
|
||||
public class SkeletonNode
|
||||
{
|
||||
public Node BaseNode {get; set;}
|
||||
public Node BaseNode { get; set; }
|
||||
public List<SkeletonNode> MatchingNodes { get; set; }
|
||||
public bool HasMatch { get { return MatchingNodes.Count > 0; } }
|
||||
public SkeletonNode ParentNode { get; set; }
|
||||
public IList<SkeletonNode> Children { get; set; }
|
||||
|
||||
public int GroupIndex
|
||||
{
|
||||
public int GroupIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
return this.BaseNode.GroupIndex;
|
||||
@@ -54,7 +56,7 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan.Comparison
|
||||
}
|
||||
}
|
||||
|
||||
public void AddMatchingSkeletonNode(SkeletonNode match, bool ignoreDatabaseName, bool matchAllChildren=true)
|
||||
public void AddMatchingSkeletonNode(SkeletonNode match, bool ignoreDatabaseName, bool matchAllChildren = true)
|
||||
{
|
||||
this.BaseNode[NodeBuilderConstants.SkeletonHasMatch] = true;
|
||||
if (matchAllChildren == true)
|
||||
@@ -80,5 +82,43 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan.Comparison
|
||||
return this.BaseNode.Graph;
|
||||
}
|
||||
|
||||
public ExecutionGraphComparisonResult ConvertToDTO()
|
||||
{
|
||||
var queue = new Queue<SkeletonNode>();
|
||||
queue.Enqueue(this);
|
||||
|
||||
var skeletonNodeDTO = new ExecutionGraphComparisonResult();
|
||||
var dtoQueue = new Queue<ExecutionGraphComparisonResult>();
|
||||
dtoQueue.Enqueue(skeletonNodeDTO);
|
||||
|
||||
while (queue.Count != 0)
|
||||
{
|
||||
var curNode = queue.Dequeue();
|
||||
var curNodeDTO = dtoQueue.Dequeue();
|
||||
|
||||
curNodeDTO.BaseNode = ExecutionPlanGraphUtils.ConvertShowPlanTreeToExecutionPlanTree(curNode.BaseNode);
|
||||
curNodeDTO.GroupIndex = curNode.GroupIndex;
|
||||
curNodeDTO.HasMatch = curNode.HasMatch;
|
||||
curNodeDTO.MatchingNodes = curNode.MatchingNodes.Select(matchingNode =>
|
||||
{
|
||||
var skeletonNodeDTO = new Contracts.ExecutionGraphComparisonResult();
|
||||
skeletonNodeDTO.BaseNode = ExecutionPlanGraphUtils.ConvertShowPlanTreeToExecutionPlanTree(matchingNode.BaseNode);
|
||||
|
||||
return skeletonNodeDTO;
|
||||
}).ToList();
|
||||
|
||||
foreach (var child in curNode.Children)
|
||||
{
|
||||
queue.Enqueue(child);
|
||||
|
||||
var childDTO = new Contracts.ExecutionGraphComparisonResult();
|
||||
childDTO.ParentNode = curNodeDTO;
|
||||
curNodeDTO.Children.Add(childDTO);
|
||||
dtoQueue.Enqueue(childDTO);
|
||||
}
|
||||
}
|
||||
|
||||
return skeletonNodeDTO;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace Microsoft.SqlTools.ServiceLayer.ExecutionPlan.ShowPlan
|
||||
{
|
||||
public class Graph
|
||||
{
|
||||
public Node Root;
|
||||
public Node Root { get; set; }
|
||||
|
||||
public Description Description;
|
||||
public Description Description { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user