Kaynağa Gözat

fix: advertise carrier types for NUMERIC and narrow integer results

Review follow-up for the type-OID projection work.

- NUMERIC/DECIMAL have no exact decimal carrier: their values round-trip
  through JSON as float64. AVG, decimal literals, SUM over NUMERIC/DECIMAL,
  arithmetic over them, and NUMERIC/DECIMAL projections now report DOUBLE
  (701) instead of NUMERIC (1700), which pgx decodes as exact numeric and
  can fail on float formatting.
- SMALLINT/TINYINT/MEDIUMINT columns and every integer CAST report BIGINT
  (20): the engine carries int64 and does not range-check stored or cast
  values, so an int2/int4 advertisement could fail to decode a value the
  engine accepted. Range enforcement was the larger change and is not done.
- The constant-false WHERE path expands wildcard names through the same
  helper as wildcard types and row values. SELECT t.* previously emitted a
  single column1 name beside the expanded type list; join * also expanded
  one table's names against every table's types.

Tests: float-carrier projections must be OID 701 and their text must parse
as float8; narrow integers stay int8 with out-of-range stored values and
casts; empty-result wildcard queries must have matching names and OIDs for
qualified, aliased, plain, and join forms.
Danilo Fragoso 1 gün önce
ebeveyn
işleme
feac3e3c11

+ 86 - 65
pkg/executor/executor.go

@@ -482,25 +482,19 @@ 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)
 		}
-		// 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))
+			result.ColumnTypes = e.joinedSelectColumnTypes(stmt, refs)
 		} else {
 			result.ColumnTypes = selectColumnTypes(stmt, schema)
 		}
@@ -1231,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
@@ -2505,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

+ 36 - 45
pkg/executor/result_types.go

