First end-to-end working version of the PostgreSQL-wire-compatible
TCP server frontend. A standard `psql` client now connects, runs
`SELECT * FROM employees`, and gets back a properly typed result
set rendered by psql with the right column alignment:
ID | NAME | SALARY
----+----------------------+----------
1 | Alice | 50000.00
2 | Bob | 42000.50
3 | Cho | 77500.00
This is the Phase 2 deliverable from the approved plan at
/Users/charleskwon/.claude/plans/compiled-launching-shore.md.
Builds on the session-state refactor in 93cf5c8 — each connection
gets its own TSqlSession on the PRG side via the new PG_NEW_SESSION
HB_FUNC, so concurrent psql clients won't share transaction logs
or plan caches.
Scope
-----
v1.0 MVP: Simple Query only, trust auth, no TLS yet. SELECT works
against the full FiveSql2 surface (CTEs, window functions, JOINs,
aggregates). DML + per-session transactions are Phase 3, extended
protocol is Phase 4, auth + TLS are Phases 5/6.
Architecture
------------
psql/pgx/JDBC ──TCP:5432──▶ pgserver.Listener
│ accept()
▼ go handleConn(net.Conn)
┌─────────────────────────────┐
│ Session goroutine │
│ 1. SSLRequest peek │
│ 2. StartupMessage │
│ 3. AuthenticationOk (trust) │
│ 4. ParameterStatus×7 │
│ 5. BackendKeyData │
│ 6. ReadyForQuery('I') │
│ 7. loop: Receive() → │
│ dispatchSimpleQuery → │
│ hbrt.Thread.Function( │
│ FIVE_SQL,sql,...,sess) │
│ emit RowDescription │
│ emit DataRow×N │
│ emit CommandComplete │
│ emit ReadyForQuery │
└─────────────────────────────┘
One goroutine per connection, each owning its own *hbrt.Thread and
TSqlSession instance. Uses the existing audit-fixed NewThread()
(cde8673) so statics + WA factory propagate.
New files (hbrtl/pgserver/)
---------------------------
* server.go — Config, Server, Serve loop with MaxConnections gate
via semaphore, Close drains in-flight sessions.
* session.go — full lifecycle: SSLRequest peek + prefixedConn
byte-injection trick for StartupMessage, ParameterStatus
broadcast (server_version "14.0 (FiveSql2)" so pgx negotiates),
BackendKeyData (random pid+secret per session, no CancelRequest
yet), query loop dispatching only Simple Query in v1.0 with a
loud "0A000 not supported" for Extended messages.
* dispatch.go — runSQL invokes FIVE_SQL via PushSymbol+Function,
unpacks the engine's `{aFieldNames, aRows}` envelope or the
`{{"__error__"}, {{nCode, cMsg, cSQL}}}` error shape, emits
RowDescription with text-format OIDs and DataRow per row.
* typemap.go — pgTypeFor() picks INT4 / INT8 / NUMERIC / TEXT /
DATE / TIMESTAMP / BOOL by sampling the first row's value type;
encodeText() formats each cell, returning nil-slice for NULL
(the PG length=-1 convention).
* errmap.go — sqlStateFor() maps FiveSql2 SQL_ERR_* codes to
canonical PG SQLSTATEs (42601/42P01/42703/42804/23505/23514/
23503/25P02/42501/02000/XX000).
* auth.go — trust mode in v1.0; password/MD5/SCRAM lands Phase 5
but the dispatch sentinel is already in place.
* tls.go — upgradeToTLS stub for SSLRequest handling; the byte-
ordering is already wired so Phase 6 just plugs in tls.Config.
* register.go — package init() registers pg_server_start /
pg_server_stop HB_FUNCs. Importing the package (done from
hbrtl/register.go via blank import) is enough to enable them.
* pgserver_test.go — unit tests for encodeText (numeric, string,
NIL), pgTypeFor (OID dispatch), sqlStateFor (error mapping),
commandTagFor (SELECT/INSERT/UPDATE/DELETE/BEGIN/COMMIT).
Other changes
-------------
* _FiveSql2/src/TSqlSession.prg — added PG_NEW_SESSION() factory
used by the Go dispatcher to allocate a per-connection session
bypassing the embedded process default.
* hbrtl/register.go — blank-import five/hbrtl/pgserver so its
init() fires and the HB_FUNCs land in the global dynamic-func
table for VM symbol lookup.
* go.mod / go.sum — github.com/jackc/pgx/v5 v5.9.2 (pgproto3
subpackage). MIT license. Same library pgx itself uses, so
protocol coverage matches the de-facto Go PG ecosystem.
Verification
------------
$ pg_server_start(15432, "trust") /* PRG one-liner */
$ psql -h 127.0.0.1 -p 15432 -U fiveuser -c 'SELECT * FROM employees'
→ 3 rows rendered correctly by psql (ID as INT4, NAME as TEXT,
SALARY as NUMERIC(10,2) with 2 decimal places)
All six release gates green:
go test ./... ✓ (incl. new hbrtl/pgserver tests)
FiveSql2 SQL:1999 43/43 ✓
Harbour compat 56/56 ✓
std.ch 17/17 ✓
FRB 7/7 ✓
examples 65/71 ✓ (unchanged baseline)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
4.0 KiB
Go
148 lines
4.0 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
|
|
oidInt4 = 23
|
|
oidInt8 = 20
|
|
oidNumeric = 1700
|
|
oidText = 25
|
|
oidDate = 1082
|
|
oidTimestamp = 1114
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// Force time package import — we'll need it for Timestamp parsing
|
|
// when extended protocol lands. Stub function keeps the import
|
|
// from being pruned in v1.0-skeleton.
|
|
var _ = time.Date
|