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 }{ {"year", "YYYY"}, {"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 }