@@ -171,12 +171,13 @@ func inferExprType(expr parser.Expr, resolve columnTypeResolver) string {
 
 // 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.
+// 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 "NUMERIC"
+			return "DOUBLE"
 		}
 		if n, err := strconv.ParseInt(lit.Value, 10, 64); err == nil && n >= -2147483648 && n <= 2147483647 {
 			return "INTEGER"
@@ -196,19 +197,16 @@ func literalType(lit *parser.LiteralExpr) string {
 // 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":
-		return "INTEGER", true
-	case "SMALLINT", "TINYINT":
-		return "SMALLINT", true
-	case "MEDIUMINT":
-		return "INTEGER", true
-	case "BIGINT":
+	case "INTEGER", "INT", "SMALLINT", "TINYINT", "MEDIUMINT", "BIGINT":
 		return "BIGINT", true
-	case "REAL", "FLOAT":
-		return "REAL", true
-	case "DOUBLE":
+	case "REAL", "FLOAT", "DOUBLE":
 		return "DOUBLE", true
 	case "TEXT", "VARCHAR", "CHAR", "CHARACTER", "CLOB", "NCHAR", "NVARCHAR":
 		return "TEXT", true
@@ -221,43 +219,42 @@ func castResultType(name string) (string, bool) {
 
 // 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.
+// 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 {
-	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)
+		return sumResultType(firstArgType(fn, resolve))
 	case "TOTAL":
 		return "DOUBLE"
 	case "AVG":
-		if isRealTypeName(argType) {
-			return "DOUBLE"
-		}
-		return "NUMERIC"
+		return "DOUBLE"
 	case "MIN", "MAX":
 		// MIN/MAX return one of the input values unchanged.
-		return argType
+		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 isRealTypeName(argType):
+	case isFloatCarrierTypeName(argType):
 		return "DOUBLE"
 	case isIntegerTypeName(argType):
 		return "BIGINT"
-	case isNumericTypeName(argType):
-		return "NUMERIC"
 	default:
 		return "TEXT"
 	}
@@ -278,14 +275,12 @@ func binaryResultType(e *parser.BinaryExpr, resolve columnTypeResolver) string {
 		left := inferExprType(e.Left, resolve)
 		right := inferExprType(e.Right, resolve)
 		switch {
-		case isRealTypeName(left) || isRealTypeName(right):
+		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"
-		case isNumericTypeName(left) && isNumericTypeName(right):
-			return "NUMERIC"
 		default:
 			return "TEXT"
 		}
@@ -303,10 +298,14 @@ func unaryResultType(e *parser.UnaryExpr, resolve columnTypeResolver) string {
 		return "BIGINT"
 	case lexer.TokenMinus, lexer.TokenPlus:
 		operand := inferExprType(e.Operand, resolve)
-		if isNumericTypeName(operand) {
+		switch {
+		case isFloatCarrierTypeName(operand):
+			return "DOUBLE"
+		case isIntegerTypeName(operand):
 			return operand
+		default:
+			return "TEXT"
 		}
-		return "TEXT"
 	default:
 		return "TEXT"
 	}
@@ -348,26 +347,18 @@ func isIntegerTypeName(name string) bool {
 	}
 }
 
-// isRealTypeName reports whether a type name is a floating-point type.
-func isRealTypeName(name string) bool {
+// 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":
+	case "REAL", "FLOAT", "DOUBLE", "NUMERIC", "DECIMAL":
 		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 {

+ 24 - 11
pkg/executor/result_types_test.go

@@ -168,7 +168,7 @@ func TestInferExprType(t *testing.T) {
 		{"1", "INTEGER"},
 		{"2147483647", "INTEGER"},
 		{"2147483648", "BIGINT"},
-		{"1.5", "NUMERIC"},
+		{"1.5", "DOUBLE"},
 		{"'text'", "TEXT"},
 		{"TRUE", "BOOLEAN"},
 		{"FALSE", "BOOLEAN"},
@@ -191,9 +191,9 @@ func TestInferExprType(t *testing.T) {
 		{"count(*)", "BIGINT"},
 		{"count(id)", "BIGINT"},
 		{"sum(id)", "BIGINT"},
-		{"sum(price)", "NUMERIC"},
+		{"sum(price)", "DOUBLE"},
 		{"sum(score)", "DOUBLE"},
-		{"avg(id)", "NUMERIC"},
+		{"avg(id)", "DOUBLE"},
 		{"avg(score)", "DOUBLE"},
 		{"total(id)", "DOUBLE"},
 		{"min(created_at)", "TIMESTAMP"},
@@ -202,20 +202,29 @@ func TestInferExprType(t *testing.T) {
 		// Arithmetic, concatenation, and bitwise operators.
 		{"id + 1", "BIGINT"},
 		{"score + 1", "DOUBLE"},
-		{"price + 1", "NUMERIC"},
+		{"price + 1", "DOUBLE"},
 		{"name || 'x'", "TEXT"},
 		{"id & 3", "BIGINT"},
 		{"-id", "INTEGER"},
-		{"-1.5", "NUMERIC"},
+		{"-1.5", "DOUBLE"},
 		{"(id)", "INTEGER"},
 
 		// Casts report the target type the executor converts to.
 		{"CAST(id AS BIGINT)", "BIGINT"},
-		{"CAST(id AS SMALLINT)", "SMALLINT"},
+		{"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"},
@@ -238,8 +247,8 @@ func TestInferExprType(t *testing.T) {
 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')")
+	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
@@ -247,11 +256,15 @@ func TestSelectColumnTypesExpressions(t *testing.T) {
 	}{
 		{"SELECT count(*) FROM t", []string{"BIGINT"}},
 		{"SELECT sum(id) FROM t", []string{"BIGINT"}},
-		{"SELECT avg(id) FROM t", []string{"NUMERIC"}},
+		{"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{"NUMERIC"}},
+		{"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"}},
@@ -282,7 +295,7 @@ func TestSelectColumnTypesWithoutFrom(t *testing.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"}
+	want := []string{"INTEGER", "DOUBLE", "TEXT", "BOOLEAN", "BIGINT"}
 	if len(res.ColumnTypes) != len(want) {
 		t.Fatalf("column types = %v, want %v", res.ColumnTypes, want)
 	}

+ 19 - 10
pkg/pgserver/connection.go

@@ -1366,19 +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 "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":
+	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", "INT8":
 		return 20 // INT8OID
 	case "NUMERIC", "DECIMAL":
-		return 1700 // NUMERICOID
+		// 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.
@@ -1405,12 +1412,14 @@ func (c *Connection) getOIDForType(typeName string) int32 {
 // getTypeSizeForType returns type size in bytes, or -1 for variable length.
 func (c *Connection) getTypeSizeForType(typeName string) int16 {
 	switch strings.ToUpper(typeName) {
-	case "SMALLINT", "INT2", "TINYINT":
-		return 2
-	case "INTEGER", "INT", "INT4", "MEDIUMINT":
+	case "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
+		return 8 // Reported as int8; see getOIDForType.
+	case "INTEGER", "INT", "INT4":
 		return 4
 	case "BIGINT", "INT8":
 		return 8
+	case "NUMERIC", "DECIMAL":
+		return 8 // Reported as float8; see getOIDForType.
 	case "REAL", "FLOAT":
 		return 4
 	case "DOUBLE":

+ 231 - 21
pkg/pgserver/oid_test.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"encoding/binary"
 	"net"
+	"strconv"
 	"testing"
 
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
@@ -16,27 +17,30 @@ func TestGetOIDForTypeDatetime(t *testing.T) {
 		"DATETIME":  1184,
 		"TIMESTAMP": 1184,
 		"DATE":      1082,
-		"SMALLINT":  21,
-		"INT2":      21,
-		"TINYINT":   21,
-		"MEDIUMINT": 23,
+		// 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,
 		"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,
+		// 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 {
@@ -128,15 +132,18 @@ func rowDescriptionOIDs(t *testing.T, data []byte) []int32 {
 func TestGetTypeSizeForType(t *testing.T) {
 	c := &Connection{}
 	cases := map[string]int16{
-		"SMALLINT":  2,
+		"SMALLINT":  8,
+		"TINYINT":   8,
+		"MEDIUMINT": 8,
 		"INTEGER":   4,
 		"BIGINT":    8,
 		"REAL":      4,
 		"DOUBLE":    8,
+		"NUMERIC":   8,
+		"DECIMAL":   8,
 		"BOOLEAN":   1,
 		"DATE":      4,
 		"TIMESTAMP": 8,
-		"NUMERIC":   -1,
 		"TEXT":      -1,
 		"BLOB":      -1,
 	}
@@ -181,17 +188,19 @@ func TestRowDescriptionProjectionOIDs(t *testing.T) {
 		{
 			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},
+			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: "avg integer", sql: "SELECT avg(i) FROM typed", want: []int32{1700}},
+		{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{1700}},
+		{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",
@@ -218,3 +227,204 @@ func TestRowDescriptionProjectionOIDs(t *testing.T) {
 		})
 	}
 }
+
+// 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)
+			}
+		})
+	}
+}