test(qa): regression test for ISSUE-002 — parseExpression 非 indicator 字段占位符
验证当衍生指标表达式引用未在 indicators 勾选的字段时,
{fieldCode} 占位符被替换为裸字段名,不残留大括号到 SQL 中。
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user