Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions internal/ast/string.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
64 changes: 64 additions & 0 deletions internal/ast/string_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 4 additions & 1 deletion internal/plan/aggregate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
101 changes: 101 additions & 0 deletions internal/plan/label_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
5 changes: 4 additions & 1 deletion internal/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading