484 lines
14 KiB
Go
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
|
|
}
|