Procházet zdrojové kódy

Merge pull request #31 from database-pizza/engine/a-type-oids

feat: accurate PostgreSQL type OIDs for expression projections
Danilo Fragoso před 1 dnem
rodič
revize
05fad6e9d8

+ 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)
 	}

+ 104 - 62
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
 	}
@@ -481,20 +482,21 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				return e.executeAggregateSelect(stmt, []storage.Row{}, schema)
 			}
 		}
-		// Non-aggregate query with WHERE false: return empty result
+		// Non-aggregate query with WHERE false: return empty result using the
+		// same wildcard expansion and type metadata the scanned path reports.
+		refs := collectAllTableRefs(stmt.From)
+		tableStars, starErr := e.resolveTableStars(stmt.From, stmt.Columns)
+		if starErr != nil {
+			return nil, starErr
+		}
 		result := NewResult("SELECT")
-		for i, col := range stmt.Columns {
-			if col.Alias != "" {
-				result.AddColumn(col.Alias)
-			} else if ref, ok := col.Expr.(*parser.ColumnRef); ok {
-				result.AddColumn(ref.Column)
-			} else if col.Star {
-				for _, c := range schema.Columns {
-					result.AddColumn(c.Name)
-				}
-			} else {
-				result.AddColumn(fmt.Sprintf("column%d", i+1))
-			}
+		for _, name := range e.selectColumnNames(stmt, schema, refs, isMultiTable, tableStars) {
+			result.AddColumn(name)
+		}
+		if isMultiTable || stmt.From[0].Join != nil {
+			result.ColumnTypes = e.joinedSelectColumnTypes(stmt, refs)
+		} else {
+			result.ColumnTypes = selectColumnTypes(stmt, schema)
 		}
 		return result, nil
 	}
@@ -1223,58 +1225,16 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 	hasJoin := len(stmt.From) > 0 && stmt.From[0].Join != nil
 	allTableRefs := collectAllTableRefs(stmt.From)
 
