diff --git a/internal/ast/string.go b/internal/ast/string.go new file mode 100644 index 0000000..6788580 --- /dev/null +++ b/internal/ast/string.go @@ -0,0 +1,62 @@ +package ast + +import "github.com/aybavs/sql-query-engine/internal/value" + +// String renders an expression back to SQL-like text. It is used to label +// result columns for computed projections, the way `SELECT COUNT(id)` reports +// a column named "COUNT(id)". +func String(e Expr) string { + switch n := e.(type) { + case *ColumnRef: + if n.Table != "" { + return n.Table + "." + n.Name + } + return n.Name + + case *Literal: + if !n.Val.IsNull() && n.Val.Type == value.TText { + return "'" + n.Val.S + "'" + } + return n.Val.String() + + case *UnaryExpr: + if n.Op == "NOT" { + return "NOT " + operand(n.Expr) + } + return n.Op + operand(n.Expr) + + case *IsNull: + if n.Negate { + return operand(n.Expr) + " IS NOT NULL" + } + return operand(n.Expr) + " IS NULL" + + case *BinaryExpr: + return operand(n.Left) + " " + n.Op + " " + operand(n.Right) + + case *AggregateCall: + if n.Star { + return n.Name + "(*)" + } + return n.Name + "(" + String(n.Arg) + ")" + + case *SlotRef: + // Slots only exist after aggregate lowering and are never user-visible; + // labels are taken from the original expressions. + return "expr" + + default: + return "expr" + } +} + +// operand renders a sub-expression, parenthesizing nested binary and unary +// operators so a rendered label cannot be read with the wrong grouping. +func operand(e Expr) string { + switch e.(type) { + case *BinaryExpr, *UnaryExpr: + return "(" + String(e) + ")" + default: + return String(e) + } +} diff --git a/internal/ast/string_test.go b/internal/ast/string_test.go new file mode 100644 index 0000000..5670310 --- /dev/null +++ b/internal/ast/string_test.go @@ -0,0 +1,64 @@ +package ast + +import ( + "testing" + + "github.com/aybavs/sql-query-engine/internal/value" +) + +func TestStringRendersExpressions(t *testing.T) { + cases := []struct { + want string + expr Expr + }{ + {"age", &ColumnRef{Name: "age"}}, + {"users.age", &ColumnRef{Table: "users", Name: "age"}}, + {"18", &Literal{Val: value.Int64(18)}}, + {"'berlin'", &Literal{Val: value.Text("berlin")}}, + {"NULL", &Literal{Val: value.NullOf(value.TInt)}}, + { + "age + 1", + &BinaryExpr{Op: "+", Left: &ColumnRef{Name: "age"}, Right: &Literal{Val: value.Int64(1)}}, + }, + { + "NOT active", + &UnaryExpr{Op: "NOT", Expr: &ColumnRef{Name: "active"}}, + }, + { + "-age", + &UnaryExpr{Op: "-", Expr: &ColumnRef{Name: "age"}}, + }, + {"city IS NULL", &IsNull{Expr: &ColumnRef{Name: "city"}}}, + {"city IS NOT NULL", &IsNull{Expr: &ColumnRef{Name: "city"}, Negate: true}}, + {"COUNT(*)", &AggregateCall{Name: "COUNT", Star: true}}, + {"COUNT(id)", &AggregateCall{Name: "COUNT", Arg: &ColumnRef{Name: "id"}}}, + {"AVG(users.age)", &AggregateCall{Name: "AVG", Arg: &ColumnRef{Table: "users", Name: "age"}}}, + } + for _, c := range cases { + if got := String(c.expr); got != c.want { + t.Errorf("String() = %q, want %q", got, c.want) + } + } +} + +func TestStringParenthesizesNestedBinary(t *testing.T) { + // SUM(total) / COUNT(id) where each operand is itself an expression + e := &BinaryExpr{ + Op: "/", + Left: &AggregateCall{Name: "SUM", Arg: &ColumnRef{Name: "total"}}, + Right: &AggregateCall{Name: "COUNT", Arg: &ColumnRef{Name: "id"}}, + } + if got, want := String(e), "SUM(total) / COUNT(id)"; got != want { + t.Fatalf("String() = %q, want %q", got, want) + } + + // a nested binary operand is parenthesized so the label stays unambiguous + nested := &BinaryExpr{ + Op: "*", + Left: &BinaryExpr{Op: "+", Left: &ColumnRef{Name: "a"}, Right: &ColumnRef{Name: "b"}}, + Right: &Literal{Val: value.Int64(2)}, + } + if got, want := String(nested), "(a + b) * 2"; got != want { + t.Fatalf("String() = %q, want %q", got, want) + } +} diff --git a/internal/plan/aggregate.go b/internal/plan/aggregate.go index 61d5b9d..2e7ca3d 100644 --- a/internal/plan/aggregate.go +++ b/internal/plan/aggregate.go @@ -100,7 +100,10 @@ func buildAggregatePlan( for i, spec := range collector.specs { collector.slots[collector.calls[i]] = len(aggregateSchema) - aggregateSchema = append(aggregateSchema, exec.Column{Name: "expr", Type: spec.OutType}) + aggregateSchema = append(aggregateSchema, exec.Column{ + Name: ast.String(collector.calls[i]), + Type: spec.OutType, + }) } projections := make([]ast.Expr, 0, len(st.Projections)) diff --git a/internal/plan/label_test.go b/internal/plan/label_test.go new file mode 100644 index 0000000..3a01c60 --- /dev/null +++ b/internal/plan/label_test.go @@ -0,0 +1,101 @@ +package plan + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aybavs/sql-query-engine/internal/catalog" + "github.com/aybavs/sql-query-engine/internal/lexer" + "github.com/aybavs/sql-query-engine/internal/parser" + "github.com/aybavs/sql-query-engine/internal/value" +) + +// labelCatalog has users(id, name, age, city) and orders(id, user_id, total). +func labelCatalog() *catalog.Catalog { + cat := catalog.New() + cat.Add(&catalog.Table{ + Name: "users", File: "users.csv", + Columns: []catalog.Column{ + {Name: "id", Type: value.TInt}, + {Name: "name", Type: value.TText}, + {Name: "age", Type: value.TInt}, + {Name: "city", Type: value.TText}, + }, + }) + cat.Add(&catalog.Table{ + Name: "orders", File: "orders.csv", + Columns: []catalog.Column{ + {Name: "id", Type: value.TInt}, + {Name: "user_id", Type: value.TInt}, + {Name: "total", Type: value.TInt}, + }, + }) + return cat +} + +// schemaNames plans a query and returns its result-column labels. +func schemaNames(t *testing.T, sql, dir string) []string { + t.Helper() + toks, err := lexer.Lex(sql) + if err != nil { + t.Fatalf("lex: %v", err) + } + st, err := parser.New(toks).ParseSelect() + if err != nil { + t.Fatalf("parse: %v", err) + } + _, schema, err := Build(st, labelCatalog(), dir) + if err != nil { + t.Fatalf("build: %v", err) + } + names := make([]string, len(schema)) + for i, c := range schema { + names[i] = c.Name + } + return names +} + +func labelDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "users.csv"), []byte("1,alice,30,berlin\n2,bob,15,paris\n"), 0o644) + os.WriteFile(filepath.Join(dir, "orders.csv"), []byte("10,1,100\n"), 0o644) + return dir +} + +func TestAggregateProjectionsAreLabelled(t *testing.T) { + dir := labelDir(t) + got := schemaNames(t, "SELECT city, COUNT(id), AVG(age) FROM users GROUP BY city", dir) + want := []string{"city", "COUNT(id)", "AVG(age)"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("labels = %v, want %v", got, want) + } + } +} + +func TestComputedProjectionIsLabelled(t *testing.T) { + dir := labelDir(t) + got := schemaNames(t, "SELECT age + 1 FROM users", dir) + if got[0] != "age + 1" { + t.Fatalf("label = %q, want %q", got[0], "age + 1") + } +} + +func TestCountStarIsLabelled(t *testing.T) { + dir := labelDir(t) + got := schemaNames(t, "SELECT COUNT(*) FROM users", dir) + if got[0] != "COUNT(*)" { + t.Fatalf("label = %q, want %q", got[0], "COUNT(*)") + } +} + +// A bare column keeps its plain name even when qualified, matching SQLite. +func TestBareColumnKeepsPlainName(t *testing.T) { + dir := labelDir(t) + got := schemaNames(t, "SELECT users.name FROM users JOIN orders ON users.id = orders.user_id", dir) + if got[0] != "name" { + t.Fatalf("label = %q, want %q", got[0], "name") + } +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go index b03ef7a..e399f1a 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -255,9 +255,12 @@ func validate(e ast.Expr, s exec.Schema) error { } } +// exprName labels a result column. A bare column reference keeps its plain +// name even when qualified in the query; any computed projection is labelled +// with its source expression, so `SELECT COUNT(id)` reports "COUNT(id)". func exprName(e ast.Expr) string { if c, ok := e.(*ast.ColumnRef); ok { return c.Name } - return "expr" + return ast.String(e) }