Files
five/hbrtl/pgserver/typemap.go
CharlesKWON d7a81af7db feat(pgserver): binary-format param decoding (Phase 4.1)
pgx defaults to binary wire format for INT2/INT4/INT8/FLOAT4/FLOAT8/
BOOL/NUMERIC/DATE/TIMESTAMP/TIMESTAMPTZ — Go's most-used PG driver
ships nearly every typed parameter as binary unless explicitly told
to use text mode. The Phase 3 implementation only decoded INT4/INT8/
BOOL, so any pgx call with a decimal price, a timestamp, or a date
was silently mis-quoted into the SQL stream.

Decoders now cover the seven additional OIDs. The interesting one is
NUMERIC: PG's wire format is base-10000 digit groups plus a separate
displayed-scale, so the decoder rebuilds the decimal string from
weight+sign+ndigits+digits[] without going through float (which would
lose precision for NUMERIC(38,*) values). Pinned by vectors covering
zero / positive / negative / fractional-only / NaN / multi-group
integer + fraction cases.

DATE / TIMESTAMP decoders assume integer_datetimes=on (which the
server advertises in ParameterStatus); the 8-byte microsecond delta
from the PG epoch (2000-01-01 UTC) is converted via Go's time.Time
machinery and re-emitted as a quoted SQL literal.

Text-format path also broadened: FLOAT4/FLOAT8/INT2 now transit
unquoted alongside INT4/INT8/BOOL/NUMERIC; the regression would have
been clients sending text-format floats getting them rewritten as
'1.5' (string literal) instead of 1.5 (numeric).

Verified: all 6 mandatory gates green (go test, SQL 43/43, compat
56/56, std.ch 17/17, FRB 7/7, pgserver 11/11). Five new decoder
tests pin each wire format against handcrafted PG payloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:02:15 +09:00

155 lines
4.3 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
package pgserver
import (
"fmt"
"strconv"
"time"
"five/hbrt"
)
// PostgreSQL OID constants for the types FiveSql2 surfaces. Values
// match the canonical pg_type entries — psql and most drivers key
// their decoders off these.
const (
oidBool = 16
oidInt2 = 21
oidInt4 = 23
oidInt8 = 20
oidFloat4 = 700
oidFloat8 = 701
oidNumeric = 1700
oidText = 25
oidDate = 1082
oidTimestamp = 1114
oidTimestamptz = 1184
)
// pgEpoch is the PostgreSQL binary date/time epoch — 2000-01-01
// UTC. DATE counts days since this point (signed int32); TIMESTAMP
// and TIMESTAMPTZ count microseconds since this point (signed
// int64), with integer_datetimes=on (which we advertise via
// ParameterStatus in session.run).
var pgEpoch = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
// pgTypeFor returns (OID, declared-size). The declared size is -1
// for variable-width types (per PG convention). Sample is one
// representative value from the column; NIL falls back to TEXT
// because we don't have schema info at this layer.
func pgTypeFor(sample hbrt.Value) (oid uint32, size int16) {
switch {
case sample.IsLogical():
return oidBool, 1
case sample.IsNumeric():
// Distinguish integer-ish from decimal so int columns
// transit as INT4/INT8 (faster, BI tools render nicely).
if isIntegerNumeric(sample) {
n := sample.AsNumInt()
if n >= -2147483648 && n <= 2147483647 {
return oidInt4, 4
}
return oidInt8, 8
}
return oidNumeric, -1
case sample.IsDate():
return oidDate, 4
case sample.IsTimestamp():
return oidTimestamp, 8
case sample.IsString():
return oidText, -1
default:
// NIL or unknown — claim TEXT so the client decodes
// whatever we send as a string. Real schema-aware OID
// dispatch lands with extended-protocol Describe support
// in v1.1.
return oidText, -1
}
}
// isIntegerNumeric distinguishes a whole-number Numeric from one
// that's been declared with decimals. Five's hbrt.Value carries
// the length/dec hints separately for printf-style formatting; if
// `dec` is 0, the source field declared no decimal places.
func isIntegerNumeric(v hbrt.Value) bool {
// Fast path: tag-level integer subtype.
if v.IsInt() || v.IsLong() {
return true
}
// AsNumDouble rounding check — within 1e-9 of an integer, no
// declared decimal places.
d := v.AsNumDouble()
if d != float64(int64(d)) {
return false
}
return v.Decimal() == 0
}
// encodeText writes one cell as a text-format PG byte slice. NULL
// is signalled by returning nil (DataRow distinguishes nil from
// empty []byte and sends length=-1 vs length=0 accordingly).
func encodeText(v hbrt.Value) []byte {
if v.IsNil() {
return nil
}
switch {
case v.IsLogical():
if v.AsBool() {
return []byte{'t'}
}
return []byte{'f'}
case v.IsNumeric():
if isIntegerNumeric(v) {
return []byte(strconv.FormatInt(v.AsNumInt(), 10))
}
precision := int(v.Decimal())
if precision <= 0 || precision > 30 {
precision = 10
}
return []byte(strconv.FormatFloat(v.AsNumDouble(), 'f', precision, 64))
case v.IsDate():
y, m, d := julianToYMD(v.AsJulian())
return []byte(fmt.Sprintf("%04d-%02d-%02d", y, m, d))
case v.IsTimestamp():
// Convert Julian + ms to a Go time and format.
jul := v.AsJulian()
y, mo, d := julianToYMD(jul)
ms := v.AsTimeMs()
hh := ms / 3600000
ms -= hh * 3600000
mm := ms / 60000
ms -= mm * 60000
ss := ms / 1000
ms -= ss * 1000
return []byte(fmt.Sprintf("%04d-%02d-%02d %02d:%02d:%02d.%03d", y, mo, d, hh, mm, ss, ms))
case v.IsString():
return []byte(v.AsString())
default:
// Fallback: best-effort string conversion.
return []byte(fmt.Sprintf("%v", v))
}
}
// julianToYMD converts an integer Julian day number to (year, month,
// day). Matches Harbour's hb_dateDecode algorithm used elsewhere in
// the runtime; duplicated here to avoid pulling in hbrtl which
// would create an import cycle.
func julianToYMD(j int64) (year, month, day int) {
if j <= 0 {
return 0, 0, 0
}
a := j + 32044
b := (4*a + 3) / 146097
c := a - (b*146097)/4
d := (4*c + 3) / 1461
e := c - (1461*d)/4
m := (5*e + 2) / 153
day = int(e - (153*m+2)/5 + 1)
month = int(m + 3 - 12*(m/10))
year = int(b*100 + d - 4800 + (m / 10))
return
}