From 9e283f67a0dd68e000693f18fe088bce28393927 Mon Sep 17 00:00:00 2001 From: lmk <1095689763@qq.com> Date: Tue, 16 Jun 2026 19:33:57 +0800 Subject: [PATCH] =?UTF-8?q?test(qa):=20regression=20test=20for=20ISSUE-002?= =?UTF-8?q?=20=E2=80=94=20parseExpression=20=E9=9D=9E=20indicator=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=8D=A0=E4=BD=8D=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验证当衍生指标表达式引用未在 indicators 勾选的字段时, {fieldCode} 占位符被替换为裸字段名,不残留大括号到 SQL 中。 --- common/report/builder/sql_builder_test.go | 483 ++++++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 common/report/builder/sql_builder_test.go diff --git a/common/report/builder/sql_builder_test.go b/common/report/builder/sql_builder_test.go new file mode 100644 index 0000000..20d0cc0 --- /dev/null +++ b/common/report/builder/sql_builder_test.go @@ -0,0 +1,483 @@ +package builder + +import ( + "context" + "testing" + + "dataengine/common/report/model" +) + +// Test data - reusable field configs +var testFields = map[string]*model.FieldConfig{ + "shop_id": {FieldCode: "shop_id", FieldName: "店铺ID", FieldType: "STRING", FieldRole: "DIMENSION", IsFilterable: true, IsSortable: true, FilterOperators: []string{"=", "IN"}}, + "shop_name": {FieldCode: "shop_name", FieldName: "店铺名称", FieldType: "STRING", FieldRole: "DIMENSION", IsFilterable: true, IsSortable: true, FilterOperators: []string{"=", "LIKE"}}, + "stat_date": {FieldCode: "stat_date", FieldName: "统计日期", FieldType: "DATE", FieldRole: "DIMENSION", IsFilterable: true, IsSortable: true, FilterOperators: []string{"=", ">=", "<=", "BETWEEN"}}, + "order_count": {FieldCode: "order_count", FieldName: "订单数", FieldType: "INT", FieldRole: "INDICATOR", IsAggregatable: true, DefaultAggregate: "SUM", ValidAggregates: []string{"SUM", "COUNT"}}, + "order_amount": {FieldCode: "order_amount", FieldName: "订单金额", FieldType: "FLOAT", FieldRole: "INDICATOR", IsAggregatable: true, IsSortable: true, DefaultAggregate: "SUM", ValidAggregates: []string{"SUM", "AVG", "MAX", "MIN"}}, + "refund_rate": {FieldCode: "refund_rate", FieldName: "退款率", FieldType: "FLOAT", FieldRole: "INDICATOR", IsAggregatable: true, DefaultAggregate: "AVG", ValidAggregates: []string{"AVG"}, Expression: "{refund_amount} / NULLIF({order_amount}, 0) * 100", ExpressionType: "CALCULATED"}, + "order_status": {FieldCode: "order_status", FieldName: "订单状态", FieldType: "STRING", FieldRole: "FILTER", IsFilterable: true, FilterOperators: []string{"=", "IN"}}, +} + +// Test data - reusable report config +var testReport = &model.ReportConfig{ + StatTableName: "test_stat_table", + DateField: "stat_date", +} + +func TestBuildSelectClause(t *testing.T) { + b := &SQLBuilder{} + + t.Run("basic dimensions + indicators", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + Dimensions: []string{"shop_id", "shop_name"}, + Indicators: []model.IndicatorSelect{ + {FieldCode: "order_count", Aggregate: "SUM", Alias: "total_orders"}, + {FieldCode: "order_amount", Aggregate: "SUM", Alias: "total_amount"}, + }, + } + sql, err := b.buildSelectClause(req, testFields, testReport) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sql == "" { + t.Fatal("empty SELECT clause") + } + assertContains(t, sql, "shop_id") + assertContains(t, sql, "shop_name") + assertContains(t, sql, "SUM(order_count) AS total_orders") + assertContains(t, sql, "SUM(order_amount) AS total_amount") + }) + + t.Run("calculated field", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + Dimensions: []string{"shop_id"}, + Indicators: []model.IndicatorSelect{ + {FieldCode: "order_count", Aggregate: "SUM", Alias: "orders"}, + {FieldCode: "refund_rate", Aggregate: "AVG", Alias: "rate"}, + }, + } + sql, err := b.buildSelectClause(req, testFields, testReport) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContains(t, sql, "shop_id") + assertContains(t, sql, "SUM(order_count) AS orders") + assertContains(t, sql, "refund_amount") + assertContains(t, sql, "order_amount") + }) + + t.Run("time_group weekly", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + Dimensions: []string{"shop_id"}, + Indicators: []model.IndicatorSelect{ + {FieldCode: "order_count", Aggregate: "SUM", Alias: "orders"}, + }, + TimeGroup: "week", + } + sql, err := b.buildSelectClause(req, testFields, testReport) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContains(t, sql, "DATE_TRUNC") + assertContains(t, sql, "time_group") + }) + + t.Run("time_group monthly", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + Dimensions: []string{"shop_id"}, + Indicators: []model.IndicatorSelect{ + {FieldCode: "order_amount", Aggregate: "SUM"}, + }, + TimeGroup: "month", + } + sql, err := b.buildSelectClause(req, testFields, testReport) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContains(t, sql, "YYYY-MM") + assertContains(t, sql, "time_group") + }) + + t.Run("invalid dimension field", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + Dimensions: []string{"nonexistent_field"}, + Indicators: []model.IndicatorSelect{ + {FieldCode: "order_count", Aggregate: "SUM"}, + }, + } + _, err := b.buildSelectClause(req, testFields, testReport) + if err == nil { + t.Fatal("expected error for nonexistent dimension") + } + }) + + t.Run("no indicators", func(t *testing.T) { + req := &model.UserSelectQueryReq{} + _, err := b.buildSelectClause(req, testFields, testReport) + if err == nil { + t.Fatal("expected error for no indicators") + } + }) + + t.Run("invalid aggregate", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + Dimensions: []string{"shop_id"}, + Indicators: []model.IndicatorSelect{ + {FieldCode: "order_count", Aggregate: "AVG"}, + }, + } + _, err := b.buildSelectClause(req, testFields, testReport) + if err == nil { + t.Fatal("expected error for invalid aggregate") + } + }) +} + +func TestBuildFilterCondition(t *testing.T) { + b := &SQLBuilder{} + + tests := []struct { + name string + operator string + value interface{} + value2 interface{} + wantLike string + }{ + {"operator =", "=", "active", nil, "order_status = ?"}, + {"operator !=", "!=", "cancelled", nil, "order_status != ?"}, + {"operator >", ">", 100, nil, "order_amount > ?"}, + {"operator <", "<", 50, nil, "order_amount < ?"}, + {"operator >=", ">=", 100, nil, "order_amount >= ?"}, + {"operator <=", "<=", 50, nil, "order_amount <= ?"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := model.FilterCondition{ + FieldCode: "order_amount", + Operator: tt.operator, + Value: tt.value, + Value2: tt.value2, + } + cond, args, err := b.buildFilterCondition(filter, tt.operator, "FLOAT") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cond == "" { + t.Fatal("empty condition") + } + if len(args) == 0 { + t.Fatal("no args") + } + }) + } +} + +func TestBuildFilterConditionIN(t *testing.T) { + b := &SQLBuilder{} + filter := model.FilterCondition{ + FieldCode: "order_status", + Operator: "IN", + Value: []interface{}{1, 2, 3}, + } + cond, args, err := b.buildFilterCondition(filter, "IN", "STRING") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContains(t, cond, "order_status IN (?,?,?)") + if len(args) != 3 { + t.Fatalf("expected 3 args, got %d", len(args)) + } +} + +func TestBuildFilterConditionLIKE(t *testing.T) { + b := &SQLBuilder{} + filter := model.FilterCondition{ + FieldCode: "shop_name", + Operator: "LIKE", + Value: "旗舰", + } + cond, args, err := b.buildFilterCondition(filter, "LIKE", "STRING") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContains(t, cond, "shop_name LIKE ?") + if len(args) != 1 { + t.Fatalf("expected 1 arg, got %d", len(args)) + } + val := args[0].(string) + assertContains(t, val, "%旗舰%") +} + +func TestBuildFilterConditionBETWEEN(t *testing.T) { + b := &SQLBuilder{} + filter := model.FilterCondition{ + FieldCode: "stat_date", + Operator: "BETWEEN", + Value: "2026-06-01", + Value2: "2026-06-16", + } + cond, args, err := b.buildFilterCondition(filter, "BETWEEN", "DATE") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContains(t, cond, "stat_date BETWEEN ? AND ?") + if len(args) != 2 { + t.Fatalf("expected 2 args, got %d", len(args)) + } +} + +func TestBuildGroupByClause(t *testing.T) { + b := &SQLBuilder{} + + t.Run("with dimensions", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + Dimensions: []string{"shop_id", "shop_name"}, + } + gb, err := b.buildGroupByClause(req, testFields) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContains(t, gb, "shop_id") + assertContains(t, gb, "shop_name") + }) + + t.Run("no dimensions", func(t *testing.T) { + req := &model.UserSelectQueryReq{} + gb, err := b.buildGroupByClause(req, testFields) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gb != "" { + t.Fatalf("expected empty, got %s", gb) + } + }) +} + +func TestBuildOrderByClause(t *testing.T) { + b := &SQLBuilder{} + + t.Run("with orders", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + OrderBy: []model.OrderCondition{ + {FieldCode: "order_amount", Direction: "DESC"}, + {FieldCode: "shop_id", Direction: "ASC"}, + }, + } + ob, err := b.buildOrderByClause(req, testFields) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContains(t, ob, "order_amount DESC") + assertContains(t, ob, "shop_id ASC") + }) + + t.Run("no orders", func(t *testing.T) { + req := &model.UserSelectQueryReq{} + ob, err := b.buildOrderByClause(req, testFields) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ob != "" { + t.Fatalf("expected empty, got %s", ob) + } + }) + + t.Run("invalid field", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + OrderBy: []model.OrderCondition{ + {FieldCode: "nonexistent", Direction: "ASC"}, + }, + } + _, err := b.buildOrderByClause(req, testFields) + if err == nil { + t.Fatal("expected error for nonexistent field") + } + }) +} + +func TestBuildTimeGroupExpr(t *testing.T) { + b := &SQLBuilder{} + + tests := []struct { + group string + wantSub string + }{ + {"week", "DATE_TRUNC"}, + {"month", "YYYY-MM"}, + {"quarter", "Q"}, + {"day", "stat_date"}, + } + + for _, tt := range tests { + t.Run(tt.group, func(t *testing.T) { + got := b.buildTimeGroupExpr("stat_date", tt.group) + assertContains(t, got, tt.wantSub) + assertContains(t, got, "time_group") + }) + } +} + +func TestAddLimit(t *testing.T) { + b := &SQLBuilder{} + + t.Run("normal pagination", func(t *testing.T) { + sql := b.AddLimit("SELECT * FROM t", 2, 10) + assertContains(t, sql, "LIMIT 10") + assertContains(t, sql, "OFFSET 10") + }) + + t.Run("page 1", func(t *testing.T) { + sql := b.AddLimit("SELECT * FROM t", 1, 20) + assertContains(t, sql, "OFFSET 0") + }) + + t.Run("capped page size", func(t *testing.T) { + sql := b.AddLimit("SELECT * FROM t", 1, 5000) + assertContains(t, sql, "LIMIT 1000") + }) +} + +func TestBuildWhereClause(t *testing.T) { + b := &SQLBuilder{} + + t.Run("time range only", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + BusinessCode: "TEST_BIZ", + TimeRange: &model.TimeRange{ + StartDate: "2026-06-01", + EndDate: "2026-06-16", + }, + } + where, args, err := b.buildWhereClause(context.Background(), req, testFields, testReport) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContains(t, where, "tenant_id") + assertContains(t, where, "stat_date") + assertContains(t, where, "business_code = ?") + if len(args) < 2 { + t.Fatalf("expected at least 2 args, got %d", len(args)) + } + }) + + t.Run("with filters", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + BusinessCode: "TEST_BIZ", + Filters: []model.FilterCondition{ + {FieldCode: "order_status", Operator: "=", Value: 1}, + {FieldCode: "shop_name", Operator: "LIKE", Value: "旗舰"}, + }, + } + where, args, err := b.buildWhereClause(context.Background(), req, testFields, testReport) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContains(t, where, "order_status = ?") + assertContains(t, where, "shop_name LIKE ?") + if len(args) < 3 { + t.Fatalf("expected at least 3 args, got %d", len(args)) + } + }) + + t.Run("invalid filter field", func(t *testing.T) { + req := &model.UserSelectQueryReq{ + BusinessCode: "TEST_BIZ", + Filters: []model.FilterCondition{ + {FieldCode: "nonexistent", Operator: "=", Value: "x"}, + }, + } + _, _, err := b.buildWhereClause(context.Background(), req, testFields, testReport) + if err == nil { + t.Fatal("expected error for nonexistent filter field") + } + }) +} + +func TestParseExpression(t *testing.T) { + b := &SQLBuilder{} + + expr := "{refund_amount} / NULLIF({order_amount}, 0) * 100" + indicators := []model.IndicatorSelect{ + {FieldCode: "refund_amount"}, + {FieldCode: "order_amount"}, + } + + result := b.parseExpression(expr, indicators) + assertContains(t, result, "refund_amount") + assertContains(t, result, "order_amount") + assertContains(t, result, "NULLIF") +} + +// Regression: ISSUE-002 — calculated expression referencing non-indicator field +// {fieldCode} placeholder should be replaced with bare field name, not left in SQL +// Found by /qa on 2026-06-16 +func TestParseExpressionRegressionNonIndicatorRef(t *testing.T) { + b := &SQLBuilder{} + expr := "{refund_amount} / NULLIF({external_field}, 0) * 100" + indicators := []model.IndicatorSelect{ + {FieldCode: "refund_amount"}, + } + result := b.parseExpression(expr, indicators) + // external_field not in indicators, but should still be replaced with bare field name + if !contains(result, "external_field") { + t.Fatalf("expected external_field in expression, got: %s", result) + } + if contains(result, "{") || contains(result, "}") { + t.Fatalf("expression should not contain unreplaced brace placeholders: %s", result) + } + if !contains(result, "refund_amount") { + t.Fatalf("expected refund_amount in expression, got: %s", result) + } + if !contains(result, "NULLIF") { + t.Fatalf("expected NULLIF in expression, got: %s", result) + } +} + +func TestConvertToSlice(t *testing.T) { + b := &SQLBuilder{} + + t.Run("from []interface{}", func(t *testing.T) { + result, err := b.convertToSlice([]interface{}{1, 2, 3}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 3 { + t.Fatalf("expected 3 elements, got %d", len(result)) + } + }) + + t.Run("from comma string", func(t *testing.T) { + result, err := b.convertToSlice("a,b,c") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 3 { + t.Fatalf("expected 3 elements, got %d", len(result)) + } + }) +} + +// assertContains helper +func assertContains(t *testing.T, s, substr string) { + t.Helper() + if s == "" { + t.Fatalf("want substr %q in empty string", substr) + } + if !contains(s, substr) { + t.Fatalf("want %q to contain %q", s, substr) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}