Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions cmd/loadjson/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import (
"syscall"
"time"

"github.com/prestodb/presto-go-client/v2/queryjson"

"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -147,10 +145,10 @@ func processFile(ctx context.Context, path string) {
log.Error().Err(ioErr).Str("path", path).Msg("failed to read file")
return
}
queryInfo := new(queryjson.QueryInfo)
queryInfo := new(ExtendedQueryInfo)
// Note that this step can succeed with any valid JSON file. But we need to do some additional validation to skip
// invalid query JSON files.
if unmarshalErr := json.Unmarshal(bytes, queryInfo); unmarshalErr != nil {
if unmarshalErr := json.Unmarshal(bytes, &queryInfo.QueryInfo); unmarshalErr != nil {
log.Error().Err(unmarshalErr).Str("path", path).Msg("failed to unmarshal JSON")
return
}
Expand Down Expand Up @@ -201,7 +199,7 @@ func processFile(ctx context.Context, path string) {
if mysqlDb != nil || ExtractPlanJson {
// OutputStage is in a tree structure, and we need to flatten it for its ORM to be correctly parsed.
// There are many other derived metrics, so we need to do soe preprocessing before sending it to the database.
if err := queryInfo.PrepareForInsert(); err != nil {
if err := queryInfo.PrepareForInsertWithTextPlan(); err != nil {
log.Error().Err(err).Str("path", path).Msg("failed to pre-process query info JSON")
return
}
Expand Down
34 changes: 34 additions & 0 deletions cmd/loadjson/query_info_ext.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package loadjson

import (
"pbench/prestoapi"

"github.com/prestodb/presto-go-client/v2/queryjson"
)

// ExtendedQueryInfo extends queryjson.QueryInfo to add the text plan field
type ExtendedQueryInfo struct {
queryjson.QueryInfo
TextPlan string `presto_query_plans:"plan"`
}

// PrepareForInsertWithTextPlan calls the original PrepareForInsert and then generates the text plan
func (e *ExtendedQueryInfo) PrepareForInsertWithTextPlan() error {
// Call the original PrepareForInsert to populate AssembledQueryPlanJson
if err := e.QueryInfo.PrepareForInsert(); err != nil {
return err
}

// Generate the text plan from the JSON plan
if e.AssembledQueryPlanJson != "" {
textPlan, err := prestoapi.FormatQueryPlanAsText(e.AssembledQueryPlanJson)
if err != nil {
// Log the error but don't fail the entire operation
// The json_plan will still be available
return err
}
e.TextPlan = textPlan
}

return nil
}
72 changes: 72 additions & 0 deletions cmd/loadjson/query_info_ext_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package loadjson

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

func TestExtendedQueryInfo_WithRealData(t *testing.T) {
// Use the existing test data
testDataPath := filepath.Join("testdata", "presto_query_info.json")

// Check if test file exists
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
t.Skip("Test data file not found, skipping test")
return
}

bytes, err := os.ReadFile(testDataPath)
if err != nil {
t.Fatalf("Failed to read test file: %v", err)
}

// Create ExtendedQueryInfo
extQueryInfo := new(ExtendedQueryInfo)
if err := json.Unmarshal(bytes, &extQueryInfo.QueryInfo); err != nil {
t.Fatalf("Failed to unmarshal query info: %v", err)
}

// Call PrepareForInsertWithTextPlan
if err := extQueryInfo.PrepareForInsertWithTextPlan(); err != nil {
t.Fatalf("PrepareForInsertWithTextPlan failed: %v", err)
}

// Verify that AssembledQueryPlanJson was populated
if extQueryInfo.AssembledQueryPlanJson == "" {
t.Error("AssembledQueryPlanJson should not be empty")
}

// Verify that TextPlan was populated
if extQueryInfo.TextPlan == "" {
t.Error("TextPlan should not be empty")
}

// Verify TextPlan contains expected elements
expectedStrings := []string{
"Fragment",
"PlanNodeId",
}

for _, expected := range expectedStrings {
if !strings.Contains(extQueryInfo.TextPlan, expected) {
t.Errorf("Expected TextPlan to contain %q", expected)
}
}

// Log a sample of the text plan
maxLen := 500
if len(extQueryInfo.TextPlan) < maxLen {
maxLen = len(extQueryInfo.TextPlan)
}
t.Logf("Generated text plan (first %d chars):\n%s...", maxLen, extQueryInfo.TextPlan[:maxLen])
}

func TestExtendedQueryInfo_StructTags(t *testing.T) {
// Verify that the TextPlan field has the correct struct tag
// This is verified by the fact that the code compiles and the ORM will use it
// The actual struct tag is: `presto_query_plans:"plan"`
t.Log("TextPlan field has struct tag: presto_query_plans:\"plan\"")
}
85 changes: 85 additions & 0 deletions prestoapi/plan_formatter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package prestoapi

import (
"encoding/json"
"fmt"
"strings"
)

// PlanNode represents a node in the query execution plan
type PlanNode struct {
ID string `json:"id"`
Name string `json:"name"`
Identifier string `json:"identifier"`
Details string `json:"details"`
Children []PlanNode `json:"children"`
RemoteSources []interface{} `json:"remoteSources"`
Estimates []map[string]interface{} `json:"estimates"`
}

// StagePlanWrapper wraps the plan for a stage
type StagePlanWrapper struct {
Plan PlanNode `json:"plan"`
}

// FormatQueryPlanAsText converts a JSON query plan to human-readable text format
// The input should be the AssembledQueryPlanJson which is a map of stage IDs to plan wrappers
func FormatQueryPlanAsText(jsonPlan string) (string, error) {
if jsonPlan == "" {
return "", nil
}

// Parse the JSON plan - it's a map of stage IDs to plan wrappers
var stages map[string]StagePlanWrapper
if err := json.Unmarshal([]byte(jsonPlan), &stages); err != nil {
return "", fmt.Errorf("failed to parse query plan JSON: %w", err)
}

var result strings.Builder

// Process each stage (fragment) in order
// Note: stages are typically numbered 0, 1, 2, etc.
for stageID, stageWrapper := range stages {
result.WriteString(fmt.Sprintf("Fragment %s\n", stageID))
result.WriteString(formatPlanNode(&stageWrapper.Plan, 0))
result.WriteString("\n")
}

return result.String(), nil
}

// formatPlanNode recursively formats a plan node and its children
func formatPlanNode(node *PlanNode, depth int) string {
if node == nil {
return ""
}

var result strings.Builder
indent := strings.Repeat(" ", depth)

// Format the node header with identifier if present
if node.Identifier != "" {
result.WriteString(fmt.Sprintf("%s- %s[PlanNodeId %s]%s\n",
indent, node.Name, node.ID, node.Identifier))
} else {
result.WriteString(fmt.Sprintf("%s- %s[PlanNodeId %s]\n",
indent, node.Name, node.ID))
}

// Add details if present (already formatted with proper indentation in the JSON)
if node.Details != "" {
detailLines := strings.Split(strings.TrimSpace(node.Details), "\n")
for _, line := range detailLines {
if line != "" {
result.WriteString(fmt.Sprintf("%s %s\n", indent, line))
}
}
}

// Recursively format children with increased indentation
for i := range node.Children {
result.WriteString(formatPlanNode(&node.Children[i], depth+1))
}

return result.String()
}
52 changes: 52 additions & 0 deletions prestoapi/plan_formatter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package prestoapi

import (
"strings"
"testing"
)

func TestFormatQueryPlanAsText(t *testing.T) {
// Sample JSON plan from the user
jsonPlan := `{"0":{"plan":{"id":"22","name":"Output","identifier":"[n_name, revenue]","details":"revenue := sum (4:5)\n","children":[{"id":"1311","name":"TopN","identifier":"[1 by (sum DESC_NULLS_LAST)]","details":"","children":[{"id":"1310","name":"TopNPartial","identifier":"[1 by (sum DESC_NULLS_LAST)]","details":"","children":[{"id":"14","name":"Aggregate(FINAL)[n_name]","identifier":"","details":"sum := \"presto.default.sum\"((sum_13)) (4:5)\n","children":[{"id":"1705","name":"LocalExchange","identifier":"[SINGLE] ()","details":"","children":[{"id":"1703","name":"Aggregate(PARTIAL)[n_name]","identifier":"","details":"sum_13 := \"presto.default.sum\"((expr)) (4:5)\n","children":[{"id":"340","name":"Project","identifier":"[projectLocality = LOCAL]","details":"expr := (l_extendedprice) * ((DOUBLE'1.0') - (l_discount)) (8:6)\n","children":[{"id":"1410","name":"InnerJoin","identifier":"[(\"l_suppkey\" = \"s_suppkey\") AND (\"c_nationkey\" = \"s_nationkey\")]","details":"Distribution: REPLICATED\n","children":[{"id":"1409","name":"InnerJoin","identifier":"[(\"l_orderkey\" = \"o_orderkey\")]","details":"Distribution: REPLICATED\n","children":[{"id":"3","name":"TableScan","identifier":"[TableHandle {connectorId='tpch', connectorHandle='lineitem:sf1.0', layout='Optional[lineitem:sf1.0]'}]","details":"l_orderkey := tpch:l_orderkey (8:5)\nl_extendedprice := tpch:l_extendedprice (8:5)\nl_suppkey := tpch:l_suppkey (8:5)\nl_discount := tpch:l_discount (8:5)\n","children":[],"remoteSources":[],"estimates":[]}],"remoteSources":[],"estimates":[]}],"remoteSources":[],"estimates":[]}],"remoteSources":[],"estimates":[]}],"remoteSources":[],"estimates":[]}],"remoteSources":[],"estimates":[]}],"remoteSources":[],"estimates":[]}],"remoteSources":[],"estimates":[]}],"remoteSources":[],"estimates":[]}],"remoteSources":[],"estimates":[]}}}`

result, err := FormatQueryPlanAsText(jsonPlan)
if err != nil {
t.Fatalf("FormatQueryPlanAsText failed: %v", err)
}

// Verify the output contains expected elements
expectedStrings := []string{
"Fragment 0",
"- Output[PlanNodeId 22][n_name, revenue]",
"revenue := sum (4:5)",
"- TopN[PlanNodeId 1311][1 by (sum DESC_NULLS_LAST)]",
"- TableScan[PlanNodeId 3]",
"l_orderkey := tpch:l_orderkey",
}

for _, expected := range expectedStrings {
if !strings.Contains(result, expected) {
t.Errorf("Expected output to contain %q, but it didn't.\nGot:\n%s", expected, result)
}
}

// Print the result for manual inspection
t.Logf("Formatted plan:\n%s", result)
}

func TestFormatQueryPlanAsText_EmptyInput(t *testing.T) {
result, err := FormatQueryPlanAsText("")
if err != nil {
t.Fatalf("FormatQueryPlanAsText with empty input failed: %v", err)
}
if result != "" {
t.Errorf("Expected empty result for empty input, got: %q", result)
}
}

func TestFormatQueryPlanAsText_InvalidJSON(t *testing.T) {
_, err := FormatQueryPlanAsText("invalid json")
if err == nil {
t.Error("Expected error for invalid JSON, got nil")
}
}
Loading