Procházet zdrojové kódy

feat: report accurate PostgreSQL type OIDs for expression projections

RowDescription metadata carried a type OID only for direct column
references; every other projection was advertised as TEXT. Extend the
executor's type inference so common expressions report the type the
executor actually produces, and add the missing scalar mappings.

- executor: inferExprType resolves literals, casts, comparisons and
  boolean expressions, integer/real arithmetic, CASE, and the aggregate
  functions (COUNT -> BIGINT, SUM -> BIGINT/DOUBLE/NUMERIC by input,
  AVG -> NUMERIC or DOUBLE, MIN/MAX -> argument type). Columns keep the
  declared schema type; joins keep the first-match rule, and qualified
  references no longer fall through to another table.
- executor: apply the inference to plain, joined, aggregate, GROUP BY,
  SELECT-without-FROM, compound, and RETURNING projections. The
  COUNT(*) fast path and the constant-false WHERE path now carry types
  too, so empty result sets report the same OIDs as populated ones.
- pgserver: map SMALLINT/INT2/TINYINT -> 21 and NUMERIC/DECIMAL -> 1700,
  with matching type sizes; INT4/INT8/MEDIUMINT integer aliases follow.

Value encoding is unchanged; only the advertised type changes. Derived
tables and CTEs still materialize column names without types, so their
projections keep TEXT metadata.
Danilo Fragoso před 1 dnem
rodič
revize
cc9c0723ce

+ 1 - 1
pkg/executor/dashboard_repro_test.go

@@ -24,7 +24,7 @@ func TestSelectColumnTypesGroupBy(t *testing.T) {
 	execMust(t, e, `INSERT INTO hit_stats VALUES (1, 1, '2024-05-06', '[]')`)
 
 	res := execMust(t, e, `SELECT hour, sum(total) FROM hit_counts GROUP BY hour ORDER BY hour`)
-	want := []string{"TIMESTAMP", "TEXT"}
+	want := []string{"TIMESTAMP", "BIGINT"}
 	if len(res.ColumnTypes) != len(want) {
 		t.Fatalf("group-by column types = %v, want %v", res.ColumnTypes, want)
 	}

+ 21 - 0
pkg/executor/executor.go

@@ -445,6 +445,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		} else {
 			result.AddColumn("column1")
 		}
+		result.ColumnTypes = []string{inferExprType(stmt.Columns[0].Expr, nil)}
 		result.AddRow(int64(count))
 		return result, nil
 	}
@@ -496,6 +497,13 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				result.AddColumn(fmt.Sprintf("column%d", i+1))
 			}
 		}
+		// An empty result still carries the projection metadata the scanned
+		// path would report.
+		if isMultiTable || stmt.From[0].Join != nil {
+			result.ColumnTypes = e.joinedSelectColumnTypes(stmt, collectAllTableRefs(stmt.From))
+		} else {
+			result.ColumnTypes = selectColumnTypes(stmt, schema)
+		}
 		return result, nil
 	}
 