-	// Resolve qualified wildcard (table.*) projections once, keyed by qualifier.
-	type starProjection struct {
-		cols   []storage.Column
-		prefix string
-	}
-	tableStars := make(map[string]starProjection)
-	for _, col := range stmt.Columns {
-		if col.TableStar == "" {
-			continue
-		}
-		key := strings.ToUpper(col.TableStar)
-		if _, ok := tableStars[key]; ok {
-			continue
-		}
-		cols, prefix, err := e.resolveTableStar(stmt.From, col.TableStar)
-		if err != nil {
-			return nil, err
-		}
-		tableStars[key] = starProjection{cols: cols, prefix: prefix}
+	// Resolve qualified wildcard (table.*) projections once, keyed by qualifier,
+	// so names, types, and row values expand exactly the same columns.
+	tableStars, err := e.resolveTableStars(stmt.From, stmt.Columns)
+	if err != nil {
+		return nil, err
 	}
 
 	// Determine columns
-	for i, col := range stmt.Columns {
-		if col.Alias != "" {
-			result.AddColumn(col.Alias)
-		} else if ref, ok := col.Expr.(*parser.ColumnRef); ok {
-			result.AddColumn(ref.Column)
-		} else if col.Star {
-			if isMultiTable || hasJoin {
-				// Add columns from ALL joined tables in order
-				for _, tref := range allTableRefs {
-					sch, _ := e.schema.GetSchema(tref.Name)
-					if sch != nil {
-						for _, c := range sch.Columns {
-							result.AddColumn(c.Name)
-						}
-					}
-				}
-			} else {
-				// Handle SELECT * - add all columns from schema
-				for _, c := range schema.Columns {
-					result.AddColumn(c.Name)
-				}
-			}
-		} else if col.TableStar != "" {
-			proj := tableStars[strings.ToUpper(col.TableStar)]
-			for _, c := range proj.cols {
-				result.AddColumn(c.Name)
-			}
-		} else {
-			result.AddColumn(fmt.Sprintf("column%d", i+1))
-		}
+	for _, name := range e.selectColumnNames(stmt, schema, allTableRefs, isMultiTable, tableStars) {
+		result.AddColumn(name)
 	}
 
 	// Populate column types from schema metadata so protocol consumers can
@@ -1382,6 +1342,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 +1442,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 +1677,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 +1688,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
@@ -2484,6 +2457,75 @@ func collectAllTableRefs(from []parser.TableRef) []parser.TableRef {
 	return refs
 }
 
+// starProjection is a resolved qualified wildcard: the table's columns and the
+// row-key prefix used to read their values.
+type starProjection struct {
+	cols   []storage.Column
+	prefix string
+}
+
+// resolveTableStars resolves every qualified wildcard in a projection.
+func (e *Executor) resolveTableStars(from []parser.TableRef, columns []parser.SelectColumn) (map[string]starProjection, error) {
+	stars := make(map[string]starProjection)
+	for _, col := range columns {
+		if col.TableStar == "" {
+			continue
+		}
+		key := strings.ToUpper(col.TableStar)
+		if _, ok := stars[key]; ok {
+			continue
+		}
+		cols, prefix, err := e.resolveTableStar(from, col.TableStar)
+		if err != nil {
+			return nil, err
+		}
+		stars[key] = starProjection{cols: cols, prefix: prefix}
+	}
+	return stars, nil
+}
+
+// selectColumnNames expands a projection into result column names using the
+// same wildcard expansion as the row values: every joined table for an
+// unqualified *, the resolved table for table.*, and the schema for a
+// single-table *.
+func (e *Executor) selectColumnNames(stmt *parser.SelectStmt, schema *storage.Schema, refs []parser.TableRef, multiTable bool, stars map[string]starProjection) []string {
+	hasJoin := len(stmt.From) > 0 && stmt.From[0].Join != nil
+	names := make([]string, 0, len(stmt.Columns))
+	for i, col := range stmt.Columns {
+		switch {
+		case col.Alias != "":
+			names = append(names, col.Alias)
+		case col.Star:
+			if multiTable || hasJoin {
+				for _, ref := range refs {
+					if refSchema, err := e.schema.GetSchema(ref.Name); err == nil {
+						for _, c := range refSchema.Columns {
+							names = append(names, c.Name)
+						}
+					}
+				}
+				continue
+			}
+			for _, c := range schema.Columns {
+				names = append(names, c.Name)
+			}
+		case col.TableStar != "":
+			for _, c := range stars[strings.ToUpper(col.TableStar)].cols {
+				names = append(names, c.Name)
+			}
+		case col.Expr != nil:
+			if ref, ok := col.Expr.(*parser.ColumnRef); ok {
+				names = append(names, ref.Column)
+				continue
+			}
+			names = append(names, fmt.Sprintf("column%d", i+1))
+		default:
+			names = append(names, fmt.Sprintf("column%d", i+1))
+		}
+	}
+	return names
+}
+
 // resolveTableStar resolves a qualified wildcard (table.*) qualifier to the
 // matching table ref in a FROM clause. It returns the table's schema columns
 // and the key prefix used to look values up in a joined/aliased row (the

+ 300 - 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,280 @@ 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"
 		}
-		types = append(types, columnType)
+		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
+		}
+		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 carried as float64, so they report DOUBLE
+// rather than NUMERIC: the engine has no exact decimal carrier.
+func literalType(lit *parser.LiteralExpr) string {
+	switch lit.Type {
+	case lexer.TokenNumber:
+		if strings.ContainsAny(lit.Value, ".eE") {
+			return "DOUBLE"
+		}
+		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.
+//
+// Casts report the carrier type of the converted value, not the target width:
+// evalCastExpr always produces int64 for integer targets and float64 for
+// real targets, and it does not range-check them. A narrower advertised type
+// (int2/int4/float4) could otherwise fail to decode a value the engine accepted.
+func castResultType(name string) (string, bool) {
+	switch strings.ToUpper(strings.TrimSpace(name)) {
+	case "INTEGER", "INT", "SMALLINT", "TINYINT", "MEDIUMINT", "BIGINT":
+		return "BIGINT", true
+	case "REAL", "FLOAT", "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 float carriers is DOUBLE, and AVG is always
+// DOUBLE because evalAggregateExpr divides in float64.
+func functionResultType(fn *parser.FunctionCall, resolve columnTypeResolver) string {
+	switch strings.ToUpper(fn.Name) {
+	case "COUNT":
+		return "BIGINT"
+	case "SUM":
+		return sumResultType(firstArgType(fn, resolve))
+	case "TOTAL":
+		return "DOUBLE"
+	case "AVG":
+		return "DOUBLE"
+	case "MIN", "MAX":
+		// MIN/MAX return one of the input values unchanged.
+		return firstArgType(fn, resolve)
+	default:
+		return "TEXT"
+	}
+}
+
+// firstArgType reports the inferred type of a function's first argument.
+func firstArgType(fn *parser.FunctionCall, resolve columnTypeResolver) string {
+	if len(fn.Args) == 0 {
+		return "TEXT"
+	}
+	return inferExprType(fn.Args[0], resolve)
+}
+
+// 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 isFloatCarrierTypeName(argType):
+		return "DOUBLE"
+	case isIntegerTypeName(argType):
+		return "BIGINT"
+	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 isFloatCarrierTypeName(left) || isFloatCarrierTypeName(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"
+		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)
+		switch {
+		case isFloatCarrierTypeName(operand):
+			return "DOUBLE"
+		case isIntegerTypeName(operand):
+			return operand
+		default:
+			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
+	}
+}
+
+// isFloatCarrierTypeName reports whether a type name is carried as float64.
+// NUMERIC and DECIMAL have no exact decimal carrier in the engine: their values
+// round-trip through JSON as float64, so they behave like floating-point types.
+func isFloatCarrierTypeName(name string) bool {
+	switch strings.ToUpper(strings.TrimSpace(name)) {
+	case "REAL", "FLOAT", "DOUBLE", "NUMERIC", "DECIMAL":
+		return true
+	default:
+		return false
+	}
+}
+
+// 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
 }

+ 186 - 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,179 @@ 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", "DOUBLE"},
+		{"'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)", "DOUBLE"},
+		{"sum(score)", "DOUBLE"},
+		{"avg(id)", "DOUBLE"},
+		{"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", "DOUBLE"},
+		{"name || 'x'", "TEXT"},
+		{"id & 3", "BIGINT"},
+		{"-id", "INTEGER"},
+		{"-1.5", "DOUBLE"},
+		{"(id)", "INTEGER"},
+
+		// Casts report the target type the executor converts to.
+		{"CAST(id AS BIGINT)", "BIGINT"},
+		{"CAST(id AS SMALLINT)", "BIGINT"},
+		{"CAST(id AS TEXT)", "TEXT"},
+		{"CAST(name AS BLOB)", "BLOB"},
+		{"CAST(name AS DOUBLE)", "DOUBLE"},
+		{"CAST(id AS REAL)", "DOUBLE"},
+		{"CAST(id AS INTEGER)", "BIGINT"},
+		{"CAST(id AS TINYINT)", "BIGINT"},
+		{"CAST(id AS DATE)", "TEXT"},
+		{"CAST(id AS NUMERIC)", "TEXT"},
+
+		// NUMERIC/DECIMAL have no exact decimal carrier: their values are
+		// float64, so expressions over them report DOUBLE.
+		{"price + price", "DOUBLE"},
+		{"-price", "DOUBLE"},
+
+		// 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, price NUMERIC, small SMALLINT)")
+	execMust(t, e, "INSERT INTO t VALUES (1, '2024-05-06 07:08:09', 19.5, 7)")
+
+	cases := []struct {
+		sql  string
+		want []string
+	}{
+		{"SELECT count(*) FROM t", []string{"BIGINT"}},
+		{"SELECT sum(id) FROM t", []string{"BIGINT"}},
+		{"SELECT sum(price) FROM t", []string{"DOUBLE"}},
+		{"SELECT avg(id) FROM t", []string{"DOUBLE"}},
+		{"SELECT avg(price) FROM t", []string{"DOUBLE"}},
+		{"SELECT small + 1 FROM t", []string{"BIGINT"}},
+		{"SELECT price + 1 FROM t", []string{"DOUBLE"}},
+		{"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{"DOUBLE"}},
+		{"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", "DOUBLE", "TEXT", "BOOLEAN", "BIGINT"}
+	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

+ 23 - 6
pkg/pgserver/connection.go

@@ -1366,13 +1366,26 @@ func (c *Connection) writeMessage(msgType byte, data []byte) error {
 	return WriteMessage(c.writer, msgType, data)
 }
 
-// getOIDForType returns PostgreSQL OID for type
+// getOIDForType returns PostgreSQL OID for type.
+//
+// Types narrower than the value the engine carries are reported as the carrier
+// type, because the engine does not range-check stored or cast values: a value
+// that does not fit int2/int4 or float4 would fail client-side decoding rather
+// than having been rejected on write. INTEGER and REAL keep their declared
+// mappings, which predate this rule and are what existing clients expect.
 func (c *Connection) getOIDForType(typeName string) int32 {
 	switch strings.ToUpper(typeName) {
-	case "INTEGER", "INT":
+	case "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
+		// Carried as int64 and not range-checked, so int8 is the honest type.
+		return 20 // INT8OID
+	case "INTEGER", "INT", "INT4":
 		return 23 // INT4OID
-	case "BIGINT":
+	case "BIGINT", "INT8":
 		return 20 // INT8OID
+	case "NUMERIC", "DECIMAL":
+		// Values round-trip through JSON as float64; there is no exact decimal
+		// carrier, so NUMERIC is not advertised.
+		return 701 // FLOAT8OID
 	case "TEXT", "VARCHAR", "CHAR", "UUID":
 		// UUID is stored as text (SQLite-style), so it reports TEXTOID rather
 		// than the native PG UUID OID.
@@ -1396,13 +1409,17 @@ 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", "MEDIUMINT":
+		return 8 // Reported as int8; see getOIDForType.
+	case "INTEGER", "INT", "INT4":
 		return 4
-	case "BIGINT":
+	case "BIGINT", "INT8":
 		return 8
+	case "NUMERIC", "DECIMAL":
+		return 8 // Reported as float8; see getOIDForType.
 	case "REAL", "FLOAT":
 		return 4
 	case "DOUBLE":

+ 324 - 4
pkg/pgserver/oid_test.go

@@ -3,6 +3,8 @@ package pgserver
 import (
 	"bytes"
 	"encoding/binary"
+	"net"
+	"strconv"
 	"testing"
 
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
@@ -15,12 +17,30 @@ func TestGetOIDForTypeDatetime(t *testing.T) {
 		"DATETIME":  1184,
 		"TIMESTAMP": 1184,
 		"DATE":      1082,
-		"BIGINT":    20,
+		// Integer types narrower than the int64 carrier report int8 because the
+		// engine does not range-check stored values.
+		"SMALLINT":  20,
+		"INT2":      20,
+		"TINYINT":   20,
+		"MEDIUMINT": 20,
 		"INTEGER":   23,
 		"INT":       23,
-		"TEXT":      25,
-		"UUID":      25,
-		"BOOLEAN":   16,
+		"INT4":      23,
+		"BIGINT":    20,
+		"INT8":      20,
+		// NUMERIC/DECIMAL carry float64 values, so they report float8.
+		"NUMERIC": 701,
+		"DECIMAL": 701,
+		"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 +128,303 @@ func rowDescriptionOIDs(t *testing.T, data []byte) []int32 {
 	}
 	return oids
 }
+
+func TestGetTypeSizeForType(t *testing.T) {
+	c := &Connection{}
+	cases := map[string]int16{
+		"SMALLINT":  8,
+		"TINYINT":   8,
+		"MEDIUMINT": 8,
+		"INTEGER":   4,
+		"BIGINT":    8,
+		"REAL":      4,
+		"DOUBLE":    8,
+		"NUMERIC":   8,
+		"DECIMAL":   8,
+		"BOOLEAN":   1,
+		"DATE":      4,
+		"TIMESTAMP": 8,
+		"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, 20, 701, 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: "sum numeric", sql: "SELECT sum(price) FROM typed", want: []int32{701}},
+		{name: "avg integer", sql: "SELECT avg(i) FROM typed", want: []int32{701}},
+		{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{701}},
+		{name: "cast", sql: "SELECT CAST(i AS BIGINT) FROM typed", want: []int32{20}},
+		{name: "narrow integer cast", sql: "SELECT CAST(i AS SMALLINT) 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)
+				}
+			}
+		})
+	}
+}
+
+// rowDescriptionColumns decodes the column names from a RowDescription message.
+func rowDescriptionColumns(t *testing.T, data []byte) []string {
+	t.Helper()
+	if len(data) < 2 {
+		t.Fatalf("row description too short")
+	}
+	count := int(binary.BigEndian.Uint16(data[:2]))
+	pos := 2
+	names := make([]string, 0, count)
+	for i := 0; i < count; i++ {
+		end := bytes.IndexByte(data[pos:], 0)
+		if end < 0 {
+			t.Fatalf("unterminated column name")
+		}
+		names = append(names, string(data[pos:pos+end]))
+		pos += end + 1 // name
+		pos += 4       // table OID
+		pos += 2       // column attribute number
+		pos += 4       // type OID
+		pos += 2       // type size
+		pos += 4       // type modifier
+		pos += 2       // format code
+	}
+	return names
+}
+
+// countMessageType counts messages of a type in a query response.
+func countMessageType(msgs []*Message, msgType byte) int {
+	count := 0
+	for _, msg := range msgs {
+		if msg.Type == msgType {
+			count++
+		}
+	}
+	return count
+}
+
+// TestFloatCarrierOIDsMatchValues asserts that projections carried as float64
+// are advertised as float8 and that their text values parse as float8. NUMERIC
+// (1700) is never advertised because the engine has no exact decimal carrier.
+func TestFloatCarrierOIDsMatchValues(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(
+		`CREATE TABLE floats (i INTEGER PRIMARY KEY, price NUMERIC, amount DECIMAL, score DOUBLE)`), 0)})
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(
+		`INSERT INTO floats VALUES (1, 19.5, 3.25, 0.1)`), 0)})
+
+	cases := []struct {
+		name string
+		sql  string
+	}{
+		{name: "numeric column", sql: "SELECT price FROM floats"},
+		{name: "decimal column", sql: "SELECT amount FROM floats"},
+		{name: "double column", sql: "SELECT score FROM floats"},
+		{name: "avg integer", sql: "SELECT avg(i) FROM floats"},
+		{name: "avg numeric", sql: "SELECT avg(price) FROM floats"},
+		{name: "sum numeric", sql: "SELECT sum(price) FROM floats"},
+		{name: "decimal literal", sql: "SELECT 1.5"},
+		{name: "float arithmetic", sql: "SELECT price + 0.5 FROM floats"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			msgs := resultMessages(runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(tc.sql), 0)}))
+			oids := rowDescriptionOIDs(t, findRowDescription(t, msgs).Data)
+			if len(oids) != 1 {
+				t.Fatalf("%s: OIDs = %v, want one column", tc.sql, oids)
+			}
+			if oids[0] == 1700 {
+				t.Fatalf("%s: OID = 1700 (numeric) for a float64 carrier", tc.sql)
+			}
+			if oids[0] != 701 {
+				t.Fatalf("%s: OID = %d, want 701 (float8)", tc.sql, oids[0])
+			}
+			values := dataRowValues(t, msgs[1])
+			if len(values) != 1 {
+				t.Fatalf("%s: values = %v, want one value", tc.sql, values)
+			}
+			if _, err := strconv.ParseFloat(values[0], 64); err != nil {
+				t.Fatalf("%s: float8 value %q does not parse: %v", tc.sql, values[0], err)
+			}
+		})
+	}
+}
+
+// TestNarrowIntegerOIDsUseCarrierType pins the chosen narrow-integer behavior:
+// the engine carries int64 without range checks, so SMALLINT/TINYINT/MEDIUMINT
+// report int8 (20) rather than a width a stored value may not fit.
+func TestNarrowIntegerOIDsUseCarrierType(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(
+		`CREATE TABLE narrow (id INTEGER PRIMARY KEY, small SMALLINT, tiny TINYINT, medium MEDIUMINT)`), 0)})
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(
+		`INSERT INTO narrow VALUES (1, 40000, 200, 9000000)`), 0)})
+
+	cases := []struct {
+		name  string
+		sql   string
+		value string
+	}{
+		{name: "smallint column", sql: "SELECT small FROM narrow", value: "40000"},
+		{name: "tinyint column", sql: "SELECT tiny FROM narrow", value: "200"},
+		{name: "mediumint column", sql: "SELECT medium FROM narrow", value: "9000000"},
+		{name: "cast smallint", sql: "SELECT CAST(small AS SMALLINT) FROM narrow", value: "40000"},
+		{name: "cast integer", sql: "SELECT CAST(small AS INTEGER) FROM narrow", value: "40000"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			msgs := resultMessages(runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(tc.sql), 0)}))
+			oids := rowDescriptionOIDs(t, findRowDescription(t, msgs).Data)
+			if len(oids) != 1 || oids[0] != 20 {
+				t.Fatalf("%s: OIDs = %v, want [20] (int8 carrier)", tc.sql, oids)
+			}
+			values := dataRowValues(t, msgs[1])
+			if len(values) != 1 || values[0] != tc.value {
+				t.Fatalf("%s: values = %v, want [%s]", tc.sql, values, tc.value)
+			}
+			if _, err := strconv.ParseInt(values[0], 10, 64); err != nil {
+				t.Fatalf("%s: int8 value %q does not parse: %v", tc.sql, values[0], err)
+			}
+		})
+	}
+}
+
+// TestEmptyResultWildcardMatchesNames guards the constant-false WHERE path:
+// wildcard projections must expand names and types into the same columns.
+func TestEmptyResultWildcardMatchesNames(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(
+		`CREATE TABLE star_t (id INTEGER PRIMARY KEY, created_at TIMESTAMP, name TEXT)`), 0)})
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(
+		`CREATE TABLE star_b (b_id BIGINT PRIMARY KEY, score DOUBLE)`), 0)})
+
+	cases := []struct {
+		name      string
+		sql       string
+		wantNames []string
+		wantOIDs  []int32
+	}{
+		{
+			name:      "qualified star",
+			sql:       "SELECT star_t.* FROM star_t WHERE 1 = 0",
+			wantNames: []string{"id", "created_at", "name"},
+			wantOIDs:  []int32{23, 1184, 25},
+		},
+		{
+			name:      "aliased qualified star",
+			sql:       "SELECT s.* FROM star_t AS s WHERE 1 = 0",
+			wantNames: []string{"id", "created_at", "name"},
+			wantOIDs:  []int32{23, 1184, 25},
+		},
+		{
+			name:      "star",
+			sql:       "SELECT * FROM star_t WHERE 1 = 0",
+			wantNames: []string{"id", "created_at", "name"},
+			wantOIDs:  []int32{23, 1184, 25},
+		},
+		{
+			name:      "column and expression",
+			sql:       "SELECT star_t.name, star_t.id + 1 FROM star_t WHERE 1 = 0",
+			wantNames: []string{"name", "column2"},
+			wantOIDs:  []int32{25, 20},
+		},
+		{
+			name:      "join star",
+			sql:       "SELECT * FROM star_t JOIN star_b ON star_t.id = star_b.b_id WHERE 1 = 0",
+			wantNames: []string{"id", "created_at", "name", "b_id", "score"},
+			wantOIDs:  []int32{23, 1184, 25, 20, 701},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			msgs := resultMessages(runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(tc.sql), 0)}))
+			rd := findRowDescription(t, msgs)
+			names := rowDescriptionColumns(t, rd.Data)
+			oids := rowDescriptionOIDs(t, rd.Data)
+			if len(names) != len(tc.wantNames) {
+				t.Fatalf("%s: names = %v, want %v", tc.sql, names, tc.wantNames)
+			}
+			for i := range tc.wantNames {
+				if names[i] != tc.wantNames[i] {
+					t.Fatalf("%s: names = %v, want %v", tc.sql, names, tc.wantNames)
+				}
+			}
+			if len(oids) != len(tc.wantOIDs) {
+				t.Fatalf("%s: OIDs = %v, want %v", tc.sql, oids, tc.wantOIDs)
+			}
+			for i := range tc.wantOIDs {
+				if oids[i] != tc.wantOIDs[i] {
+					t.Fatalf("%s: OIDs = %v, want %v", tc.sql, oids, tc.wantOIDs)
+				}
+			}
+			if rows := countMessageType(msgs, MsgDataRow); rows != 0 {
+				t.Fatalf("%s: got %d data rows, want 0", tc.sql, rows)
+			}
+		})
+	}
+}