diff --git a/go.mod b/go.mod index 5f8ad0c..d932011 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( cloud.google.com/go v0.65.0 cloud.google.com/go/spanner v1.10.0 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e + github.com/golang/protobuf v1.4.2 github.com/google/go-cmp v0.5.2 github.com/jessevdk/go-flags v1.4.0 github.com/mattn/go-runewidth v0.0.8 // indirect diff --git a/query_plan.go b/query_plan.go index b9c35aa..8b29c37 100644 --- a/query_plan.go +++ b/query_plan.go @@ -58,6 +58,14 @@ type executionStatsValue struct { Total string `json:"total"` } +func (v executionStatsValue) String() string { + if v.Unit == "" { + return v.Total + } else { + return fmt.Sprintf("%s %s", v.Total, v.Unit) + } +} + // queryPlanNodeWithStatsTyped is proto-free typed representation of QueryPlanNodeWithStats type queryPlanNodeWithStatsTyped struct { ID int32 `json:"id"` @@ -112,6 +120,19 @@ type QueryPlanRow struct { Predicates []string } +func isPredicate(planNodes []*pb.PlanNode, childLink *pb.PlanNode_ChildLink) bool { + // Known predicates are Condition(Filter) or Seek Condition/Residual Condition(FilterScan) or Split Range(Distributed Union). + // Agg is a Function but not a predicate. + child := planNodes[childLink.ChildIndex] + if child.DisplayName != "Function" { + return false + } + if strings.HasSuffix(childLink.GetType(), "Condition") || childLink.GetType() == "Split Range" { + return true + } + return false +} + func (n *Node) RenderTreeWithStats(planNodes []*pb.PlanNode) ([]QueryPlanRow, error) { tree := treeprint.New() renderTreeWithStats(tree, "", n) @@ -142,11 +163,10 @@ func (n *Node) RenderTreeWithStats(planNodes []*pb.PlanNode) ([]QueryPlanRow, er var predicates []string for _, cl := range planNodes[planNode.ID].GetChildLinks() { - child := planNodes[cl.ChildIndex] - if child.DisplayName != "Function" || !(cl.GetType() == "Residual Condition" || cl.GetType() == "Seek Condition" || cl.GetType() == "Split Range") { + if !isPredicate(planNodes, cl) { continue } - predicates = append(predicates, fmt.Sprintf("%s: %s", cl.GetType(), child.GetShortRepresentation().GetDescription())) + predicates = append(predicates, fmt.Sprintf("%s: %s", cl.GetType(), planNodes[cl.ChildIndex].GetShortRepresentation().GetDescription())) } result = append(result, QueryPlanRow{ @@ -155,7 +175,7 @@ func (n *Node) RenderTreeWithStats(planNodes []*pb.PlanNode) ([]QueryPlanRow, er Text: branchText + text, RowsTotal: planNode.ExecutionStats.Rows.Total, Execution: planNode.ExecutionStats.ExecutionSummary.NumExecutions, - LatencyTotal: fmt.Sprintf("%s %s", planNode.ExecutionStats.Latency.Total, planNode.ExecutionStats.Latency.Unit), + LatencyTotal: planNode.ExecutionStats.Latency.String(), }) } return result, nil diff --git a/query_plan_test.go b/query_plan_test.go index 400da25..c91afd6 100644 --- a/query_plan_test.go +++ b/query_plan_test.go @@ -1,10 +1,12 @@ package main import ( + "io/ioutil" "testing" "github.com/google/go-cmp/cmp" "google.golang.org/genproto/googleapis/spanner/v1" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/structpb" pb "google.golang.org/genproto/googleapis/spanner/v1" @@ -18,6 +20,77 @@ func mustNewStruct(m map[string]interface{}) *structpb.Struct { } } +func TestRenderTreeUsingTestdataPlans(t *testing.T) { + for _, test := range []struct { + title string + file string + want []QueryPlanRow + }{ + { + // Original Query: + // SELECT s.LastName FROM (SELECT s.LastName FROM Singers AS s WHERE s.FirstName LIKE 'A%' LIMIT 3) s WHERE s.LastName LIKE 'Rich%'; + title: "With Filter Operator", + file: "testdata/plans/filter.input.json", + want: []QueryPlanRow{ + { + ID: 0, + Text: "Serialize Result", + }, + { + ID: 1, + Text: "+- Filter", + Predicates: []string{"Condition: STARTS_WITH($LastName, 'Rich')"}, + }, + { + ID: 2, + Text: " +- Global Limit", + }, + { + ID: 3, + Text: " +- Distributed Union", + Predicates: []string{"Split Range: STARTS_WITH($FirstName, 'A')"}, + }, + { + ID: 4, + Text: " +- Local Limit", + }, + { + ID: 5, + Text: " +- Local Distributed Union", + }, + { + ID: 6, + Text: " +- FilterScan", + Predicates: []string{"Seek Condition: STARTS_WITH($FirstName, 'A')"}, + }, + { + ID: 7, + Text: " +- Index Scan (Index: SingersByFirstLastName)", + }, + }}, + } { + t.Run(test.title, func(t *testing.T) { + b, err := ioutil.ReadFile(test.file) + if err != nil { + t.Fatal(err) + } + var plan pb.QueryPlan + err = protojson.Unmarshal(b, &plan) + if err != nil { + t.Fatal(err) + } + tree := BuildQueryPlanTree(&plan, 0) + got, err := tree.RenderTreeWithStats(plan.GetPlanNodes()) + if err != nil { + t.Errorf("error should be nil, but got = %v", err) + } + if !cmp.Equal(test.want, got) { + t.Errorf("node.RenderTreeWithStats() differ: %s", cmp.Diff(test.want, got)) + } + }) + } +} + func TestRenderTreeWithStats(t *testing.T) { for _, test := range []struct { title string diff --git a/testdata/plans/filter.input.json b/testdata/plans/filter.input.json new file mode 100644 index 0000000..5317a71 --- /dev/null +++ b/testdata/plans/filter.input.json @@ -0,0 +1,277 @@ +{ + "planNodes": [ + { + "childLinks": [ + { + "childIndex": 1 + }, + { + "childIndex": 22 + } + ], + "displayName": "Serialize Result", + "kind": "RELATIONAL" + }, + { + "childLinks": [ + { + "childIndex": 2 + }, + { + "childIndex": 19, + "type": "Condition" + } + ], + "displayName": "Filter", + "index": 1, + "kind": "RELATIONAL" + }, + { + "childLinks": [ + { + "childIndex": 3 + }, + { + "childIndex": 18, + "type": "Limit" + } + ], + "displayName": "Limit", + "index": 2, + "kind": "RELATIONAL", + "metadata": { + "call_type": "Global" + } + }, + { + "childLinks": [ + { + "childIndex": 4 + }, + { + "childIndex": 15, + "type": "Split Range" + } + ], + "displayName": "Distributed Union", + "index": 3, + "kind": "RELATIONAL", + "metadata": { + "subquery_cluster_node": "4" + } + }, + { + "childLinks": [ + { + "childIndex": 5 + }, + { + "childIndex": 14, + "type": "Limit" + } + ], + "displayName": "Limit", + "index": 4, + "kind": "RELATIONAL", + "metadata": { + "call_type": "Local" + } + }, + { + "childLinks": [ + { + "childIndex": 6 + } + ], + "displayName": "Distributed Union", + "index": 5, + "kind": "RELATIONAL", + "metadata": { + "call_type": "Local", + "subquery_cluster_node": "6" + } + }, + { + "childLinks": [ + { + "childIndex": 7 + }, + { + "childIndex": 13, + "type": "Seek Condition" + } + ], + "displayName": "FilterScan", + "index": 6, + "kind": "RELATIONAL" + }, + { + "childLinks": [ + { + "childIndex": 8, + "variable": "FirstName" + }, + { + "childIndex": 9, + "variable": "LastName" + } + ], + "displayName": "Scan", + "index": 7, + "kind": "RELATIONAL", + "metadata": { + "scan_target": "SingersByFirstLastName", + "scan_type": "IndexScan" + } + }, + { + "displayName": "Reference", + "index": 8, + "kind": "SCALAR", + "shortRepresentation": { + "description": "FirstName" + } + }, + { + "displayName": "Reference", + "index": 9, + "kind": "SCALAR", + "shortRepresentation": { + "description": "LastName" + } + }, + { + "childLinks": [ + { + "childIndex": 11 + }, + { + "childIndex": 12 + } + ], + "displayName": "Function", + "index": 10, + "kind": "SCALAR", + "shortRepresentation": { + "description": "STARTS_WITH($FirstName, 'A')" + } + }, + { + "displayName": "Reference", + "index": 11, + "kind": "SCALAR", + "shortRepresentation": { + "description": "$FirstName" + } + }, + { + "displayName": "Constant", + "index": 12, + "kind": "SCALAR", + "shortRepresentation": { + "description": "'A'" + } + }, + { + "childLinks": [ + { + "childIndex": 10 + } + ], + "displayName": "Function", + "index": 13, + "kind": "SCALAR", + "shortRepresentation": { + "description": "STARTS_WITH($FirstName, 'A')" + } + }, + { + "displayName": "Constant", + "index": 14, + "kind": "SCALAR", + "shortRepresentation": { + "description": "3" + } + }, + { + "childLinks": [ + { + "childIndex": 16 + }, + { + "childIndex": 17 + } + ], + "displayName": "Function", + "index": 15, + "kind": "SCALAR", + "shortRepresentation": { + "description": "STARTS_WITH($FirstName, 'A')" + } + }, + { + "displayName": "Reference", + "index": 16, + "kind": "SCALAR", + "shortRepresentation": { + "description": "$FirstName" + } + }, + { + "displayName": "Constant", + "index": 17, + "kind": "SCALAR", + "shortRepresentation": { + "description": "'A'" + } + }, + { + "displayName": "Constant", + "index": 18, + "kind": "SCALAR", + "shortRepresentation": { + "description": "3" + } + }, + { + "childLinks": [ + { + "childIndex": 20 + }, + { + "childIndex": 21 + } + ], + "displayName": "Function", + "index": 19, + "kind": "SCALAR", + "shortRepresentation": { + "description": "STARTS_WITH($LastName, 'Rich')" + } + }, + { + "displayName": "Reference", + "index": 20, + "kind": "SCALAR", + "shortRepresentation": { + "description": "$LastName" + } + }, + { + "displayName": "Constant", + "index": 21, + "kind": "SCALAR", + "shortRepresentation": { + "description": "'Rich'" + } + }, + { + "displayName": "Reference", + "index": 22, + "kind": "SCALAR", + "shortRepresentation": { + "description": "$LastName" + } + } + ] +}