diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 70c8fd3dd3..d6c91558a5 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -3013,6 +3013,7 @@ func (aH *APIHandler) QueryRangeV3Format(w http.ResponseWriter, r *http.Request) RespondError(w, apiErrorObj, nil) return } + queryRangeParams.Version = "v3" aH.Respond(w, queryRangeParams) } @@ -3067,6 +3068,14 @@ func (aH *APIHandler) queryRangeV3(ctx context.Context, queryRangeParams *v3.Que postprocess.FillGaps(result, queryRangeParams) } + if queryRangeParams.CompositeQuery.PanelType == v3.PanelTypeTable && queryRangeParams.FormatForWeb { + if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypeClickHouseSQL { + result = postprocess.TransformToTableForClickHouseQueries(result) + } else if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypeBuilder { + result = postprocess.TransformToTableForBuilderQueries(result, queryRangeParams) + } + } + resp := v3.QueryRangeResponse{ Result: result, } @@ -3315,8 +3324,10 @@ func (aH *APIHandler) queryRangeV4(ctx context.Context, queryRangeParams *v3.Que } if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypeBuilder { - result, err = postprocess.PostProcessResult(result, queryRangeParams) + } else if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypeClickHouseSQL && + queryRangeParams.CompositeQuery.PanelType == v3.PanelTypeTable && queryRangeParams.FormatForWeb { + result = postprocess.TransformToTableForClickHouseQueries(result) } if err != nil { @@ -3340,6 +3351,7 @@ func (aH *APIHandler) QueryRangeV4(w http.ResponseWriter, r *http.Request) { RespondError(w, apiErrorObj, nil) return } + queryRangeParams.Version = "v4" // add temporality for each metric temporalityErr := aH.populateTemporality(r.Context(), queryRangeParams) diff --git a/pkg/query-service/model/v3/v3.go b/pkg/query-service/model/v3/v3.go index 2a12c8e1fa..7e6daa3751 100644 --- a/pkg/query-service/model/v3/v3.go +++ b/pkg/query-service/model/v3/v3.go @@ -354,6 +354,8 @@ type QueryRangeParamsV3 struct { CompositeQuery *CompositeQuery `json:"compositeQuery"` Variables map[string]interface{} `json:"variables,omitempty"` NoCache bool `json:"noCache"` + Version string `json:"-"` + FormatForWeb bool `json:"formatForWeb,omitempty"` } type PromQuery struct { @@ -986,10 +988,24 @@ type QueryRangeResponse struct { Result []*Result `json:"result"` } +type TableColumn struct { + Name string `json:"name"` +} + +type TableRow struct { + Data []interface{} `json:"data"` +} + +type Table struct { + Columns []*TableColumn `json:"columns"` + Rows []*TableRow `json:"rows"` +} + type Result struct { - QueryName string `json:"queryName"` - Series []*Series `json:"series"` - List []*Row `json:"list"` + QueryName string `json:"queryName,omitempty"` + Series []*Series `json:"series,omitempty"` + List []*Row `json:"list,omitempty"` + Table *Table `json:"table,omitempty"` } type LogsLiveTailClient struct { diff --git a/pkg/query-service/postprocess/process.go b/pkg/query-service/postprocess/process.go index fc35b404de..1f9ace33eb 100644 --- a/pkg/query-service/postprocess/process.go +++ b/pkg/query-service/postprocess/process.go @@ -86,6 +86,13 @@ func PostProcessResult(result []*v3.Result, queryRangeParams *v3.QueryRangeParam if queryRangeParams.CompositeQuery.FillGaps { FillGaps(result, queryRangeParams) } + + if queryRangeParams.FormatForWeb && + queryRangeParams.CompositeQuery.QueryType == v3.QueryTypeBuilder && + queryRangeParams.CompositeQuery.PanelType == v3.PanelTypeTable { + result = TransformToTableForBuilderQueries(result, queryRangeParams) + } + return result, nil } diff --git a/pkg/query-service/postprocess/table.go b/pkg/query-service/postprocess/table.go new file mode 100644 index 0000000000..1599bf37be --- /dev/null +++ b/pkg/query-service/postprocess/table.go @@ -0,0 +1,299 @@ +package postprocess + +import ( + "fmt" + "sort" + "strings" + + "go.signoz.io/signoz/pkg/query-service/constants" + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" +) + +func getAutoColNameForQuery(queryName string, params *v3.QueryRangeParamsV3) string { + q := params.CompositeQuery.BuilderQueries[queryName] + if q.DataSource == v3.DataSourceTraces || q.DataSource == v3.DataSourceLogs { + if q.AggregateAttribute.Key != "" { + return fmt.Sprintf("%s(%s)", q.AggregateOperator, q.AggregateAttribute.Key) + } + return string(q.AggregateOperator) + } else if q.DataSource == v3.DataSourceMetrics { + if q.SpaceAggregation != "" && params.Version == "v4" { + return fmt.Sprintf("%s(%s)", q.SpaceAggregation, q.AggregateAttribute.Key) + } + return fmt.Sprintf("%s(%s)", q.AggregateOperator, q.AggregateAttribute.Key) + } + return queryName +} + +func TransformToTableForBuilderQueries(results []*v3.Result, params *v3.QueryRangeParamsV3) []*v3.Result { + if len(results) == 0 { + return []*v3.Result{} + } + + // Sort results by QueryName + sort.Slice(results, func(i, j int) bool { + return results[i].QueryName < results[j].QueryName + }) + + // Create a map to store all unique labels + seen := make(map[string]struct{}) + labelKeys := []string{} + for _, result := range results { + for _, series := range result.Series { + for _, labels := range series.LabelsArray { + for key := range labels { + if _, ok := seen[key]; !ok { + seen[key] = struct{}{} + labelKeys = append(labelKeys, key) + } + } + } + } + } + + // Create columns + // There will be one column for each label key and one column for each query name + columns := make([]*v3.TableColumn, 0, len(labelKeys)+len(results)) + for _, key := range labelKeys { + columns = append(columns, &v3.TableColumn{Name: key}) + } + for _, result := range results { + columns = append(columns, &v3.TableColumn{Name: result.QueryName}) + } + + // Create a map to store unique rows + rowMap := make(map[string]*v3.TableRow) + + for _, result := range results { + for _, series := range result.Series { + if len(series.Points) == 0 { + continue + } + + // Create a key for the row based on labels + var keyParts []string + rowData := make([]interface{}, len(columns)) + for i, key := range labelKeys { + value := "n/a" + for _, labels := range series.LabelsArray { + if v, ok := labels[key]; ok { + value = v + break + } + } + keyParts = append(keyParts, fmt.Sprintf("%s=%s", key, value)) + rowData[i] = value + } + rowKey := strings.Join(keyParts, ",") + + // Get or create the row + row, ok := rowMap[rowKey] + if !ok { + row = &v3.TableRow{Data: rowData} + rowMap[rowKey] = row + } + + // Add the value for this query + for i, col := range columns { + if col.Name == result.QueryName { + row.Data[i] = series.Points[0].Value + break + } + } + } + } + + // Convert rowMap to a slice of TableRows + rows := make([]*v3.TableRow, 0, len(rowMap)) + for _, row := range rowMap { + for i, value := range row.Data { + if value == nil { + row.Data[i] = "n/a" + } + } + rows = append(rows, row) + } + + // Get sorted query names + queryNames := make([]string, 0, len(params.CompositeQuery.BuilderQueries)) + for queryName := range params.CompositeQuery.BuilderQueries { + queryNames = append(queryNames, queryName) + } + sort.Strings(queryNames) + + // Sort rows based on OrderBy from BuilderQueries + sortRows(rows, columns, params.CompositeQuery.BuilderQueries, queryNames) + + for _, column := range columns { + if _, exists := params.CompositeQuery.BuilderQueries[column.Name]; exists { + column.Name = getAutoColNameForQuery(column.Name, params) + } + } + + // Create the final result + tableResult := v3.Result{ + Table: &v3.Table{ + Columns: columns, + Rows: rows, + }, + } + + return []*v3.Result{&tableResult} +} + +func sortRows(rows []*v3.TableRow, columns []*v3.TableColumn, builderQueries map[string]*v3.BuilderQuery, queryNames []string) { + sort.SliceStable(rows, func(i, j int) bool { + for _, queryName := range queryNames { + query := builderQueries[queryName] + orderByList := query.OrderBy + if len(orderByList) == 0 { + // If no orderBy is specified, sort by value in descending order + orderByList = []v3.OrderBy{{ColumnName: constants.SigNozOrderByValue, Order: "desc"}} + } + for _, orderBy := range orderByList { + name := orderBy.ColumnName + if name == constants.SigNozOrderByValue { + name = queryName + } + colIndex := -1 + for k, col := range columns { + if col.Name == name { + colIndex = k + break + } + } + if colIndex == -1 { + continue + } + + valI := rows[i].Data[colIndex] + valJ := rows[j].Data[colIndex] + + // Handle "n/a" values + if valI == "n/a" && valJ == "n/a" { + continue + } + + // Compare based on the data type + switch v := valI.(type) { + case float64: + switch w := valJ.(type) { + case float64: + if v != w { + return (v < w) == (orderBy.Order == "asc") + } + default: + // For any other type, sort float64 first + return orderBy.Order == "asc" + } + case string: + switch w := valJ.(type) { + case float64: + // If types are different, sort numbers before strings + return orderBy.Order != "asc" + case string: + if v != w { + return (v < w) == (orderBy.Order == "asc") + } + default: + // For any other type, sort strings before bools + return orderBy.Order == "asc" + } + case bool: + switch w := valJ.(type) { + case float64, string: + // If types are different, sort bools after numbers and strings + return orderBy.Order != "asc" + case bool: + if v != w { + return (!v && w) == (orderBy.Order == "asc") + } + } + } + } + } + return false + }) +} + +func TransformToTableForClickHouseQueries(results []*v3.Result) []*v3.Result { + if len(results) == 0 { + return []*v3.Result{} + } + + // Sort results by QueryName + sort.Slice(results, func(i, j int) bool { + return results[i].QueryName < results[j].QueryName + }) + + // Create a map to store all unique labels + seen := make(map[string]struct{}) + labelKeys := []string{} + for _, result := range results { + for _, series := range result.Series { + for _, labels := range series.LabelsArray { + for key := range labels { + if _, ok := seen[key]; !ok { + seen[key] = struct{}{} + labelKeys = append(labelKeys, key) + } + } + } + } + } + + // Create columns + // Why don't we have a column for each query name? + // Because we don't know if the query is an aggregation query or a non-aggregation query + // So we create a column for each query name that has at least one point + columns := make([]*v3.TableColumn, 0) + for _, key := range labelKeys { + columns = append(columns, &v3.TableColumn{Name: key}) + } + for _, result := range results { + if len(result.Series) > 0 && len(result.Series[0].Points) > 0 { + columns = append(columns, &v3.TableColumn{Name: result.QueryName}) + } + } + + rows := make([]*v3.TableRow, 0) + for _, result := range results { + for _, series := range result.Series { + + // Create a key for the row based on labels + rowData := make([]interface{}, len(columns)) + for i, key := range labelKeys { + value := "n/a" + for _, labels := range series.LabelsArray { + if v, ok := labels[key]; ok { + value = v + break + } + } + rowData[i] = value + } + + // Get or create the row + row := &v3.TableRow{Data: rowData} + + // Add the value for this query + for i, col := range columns { + if col.Name == result.QueryName && len(series.Points) > 0 { + row.Data[i] = series.Points[0].Value + break + } + } + rows = append(rows, row) + } + } + + // Create the final result + tableResult := v3.Result{ + Table: &v3.Table{ + Columns: columns, + Rows: rows, + }, + } + + return []*v3.Result{&tableResult} +} diff --git a/pkg/query-service/postprocess/table_test.go b/pkg/query-service/postprocess/table_test.go new file mode 100644 index 0000000000..6e8f588a5f --- /dev/null +++ b/pkg/query-service/postprocess/table_test.go @@ -0,0 +1,668 @@ +package postprocess + +import ( + "encoding/json" + "reflect" + "testing" + + "go.signoz.io/signoz/pkg/query-service/constants" + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" +) + +func TestSortRows(t *testing.T) { + tests := []struct { + name string + rows []*v3.TableRow + columns []*v3.TableColumn + builderQueries map[string]*v3.BuilderQuery + queryNames []string + expected []*v3.TableRow + }{ + { + name: "Sort by single numeric query, ascending order", + rows: []*v3.TableRow{ + {Data: []interface{}{"service2", 20.0}}, + {Data: []interface{}{"service1", 10.0}}, + {Data: []interface{}{"service3", 30.0}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: constants.SigNozOrderByValue, Order: "asc"}}}, + }, + queryNames: []string{"A"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service1", 10.0}}, + {Data: []interface{}{"service2", 20.0}}, + {Data: []interface{}{"service3", 30.0}}, + }, + }, + { + name: "Sort by single numeric query, descending order", + rows: []*v3.TableRow{ + {Data: []interface{}{"service2", 20.0}}, + {Data: []interface{}{"service1", 10.0}}, + {Data: []interface{}{"service3", 30.0}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: constants.SigNozOrderByValue, Order: "desc"}}}, + }, + queryNames: []string{"A"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service3", 30.0}}, + {Data: []interface{}{"service2", 20.0}}, + {Data: []interface{}{"service1", 10.0}}, + }, + }, + { + name: "Sort by single string query, ascending order", + rows: []*v3.TableRow{ + {Data: []interface{}{"service2", "b"}}, + {Data: []interface{}{"service1", "c"}}, + {Data: []interface{}{"service3", "a"}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: "A", Order: "asc"}}}, + }, + queryNames: []string{"A"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service3", "a"}}, + {Data: []interface{}{"service2", "b"}}, + {Data: []interface{}{"service1", "c"}}, + }, + }, + { + name: "Sort with n/a values", + rows: []*v3.TableRow{ + {Data: []interface{}{"service1", 10.0, "n/a"}}, + {Data: []interface{}{"service2", "n/a", 15.0}}, + {Data: []interface{}{"service3", 30.0, 25.0}}, + {Data: []interface{}{"service4", "n/a", "n/a"}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + {Name: "B"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: constants.SigNozOrderByValue, Order: "asc"}}}, + "B": {OrderBy: []v3.OrderBy{{ColumnName: constants.SigNozOrderByValue, Order: "desc"}}}, + }, + queryNames: []string{"A", "B"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service1", 10.0, "n/a"}}, + {Data: []interface{}{"service3", 30.0, 25.0}}, + {Data: []interface{}{"service4", "n/a", "n/a"}}, + {Data: []interface{}{"service2", "n/a", 15.0}}, + }, + }, + { + name: "Sort with different data types", + rows: []*v3.TableRow{ + {Data: []interface{}{"service1", "string", 10.0, true}}, + {Data: []interface{}{"service2", 20.0, "string", false}}, + {Data: []interface{}{"service3", true, 30.0, "string"}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + {Name: "B"}, + {Name: "C"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: constants.SigNozOrderByValue, Order: "asc"}}}, + "B": {OrderBy: []v3.OrderBy{{ColumnName: constants.SigNozOrderByValue, Order: "desc"}}}, + "C": {OrderBy: []v3.OrderBy{{ColumnName: constants.SigNozOrderByValue, Order: "asc"}}}, + }, + queryNames: []string{"A", "B", "C"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service2", 20.0, "string", false}}, + {Data: []interface{}{"service1", "string", 10.0, true}}, + {Data: []interface{}{"service3", true, 30.0, "string"}}, + }, + }, + { + name: "Sort with SigNozOrderByValue", + rows: []*v3.TableRow{ + {Data: []interface{}{"service1", 20.0}}, + {Data: []interface{}{"service2", 10.0}}, + {Data: []interface{}{"service3", 30.0}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: constants.SigNozOrderByValue, Order: "desc"}}}, + }, + queryNames: []string{"A"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service3", 30.0}}, + {Data: []interface{}{"service1", 20.0}}, + {Data: []interface{}{"service2", 10.0}}, + }, + }, + { + name: "Sort by multiple queries with mixed types", + rows: []*v3.TableRow{ + {Data: []interface{}{"service1", 10.0, "b", true}}, + {Data: []interface{}{"service2", 20.0, "a", false}}, + {Data: []interface{}{"service3", 10.0, "c", true}}, + {Data: []interface{}{"service4", 20.0, "b", false}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + {Name: "B"}, + {Name: "C"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: "A", Order: "asc"}}}, + "B": {OrderBy: []v3.OrderBy{{ColumnName: "B", Order: "desc"}}}, + "C": {OrderBy: []v3.OrderBy{{ColumnName: "C", Order: "asc"}}}, + }, + queryNames: []string{"A", "B", "C"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service3", 10.0, "c", true}}, + {Data: []interface{}{"service1", 10.0, "b", true}}, + {Data: []interface{}{"service4", 20.0, "b", false}}, + {Data: []interface{}{"service2", 20.0, "a", false}}, + }, + }, + { + name: "Sort with all n/a values", + rows: []*v3.TableRow{ + {Data: []interface{}{"service1", "n/a", "n/a"}}, + {Data: []interface{}{"service2", "n/a", "n/a"}}, + {Data: []interface{}{"service3", "n/a", "n/a"}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + {Name: "B"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: "A", Order: "asc"}}}, + "B": {OrderBy: []v3.OrderBy{{ColumnName: "B", Order: "desc"}}}, + }, + queryNames: []string{"A", "B"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service1", "n/a", "n/a"}}, + {Data: []interface{}{"service2", "n/a", "n/a"}}, + {Data: []interface{}{"service3", "n/a", "n/a"}}, + }, + }, + { + name: "Sort with negative numbers", + rows: []*v3.TableRow{ + {Data: []interface{}{"service1", -10.0}}, + {Data: []interface{}{"service2", 20.0}}, + {Data: []interface{}{"service3", -30.0}}, + {Data: []interface{}{"service4", 0.0}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: "A", Order: "asc"}}}, + }, + queryNames: []string{"A"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service3", -30.0}}, + {Data: []interface{}{"service1", -10.0}}, + {Data: []interface{}{"service4", 0.0}}, + {Data: []interface{}{"service2", 20.0}}, + }, + }, + { + name: "Sort with mixed case strings", + rows: []*v3.TableRow{ + {Data: []interface{}{"service1", "Apple"}}, + {Data: []interface{}{"service2", "banana"}}, + {Data: []interface{}{"service3", "Cherry"}}, + {Data: []interface{}{"service4", "date"}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: "A", Order: "asc"}}}, + }, + queryNames: []string{"A"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service1", "Apple"}}, + {Data: []interface{}{"service3", "Cherry"}}, + {Data: []interface{}{"service2", "banana"}}, + {Data: []interface{}{"service4", "date"}}, + }, + }, + { + name: "Sort with empty strings", + rows: []*v3.TableRow{ + {Data: []interface{}{"service1", ""}}, + {Data: []interface{}{"service2", "b"}}, + {Data: []interface{}{"service3", ""}}, + {Data: []interface{}{"service4", "a"}}, + }, + columns: []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + }, + builderQueries: map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: "A", Order: "asc"}}}, + }, + queryNames: []string{"A"}, + expected: []*v3.TableRow{ + {Data: []interface{}{"service1", ""}}, + {Data: []interface{}{"service3", ""}}, + {Data: []interface{}{"service4", "a"}}, + {Data: []interface{}{"service2", "b"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sortRows(tt.rows, tt.columns, tt.builderQueries, tt.queryNames) + if !reflect.DeepEqual(tt.rows, tt.expected) { + exp, _ := json.Marshal(tt.expected) + got, _ := json.Marshal(tt.rows) + t.Errorf("sortRows() = %v, want %v", string(got), string(exp)) + } + }) + } +} + +func TestSortRowsWithEmptyQueries(t *testing.T) { + rows := []*v3.TableRow{ + {Data: []interface{}{"service1", 20.0}}, + {Data: []interface{}{"service2", 10.0}}, + {Data: []interface{}{"service3", 30.0}}, + } + columns := []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + } + builderQueries := map[string]*v3.BuilderQuery{} + queryNames := []string{} + + sortRows(rows, columns, builderQueries, queryNames) + + // Expect the original order to be maintained + expected := []*v3.TableRow{ + {Data: []interface{}{"service1", 20.0}}, + {Data: []interface{}{"service2", 10.0}}, + {Data: []interface{}{"service3", 30.0}}, + } + + if !reflect.DeepEqual(rows, expected) { + t.Errorf("sortRows() with empty queries = %v, want %v", rows, expected) + } +} + +func TestSortRowsWithInvalidColumnName(t *testing.T) { + rows := []*v3.TableRow{ + {Data: []interface{}{"service1", 20.0}}, + {Data: []interface{}{"service2", 10.0}}, + {Data: []interface{}{"service3", 30.0}}, + } + columns := []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + } + builderQueries := map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: "InvalidColumn", Order: "asc"}}}, + } + queryNames := []string{"A"} + + sortRows(rows, columns, builderQueries, queryNames) + + // Expect the original order to be maintained + expected := []*v3.TableRow{ + {Data: []interface{}{"service1", 20.0}}, + {Data: []interface{}{"service2", 10.0}}, + {Data: []interface{}{"service3", 30.0}}, + } + + if !reflect.DeepEqual(rows, expected) { + t.Errorf("sortRows() with invalid column name = %v, want %v", rows, expected) + } +} + +func TestSortRowsStability(t *testing.T) { + rows := []*v3.TableRow{ + {Data: []interface{}{"service1", 10.0, "a"}}, + {Data: []interface{}{"service2", 10.0, "b"}}, + {Data: []interface{}{"service3", 10.0, "c"}}, + } + columns := []*v3.TableColumn{ + {Name: "service_name"}, + {Name: "A"}, + {Name: "B"}, + } + builderQueries := map[string]*v3.BuilderQuery{ + "A": {OrderBy: []v3.OrderBy{{ColumnName: "A", Order: "asc"}}}, + } + queryNames := []string{"A"} + + sortRows(rows, columns, builderQueries, queryNames) + + // Expect the original order to be maintained for equal values + expected := []*v3.TableRow{ + {Data: []interface{}{"service1", 10.0, "a"}}, + {Data: []interface{}{"service2", 10.0, "b"}}, + {Data: []interface{}{"service3", 10.0, "c"}}, + } + + if !reflect.DeepEqual(rows, expected) { + t.Errorf("sortRows() stability test failed = %v, want %v", rows, expected) + } +} + +func TestTransformToTableForClickHouseQueries(t *testing.T) { + tests := []struct { + name string + input []*v3.Result + expected []*v3.Result + }{ + { + name: "Empty input", + input: []*v3.Result{}, + expected: []*v3.Result{}, + }, + { + name: "Single result with one series", + input: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + LabelsArray: []map[string]string{ + {"service": "frontend"}, + }, + Points: []v3.Point{ + {Value: 10.0}, + }, + }, + }, + }, + }, + expected: []*v3.Result{ + { + Table: &v3.Table{ + Columns: []*v3.TableColumn{ + {Name: "service"}, + {Name: "A"}, + }, + Rows: []*v3.TableRow{ + {Data: []interface{}{"frontend", 10.0}}, + }, + }, + }, + }, + }, + { + name: "Multiple results with multiple series", + input: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + LabelsArray: []map[string]string{ + {"service": "frontend", "env": "prod"}, + }, + Points: []v3.Point{ + {Value: 10.0}, + }, + }, + { + LabelsArray: []map[string]string{ + {"service": "backend", "env": "prod"}, + }, + Points: []v3.Point{ + {Value: 20.0}, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + LabelsArray: []map[string]string{ + {"service": "frontend", "env": "prod"}, + }, + Points: []v3.Point{ + {Value: 15.0}, + }, + }, + { + LabelsArray: []map[string]string{ + {"service": "backend", "env": "prod"}, + }, + Points: []v3.Point{ + {Value: 25.0}, + }, + }, + }, + }, + }, + expected: []*v3.Result{ + { + Table: &v3.Table{ + Columns: []*v3.TableColumn{ + {Name: "service"}, + {Name: "env"}, + {Name: "A"}, + {Name: "B"}, + }, + Rows: []*v3.TableRow{ + {Data: []interface{}{"frontend", "prod", 10.0, nil}}, + {Data: []interface{}{"backend", "prod", 20.0, nil}}, + {Data: []interface{}{"frontend", "prod", nil, 15.0}}, + {Data: []interface{}{"backend", "prod", nil, 25.0}}, + }, + }, + }, + }, + }, + { + name: "Results with missing labels", + input: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + LabelsArray: []map[string]string{ + {"service": "frontend"}, + }, + Points: []v3.Point{ + {Value: 10.0}, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + LabelsArray: []map[string]string{ + {"env": "prod"}, + }, + Points: []v3.Point{ + {Value: 20.0}, + }, + }, + }, + }, + }, + expected: []*v3.Result{ + { + Table: &v3.Table{ + Columns: []*v3.TableColumn{ + {Name: "service"}, + {Name: "env"}, + {Name: "A"}, + {Name: "B"}, + }, + Rows: []*v3.TableRow{ + {Data: []interface{}{"frontend", "n/a", 10.0, nil}}, + {Data: []interface{}{"n/a", "prod", nil, 20.0}}, + }, + }, + }, + }, + }, + { + name: "Results with empty series", + input: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + LabelsArray: []map[string]string{ + {"service": "frontend"}, + }, + Points: []v3.Point{ + {Value: 10.0}, + }, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{}, + }, + }, + expected: []*v3.Result{ + { + Table: &v3.Table{ + Columns: []*v3.TableColumn{ + {Name: "service"}, + {Name: "A"}, + }, + Rows: []*v3.TableRow{ + {Data: []interface{}{"frontend", 10.0}}, + }, + }, + }, + }, + }, + { + name: "Results with empty points", + input: []*v3.Result{ + { + QueryName: "A", + Series: []*v3.Series{ + { + LabelsArray: []map[string]string{ + {"service": "frontend"}, + }, + Points: []v3.Point{}, + }, + }, + }, + { + QueryName: "B", + Series: []*v3.Series{ + { + LabelsArray: []map[string]string{ + {"service": "backend"}, + }, + Points: []v3.Point{ + {Value: 20.0}, + }, + }, + }, + }, + }, + expected: []*v3.Result{ + { + Table: &v3.Table{ + Columns: []*v3.TableColumn{ + {Name: "service"}, + {Name: "B"}, + }, + Rows: []*v3.TableRow{ + {Data: []interface{}{"frontend", nil}}, + {Data: []interface{}{"backend", 20.0}}, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TransformToTableForClickHouseQueries(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("TransformToTableForClickHouseQueries() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestTransformToTableForClickHouseQueriesSorting(t *testing.T) { + input := []*v3.Result{ + { + QueryName: "B", + Series: []*v3.Series{ + { + LabelsArray: []map[string]string{ + {"service": "frontend"}, + }, + Points: []v3.Point{ + {Value: 10.0}, + }, + }, + }, + }, + { + QueryName: "A", + Series: []*v3.Series{ + { + LabelsArray: []map[string]string{ + {"service": "backend"}, + }, + Points: []v3.Point{ + {Value: 20.0}, + }, + }, + }, + }, + } + + expected := []*v3.Result{ + { + Table: &v3.Table{ + Columns: []*v3.TableColumn{ + {Name: "service"}, + {Name: "A"}, + {Name: "B"}, + }, + Rows: []*v3.TableRow{ + {Data: []interface{}{"backend", 20.0, nil}}, + {Data: []interface{}{"frontend", nil, 10.0}}, + }, + }, + }, + } + + result := TransformToTableForClickHouseQueries(input) + if !reflect.DeepEqual(result, expected) { + t.Errorf("TransformToTableForClickHouseQueries() sorting test failed. Got %v, want %v", result, expected) + } +}