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>
131 lines
4.1 KiB
Go
131 lines
4.1 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
package pgserver
|
|
|
|
import (
|
|
"bytes"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"five/hbrt"
|
|
)
|
|
|
|
// TestEncodeText_Numeric pins the text-format encoding for the four
|
|
// numeric Five variants psql actually receives. Regressions here
|
|
// would surface as silently mis-formatted DataRow values that some
|
|
// clients render and others reject — easier to catch with a focused
|
|
// unit test than via a psql round-trip.
|
|
func TestEncodeText_Numeric(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
v hbrt.Value
|
|
want []byte
|
|
}{
|
|
{"int-positive", hbrt.MakeInt(42), []byte("42")},
|
|
{"int-negative", hbrt.MakeInt(-7), []byte("-7")},
|
|
{"long", hbrt.MakeLong(9876543210), []byte("9876543210")},
|
|
// MakeDouble's metadata: (value, len, dec) — dec=2 should
|
|
// surface as "50000.00" not "50000".
|
|
{"decimal-2dp", hbrt.MakeDouble(50000.0, 10, 2), []byte("50000.00")},
|
|
{"decimal-fraction", hbrt.MakeDouble(42000.5, 10, 2), []byte("42000.50")},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := encodeText(tc.v)
|
|
if !bytes.Equal(got, tc.want) {
|
|
t.Errorf("encodeText: want %q, got %q", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEncodeText_Strings covers the trivial case but also the NIL
|
|
// → nil-slice contract that DataRow uses to distinguish NULL from
|
|
// empty string ("" sends length=0; NIL sends length=-1).
|
|
func TestEncodeText_Strings(t *testing.T) {
|
|
if got := encodeText(hbrt.MakeString("hello")); !bytes.Equal(got, []byte("hello")) {
|
|
t.Errorf("string encode: got %q", got)
|
|
}
|
|
if got := encodeText(hbrt.MakeString("")); got == nil {
|
|
t.Error("empty string must encode as []byte{}, not nil (NULL marker)")
|
|
}
|
|
if got := encodeText(hbrt.MakeNil()); got != nil {
|
|
t.Errorf("NIL must encode as nil slice (PG NULL marker), got %q", got)
|
|
}
|
|
if got := encodeText(hbrt.MakeBool(true)); !bytes.Equal(got, []byte("t")) {
|
|
t.Errorf("bool true: got %q", got)
|
|
}
|
|
if got := encodeText(hbrt.MakeBool(false)); !bytes.Equal(got, []byte("f")) {
|
|
t.Errorf("bool false: got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestPgTypeFor verifies OID selection for the column-type
|
|
// detection path. Integer-shaped numerics that fit int32 must
|
|
// transit as INT4 so BI tools display them right-aligned with
|
|
// no decimal point.
|
|
func TestPgTypeFor(t *testing.T) {
|
|
type ent struct {
|
|
v hbrt.Value
|
|
wantOID uint32
|
|
}
|
|
for i, tc := range []ent{
|
|
{hbrt.MakeInt(0), oidInt4},
|
|
{hbrt.MakeInt(2147483647), oidInt4},
|
|
{hbrt.MakeLong(9999999999), oidInt8},
|
|
{hbrt.MakeDouble(1.5, 10, 2), oidNumeric},
|
|
{hbrt.MakeString("x"), oidText},
|
|
{hbrt.MakeBool(true), oidBool},
|
|
{hbrt.MakeNil(), oidText}, // fallback when no sample
|
|
} {
|
|
oid, _ := pgTypeFor(tc.v)
|
|
if oid != tc.wantOID {
|
|
t.Errorf("case %d: want oid %d, got %d", i, tc.wantOID, oid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSqlStateFor verifies the FiveSql2-error-code → SQLSTATE map.
|
|
// Drivers dispatch on the leading two chars (class code), so the
|
|
// table needs to match the canonical PG layout for libpq-style
|
|
// exception handling to work.
|
|
func TestSqlStateFor(t *testing.T) {
|
|
want := map[int]string{
|
|
1: "42601",
|
|
2: "42P01",
|
|
3: "42703",
|
|
8: "25P02",
|
|
99: "XX000",
|
|
}
|
|
for code, expect := range want {
|
|
got := sqlStateFor(code)
|
|
if got != expect {
|
|
t.Errorf("sqlStateFor(%d) = %q, want %q", code, got, expect)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCommandTagFor pins the CommandComplete tag verbs. Tagged
|
|
// rows (n) come in Phase 3; for v1.0 we always emit "VERB 0" so
|
|
// psql-style row-count display works (it prints "(0 행)" but
|
|
// doesn't error out).
|
|
func TestCommandTagFor(t *testing.T) {
|
|
cases := []struct{ sql, want string }{
|
|
{"SELECT * FROM x", "SELECT 0"},
|
|
{" select 1", "SELECT 0"},
|
|
{"INSERT INTO x VALUES (1)", "INSERT 0"},
|
|
{"UPDATE x SET a=1", "UPDATE 0"},
|
|
{"DELETE FROM x", "DELETE 0"},
|
|
{"BEGIN", "BEGIN"},
|
|
{"COMMIT", "COMMIT"},
|
|
{"CREATE TABLE foo (x INT)", "CREATE"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := commandTagFor(c.sql); got != c.want {
|
|
t.Errorf("commandTagFor(%q) = %q, want %q", c.sql, got, c.want)
|
|
}
|
|
}
|
|
_ = strconv.Itoa // keep import; will be used in Phase 3 with row counts
|
|
}
|