Files
data-engine/common/report/builder/sql_builder_test.go
T
lmk 9e283f67a0 test(qa): regression test for ISSUE-002 — parseExpression 非 indicator 字段占位符
验证当衍生指标表达式引用未在 indicators 勾选的字段时,
{fieldCode} 占位符被替换为裸字段名,不残留大括号到 SQL 中。
2026-06-16 19:33:57 +08:00

484 lines
14 KiB
Go

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
}