@@ -1382,6 +1390,9 @@ func (e *Executor) executeCompound(c *parser.CompoundSelect) (*Result, error) {
 	for _, col := range left.Columns {
 		result.AddColumn(col)
 	}
+	// A compound SELECT reports the left branch's projection metadata, as
+	// PostgreSQL does: the set operation does not retype its columns.
+	result.ColumnTypes = append([]string(nil), left.ColumnTypes...)
 
 	switch c.Op {
 	case parser.SetOpUnion:
@@ -1479,6 +1490,9 @@ func (e *Executor) executeSelectExpr(stmt *parser.SelectStmt) (*Result, error) {
 		} else {
 			result.AddColumn(fmt.Sprintf("column%d", i+1))
 		}
+		// A SELECT without FROM has no schema, but literal and operator
+		// projections still have a known type.
+		result.ColumnTypes = append(result.ColumnTypes, inferExprType(col.Expr, nil))
 	}
 
 	// Evaluate expressions
@@ -1711,6 +1725,7 @@ func schemaFromColumns(columns []string) *storage.Schema {
 // executeAggregateSelect executes a SELECT with aggregate functions.
 func (e *Executor) executeAggregateSelect(stmt *parser.SelectStmt, rows []storage.Row, schema *storage.Schema) (*Result, error) {
 	result := NewResult("SELECT")
+	resolve := schemaTypeResolver(schema)
 
 	// Determine columns and evaluate aggregates
 	for i, col := range stmt.Columns {
@@ -1721,6 +1736,12 @@ func (e *Executor) executeAggregateSelect(stmt *parser.SelectStmt, rows []storag
 		} else {
 			result.AddColumn(fmt.Sprintf("column%d", i+1))
 		}
+		if col.Star || col.TableStar != "" {
+			// The aggregate path emits one synthesized column per wildcard.
+			result.ColumnTypes = append(result.ColumnTypes, "TEXT")
+			continue
+		}
+		result.ColumnTypes = append(result.ColumnTypes, inferExprType(col.Expr, resolve))
 	}
 
 	// Calculate values

+ 309 - 46
pkg/executor/result_types.go

@@ -1,19 +1,43 @@
 package executor
 
 import (
+	"strconv"
 	"strings"
 
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
 )
 
+// columnTypeResolver resolves a possibly-qualified column reference to the SQL
+// type name declared in the schema.
+type columnTypeResolver func(table, column string) (string, bool)
+
+// schemaTypeResolver builds a resolver over a single table schema. The table
+// qualifier is ignored because the analyzer has already validated it.
+func schemaTypeResolver(schema *storage.Schema) columnTypeResolver {
+	return func(_ string, column string) (string, bool) {
+		if schema == nil {
+			return "", false
+		}
+		for _, c := range schema.Columns {
+			if strings.EqualFold(c.Name, column) {
+				return c.Type, true
+			}
+		}
+		return "", false
+	}
+}
+
 // selectColumnTypes computes the result column types for a single-table SELECT
 // projection, parallel to the column names produced by the normal projection
-// expansion. Direct column references (including aliases) and SELECT * resolve
-// to the declared schema type; any expression whose type the engine does not
-// know is reported as TEXT. Types are derived from schema metadata rather than
-// row values so they remain correct even when the result set is empty.
+// expansion. Direct column references (including aliases) resolve to the
+// declared schema type and common expressions resolve to the type the executor
+// produces for them. Types are derived from schema metadata and the expression
+// shape rather than row values so they remain correct even when the result set
+// is empty.
 func selectColumnTypes(stmt *parser.SelectStmt, schema *storage.Schema) []string {
+	resolve := schemaTypeResolver(schema)
 	types := make([]string, 0, len(stmt.Columns))
 	for _, col := range stmt.Columns {
 		if col.Star {
@@ -31,36 +55,22 @@ func selectColumnTypes(stmt *parser.SelectStmt, schema *storage.Schema) []string
 			}
 			continue
 		}
-		types = append(types, projectionColumnType(col, schema))
+		types = append(types, inferExprType(col.Expr, resolve))
 	}
 	return types
 }
 
-// projectionColumnType resolves the type of a single projected expression. A
-// direct column reference resolves to the declared schema type (matched
-// case-insensitively and ignoring any table qualifier); anything else is
-// unknown and reported as TEXT.
+// projectionColumnType resolves the type of a single projected expression
+// against a single table schema.
 func projectionColumnType(col parser.SelectColumn, schema *storage.Schema) string {
-	ref, ok := col.Expr.(*parser.ColumnRef)
-	if !ok {
-		return "TEXT"
-	}
-	for _, c := range schema.Columns {
-		if strings.EqualFold(c.Name, ref.Column) {
-			return c.Type
-		}
-	}
-	if storage.IsRowIDColumn(ref.Column) {
-		// The hidden rowid alias is a signed 64-bit integer, not an unknown
-		// expression, so report it as BIGINT (int8) on the wire.
-		return "BIGINT"
-	}
-	return "TEXT"
+	return inferExprType(col.Expr, schemaTypeResolver(schema))
 }
 
-// joinedSelectColumnTypes resolves direct projections against every table in a
-// joined or comma-separated FROM clause. Expressions retain TEXT metadata.
+// joinedSelectColumnTypes resolves projections against every table in a joined
+// or comma-separated FROM clause. An unqualified reference resolves to the first
+// table that declares it, matching how row lookups are performed.
 func (e *Executor) joinedSelectColumnTypes(stmt *parser.SelectStmt, refs []parser.TableRef) []string {
+	resolve := e.joinedTypeResolver(refs)
 	types := make([]string, 0, len(stmt.Columns))
 	for _, projection := range stmt.Columns {
 		if projection.Star {
@@ -82,36 +92,289 @@ func (e *Executor) joinedSelectColumnTypes(stmt *parser.SelectStmt, refs []parse
 			continue
 		}
 
-		ref, ok := projection.Expr.(*parser.ColumnRef)
-		if !ok {
-			types = append(types, "TEXT")
-			continue
-		}
-		columnType := "TEXT"
-		found := false
-		for _, table := range refs {
-			if ref.Table != "" && !strings.EqualFold(ref.Table, table.Alias) && !strings.EqualFold(ref.Table, table.Name) {
+		types = append(types, inferExprType(projection.Expr, resolve))
+	}
+	return types
+}
+
+// joinedTypeResolver resolves a column reference against the FROM clause. It
+// mirrors the row lookup rules: a qualified reference is limited to the matching
+// table alias or name, and the first table declaring an unqualified column wins.
+func (e *Executor) joinedTypeResolver(refs []parser.TableRef) columnTypeResolver {
+	return func(table, column string) (string, bool) {
+		for _, ref := range refs {
+			if table != "" && !strings.EqualFold(table, ref.Alias) && !strings.EqualFold(table, ref.Name) {
 				continue
 			}
-			schema, err := e.schema.GetSchema(table.Name)
+			schema, err := e.schema.GetSchema(ref.Name)
 			if err != nil {
 				continue
 			}
-			for _, column := range schema.Columns {
-				if strings.EqualFold(column.Name, ref.Column) {
-					columnType = column.Type
-					found = true
-					break
+			for _, candidate := range schema.Columns {
+				if strings.EqualFold(candidate.Name, column) {
+					return candidate.Type, true
 				}
 			}
-			if found {
+			if table != "" {
+				// A qualifier names exactly one table; do not fall through to
+				// another relation that happens to declare the same column.
 				break
 			}
 		}
-		if !found && storage.IsRowIDColumn(ref.Column) {
-			columnType = "BIGINT"
+		return "", false
+	}
+}
+
+// inferExprType reports the SQL type name of a projection expression, using the
+// same names the schema stores so the wire layer can map them to PostgreSQL
+// type OIDs. It only describes the values the executor produces; nothing is
+// converted. TEXT is the fallback for expressions whose type is not known.
+func inferExprType(expr parser.Expr, resolve columnTypeResolver) string {
+	switch e := expr.(type) {
+	case nil:
+		return "TEXT"
+	case *parser.ColumnRef:
+		if resolve != nil {
+			if typeName, ok := resolve(e.Table, e.Column); ok && !isUnknownTypeName(typeName) {
+				return typeName
+			}
+		}
+		if storage.IsRowIDColumn(e.Column) {
+			// The hidden rowid alias is a signed 64-bit integer.
+			return "BIGINT"
+		}
+		return "TEXT"
+	case *parser.ParenExpr:
+		return inferExprType(e.Expr, resolve)
+	case *parser.LiteralExpr:
+		return literalType(e)
+	case *parser.CastExpr:
+		if typeName, ok := castResultType(e.Type.Name); ok {
+			return typeName
 		}
-		types = append(types, columnType)
+		return "TEXT"
+	case *parser.FunctionCall:
+		return functionResultType(e, resolve)
+	case *parser.BinaryExpr:
+		return binaryResultType(e, resolve)
+	case *parser.UnaryExpr:
+		return unaryResultType(e, resolve)
+	case *parser.CaseExpr:
+		return caseResultType(e, resolve)
+	case *parser.InExpr, *parser.BetweenExpr, *parser.LikeExpr, *parser.IsNullExpr,
+		*parser.IsDistinctExpr, *parser.ExistsExpr:
+		return "BOOLEAN"
+	default:
+		return "TEXT"
+	}
+}
+
+// literalType reports the type of a literal value. Integer literals are INTEGER
+// when they fit int32 and BIGINT otherwise, matching how the value is carried.
+// Non-integral numeric literals are NUMERIC; their text form is decimal text.
+func literalType(lit *parser.LiteralExpr) string {
+	switch lit.Type {
+	case lexer.TokenNumber:
+		if strings.ContainsAny(lit.Value, ".eE") {
+			return "NUMERIC"
+		}
+		if n, err := strconv.ParseInt(lit.Value, 10, 64); err == nil && n >= -2147483648 && n <= 2147483647 {
+			return "INTEGER"
+		}
+		return "BIGINT"
+	case lexer.TokenString:
+		return "TEXT"
+	case lexer.TokenBlob:
+		return "BLOB"
+	case lexer.TokenTRUE, lexer.TokenFALSE:
+		return "BOOLEAN"
+	default: // NULL and anything unrecognized stay unknown/TEXT.
+		return "TEXT"
+	}
+}
+
+// castResultType reports the result type of CAST(x AS name). Only targets the
+// executor actually converts are reported; every other target leaves the value
+// untouched, so the value keeps its original encoding and stays TEXT metadata.
+func castResultType(name string) (string, bool) {
+	switch strings.ToUpper(strings.TrimSpace(name)) {
+	case "INTEGER", "INT":
+		return "INTEGER", true
+	case "SMALLINT", "TINYINT":
+		return "SMALLINT", true
+	case "MEDIUMINT":
+		return "INTEGER", true
+	case "BIGINT":
+		return "BIGINT", true
+	case "REAL", "FLOAT":
+		return "REAL", true
+	case "DOUBLE":
+		return "DOUBLE", true
+	case "TEXT", "VARCHAR", "CHAR", "CHARACTER", "CLOB", "NCHAR", "NVARCHAR":
+		return "TEXT", true
+	case "BLOB":
+		return "BLOB", true
+	default:
+		return "", false
+	}
+}
+
+// functionResultType reports the result type of a function call. Aggregate
+// result types mirror the values the executor computes: COUNT is BIGINT, SUM
+// over integers is BIGINT, SUM over reals is DOUBLE, and AVG over non-real input
+// is NUMERIC because it produces a non-integral decimal.
+func functionResultType(fn *parser.FunctionCall, resolve columnTypeResolver) string {
+	argType := "TEXT"
+	if len(fn.Args) > 0 {
+		argType = inferExprType(fn.Args[0], resolve)
+	}
+	switch strings.ToUpper(fn.Name) {
+	case "COUNT":
+		return "BIGINT"
+	case "SUM":
+		return sumResultType(argType)
+	case "TOTAL":
+		return "DOUBLE"
+	case "AVG":
+		if isRealTypeName(argType) {
+			return "DOUBLE"
+		}
+		return "NUMERIC"
+	case "MIN", "MAX":
+		// MIN/MAX return one of the input values unchanged.
+		return argType
+	default:
+		return "TEXT"
+	}
+}
+
+// sumResultType reports the SUM result type for an input type. The executor
+// accumulates integers in int64 and everything else in float64.
+func sumResultType(argType string) string {
+	switch {
+	case isRealTypeName(argType):
+		return "DOUBLE"
+	case isIntegerTypeName(argType):
+		return "BIGINT"
+	case isNumericTypeName(argType):
+		return "NUMERIC"
+	default:
+		return "TEXT"
+	}
+}
+
+// binaryResultType reports the result type of a binary operator. Comparisons and
+// boolean operators produce BOOLEAN; arithmetic reports the widest operand type.
+func binaryResultType(e *parser.BinaryExpr, resolve columnTypeResolver) string {
+	switch e.Op {
+	case lexer.TokenEq, lexer.TokenNeq, lexer.TokenLt, lexer.TokenLte, lexer.TokenGt, lexer.TokenGte,
+		lexer.TokenAND, lexer.TokenOR:
+		return "BOOLEAN"
+	case lexer.TokenConcat:
+		return "TEXT"
+	case lexer.TokenBitAnd, lexer.TokenBitOr, lexer.TokenShiftLeft, lexer.TokenShiftRight:
+		return "BIGINT"
+	case lexer.TokenPlus, lexer.TokenMinus, lexer.TokenStar, lexer.TokenSlash, lexer.TokenPercent:
+		left := inferExprType(e.Left, resolve)
+		right := inferExprType(e.Right, resolve)
+		switch {
+		case isRealTypeName(left) || isRealTypeName(right):
+			return "DOUBLE"
+		case isIntegerTypeName(left) && isIntegerTypeName(right):
+			// Integer arithmetic is accumulated in int64, so report the wider
+			// integer type even when both operands are INTEGER.
+			return "BIGINT"
+		case isNumericTypeName(left) && isNumericTypeName(right):
+			return "NUMERIC"
+		default:
+			return "TEXT"
+		}
+	default:
+		return "TEXT"
+	}
+}
+
+// unaryResultType reports the result type of a unary operator.
+func unaryResultType(e *parser.UnaryExpr, resolve columnTypeResolver) string {
+	switch e.Op {
+	case lexer.TokenNOT:
+		return "BOOLEAN"
+	case lexer.TokenBitNot:
+		return "BIGINT"
+	case lexer.TokenMinus, lexer.TokenPlus:
+		operand := inferExprType(e.Operand, resolve)
+		if isNumericTypeName(operand) {
+			return operand
+		}
+		return "TEXT"
+	default:
+		return "TEXT"
+	}
+}
+
+// caseResultType reports the type of a CASE expression when every branch agrees
+// on the same non-TEXT type; otherwise the type is unknown.
+func caseResultType(e *parser.CaseExpr, resolve columnTypeResolver) string {
+	result := ""
+	for _, when := range e.Whens {
+		typeName := inferExprType(when.Result, resolve)
+		if result == "" {
+			result = typeName
+			continue
+		}
+		if !strings.EqualFold(result, typeName) {
+			return "TEXT"
+		}
+	}
+	if e.Else != nil {
+		typeName := inferExprType(e.Else, resolve)
+		if result == "" || !strings.EqualFold(result, typeName) {
+			return "TEXT"
+		}
+	}
+	if result == "" {
+		return "TEXT"
+	}
+	return result
+}
+
+// isIntegerTypeName reports whether a type name is an integer type.
+func isIntegerTypeName(name string) bool {
+	switch strings.ToUpper(strings.TrimSpace(name)) {
+	case "INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT":
+		return true
+	default:
+		return false
+	}
+}
+
+// isRealTypeName reports whether a type name is a floating-point type.
+func isRealTypeName(name string) bool {
+	switch strings.ToUpper(strings.TrimSpace(name)) {
+	case "REAL", "FLOAT", "DOUBLE":
+		return true
+	default:
+		return false
+	}
+}
+
+// isNumericTypeName reports whether a type name is any numeric type.
+func isNumericTypeName(name string) bool {
+	switch strings.ToUpper(strings.TrimSpace(name)) {
+	case "NUMERIC", "DECIMAL":
+		return true
+	default:
+		return isIntegerTypeName(name) || isRealTypeName(name)
+	}
+}
+
+// isUnknownTypeName reports whether a schema type name carries no type
+// information, as used for derived-table and CTE columns.
+func isUnknownTypeName(name string) bool {
+	switch strings.ToUpper(strings.TrimSpace(name)) {
+	case "", "ANY", "UNKNOWN", "NULL":
+		return true
+	default:
+		return false
 	}
-	return types
 }

+ 173 - 3
pkg/executor/result_types_test.go

@@ -1,6 +1,11 @@
 package executor
 
-import "testing"
+import (
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
 
 func TestSelectColumnTypesFromSchema(t *testing.T) {
 	_, schema, table := newTestDB(t)
@@ -47,12 +52,14 @@ func TestSelectColumnTypesAlias(t *testing.T) {
 	}
 }
 
-func TestSelectColumnTypesUnknownExpression(t *testing.T) {
+func TestSelectColumnTypesArithmeticExpression(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
 	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
+	// Integer arithmetic accumulates in int64, so it reports the wider
+	// integer type rather than the schema type of its operands.
 	res := execMust(t, e, "SELECT id + 1, id * 2 FROM t")
-	if len(res.ColumnTypes) != 2 || res.ColumnTypes[0] != "TEXT" || res.ColumnTypes[1] != "TEXT" {
+	if len(res.ColumnTypes) != 2 || res.ColumnTypes[0] != "BIGINT" || res.ColumnTypes[1] != "BIGINT" {
 		t.Fatalf("unexpected column types %v", res.ColumnTypes)
 	}
 }
@@ -122,3 +129,166 @@ func TestUUIDColumnRoundTrip(t *testing.T) {
 		t.Fatalf("column types = %v, want [UUID TEXT]", res.ColumnTypes)
 	}
 }
+
+// TestInferExprType pins the expression type inference behind result metadata.
+func TestInferExprType(t *testing.T) {
+	schema := &storage.Schema{Name: "t", Columns: []storage.Column{
+		{Name: "id", Type: "INTEGER"},
+		{Name: "big", Type: "BIGINT"},
+		{Name: "small", Type: "SMALLINT"},
+		{Name: "price", Type: "NUMERIC"},
+		{Name: "score", Type: "DOUBLE"},
+		{Name: "flag", Type: "BOOLEAN"},
+		{Name: "name", Type: "TEXT"},
+		{Name: "data", Type: "BLOB"},
+		{Name: "created_at", Type: "TIMESTAMP"},
+		{Name: "day", Type: "DATE"},
+	}}
+	resolve := schemaTypeResolver(schema)
+
+	cases := []struct {
+		expr string
+		want string
+	}{
+		// Direct column references keep the declared schema type.
+		{"id", "INTEGER"},
+		{"big", "BIGINT"},
+		{"small", "SMALLINT"},
+		{"price", "NUMERIC"},
+		{"score", "DOUBLE"},
+		{"flag", "BOOLEAN"},
+		{"name", "TEXT"},
+		{"data", "BLOB"},
+		{"created_at", "TIMESTAMP"},
+		{"day", "DATE"},
+		{"rowid", "BIGINT"},
+		{"missing", "TEXT"},
+
+		// Literals.
+		{"1", "INTEGER"},
+		{"2147483647", "INTEGER"},
+		{"2147483648", "BIGINT"},
+		{"1.5", "NUMERIC"},
+		{"'text'", "TEXT"},
+		{"TRUE", "BOOLEAN"},
+		{"FALSE", "BOOLEAN"},
+		{"X'00'", "BLOB"},
+		{"NULL", "TEXT"},
+
+		// Comparisons and boolean expressions.
+		{"id = 1", "BOOLEAN"},
+		{"id <> 1", "BOOLEAN"},
+		{"id > 0 AND flag", "BOOLEAN"},
+		{"flag OR id < 0", "BOOLEAN"},
+		{"NOT flag", "BOOLEAN"},
+		{"name LIKE 'a%'", "BOOLEAN"},
+		{"id BETWEEN 1 AND 3", "BOOLEAN"},
+		{"id IN (1, 2)", "BOOLEAN"},
+		{"name IS NULL", "BOOLEAN"},
+		{"id IS DISTINCT FROM 1", "BOOLEAN"},
+
+		// Aggregates.
+		{"count(*)", "BIGINT"},
+		{"count(id)", "BIGINT"},
+		{"sum(id)", "BIGINT"},
+		{"sum(price)", "NUMERIC"},
+		{"sum(score)", "DOUBLE"},
+		{"avg(id)", "NUMERIC"},
+		{"avg(score)", "DOUBLE"},
+		{"total(id)", "DOUBLE"},
+		{"min(created_at)", "TIMESTAMP"},
+		{"max(name)", "TEXT"},
+
+		// Arithmetic, concatenation, and bitwise operators.
+		{"id + 1", "BIGINT"},
+		{"score + 1", "DOUBLE"},
+		{"price + 1", "NUMERIC"},
+		{"name || 'x'", "TEXT"},
+		{"id & 3", "BIGINT"},
+		{"-id", "INTEGER"},
+		{"-1.5", "NUMERIC"},
+		{"(id)", "INTEGER"},
+
+		// Casts report the target type the executor converts to.
+		{"CAST(id AS BIGINT)", "BIGINT"},
+		{"CAST(id AS SMALLINT)", "SMALLINT"},
+		{"CAST(id AS TEXT)", "TEXT"},
+		{"CAST(name AS BLOB)", "BLOB"},
+		{"CAST(name AS DOUBLE)", "DOUBLE"},
+		{"CAST(id AS DATE)", "TEXT"},
+
+		// CASE keeps a type only when every branch agrees.
+		{"CASE WHEN flag THEN id ELSE 0 END", "INTEGER"},
+		{"CASE WHEN flag THEN id ELSE name END", "TEXT"},
+
+		// Functions whose result type is not modeled stay TEXT.
+		{"lower(name)", "TEXT"},
+	}
+
+	for _, tc := range cases {
+		stmt := parse(t, "SELECT "+tc.expr).(*parser.SelectStmt)
+		if got := inferExprType(stmt.Columns[0].Expr, resolve); got != tc.want {
+			t.Errorf("inferExprType(%q) = %q, want %q", tc.expr, got, tc.want)
+		}
+	}
+}
+
+// TestSelectColumnTypesExpressions verifies that the executor attaches inferred
+// types to non-column projections, including over an empty result set.
+func TestSelectColumnTypesExpressions(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, created_at TIMESTAMP)")
+	execMust(t, e, "INSERT INTO t VALUES (1, '2024-05-06 07:08:09')")
+
+	cases := []struct {
+		sql  string
+		want []string
+	}{
+		{"SELECT count(*) FROM t", []string{"BIGINT"}},
+		{"SELECT sum(id) FROM t", []string{"BIGINT"}},
+		{"SELECT avg(id) FROM t", []string{"NUMERIC"}},
+		{"SELECT id = 1 FROM t", []string{"BOOLEAN"}},
+		{"SELECT 'text' FROM t", []string{"TEXT"}},
+		{"SELECT 42 FROM t", []string{"INTEGER"}},
+		{"SELECT 1.5 FROM t", []string{"NUMERIC"}},
+		{"SELECT CAST(id AS BIGINT) FROM t", []string{"BIGINT"}},
+		{"SELECT created_at FROM t GROUP BY created_at", []string{"TIMESTAMP"}},
+		{"SELECT id FROM t UNION SELECT id FROM t", []string{"INTEGER"}},
+		// The empty result still reports projection metadata.
+		{"SELECT id, created_at FROM t WHERE id = 999", []string{"INTEGER", "TIMESTAMP"}},
+		{"SELECT id + 1 FROM t WHERE id = 999", []string{"BIGINT"}},
+		{"SELECT count(*), max(created_at) FROM t WHERE id = 999", []string{"BIGINT", "TIMESTAMP"}},
+	}
+
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if len(res.ColumnTypes) != len(tc.want) {
+			t.Errorf("%s: column types = %v, want %v", tc.sql, res.ColumnTypes, tc.want)
+			continue
+		}
+		for i := range tc.want {
+			if res.ColumnTypes[i] != tc.want[i] {
+				t.Errorf("%s: column types = %v, want %v", tc.sql, res.ColumnTypes, tc.want)
+				break
+			}
+		}
+	}
+}
+
+// TestSelectColumnTypesWithoutFrom covers projections with no schema at all.
+func TestSelectColumnTypesWithoutFrom(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	res := execMust(t, e, "SELECT 1, 1.5, 'x', 1 = 1, CAST(1 AS SMALLINT)")
+	want := []string{"INTEGER", "NUMERIC", "TEXT", "BOOLEAN", "SMALLINT"}
+	if len(res.ColumnTypes) != len(want) {
+		t.Fatalf("column types = %v, want %v", res.ColumnTypes, want)
+	}
+	for i := range want {
+		if res.ColumnTypes[i] != want[i] {
+			t.Fatalf("column types = %v, want %v", res.ColumnTypes, want)
+		}
+	}
+}

+ 4 - 11
pkg/executor/returning.go

@@ -45,18 +45,11 @@ func returningProjection(cols []parser.SelectColumn, schema *storage.Schema) ([]
 	return names, types
 }
 
-// projectionType resolves the declared type of a direct column reference.
+// projectionType resolves the declared type of a RETURNING expression. Direct
+// column references use the schema type and expressions use the executor's type
+// inference, so RETURNING metadata matches the equivalent SELECT projection.
 func projectionType(expr parser.Expr, schema *storage.Schema) string {
-	ref, ok := expr.(*parser.ColumnRef)
-	if !ok {
-		return "TEXT"
-	}
-	for _, c := range schema.Columns {
-		if strings.EqualFold(c.Name, ref.Column) {
-			return c.Type
-		}
-	}
-	return "TEXT"
+	return inferExprType(expr, schemaTypeResolver(schema))
 }
 
 // returningResult evaluates a RETURNING projection over the affected rows and

+ 13 - 5
pkg/pgserver/connection.go

@@ -1369,10 +1369,16 @@ func (c *Connection) writeMessage(msgType byte, data []byte) error {
 // getOIDForType returns PostgreSQL OID for type
 func (c *Connection) getOIDForType(typeName string) int32 {
 	switch strings.ToUpper(typeName) {
-	case "INTEGER", "INT":
+	case "SMALLINT", "INT2", "TINYINT":
+		// TINYINT has no PostgreSQL equivalent; the smallest integer type
+		// reports it without losing the integer nature of the values.
+		return 21 // INT2OID
+	case "INTEGER", "INT", "INT4", "MEDIUMINT":
 		return 23 // INT4OID
-	case "BIGINT":
+	case "BIGINT", "INT8":
 		return 20 // INT8OID
+	case "NUMERIC", "DECIMAL":
+		return 1700 // NUMERICOID
 	case "TEXT", "VARCHAR", "CHAR", "UUID":
 		// UUID is stored as text (SQLite-style), so it reports TEXTOID rather
 		// than the native PG UUID OID.
@@ -1396,12 +1402,14 @@ func (c *Connection) getOIDForType(typeName string) int32 {
 	}
 }
 
-// getTypeSizeForType returns type size
+// getTypeSizeForType returns type size in bytes, or -1 for variable length.
 func (c *Connection) getTypeSizeForType(typeName string) int16 {
 	switch strings.ToUpper(typeName) {
-	case "INTEGER", "INT":
+	case "SMALLINT", "INT2", "TINYINT":
+		return 2
+	case "INTEGER", "INT", "INT4", "MEDIUMINT":
 		return 4
-	case "BIGINT":
+	case "BIGINT", "INT8":
 		return 8
 	case "REAL", "FLOAT":
 		return 4

+ 111 - 1
pkg/pgserver/oid_test.go

@@ -3,6 +3,7 @@ package pgserver
 import (
 	"bytes"
 	"encoding/binary"
+	"net"
 	"testing"
 
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
@@ -15,12 +16,27 @@ func TestGetOIDForTypeDatetime(t *testing.T) {
 		"DATETIME":  1184,
 		"TIMESTAMP": 1184,
 		"DATE":      1082,
-		"BIGINT":    20,
+		"SMALLINT":  21,
+		"INT2":      21,
+		"TINYINT":   21,
+		"MEDIUMINT": 23,
 		"INTEGER":   23,
 		"INT":       23,
+		"INT4":      23,
+		"BIGINT":    20,
+		"INT8":      20,
+		"NUMERIC":   1700,
+		"DECIMAL":   1700,
+		"REAL":      700,
+		"FLOAT":     700,
+		"DOUBLE":    701,
 		"TEXT":      25,
+		"VARCHAR":   25,
+		"CHAR":      25,
 		"UUID":      25,
 		"BOOLEAN":   16,
+		"BOOL":      16,
+		"BLOB":      17,
 	}
 	for name, want := range cases {
 		if got := c.getOIDForType(name); got != want {
@@ -108,3 +124,97 @@ func rowDescriptionOIDs(t *testing.T, data []byte) []int32 {
 	}
 	return oids
 }
+
+func TestGetTypeSizeForType(t *testing.T) {
+	c := &Connection{}
+	cases := map[string]int16{
+		"SMALLINT":  2,
+		"INTEGER":   4,
+		"BIGINT":    8,
+		"REAL":      4,
+		"DOUBLE":    8,
+		"BOOLEAN":   1,
+		"DATE":      4,
+		"TIMESTAMP": 8,
+		"NUMERIC":   -1,
+		"TEXT":      -1,
+		"BLOB":      -1,
+	}
+	for name, want := range cases {
+		if got := c.getTypeSizeForType(name); got != want {
+			t.Errorf("getTypeSizeForType(%q) = %d, want %d", name, got, want)
+		}
+	}
+}
+
+// projectionOIDs runs sql and returns the type OIDs in its RowDescription.
+func projectionOIDs(t *testing.T, c *Connection, client net.Conn, sql string) []int32 {
+	t.Helper()
+	msgs := resultMessages(runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(sql), 0)}))
+	return rowDescriptionOIDs(t, findRowDescription(t, msgs).Data)
+}
+
+// TestRowDescriptionProjectionOIDs asserts the wire OIDs for column
+// projections, expression projections, and empty result sets.
+func TestRowDescriptionProjectionOIDs(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(
+		`CREATE TABLE typed (
+			i INTEGER PRIMARY KEY,
+			big BIGINT,
+			small SMALLINT,
+			price NUMERIC,
+			flag BOOLEAN,
+			name TEXT,
+			data BLOB,
+			created_at TIMESTAMP,
+			day DATE
+		)`), 0)})
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(
+		`INSERT INTO typed VALUES (1, 9000000000, 7, 19.5, TRUE, 'x', X'00', '2024-05-06 07:08:09', '2024-05-06')`), 0)})
+
+	cases := []struct {
+		name string
+		sql  string
+		want []int32
+	}{
+		{
+			name: "columns",
+			sql:  "SELECT i, big, small, price, flag, name, data, created_at, day FROM typed",
+			want: []int32{23, 20, 21, 1700, 16, 25, 17, 1184, 1082},
+		},
+		{name: "count star", sql: "SELECT count(*) FROM typed", want: []int32{20}},
+		{name: "sum integer", sql: "SELECT sum(i) FROM typed", want: []int32{20}},
+		{name: "avg integer", sql: "SELECT avg(i) FROM typed", want: []int32{1700}},
+		{name: "comparison", sql: "SELECT i = 1 FROM typed", want: []int32{16}},
+		{name: "string literal", sql: "SELECT 'text'", want: []int32{25}},
+		{name: "integer literal", sql: "SELECT 42", want: []int32{23}},
+		{name: "wide integer literal", sql: "SELECT 4294967296", want: []int32{20}},
+		{name: "numeric literal", sql: "SELECT 1.5", want: []int32{1700}},
+		{name: "cast", sql: "SELECT CAST(i AS BIGINT) FROM typed", want: []int32{20}},
+		{
+			name: "empty result",
+			sql:  "SELECT i, created_at, i + 1 FROM typed WHERE i = 999",
+			want: []int32{23, 1184, 20},
+		},
+		{
+			name: "aggregate empty result",
+			sql:  "SELECT count(*), max(created_at) FROM typed WHERE i = 999",
+			want: []int32{20, 1184},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := projectionOIDs(t, c, client, tc.sql)
+			if len(got) != len(tc.want) {
+				t.Fatalf("%s: OIDs = %v, want %v", tc.sql, got, tc.want)
+			}
+			for i := range tc.want {
+				if got[i] != tc.want[i] {
+					t.Fatalf("%s: OIDs = %v, want %v", tc.sql, got, tc.want)
+				}
+			}
+		})
+	}
+}